fix(#5541): iOS PWA streaming freeze + SSE reconnect reliability
Two mobile reliability fixes on the crown-jewel chat streaming path: (1) content-visibility:auto on off-screen .msg-row under @media(pointer:coarse) so WKWebView skips layout/paint for off-screen rows during streaming (kills the long-chat freeze). The LIVE turn is kept content-visibility:visible via the STABLE #liveAssistantTurn id (covers ALL render modes — Compact Worklog, Transparent Stream, restored-live — not just the Transparent-Stream path that stamps data-live-assistant-turn), so a normal live turn on touch never blanks mid-stream and its height keeps growing so the new-message cue still fires. contain-intrinsic-size:auto 1px preserves flick-scroll momentum. Scoped to touch — desktop find-in-page untouched. Deliberately does NOT set overflow-anchor:none (inert on iOS WebKit; re-opens #4856/#5338). (2) SSE reconnect ladder extended 4->6 steps + a last-ditch _restoreSettledSession full-session poll (8s watchdog) after retries exhaust, so a response completed during an iOS Tailscale/VPN reconnect is recovered without an error banner. Co-authored-by: luperrypf <luperrypf@users.noreply.github.com>
This commit is contained in:
1 parent
56a133cc72
commit
cebfdf41f9
4 files changed
+75
-6
No files matched your search
@@ -14,6 +14,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **iOS/Android PWA: streaming stays smooth on long chats, and a dropped connection recovers instead of erroring.** Two mobile reliability fixes. (1) On touch devices, the chat now skips layout and paint for off-screen message rows while a response streams (`content-visibility` scoped to `pointer:coarse`), so a long conversation no longer degrades into a freeze on iOS WKWebView — the live streaming turn (targeted by its stable `#liveAssistantTurn` id so every render mode is covered) stays fully rendered so it never blanks mid-stream and the new-message cue still works, off-screen rows keep a tiny size estimate to preserve flick-scroll momentum, and desktop find-in-page is untouched. (2) The SSE reconnect ladder is extended (4→6 backoff steps) with a last-ditch full-session poll (an 8s-watchdog-guarded `_restoreSettledSession`, with a "Restoring session…" affordance) after the retries are exhausted, so a response that finished while the connection was flapping (e.g. an iOS Tailscale/VPN reconnect) is recovered without showing an error banner. Deliberately does not touch `overflow-anchor` — that would re-open the #4856/#5338 mobile jump-to-top regression. Thanks @luperrypf. (#5541)
|
||||
- **Starting a new chat is instant again — memory extraction no longer blocks it, and a managed restart no longer drops that memory.** Creating a new conversation could stall 1–5+ seconds because the previous session's memory-commit (the extraction call to the memory provider) ran synchronously on the request. That commit now runs in the background so "+ New Chat" responds immediately. To keep the deferred work safe across a restart, a `SIGTERM` handler drives an orderly stop so the server drains in-flight memory commits before exiting (previously a managed stop terminated instantly and lost them), the background-commit registry is bounded and self-cleaning, and the drain is wall-clock–bounded so a hung memory provider can't stall the stop (each commit runs under a shared time budget). Thanks @luperrypf. (#5543)
|
||||
- **Auto-scroll now resumes when you return to the bottom of a streaming reply.** If you scrolled up even once while a response was streaming, the chat permanently stopped following new output — scrolling back to the bottom didn't help; only starting a new turn re-engaged auto-follow. Returning to the very bottom of the transcript (within ~80px) now re-pins auto-scroll, so the view follows the stream again. It only re-pins at the true bottom and never while you're actively scrolling (wheel, keyboard, touch, or scrollbar drag), so reading back through history mid-stream still never yanks you down — preserving the #4295 behavior. Thanks @luperrypf. (#5544)
|
||||
- **Forking a long or compacted conversation now keeps a clean turn boundary.** "Fork from here" on a large session (one big enough to have been context-compacted) could cut the copied context in the middle of a turn — or right after a tool call, before its result — leaving the new conversation with a malformed context that the model choked on. The fork now aligns its cut to a real turn boundary in the same display coordinate space `/api/session` uses (matching both the compacted and non-compacted cases), erring toward keeping slightly less rather than slicing a turn, with send-time sanitization as a backstop. Small, non-compacted forks are unchanged. Thanks @b3nw. (#5563)
|
||||
|
||||
+37
-1
@@ -5765,7 +5765,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// during this retry window and only surface an error after all probes fail.
|
||||
if(!_reconnectAttempted && streamId){
|
||||
_reconnectAttempted=true;
|
||||
const _retryDelays=[1500,3000,5000,8000];
|
||||
const _retryDelays=[1500,3000,5000,8000,12000,20000];
|
||||
setComposerStatus(`Reconnecting… (1/${_retryDelays.length})`);
|
||||
const _probeReconnect=async(attempt=0)=>{
|
||||
if(_terminalStateReached || _streamFinalized) return;
|
||||
@@ -5794,6 +5794,42 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
setTimeout(()=>{void _probeReconnect(attempt+1);}, nextDelay);
|
||||
return;
|
||||
}
|
||||
// Last-ditch: the stream may have finished while we were retrying.
|
||||
// _restoreSettledSession polls the full session API (not just stream
|
||||
// status) and can recover a completed response without an error banner.
|
||||
// This is especially important on iOS where Tailscale reconnects can
|
||||
// take longer than the retry window.
|
||||
setComposerStatus('Restoring session…');
|
||||
let _restoreTimedOut=false;
|
||||
const _restoreTimer=setTimeout(()=>{
|
||||
// If _restoreSettledSession hangs (flaky Tailscale), don't leave
|
||||
// the UI stuck on "Restoring session…" forever. Fall through to
|
||||
// _handleStreamError after 8s.
|
||||
_restoreTimedOut=true;
|
||||
if(!_terminalStateReached&&!_streamFinalized){
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
if(_deferStreamErrorIfPageHidden(source)) return;
|
||||
_flushReasoningToAnchor();
|
||||
_scheduleAnchorRegistryCleanup(120000);
|
||||
_handleStreamError(source);
|
||||
}
|
||||
},8000);
|
||||
try{
|
||||
if(await _restoreSettledSession(source, {preserveVisibleOnShorterTerminalSnapshot:true})){
|
||||
if(_restoreTimedOut) return; // timer already fired _handleStreamError
|
||||
clearTimeout(_restoreTimer);
|
||||
return;
|
||||
}
|
||||
}catch(_){
|
||||
// _restoreSettledSession threw. If the timer already fired,
|
||||
// _handleStreamError was called there; we return below.
|
||||
// Otherwise the code below cancels the timer and calls it directly.
|
||||
}
|
||||
if(_restoreTimedOut) return; // timer already fired _handleStreamError
|
||||
clearTimeout(_restoreTimer);
|
||||
if(_terminalStateReached||_streamFinalized) return;
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
if(_deferStreamErrorIfPageHidden(source)) return;
|
||||
_flushReasoningToAnchor();
|
||||
_scheduleAnchorRegistryCleanup(120000);
|
||||
_handleStreamError(source);
|
||||
|
||||
@@ -5704,6 +5704,38 @@ main.main > #mainPlugin{display:none;}
|
||||
|
||||
/* ── Unified indent rail — every child of a turn lines up on --msg-rail ── */
|
||||
.msg-row { padding: 12px 0; }
|
||||
|
||||
/* On touch devices (iOS/Android PWA), skip layout/paint for off-screen rows
|
||||
so streaming performance doesn't degrade as the chat grows. WKWebView is
|
||||
especially sensitive: every scrollHeight read forces a full-document re-layout.
|
||||
Scoped to pointer:coarse to preserve find-in-page on desktop.
|
||||
|
||||
contain-intrinsic-size: auto 1px keeps off-screen rows in the accessibility
|
||||
tree (VoiceOver) via content-visibility:auto, while the 1px estimate is small
|
||||
enough to avoid scrollHeight recalculation jumps that kill flick-scroll
|
||||
momentum on iOS. */
|
||||
@media (pointer: coarse) {
|
||||
.msg-row { content-visibility: auto; contain-intrinsic-size: auto 1px; contain: layout style; }
|
||||
/* Keep the LIVE streaming turn fully rendered while a response streams. The
|
||||
live turn is always id="liveAssistantTurn" (assigned on every render path:
|
||||
Compact Worklog, Transparent Stream, restored-live, etc.), but ONLY the
|
||||
Transparent Stream path also stamps data-live-assistant-turn="1" — so we
|
||||
target the STABLE #liveAssistantTurn id to cover EVERY mode, plus the
|
||||
data-* attribute/segment forms as belt-and-suspenders. Without the id
|
||||
selector, a normal (non-Transparent-Stream) live turn on touch could be
|
||||
content-visibility:auto-skipped and blank out mid-stream. Keeping it visible
|
||||
also keeps its measured height growing so the new-message scroll cue still
|
||||
appears while a reader is scrolled up during streaming.
|
||||
(A child .assistant-segment carrying the un-suffixed data-live-assistant
|
||||
cannot opt a skipped ancestor subtree back in, so we target the turn row.) */
|
||||
#liveAssistantTurn,
|
||||
.msg-row[data-live-assistant-turn],
|
||||
.msg-row[data-live-assistant],
|
||||
.assistant-segment[data-live-assistant] { content-visibility: visible; }
|
||||
/* NOTE: deliberately NOT setting overflow-anchor:none here — it is inert on iOS
|
||||
WebKit and on Android it re-opens the #4856 / #5338 mobile jump-to-top
|
||||
regression the scroll-anchor work fought hard to close. */
|
||||
}
|
||||
.msg-body { padding-left: var(--msg-rail); padding-top: 8px; max-width: var(--msg-max); }
|
||||
.msg-body code,
|
||||
.msg-body pre,
|
||||
|
||||
@@ -13,8 +13,8 @@ though the backend was frequently still producing tokens (or the replay file was
|
||||
a beat away from becoming visible). The settled response then reappeared later,
|
||||
producing the disappear-then-restore flicker.
|
||||
|
||||
Fix: replace the single 1.5s probe with a short staged retry window
|
||||
(`_retryDelays=[1500,3000,5000,8000]` ms). Each stage re-queries the stream
|
||||
Fix: replace the single 1.5s probe with a staged retry window
|
||||
(`_retryDelays=[1500,3000,5000,8000,12000,20000]` ms). Each stage re-queries the stream
|
||||
status and reconnects/replays if the backend is reachable; the live DOM and
|
||||
`S.activeStreamId`/INFLIGHT state are kept INTACT across the whole window, and
|
||||
`_handleStreamError` is only reached after every stage has failed. This file
|
||||
@@ -58,8 +58,8 @@ def _reconnect_block(compact: str) -> str:
|
||||
|
||||
|
||||
def test_staged_retry_delays_present():
|
||||
# Four escalating backoff stages replace the old single 1500ms probe.
|
||||
assert "_retryDelays=[1500,3000,5000,8000]" in _compact(MESSAGES_JS)
|
||||
# Six escalating backoff stages replace the old single 1500ms probe.
|
||||
assert "_retryDelays=[1500,3000,5000,8000,12000,20000]" in _compact(MESSAGES_JS)
|
||||
|
||||
|
||||
def test_probe_reconnect_helper_defined():
|
||||
@@ -84,7 +84,7 @@ def test_stage_counter_starts_at_one():
|
||||
compact = _compact(MESSAGES_JS)
|
||||
assert "setComposerStatus(`Reconnecting…(1/${_retryDelays.length})`);" in compact
|
||||
# And the declaration precedes that first status call.
|
||||
decl = compact.find("const_retryDelays=[1500,3000,5000,8000];")
|
||||
decl = compact.find("const_retryDelays=[1500,3000,5000,8000,12000,20000];")
|
||||
first_status = compact.find("setComposerStatus(`Reconnecting…(1/${_retryDelays.length})`)")
|
||||
assert decl != -1 and first_status != -1
|
||||
assert decl < first_status
|
||||
|
||||
Reference in new issue
Block a user