Skip to content

fix(webui): composer clears on send with draft recovery (webui-parity slice 13) - #56

Merged
fengzhi09 merged 4 commits into
mainfrom
fix/composer-send-echo
Sep 27, 2026
Merged

fengzhi09 merged 4 commits into
mainfrom
fix/composer-send-echo

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

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.

  • Optimistic clear: submit parks the outgoing text in a module-scope outbox (lib/composer-sent.ts, mirroring composer-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.
  • Restore on failure: the catch path reads the payload back into the composer and shows the error banner — nothing vanishes.
  • No cross-session or cross-tab restore: the record is keyed by (cid, sessionId), and the session is resolved at catch time from the module-scope store via getActiveSessionId(). 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.
  • Typing during send stays possible; on failure the restored text is merged after any interim draft rather than clobbering it.
  • Same semantics for slash commands (/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:

  • The reported scenario now passes: send in A → 新建会话 → fresh empty B → release the failure → B's composer is empty, banner shown, no leak.
  • The tripwire now fails on the plausible revert: keeping the live accessor in the file while passing closure constants into failComposerSent is 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.
  • No instance-scoped session state remains (the other useRefs are DOM/drag only).
  • Interim-merge, immediate clear, attachments-once, cross-tab isolation all re-verified.

Gates

test:webapp 551/551 · webapp:typecheck 0 · repo typecheck 0 · check:source 4635 ✓ · full server suite 1815 tests / 0 fail / 2 skipped

composer-send-echo and others added 4 commits September 28, 2026 01:05
…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
fengzhi09 force-pushed the fix/composer-send-echo branch from 7e34d52 to 7b9908e Compare September 27, 2026 17:06
@fengzhi09
fengzhi09 merged commit 8be5a69 into main Sep 27, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the fix/composer-send-echo branch September 27, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant