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

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