test_same_stream_in_two_tabs_receives_identical_token_sequence started both
SSE-handler threads then immediately put_nowait'd the tokens + stream_end
without waiting for both threads to subscribe. StreamChannel.put_nowait()
buffers events only while there are zero subscribers and clears that buffer
the moment the first subscriber attaches and broadcasts live — so producing
events in the window after tab A subscribes but before tab B does orphans
tab B (subscribes to a cleared buffer, never sees stream_end, hangs ->
thread.join timeout -> 'every tab should finish' fails). ~20% timing flake
on slow CI runners (3.11 shard-4).
Wait until len(stream._subscribers) == len(handlers) (bounded 5s) before
producing events, so delivery is deterministic regardless of thread-start
timing. Test-only; no production change (buffer-then-broadcast is correct for
the real reconnect case).
Verified: original flakes 2/30 under CPU contention; fixed 30/30.
Fixes#5628
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Fable senior review (COMMENT, no blockers) + DRY nit:
- Widen the ResizeObserver from #msg to the whole #composerWrap so growth from the
attachment tray / selection chips (not just textarea rows) also re-pins a bottom-
pinned reader. Fable flagged tray/chip growth as a likely REMAINING #5515 trigger
— same viewport-shrink physics, now covered by the same guarded re-pin at one seam.
#composerWrap contains #msg, so the textarea path is still covered.
- DRY: _repinMessagesAfterComposerResize() now reuses _messageBottomDistance()
instead of re-implementing the bottom-distance expression inline.
Re-verified live (composer grow -> bottomDist stays 0) + 524 scroll/composer
regression tests green + 6 issue tests green.
#5514 (deterministic): with the chat pinned to the bottom, growing the composer
past one row (multi-line typing, Shift+Enter, a multi-line / WisprFlow paste)
shrinks the flex:1 .messages viewport by the same delta. scrollTop is unchanged
but max-scrollTop rose by delta, so the reader is stranded delta-px above the
bottom — the transcript 'scrolls up one row per composer row.' autoResize()
resized the textarea and called updateSendBtn() but never re-pinned the transcript.
Reproduced live: composer 176px -> viewport shrank 745->613 -> transcript stranded
132px above bottom while _scrollPinned stayed true.
#5515 (intermittent 'random scroll up'): the same viewport-shrink from composer
growth via paths that don't route through input->autoResize (paste, draft restore,
programmatic value set, reflow) reads to the user as a random upward jump. Covered
by a ResizeObserver seam (below).
Fix: new _repinMessagesAfterComposerResize() (static/ui.js) re-pins to the bottom
ONLY when the reader is genuinely still pinned (honors _messageUserUnpinned /
_scrollPinned — never yanks a reader who scrolled away; verified live). Called from:
(a) autoResize() after the height write, and
(b) a ResizeObserver on the #msg textarea that re-pins on GROW only, catching
every composer height-change path (not just the typed-input seam).
Verified live on a 40-message seeded session: pinned reader stays at bottomDist=0
after a 4-line composer grow; an unpinned (scrolled-up) reader is NOT moved.
Tests: tests/test_issue5514_composer_grow_scroll_pin.py (3 static wiring + 3 node-vm
behavioral). 524 scroll/composer regression tests across 39 files still green.
NOTE on #5515: this resolves the composer-growth cause (which the reporter's
WisprFlow-paste symptom points at). If a purely non-composer intermittent jump
persists, the remaining suspect is touch-device overflow-anchor:auto on content-
above height change — that path has no repro yet and is tracked as needinfo on
#5515.
* fix(#5472): harden failed-send draft restore — order persist after clear, honest test
Two NIT-level follow-ups from the Opus review of #5484 (both on already-shipped
v0.51.842 code; neither is a functional break):
1. Draft clear/save ordering. send() fires an unawaited _clearComposerDraft POST
(text:'') at send time, and the failed-send restore fired a separate
_saveComposerDraftNow POST (text:<draft>). Under HTTP/2 multiplexing those two
same-origin writes can be reordered, leaving the SERVER draft empty after a
reload (the in-memory composer restore was always fine). send() now captures
the clear POST's promise and passes it to the restore helper, which chains the
re-persist after the clear resolves (falls back to immediate persist if the
promise is absent/rejects).
2. Honest happy-path test. test_send_still_clears_composer_on_the_happy_path
asserted a bare $('msg').value='' string that matches ~13 sites, so it would
pass even if the MAIN-path clear were removed. It now anchors to the unique
main-path _clearComposerDraft line and window-checks the composer wipe
immediately precedes it.
Tests: +2 (promise-ordering behavioral via node-vm microtask ordering; absent-promise
fallback), signature/call assertions updated. 14 in the #5472 file, 93 across the
related composer/send/draft/upload surface — all green.
* fix(#5472): make deferred draft persist stale-aware (Codex #5488 catch)
The promise-ordering fix introduced a regression: the deferred _persist saved the
CAPTURED snapshot and its _saveComposerDraftNow clearTimeout cancelled any newer
debounced save. So if the user edited the restored composer before the clear POST
settled, the delayed persist overwrote their edit with the stale original text
(lost on reload).
Restructure the helper so the deferred persist is stale-aware at FIRE time:
- If the failed session is still visible, re-read the LIVE composer (.value
+ current S.pendingFiles) instead of the captured snapshot — a post-restore edit
is captured, not clobbered.
- If we restored the visible session but the user has since switched away, SKIP the
stale write entirely (the session-switch save path already saved that session).
- Background failure (sid never visible): persist the captured snapshot, since
there's no live composer to read — it's the only copy.
The visible-composer restore + session guard are unchanged in behavior (moved above
the persist). Helper now returns whether it restored the VISIBLE composer.
Tests: +3 regression cases (post-restore edit captured; switch-away skip; background
snapshot persist), signature/guard assertions updated. 17 in the #5472 file, 87
across the related surface — all green.
* fix(#5472): preserve composer draft when a send fails
When a provider/background error aborts a send, send() has already cleared
the composer and the persisted draft before /api/chat/start durably accepts
the turn. On a start-time throw the turn is never persisted, so the user's
typed message is lost and must be retyped.
Add _restoreComposerDraftAfterFailedSend(text, sid): restores the typed text
into the composer, keeps staged files in S.pendingFiles, and re-persists the
draft (via _saveComposerDraftNow) so it also survives a reload. Guarded to
no-op when there's nothing to restore and to never clobber a new message the
user began typing during the async send window. Wired into the /api/chat/start
throw handler's general-error branch. Mirrors the existing _trySteer /
_stashClarifyDraft draft-restore idioms.
Tests: tests/test_issue5472_preserve_draft_on_failed_send.py — 3 static wiring
assertions + 4 node-vm behavioral tests of the helper's branching logic.
* fix(#5472): address gate findings — original-payload snapshot, re-stage files, session-guard
Codex + Opus gate on the first commit converged on three real edges:
1. /moa and bundle sends rewrite `text` before /api/chat/start, so restoring
`text` replayed the transformed payload, not the user's typed command.
Fix: capture an immutable _failedSendDraftText snapshot right after the
post-flush trim, before any slash rewrite, and restore that.
2. uploadPendingFiles() clears S.pendingFiles=[] before the start call, so the
original 'keep staged files' assumption was false — a resend silently
dropped attachments. Fix: snapshot S.pendingFiles before the upload drain
(_failedSendFilesSnapshot) and re-stage from it (+ renderTray()) on failure.
3. A start failure after a session switch could write the failed old session's
draft into the currently-visible composer. Fix: the helper now only mutates
the visible composer/tray when sid === the visible session; the sid-keyed
_saveComposerDraftNow persist stays unconditional so the draft survives a
switch-back/reload.
Helper signature is now (draftText, filesSnapshot, sid). Tests expanded to 11
(5 static + 6 node-vm behavioral) covering all three edges: original-vs-mutated
payload, re-stage drained attachments, and cross-session no-pollution.
* test(#5472): bound send() body slice to LIVE_STREAMS instead of fixed char window
The #5472 snapshot-capture lines added ~400 chars near the top of send(), which
pushed 'cancelStream' (in the busy-interrupt branch) past the arbitrary
[send_idx:send_idx+5000] window that test_1062's structural assertions used —
a real CI failure (shard 1, all 3 Pythons) caused by a brittle test, not by the
fix logic. Bound both send()-body slices in test_1062 to the actual function
extent (up to the 'const LIVE_STREAMS=' decl that immediately follows send()) so
future additions near the top of send() don't false-fail these assertions.
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>