From 3a9f78c55eba0d27971492c84f085649637d2365 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 15:50:05 +0800 Subject: [PATCH] feat(web): restore actions after steering consumption --- ...-29-addressable-queue-operations.i18n.yaml | 4 +-- ...2026-07-29-addressable-queue-operations.md | 2 +- ...6-07-29-addressable-queue-operations.zh.md | 2 +- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 +-- .../2026-07-30-web-queue-steer-action.md | 10 +++---- .../2026-07-30-web-queue-steer-action.zh.md | 10 +++---- .../snapshots/steering/mid-steer.expected.md | 2 ++ .../snapshots/steering/settled.expected.md | 6 +++- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageIconActions.tsx | 26 +++++++++-------- .../src/client/chat/MessageItem.tsx | 28 ++++++++++++++----- .../tests/chat-branch-tails.spec.tsx | 16 +++++++++-- .../ui-conversation/tests/chat-view.spec.tsx | 15 ++++++++++ 15 files changed, 91 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index 217bcd32db..75c93214c7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.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-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: e5fbacfa7de244daa1c5f8e504ee98a4a7730393 -2026-07-29-addressable-queue-operations.zh.md: a532d18d1b29fcebc7e9d8222da9eb5a162b8ec6 +2026-07-29-addressable-queue-operations.md: 04bcaa26be2f4f70c4003f6cc2fce4798499d205 +2026-07-29-addressable-queue-operations.zh.md: a16bf8667294de8d08da54ddb0d317bf9938e4eb diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index e5fbacfa7d..04bcaa26be 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -18,7 +18,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Queue addresses require a live ordinary-session Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A session-backed subagent returns `agent-busy` before inbox access and retains its continuation owner; for ordinary sessions, a missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. -**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit, delete, and a running-only strict-steer action. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence, while strict steer preserves every content block and retires the row only through the authoritative snapshot. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. The [Web Queue steer action](2026-07-30-web-queue-steer-action.md) owns the strict transfer contract. +**Web actions address Queue only.** The placement-aware `session/queue` snapshot carries both queued and pending-steering occurrences; QueueDock selects only queued items, while ChatView projects steering and retains the existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit, delete, and a running-only strict-steer action. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence, while strict steer preserves every content block and retires the row only through the authoritative snapshot. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. The [Web Queue steer action](2026-07-30-web-queue-steer-action.md) owns the strict transfer and pending-projection contract. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index a532d18d1b..a16bf86672 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -18,7 +18,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **Queue 寻址要求普通会话的 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。由会话支撑的 subagent 会在访问 inbox 前返回 `agent-busy`,并保留其继续执行 owner;对于普通会话,Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 -**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑、删除以及仅在运行期间可用的严格 steering 操作。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项;严格 steering 会保留每个内容块,并且只通过权威快照退役该行。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。[Web Queue steering 操作](2026-07-30-web-queue-steer-action.md)负责定义严格转移契约。 +**Web 操作只面向 Queue。** 带 placement 的 `session/queue` 会同时携带 queued 和待处理 steering;QueueDock 只选择 queued 项,ChatView 则投影 steering,并在消费后沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑、删除以及仅在运行期间可用的严格 steering 操作。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项;严格 steering 会保留每个内容块,并且只通过权威快照退役该行。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。[Web Queue steering 操作](2026-07-30-web-queue-steer-action.md)负责定义严格转移与待处理投影契约。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index 1220cc39fa..555e2fb434 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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-07-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: b84ee5acf472ba8482f6dfb7f466a905ddfd8f4f -2026-07-30-web-queue-steer-action.zh.md: fd4b4f8f8ab18db8a5372ff1a419fa186e29f399 +2026-07-30-web-queue-steer-action.md: 78d8c4d50daa4d162ff17c1efc03c32face8019a +2026-07-30-web-queue-steer-action.zh.md: 6b8af563ce527b8122b9f5e783a6ef700dcdd401 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index b84ee5acf4..78d8c4d50d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -6,7 +6,7 @@ English | [中文](2026-07-30-web-queue-steer-action.zh.md) ## Problem -The Web composer originally queued every Enter submission while an agent ran. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events as user-style bubbles without message actions, but Web had neither an action connecting those two surfaces nor a direct composer gesture for choosing current-turn steering. +The Web composer originally queued every Enter submission while an agent ran. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events as user-style bubbles, but Web had neither an action connecting those two surfaces nor a direct composer gesture for choosing current-turn steering. Implementing the row action as a client-side delete followed by `session.prompt(mode: 'steer')` would split one user intent across two RPCs. Driver claim could win between them, the steer could fail after deletion, or the existing best-effort `agent.steer()` fallback could silently append a new Queue item after the original occurrence was removed. A send-now action must therefore distinguish current-turn steering from Queue promotion and preserve the original row when steering is no longer possible. @@ -16,7 +16,7 @@ Implementing the row action as a client-side delete followed by `session.prompt( Each non-editing QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. Edit and delete keep their existing behavior. -Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot. When AgentLoop drains it, the existing durable `steering/message` event renders the same user-style bubble without a separate durable presentation path. +Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot and immediately projects the same pending steering after the `Deep diving...` running-status row; that bubble offers Copy but no Fork because the message has no durable event sequence yet. Once AgentLoop drains it, the existing durable `steering/message` event takes over the same user-style bubble and restores its clock, Copy, and Fork without a separate durable presentation path. The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error; if the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error. The UI reports either race without optimistically removing the row. @@ -34,9 +34,9 @@ The action does not run `agent/prompt-submit`: choosing steering intentionally c `session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one synchronous Agent operation; the Host never reconstructs it by combining remove and prompt calls. -The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail without edit or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. +The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. -When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending `steering/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. ChatView matches the shared `MessageId` and suppresses the transient projection as soon as the durable node exists, so one bubble changes authority without a visible gap or duplicate; an append failure still retires the claimed row. +When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending `steering/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. ChatView matches the shared `MessageId` and suppresses the transient projection as soon as the durable node exists, so one bubble changes authority without a visible gap or duplicate; the durable projection restores the clock, Copy, and Fork against its logged event time and sequence, while an append failure still retires the claimed row. The existing `session.prompt(mode: 'steer')` contract remains best-effort for new input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because failure can safely leave its already-pending message untouched. @@ -44,7 +44,7 @@ The existing `session.prompt(mode: 'steer')` contract remains best-effort for ne AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value, drains as `steering/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. -Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, authoritative success retirement, and filtering of steering occurrences. ChatView tests cover the transient bubble and its single-copy handoff to the durable node. +Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, authoritative success retirement, and filtering of steering occurrences. ChatView tests and Web ARIA snapshots cover pending steering after the running-status row with Copy alone, plus its single-copy handoff to a durable node with clock, Copy, and Fork. The keyless Web steering scenario queues a message through the real composer while the first response streams, activates the row arrow, then uses `ask_user_question` as a stable pending-steering barrier. It proves the Host-backed pending bubble appears before admission, hands off to one durable interjection after the answer, and affects the next model request. Assembled composer scenarios prove default-mode Cmd+Enter reaches the same pending and durable path without creating a Queue row, while Steer-mode Cmd+Enter creates a Queue row instead. Settings and submission-policy coverage pin the default, persistence, busy-only scope, and complementary gesture mapping; Queue edit/delete scenarios continue to prove those actions are unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index fd4b4f8f8a..6b8af563ce 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 `steering/message` 事件渲染为不带消息操作的用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。 +Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 `steering/message` 事件渲染为用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。 如果 Web 先在客户端删除该行,再调用 `session.prompt(mode: 'steer')`,就会把用户的一次意图拆分到两个 RPC 中。驱动器可能在两次调用之间先认领该项,steering 投递也可能在删除后失败;现有尽力而为的 `agent.steer()` 回退还可能在原单次入队项被移除后,静默追加一个新的 Queue 项。因此,立即发送操作必须区分当前轮次 steering 与 Queue 前移,并在 steering 已不可用时保留原行。 @@ -16,7 +16,7 @@ Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。编辑和删除保持现有行为。 -触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行。AgentLoop 排空该项时,现有持久 `steering/message` 事件会渲染相同的用户样式气泡,无需另建持久展示路径。 +触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行,并在 `Deep diving...` 运行状态行之后立即投影同一条待处理 steering;该气泡提供复制,但消息尚无持久事件序号,因此不提供 fork。AgentLoop 排空该项后,现有持久 `steering/message` 事件会接管同一个用户样式气泡,并恢复时钟、复制和 fork,无需另建持久展示路径。 running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变,并返回类型化的 `steer-unavailable` 错误;如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误。UI 会报告任一竞态,不会乐观地移除该行。 @@ -34,9 +34,9 @@ Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲 `session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次同步 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。 -Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾渲染待处理 steering,且不提供编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。 +Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。 -AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。ChatView 会匹配两边共享的 `MessageId`,并在持久节点出现时立即抑制瞬态投影,因此同一个气泡切换权威时不会产生可见空档或重复;如果追加失败,已认领行仍会退役。 +AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。ChatView 会匹配两边共享的 `MessageId`,并在持久节点出现时立即抑制瞬态投影,因此同一个气泡切换权威时不会产生可见空档或重复;持久投影会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作,而追加失败时已认领行仍会退役。 现有 `session.prompt(mode: 'steer')` 对新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为失败时可以安全地保留其已经待处理的消息。 @@ -44,7 +44,7 @@ AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值、以 `steering/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 -Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行、成功后由权威快照退役,以及过滤 steering 单次入队项。ChatView 测试覆盖瞬态气泡及其只保留一份的持久节点交接。 +Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行、成功后由权威快照退役,以及过滤 steering 单次入队项。ChatView 测试与 Web ARIA 快照覆盖待处理阶段位于运行状态行之后且仅有复制,并覆盖它向带时钟、复制和 fork 的持久节点进行单份交接。 无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。该场景证明 Host 支撑的待处理气泡会在准入前出现,在回答后交接为唯一一条持久插话,并影响下一次模型请求。组装后的 composer 场景证明默认模式下的 Cmd+Enter 无需创建 Queue 行,也会进入同一条待处理与持久路径;Steer 模式下的 Cmd+Enter 则会创建 Queue 行。Settings 与提交策略覆盖会固定默认值、持久化、仅限繁忙态的作用域和互补手势映射;Queue 编辑/删除场景继续证明这些操作没有变化。 diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 9f077b21a5..61f48ffbab 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -24,6 +24,8 @@ - text: Ask question waiting - status: Deep diving... - text: "Interjection: include the word BANANA in your final reply." +- button "Copy": + - img - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 72e28598e5..be28e218d3 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -21,7 +21,11 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation": + - img - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f3f3c43124..fcf4c0b84e 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: c80ca786fb289055fcb3ad2b5a7aab64127ca485 -README.zh.md: 601139a00d2d40fb34e0cdcb8554984f7db9a724 +README.md: 61056fc36e2e7f5f85726e158b9fd27e06c21941 +README.zh.md: 3a1a74a4106321415ee343908c999a2cf41615a0 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c80ca786fb..61056fc36e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,7 +36,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; an unavailable steering window leaves the Queue occurrence in place and reports the failure. -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 at the conversation tail. The Host delays steering retirement until the durable `steering/message` has entered the mux stream, and ChatView deduplicates the two projections by their shared `MessageId`; the bubble therefore hands off without a gap or duplicate, while reconnect restores pending state from the same authority. +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; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream, and ChatView deduplicates the two projections by their shared `MessageId`; the bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While 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. The preference affects only the 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 601139a00d..3a1a74a410 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,7 +36,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;steering 窗口不可用时,Queue 单次入队项会留在原处并显示失败。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾的用户样式气泡。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering,ChatView 则按两份投影共享的 `MessageId` 去重;气泡交接时因而不会产生空档或重复,重连也能从同一权威恢复待处理状态。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering,ChatView 则按两份投影共享的 `MessageId` 去重;气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。该偏好只影响繁忙态下这对手势,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index cd6b0a36ce..91ba066544 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,5 +1,5 @@ -// Shared IconActions chrome for user and assistant messages: copy live, -// branch wired through onBranch, date-aware clock. +// Shared IconActions chrome for user, steering, and assistant messages: copy +// live, optional branch wiring, and an optional date-aware clock. import { useCallback } from 'react' import { @@ -13,12 +13,14 @@ import css from './MessageIconActions.module.css' export interface MessageIconActionsProps { /** Plain text the copy action writes. */ text: string - /** Unix epoch ms for the clock label. */ - time: number + /** Unix epoch ms for the clock label; omitted for transient messages. */ + time?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' /** Fork the session at this message. */ onBranch?: (() => void) | undefined + /** Whether to render the branch action; defaults to true. */ + showBranch?: boolean | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -31,13 +33,13 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, className, t, + text, time, clock, onBranch, showBranch = true, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) - const clockEl = ( + const clockEl = time === undefined ? null : ( {formatMessageClock(time, t, day)} @@ -50,11 +52,13 @@ export function MessageIconActions({ - - - + {showBranch && ( + + + + )} {clock === 'end' ? clockEl : null} ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 5783d0211d..6e542bf468 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,6 +1,6 @@ -// MessageItem: simple chat nodes — user bubble (right-aligned, with -// clock + copy / branch IconActions), steering (same bubble, no actions), -// context injection, compaction marker, retry disclosure, and +// MessageItem: simple chat nodes — user and consumed-steering bubbles +// (right-aligned, with clock + copy / branch IconActions), pending steering +// (copy only), context injection, compaction marker, retry disclosure, and // unknown-surface JSON rows. import { memo, useEffect, useMemo, useState } from 'react' @@ -167,7 +167,7 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -/** Right-aligned bubble shared by user and steering rows (steering has no actions). */ +/** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ content, actions, pending = false, t, }: { @@ -201,7 +201,22 @@ export function PendingSteeringBubble({ content, t }: { content: readonly unknown[] t: ChatViewSlotProps['t'] }): ReactNode { - return + return ( + ( + + )} + /> + ) } export const MessageItem = memo(function MessageItem({ @@ -210,6 +225,7 @@ export const MessageItem = memo(function MessageItem({ const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': + case 'steering': return ( ) - case 'steering': - return case 'context': return ( diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index a829f6f04a..4074996063 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -102,18 +102,28 @@ describe('MessageItem arms', () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) - it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => { + it('consumed steering renders copy and branch actions without a badge', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + const fork = vi.fn() const view = render( , ) expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() - expect(view.queryByRole('button', { name: '复制' })).toBeNull() + fireEvent.click(view.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('steer!') + fireEvent.click(view.getByRole('button', { name: '在新对话中分支' })) + expect(fork).toHaveBeenCalledWith(2) }) it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 507b301922..b0e55736a4 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -252,6 +252,11 @@ describe('ChatView', () => { }) it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) const pending = { id: 'steer-occurrence' as never, messageId: 'steer-message' as never, @@ -273,6 +278,11 @@ describe('ChatView', () => { expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull() expect(view.queryByText('later')).toBeNull() + const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') + expect(pendingBubble).not.toBeNull() + fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('interrupt now') + expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) @@ -291,6 +301,11 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) + const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(branchButtons).toHaveLength(2) + fireEvent.click(branchButtons[1]!) + expect(h.forkAt).toHaveBeenCalledWith(2) act(() => { h.set({ queue: [queued] }) }) expect(view.getAllByText('interrupt now')).toHaveLength(1)