Просмотр исходного кода

Merge pull request #3582 from deepseek-harness/worktree/queue-submission-sending

fix(web): show sending state for queued submissions
Yichen Jiang 1 неделя назад
Родитель
Сommit
4dd2c7b51b

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.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 .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md
-2026-08-26-local-submission-echoes.md: 258df8f3f4bdd0441b714c7602273897d2d79fc7
-2026-08-26-local-submission-echoes.zh.md: 57a9523a1eaa399e596f0c8eb6f8913545976f87
+2026-08-26-local-submission-echoes.md: 0fdb6b65e6e682a955fab770a543e82715d56d90
+2026-08-26-local-submission-echoes.zh.md: e6ca68578507bf9dd6c7cc0e95f17b597f7a5a80

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md

@@ -14,6 +14,8 @@ A multi-image prompt spent seconds in client serialization plus host admission b
 
 **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.
 
+**Queue actions require the observed Host identity.** Queued echoes display a sending status and disabled edit, remove, and steer buttons in the same positions as admitted rows. The collapsed dock retains that status in its header. Prompt acknowledgement alone carries no queue-item identity, so only the correlated Host row enables its applicable actions. This keeps slow admission visible without treating a local request id as a mutable Host queue item.
+
 **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). Failed detached sends are restored together in submission order while the composer is empty or still contains the preceding automatic restoration; a user edit ends that restoration sequence. Draft images remain owned by the detached attempt through echo retirement, so Session scope disposal can release them after they have left the rail. An observed echo gives each preview URL to `HistoricalImageCache.seed` under the admitted reference. The cache exposes that preview synchronously, fetches the durable attachment, replaces the preview with the canonical URL, and revokes both URLs with their respective lifetimes. Direct subagent continuations do not register echoes because their transport assigns a different RPC identity and image input is unsupported.
 
 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.

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md

@@ -14,6 +14,8 @@
 
 **退休由观察驱动并延迟一帧;显示去重是渲染期的声明式规则。**Session 在带其 rpcId 的 durable `user/message` 或 queue occurrence 到达时(append、窗口安装或 control frame)标记回显为已观察,并在一个动画帧之后移除,晚于先注册的会话组装帧。ChatView 独立地隐藏 rpcId 出现在已渲染 user/steering 节点或 queue 行中的回显,因此无论 store 更新顺序如何,每一次渲染中回显与 durable 恰有一个可见。带标识的 prompt 失败、`abandon()` 或销毁使回显立即按 failed 退休;先到的 settlement 生效。
 
+**队列操作要求观察到 Host 身份。**排队回显显示发送状态,并在正式队列行的相同位置显示禁用的编辑、删除和插话按钮。折叠后的队列在标题栏保留该状态。单独的 prompt 确认不携带队列项身份,因此只有关联的 Host 队列行到达后才启用适用的操作。这使缓慢受理的状态可见,同时避免把本地请求 id 当作可修改的 Host 队列项。
+
 **Composer 乐观提交。**Enter 在一个 machine 事务里清空草稿、occurrence 表和撤销历史,phase 保持 `plain`;发送作为 detached attempt 运行,允许并发发送,唯一的冻结 in-flight 槽只留给命令。多个 detached 发送失败时,只要 composer 为空或仍是上一次自动还原的内容,就按提交顺序合并还原;用户编辑后停止这一轮自动还原。草稿图片由 detached attempt 持有到回显退休,因此图片离开 rail 后销毁 Session scope 仍能释放它们。回显以 observed 退休时,`HistoricalImageCache.seed` 把每个预览 URL 挂到 admitted 引用名下。缓存同步公开预览 URL,同时读取 durable 附件;读取完成后用规范化 URL 替换预览,并按各自生命周期撤销两个 URL。直接 subagent continuation 不注册回显,因为它的 transport 会分配另一个 RPC id,而且不支持图片输入。
 
 客户端图片编码从同步分块 `btoa` 循环换成 `FileReader.readAsDataURL`(原生编码)。browser→host 传输仍是一个 base64 JSON 整包;#2885 剩余的传输改造不在本决定范围内。

+ 74 - 11
apps/web/tests/queue-actions.e2e.ts

@@ -26,6 +26,8 @@ const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
 const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
 const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const SENDING_EXPECTED = join(SNAPSHOT_DIR, 'sending.expected.md')
+const FAILED_EXPECTED = join(SNAPSHOT_DIR, 'failed.expected.md')
 const MODE = webSnapshotMode()
 
 const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
@@ -34,6 +36,7 @@ const EDIT = 'Queue item to edit'
 const EDITED = 'Edited queue item'
 const TAIL = 'Queue item preserved after stop'
 const WAKE = 'Wake the preserved queue'
+const FAILED = 'Queue submission to retry'
 
 /** Durable turn-end classifications observed by the scenario. */
 function turnEndReasons(events: readonly SessionEvent[]): string[] {
@@ -93,15 +96,44 @@ describe('web e2e: queue row actions', () => {
     await input.press('Enter')
     await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
 
-    for (const text of [REMOVE, EDIT]) {
-      // A just-submitted composer is read-only for the prompt round-trip.
-      await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
-      await input.fill(text)
+    const received = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    await page.route('**/api/session/prompt', async (route) => {
+      received.resolve(undefined)
+      await release.promise
+      await route.continue()
+    }, { times: 1 })
+    try {
+      await input.fill(REMOVE)
       await input.press('Enter')
+      await received.promise
+      const pending = page.locator('[data-queue-dock] [data-submission-echo]')
+      await pending.getByRole('status').waitFor()
+      expect(await pending.getByRole('status').textContent()).toBe('Sending…')
+      expect(await pending.getByRole('button').count()).toBe(3)
+      expect(await pending.getByRole('button').evaluateAll(buttons =>
+        buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true)
+      expect(await input.textContent()).toBe('')
+      expect(await input.getAttribute('contenteditable')).toBe('true')
+      const sending = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(SENDING_EXPECTED, sending, MODE)
+      await page.setViewportSize({ width: 390, height: 1000 })
+      await page.locator('[data-sidebar-collapsed="true"]').waitFor()
+      await expect.poll(() => pending.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
+      await page.setViewportSize({ width: 1680, height: 1000 })
+      await page.locator('[data-sidebar-collapsed="true"]').waitFor({ state: 'detached' })
+    } finally {
+      release.resolve(undefined)
     }
+    await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true)
+    expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
+    expect(await page.locator('[data-queue-dock]').getByRole('status').count()).toBe(0)
+    await input.fill(EDIT)
+    await input.press('Enter')
     const queueHeader = page.getByRole('button', { name: '2 queued messages' })
     await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
       .toBe('false')
+    await expect.poll(() => queueHeader.getByRole('status').count(), { timeout: 10_000 }).toBe(0)
     const collapsedSnapshot = await captureStableAria(
       page,
       '[class*="centerCol"]',
@@ -110,7 +142,7 @@ describe('web e2e: queue row actions', () => {
     await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE)
     await queueHeader.click()
     await expect.poll(
-      () => page.getByRole('button', { name: 'Remove queued message' }).count(),
+      () => page.getByRole('button', { name: 'Remove queued message', disabled: false }).count(),
       { timeout: 10_000 },
     ).toBe(2)
 
@@ -150,13 +182,36 @@ describe('web e2e: queue row actions', () => {
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
-    expect(tripwire.pageErrors).toEqual([])
-    expect(tripwire.warnings).toEqual([])
+    await page.route('**/api/session/prompt', async (route) => {
+      const envelope = route.request().postDataJSON() as { rpcId: string }
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        json: {
+          type: 'server-response', rpcId: envelope.rpcId,
+          result: {
+            ok: false,
+            error: { code: 'session/agent-busy', message: 'Queue submission failed', details: { reason: 'admission refused' } },
+          },
+        },
+      })
+    }, { times: 1 })
+    await input.fill(FAILED)
+    await input.press('Enter')
+    const failure = page.getByRole('alert').filter({ hasText: 'Queue submission failed' })
+    await failure.waitFor()
+    await expect.poll(() => input.textContent()).toBe(FAILED)
+    expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
+    const failed = [
+      await failure.ariaSnapshot(),
+      await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd),
+    ].join('\n')
+    await compareOrRefreshGolden(FAILED_EXPECTED, failed, MODE)
 
     await input.fill(TAIL)
     await input.press('Enter')
     await expect.poll(
-      () => page.getByRole('button', { name: 'Remove queued message' }).count(),
+      () => page.getByRole('button', { name: 'Remove queued message', disabled: false }).count(),
       { timeout: 10_000 },
     ).toBe(2)
 
@@ -186,6 +241,8 @@ describe('web e2e: queue row actions', () => {
       ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
       : [])).toEqual([ACTIVE_PROMPT, EDITED, TAIL, WAKE])
     await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
   }, 120_000)
 
   it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
@@ -232,6 +289,7 @@ describe('web e2e: queue row actions', () => {
     const queueHeader = page.getByRole('button', { name: '2 queued messages' })
     await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
       .toBe('false')
+    await expect.poll(() => queueHeader.getByRole('status').count(), { timeout: 10_000 }).toBe(0)
 
     const layoutSnapshot = await captureStableAria(
       page,
@@ -241,9 +299,13 @@ describe('web e2e: queue row actions', () => {
     await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
 
     const expectAlignedContextPanels = async () => {
-      const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
-      const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
-      const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
+      // Sample one layout: the responsive grid can move between separate browser round trips.
+      const [queuePanelBox, todoBox, goalBox] = await page.evaluate(() => [
+        '[data-queue-dock] > div', '[data-testid="todo-panel"]', '[data-goal-bar] > div',
+      ].map((selector) => {
+        const box = document.querySelector(selector)?.getBoundingClientRect()
+        return box === undefined ? null : { x: box.x, y: box.y, width: box.width }
+      }))
       expect(queuePanelBox).not.toBeNull()
       expect(todoBox).not.toBeNull()
       expect(goalBox).not.toBeNull()
@@ -282,6 +344,7 @@ describe('web e2e: queue row actions', () => {
       [
         'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
         'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
+        'sending.expected.md', 'failed.expected.md',
       ],
     )
   })

+ 1 - 1
apps/web/tests/queue-image.e2e.ts

@@ -105,7 +105,7 @@ describe('web e2e: queued image submission', () => {
     await dockThumb.waitFor({ timeout: 15_000 })
     await expect.poll(() => dockThumb.getAttribute('src')).toMatch(/^blob:/)
     await page.getByText(QUEUED_TEXT, { exact: true }).waitFor()
-    await page.getByRole('button', { name: 'Remove queued message' }).waitFor({ timeout: 15_000 })
+    await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 })
     const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE)
 

+ 2 - 2
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: 9b81858adee21d3b8d8a158fe5ffa239b6d4ffe3
-README.zh.md: 40c02c469032e98191cbaa2a0203cc1f5a5a889e
+README.md: 6dc4c34c8c3ae809a95f9bd8d486df648317e23c
+README.zh.md: 85305791b155e80e692f1f77b105c3f7f42028b0

+ 2 - 0
packages/client/ui-conversation/README.md

@@ -46,6 +46,8 @@ The resident composer survives no-Session and Session transitions. The no-Sessio
 
 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`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. 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 attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
 
+Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
+
 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. Plan mode and active goals do not change attachment intake. Continuable subagents keep separate Send and Stop actions but expose no paperclip, paste, or drop intake ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)).
 
 <a id="temporary-composer-entries"></a>

+ 2 - 0
packages/client/ui-conversation/README.zh.md

@@ -46,6 +46,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 
+排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
+
 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。Plan Mode 与 active goal 不改变附件入口。continuable 子代理保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。
 
 <a id="temporary-composer-entries"></a>

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

@@ -142,6 +142,7 @@ export const zh = {
   'web.contentTruncated': '内容已截断',
   'details.running': '运行中…',
   'queue.count': '{n} 条排队消息',
+  'queue.sending': '发送中…',
   'queue.image': '排队消息图片',
   'queue.file': '排队文件 {name}',
   'queue.edit': '编辑排队消息',
@@ -305,6 +306,7 @@ export const en = {
   'web.contentTruncated': 'Content truncated',
   'details.running': 'Running…',
   'queue.count': '{n} queued messages',
+  'queue.sending': 'Sending…',
   'queue.image': 'Queued message image',
   'queue.file': 'Queued file {name}',
   'queue.edit': 'Edit queued message',

+ 11 - 0
packages/client/ui-conversation/src/client/queue/QueueDock.module.css

@@ -131,6 +131,10 @@
   overflow: hidden;
 }
 
+.pendingRow .attachments {
+  flex-shrink: 1;
+}
+
 .file {
   display: inline-flex;
   flex: 0 1 180px;
@@ -216,6 +220,13 @@
   gap: 10px;
 }
 
+.status {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xs-13);
+  white-space: nowrap;
+}
+
 .action {
   display: grid;
   flex: none;

+ 36 - 2
packages/client/ui-conversation/src/client/queue/QueueDock.tsx

@@ -84,7 +84,8 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
 
 /**
  * Queue strip: one item renders directly; multiple items default to a
- * collapsible count header; an empty queue renders nothing.
+ * collapsible count header; an empty queue renders nothing. Local submissions
+ * show sending status and disabled actions until their Host queue rows arrive.
  */
 export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: QueueDockProps) {
   const inbox = useSession(s => s.queue)
@@ -155,6 +156,9 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
           >
             <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>
             <span className={css.count}>{t('queue.count', { n: rowCount })}</span>
+            {!listVisible && pendingQueue.length > 0 && (
+              <span className={css.status} role="status">{t('queue.sending')}</span>
+            )}
             <span className={css.chevron} aria-hidden>
               {expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
             </span>
@@ -301,7 +305,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
           })}
           {listVisible && pendingQueue.map((submission) => {
             return (
-              <li key={submission.requestId} className={css.row} data-submission-echo="">
+              <li key={submission.requestId} className={`${css.row} ${css.pendingRow}`} data-submission-echo="">
                 {rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
                 {submission.attachments.length > 0 && (
                   <span className={css.attachments}>
@@ -324,6 +328,36 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
                   </span>
                 )}
                 <span className={css.preview}>{projectUserText(submission.text, [])}</span>
+                <span className={css.status} role="status">{t('queue.sending')}</span>
+                {queueMutable && <div className={css.actions}>
+                  <button
+                    type="button"
+                    className={css.action}
+                    aria-label={t('queue.edit')}
+                    title={t('queue.sending')}
+                    disabled
+                  >
+                    <IconEditOutline16 size={14} />
+                  </button>
+                  <button
+                    type="button"
+                    className={css.action}
+                    aria-label={t('queue.remove')}
+                    title={t('queue.sending')}
+                    disabled
+                  >
+                    <IconTrashOutline16 size={14} />
+                  </button>
+                  <button
+                    type="button"
+                    className={css.action}
+                    aria-label={t('queue.steer')}
+                    title={t('queue.sending')}
+                    disabled
+                  >
+                    <IconSendOutline14 />
+                  </button>
+                </div>}
               </li>
             )
           })}

+ 36 - 1
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -141,10 +141,19 @@ describe('QueueDock', () => {
       }],
     }
     const source = liveSession(pending)
-    const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} />)
+    const props = kitFor(pending)
+    const view = render(<QueueDock {...props} useSession={source.useSession} />)
     expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull()
     expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview')
     expect(view.getByLabelText('排队文件 notes.txt').textContent).toContain('2.4GB')
+    expect(view.getByRole('status').textContent).toBe('发送中…')
+    for (const name of ['编辑排队消息', '删除排队消息', '插话发送']) {
+      const button = view.getByRole('button', { name }) as HTMLButtonElement
+      expect(button.disabled).toBe(true)
+      fireEvent.click(button)
+    }
+    expect(props.updateQueue).not.toHaveBeenCalled()
+    expect(view.queryByRole('textbox')).toBeNull()
 
     act(() => {
       source.push({
@@ -154,6 +163,32 @@ describe('QueueDock', () => {
     })
     expect(view.getAllByText('等待上传')).toHaveLength(1)
     expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
+    expect(view.queryByRole('status')).toBeNull()
+    for (const name of ['编辑排队消息', '删除排队消息', '插话发送']) {
+      expect((view.getByRole('button', { name }) as HTMLButtonElement).disabled).toBe(false)
+    }
+    fireEvent.click(view.getByRole('button', { name: '编辑排队消息' }))
+    expect((view.getByRole('textbox') as HTMLInputElement).value).toBe('等待上传')
+  })
+
+  it('keeps sending status visible while a queue containing local submissions is collapsed', () => {
+    const pending: SessionSnapshot = {
+      ...snapshotWith([row('accepted', '已排队')]),
+      pendingSubmissions: [{
+        requestId: 'req-waiting' as never, placement: 'queued', time: 1,
+        text: '等待发送', attachments: [],
+      }],
+    }
+    const source = liveSession(pending)
+    const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} />)
+    expect(view.getByRole('status').textContent).toBe('发送中…')
+    const header = view.getByRole('button', { name: /2 条排队消息\s*发送中…/ })
+    expect(header.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(header)
+    expect(view.getAllByRole('status')).toHaveLength(1)
+    expect(view.getByRole('status').closest('[data-submission-echo]')).not.toBeNull()
+    act(() => { source.push(snapshotWith([row('accepted', '已排队')])) })
+    expect(view.queryByRole('status')).toBeNull()
   })
 
   it('leaves pending steering to the conversation flow', () => {

+ 12 - 0
snapshots/web/queue-actions/failed.expected.md

@@ -0,0 +1,12 @@
+- alert: Queue submission failed (session/agent-busy)
+- textbox "Message or run a task... / commands, @ files or sessions":
+  - paragraph: Queue submission to retry
+- button "Commands":
+  - img
+- button "Add attachment":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "Send message"

+ 44 - 0
snapshots/web/queue-actions/sending.expected.md

@@ -0,0 +1,44 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Reply with a one-sentence description" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
+- button "Copy":
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- paragraph: partial
+- status: Deep diving...
+- list:
+  - listitem:
+    - text: Queue item to remove
+    - status: Sending…
+    - button "Edit queued message" [disabled]:
+      - img
+    - button "Remove queued message" [disabled]:
+      - img
+    - button "Steer queued message" [disabled]:
+      - img
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- button "Add attachment":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "Stop generating"