diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d700fcc..680b05eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/static/messages.js b/static/messages.js index 1f3114b9..fe66187c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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); diff --git a/static/style.css b/static/style.css index 70509695..04d4ca8a 100644 --- a/static/style.css +++ b/static/style.css @@ -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, diff --git a/tests/test_sse_error_multi_probe_reconnect.py b/tests/test_sse_error_multi_probe_reconnect.py index 9f03ca95..91d625a5 100644 --- a/tests/test_sse_error_multi_probe_reconnect.py +++ b/tests/test_sse_error_multi_probe_reconnect.py @@ -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