feat(web): steer queued messages into active turns

This commit is contained in:
kingwl
2026-07-30 03:17:54 +08:00
committed by imccyu
parent fff0172208
commit 955a12cca4
49 changed files with 508 additions and 147 deletions

View File

@@ -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/runtime/README.md
README.md: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c
README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a
README.md: a98e97d796ca4e4be07d8d8d25ebc0a24d066a8b
README.zh.md: a973c4fbbf16633fed11d137ae548604516d8a51

View File

@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit, remove, and strict-steer operations without optimistic mutation, so the next Host snapshot is the sole visible commit; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`.
## The human transcript

View File

@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑、移除和严格 steering 操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果;认领与窗口关闭竞态分别会返回 `queue-item-not-found` 和 `steer-unavailable`。
## 面向人的 transcript(文本记录)

View File

@@ -39,9 +39,9 @@ export interface ISession {
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Apply one mutation to a still-pending queue occurrence.
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @param action - requested queue operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>

View File

@@ -110,11 +110,20 @@ describe('queue operation transport', () => {
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
}])
await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
},
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'steer' },
},
])
expect(session.getSnapshot().queue).toBe(before)
})
})

View File

@@ -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: d92202e6dc2b1003db0cdf97910746a0de133357
README.zh.md: a75ee75a5fb2b3d8e5278283b6b6da15bf9c02cd
README.md: 2bf74454fab14303f305b9822e1032f67de0d3ae
README.zh.md: 2677ab5cc85c16fffeca688a4648caf2410ba895

View File

@@ -62,5 +62,5 @@ None; this package neither assembles nor sends a provider request.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **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 with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay.
- **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.

View File

@@ -62,5 +62,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering(中途引导)操作会被保存和取消取代;Enter 保存,Escape 取消。
- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。Host 不会把待处理 steering 纳入 Queue 快照;已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以普通气泡呈现,因此回放仍然如实。

View File

@@ -89,8 +89,11 @@ export const zh = {
'queue.save': '保存排队消息',
'queue.cancelEdit': '取消编辑',
'queue.remove': '删除排队消息',
'queue.steer': '插话发送',
'queue.steer.unavailable': '仅运行中可插话发送',
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
'queue.steerFailed': '插话失败:当前回复已结束,或这条消息已经开始发送。',
'terminal.signal': '信号 {signal}',
'terminal.exitCode': '退出码 {code}',
'terminal.running': '运行中',
@@ -189,8 +192,11 @@ export const en = {
'queue.save': 'Save queued message',
'queue.cancelEdit': 'Cancel editing',
'queue.remove': 'Remove queued message',
'queue.steer': 'Steer queued message',
'queue.steer.unavailable': 'Steering is available only while the agent is running',
'queue.editFailed': 'Edit failed: this message may have already started sending.',
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
'queue.steerFailed': 'Steering failed: the current response ended or this message already started sending.',
'terminal.signal': 'signal {signal}',
'terminal.exitCode': 'exit code {code}',
'terminal.running': 'Running',

View File

@@ -9,7 +9,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
IconCloseOutline16, IconEditOutline16, IconRightUpOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import { NS } from '../locales.ts'
@@ -30,6 +30,7 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
*/
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
const queue = useSession(s => s.queue)
const running = useSession(s => s.running)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
const [collapsed, setCollapsed] = useState(true)
@@ -170,6 +171,22 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconRightUpOutline16 size={14} />
</button>
</>
)}
</div>

View File

@@ -31,9 +31,9 @@ export interface IConversation {
*/
send(text: string): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* Apply one edit, remove, or strict steer operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @param action - requested queue operation.
* @returns completion; business failures reject.
*/
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* QueueDock rendering and operations: authoritative rows, inline editing,
* collapse state, removal, failure notices, and live retirement.
* collapse state, removal, strict steering, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
@@ -187,10 +187,10 @@ describe('QueueDock', () => {
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(5)
expect(container.querySelectorAll('button')).toHaveLength(7)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
@@ -273,6 +273,45 @@ describe('QueueDock', () => {
})
})
it('strictly steers complete row content only while the agent is running', async () => {
const running = snapshotWith([row('i-steer', null, 'image [image]')])
const source = liveSession(running)
const updateQueue = vi.fn(() => Promise.resolve())
const rendered = render(
<QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
)
const button = rendered.getByLabelText('插话发送')
expect(button).toHaveProperty('disabled', false)
fireEvent.click(button)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' })
})
act(() => { source.push({ ...running, running: false }) })
expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true)
expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
})
it('keeps the row and reports a strict steer race', async () => {
const snap = snapshotWith([row('i-steer-race', 'pending steer')])
const source = liveSession(snap)
const notify = vi.fn()
const updateQueue = vi.fn(() => Promise.reject(new Error('steer unavailable')))
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('插话发送'))
await waitFor(() => {
expect(notify).toHaveBeenCalledWith(
'error',
'插话失败:当前回复已结束,或这条消息已经开始发送。',
)
})
expect(getByText('pending steer')).toBeTruthy()
})
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
const snap = snapshotWith([row('i-race', 'pending')])
const source = liveSession(snap)