diff --git a/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.i18n.yaml index 7f36d0b961..9583c7c1de 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.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/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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md b/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md index 31f21cfa58..44109de724 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md +++ b/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md b/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md index 0c7ddfe8e6..7dba28385b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md @@ -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 记录了新名称。 ## 备选方案 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index de73d95f3c..d93d7d7dc5 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: f3172c9849c5d6e65c5656fcc2949f598729f662 -README.zh.md: 9b3174c52d72047500c003b04d654fa88a20d022 +README.md: e06270a339a23fa17fb9d51642317fcf2aec6c0f +README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f3172c9849..e06270a339 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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)). ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 9b3174c52d..315fd3d779 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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))。 ## 临时 composer entry diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 52853f01cc..aa37b85ecf 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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 => { diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 966956135a..910faedd0e 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -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(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('发送消息')