From e4256a1684b23de0eaa9190e6841eab1e9690718 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 21:34:38 +0800 Subject: [PATCH 1/7] feat(web): steer the whole queue with an empty-draft Cmd/Ctrl+Enter --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 6 + .../2026-08-06-web-queue-steer-all-gesture.md | 30 +++++ ...26-08-06-web-queue-steer-all-gesture.zh.md | 30 +++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/input/contract.ts | 6 + .../src/client/input/facade.ts | 15 +++ .../ui-conversation/src/client/input/hub.ts | 33 +++++- .../src/client/skeleton/InputBar.tsx | 13 ++- .../ui-conversation/tests/input-bar.spec.tsx | 104 ++++++++++++++++-- .../tests/service-orchestration.spec.ts | 78 ++++++++++++- 13 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md 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 new file mode 100644 index 0000000000..5b51304fe6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 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 new file mode 100644 index 0000000000..e65089fca7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -0,0 +1,30 @@ +# Agent Note: Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter + +Status: implemented + +English | [中文](2026-08-06-web-queue-steer-all-gesture.zh.md) + +## Problem + +While a primary session runs, the Web queue accumulates messages the user typed with plain Enter (or queued while the busy-Enter preference was Queue). Flushing them into the current turn required clicking the per-row 插话发送 button once per message; an empty composer draft had no keyboard gesture at all — the input machine rejects empty drafts, so Enter and Cmd/Ctrl+Enter were both no-ops. With several queued messages, steering them one by one is the obvious multi-click friction, and the empty-draft accelerated chord is the natural slot for "steer everything". + +## Decision + +Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary. + +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. + +## 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. + +## Related + +The per-row 插话发送 action and its strict-steer boundary are owned by [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md); this note only adds the whole-queue keyboard gesture on top of that decision. + +## Alternatives considered + +- **Intercepting inside the input machine.** Rejected: the machine is queue-agnostic by design (the wiring layer overlays the queue projection) and cannot distinguish the accelerated chord from plain Enter, which must stay a no-op. +- **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. 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 new file mode 100644 index 0000000000..abccf60a9d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 空输入时 Cmd/Ctrl+Enter 将 Web 排队消息全部插话 + +Status: implemented + +[English](2026-08-06-web-queue-steer-all-gesture.md) | 中文 + +## Problem + +主会话运行时,用户用普通 Enter(或在 busy-Enter 偏好为 Queue 时)输入的消息会在 Web 队列里累积。把它们灌进当前轮次需要逐条点击「插话发送」按钮;而输入框草稿为空时没有任何键盘手势——输入机对空草稿直接拒绝,Enter 与 Cmd/Ctrl+Enter 都是空操作。排队消息一多,逐条插话是明显的多点摩擦,空草稿 + 加速 Enter 正是「全部插话」的自然位置。 + +## Decision + +空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`(pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。 + +该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 + +## Consequences + +一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。代价是呈现层多了一个分支,必须与 dock 的门控窗口(running、非 subagent)保持同步——hub 在执行时重新读取快照,所以该门控只是建议性的,host 仍是权威。 + +## Related + +逐条「插话发送」动作及其严格 steer 边界由 [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md) 记录;本笔记只在其之上增加整队列键盘手势。 + +## Alternatives considered + +- **在输入机内拦截。** 已拒绝:输入机按设计不感知队列(队列投影由 wiring 层叠加),且无法区分加速 Enter 与必须保持空操作的普通 Enter。 +- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 +- **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 +- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index be53206dde..73b3204d61 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: bbd115eac0eb914914dc11e504639633c801abdd -README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc +README.md: e7b57e25208a402a81284f930644bfc4208c9291 +README.zh.md: 1b8543fa24d8e8a62f6b8995619548928ab80ce1 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bbd115eac0..e7b57e2520 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 stays absent because the message has not entered a durable turn. 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 branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, 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. 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. 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 843b49e311..1b8543fa24 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` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+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 仍是无操作。已寻址 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/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..0cdb4b9939 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -131,7 +131,7 @@ export function apply(ctx: Context): void { // The per-session input machine registry (InputService face; published as // ctx.conversation.input by the service below sharing this one instance). - const inputHub = new InputHub(ctx) + const inputHub = new InputHub(ctx, t) // Decision 19/20: the input machine feeds every session-scope slot // component through the standard provide channel — the 'input' hook plus diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 6af78bdb72..bc4b365373 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -88,6 +88,12 @@ export interface ComposerKeyboard { setDraft(text: string, editRange?: EditRange): void /** Submit with an explicit delivery mode resolved by the keyboard policy. */ submit(mode: InputSubmitMode): void + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture; the queue dock's per-row steer + * button is the same operation applied to the whole queue). + */ + steerQueue(): void undo(): void redo(): void /** Paste over the selection (sync components ride the same transaction). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 79516d04fa..fdceebd282 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -39,6 +39,11 @@ export interface SessionInputDeps { popup?: (() => PopupDismissFace | undefined) | undefined /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order (the empty-draft accelerated-Enter gesture); absent = unsupported. + */ + steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink(text: string, mode: InputSubmitMode): void } @@ -173,6 +178,16 @@ export class SessionInputShell implements SessionInput { return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass' } + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture). Execution belongs to the hub's + * queue choreography; absent dep = the gesture falls back to the machine's + * empty-draft no-op. + */ + steerQueue(): void { + this.deps.steerQueue?.() + } + /** * Space adjudication over the controller's hot state. * @returns true = a claim/insert was applied — the caller preventDefaults. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 560999aa21..e9e58035d2 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -10,6 +10,7 @@ */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -25,8 +26,14 @@ interface CommandFace { export class InputHub implements InputService { private readonly shells = new Map() - /** @param ctx - client root context (services resolved lazily per call — boot order stays free). */ - constructor(private readonly rootCtx: ClientContext) {} + /** + * @param ctx - client root context (services resolved lazily per call — boot order stays free). + * @param t - conversation-namespace translate thunk (reads the active locale at call time). + */ + constructor( + private readonly rootCtx: ClientContext, + private readonly t: TranslateNS<'conversation'>, + ) {} /** * Resolve the facade for one session-scope ctx (InputService face). @@ -58,6 +65,7 @@ export class InputHub implements InputService { popup: () => this.popup(actx), queue: queueReadFaceOf(session), defaultSink: (text, mode) => { this.sink(session, text, mode) }, + steerQueue: () => { void this.steerQueue(session, shell) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -139,6 +147,27 @@ export class InputHub implements InputService { ) } + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order — the same strict-steer operation as the queue dock's per-row + * button. A turn closing mid-way (`steer-unavailable`) or a row already + * claimed by the agent (`queue-item-not-found`) converges silently, while a + * genuine failure surfaces as one composer notice. + * @param session - the addressed host session. + * @param shell - the resident shell (notice outlet). + */ + private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise { + const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued') + if (queued.length === 0) return + for (const item of queued) { + const result = await session.updateQueue(item.id, { kind: 'steer' }) + if (result.ok) continue + if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return + shell.notify('error', this.t('queue.steerFailed')) + return + } + } + private controller(actx: ClientContext): SlashController | undefined { const slash = this.rootCtx.get('slash') return slash?.sessionOf(actx) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..7a85ef7e08 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -247,9 +247,20 @@ export function InputBar({ e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return + const accelerated = e.ctrlKey || e.metaKey + // Empty-draft accelerated Enter acts on the queue instead of the (empty) + // draft: the machine rejects empty drafts, so the gesture steers every + // 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')) { + keyboard.steerQueue() + return + } keyboard.submit(resolveSubmitMode( running, - e.ctrlKey || e.metaKey ? 'accelerated' : 'enter', + accelerated ? 'accelerated' : 'enter', subagent === null, )) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..67a9b48e90 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -56,6 +56,10 @@ interface BenchOptions { subagent?: Exclude disabled?: boolean promptError?: ConversationSnapshot['promptError'] + /** Authoritative queue rows served to the machine overlay (empty = none). */ + queue?: ConversationSnapshot['queue'] + /** The hub's steer-all face (empty-draft accelerated Enter). */ + steerQueue?: () => void variant?: 'hero' | 'composer' placeholder?: string t?: InputBarProps['t'] @@ -69,14 +73,34 @@ interface BenchOptions { toggleCommandMenu?: (selection: { start: number; end: number }) => void } +/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */ +function row(id: string): ConversationSnapshot['queue'][number] { + return { + id: id as never, messageId: `message-${id}` as never, placement: 'queued', + content: [{ type: 'text', text: id }], preview: id, text: id, + } +} + /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { const sink = vi.fn() const lex = over?.lexicon + const session = createSnapshotStore(snapshotOf({ + running: over?.running ?? false, + subagent: over?.subagent ?? null, + removed: over?.disabled ?? false, + promptError: over?.promptError ?? null, + queue: over?.queue ?? [], + })) type ShellDeps = ConstructorParameters[0] const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, + queue: { + getSnapshot: () => session.getSnapshot().queue, + subscribe: fn => session.subscribe(fn), + }, + ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}), // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined @@ -88,12 +112,6 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) - const session = createSnapshotStore(snapshotOf({ - running: over?.running ?? false, - subagent: over?.subagent ?? null, - removed: over?.disabled ?? false, - promptError: over?.promptError ?? null, - })) const stop = vi.fn() const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] @@ -147,7 +165,7 @@ function bench(over?: BenchOptions) { const button = view.container.querySelector( `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`, )! - return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } + return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher, steerQueue: over?.steerQueue } } describe('Enter semantics', () => { @@ -190,6 +208,78 @@ describe('Enter semantics', () => { expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer') }) + it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { + const steerQueue = vi.fn() + const queue = [row('q-1'), row('q-2')] + const meta = bench({ running: true, queue, steerQueue }) + fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) + expect(meta.steerQueue).toHaveBeenCalledTimes(1) + expect(meta.sink).not.toHaveBeenCalled() + + const ctrl = bench({ running: true, queue, steerQueue: vi.fn() }) + fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(ctrl.steerQueue).toHaveBeenCalledTimes(1) + expect(ctrl.sink).not.toHaveBeenCalled() + }) + + it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => { + // Idle: the gesture falls through to the machine's empty-draft no-op. + const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) + expect(idle.steerQueue).not.toHaveBeenCalled() + expect(idle.sink).not.toHaveBeenCalled() + + // Plain Enter never steers the queue, even under the busy Steer preference. + const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(plain.textarea, { key: 'Enter' }) + expect(plain.steerQueue).not.toHaveBeenCalled() + expect(plain.sink).not.toHaveBeenCalled() + + // Subagent sessions keep the queue transport (no steering face). + const subagent = { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'continuable' as const, + }, + parentAvailable: true, + } + const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true }) + expect(child.steerQueue).not.toHaveBeenCalled() + expect(child.sink).not.toHaveBeenCalled() + + // No queued rows: the empty draft stays a no-op. + const none = bench({ running: true, steerQueue: vi.fn() }) + fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true }) + expect(none.steerQueue).not.toHaveBeenCalled() + expect(none.sink).not.toHaveBeenCalled() + + // Pending steering rows are not the queue: nothing to flush. + const steering = bench({ + running: true, + queue: [{ ...row('s-1'), placement: 'steering' }], + steerQueue: vi.fn(), + }) + fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true }) + expect(steering.steerQueue).not.toHaveBeenCalled() + expect(steering.sink).not.toHaveBeenCalled() + }) + + it('draft content outranks the queue: accelerated Enter steers the draft only', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) + expect(sink).toHaveBeenCalledWith('插话', 'steer') + expect(steerQueue).not.toHaveBeenCalled() + }) + + it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => { + const { textarea, sink } = bench({ running: true, queue: [row('q-1')] }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(sink).not.toHaveBeenCalled() + }) + it('platform undo/redo chords route to the machine, never the browser stack', () => { const { textarea, shell } = bench({ draft: '' }) fireEvent.change(textarea, { target: { value: 'first' } }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index ebd51f8408..126cb65a47 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -6,8 +6,11 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' import { InputHub } from '../src/client/input/hub.ts' +import { zh } from '../src/client/locales.ts' async function bench() { const runtime = await SlotTestRuntime.create() @@ -21,13 +24,13 @@ async function bench() { }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. - const fiber = runtime.ctx.plugin(ConversationService, { - input: new InputHub(runtime.ctx), - }) + const hub = new InputHub(runtime.ctx, makeTranslate(zh, {})) + const fiber = runtime.ctx.plugin(ConversationService, { input: hub }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder } + const shell = hub.shellFor(runtime.sessions.binding('s1')!) + return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder } } describe('ConversationService', () => { @@ -85,9 +88,74 @@ describe('ConversationService', () => { // No SessionsService at all: a bare context (the runtime always provides one). const bare = new Context() await bare.plugin(ConversationService, { - input: new InputHub(bare), + input: new InputHub(bare, makeTranslate(zh, {})), }).await() const orphan = bare.get('conversation') as ConversationService await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) }) }) + +describe('InputHub queue steering (empty-draft accelerated Enter)', () => { + const row = (id: string): QueuedMessage => ({ + id: id as never, + messageId: `message-${id}` as never, + placement: 'queued', + content: [{ type: 'text', text: id }], + preview: id, + text: id, + }) + + it('steers every queued row in FIFO order and leaves steering rows alone', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')] + }) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.updateQueue).toHaveBeenCalledTimes(2) + }) + expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' }) + expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('converges silently when the turn closes or a row is claimed mid-steer', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('surfaces one notice on a genuine steer failure and stops', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'internal', message: 'broken', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.shell.notices.getSnapshot()).toEqual( + expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }), + ) + }) + expect(b.updateQueue).toHaveBeenCalledTimes(1) + await b.runtime.dispose() + }) + + it('no-ops without queued rows', async () => { + const b = await bench() + b.shell.steerQueue() + expect(b.updateQueue).not.toHaveBeenCalled() + await b.runtime.dispose() + }) +}) From 653e22bc50c752aeda97b42dd05ad42ec98936ca Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 11:55:46 +0800 Subject: [PATCH 2/7] test(web): cover empty-draft queue flush with a keyless replay scenario Also pin the queue-item-not-found convergence arm through the hub and document the repeated-trigger contract on steerQueue. --- .../snapshots/steer-all/mid-steer.expected.md | 36 ++++++ .../snapshots/steer-all/replay.override.json | 47 ++++++++ .../snapshots/steer-all/settled.expected.md | 51 +++++++++ apps/web/tests/steering.e2e.ts | 107 ++++++++++++++++++ .../ui-conversation/src/client/input/hub.ts | 5 +- .../tests/service-orchestration.spec.ts | 13 +++ 6 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/snapshots/steer-all/mid-steer.expected.md create mode 100644 apps/web/tests/snapshots/steer-all/replay.override.json create mode 100644 apps/web/tests/snapshots/steer-all/settled.expected.md diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md new file mode 100644 index 0000000000..3546fe018f --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -0,0 +1,36 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- text: Running +- button "Think": + - img + - img + - text: Think +- status: Deep diving... +- text: "Interjection Interjection: include the word BANANA in your final reply." +- button "Copy": + - img +- text: "Interjection Interjection: include the word ORANGE in your final reply." +- button "Copy": + - img +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/steer-all/replay.override.json b/apps/web/tests/snapshots/steer-all/replay.override.json new file mode 100644 index 0000000000..6a1c133faf --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/replay.override.json @@ -0,0 +1,47 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "reasoning" }, + { "type": "reasoning-delta", "index": 0, "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { + "type": "tool-call-delta", + "index": 1, + "id": "call_00_steer_all", + "name": "ask_user_question", + "argumentsDelta": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "reasoning", + "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." + } + }, + { + "type": "block-end", + "index": 1, + "block": { + "type": "tool-call", + "id": "call_00_steer_all", + "name": "ask_user_question", + "arguments": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" + } + }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "Got it: BANANA and ORANGE." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "Got it: BANANA and ORANGE." } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md new file mode 100644 index 0000000000..aad8000181 --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -0,0 +1,51 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. +- button "Ask question 1/1 answered": + - img + - img + - text: Ask question 1/1 answered +- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: "Available only on the last message of a completed turn Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- paragraph: "Got it: BANANA and ORANGE." +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 6c96f9b6aa..182f892d54 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -34,6 +34,18 @@ const REPLAY_PACE_MS = 100 const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.' const STEER = 'Interjection: include the word BANANA in your final reply.' +// Empty-draft flush scenario: an override-only fixture. The whole-script +// replacement answers both model calls of a FRESH session (no recorded +// session.jsonl exists — call 0 keeps the turn open with a question-tool +// call, call 1 is the reply after both steerings drain). +const STEER_ALL_DIR = fileURLToPath(new URL('./snapshots/steer-all', import.meta.url)) +const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl') +const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json') +const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md') +const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md') +const STEER_ONE = 'Interjection: include the word BANANA in your final reply.' +const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.' + /** Concatenated assistant text deltas — the model-visible reply body. */ function assistantText(events: SessionEvent[]): string { return events @@ -278,3 +290,98 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) }) + +describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + // The scenario boots a fresh session against the override-only fixture; + // the replay.override.json sidecar replaces the derived script, so the + // (deliberately absent) session.jsonl is never read. + scaffold = await launchWebScaffold({ + replayFixture: STEER_ALL_FIXTURE, + replayOverride: STEER_ALL_OVERRIDE, + paceMs: REPLAY_PACE_MS, + }) + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-steer-all')) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled(30_000) + + // Call 0 streams a question-tool call; the fills must land inside the + // first replay window, before the question composer replaces the textarea. + await input.fill(PROMPT) + await input.press('Enter') + await input.fill(STEER_ONE) + await input.press('Enter') + await input.fill(STEER_TWO) + await input.press('Enter') + const dock = page.locator('[data-queue-dock]') + // Both messages queued: the two-row dock shows a collapsed count header, + // and Playwright text matching skips the hidden rows — expand the list, + // then assert each row's content. + await dock.getByText('2 queued messages').waitFor({ timeout: 10_000 }) + await dock.getByRole('button').click() + await dock.getByText(STEER_ONE, { exact: true }).waitFor({ timeout: 10_000 }) + await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.locator('[data-pending-steering]').count()).toBe(0) + + // Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock + // empties, and the pending steering renders at the conversation tail. + await input.press('Meta+Enter') + await expect.poll( + () => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(), + { timeout: 10_000 }, + ).toBe(2) + expect(await page.locator('[data-queue-dock]').count()).toBe(0) + const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE) + + // Answer the question; the step closes, the loop drains both steerings + // into one next-step request, and the final reply obeys both markers. + const composer = page.locator('[data-question-key]') + await composer.waitFor({ timeout: 30_000 }) + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + await settled + + const first = claimedMessages(sessionEvents, STEER_ONE) + const second = claimedMessages(sessionEvents, STEER_TWO) + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(assistantText(sessionEvents)).toContain('BANANA') + expect(assistantText(sessionEvents)).toContain('ORANGE') + await expect.poll(() => page.getByText(STEER_ONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText(STEER_TWO, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await page.locator('[data-pending-steering]').count()).toBe(0) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(STEER_ALL_DIR, [ + 'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md', + ]) + }) +}) diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index e9e58035d2..e0dbb715a1 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -152,7 +152,10 @@ export class InputHub implements InputService { * order — the same strict-steer operation as the queue dock's per-row * button. A turn closing mid-way (`steer-unavailable`) or a row already * claimed by the agent (`queue-item-not-found`) converges silently, while a - * genuine failure surfaces as one composer notice. + * genuine failure surfaces as one composer notice. Repeated triggers + * (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found` + * convergence: the snapshot may still list a row the host already steered, + * and the duplicate strict steer is a silent no-op. * @param session - the addressed host session. * @param shell - the resident shell (notice outlet). */ diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 126cb65a47..c8a7be4dc9 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -125,12 +125,25 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { await b.runtime.sessions.updateSnapshot('s1', (draft) => { draft.queue = [row('q-1'), row('q-2')] }) + // The turn closes before the second row: the flush stops, silently. b.updateQueue.mockResolvedValueOnce({ ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, } as never) b.shell.steerQueue() await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) expect(b.shell.notices.getSnapshot()).toBeNull() + + // A row the host already claimed (e.g. a repeated empty-draft chord): + // the duplicate strict steer is a silent no-op. + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-3')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) }) + expect(b.shell.notices.getSnapshot()).toBeNull() await b.runtime.dispose() }) From 9597ff9bf7a470d368c0b9848a309f01ced19cee Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 7 Aug 2026 11:54:45 +0800 Subject: [PATCH 3/7] feat(web): advertise whole-queue steering shortcut --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 ++-- .../2026-08-06-web-queue-steer-all-gesture.md | 5 +++- ...26-08-06-web-queue-steer-all-gesture.zh.md | 5 +++- .../queue-actions/collapsed.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../queue-actions/layout.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/locales.ts | 2 ++ .../src/client/skeleton/InputBar.tsx | 9 ++++--- .../ui-conversation/tests/input-bar.spec.tsx | 24 +++++++++++++++++++ 13 files changed, 50 insertions(+), 15 deletions(-) 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..0e3cc06a83 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: c4452c16fe01f876aff72715a7d1056a09ae1e1f +2026-08-06-web-queue-steer-all-gesture.zh.md: 27e147304eec0dff8ba6209d4de3f312375fe4df 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..c4452c16fe 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, 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. + ## 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..27e147304e 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 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 +同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 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 cdde5d8790..3dc7f577af 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -17,7 +17,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 8bfd2f964d..935da27c5c 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -33,7 +33,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 48b714a88c..65c455975d 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -26,7 +26,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 974f8a65b0..d447dbba84 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: 31ae990d2d97757a4d7ea06b323e2131f764b5f7 -README.zh.md: 0f21da2f48dbfd6673fb02d6719390bec872afeb +README.md: c3b5470501cbbe08054c177436a211bc02bb077b +README.zh.md: 9bb19a6f04a7ac4aebc8af0bff0014bbdfc0b755 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 31ae990d2d..c3b5470501 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 stays absent because the message has not entered a durable turn. 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 branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, 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 0f21da2f48..9bb19a6f04 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` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 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..9fd3b21b74 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': '发送消息', + 'input.steerQueueShortcut': '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', + 'input.steerQueueShortcut': '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..5303c7b7d9 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 && 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,9 @@ export function InputBar({ data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (disabled ? t('placeholder.unavailable') - : planActive ? t('placeholder.plan') : t('placeholder.default'))} + : canSteerQueue + ? t('input.steerQueueShortcut') + : 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..f8127fa892 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -169,6 +169,30 @@ 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('上层指定提示') + }) + 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' }) From 1e759608f6c7717eab14ffccd39f37be8c8324d4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 13:48:33 +0800 Subject: [PATCH 4/7] test(web): refresh steer-all goldens against merged master UI --- .../tests/snapshots/steer-all/mid-steer.expected.md | 3 --- apps/web/tests/snapshots/steer-all/settled.expected.md | 10 +--------- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 3546fe018f..65f22f2140 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -7,9 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index aad8000181..a61f57572e 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -7,9 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img @@ -25,14 +22,9 @@ - text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: "Available only on the last message of a completed turn Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - paragraph: "Got it: BANANA and ORANGE." - button "Copy": - img diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f5ad20c3fc..0734355828 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: a75f25d8669cd688795842a655106e0e27bb7173 -README.zh.md: f0d744c31020730857d210d75749b907dbffca08 +README.md: 8d5034a84edd56b7c9b411f6e281d1c3b7733a82 +README.zh.md: 309bf54c2b1759c33f21ada33184e2a118b2f00f From 4ede2798fead9f69387a799ec372be1eade4beaf Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 14:20:14 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(web):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?placeholder=20key=20family,=20menu-open=20gate,=20plan=20preced?= =?UTF-8?q?ence,=20note=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 +-- .../2026-08-06-web-queue-steer-all-gesture.md | 2 +- ...26-08-06-web-queue-steer-all-gesture.zh.md | 2 +- .../ui-conversation/src/client/locales.ts | 4 +-- .../src/client/skeleton/InputBar.tsx | 7 +++-- .../ui-conversation/tests/input-bar.spec.tsx | 27 +++++++++++++++++++ 6 files changed, 38 insertions(+), 8 deletions(-) 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 0e3cc06a83..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: c4452c16fe01f876aff72715a7d1056a09ae1e1f -2026-08-06-web-queue-steer-all-gesture.zh.md: 27e147304eec0dff8ba6209d4de3f312375fe4df +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 c4452c16fe..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,7 +14,7 @@ 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, 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. +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 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 27e147304e..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,7 +14,7 @@ Status: implemented 该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 -同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先。 +同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定且不处于瞬态机器锁(adjudicating/submitting)、命令菜单未打开、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先;可用时 steer 提示会刻意优先于 plan 模式 placeholder(该窗口内手势确实可用)。 ## Consequences diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9fd3b21b74..20aab2cfda 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -22,7 +22,7 @@ export const zh = { 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', - 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter 插话发送全部排队消息', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', @@ -163,7 +163,7 @@ export const en = { 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', - 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter steers all queued messages', + '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 5303c7b7d9..e326c8e523 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -89,7 +89,7 @@ export function InputBar({ const disabled = removed || inert || !live const locked = disabled const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' - const canSteerQueue = !locked && !machineBusy && empty && running && subagent === null + 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 @@ -486,8 +486,11 @@ export function InputBar({ data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (disabled ? t('placeholder.unavailable') + // 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('input.steerQueueShortcut') + ? t('placeholder.steerQueue') : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f8127fa892..4ae6626b14 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -191,6 +191,33 @@ describe('Enter semantics', () => { 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', () => { From 0306aefaa9e04318f13a216f659943dfa466bd61 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 11:12:36 +0800 Subject: [PATCH 6/7] test(web): refresh steer-all mid golden against merged master UI --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 65f22f2140..20d799ca79 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -11,11 +11,6 @@ - img - img - text: Context injection @deepseek-ai/dsh-system-prompt -- text: Running -- button "Think": - - img - - img - - text: Think - status: Deep diving... - text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": From 85ebb03c0018bdd4a3c4d577818d9c4ac007f00f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 11:30:33 +0800 Subject: [PATCH 7/7] test(web): wait for the reasoning row before the steer-all mid snapshot The mid golden was racing the reasoning block's stream: captureStableAria could freeze on the pre-render gap between steering acceptance and the assistant step, pinning a snapshot without the Think row. Wait for the [data-variant=think] row so the golden captures the complete assistant step. --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 5 +++++ apps/web/tests/steering.e2e.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 20d799ca79..998ee98129 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -11,6 +11,11 @@ - img - img - text: Context injection @deepseek-ai/dsh-system-prompt +- text: Running +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. - status: Deep diving... - text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 182f892d54..8a09582b56 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -353,6 +353,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { { timeout: 10_000 }, ).toBe(2) expect(await page.locator('[data-queue-dock]').count()).toBe(0) + // The reasoning row streams independently of the steering handoff; wait + // for it so the mid snapshot pins the assistant step, not the pre-render + // gap a fast machine can catch between steering acceptance and the block. + await page.locator('[data-variant="think"]').first().waitFor({ timeout: 10_000 }) const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE)