feat(web): surface and configure composer steering
This commit is contained in:
@@ -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': {
|
||||
|
||||
@@ -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<string, readonly CodeSubCall[]>
|
||||
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
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (`<done>/<total> 已完成 · <active item>` 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 `"<done>/<total> tasks · <n> 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 `"<n> 条排队消息"` 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 `"<n> 条排队消息"` 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.
|
||||
|
||||
@@ -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 · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `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(文本记录),因此立即展示、重连和回放共享同一个线性权威。
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const openedRef = useRef(false)
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
const lastSteeringIdRef = useRef<string | null>(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 && <TurnStatus />}
|
||||
{pendingSteering.map(item => (
|
||||
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
|
||||
))}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<div className={css.toBottomSlot}>
|
||||
|
||||
@@ -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 (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined}>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
@@ -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 <UserStyleBubble content={content} pending t={t} />
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
node, retryActive = false, onFork, t,
|
||||
}: MessageItemProps) {
|
||||
|
||||
@@ -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'
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<readonly QueuedMessage[]> | 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()
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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<InputEvent, { type: 'adjudicated' }>['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 }]
|
||||
: []
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BusyEnterBehavior> = 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.
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<QueueItemId | null>(null)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<BusyEnterBehavior>
|
||||
}
|
||||
/** 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<EnterBehaviorRowInjected>
|
||||
|
||||
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 (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('settings.enter.title')}</div>
|
||||
<div className={css.desc}>{t('settings.enter.description')}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { 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={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{t(selectedLabel)}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLTextAreaElement>): void => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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(<h.ChatView {...h.props} />)
|
||||
|
||||
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 }
|
||||
|
||||
@@ -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<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
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(<EnterBehaviorRow {...props} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -40,9 +40,9 @@ function effectAt<T extends InputEffect['type']>(
|
||||
}
|
||||
|
||||
/** 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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}),
|
||||
|
||||
@@ -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[] }
|
||||
/**
|
||||
|
||||
@@ -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<MuxFrame>(
|
||||
@@ -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<MuxFrame>(liveStream, 2, live)
|
||||
// subscribed baseline + one snapshot per accepted inbox occurrence.
|
||||
const liveCollected = collect<MuxFrame>(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<MuxFrame>(
|
||||
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<MuxFrame>(
|
||||
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<MuxFrame>(
|
||||
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 }],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {} } },
|
||||
|
||||
Reference in New Issue
Block a user