Merge remote-tracking branch 'origin/master' into worktree/2986-trajectory-image-attachments

This commit is contained in:
creatixchu
2026-08-24 20:32:07 +08:00
13 changed files with 184 additions and 23 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md
2026-08-20-running-draft-primary-send.md: 79c8c3a74fc49a325b229324b0d96b2387308d3d
2026-08-20-running-draft-primary-send.zh.md: 180352810f57ecc04882322318f9708cf247a09c
@@ -0,0 +1,31 @@
# Agent Note: Running drafts take the primary Send action
Status: implemented
English | [中文](2026-08-20-running-draft-primary-send.zh.md)
## Problem
The ordinary Web composer remains editable while a Turn is running, and keyboard submission can queue or steer its draft. Its single primary pointer control nevertheless stayed on Stop for the entire Turn. A pointer user who entered a follow-up and activated that control stopped the current Turn instead of submitting the visible draft, so the control contradicted the composer's editable state and the user's current content.
## Decision
`InputBar` chooses the ordinary session's primary action from the running state, draft content, and owner block. An empty running composer shows Stop and routes it through the existing session cancellation callback. Non-whitespace text or at least one attachment changes that same control to Send; the click uses the existing Queue submission path. An owner-blocked running composer keeps Stop even when a retained draft exists, because the block disables both editing and submission. Clearing the draft or completing a successful submission restores Stop while the Turn remains active. Idle sessions continue to show Send, disabled while the draft is empty or submission is unavailable.
The pointer action does not inherit the `ui-conversation.busyEnter` preference. That preference continues to choose Queue or Steer only for the two keyboard gestures. Continuable subagents retain independent Send and Stop controls, and one-shot subagents retain their read-only behavior.
## Verification
The `InputBar` component test covers empty, text, cleared, submitted, attachment-only, and owner-blocked running drafts, including Queue submission while the keyboard preference selects Steer. The keyless assembled Web scenario parks a real composed Turn in the replay adapter, captures the running draft with Send, clicks it through the Host Queue path, observes Stop return after the draft clears, removes the queued row, and then cancels the Turn.
## Alternatives considered
**Keep Stop for the whole running Turn.** This preserves immediate cancellation but leaves the visible editable draft without a pointer submission action and makes the primary control act against the content beside it.
**Render Send and Stop simultaneously for every running session.** Continuable subagents need two independent operations because their cancellation route differs from continuation delivery. Ordinary sessions have one established primary seat; adding a permanent second control would spend more space and create a different hierarchy when the draft itself already identifies the immediate action.
**Apply the busy-Enter preference to pointer Send.** A button labeled Send would silently change between Queue and Steer according to a keyboard preference. Keeping pointer submission on Queue preserves the existing explicit distinction and avoids an invisible mode on the button.
## Consequences
Pointer users can submit a follow-up without waiting for the active Turn or using a keyboard shortcut. An actionable draft occupies the single primary seat, so Stop returns after the draft is cleared or accepted rather than remaining simultaneously visible; an owner block returns that seat to Stop because the retained draft cannot be edited or submitted. Keyboard delivery selection, cancellation transport, and subagent controls are unchanged. Issue #2850 records the user-facing defect and acceptance boundary.
@@ -0,0 +1,31 @@
# Agent Note: 运行中草稿取得主 Send 操作
Status: implemented
[English](2026-08-20-running-draft-primary-send.md) | 中文
## 问题
普通 Web composer 在 Turn 运行期间仍可编辑,键盘提交也能把草稿送入 Queue 或 Steer。然而,其唯一的主指针控件会在整个 Turn 中一直保持 Stop。指针用户输入后续消息并激活该控件时,会停止当前 Turn,而不是提交眼前的草稿;该控件因而与 composer 的可编辑状态和用户当前内容相冲突。
## 决策
`InputBar` 根据运行状态、草稿内容和 owner block 选择普通会话的主操作。运行中的 composer 为空时显示 Stop,并通过既有会话取消回调执行。存在非空白文字或至少一个附件时,同一控件切换为 Send,点击后使用既有 Queue 提交路径。owner block 会禁用编辑与提交,因此即使保留了草稿,运行中的 composer 也保持 Stop。清空草稿或成功提交后,只要 Turn 仍在运行,就会恢复 Stop。空闲会话仍显示 Send;草稿为空或无法提交时,该按钮保持禁用。
指针操作不继承 `ui-conversation.busyEnter` 偏好。该偏好仍然只为两个键盘手势选择 Queue 或 Steer。可继续 subagent 保留相互独立的 Send 与 Stop 控件,one-shot subagent 保持只读行为。
## 验证
`InputBar` 组件测试覆盖运行中草稿的空白、文字、清空、提交成功、仅附件和 owner-blocked 状态,并证明键盘偏好选择 Steer 时,按钮提交仍使用 Queue。无密钥的组装 Web 场景通过 replay 适配器停住真实组合出的 Turn,捕获显示 Send 的运行中草稿,经 Host Queue 路径点击提交,在草稿清空后观察 Stop 恢复,移除 Queue 行,再取消该 Turn。
## 备选方案
**在整个运行中 Turn 保持 Stop。** 这样可以始终立即取消,但可见的可编辑草稿没有指针提交操作,主控件也会执行与相邻内容相反的动作。
**为每个运行中会话同时渲染 Send 与 Stop。** 可继续 subagent 需要两个独立操作,因为其取消路由与继续投递不同。普通会话已有单一主操作位置;永久增加第二个控件会占用更多空间,并在草稿本身已经指明当前操作时引入另一套层级。
**让指针 Send 采用 busy-Enter 偏好。** 标记为 Send 的按钮会随键盘偏好在 Queue 与 Steer 之间静默变化。保持指针提交始终使用 Queue,可以保留既有显式区分,避免按钮携带不可见模式。
## 影响
指针用户无需等待当前 Turn 结束或使用键盘快捷键,即可提交后续消息。可操作草稿会占用唯一的主操作位置,因此 Stop 会在草稿清空或被接纳后恢复,而不是同时显示;owner block 会让该位置恢复 Stop,因为保留的草稿无法编辑或提交。键盘投递选择、取消传输和 subagent 控件均不变。Issue #2850 记录用户可见缺陷与验收边界。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md
2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76
2026-08-06-web-queue-steer-all-gesture.zh.md: 1f65933ac430ca22ac8d6c78471a3bd08b421ad6
2026-08-06-web-queue-steer-all-gesture.md: c51e837d37b61a73f9602445daea9fbdfc77a299
2026-08-06-web-queue-steer-all-gesture.zh.md: b0009a7a51ebe89f61d1a4b995b1138a7247d266
@@ -30,4 +30,4 @@ The per-row 插话发送 action and its strict-steer boundary are owned by [Stee
- **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence.
- **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO.
- **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing.
- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly.
- **A send-button tooltip.** Rejected: the primary button is Stop in the empty-draft running window, which is also the only window where the whole-queue gesture is available. The placeholder occupies that exact window and can describe the keyboard action directly.
@@ -30,4 +30,4 @@ Status: implemented
- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。
- **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。
- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。
- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。
- **发送按钮 tooltip。** 已拒绝:主按钮在空草稿的运行窗口内是 Stop这也是整队列手势唯一可用的窗口。placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。
+27 -8
View File
@@ -1,6 +1,7 @@
// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
// transient-retry recovery, and retry exhaustion, all through the real
// composition and wire. The model adapter is dsh-llm-replay with override
// Web e2e scenarios: live-turn interactions — running-draft submission,
// cancellation, error surfacing, transient-retry recovery, and retry
// exhaustion, all through the real composition and wire. The model adapter is
// dsh-llm-replay with override
// sidecars: `hang` (+ a readyFile marker) makes mid-stream cancel
// deterministic by construction, `throw` entries express provider failures by
// stable code, and `{ patches }` augmentation injects transient throws before
@@ -29,11 +30,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// One golden pins the stable mid-turn loading state; the other four capture
// what the user is left looking at after cancel, after a non-retryable failure,
// after retry recovery, and after retry exhaustion.
// One golden pins the empty mid-turn loading state, one pins the sendable draft
// state, and the other four capture what remains after cancel, after a
// non-retryable failure, after retry recovery, and after retry exhaustion.
const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md')
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md')
@@ -44,6 +46,7 @@ const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-se
// patch. Kept deliberately tool-free so the derived script is exactly one
// model call.
const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
const RUNNING_DRAFT = 'Queue this follow-up while the current turn is running.'
/** turn/end reasons observed, in order. */
function turnEndReasons(events: SessionEvent[]): string[] {
@@ -147,6 +150,22 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
).toBe(true)
const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
const input = page.locator('textarea').first()
await input.fill(RUNNING_DRAFT)
const send = page.getByRole('button', { name: 'Send message', exact: true })
await send.waitFor({ timeout: 10_000 })
expect(await page.getByRole('button', { name: 'Stop generating', exact: true }).count()).toBe(0)
const runningDraftSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(RUNNING_DRAFT_EXPECTED, runningDraftSnapshot, MODE)
await send.click()
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: RUNNING_DRAFT })
await queuedRow.waitFor({ timeout: 10_000 })
await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor({ timeout: 10_000 })
await queuedRow.getByRole('button', { name: 'Remove queued message' }).click()
await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
@@ -281,8 +300,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md',
'retry-exhausted.expected.md',
'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'running-draft.expected.md',
'error-auth.expected.md', 'retry.expected.md', 'retry-exhausted.expected.md',
])
})
})
@@ -0,0 +1,28 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: Standard mode
- button "Session log":
- text: Session log
- img
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- textbox "Message the agent": Queue this follow-up while the current turn is running.
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message"
@@ -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: 14d2665abea44f395e758deaf527bfe767e0fff6
README.zh.md: ae118eb2f07db9ee7ee464ee9b467721c20254e7
README.md: 8506cc9e2d2935151003ad11e34d056dda70e0ff
README.zh.md: bf6643112878f6537d3db1d1686db046202fada5
@@ -20,6 +20,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis
The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
An ordinary running composer keeps Stop as its primary pointer action while its draft is empty or an owner block makes input unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. Keyboard Queue/Steer selection remains governed by the busy-Enter setting, while continuable subagents keep independent Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)).
## Temporary composer entries
`conversation.composer` is a generic chain. Its complete owner currency is:
@@ -20,6 +20,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inertWorkspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
普通 composer 运行期间,草稿为空或 owner block 使输入不可用时,主指针操作保持为 Stop。可提交文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。键盘 Queue/Steer 选择仍由繁忙态 Enter 设置决定,可继续 subagent 则保留相互独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。
## 临时 composer entry
`conversation.composer` 是通用 chain,其完整 owner currency 为:
@@ -554,10 +554,10 @@ export function InputBar({
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
// Ordinary sessions retain their primary Send/Stop toggle. A continuable
// child keeps Send as the primary action and exposes Stop independently so
// pointer users can queue follow-ups while its current turn is running.
const primaryStops = running && subagent === null
// An ordinary running session keeps Stop while the composer is empty or
// owner-blocked; an actionable draft gets the existing Queue action. A
// continuable child keeps Send primary and exposes Stop independently.
const primaryStops = running && subagent === null && (empty || blocked !== undefined)
const interruptible = running && continuable
const primaryLabel = primaryStops ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
@@ -64,6 +64,7 @@ interface BenchOptions {
subagent?: Exclude<SessionSnapshot['subagent'], null>
disabled?: boolean
inert?: boolean
blocked?: { readonly reason: string }
workspacePickerOpen?: boolean
onRequestWorkspace?: () => void
promptError?: SessionSnapshot['promptError']
@@ -186,6 +187,7 @@ function bench(over?: BenchOptions) {
renderSlot,
variant: over?.variant ?? 'composer',
...(over?.inert === true ? { disabled: true } : {}),
...(over?.blocked !== undefined ? { blocked: over.blocked } : {}),
...(over?.workspacePickerOpen !== undefined ? { workspacePickerOpen: over.workspacePickerOpen } : {}),
...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}),
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
@@ -196,7 +198,9 @@ function bench(over?: BenchOptions) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
const sendableDraft = (over?.draft?.trim() ?? '') !== '' || (over?.attachments?.length ?? 0) > 0
const primaryStops = over?.running === true && over.subagent === undefined
&& (!sendableDraft || over.blocked !== undefined)
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
)!
@@ -606,15 +610,53 @@ describe('Enter semantics', () => {
})
describe('running and lock semantics', () => {
it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
it('running switches the primary between Stop and Queue Send with the draft', async () => {
const { textarea, button, stop, sink } = bench({ running: true, busyEnter: 'steer' })
expect(textarea.disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
fireEvent.change(textarea, { target: { value: '排队消息' } })
expect(button.getAttribute('aria-label')).toBe('发送消息')
fireEvent.change(textarea, { target: { value: ' ' } })
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.change(textarea, { target: { value: '排队消息2' } })
expect(button.getAttribute('aria-label')).toBe('发送消息')
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') })
expect(stop).toHaveBeenCalledTimes(1)
})
it('running treats an attachment-only draft as Send', async () => {
const attachment = {
kind: 'image' as const,
id: 'draft-1' as DraftAttachmentId,
file: new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }),
previewUrl: 'blob:pixel',
}
const { button, sink } = bench({ running: true, attachments: [attachment] })
expect(button.getAttribute('aria-label')).toBe('发送消息')
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal))
await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') })
})
it('running blocked composer keeps Stop with a retained draft', () => {
const { button, sink, stop, textarea } = bench({
running: true,
draft: '保留的草稿',
blocked: { reason: '请选择可用模型' },
placeholder: '请选择可用模型',
})
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('请选择可用模型')
expect(button.getAttribute('aria-label')).toBe('停止生成')
expect(button.disabled).toBe(false)
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
expect(sink).not.toHaveBeenCalled()
})
it('running plain Enter follows the busy-state Steer preference', () => {