fix(web): flush the intent draft into the list snapshot in the same tick

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.
This commit is contained in:
Chinesezjc
2026-07-26 02:21:58 +08:00
parent 6f50208ea3
commit 50cf90bf6e
6 changed files with 106 additions and 9 deletions

View File

@@ -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 {

View File

@@ -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)