From 50cf90bf6ef6f817afc5a6e6fbd60c4096046750 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 02:21:58 +0800 Subject: [PATCH] fix(web): flush the intent draft into the list snapshot in the same tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero composer renders the frontend Session Intent's retained prompt from the sessions list snapshot, but updateIntent only reached that snapshot through the intent watch's microtask-deferred markDirty. React therefore rolled the controlled textarea back during the change tick, which corrupted IME composition (Pinyin "nihao" committed fragments like "nnini hni hani hao你好") and jumped the caret on plain typing. SessionManager.updateIntent now calls notifyNow after updatePendingPrompt, per the Notifier channel rule for direct echoes of user gestures. The workspace-flow snapshot helper asserts the same-tick echo instead of waiting for it, and a runtime unit test pins the contract at the manager seam. --- ...7-26-intent-draft-same-tick-echo.i18n.yaml | 6 ++++ .../2026-07-26-intent-draft-same-tick-echo.md | 27 ++++++++++++++++++ ...26-07-26-intent-draft-same-tick-echo.zh.md | 27 ++++++++++++++++++ apps/web/tests/workspace-flow.snapshot.ts | 18 ++++++------ .../runtime/src/client/sessions/manager.ts | 9 +++++- .../runtime/tests/session-intents.spec.ts | 28 +++++++++++++++++++ 6 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml new file mode 100644 index 0000000000..390f118a55 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml @@ -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 +2026-07-26-intent-draft-same-tick-echo.md: 1a4fdb48c0434bd37d7771dddb640720e1b610e6 +2026-07-26-intent-draft-same-tick-echo.zh.md: 9ecdf7154f5014de242021f99d2e51959c2a3169 diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md new file mode 100644 index 0000000000..1a4fdb48c0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md @@ -0,0 +1,27 @@ +# Agent Note: Intent draft echoes in the same tick + +Status: implemented + +English | [中文](2026-07-26-intent-draft-same-tick-echo.zh.md) + +## Problem + +The hero composer ("Let's start building") is a controlled textarea whose value is the frontend Session Intent's retained prompt, read from the sessions **list** snapshot (`EmptyState` binds `intent.prompt` via `useSessions`). Typing routed through `SessionManager.updateIntent → Session.updatePendingPrompt`, which flushes the **Session's own** notifier synchronously — but the list snapshot the composer actually renders from only heard about the change through the intent watch subscription in `startIntent`, which calls `markDirty()`, a microtask-deferred flush. + +A deferred echo violates the controlled-input contract documented on the Notifier (see the [web client architecture note](../architecture/2026-07-19-gui-web-client-architecture.md)): React compares the DOM value against the still-stale snapshot during the same tick as `onChange` and rolls the textarea back. With plain typing this shows as caret jumps; with an IME it corrupts input — every composition update gets rolled back and re-applied against a stale value, so typing Pinyin "nihao" commits fragments like "nnini hni hani hao你好". The resident composer (`ConversationRoot`) was not affected: its draft lives in the chat store (sync flush) or comes from `updateSessionPrompt`, which reads the Session snapshot directly rather than the list projection. + +## Decision + +`SessionManager.updateIntent` calls `this.notifier.notifyNow()` after `updatePendingPrompt`, flushing the list snapshot in the same tick as the change event. This matches the Notifier's channel rule: a direct echo of a user gesture whose controlled input renders from this snapshot uses `notifyNow`; the intent watch keeps `markDirty` for every other (async) intent transition. + +## Alternatives considered + +**Change the intent watch callback in `startIntent` to `notifyNow`.** Wrong channel for that seam: the watch also fires on frame-driven Session changes (publication, send phases), and the architecture note bans `notifyNow` for frame-driven sources because it collapses batching. + +**Have `EmptyState` read the prompt from the Session snapshot instead of the list.** Restructures the slot contract (EmptyState is deliberately bound to the standard `useSessions` feed and has no session scope yet — the frontend Session is page-local) for no gain over flushing the projection it already reads. + +**Suppress the rollback in `InputBar` with local uncontrolled state.** Hides the symptom, forfeits the single-source-of-truth draft (the retained prompt must survive workspace retargeting and send/retry), and leaves every other list-snapshot-controlled input exposed. + +## Consequences + +Typing in the hero composer, IME composition included, echoes synchronously. `updateIntent` on a no-intent state stays a no-op with no notification. The web workspace-flow snapshot's composer helper now asserts the same-tick echo instead of waiting for it, so a regression to a deferred echo fails the keyless snapshot gate; a runtime unit test pins the same contract at the manager seam. diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md new file mode 100644 index 0000000000..9ecdf7154f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Intent draft echoes in the same tick + +Status: implemented + +[English](2026-07-26-intent-draft-same-tick-echo.md) | 中文 + +## Problem + +hero composer(「Let's start building」)是一个受控(controlled)的 textarea,它的值取自前端 Session Intent 保留下来的提示词,读自会话**列表**快照(`EmptyState` 通过 `useSessions` 绑定 `intent.prompt`)。输入经由 `SessionManager.updateIntent → Session.updatePendingPrompt`,后者会同步刷新 **Session 自身的** notifier——但 composer 实际渲染所依据的那份列表快照,只能通过 `startIntent` 中的 intent watch 订阅得知这次变更,而该订阅调用的是 `markDirty()`,即一次延迟到微任务的刷新。 + +延迟的回显违反了 Notifier 上所记录的受控输入契约(见 [web 客户端架构笔记](../architecture/2026-07-19-gui-web-client-architecture.md)):React 在与 `onChange` 相同的 tick 内,把 DOM 值与仍然陈旧的快照相比对,随后把 textarea 回滚。普通输入时,这表现为光标跳动;使用输入法(IME)时,它会损坏输入——每一次 composition 更新都会被回滚,并针对陈旧的值重新应用,因此输入拼音「nihao」会提交出类似「nnini hni hani hao你好」这样的片段。resident composer(`ConversationRoot`)不受影响:它的草稿存放在 chat store 中(同步刷新),或来自 `updateSessionPrompt`,后者直接读取 Session 快照,而不是列表投影。 + +## Decision + +`SessionManager.updateIntent` 在 `updatePendingPrompt` 之后调用 `this.notifier.notifyNow()`,从而在与变更事件相同的 tick 内刷新列表快照。这符合 Notifier 的通道规则:当某个用户手势的受控输入正是从该快照渲染时,对它的直接回显使用 `notifyNow`;而 intent watch 对其余所有(异步的)intent 状态转换仍保留 `markDirty`。 + +## Alternatives considered + +**把 `startIntent` 中的 intent watch 回调改为 `notifyNow`。** 对那个 seam 而言是错误的通道:该 watch 也会在帧驱动的 Session 变更(发布、发送阶段)时触发,而架构笔记禁止对帧驱动的来源使用 `notifyNow`,因为那会瓦解批处理。 + +**让 `EmptyState` 从 Session 快照而非列表读取提示词。** 这会重构槽位契约(EmptyState 有意绑定到标准的 `useSessions` 数据源,且尚无 session 作用域——前端 Session 是页面本地的),相比刷新它本就读取的那份投影并无收益。 + +**在 `InputBar` 中用本地的非受控状态抑制回滚。** 这只是掩盖症状,放弃了单一真源的草稿(保留下来的提示词必须在工作区重定向以及发送/重试后依然存在),并让其余每一个由列表快照控制的输入都暴露在同一问题之下。 + +## Consequences + +在 hero composer 中输入(包括输入法 composition 在内)会同步回显。在无 intent 的状态上调用 `updateIntent` 仍是一次空操作,不发出任何通知。web workspace-flow 快照的 composer 辅助函数现在断言的是同一 tick 内的回显,而不是等待它,因此一旦回退成延迟回显,就会让无密钥快照门禁失败;一个运行时单元测试在 manager 这一 seam 处钉住了同一份契约。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 78ac843a64..3c96374605 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -116,10 +116,12 @@ function workspaceChip(): HTMLElement { return chip } -/** Wait for the runtime-owned controlled input to echo a browser edit. */ -async function setComposerText(composer: HTMLElement, value: string): Promise { +/** Edit the runtime-owned controlled input and assert the same-tick echo: + * a deferred echo makes React roll the textarea back mid-IME-composition, + * committing partial keystrokes (e.g. Pinyin "nihao" leaking as "nnini h…"). */ +function setComposerText(composer: HTMLElement, value: string): void { fireEvent.change(composer, { target: { value } }) - await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) }) + expect((composer as HTMLTextAreaElement).value).toBe(value) } it('starts a writable page-local draft without inventing a sidebar Workspace', async () => { @@ -127,7 +129,7 @@ it('starts a writable page-local draft without inventing a sidebar Workspace', a const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const tree = screen.getByRole('tree', { name: 'Sessions' }) - await setComposerText(composer, 'keep this local') + setComposerText(composer, 'keep this local') expect({ headline: visibleText(screen.getByText("Let's start building")), @@ -188,7 +190,7 @@ it('drops the page-local draft on refresh while retaining real Workspaces and Se const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const tree = screen.getByRole('tree', { name: 'Sessions' }) - await setComposerText(composer, 'discard this page-local draft') + setComposerText(composer, 'discard this page-local draft') const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]') if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh') @@ -232,7 +234,7 @@ it('keeps a published Session with only cwd membership evidence in Ungrouped', a boot('?fixture&fixtureAttach=fail') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'keep this cwd-only session') + setComposerText(composer, 'keep this cwd-only session') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const tree = screen.getByRole('tree', { name: 'Sessions' }) @@ -267,7 +269,7 @@ it('materializes the automatic Workspace and Session on the first successful sen boot('?fixture=empty') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'build a lighthouse') + setComposerText(composer, 'build a lighthouse') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const tree = screen.getByRole('tree', { name: 'Sessions' }) @@ -296,7 +298,7 @@ it('keeps the published Workspace, Session, and unsent prompt after rejection', boot('?fixture=empty&fixturePrompt=reject') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'do not lose this') + setComposerText(composer, 'do not lose this') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const alert = await screen.findByRole('alert', {}, { timeout: 10_000 }) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 907fc961f6..51d07e70e6 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -162,7 +162,14 @@ export class SessionManager { * @param text - exact controlled-input value for the active frontend Session. */ updateIntent(text: string): void { - this.getIntent()?.updatePendingPrompt(text) + const session = this.getIntent() + if (session === undefined) return + session.updatePendingPrompt(text) + // The intent watch defers via markDirty, but the hero composer reads this + // prompt from the LIST snapshot as a controlled value: it must flush in + // the same tick as onChange (see Notifier.notifyNow) or React rolls the + // textarea back and IME composition breaks. + this.notifier.notifyNow() } private discardIntent(): void { diff --git a/packages/client/runtime/tests/session-intents.spec.ts b/packages/client/runtime/tests/session-intents.spec.ts index c09bfa4ee4..fca2c3e0b4 100644 --- a/packages/client/runtime/tests/session-intents.spec.ts +++ b/packages/client/runtime/tests/session-intents.spec.ts @@ -60,6 +60,34 @@ describe('frontend Session and Workspace intents', () => { expect(workspaces.list.getSnapshot().intent).toBeUndefined() }) + it('echoes updateIntent into the list snapshot in the same tick (controlled-input contract)', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + await ready(api, workspaces, sessions, [workspace('target')]) + let notified = 0 + sessions.list.subscribe(() => { notified += 1 }) + // IME composition drives change events that a controlled textarea must see + // reflected before the handler returns; a microtask-deferred echo makes + // React roll the DOM back and the composition commits partial keystrokes. + sessions.updateIntent('你') + expect(sessions.list.getSnapshot().intent?.prompt).toBe('你') + expect(notified).toBeGreaterThan(0) + }) + + it('ignores updateIntent with no active Intent', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + await ready(api, workspaces, sessions, [workspace('only', [sid('s-real')])], [ + { sessionId: sid('s-real'), updatedAt: 1, running: false }, + ]) + sessions.open(sid('s-real')) + expect(sessions.list.getSnapshot().intent).toBeUndefined() + let notified = 0 + sessions.list.subscribe(() => { notified += 1 }) + sessions.updateIntent('dropped') + expect(notified).toBe(0) + }) + it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => { const api = new FakeApiClient() const { sessions, workspaces } = services(api)