fix(web): keep plain Send while a file upload is pending
Review follow-up: the mode label predicate now includes !uploadsPending, so a running draft whose file is still uploading (or failed) keeps the plain Send label on its disabled button, matching the documented 'enabled button' condition. Test pins pending vs ready upload states; the Agent Note and README pair name the upload gate.
This commit is contained in:
1 parent
2f630626b8
commit
8935c3d725
8 files changed
+40
-18
No files matched your search
+2
-2
@@ -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-09-04-busy-send-button-follows-enter-setting.md
|
||||
2026-09-04-busy-send-button-follows-enter-setting.md: 31f21cfa58f96e95bfda2e8aa04d41f8fa24c46a
|
||||
2026-09-04-busy-send-button-follows-enter-setting.zh.md: 0c7ddfe8e6f207ee489c519626b06e67c75530bf
|
||||
2026-09-04-busy-send-button-follows-enter-setting.md: 44109de724fc4fe5889fbf8e425bbf735938f5eb
|
||||
2026-09-04-busy-send-button-follows-enter-setting.zh.md: 7dba28385bc83cc1f3e3b6ead3c2fcfdd0042b91
|
||||
+2
-2
@@ -10,7 +10,7 @@ The Web composer offers one user-facing choice for submitting while the agent is
|
||||
|
||||
## Decision
|
||||
|
||||
The running Send button delivers through the same mode as plain Enter. `InputBar` computes `resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)` once per render, where `steeringAvailable` is the same ordinary-Session-or-continuable-child predicate the keyboard path uses, applies it to the primary click through `ComposerKeyboard.submit(mode)`, and applies it to the primary label exactly when the click would deliver a plain message: the composer is running and steer-capable, the button is enabled, and the draft is non-empty, unclaimed, and not a `/` line headed for command adjudication. That state shows `input.send.queue` ("Queue message" / "排队发送") or `input.send.steer` ("Steer message" / "插话发送") as both the tooltip and the accessible name; every other state — idle sessions, one-shot children, locked composers, empty drafts, and command drafts whose click executes the command rather than delivering a message — keeps `input.send` ("Send message"). Cmd/Ctrl+Enter still resolves to the opposite mode, and the empty-draft accelerated gesture still steers the whole queue.
|
||||
The running Send button delivers through the same mode as plain Enter. `InputBar` computes `resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)` once per render, where `steeringAvailable` is the same ordinary-Session-or-continuable-child predicate the keyboard path uses, applies it to the primary click through `ComposerKeyboard.submit(mode)`, and applies it to the primary label exactly when the click would deliver a plain message: the composer is running and steer-capable, the button is enabled (no file upload still pending), and the draft is non-empty, unclaimed, and not a `/` line headed for command adjudication. That state shows `input.send.queue` ("Queue message" / "排队发送") or `input.send.steer` ("Steer message" / "插话发送") as both the tooltip and the accessible name; every other state — idle sessions, one-shot children, locked composers, empty drafts, drafts with a pending upload, and command drafts whose click executes the command rather than delivering a message — keeps `input.send` ("Send message"). Cmd/Ctrl+Enter still resolves to the opposite mode, and the empty-draft accelerated gesture still steers the whole queue.
|
||||
|
||||
The composer bar's inject face carries the live preference instead of a resolver closure. `ComposerBarInjected.hooks.busyEnter` publishes `ComposerSubmissionPolicy.busyEnter`, so the bar receives a `useBusyEnter` selector hook and re-renders the label when the Settings row or a Host settings update changes the value. `resolveSubmitMode` is a pure exported function in `submission-policy.ts` taking the preference explicitly; the policy class keeps only the store and its Host adoption and write-through.
|
||||
|
||||
@@ -18,7 +18,7 @@ The Settings row is retitled to cover both inputs: "Send behavior while busy" /
|
||||
|
||||
## Verification
|
||||
|
||||
`input-bar.client.spec.tsx` asserts that a running draft's button is labeled by mode and submits with that mode under both preferences, that flipping the preference store re-labels the mounted button before the next click, that idle Send keeps the plain label and Queue delivery regardless of the preference, that a continuable subagent's Send follows the same mode and label as an ordinary Session while its empty-draft disabled button and a one-shot child keep plain Send, and that a `/` line and a claimed command keep plain Send while running. `submission-policy.client.spec.ts` pins `resolveSubmitMode` for every preference, running, gesture, and steering-availability combination. `enter-behavior-row.client.spec.tsx` and the `settings-chrome` ARIA goldens carry the new Settings copy. The keyless `live-interactions` Web scenario waits for "Queue message" on the parked running draft and asserts that no "Send message" button exists at that moment, and its `running-draft.expected.md` golden records the new name.
|
||||
`input-bar.client.spec.tsx` asserts that a running draft's button is labeled by mode and submits with that mode under both preferences, that flipping the preference store re-labels the mounted button before the next click, that idle Send keeps the plain label and Queue delivery regardless of the preference, that a continuable subagent's Send follows the same mode and label as an ordinary Session while its empty-draft disabled button and a one-shot child keep plain Send, and that a `/` line, a claimed command, and a draft with a still-uploading file keep plain Send while running. `submission-policy.client.spec.ts` pins `resolveSubmitMode` for every preference, running, gesture, and steering-availability combination. `enter-behavior-row.client.spec.tsx` and the `settings-chrome` ARIA goldens carry the new Settings copy. The keyless `live-interactions` Web scenario waits for "Queue message" on the parked running draft and asserts that no "Send message" button exists at that moment, and its `running-draft.expected.md` golden records the new name.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ Web composer 为 agent(智能体)运行期间的提交只提供一个面向
|
||||
|
||||
## 决策
|
||||
|
||||
运行中的 Send 按钮按与 plain Enter 相同的模式投递。`InputBar` 每次渲染计算一次 `resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)`,其中 `steeringAvailable` 与键盘路径使用同一个"普通 Session 或可继续 child"判定;用它通过 `ComposerKeyboard.submit(mode)` 执行主按钮点击,并且仅在点击会投递一条普通消息时用它决定主按钮标签:composer 运行中且可 steering、按钮可用、草稿非空、未被认领且不是将进入命令 adjudication 的 `/` 行。该状态把 `input.send.queue`("Queue message" / "排队发送")或 `input.send.steer`("Steer message" / "插话发送")同时用作 tooltip 与可访问名称;其余所有状态——空闲会话、one-shot child、锁定的 composer、空草稿,以及点击会执行命令而非投递消息的命令草稿——保留 `input.send`("Send message")。Cmd/Ctrl+Enter 仍解析为相反模式,空草稿下的加速手势仍对整个队列执行 steering(中途引导)。
|
||||
运行中的 Send 按钮按与 plain Enter 相同的模式投递。`InputBar` 每次渲染计算一次 `resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)`,其中 `steeringAvailable` 与键盘路径使用同一个"普通 Session 或可继续 child"判定;用它通过 `ComposerKeyboard.submit(mode)` 执行主按钮点击,并且仅在点击会投递一条普通消息时用它决定主按钮标签:composer 运行中且可 steering、按钮可用(没有仍在上传的文件)、草稿非空、未被认领且不是将进入命令 adjudication 的 `/` 行。该状态把 `input.send.queue`("Queue message" / "排队发送")或 `input.send.steer`("Steer message" / "插话发送")同时用作 tooltip 与可访问名称;其余所有状态——空闲会话、one-shot child、锁定的 composer、空草稿、带待上传附件的草稿,以及点击会执行命令而非投递消息的命令草稿——保留 `input.send`("Send message")。Cmd/Ctrl+Enter 仍解析为相反模式,空草稿下的加速手势仍对整个队列执行 steering(中途引导)。
|
||||
|
||||
composer bar 的 inject 接口携带实时偏好,而不是解析闭包。`ComposerBarInjected.hooks.busyEnter` 发布 `ComposerSubmissionPolicy.busyEnter`,因此 bar 获得 `useBusyEnter` 选择器 hook,并在设置行或 Host 设置更新改变该值时重新渲染标签。`resolveSubmitMode` 是 `submission-policy.ts` 中导出的纯函数,显式接收偏好值;policy 类只保留 store 及其 Host 采纳与写回。
|
||||
|
||||
@@ -18,7 +18,7 @@ composer bar 的 inject 接口携带实时偏好,而不是解析闭包。`Comp
|
||||
|
||||
## 验证
|
||||
|
||||
`input-bar.client.spec.tsx` 断言运行中草稿的按钮在两种偏好下都按模式标注并以该模式提交,切换偏好 store 会在下一次点击前重新标注已挂载的按钮,空闲 Send 无论偏好如何都保留普通标签与 Queue 投递,可继续 subagent 的 Send 与普通 Session 遵循同一模式与标签,而其空草稿下的禁用按钮与 one-shot child 保留普通 Send,运行中的 `/` 行与已认领命令也保留普通 Send。`submission-policy.client.spec.ts` 钉住 `resolveSubmitMode` 在偏好、运行状态、手势与 steering 可用性所有组合下的结果。`enter-behavior-row.client.spec.tsx` 与 `settings-chrome` ARIA golden 携带新的设置文案。无密钥的 `live-interactions` Web 场景在停住的运行中草稿上等待"Queue message",并断言此刻不存在"Send message"按钮,其 `running-draft.expected.md` golden 记录了新名称。
|
||||
`input-bar.client.spec.tsx` 断言运行中草稿的按钮在两种偏好下都按模式标注并以该模式提交,切换偏好 store 会在下一次点击前重新标注已挂载的按钮,空闲 Send 无论偏好如何都保留普通标签与 Queue 投递,可继续 subagent 的 Send 与普通 Session 遵循同一模式与标签,而其空草稿下的禁用按钮与 one-shot child 保留普通 Send,运行中的 `/` 行、已认领命令与带仍在上传文件的草稿也保留普通 Send。`submission-policy.client.spec.ts` 钉住 `resolveSubmitMode` 在偏好、运行状态、手势与 steering 可用性所有组合下的结果。`enter-behavior-row.client.spec.tsx` 与 `settings-chrome` ARIA golden 携带新的设置文案。无密钥的 `live-interactions` Web 场景在停住的运行中草稿上等待"Queue message",并断言此刻不存在"Send message"按钮,其 `running-draft.expected.md` golden 记录了新名称。
|
||||
|
||||
## 备选方案
|
||||
|
||||
|
||||
@@ -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: f3172c9849c5d6e65c5656fcc2949f598729f662
|
||||
README.zh.md: 9b3174c52d72047500c003b04d654fa88a20d022
|
||||
README.md: e06270a339a23fa17fb9d51642317fcf2aec6c0f
|
||||
README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024
|
||||
@@ -48,7 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a
|
||||
|
||||
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 Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
|
||||
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 Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## Temporary composer entries
|
||||
|
||||
@@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
|
||||
|
||||
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
|
||||
|
||||
普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
|
||||
普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## 临时 composer entry
|
||||
|
||||
@@ -338,17 +338,18 @@ export const InputBar = memo(function InputBar({
|
||||
// owner-blocked; an actionable draft gets the busy Send action, delivered
|
||||
// through the same mode plain Enter resolves to. The label names that mode
|
||||
// only when the click would deliver a plain message right now — an enabled
|
||||
// button over a non-empty draft that is neither a claimed command nor a
|
||||
// `/` line headed for adjudication — so it never describes a delivery the
|
||||
// click cannot or does not perform; every other state keeps plain Send. A
|
||||
// continuable child keeps Send primary and exposes Stop independently.
|
||||
// button (no pending upload) over a non-empty draft that is neither a
|
||||
// claimed command nor a `/` line headed for adjudication — so it never
|
||||
// describes a delivery the click cannot or does not perform; every other
|
||||
// state keeps plain Send. A continuable child keeps Send primary and
|
||||
// exposes Stop independently.
|
||||
const primaryStops = running && subagent === null && (empty || blocked !== undefined)
|
||||
const interruptible = running && continuable
|
||||
const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)
|
||||
const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/')
|
||||
const primaryLabel = primaryStops
|
||||
? t('input.stop')
|
||||
: running && steeringAvailable && !disabled && plainMessageDraft
|
||||
: running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
|
||||
? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue')
|
||||
: t('input.send')
|
||||
const onPrimary = (): void => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { SubmitOutcome } from '../src/client/contract/input.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts'
|
||||
import type {
|
||||
ComposerAttachment, ComposerAttachmentsOwnerProps,
|
||||
ComposerAttachment, ComposerAttachmentsOwnerProps, DraftFileUploads,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
import type { DraftAttachmentId } from '../src/client/contract/input.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
@@ -89,6 +89,8 @@ interface BenchOptions {
|
||||
rightItems?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
attachments?: readonly ComposerAttachment[]
|
||||
/** Upload states served for file-kind drafts (absent = every file is ready). */
|
||||
fileUploads?: DraftFileUploads
|
||||
addFiles?: (files: readonly File[]) => string | null
|
||||
commandMenuOpen?: boolean
|
||||
busyEnter?: 'queue' | 'steer'
|
||||
@@ -180,7 +182,7 @@ function bench(over?: BenchOptions) {
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
addFiles: over?.addFiles ?? (() => null),
|
||||
useFileUploads: bindSnapshotSelector(createSnapshotStore({})),
|
||||
useFileUploads: bindSnapshotSelector(createSnapshotStore<DraftFileUploads>(over?.fileUploads ?? {})),
|
||||
retryFileUpload: undefined,
|
||||
removeAttachment,
|
||||
resolveDraftAttachments: ids => ids.flatMap((id) => {
|
||||
@@ -217,7 +219,9 @@ function bench(over?: BenchOptions) {
|
||||
const steeringAvailable = over?.subagent === undefined || over.subagent.address.mode === 'continuable'
|
||||
const composerLocked = over?.disabled === true || over?.inert === true || over?.blocked !== undefined
|
||||
|| (over?.subagent?.address.mode === 'continuable' && over.subagent.parentAvailable !== true)
|
||||
const plainMessageDraft = sendableDraft && !(over?.draft?.trimStart().startsWith('/') ?? false)
|
||||
const uploadsPending = (over?.attachments ?? []).some(attachment =>
|
||||
attachment.kind === 'file' && over?.fileUploads?.[attachment.id]?.status !== 'ready')
|
||||
const plainMessageDraft = sendableDraft && !uploadsPending && !(over?.draft?.trimStart().startsWith('/') ?? false)
|
||||
const primaryLabel = primaryStops
|
||||
? '停止生成'
|
||||
: over?.running === true && steeringAvailable && !composerLocked && plainMessageDraft
|
||||
@@ -782,6 +786,23 @@ describe('running and lock semantics', () => {
|
||||
expect(claimed.view.container.querySelector('button[aria-label="插话发送"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('running Send keeps the plain label while a file upload is still pending', () => {
|
||||
const file = { kind: 'file' as const, id: 'file-1' as DraftAttachmentId, file: new File(['x'], 'note.txt', { type: 'text/plain' }) }
|
||||
const pending = bench({
|
||||
running: true, busyEnter: 'steer', draft: '带附件', attachments: [file],
|
||||
fileUploads: { [file.id]: { status: 'uploading', loaded: 0 } },
|
||||
})
|
||||
expect(pending.button.getAttribute('aria-label')).toBe('发送消息')
|
||||
expect(pending.button.disabled).toBe(true)
|
||||
|
||||
const ready = bench({
|
||||
running: true, busyEnter: 'steer', draft: '带附件', attachments: [file],
|
||||
fileUploads: { [file.id]: { status: 'ready', receiptId: 'receipt-1' as never, file: { kind: 'file' } as never } },
|
||||
})
|
||||
expect(ready.button.getAttribute('aria-label')).toBe('插话发送')
|
||||
expect(ready.button.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('idle Send keeps the plain label regardless of the busy-state preference', () => {
|
||||
const { button, sink } = bench({ busyEnter: 'steer', draft: '空闲发送' })
|
||||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||||
|
||||
Reference in new issue
Block a user