feat(web): steer the whole queue with an empty-draft Cmd/Ctrl+Enter

This commit is contained in:
_Kerman
2026-08-06 21:40:50 +08:00
parent 6a32047e77
commit e4256a1684
13 changed files with 305 additions and 20 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md
2026-08-06-web-queue-steer-all-gesture.md: e65089fca763a4226a40b090f3a5f8f56284b9bc
2026-08-06-web-queue-steer-all-gesture.zh.md: abccf60a9da54ba1c639e93c63107d97913d9d66
@@ -0,0 +1,30 @@
# Agent Note: Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter
Status: implemented
English | [中文](2026-08-06-web-queue-steer-all-gesture.zh.md)
## Problem
While a primary session runs, the Web queue accumulates messages the user typed with plain Enter (or queued while the busy-Enter preference was Queue). Flushing them into the current turn required clicking the per-row 插话发送 button once per message; an empty composer draft had no keyboard gesture at all — the input machine rejects empty drafts, so Enter and Cmd/Ctrl+Enter were both no-ops. With several queued messages, steering them one by one is the obvious multi-click friction, and the empty-draft accelerated chord is the natural slot for "steer everything".
## Decision
Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary.
The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter.
## Consequences
One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The cost is a presentation-layer branch that must stay in sync with the dock's gating window (running, non-subagent) — the hub re-checks the snapshot at execution time, so the gate is advisory and the host remains authoritative.
## Related
The per-row 插话发送 action and its strict-steer boundary are owned by [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md); this note only adds the whole-queue keyboard gesture on top of that decision.
## Alternatives considered
- **Intercepting inside the input machine.** Rejected: the machine is queue-agnostic by design (the wiring layer overlays the queue projection) and cannot distinguish the accelerated chord from plain Enter, which must stay a no-op.
- **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence.
- **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO.
- **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing.
@@ -0,0 +1,30 @@
# Agent Note: 空输入时 Cmd/Ctrl+Enter 将 Web 排队消息全部插话
Status: implemented
[English](2026-08-06-web-queue-steer-all-gesture.md) | 中文
## Problem
主会话运行时,用户用普通 Enter(或在 busy-Enter 偏好为 Queue 时)输入的消息会在 Web 队列里累积。把它们灌进当前轮次需要逐条点击「插话发送」按钮;而输入框草稿为空时没有任何键盘手势——输入机对空草稿直接拒绝,Enter 与 Cmd/Ctrl+Enter 都是空操作。排队消息一多,逐条插话是明显的多点摩擦,空草稿 + 加速 Enter 正是「全部插话」的自然位置。
## Decision
空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()``SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。
该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。
## Consequences
一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。代价是呈现层多了一个分支,必须与 dock 的门控窗口(running、非 subagent)保持同步——hub 在执行时重新读取快照,所以该门控只是建议性的,host 仍是权威。
## Related
逐条「插话发送」动作及其严格 steer 边界由 [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md) 记录;本笔记只在其之上增加整队列键盘手势。
## Alternatives considered
- **在输入机内拦截。** 已拒绝:输入机按设计不感知队列(队列投影由 wiring 层叠加),且无法区分加速 Enter 与必须保持空操作的普通 Enter。
- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。
- **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。
- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: bbd115eac0eb914914dc11e504639633c801abdd
README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc
README.md: e7b57e25208a402a81284f930644bfc4208c9291
README.zh.md: 1b8543fa24d8e8a62f6b8995619548928ab80ce1
+1 -1
View File
@@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority.
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
+1 -1
View File
@@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
@@ -131,7 +131,7 @@ export function apply(ctx: Context): void {
// The per-session input machine registry (InputService face; published as
// ctx.conversation.input by the service below sharing this one instance).
const inputHub = new InputHub(ctx)
const inputHub = new InputHub(ctx, t)
// Decision 19/20: the input machine feeds every session-scope slot
// component through the standard provide channel — the 'input' hook plus
@@ -88,6 +88,12 @@ export interface ComposerKeyboard {
setDraft(text: string, editRange?: EditRange): void
/** Submit with an explicit delivery mode resolved by the keyboard policy. */
submit(mode: InputSubmitMode): void
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
* button is the same operation applied to the whole queue).
*/
steerQueue(): void
undo(): void
redo(): void
/** Paste over the selection (sync components ride the same transaction). */
@@ -39,6 +39,11 @@ export interface SessionInputDeps {
popup?: (() => PopupDismissFace | undefined) | undefined
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/**
* Steer every still-pending queued message into the running turn, in FIFO
* order (the empty-draft accelerated-Enter gesture); absent = unsupported.
*/
steerQueue?: (() => void) | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: InputSubmitMode): void
}
@@ -173,6 +178,16 @@ export class SessionInputShell implements SessionInput {
return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass'
}
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture). Execution belongs to the hub's
* queue choreography; absent dep = the gesture falls back to the machine's
* empty-draft no-op.
*/
steerQueue(): void {
this.deps.steerQueue?.()
}
/**
* Space adjudication over the controller's hot state.
* @returns true = a claim/insert was applied — the caller preventDefaults.
@@ -10,6 +10,7 @@
*/
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import { queueReadFaceOf } from '../queue/store.ts'
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
@@ -25,8 +26,14 @@ interface CommandFace {
export class InputHub implements InputService {
private readonly shells = new Map<SessionId, SessionInputShell>()
/** @param ctx - client root context (services resolved lazily per call — boot order stays free). */
constructor(private readonly rootCtx: ClientContext) {}
/**
* @param ctx - client root context (services resolved lazily per call — boot order stays free).
* @param t - conversation-namespace translate thunk (reads the active locale at call time).
*/
constructor(
private readonly rootCtx: ClientContext,
private readonly t: TranslateNS<'conversation'>,
) {}
/**
* Resolve the facade for one session-scope ctx (InputService face).
@@ -58,6 +65,7 @@ export class InputHub implements InputService {
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode) => { this.sink(session, text, mode) },
steerQueue: () => { void this.steerQueue(session, shell) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
@@ -139,6 +147,27 @@ export class InputHub implements InputService {
)
}
/**
* Steer every still-pending queued message into the running turn, in FIFO
* order — the same strict-steer operation as the queue dock's per-row
* button. A turn closing mid-way (`steer-unavailable`) or a row already
* claimed by the agent (`queue-item-not-found`) converges silently, while a
* genuine failure surfaces as one composer notice.
* @param session - the addressed host session.
* @param shell - the resident shell (notice outlet).
*/
private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise<void> {
const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued')
if (queued.length === 0) return
for (const item of queued) {
const result = await session.updateQueue(item.id, { kind: 'steer' })
if (result.ok) continue
if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return
shell.notify('error', this.t('queue.steerFailed'))
return
}
}
private controller(actx: ClientContext): SlashController | undefined {
const slash = this.rootCtx.get('slash')
return slash?.sessionOf(actx)
@@ -247,9 +247,20 @@ export function InputBar({
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
const accelerated = e.ctrlKey || e.metaKey
// Empty-draft accelerated Enter acts on the queue instead of the (empty)
// draft: the machine rejects empty drafts, so the gesture steers every
// still-pending queued message into the running turn (the dock's per-row
// steer button applied to the whole queue). Steering needs the same
// window as the per-row button: a running ordinary session.
if (accelerated && empty && running && subagent === null
&& input.queue.some(row => row.placement === 'queued')) {
keyboard.steerQueue()
return
}
keyboard.submit(resolveSubmitMode(
running,
e.ctrlKey || e.metaKey ? 'accelerated' : 'enter',
accelerated ? 'accelerated' : 'enter',
subagent === null,
))
}
@@ -56,6 +56,10 @@ interface BenchOptions {
subagent?: Exclude<ConversationSnapshot['subagent'], null>
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
/** Authoritative queue rows served to the machine overlay (empty = none). */
queue?: ConversationSnapshot['queue']
/** The hub's steer-all face (empty-draft accelerated Enter). */
steerQueue?: () => void
variant?: 'hero' | 'composer'
placeholder?: string
t?: InputBarProps['t']
@@ -69,14 +73,34 @@ interface BenchOptions {
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
function row(id: string): ConversationSnapshot['queue'][number] {
return {
id: id as never, messageId: `message-${id}` as never, placement: 'queued',
content: [{ type: 'text', text: id }], preview: id, text: id,
}
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
function bench(over?: BenchOptions) {
const sink = vi.fn()
const lex = over?.lexicon
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
queue: over?.queue ?? [],
}))
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
const shell = new SessionInputShell({
actx: SCTX,
defaultSink: sink,
queue: {
getSnapshot: () => session.getSnapshot().queue,
subscribe: fn => session.subscribe(fn),
},
...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
@@ -88,12 +112,6 @@ function bench(over?: BenchOptions) {
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
@@ -147,7 +165,7 @@ function bench(over?: BenchOptions) {
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher, steerQueue: over?.steerQueue }
}
describe('Enter semantics', () => {
@@ -190,6 +208,78 @@ describe('Enter semantics', () => {
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
})
it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => {
const steerQueue = vi.fn()
const queue = [row('q-1'), row('q-2')]
const meta = bench({ running: true, queue, steerQueue })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
expect(meta.steerQueue).toHaveBeenCalledTimes(1)
expect(meta.sink).not.toHaveBeenCalled()
const ctrl = bench({ running: true, queue, steerQueue: vi.fn() })
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
expect(ctrl.steerQueue).toHaveBeenCalledTimes(1)
expect(ctrl.sink).not.toHaveBeenCalled()
})
it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => {
// Idle: the gesture falls through to the machine's empty-draft no-op.
const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
expect(idle.steerQueue).not.toHaveBeenCalled()
expect(idle.sink).not.toHaveBeenCalled()
// Plain Enter never steers the queue, even under the busy Steer preference.
const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
expect(plain.steerQueue).not.toHaveBeenCalled()
expect(plain.sink).not.toHaveBeenCalled()
// Subagent sessions keep the queue transport (no steering face).
const subagent = {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable' as const,
},
parentAvailable: true,
}
const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
expect(child.steerQueue).not.toHaveBeenCalled()
expect(child.sink).not.toHaveBeenCalled()
// No queued rows: the empty draft stays a no-op.
const none = bench({ running: true, steerQueue: vi.fn() })
fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true })
expect(none.steerQueue).not.toHaveBeenCalled()
expect(none.sink).not.toHaveBeenCalled()
// Pending steering rows are not the queue: nothing to flush.
const steering = bench({
running: true,
queue: [{ ...row('s-1'), placement: 'steering' }],
steerQueue: vi.fn(),
})
fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })
expect(steering.steerQueue).not.toHaveBeenCalled()
expect(steering.sink).not.toHaveBeenCalled()
})
it('draft content outranks the queue: accelerated Enter steers the draft only', () => {
const steerQueue = vi.fn()
const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue })
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(sink).toHaveBeenCalledWith('插话', 'steer')
expect(steerQueue).not.toHaveBeenCalled()
})
it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => {
const { textarea, sink } = bench({ running: true, queue: [row('q-1')] })
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
expect(sink).not.toHaveBeenCalled()
})
it('platform undo/redo chords route to the machine, never the browser stack', () => {
const { textarea, shell } = bench({ draft: '' })
fireEvent.change(textarea, { target: { value: 'first' } })
@@ -6,8 +6,11 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { InputHub } from '../src/client/input/hub.ts'
import { zh } from '../src/client/locales.ts'
async function bench() {
const runtime = await SlotTestRuntime.create()
@@ -21,13 +24,13 @@ async function bench() {
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
const fiber = runtime.ctx.plugin(ConversationService, {
input: new InputHub(runtime.ctx),
})
const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
const fiber = runtime.ctx.plugin(ConversationService, { input: hub })
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder }
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
@@ -85,9 +88,74 @@ describe('ConversationService', () => {
// No SessionsService at all: a bare context (the runtime always provides one).
const bare = new Context()
await bare.plugin(ConversationService, {
input: new InputHub(bare),
input: new InputHub(bare, makeTranslate(zh, {})),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
})
})
describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
const row = (id: string): QueuedMessage => ({
id: id as never,
messageId: `message-${id}` as never,
placement: 'queued',
content: [{ type: 'text', text: id }],
preview: id,
text: id,
})
it('steers every queued row in FIFO order and leaves steering rows alone', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')]
})
b.shell.steerQueue()
await vi.waitFor(() => {
expect(b.updateQueue).toHaveBeenCalledTimes(2)
})
expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' })
expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' })
expect(b.shell.notices.getSnapshot()).toBeNull()
await b.runtime.dispose()
})
it('converges silently when the turn closes or a row is claimed mid-steer', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), row('q-2')]
})
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) })
expect(b.shell.notices.getSnapshot()).toBeNull()
await b.runtime.dispose()
})
it('surfaces one notice on a genuine steer failure and stops', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), row('q-2')]
})
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'internal', message: 'broken', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => {
expect(b.shell.notices.getSnapshot()).toEqual(
expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }),
)
})
expect(b.updateQueue).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
})
it('no-ops without queued rows', async () => {
const b = await bench()
b.shell.steerQueue()
expect(b.updateQueue).not.toHaveBeenCalled()
await b.runtime.dispose()
})
})