fix(webui): composer clears on send with draft recovery (webui-parity slice 13) - #56
Merged
Merged
Conversation
…arrives
Ticket 13. The composer's success path cleared the draft AFTER the
awaited request resolved, so the entire in-flight window left the
text sitting in the box (the user's screenshot showed exactly that:
'正在发送…' with the text still there). On a hung request, the text
neither sends nor surfaces an error.
Fix: park the message in a module-scope outbox and clear the composer
IMMEDIATELY, before dispatch. On success the outbox flips to
'delivered' and the SSE stream renders the user bubble; on failure
the catch branch reads the text back into the composer. The
record/clear/restore semantics apply to the slash-command branch
exactly the same way as the message branch.
A new module-scope store 'lib/composer-sent.ts' mirrors the
composer-draft.ts idiom (single object, subscribe/get, useSyncExternal-
Store-shaped) so the outbox survives the composer remount
page.tsx triggers when the first user line arrives. Per-cid and
per-session scoping: 'failSend' only hands the text back when
both cid and sessionId still match, so switching sessions during
an in-flight send cannot paste another session's text into the new
session's composer.
Verification:
- composer-sent.test.ts pins the three pure transitions
(dispatchSend / completeSend / failSend), the cid + sessionId
restore-gate, and the store wrapper's subscribe contract.
- pnpm --filter @mavis/webui test:webapp: 546 pass / 0 fail.
- pnpm --filter @mavis/webui webapp:typecheck: clean.
- pnpm typecheck (repo): clean.
- pnpm check:source: clean.
- Live self-check on isolated instance (PORT=18176, FRONTEND=18177,
own data dir, screenshots in /tmp/dev-echo/):
- success: textarea empties immediately after Enter, user bubble
renders after SSE, send button stays disabled until delivery,
typing into the textarea during in-flight is not blocked.
- failure (forced 500 via route interception): the original
text is restored to the composer with the error banner
'消息发送失败: ...'.
- slash command: same record/clear/restore behaviour, calls
/api/cmd not /api/send, text and banner restored on failure.
- session switch: failure in session A while user is in session
B does NOT paste A's text into B's composer (cid + sessionId
mismatch → failSend returns null, record stays untouched).
The textarea's 'disabled={readOnly}' is unchanged — typing during
in-flight already worked; only the second-submit gate (sending on
the send button) blocks re-entry.
Acceptance flagged two regressions on the previous fix:
1. The cid+sessionId restore-gate was dead code because
'submit' captured cid/sessionId once at dispatch and passed the
SAME closure constants to failComposerSent — the gate compared
dispatch-time values against themselves and always passed, so
session-A's failure leaked into session-B's composer.
Fix: read the LIVE session id from a ref (liveStateRef.current)
that mirrors 'state' on every render. The catch handler now
compares the record's dispatch context against the CURRENT
context, so a session switch mid-flight fails the gate and the
text stays out of the new session's composer. The ref is updated
synchronously during render (not via useEffect) so a catch that
fires from a microtask after the render still sees the rotation.
2. A failed send now MERGES interim typing rather than clobbering
it. If the user typed something during the in-flight window,
the restored text is appended after the current draft with a
blank-line separator; both messages remain editable, the banner
explains the failure. If the composer is empty, the restore is
the clean setValue path.
Pins added (the reducers were right; the wiring was the bug):
- packages/webui/webapp/test/composer-submit-tripwire.test.ts
pins the order of operations in 'submit': startComposerSent
and the draft clear must precede any 'await api.sendMessage' /
'await api.sendCommand' (so a future regression to clear-
after-await keeps the existing 550 reducer tests green but
fails this tripwire), and the catch branch must reference
liveStateRef.current (so passing closure constants to the
fix back fails this tripwire).
- packages/webui/webapp/test/composer-sent.test.ts: a new
'live-context semantics' test pins the wire contract — the
args to failSend are the LIVE catch-time context, and a
rotation between dispatch and catch correctly aborts the
restore.
Verification:
- pnpm --filter @mavis/webui webapp:typecheck: clean
- pnpm --filter @mavis/webui test:webapp: 550 pass / 0 fail
(4 new tests: 3 in the tripwire file, 1 in composer-sent)
- pnpm typecheck (repo): clean
- pnpm check:source: clean (4635 files)
- Live self-check on isolated instance (PORT=18176, FRONTEND=18177,
own data dir, screenshots in /tmp/dev-echo-2/):
- success: textarea empties immediately after Enter, user
bubble renders after SSE, send button stays disabled until
delivery, typing during in-flight is not blocked.
- failure (forced 500 via route interception): the original
text is restored to the composer with the error banner.
- slash command: same record/clear/restore behaviour, calls
/api/cmd not /api/send, text and banner restored on failure.
- session switch: failure in session A while user is in
session B does NOT paste A's text into B's composer
(cid + sessionId mismatch -> failSend returns null).
- interim typing: failed message is APPENDED to the
user's interim draft, neither vanishes.
Untouched (per 'another agent merging slice 04b' constraint):
panels.tsx, toolbar.tsx, persist.ts, page.tsx. No process-safety
violations.
…ighten tripwire
Acceptance re-verify caught two regressions on v2:
1. The live-state ref (liveStateRef) was instance-scoped, but
the 新建会话 → fresh empty B scenario unmounts the composer:
page.tsx swaps the composer between the inline and chat-tree
positions when hasConversation flips, and a fresh session
clears chat. The in-flight submit's closure kept the OLD
composer's ref frozen at the dispatch-time session id and
never saw the rotation — so a session-A failure still leaked
into session B's composer. Acceptance verified v2's fix only
worked for chat→chat switching (the instance survives there).
Fix: add getActiveSessionId() to lib/store.tsx and read from
it at catch time. The store snapshot is module-scope — the
same store the SSE handler writes — so it always reflects the
current session regardless of which composer instance is
mounted or unmounted. The cid side has always been module-
scope via clientId() (lib/cid.ts).
2. The tripwire's catch-branch assertion grepped for
liveStateRef.current and let any plausible revert that kept
the identifier (or a renamed copy) in the file pass. A revert
that keeps the identifier but passes closure constants to
failComposerSent's call site would have shipped.
Tightened assertion 2: it now slices the entire catch block,
requires clientId() AND getActiveSessionId() to be called in
it, AND forbids dispatchSessionId / dispatchCid from appearing
inside the failComposerSent argument list. A revert that
passes closure constants to the gate fails on the forbidden-
identifier assertion; a revert that removes the live accessor
fails on the missing-call assertion. A separate assertion
pins the import line at the top of the module so a missing
import is caught even before runtime.
Verification:
- pnpm --filter @mavis/webui webapp:typecheck: clean
- pnpm --filter @mavis/webui test:webapp: 551 pass / 0 fail
(was 550 — +1 'the live accessor is imported' assertion)
- pnpm typecheck (repo): clean
- pnpm check:source: clean (4635 files)
Live self-check on isolated instance (PORT=18176, FRONTEND=18177,
own data dir, killed via verified PGID, no pattern kills):
- Success: textarea empties immediately after Enter, user
bubble renders after SSE, typing during in-flight is not
blocked.
- Failure (forced 500 via route interception, no session
switch): original text restored to composer, banner shown.
- FRESH SESSION (the acceptance scenario): send in A held,
click 新建会话 (composer remounts because hasConversation
flips), release the failure → B's composer stays empty,
banner shows '消息发送失败: fresh-session-A-failure'.
Screenshot at /tmp/dev-echo-5/01-fresh-session.png.
Untouched (per slice-04b constraint): panels.tsx, toolbar.tsx,
persist.ts, page.tsx. No process-safety violations.
test:webapp 586/586 · webapp:typecheck clean · check:source 4641
fengzhi09
force-pushed
the
fix/composer-send-echo
branch
from
September 27, 2026 17:06
7e34d52 to
7b9908e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Slice 13, from the user's screenshot: after sending, the text stayed in the composer while the message was already going out ("正在发送…"). The user asked for a "record only / draft" state — the message must not be lost, and it must appear as a message normally, not remain in the input box.
submitparks the outgoing text in a module-scope outbox (lib/composer-sent.ts, mirroringcomposer-draft.ts) and clears the composer before the await. The backend does session switching and transcript backfill before its ack, so clearing after the await left the text sitting in the box for the whole in-flight window.(cid, sessionId), and the session is resolved at catch time from the module-scope store viagetActiveSessionId(). This matters: creating a new session unmounts the composer, so an instance-scoped ref is frozen at the dispatch-time session and leaks the old session's text into the new one. That was caught in acceptance and fixed in this PR./api/cmd).Acceptance (independent agent) — FAIL twice, then PASS
The first pass failed on session isolation; the re-verify caught that the fix was scoped to a per-instance ref and missed the exact reported flow (新建会话 unmounts the composer), and also found a hole in the tripwire I had mandated. Both fixed here:
failComposerSentis rejected by the "catch branch passes LIVE module-scope values" assertion; reverting to clear-after-await fails the "record parked BEFORE any awaited send" assertion. Each revert fails one targeted assertion.useRefs are DOM/drag only).Gates
test:webapp551/551 ·webapp:typecheck0 · repotypecheck0 ·check:source4635 ✓ · full server suite 1815 tests / 0 fail / 2 skipped