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 780858e1d8..1220cc39fa 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: 4a8a04c056748d5c607c5ec868de4f2dd8342f2c -2026-07-30-web-queue-steer-action.zh.md: 92225f3a61e24b9eb687c50400aa6fe5e6ff5623 +2026-07-30-web-queue-steer-action.md: b84ee5acf472ba8482f6dfb7f466a905ddfd8f4f +2026-07-30-web-queue-steer-action.zh.md: fd4b4f8f8ab18db8a5372ff1a419fa186e29f399 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 4a8a04c056..b84ee5acf4 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 deliberately queues Enter submissions while an agent runs. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events with an interjection badge, but Web has no action connecting those two surfaces. +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. 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. @@ -14,12 +14,14 @@ Implementing the row action as a client-side delete followed by `session.prompt( ### Product contract -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, and the composer continues to submit Enter as Queue. +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 and transcript badge render the message without a new chat presentation path. +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. 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. +The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While it is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. The browser persists that preference, and it affects only the busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. + ### Agent and lifecycle boundary `InboxAction` gains a consumer-backed `{ kind: 'steer' }` operation alongside edit and remove. `Agent.updateInbox()` handles it only after locating the queued occurrence and proving `acceptsNextStep`; it never delegates to the best-effort `agent.steer()` alias. @@ -32,17 +34,19 @@ 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 transient `session/queue` projection remains Queue-only. It ignores the new pending steering occurrence and removes the old row when its discard arrives. Pending steering does not gain edit, delete, or reconnect presentation in this cut. A later dedicated pending-steering projection may add that observability without widening Queue mutation semantics. +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 existing `session.prompt(mode: 'steer')` contract remains best-effort for new input: outside the next-step window it may become a waking follow-up. Only the Queue row action is strict, because failure can safely leave its already-pending message untouched. +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. + +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. ### Verification 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, authoritative Queue snapshots, and the absence of pending steering from reconnect snapshots. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, and authoritative success retirement. +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. -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. After the answer, it proves one badged interjection becomes durable and the next model request obeys it. Queue edit/delete scenarios continue to prove those actions are unchanged. +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. ## Alternatives considered @@ -50,18 +54,18 @@ The keyless Web steering scenario queues a message through the real composer whi **Restore Queue promotion under the upward arrow.** Rejected because moving an item to the front still creates an independent admitted turn. The control promises current-turn steering, not priority within Queue. -**Use the existing best-effort `agent.steer()` behavior.** Rejected for this action because a closed next-step window would silently turn the selected row back into queued work, possibly at a different position and identity. Strict failure preserves the original occurrence and makes the semantic race visible. +**Use the existing best-effort `agent.steer()` behavior for the Queue row.** Rejected for that action because a closed next-step window would silently turn the selected row back into queued work, possibly at a different position and identity. Strict failure preserves the original occurrence and makes the semantic race visible. Newly typed composer input has no existing Queue occurrence to preserve, so it intentionally uses the best-effort behavior. **Change `agent.steer()` to be strict for every caller.** Rejected because TUI and plugin callers use its safe follow-up fallback for newly submitted input. A queued row has recoverable state that those callers do not. **Preserve the same `InboxItemId` while changing placement.** Rejected because `InboxItemId` identifies one FIFO acceptance and `placement` records that acceptance's resolved delivery. Ending one queued occurrence and accepting one steering occurrence keeps lifecycle facts truthful and leaves the conservation invariant unchanged. -**Expose pending steering in `session/queue`.** Deferred because the existing product design provides no pending-steering row state or operations. Authoritative Queue retirement plus the durable consumed bubble is sufficient for the first interaction cut; reconnect visibility can be added through a dedicated projection if product testing shows the gap matters. +**Add a dedicated pending-steering projection and client store.** Rejected because queued and steering occurrences already share one Agent inbox lifecycle and one Host mirror. A second projection would duplicate reconnect state and ordering authority; a placement tag lets each client surface select its rows without widening Queue mutation semantics. **Cancel the active turn and run the selected Queue item.** Rejected because it destroys unrelated in-flight work and starts a new turn rather than steering the current one. ## Consequences -A successful action can be pending but absent from the Web after its Queue row retires and before `steering/message` commits; a refresh during that interval has no pending-steering indication. The running bit can also remain true briefly after the strict next-step window closes, so the button may be enabled for an operation that correctly returns `steer-unavailable`. +`session/queue` describes a placement-aware transient inbox snapshot rather than a Queue-only list, so every consumer must filter by placement. Pending steering survives reconnect and appears immediately, but remains non-durable until `steering/message` commits. The running bit can also remain true briefly after the strict next-step window closes, so the button may be enabled for an operation that correctly returns `steer-unavailable`. The explicit action changes delivery from an independently admitted turn to current-turn steering, so prompt-admission plugins do not process the converted message. Enqueue-before-discard lifecycle publication remains required for re-entrant cancellation safety; focused regression coverage protects that ordering. 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 92225f3a61..fd4b4f8f8a 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 没有连接这两个界面的操作。 +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 已不可用时保留原行。 @@ -14,12 +14,14 @@ Web composer 会在 agent 运行期间有意把 Enter 提交作为 Queue 入队 ### 产品契约 -每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。编辑和删除保持现有行为,composer 也继续把 Enter 提交为 Queue。 +每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。编辑和删除保持现有行为。 -触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行。AgentLoop 排空该项时,现有持久 `steering/message` 事件与 transcript 插话徽标会渲染这条消息,无需新增聊天展示路径。 +触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行。AgentLoop 排空该项时,现有持久 `steering/message` 事件会渲染相同的用户样式气泡,无需另建持久展示路径。 running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变,并返回类型化的 `steer-unavailable` 错误;如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误。UI 会报告任一竞态,不会乐观地移除该行。 +Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。浏览器会持久化该偏好,并且它只影响繁忙态下这对手势。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 + ### Agent 与生命周期边界 `InboxAction` 会在编辑和移除之外,新增由实际消费方支撑的 `{ kind: 'steer' }` 操作。`Agent.updateInbox()` 只有在找到 queued 单次入队项并确认 `acceptsNextStep` 后才会处理该操作,绝不会委托给尽力而为的 `agent.steer()` 别名。 @@ -32,17 +34,19 @@ running 标志位只用于提示交互状态。在同步变更边界上,AgentL `session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次同步 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。 -Host 的瞬态 `session/queue` 投影仍然只包含 Queue。它会忽略新的待处理 steering 单次入队项,并在收到旧项的 discard 时移除原行。本阶段不会为待处理 steering 增加编辑、删除或重连展示。未来可以用专用的待处理 steering 投影补充这种可观测性,而无需扩大 Queue 变更语义。 +Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾渲染待处理 steering,且不提供编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。 -现有 `session.prompt(mode: 'steer')` 对新输入仍采用尽力而为的契约:在 next-step 窗口之外,它可能变为会唤醒 agent 的后续轮次。只有 Queue 行操作采用严格语义,因为失败时可以安全地保留其已经待处理的消息。 +AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。ChatView 会匹配两边共享的 `MessageId`,并在持久节点出现时立即抑制瞬态投影,因此同一个气泡切换权威时不会产生可见空档或重复;如果追加失败,已认领行仍会退役。 + +现有 `session.prompt(mode: 'steer')` 对新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为失败时可以安全地保留其已经待处理的消息。 ### 验证 AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值、以 `steering/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 -Host schema 和代理测试覆盖新操作、两种类型化错误、权威 Queue 快照,以及重连快照不包含待处理 steering。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行,以及成功后由权威快照退役。 +Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行、成功后由权威快照退役,以及过滤 steering 单次入队项。ChatView 测试覆盖瞬态气泡及其只保留一份的持久节点交接。 -无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。回答问题后,该场景证明一条带徽标的插话成为持久记录,并且下一次模型请求遵循它。Queue 编辑/删除场景继续证明这些操作没有变化。 +无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。该场景证明 Host 支撑的待处理气泡会在准入前出现,在回答后交接为唯一一条持久插话,并影响下一次模型请求。组装后的 composer 场景证明默认模式下的 Cmd+Enter 无需创建 Queue 行,也会进入同一条待处理与持久路径;Steer 模式下的 Cmd+Enter 则会创建 Queue 行。Settings 与提交策略覆盖会固定默认值、持久化、仅限繁忙态的作用域和互补手势映射;Queue 编辑/删除场景继续证明这些操作没有变化。 ## 考虑过的替代方案 @@ -50,18 +54,18 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、权威 Queu **恢复向上箭头对应的 Queue 前移操作。** 不予采纳,因为把某个项移到队首仍然会创建一个独立接纳的轮次。该控件承诺的是当前轮次 steering,而不是 Queue 内的优先级。 -**使用现有尽力而为的 `agent.steer()` 行为。** 不予采纳,因为关闭的 next-step 窗口会静默地把选中行重新变成 queued 工作,而且位置和标识可能不同。严格失败会保留原单次入队项,并让这项语义竞态明确可见。 +**为 Queue 行使用现有尽力而为的 `agent.steer()` 行为。** 不予采纳,因为关闭的 next-step 窗口会静默地把选中行重新变成 queued 工作,而且位置和标识可能不同。严格失败会保留原单次入队项,并让这项语义竞态明确可见。新输入的 composer 消息没有需要保留的现有 Queue 单次入队项,因此有意采用尽力而为行为。 **让每个调用方使用的 `agent.steer()` 都采用严格语义。** 不予采纳,因为 TUI 和插件调用方会针对新提交的输入使用其安全的后续轮次回退。queued 行具有这些调用方不具备的可恢复状态。 **改变投递方式时保留同一个 `InboxItemId`。** 不予采纳,因为 `InboxItemId` 标识一次 FIFO 接受,而 `placement` 记录该次接受解析出的投递方式。结束一个 queued 单次入队项并接受一个 steering 单次入队项,能够使生命周期事实保持如实,并让守恒不变量保持不变。 -**在 `session/queue` 中暴露待处理 steering。** 暂缓,因为现有产品设计没有为待处理 steering 提供行状态或操作。权威的 Queue 退役加上持久的已消费气泡,足以支撑首个交互阶段;如果产品测试表明这一缺口影响显著,可以通过专用投影增加重连可见性。 +**增加专用的待处理 steering 投影和客户端 store。** 不予采纳,因为 queued 与 steering 单次入队项已经共享同一套 Agent inbox 生命周期和 Host mirror。第二份投影会重复保存重连状态与顺序权威;placement 标签能让各客户端界面选取自己的行,而不扩大 Queue 变更语义。 **取消活动轮次并运行选中的 Queue 项。** 不予采纳,因为这会破坏无关的进行中工作,并且会启动新轮次,而不是 steering 当前轮次。 ## 后果 -操作成功后,从 Queue 行退役到 `steering/message` 提交之间,对应消息可能仍处于待处理状态,却不会出现在 Web 中;如果在此期间刷新,界面不会显示待处理 steering。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此按钮可能会为一个最终正确返回 `steer-unavailable` 的操作保持启用。 +`session/queue` 表示带 placement 的瞬态 inbox 快照,而不只是 Queue 列表,因此每个消费方都必须按 placement 过滤。待处理 steering 会在界面中立即出现并能在重连后恢复,但在 `steering/message` 提交前仍不持久。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此按钮可能会为一个最终正确返回 `steer-unavailable` 的操作保持启用。 这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering,因此提示词接纳插件不会处理转换后的消息。为保证可重入取消安全,生命周期事件仍必须先发布 enqueue 再发布 discard;有针对性的回归覆盖会保护这一顺序。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 7bdfcc1d59..19e1b33538 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -2,8 +2,9 @@ // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme // -> theme/change -> ui-layout's presenter -> body attribute -> alias token) -// and the Language row (settings-scoped localization + persisted dsh.locale), -// plus Permission as the persisted default for subsequently created sessions. +// the Language row (settings-scoped localization + persisted dsh.locale), +// the busy-state Enter preference, plus Permission as the persisted default +// for subsequently created sessions. // Zero model calls: everything is pure client + persistence state on a blank // frame, so there is no fixture and a stray stream would fail loud on the // open llm seam. @@ -182,6 +183,32 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('persists the busy-state Enter behavior across reload and restores Queue', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior')) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '排队发送' }).click() + await page.getByRole('menuitem', { name: '插话发送' }).click() + await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer') + await page.keyboard.press('Escape') + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByRole('button', { name: '设置', exact: true }).click() + const reloaded = page.getByRole('dialog', { name: '设置' }) + await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + await reloaded.getByRole('button', { name: '插话发送' }).click() + await page.getByRole('menuitem', { name: '排队发送' }).click() + await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 }) + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue') + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('switches the settings surface language and persists dsh.locale', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language')) await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 3f6ebab8a2..bfff0cd9f0 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -23,7 +23,7 @@ - img - button "Remove queued message": - img - - button "插话发送": + - button "Steer queued message": - img - listitem: - textbox "Edit queued message": Edited queue item diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 0a43cee68b..fe6f5b6087 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -33,6 +33,8 @@ - img - button "Remove queued message": - img + - button "Steer queued message": + - img - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 118e4ff3e1..cb693b62a8 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -28,3 +28,7 @@ - button "跟随系统" [pressed]: - img - text: 跟随系统 + - text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为 + - button "排队发送": + - text: 排队发送 + - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 27b40ef442..9f077b21a5 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -23,6 +23,7 @@ - img - text: Ask question waiting - status: Deep diving... +- text: "Interjection: include the word BANANA in your final reply." - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index bfa8aae162..794a92b74b 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -1,7 +1,7 @@ -// Web e2e scenario: queue a message while the first response streams, strictly -// transfer that exact occurrence to steering through QueueDock, then prove it -// is logged, rendered, and obeyed. The following question tool supplies a -// deterministic pending-steering snapshot before the step can drain. +// Web e2e scenarios for both steering entry points: QueueDock strictly +// transfers one queued occurrence, while the complementary composer gestures +// choose Queue or Steer. The question tool supplies a deterministic pending- +// steering snapshot before the step can drain. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -18,13 +18,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// Two goldens for the two distinct states this interaction produces: the -// mid-turn moment (steer ACCEPTED but deliberately invisible — the loop -// drains steering at the step boundary, so no steering text exists while -// the question still blocks the step) and the settled transcript (plain -// bubble in place, final reply obeying it). The pair pins the timing -// semantics visually: if the client ever starts rendering pending steers -// eagerly, the mid-steer golden flips first. +// Two goldens pin the transient Host projection and its durable handoff: the +// mid-turn state renders accepted steering from session/queue while the +// question blocks admission, then the settled state renders the same message +// from steering/message beside the reply that obeys it. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() @@ -90,18 +87,17 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { const steerButton = queuedRow.getByRole('button', { name: 'Steer queued message' }) await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) await steerButton.click({ timeout: 10_000 }) - await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER }) + await pendingSteering.waitFor({ timeout: 10_000 }) - // The blocked composer is the mid-turn barrier: the tool cannot finish - // this step, so the accepted steering remains pending and invisible. + // The blocked composer keeps steering pending long enough to observe the + // Host-authoritative mirror before the loop admits it durably. const composer = page.locator('[data-question-key]') await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) if (MODE !== 'record') { - // Mid-turn golden: the converted steer is pending in the loop but the - // loop drains steering only at the step boundary, so no Queue row or - // steering/message bubble renders while the question still blocks. - expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) + expect(await page.getByText(STEER, { exact: true }).count()).toBe(1) + expect(await pendingSteering.count()).toBe(1) expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) @@ -137,6 +133,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // Visible: the plain steering bubble plus the reply that obeys it // (steer text + final reply each contain the marker word). await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await pendingSteering.count()).toBe(0) await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) expect(await page.locator('[data-question-key]').count()).toBe(0) // Settled golden: steer text between the question round trip and the @@ -151,3 +148,120 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md']) }) }) + +describe('web e2e: composer shortcut steers directly', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 100 }) + 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')('uses Cmd+Enter without creating a Queue row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering')) + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled(30_000) + await input.fill(PROMPT) + await input.press('Enter') + await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 }) + + await input.fill(STEER) + await input.press('Meta+Enter') + await expect.poll(() => input.inputValue(), { timeout: 5_000 }).toBe('') + expect(await page.locator('[data-queue-dock]').count()).toBe(0) + + const composer = page.locator('[data-question-key]') + await composer.waitFor({ timeout: 30_000 }) + const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER }) + await pendingSteering.waitFor({ timeout: 10_000 }) + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + await settled + + const steerEvents = sessionEvents.filter(event => event.type === 'steering/message') + expect(steerEvents).toHaveLength(1) + expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1) + await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await pendingSteering.count()).toBe(0) + await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(2) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) + +describe('web e2e: composer shortcut follows the swapped busy behavior', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 100 }) + 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 Cmd+Enter when plain Enter is configured to Steer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-swapped-shortcut')) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Queue' }).click() + await page.getByRole('menuitem', { name: 'Steer' }).click() + await dialog.getByRole('button', { name: 'Steer' }).waitFor({ timeout: 10_000 }) + await page.keyboard.press('Escape') + + const input = page.locator('textarea').first() + const settled = scaffold.whenTurnSettled(30_000) + await input.fill(PROMPT) + await input.press('Enter') + await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 }) + + const queuedText = 'Queued by the complementary Cmd+Enter shortcut.' + await input.fill(queuedText) + await input.press('Meta+Enter') + const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText }) + await queuedRow.getByText(queuedText, { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.locator('[data-pending-steering]').filter({ hasText: queuedText }).count()).toBe(0) + expect(sessionEvents.filter(event => event.type === 'steering/message')).toHaveLength(0) + + // Remove the asserted Queue row, then finish the recorded question turn + // so replay teardown still proves that every fixture call was consumed. + await queuedRow.getByRole('button', { name: 'Remove queued message' }).click() + 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 + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index e7f9922425..c4bc6ed9b5 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -146,7 +146,8 @@ function materializeNode( } case 'steering/message': return { - kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + kind: 'steering', messageId: event.data.message.id, + seq: event.seq, time: event.time, turn: event.data.turn, content: event.data.message.content, source: event.data.message.source, } case 'tool/result': { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index a4c260a068..31872356c8 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,6 +4,7 @@ // string here (narrow to real brands when convenient). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' @@ -104,6 +105,8 @@ export interface AssistantMessageNode { /** A steering message injected mid-turn. */ export interface SteeringMessageNode { kind: 'steering' + /** Stable identity shared with its pre-admission inbox occurrence. */ + messageId: MessageId seq: number /** Unix epoch ms from the source session event. */ time: number @@ -271,9 +274,15 @@ export interface RunningToolCall { } -/** One independently addressable row from the transient queue snapshot. */ +/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */ export interface QueuedMessage { readonly id: InboxItemId + /** Stable message identity used for transient-to-durable steering handoff. */ + readonly messageId: MessageId + /** Agent-resolved placement; only queued rows accept queue mutations. */ + readonly placement: 'queued' | 'steering' + /** Complete content used to render pending steering before it becomes durable. */ + readonly content: readonly ContentBlock[] readonly preview: string /** Complete editable text; null when the message contains non-text blocks. */ readonly text: string | null @@ -332,7 +341,7 @@ export interface ConversationSnapshot { */ codeDispatches: ReadonlyMap pending: readonly PendingInteraction[] - /** Authoritative transient inbox snapshot, replaced after every host-side change. */ + /** Authoritative transient inbox snapshot, including queued and steering placements. */ queue: readonly QueuedMessage[] running: boolean /** diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 2bcb2ac5fc..490693a6a4 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -446,6 +446,9 @@ export class Session implements SessionFace { case 'session/queue': { this.queued = frame.items.map(item => ({ id: item.id, + messageId: item.message.id, + placement: item.placement, + content: item.message.content, preview: queuePreviewOf(item.message.content), text: queueTextOf(item.message.content), })) diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 6fae44ede4..05ac3cb067 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -74,7 +74,8 @@ function materializeNode( } case 'steering/message': return { - kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + kind: 'steering', messageId: event.data.message.id, + seq: event.seq, time: event.time, turn: event.data.turn, content: event.data.message.content, source: event.data.message.source, } case 'tool/result': { diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index b594e9a9df..cc2f3d0e53 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -22,6 +22,7 @@ interface QueueFixture { id: string body: string content?: ContentBlock[] + placement?: 'queued' | 'steering' } /** Build one authoritative queue snapshot. */ @@ -31,6 +32,7 @@ function queueFrame(items: QueueFixture[]): MuxFrame { sessionId: SID, items: items.map(item => ({ id: iid(item.id), + placement: item.placement ?? 'queued', message: createUserMessage({ content: item.content ?? text(item.body), source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, @@ -49,8 +51,14 @@ describe('queue snapshot intake', () => { session.handleMuxEnvelope(rid('env-1'), queueFrame([ { id: 'q-1', body: '第一条 排队\n消息' }, ])) - expect(session.getSnapshot().queue).toEqual([ - { id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' }, + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-1', placement: 'queued', + content: [{ type: 'text', text: '第一条 排队\n消息' }], + preview: '第一条 排队 消息', text: '第一条 排队\n消息', + }, ]) }) @@ -61,8 +69,14 @@ describe('queue snapshot intake', () => { body: '', content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], }])) - expect(session.getSnapshot().queue).toEqual([ - { id: 'q-image', preview: 'hi [image]', text: null }, + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-image', placement: 'queued', + content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }], + preview: 'hi [image]', text: null, + }, ]) }) @@ -85,8 +99,14 @@ describe('queue snapshot intake', () => { session.handleMuxEnvelope(rid('env-5'), queueFrame([ { id: 'q-2', body: 'two edited' }, ])) - expect(session.getSnapshot().queue).toEqual([ - { id: 'q-2', preview: 'two edited', text: 'two edited' }, + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-2', placement: 'queued', + content: [{ type: 'text', text: 'two edited' }], + preview: 'two edited', text: 'two edited', + }, ]) session.handleMuxEnvelope(rid('env-6'), queueFrame([])) expect(session.getSnapshot().queue).toEqual([]) @@ -99,6 +119,21 @@ describe('queue snapshot intake', () => { session.handleAgentError('unrelated') expect(session.getSnapshot().queue).toBe(before) }) + + it('retains steering placement and complete content in the same authoritative snapshot', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('env-steering'), queueFrame([ + { id: 'q-next', body: 'later' }, + { id: 's-now', body: 'interrupt now', placement: 'steering' }, + ])) + + expect(session.getSnapshot().queue.map(item => ({ + id: item.id, placement: item.placement, content: item.content, + }))).toEqual([ + { id: 'q-next', placement: 'queued', content: text('later') }, + { id: 's-now', placement: 'steering', content: text('interrupt now') }, + ]) + }) }) describe('queue operation transport', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1a8fc3a22a..f3f3c43124 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: 2bf74454fab14303f305b9822e1032f67de0d3ae -README.zh.md: 2677ab5cc85c16fffeca688a4648caf2410ba895 +README.md: c80ca786fb289055fcb3ad2b5a7aab64127ca485 +README.zh.md: 601139a00d2d40fb34e0cdcb8554984f7db9a724 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 2bf74454fa..c80ca786fb 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -34,7 +34,11 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. -`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 and delete actions. +`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. + +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. 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. @@ -63,4 +67,4 @@ None; this package neither assembles nor sends a provider request. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. - **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels. -- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The Host omits pending steering from the Queue snapshot; a consumed `steering/message` still folds into the durable transcript as a plain bubble so replay remains truthful. +- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `steering/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 2677ab5cc8..601139a00d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -34,7 +34,11 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 -`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。 +`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` 去重;气泡交接时因而不会产生空档或重复,重连也能从同一权威恢复待处理状态。 + +键盘消息提交会根据所寻址会话的运行状态解析投递方式。空闲时,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 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 @@ -63,4 +67,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 - **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering(中途引导)操作会被保存和取消取代;Enter 保存,Escape 取消。 -- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。Host 不会把待处理 steering 纳入 Queue 快照;已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以普通气泡呈现,因此回放仍然如实。 +- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `steering/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 625728d4cf..bb34164a3f 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,6 +1,6 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' -import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { deferRegistration, resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -16,7 +16,10 @@ import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import type { IConversation } from './service.ts' import { InputHub } from './input/hub.ts' +import { ComposerSubmissionPolicy } from './input/submission-policy.ts' import { InputBar } from './skeleton/InputBar.tsx' +import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' +import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx' import { ChatView } from './chat/ChatView.tsx' import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' @@ -93,6 +96,22 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() + const submissionPolicy = new ComposerSubmissionPolicy() + + ctx.effect(() => { + const row = deferRegistration(ctx.slots, 'settings.general.item', EnterBehaviorRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'composer-enter', + order: 20, + locale: NS, + inject: (): EnterBehaviorRowInjected => ({ + hooks: { busyEnter: submissionPolicy.busyEnter }, + setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) }, + }), + }, EnterBehaviorRow)) + return () => { row.dispose() } + }, 'ui-conversation: Enter behavior settings row') // Chat scroll offsets by session, surviving view switches (the chat view // unmounts under the tab ring). Deliberately not persisted: a fresh page @@ -203,6 +222,7 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, + resolveSubmitMode: (running, gesture) => submissionPolicy.resolve(running, gesture), toggleCommandMenu: undefined, stop: undefined, command: undefined, @@ -213,6 +233,7 @@ export function apply(ctx: Context): void { const slash = inputHub.slash(sessionId) return { keyboard: shell, + resolveSubmitMode: (running, gesture) => submissionPolicy.resolve(running, gesture), toggleCommandMenu: slash === undefined ? undefined : (selection) => { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 4a8e715bcf..4f237c2d0c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -34,7 +34,7 @@ import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat- import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' -import { MessageItem } from './MessageItem.tsx' +import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 @@ -236,6 +236,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) @@ -248,6 +249,10 @@ export function ChatView({ const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) + const pendingSteering = useMemo(() => { + const durable = new Set(nodes.flatMap(node => node.kind === 'steering' ? [node.messageId] : [])) + return inbox.filter(item => item.placement === 'steering' && !durable.has(item.messageId)) + }, [inbox, nodes]) const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. @@ -261,6 +266,7 @@ export function ChatView({ const firstSeqRef = useRef(null) const openedRef = useRef(false) const lastKeyRef = useRef(null) + const lastSteeringIdRef = useRef(null) /** Flow tip signature — follow-scroll only when this moves, never on a * scroll-driven at-bottom chrome re-render (that was snapping inertial * scrolls the rest of the way to the floor). */ @@ -269,7 +275,8 @@ export function ChatView({ const firstSeq = nodes[0]?.seq ?? null const lastItem = items[items.length - 1] const lastKey = lastItem?.key ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}` + const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null + const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { el.scrollTop = el.scrollHeight @@ -298,6 +305,7 @@ export function ChatView({ } firstSeqRef.current = firstSeq lastKeyRef.current = lastKey + lastSteeringIdRef.current = lastSteeringId followSigRef.current = followSig return } @@ -308,6 +316,7 @@ export function ChatView({ firstSeqRef.current = firstSeq /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey + lastSteeringIdRef.current = lastSteeringId followSigRef.current = followSig return } @@ -316,12 +325,14 @@ export function ChatView({ // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey + lastSteeringIdRef.current = lastSteeringId followSigRef.current = followSig // Follow new flow content while pinned; do NOT re-pin on every render // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el) + if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el) }) const onScrollRef = useRef(() => {}) @@ -467,6 +478,9 @@ export function ChatView({ {/* Turn-level loading signal: rides the whole running turn (first-token wait, tool execution, streaming) so it never flickers per step. */} {running && } + {pendingSteering.map(item => ( + + ))} {!atBottom && (
diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index b54604060a..5783d0211d 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -169,17 +169,19 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows (steering has no actions). */ function UserStyleBubble({ - content, actions, t, + content, actions, pending = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode + /** Whether this is the Host-authoritative pre-admission steering projection. */ + pending?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return ( -
+
{projectUserText(text)} {rest.map((block, i) => )} @@ -189,6 +191,19 @@ function UserStyleBubble({ ) } +/** + * Render one Host-authoritative pending steering item with the same visual + * language as its eventual durable transcript node. + * @param props - Pending message content and conversation translator. + * @returns the pending steering bubble. + */ +export function PendingSteeringBubble({ content, t }: { + content: readonly unknown[] + t: ChatViewSlotProps['t'] +}): ReactNode { + return +} + export const MessageItem = memo(function MessageItem({ node, retryActive = false, onFork, t, }: MessageItemProps) { diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts new file mode 100644 index 0000000000..c5bcdc7826 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts @@ -0,0 +1,10 @@ +/** Composer submission vocabulary shared by the input and settings domains. */ + +/** Delivery mode requested for one ordinary composer message. */ +export type InputSubmitMode = 'queue' | 'steer' + +/** Configurable meaning of plain Enter while the addressed agent is busy. */ +export type BusyEnterBehavior = InputSubmitMode + +/** Keyboard gesture whose delivery mode the submission policy resolves. */ +export type ComposerSubmitGesture = 'enter' | 'accelerated' diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a6c9d16e61..16f789f70c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -7,6 +7,7 @@ import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInte import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' +import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -285,6 +286,8 @@ export interface ComposerBarOwnerProps { export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined + /** Resolve one keyboard submission gesture against the current running state and persisted preference. */ + resolveSubmitMode: (running: boolean, gesture: ComposerSubmitGesture) => InputSubmitMode /** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */ toggleCommandMenu: ((selection: EditSelection) => void) | undefined /** Cancel the in-flight turn; absent with the session. */ diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index f503381bbb..6af78bdb72 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -11,6 +11,7 @@ import type { ReferenceInsert, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { QueueRow } from '../contract/queue.ts' +import type { InputSubmitMode } from '../contract/composer-submission.ts' /** * The scoped-event application verbs: the hub's bail listeners call these, @@ -28,8 +29,11 @@ export interface InputTarget { export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void - /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */ - submit(): void + /** + * THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. + * @param mode - delivery intent retained through asynchronous adjudication and serialization. + */ + submit(mode?: InputSubmitMode): void /** * Surface a notice outside the machine's own effect stream: detached * command results and business notifications render through here. @@ -82,8 +86,8 @@ export interface ComposerKeyboard { readonly snapshot: InputState /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ setDraft(text: string, editRange?: EditRange): void - /** Newline at the selection as a machine transaction (Ctrl+Enter path). */ - newline(selection: EditSelection): void + /** Submit with an explicit delivery mode resolved by the keyboard policy. */ + submit(mode: InputSubmitMode): void undo(): void redo(): void /** Paste over the selection (sync components ride the same transaction). */ @@ -191,7 +195,7 @@ export interface InputState { readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ readonly paste?: PasteAttemptState - /** Read-only queue projection (session/queued frames + connect snapshot). */ + /** Read-only transient inbox projection (`session/queue`, including pending steering). */ readonly queue: readonly QueuedMessage[] } @@ -206,6 +210,8 @@ export interface SubmitAttempt { readonly signal: AbortSignal /** Draft at enter time; rollback restores it only while the live draft still equals it. */ readonly draftSnapshot: string + /** Default-message delivery intent retained while slash adjudication is pending. */ + readonly mode: InputSubmitMode } /** @@ -217,8 +223,6 @@ export interface SubmitAttempt { export type InputEvent = /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } - /** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */ - | { readonly type: 'newline'; readonly selection: EditSelection } | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } /** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */ | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } @@ -239,7 +243,7 @@ export type InputEvent = | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert } /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */ | { readonly type: 'invalidate-paste' } - | { readonly type: 'enter' } + | { readonly type: 'enter'; readonly mode: InputSubmitMode } | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } @@ -258,5 +262,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string } + | { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 79a223a43e..79516d04fa 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -16,6 +16,7 @@ import type { EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' +import type { InputSubmitMode } from '../contract/composer-submission.ts' import { InputMachine } from './machine.ts' /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */ @@ -39,7 +40,7 @@ export interface SessionInputDeps { /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string): void + defaultSink(text: string, mode: InputSubmitMode): void } /** Guard tier from the machine phase. */ @@ -68,7 +69,7 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session — decision 20). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - submit: () => { this.submit() }, + submit: () => { this.submit('queue') }, } // Real wall clock: the typing-run merge window must actually expire in @@ -106,15 +107,6 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'send-committed' })) } - /** - * Insert a newline at the selection as one machine transaction (the - * execCommand path is gone — a second undo history would fork). - * @param selection - current DOM selection in draft coordinates. - */ - newline(selection: EditSelection): void { - this.run(this.core.dispatch({ type: 'newline', selection })) - } - /** Undo the latest transaction (InputBar intercepts the platform chord). */ undo(): void { this.run(this.core.dispatch({ type: 'undo' })) @@ -152,8 +144,8 @@ export class SessionInputShell implements SessionInput { * (adjudicating/submitting) force-closes the transient layers: the popup * dismisses and the menu tracks frozen. */ - submit(): void { - this.run(this.core.dispatch({ type: 'enter' })) + submit(mode: InputSubmitMode = 'queue'): void { + this.run(this.core.dispatch({ type: 'enter', mode })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { this.deps.popup?.()?.dismiss() @@ -338,7 +330,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft) + this.sinkSerialized(fx.draft, fx.mode) return } default: @@ -353,10 +345,10 @@ export class SessionInputShell implements SessionInput { * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string): void { + private sinkSerialized(draft: string, mode: InputSubmitMode): void { const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim()) + this.deps.defaultSink(draft.trim(), mode) return } const slash = this.deps.slash?.() @@ -376,7 +368,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.deps.defaultSink(out.trim()) + this.deps.defaultSink(out.trim(), mode) }, (error: unknown) => { controller.abort() diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 1fe2f95e7f..560999aa21 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -12,6 +12,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' +import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' @@ -56,7 +57,7 @@ export class InputHub implements InputService { slash: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text) => { this.sink(session, text) }, + defaultSink: (text, mode) => { this.sink(session, text, mode) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -123,12 +124,12 @@ export class InputHub implements InputService { * exactly one path; a failed first prompt is an ordinary prompt failure * (error strip via promptError, draft restored only while untouched). */ - private sink(session: SessionFace, text: string): void { + private sink(session: SessionFace, text: string, mode: InputSubmitMode): void { if (text === '') return const shell = this.shells.get(session.sessionId) // Commit, not an editable clear: undo must not resurrect sent content. shell?.commitSend() - void session.prompt([{ type: 'text', text }], 'queue').then( + void session.prompt([{ type: 'text', text }], mode).then( (result) => { if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text) }, diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f32d9e11d7..ec62eb63b4 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -14,6 +14,7 @@ * paste-upgrade all answer their bail events this way). */ import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions, InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, @@ -149,7 +150,6 @@ export class InputMachine { dispatch(ev: InputEvent): readonly InputEffect[] { switch (ev.type) { case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange) - case 'newline': return this.onNewline(ev.selection) case 'begin-command': return this.onBeginCommand(ev.claim, ev.span) case 'insert-ref': return this.onInsertRef(ev.reference, ev.span) case 'consume-token': return this.onConsumeToken(ev.guard) @@ -162,7 +162,7 @@ export class InputMachine { this.paste = undefined return [] } - case 'enter': return this.onEnter() + case 'enter': return this.onEnter(ev.mode) case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) @@ -254,19 +254,6 @@ export class InputMachine { return [] } - /** F1: caret newline as an ordinary machine transaction (execCommand path removed). */ - private onNewline(selection: EditSelection): InputEffect[] { - const { start, end } = selection - if (start < 0 || start > end || end > this.draft.length) return [] - this.pushTxn(selection) - this.typingRun = undefined - this.reconcile({ start, end, insertedLength: 1 }) - this.adopt(this.draft.slice(0, start) + '\n' + this.draft.slice(end)) - this.watchClaim() - this.paste = undefined - return [] - } - /** Span CAS: revision equality (content identity follows) plus bounds sanity. */ private casOk(span: TokenSpan): boolean { return span.draftRev === this.draftRev @@ -461,18 +448,18 @@ export class InputMachine { // ---- submit plane ---- /** Mint the next SubmitAttempt and take the in-flight slot. */ - private beginAttempt(): SubmitAttempt { + private beginAttempt(mode: InputSubmitMode): SubmitAttempt { const controller = new AbortController() this.seq += 1 - const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft } + const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode } this.inflight = { attempt, controller } return attempt } - private onEnter(): InputEffect[] { + private onEnter(mode: InputSubmitMode): InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { - const attempt = this.beginAttempt() + const attempt = this.beginAttempt(mode) this.phase = 'submitting' this.paste = undefined return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }] @@ -481,11 +468,11 @@ export class InputMachine { if (trimmed === '') return [] this.paste = undefined if (trimmed.startsWith('/')) { - const attempt = this.beginAttempt() + const attempt = this.beginAttempt(mode) this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft }] + return [{ type: 'default-sink', draft: this.draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -506,7 +493,7 @@ export class InputMachine { this.inflight = undefined this.phase = 'plain' return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot }] + ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: attempt.mode }] : [] } diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts new file mode 100644 index 0000000000..99ce299c52 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -0,0 +1,72 @@ +/** + * Browser-local Composer submission policy. It owns the persisted busy-Enter + * preference and resolves keyboard gestures into queue/steer delivery modes; + * Host and Agent keep the actual delivery-window authority. + */ +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, +} from '../contract/composer-submission.ts' + +/** localStorage key holding the busy-Enter preference. */ +export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter' + +/** Default preserves Enter-as-Queue for running conversations. */ +export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' + +/** + * Persisted policy used by both the composer inject face and its Settings row. + * Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window + * submission into the next waking Queue item. + */ +export class ComposerSubmissionPolicy { + /** Reactive preference source for the Settings row. */ + readonly busyEnter: SnapshotStore = createSnapshotStore(restoreBusyEnter()) + + /** + * Resolve one keyboard gesture without changing state. + * @param running - whether the addressed agent currently reports busy. + * @param gesture - plain Enter or the Cmd/Ctrl-accelerated chord. + * @returns Queue outside busy state; otherwise the preferred mode or its opposite. + */ + resolve(running: boolean, gesture: ComposerSubmitGesture): InputSubmitMode { + if (!running) return 'queue' + const preferred = this.busyEnter.getSnapshot() + if (gesture === 'enter') return preferred + return preferred === 'queue' ? 'steer' : 'queue' + } + + /** + * Change and persist the plain-Enter behavior used during busy state. + * @param behavior - Queue or Steer. + */ + setBusyEnter(behavior: BusyEnterBehavior): void { + if (this.busyEnter.getSnapshot() === behavior) return + this.busyEnter.set(behavior) + persistBusyEnter(behavior) + } +} + +/** Restore a valid preference; unavailable or corrupt storage uses Queue. */ +function restoreBusyEnter(): BusyEnterBehavior { + if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR + let stored: string | null + try { + stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY) + } catch { + // Storage access can fail in privacy modes; the default remains usable. + return DEFAULT_BUSY_ENTER_BEHAVIOR + } + if (stored === 'queue' || stored === 'steer') return stored + return DEFAULT_BUSY_ENTER_BEHAVIOR +} + +/** Persist a preference when browser storage is available. */ +function persistBusyEnter(behavior: BusyEnterBehavior): void { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior) + } catch { + // A storage failure makes the preference session-only; input stays usable. + } +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 21897a529b..1ecb33b854 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,6 +23,10 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', + 'settings.enter.title': '繁忙时 Enter 键行为', + 'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为', + 'settings.enter.queue': '排队发送', + 'settings.enter.steer': '插话发送', 'access.confirm.title': '确认启用 Full access?', 'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', 'access.confirm.acknowledge': '我已了解风险,并愿意继续', @@ -126,6 +130,10 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', + 'settings.enter.title': 'Enter behavior while busy', + 'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior', + 'settings.enter.queue': 'Queue', + 'settings.enter.steer': 'Steer', 'access.confirm.title': 'Enable Full access?', 'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', 'access.confirm.acknowledge': 'I understand the risks and want to continue', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 10c16e6dc5..5085a6e579 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -4,7 +4,7 @@ // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' -import { useEffect, useId, useState } from 'react' +import { useEffect, useId, useMemo, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { @@ -29,7 +29,8 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock * collapsible count header; an empty queue renders nothing. */ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) { - const queue = useSession(s => s.queue) + const inbox = useSession(s => s.queue) + const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) const running = useSession(s => s.running) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/queue/store.ts index 536523465b..35364383c6 100644 --- a/packages/client/ui-conversation/src/client/queue/store.ts +++ b/packages/client/ui-conversation/src/client/queue/store.ts @@ -1,7 +1,7 @@ /** * Queue read face for the InputState.queue projection (frozen contract in * ../input/contract.ts): a uSES-compatible observable over one session's - * queue rows. The Session snapshot already keeps the queue array + * transient inbox rows. The Session snapshot already keeps the queue array * reference-stable across unrelated snapshot swaps, so this is a pure * projection — no second store, no copy. */ @@ -9,7 +9,7 @@ import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-ru import type { QueuedMessage } from '../input/contract.ts' /** - * Project a session's queue rows as a bare observable (subscribe/getSnapshot). + * Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot). * The wiring layer (T5) overlays this onto InputState.queue; the runtime * QueuedMessage and the input-contract QueuedMessage are structurally * identical. diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css new file mode 100644 index 0000000000..9d8b3d7ceb --- /dev/null +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css @@ -0,0 +1,56 @@ +/* Composer Enter preference row: title/description plus selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx new file mode 100644 index 0000000000..2b5552c3cc --- /dev/null +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx @@ -0,0 +1,76 @@ +/** General Settings row for the Composer's busy-state Enter preference. */ +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { BusyEnterBehavior } from '../contract/composer-submission.ts' +import type { ConversationKey } from '../locales.ts' +import css from './EnterBehaviorRow.module.css' + +/** Registration-side preference face. */ +export interface EnterBehaviorRowInjected { + hooks: { + /** Persisted busy-state preference bound as useBusyEnter. */ + busyEnter: SnapshotStore + } + /** Change the busy-state plain-Enter behavior. */ + setBusyEnter: (behavior: BusyEnterBehavior) => void +} + +/** Full Settings-row props. */ +export type EnterBehaviorRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'conversation'> + & InjectFace + +const OPTIONS: readonly { + id: BusyEnterBehavior + label: ConversationKey +}[] = [ + { id: 'queue', label: 'settings.enter.queue' }, + { id: 'steer', label: 'settings.enter.steer' }, +] + +/** + * Render the busy-state Enter behavior selector. + * @param props - composed Settings slot props. + * @returns the preference row. + */ +export function EnterBehaviorRow({ useBusyEnter, setBusyEnter, t }: EnterBehaviorRowProps) { + const behavior = useBusyEnter(value => value) + const [open, setOpen] = useState(false) + const selectedLabel = behavior === 'queue' ? 'settings.enter.queue' : 'settings.enter.steer' + + return ( +
+
+
{t('settings.enter.title')}
+
{t('settings.enter.description')}
+
+ { setOpen(false) }} + items={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))} + selectedId={behavior} + onSelect={(id) => { + setOpen(false) + setBusyEnter(id as BusyEnterBehavior) + }} + align="end" + portal + anchor={( + + )} + /> +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 43a0f3e1ec..7b8aa87414 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -34,7 +34,7 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t, + useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, }: InputBarProps) { @@ -178,23 +178,10 @@ export function InputBar({ e.preventDefault() return } - if (e.ctrlKey || e.metaKey) { - // Newline as a machine transaction (the machine owns undo history; an - // execCommand write would fork a second, browser-owned history). - e.preventDefault() - if (!machineBusy && !locked) { - const el = e.currentTarget - const sel = selectionOf(el) - keyboard.newline(sel) - const caret = sel.start + 1 - requestAnimationFrame(() => { el.setSelectionRange(caret, caret) }) - } - return - } e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return - inputActions.submit() + keyboard.submit(resolveSubmitMode(running, e.ctrlKey || e.metaKey ? 'accelerated' : 'enter')) } const onChange = (e: ChangeEvent): void => { diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d3fabd9bee..20e7e54f86 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -37,6 +37,7 @@ async function bench() { await runtime.root.declare({ 'conversation': { kind: 'single', scope: 'session-maybe' }, 'details': { kind: 'single', scope: 'session' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_p: { renderSlot?: unknown }) => null) const feature = await runtime.mount({ inject: [...inject], apply }) @@ -85,6 +86,7 @@ describe('apply wiring', () => { // The hero workspace picker hole rides the conversation entry's children // declaration (the empty-state occupant is gone). expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter']) await b.runtime.dispose() }) @@ -112,6 +114,7 @@ describe('apply wiring', () => { expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0) expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) + expect(b.slots.entries('settings.general.item')).toHaveLength(0) expect(b.runtime.ctx.get('conversation')).toBeUndefined() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 04f412fb25..507b301922 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -251,6 +251,51 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) + it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { + const pending = { + id: 'steer-occurrence' as never, + messageId: 'steer-message' as never, + placement: 'steering' as const, + content: [{ type: 'text' as const, text: 'interrupt now' }], + preview: 'interrupt now', + text: 'interrupt now', + } + const queued = { + id: 'queued-occurrence' as never, + messageId: 'queued-message' as never, + placement: 'queued' as const, + content: [{ type: 'text' as const, text: 'later' }], + preview: 'later', + text: 'later', + } + const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true }) + const view = render() + + expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull() + expect(view.queryByText('later')).toBeNull() + expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now')) + & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + + act(() => { + h.set({ + queue: [queued, pending], + nodes: [ + assistant(1, 'working'), + { + kind: 'steering', messageId: pending.messageId, + seq: 2, time: 2_000, turn: 1, + content: [{ type: 'text', text: 'interrupt now' }], source: null, + }, + ], + }) + }) + expect(view.getAllByText('interrupt now')).toHaveLength(1) + expect(view.container.querySelector('[data-pending-steering]')).toBeNull() + + act(() => { h.set({ queue: [queued] }) }) + expect(view.getAllByText('interrupt now')).toHaveLength(1) + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx new file mode 100644 index 0000000000..e8d44c9bf3 --- /dev/null +++ b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx' +import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx' +import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts' +import { en } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + localStorage.clear() +}) + +function emptySessions() { + return bindSnapshotSelector(createSnapshotStore({ + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + })) +} + +function emptyWorkspaces() { + return bindSnapshotSelector(createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + })) +} + +function mount() { + const policy = new ComposerSubmissionPolicy() + const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) }) + const props: EnterBehaviorRowProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useBusyEnter: bindSnapshotSelector(policy.busyEnter), + setBusyEnter, + t: makeTranslate(en), + } + render() + return { policy, setBusyEnter } +} + +describe('EnterBehaviorRow', () => { + it('explains the busy-only scope and shows Queue by default', () => { + mount() + expect(screen.getByText('Enter behavior while busy')).toBeDefined() + expect(screen.getByText('Busy only; Cmd/Ctrl+Enter uses the other behavior')).toBeDefined() + expect(screen.getByRole('button', { name: /Queue/ }).getAttribute('aria-expanded')).toBe('false') + }) + + it('selects Steer, follows later preference changes, and closes outside', () => { + const b = mount() + const trigger = screen.getByRole('button', { name: /Queue/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: 'Steer' })) + expect(b.setBusyEnter).toHaveBeenCalledWith('steer') + expect(screen.getByRole('button', { name: /Steer/ })).toBeDefined() + + act(() => { b.policy.setBusyEnter('queue') }) + const queueTrigger = screen.getByRole('button', { name: /Queue/ }) + fireEvent.click(queueTrigger) + expect(screen.getByRole('menuitem', { name: 'Steer' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menuitem', { name: 'Steer' })).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2072fd2c90..bfe34316fa 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // InputBar behavior over the machine wiring: Enter-send semantics (IME guard, -// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running +// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running // semantics (input stays free; primary turns stop), the machine pending lock, // decoration backdrop, error/notice strips, and the focus-keeping mousedown. @@ -53,6 +53,7 @@ interface BenchOptions { leftItems?: React.ReactNode rightItems?: React.ReactNode commandMenuOpen?: boolean + busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void } @@ -107,6 +108,11 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + resolveSubmitMode: (running, gesture) => { + if (!running) return 'queue' + const preferred = over?.busyEnter ?? 'queue' + return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue' + }, toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), @@ -137,7 +143,7 @@ describe('Enter semantics', () => { 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' }) - expect(sink).toHaveBeenCalledWith('hello') + expect(sink).toHaveBeenCalledWith('hello', 'queue') fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) @@ -159,12 +165,18 @@ describe('Enter semantics', () => { expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline }) - it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => { - const { textarea, shell, sink } = bench({ draft: 'hello' }) - textarea.setSelectionRange(5, 5) - fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) - expect(shell.snapshot.draft).toBe('hello\n') - expect(sink).not.toHaveBeenCalled() + it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => { + const idle = bench({ draft: 'hello' }) + fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) + expect(idle.sink).toHaveBeenCalledWith('hello', 'queue') + + const busyCtrl = bench({ running: true, draft: 'steer with ctrl' }) + fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer') + + const busyMeta = bench({ running: true, draft: 'steer with cmd' }) + fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true }) + expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer') }) it('platform undo/redo chords route to the machine, never the browser stack', () => { @@ -205,12 +217,28 @@ describe('running and lock semantics (queue cut 1)', () => { expect(textarea.disabled).toBe(false) // running no longer locks fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2') + expect(sink).toHaveBeenCalledWith('排队消息2', 'queue') expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) }) + it('running plain Enter follows the busy-state Steer preference', () => { + const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(sink).toHaveBeenCalledWith('直接插话', 'steer') + }) + + it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { + const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) + fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) + expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue') + + const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' }) + fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue') + }) + it('running subagent primary admits a follow-up instead of exposing Stop', () => { const { button, sink, stop } = bench({ running: true, @@ -226,7 +254,7 @@ describe('running and lock semantics (queue cut 1)', () => { }) expect(button.getAttribute('aria-label')).toBe('发送消息') fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('后续消息') + expect(sink).toHaveBeenCalledWith('后续消息', 'queue') expect(stop).not.toHaveBeenCalled() const empty = bench({ @@ -253,7 +281,7 @@ describe('running and lock semantics (queue cut 1)', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go') + expect(sink).toHaveBeenCalledWith('go', 'queue') const empty = bench() expect(empty.button.disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.spec.ts index 510cb28076..89737d7106 100644 --- a/packages/client/ui-conversation/tests/input-machine.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.spec.ts @@ -40,9 +40,9 @@ function effectAt( } /** Drive plain → adjudicating and hand back the minted attempt. */ -function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt { +function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt { m.dispatch({ type: 'draft-changed', draft }) - const fx = m.dispatch({ type: 'enter' }) + const fx = m.dispatch({ type: 'enter', mode }) return effectAt(fx, 0, 'adjudicate').attempt } @@ -52,35 +52,42 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` }) m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) }) m.dispatch({ type: 'draft-changed', draft: claim.token + args }) - const fx = m.dispatch({ type: 'enter' }) + const fx = m.dispatch({ type: 'enter', mode: 'queue' }) return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim } } function staleAttempt(): SubmitAttempt { - return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' } + return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' } } describe('input-machine: plain × enter', () => { it('empty and whitespace-only drafts produce nothing', () => { const m = new InputMachine() - expect(m.dispatch({ type: 'enter' })).toEqual([]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) m.dispatch({ type: 'draft-changed', draft: ' \n ' }) - expect(m.dispatch({ type: 'enter' })).toEqual([]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) expect(m.state.phase).toBe('plain') }) it('non-command text falls to the default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - expect(m.dispatch({ type: 'enter' })) - .toEqual([{ type: 'default-sink', draft: 'hello world' }]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })) + .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'queue' }]) expect(m.state.phase).toBe('plain') }) + it('retains an explicit steer mode on the default sink effect', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'steer now' }) + expect(m.dispatch({ type: 'enter', mode: 'steer' })) + .toEqual([{ type: 'default-sink', draft: 'steer now', mode: 'steer' }]) + }) + it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '/goal x' }) - const fx = m.dispatch({ type: 'enter' }) + const fx = m.dispatch({ type: 'enter', mode: 'queue' }) const eff = effectAt(fx, 0, 'adjudicate') expect(eff.draft).toBe('/goal x') expect(eff.attempt.draftSnapshot).toBe('/goal x') @@ -91,14 +98,14 @@ describe('input-machine: plain × enter', () => { it('leading is judged after trim including newlines', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' }) - expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate') + expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate') }) it('a non-whitespace prefix before "/" is not leading — default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' }) - expect(m.dispatch({ type: 'enter' })) - .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })) + .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }]) }) }) @@ -126,9 +133,9 @@ describe('input-machine: adjudication outcomes', () => { it('undefined outcome falls back to the default sink', () => { const m = new InputMachine() - const attempt = enterAdjudicating(m, '/unknown thing') + const attempt = enterAdjudicating(m, '/unknown thing', 'steer') expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) - .toEqual([{ type: 'default-sink', draft: '/unknown thing' }]) + .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }]) expect(m.state.phase).toBe('plain') }) @@ -152,7 +159,7 @@ describe('input-machine: adjudication outcomes', () => { it('enter is a no-op while adjudicating (pending lock)', () => { const m = new InputMachine() enterAdjudicating(m, '/goal x') - expect(m.dispatch({ type: 'enter' })).toEqual([]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) expect(m.state.phase).toBe('adjudicating') }) @@ -344,31 +351,6 @@ describe('input-machine: occurrence reconciliation on draft edits', () => { }) }) -describe('input-machine: newline transaction (F1)', () => { - it('inserts \\n at the caret and shifts trailing occurrences', () => { - const m = new InputMachine() - m.dispatch({ type: 'draft-changed', draft: 'ab @wor' }) - m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) }) - m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } }) - expect(m.state.draft).toBe(`ab\n ${P} `) - expect(m.state.occurrences[0]?.offset).toBe(4) - m.dispatch({ type: 'undo' }) - expect(m.state.draft).toBe(`ab ${P} `) - }) - - it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => { - const m = new InputMachine() - m.dispatch({ type: 'draft-changed', draft: '/go' }) - m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) - expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([]) - expect(m.state.phase).toBe('claimed') - m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } }) - expect(m.state.draft).toBe('\n/goal ') - expect(m.state.phase).toBe('plain') - expect(m.state.claim).toBeUndefined() - }) -}) - describe('input-machine: consume-token guards', () => { it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => { const m = new InputMachine() @@ -587,7 +569,7 @@ describe('input-machine: paste plane', () => { const b = new InputMachine() b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } }) - b.dispatch({ type: 'enter' }) + b.dispatch({ type: 'enter', mode: 'queue' }) expect(b.state.paste).toBeUndefined() }) @@ -755,7 +737,7 @@ describe('input-machine: submitting transaction', () => { it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => { const m = new InputMachine() enterSubmitting(m, 'goal', 'x') - expect(m.dispatch({ type: 'enter' })).toEqual([]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([]) expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' }) }) @@ -768,7 +750,7 @@ describe('input-machine: submitting transaction', () => { m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } }) m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) m.dispatch({ type: 'draft-changed', draft: '/goal go' }) - const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt + const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } }) expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }]) expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] }) @@ -809,7 +791,7 @@ describe('input-machine: submitting transaction', () => { const m = new InputMachine() const { attempt: first } = enterSubmitting(m, 'goal', 'x') m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' }) - const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt + const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt expect(second.seq).not.toBe(first.seq) expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([]) expect(m.state.phase).toBe('submitting') diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 45ce41c3b8..af8dbb8e66 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -47,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + resolveSubmitMode: () => 'queue', toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), @@ -88,7 +89,7 @@ describe('matrix row: plain', () => { fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息') + expect(sink).toHaveBeenCalledWith('普通消息', 'queue') expect(shell.snapshot.phase).toBe('plain') }) }) @@ -187,7 +188,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队') + expect(sink).toHaveBeenCalledWith('排队', 'queue') }) }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index a4eb2e668f..f3ef3410ff 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -133,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + resolveSubmitMode: () => 'queue', toggleCommandMenu: (selection) => { const snapshot = shell.snapshot controller.toggleSource('command', { @@ -235,7 +236,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') }) expect(b2.executed).toHaveLength(0) }) }) @@ -289,7 +290,7 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') }) + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index eb5c293501..4195488a53 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -23,7 +23,11 @@ const SID = 's1' as SessionId const iid = (id: string): QueueItemId => id as QueueItemId function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage { - return { id: iid(id), preview, text } + return { + id: iid(id), messageId: `message-${id}` as never, placement: 'queued', + content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }], + preview, text, + } } function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { @@ -85,6 +89,14 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) + it('leaves pending steering to the conversation flow', () => { + const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const } + const snap = snapshotWith([steering]) + const source = liveSession(snap) + const { container } = render() + expect(container.innerHTML).toBe('') + }) + it('renders one row directly and defaults multiple rows to a collapsible count header', () => { const single = snapshotWith([row('i-1', 'one')]) const source = liveSession(single) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index eff655d229..1642425930 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -164,6 +164,7 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + resolveSubmitMode={() => 'queue'} toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} @@ -220,7 +221,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue') expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts new file mode 100644 index 0000000000..485840fc2e --- /dev/null +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, +} from '../src/client/input/submission-policy.ts' + +afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() +}) + +describe('ComposerSubmissionPolicy', () => { + it('defaults to Queue and only applies the preference while running', () => { + const policy = new ComposerSubmissionPolicy() + expect(policy.busyEnter.getSnapshot()).toBe(DEFAULT_BUSY_ENTER_BEHAVIOR) + expect(policy.resolve(false, 'enter')).toBe('queue') + expect(policy.resolve(false, 'accelerated')).toBe('queue') + expect(policy.resolve(true, 'enter')).toBe('queue') + expect(policy.resolve(true, 'accelerated')).toBe('steer') + + const changed = vi.fn() + policy.busyEnter.subscribe(changed) + policy.setBusyEnter('steer') + expect(changed).toHaveBeenCalledTimes(1) + expect(policy.resolve(true, 'enter')).toBe('steer') + expect(policy.resolve(true, 'accelerated')).toBe('queue') + expect(policy.resolve(false, 'enter')).toBe('queue') + expect(policy.resolve(false, 'accelerated')).toBe('queue') + expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer') + }) + + it('restores a valid preference and leaves an identical write untouched', () => { + localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer') + const write = vi.spyOn(Storage.prototype, 'setItem') + const policy = new ComposerSubmissionPolicy() + expect(policy.busyEnter.getSnapshot()).toBe('steer') + policy.setBusyEnter('steer') + expect(write).not.toHaveBeenCalled() + write.mockRestore() + }) + + it('uses Queue for invalid, unavailable, or unreadable storage', () => { + localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid') + expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') + + vi.stubGlobal('localStorage', undefined) + expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') + + vi.stubGlobal('localStorage', { + getItem: () => { throw new Error('blocked') }, + setItem: vi.fn(), + }) + expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') + }) + + it('keeps the in-memory preference when persistence throws', () => { + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => { throw new Error('quota') }, + }) + const policy = new ComposerSubmissionPolicy() + policy.setBusyEnter('steer') + expect(policy.busyEnter.getSnapshot()).toBe('steer') + }) +}) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7d1c033393..e9808518fe 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -818,29 +818,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionId, items: items.map(item => ({ id: item.id, + placement: item.placement, message: item.message, })), }) } ctx.effect(() => { - const retire = (agent: Agent, item: InboxItem): boolean => { - const entries = queuedMirror.get(agent.id) - if (entries === undefined) { - rememberUnseen(agent.id, item.id, { kind: 'terminal' }) - return false - } - const index = entries.findIndex(entry => entry.id === item.id) - if (index === -1) { - rememberUnseen(agent.id, item.id, { kind: 'terminal' }) - return false - } + const retireKnown = (sessionId: SessionId, itemId: InboxItemId): boolean => { + const entries = queuedMirror.get(sessionId) + if (entries === undefined) return false + const index = entries.findIndex(entry => entry.id === itemId) + if (index === -1) return false entries.splice(index, 1) - if (entries.length === 0) queuedMirror.delete(agent.id) + if (entries.length === 0) queuedMirror.delete(sessionId) return true } + const retire = (agent: Agent, item: InboxItem): boolean => { + if (retireKnown(agent.id, item.id)) return true + rememberUnseen(agent.id, item.id, { kind: 'terminal' }) + return false + } const disposers = [ ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { - if (item.placement !== 'queued') return const unseen = takeUnseen(agent.id, item.id) if (unseen?.kind === 'terminal') return let entries = queuedMirror.get(agent.id) @@ -866,7 +865,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro publishQueue(agent.id) }), ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => { - if (retire(agent, item)) publishQueue(agent.id) + if (item.placement === 'steering') { + // AgentLoop appends the durable steering/message synchronously after + // this claim. Retain and retire the mirror row in the following + // microtask so any re-entrant snapshot and the Host's linear mux + // stream keep it visible until the durable event exists. + const present = queuedMirror.get(agent.id)?.some(entry => entry.id === item.id) === true + if (!present) { + retire(agent, item) + return + } + queueMicrotask(() => { + if (retireKnown(agent.id, item.id)) publishQueue(agent.id) + }) + } else if (retire(agent, item)) { + // Queued claims have no durable same-message handoff to order. + // Publish retirement synchronously as before. + publishQueue(agent.id) + } }), ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => { let changed = false @@ -2509,6 +2525,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionId, items: items.map(item => ({ id: item.id, + placement: item.placement, message: item.message, })), })) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 0fd97b346e..3d5e94c2c1 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -54,6 +54,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ sessionId: sessionIdSchema, items: z.array(z.object({ id: inboxItemIdSchema, + placement: z.union([z.literal('queued'), z.literal('steering')]), message: messageSchema, })), }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 43bebaa595..f4460fadc2 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -32,10 +32,12 @@ export type ToolEventView = | { for: 'call'; view: ToolCallView } | { for: 'result'; view: ToolResultView } -/** One pending queued occurrence in an authoritative queue snapshot. */ +/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */ export interface QueuedInboxItem { - /** Agent-owned occurrence identity used by queue mutations. */ + /** Agent-owned occurrence identity; queue mutations address only `queued` items. */ id: InboxItemId + /** Agent-resolved FIFO placement; clients render queued and steering items on different surfaces. */ + placement: 'queued' | 'steering' /** Complete pending message; it is not durable until the Agent claims it. */ message: Message } @@ -71,11 +73,12 @@ export type MuxFrame = | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } /** - * Complete transient queue state after every enqueue, mutation, claim, or + * Complete transient inbox state after every enqueue, mutation, claim, or * discard. Pending work is not model-visible and therefore has no durable * session event; the whole snapshot makes edit, deletion, cancel, and - * reconnect converge through one authoritative signal. Pending steering is - * outside this Web queue projection. + * reconnect converge through one authoritative signal. `session/queue` + * covers both resolved placements: queued items render + * in QueueDock, while pending steering renders at the conversation tail. */ | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } /** diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 61e4c6f4d3..473d452327 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -379,7 +379,7 @@ describe('session/queue frames', () => { const liveFrames = (await collected).filter(frame => frame.type === 'session/queue') expect(liveFrames.map(frame => frame.items)).toEqual([ - [{ id: edited.id, message: edited.message }], + [{ id: edited.id, placement: edited.placement, message: edited.message }], ]) const replay = new AbortController() const replayFrames = await collect( @@ -393,8 +393,8 @@ describe('session/queue frames', () => { const agent = stubAgent(ctx) const live = new AbortController() const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal) - // subscribed baseline + one queued snapshot; pending steering stays off this wire. - const liveCollected = collect(liveStream, 2, live) + // subscribed baseline + one snapshot per accepted inbox occurrence. + const liveCollected = collect(liveStream, 3, live) const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued') const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering') @@ -406,7 +406,15 @@ describe('session/queue frames', () => { { type: 'session/queue', sessionId: agent.id, - items: [{ id: queued.id, message: queued.message }], + items: [{ id: queued.id, placement: 'queued', message: queued.message }], + }, + { + type: 'session/queue', + sessionId: agent.id, + items: [ + { id: queued.id, placement: 'queued', message: queued.message }, + { id: steering.id, placement: 'steering', message: steering.message }, + ], }, ]) @@ -414,7 +422,88 @@ describe('session/queue frames', () => { const replay = new AbortController() const replayFrames = await collect( api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay) - expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]]) + expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]]) + }) + + it('publishes the durable steering event before retiring its transient row', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + const agent = stubAgent(ctx) + const abort = new AbortController() + const collected = collect( + api.events.mux({ rpcId: RpcId('t-steering-order'), payload: {} }, abort.signal), 5, abort) + const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering') + + agent.session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + ctx.emit('agent/inbox/enqueue', agent, steering) + ctx.emit('agent/inbox/dequeue', agent, steering) + agent.session.append('steering/message', { + turn: 1, + message: steering.message, + }, { surfaceOp: 'append' }) + + const frames = await collected + expect(frames.map(frame => frame.type)).toEqual([ + 'session/subscribed', + 'session/event', + 'session/queue', + 'session/event', + 'session/queue', + ]) + expect(frames[2]).toMatchObject({ + type: 'session/queue', + items: [{ id: steering.id, placement: 'steering' }], + }) + expect(frames[3]).toMatchObject({ + type: 'session/event', + event: { type: 'steering/message', data: { message: { id: steering.message.id } } }, + }) + expect(frames[4]).toMatchObject({ type: 'session/queue', items: [] }) + }) + + it('retains claimed steering in re-entrant snapshots until its durable event', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + const agent = stubAgent(ctx) + const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering') + const queued = inboxItem('i-reentrant', inboxMessage('m-reentrant', 'later'), 'queued') + ctx.on('agent/inbox/dequeue', (subject, item) => { + if (subject === agent && item.id === steering.id) ctx.emit('agent/inbox/enqueue', agent, queued) + }) + const abort = new AbortController() + const collected = collect( + api.events.mux({ rpcId: RpcId('t-steering-reentrant-order'), payload: {} }, abort.signal), 6, abort) + + agent.session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + ctx.emit('agent/inbox/enqueue', agent, steering) + ctx.emit('agent/inbox/dequeue', agent, steering) + agent.session.append('steering/message', { + turn: 1, + message: steering.message, + }, { surfaceOp: 'append' }) + + const frames = await collected + expect(frames[3]).toMatchObject({ + type: 'session/queue', + items: [ + { id: steering.id, placement: 'steering' }, + { id: queued.id, placement: 'queued' }, + ], + }) + expect(frames[4]).toMatchObject({ + type: 'session/event', + event: { type: 'steering/message', data: { message: { id: steering.message.id } } }, + }) + expect(frames[5]).toMatchObject({ + type: 'session/queue', + items: [{ id: queued.id, placement: 'queued' }], + }) }) it('publishes edits in place in the authoritative order', async () => { @@ -434,10 +523,16 @@ describe('session/queue frames', () => { const frames = (await collected).filter(frame => frame.type === 'session/queue') expect(frames.map(frame => frame.items)).toEqual([ - [{ id: first.id, message: first.message }], - [{ id: first.id, message: first.message }, { id: second.id, message: second.message }], - [{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }], - [{ id: first.id, message: first.message }], + [{ id: first.id, placement: first.placement, message: first.message }], + [ + { id: first.id, placement: first.placement, message: first.message }, + { id: second.id, placement: second.placement, message: second.message }, + ], + [ + { id: first.id, placement: first.placement, message: first.message }, + { id: edited.id, placement: edited.placement, message: edited.message }, + ], + [{ id: first.id, placement: first.placement, message: first.message }], ]) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 541ddab1c8..5c721b33d1 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -481,7 +481,7 @@ describe('events frame schemas', () => { { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' }, { type: 'session/queue', sessionId: 's', items: [ - { id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } }, + { id: 'i1', placement: 'steering', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } }, ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },