diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml index 5b51304fe6..1935c6de2e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.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/feature/2026-08-06-web-queue-steer-all-gesture.md -2026-08-06-web-queue-steer-all-gesture.md: e65089fca763a4226a40b090f3a5f8f56284b9bc -2026-08-06-web-queue-steer-all-gesture.zh.md: abccf60a9da54ba1c639e93c63107d97913d9d66 +2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76 +2026-08-06-web-queue-steer-all-gesture.zh.md: fb36852f66a86408af12df59001230b2024eccaf diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md index e65089fca7..e546f68647 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -14,9 +14,11 @@ Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inb The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. +The same computed availability gate drives discovery: while the draft is empty, the input is unlocked and not in a transient machine lock, the command menu is closed, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence, and the steer hint deliberately outranks the plan-mode placeholder while available (the gesture genuinely works in that window). + ## Consequences -One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The cost is a presentation-layer branch that must stay in sync with the dock's gating window (running, non-subagent) — the hub re-checks the snapshot at execution time, so the gate is advisory and the host remains authoritative. +One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The gesture and its placeholder share one presentation-layer gate, while the hub re-checks the snapshot at execution time, so the client gate remains advisory and the host remains authoritative. ## Related @@ -28,3 +30,4 @@ The per-row 插话发送 action and its strict-steer boundary are owned by [Stee - **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. - **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. - **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. +- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md index abccf60a9d..fb36852f66 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -14,9 +14,11 @@ Status: implemented 该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 +同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定且不处于瞬态机器锁(adjudicating/submitting)、命令菜单未打开、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先;可用时 steer 提示会刻意优先于 plan 模式 placeholder(该窗口内手势确实可用)。 + ## Consequences -一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。代价是呈现层多了一个分支,必须与 dock 的门控窗口(running、非 subagent)保持同步——hub 在执行时重新读取快照,所以该门控只是建议性的,host 仍是权威。 +一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。手势及其 placeholder 共用一个呈现层门控;hub 在执行时会重新读取快照,因此客户端门控仍只是建议性的,host 仍是权威。 ## Related @@ -28,3 +30,4 @@ Status: implemented - **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 - **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 - **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 +- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index 18b40d976a..3c0d8ad785 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -14,7 +14,7 @@ - paragraph: partial - status: Deep diving... - button "2 queued messages" -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 7dc4f38f86..3672a64c32 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -30,7 +30,7 @@ - tooltip "Save queued message" - button "Cancel editing": - img -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 7370a15264..1f9c239c60 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -29,7 +29,7 @@ - button "Clear goal": - img - button "2 queued messages" -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 0d9ae5fcf3..1d1f63a427 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -23,7 +23,7 @@ - img - button "Steer queued message": - img -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0734355828..a2f09ed379 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: 8d5034a84edd56b7c9b411f6e281d1c3b7733a82 -README.zh.md: 309bf54c2b1759c33f21ada33184e2a118b2f00f +README.md: 2c12219acf98a131109c6bebadbec94f24d9ea7c +README.zh.md: 907c39989ec2ee62b38c685256fe0056fa725059 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 8d5034a84e..2c12219acf 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 309bf54c2b..907c39989e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9ba5ed3876..20aab2cfda 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -22,6 +22,7 @@ export const zh = { 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', @@ -162,6 +163,7 @@ export const en = { 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', 'context.aria': '{percent} of context used', 'context.used': 'of context used', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 7a85ef7e08..e326c8e523 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -89,6 +89,8 @@ export function InputBar({ const disabled = removed || inert || !live const locked = disabled const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null + && input.queue.some(row => row.placement === 'queued') // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does @@ -253,8 +255,7 @@ export function InputBar({ // still-pending queued message into the running turn (the dock's per-row // steer button applied to the whole queue). Steering needs the same // window as the per-row button: a running ordinary session. - if (accelerated && empty && running && subagent === null - && input.queue.some(row => row.placement === 'queued')) { + if (accelerated && canSteerQueue) { keyboard.steerQueue() return } @@ -485,7 +486,12 @@ export function InputBar({ data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (disabled ? t('placeholder.unavailable') - : planActive ? t('placeholder.plan') : t('placeholder.default'))} + // The steer hint deliberately outranks the plan placeholder: + // while it shows, the whole-queue gesture is genuinely available + // (the gate never consults plan mode), so the actionable hint wins. + : canSteerQueue + ? t('placeholder.steerQueue') + : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} onKeyDown={onKeyDown} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 67a9b48e90..4ae6626b14 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -169,6 +169,57 @@ function bench(over?: BenchOptions) { } describe('Enter semantics', () => { + it('advertises the empty-draft whole-queue steering gesture when it is available', () => { + const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() }) + expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') + }) + + it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => { + expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ + running: true, + queue: [row('q-1')], + subagent: { + address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }, + }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ + running: true, + queue: [row('q-1')], + placeholder: '上层指定提示', + }).textarea.placeholder).toBe('上层指定提示') + // The command menu owns Enter while open: neither the hint nor the + // gesture may claim the chord. + expect(bench({ + running: true, + queue: [row('q-1')], + commandMenuOpen: true, + }).textarea.placeholder).toBe('给智能体发消息') + // The steer hint intentionally outranks the plan placeholder: while it + // shows, the whole-queue gesture is genuinely available in plan mode. + expect(bench({ + running: true, + queue: [row('q-1')], + plan: { active: true, pending: false }, + }).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') + }) + + it('an open command menu withholds the whole-queue steering gesture', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ + running: true, + queue: [row('q-1')], + commandMenuOpen: true, + steerQueue, + }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(steerQueue).not.toHaveBeenCalled() + expect(sink).not.toHaveBeenCalled() + }) + it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' })