diff --git a/CHANGELOG.md b/CHANGELOG.md index 680b05eb..c4b5b7be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed +- **Transparent Stream: the final reply stays a visible answer after the turn settles.** In Transparent Stream mode, the moment a turn settled its final reply folded into the collapsed worklog/activity disclosure group — the answer vanished from view until you refreshed the page (the stored transcript was always correct, so a reload fixed it). The live-anchor scene renderer was hardcoding the compact-worklog layout mode when it projected the settling turn, regardless of your active display mode, so the final answer got compact-worklog display hints and was grouped away. It now reads your actual active mode and applies per-mode display hints, so in Transparent Stream the answer settles as a visible chronological reply. Compact Worklog is unchanged (bit-identical projection). Thanks @rodboev. (#5558, #5550) - **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) diff --git a/static/messages.js b/static/messages.js index fe66187c..8c63221c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2705,12 +2705,35 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const seq=Number(_assistantSegmentSeq||_currentLiveSegmentSeq||0); return Number.isFinite(seq)&&seq>0?seq:1; } + function _anchorSceneActiveMode(){ + const normalize=value=>value==='transparent_stream'||value==='compact_worklog'?value:''; + if(typeof window!=='undefined'){ + if(typeof window.chatActivityMode==='function'){ + try{ + const mode=normalize(window.chatActivityMode()); + if(mode) return mode; + }catch(_){} + } + const displayMode=normalize(window._chatActivityDisplayMode); + if(displayMode) return displayMode; + if(window._transparentStream) return 'transparent_stream'; + } + return 'compact_worklog'; + } + function _anchorSceneRowDisplayHintForMode(row, sceneMode){ + const hints=row&&typeof row==='object'&&row.display_hints&&typeof row.display_hints==='object' + ? row.display_hints + : null; + if(sceneMode==='transparent_stream') return (hints&&hints.transparent_stream)||'chronological_activity'; + if(sceneMode==='compact_worklog') return (hints&&hints.compact_worklog)||row.display_hint||'activity_row'; + return row&&row.display_hint||'activity_row'; + } function _renderAnchorLiveScene(){ if(!_anchorRegistry||!_isActiveSession()) return false; if(typeof window==='undefined'||typeof window._renderLiveAnchorActivitySceneForStream!=='function') return false; try{ return !!window._renderLiveAnchorActivitySceneForStream(streamId, activeSid, { - mode:'compact_worklog', + mode:_anchorSceneActiveMode(), }); }catch(err){ if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){ @@ -2723,7 +2746,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _projectLiveAnchorActivityScene(){ if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.projectAssistantTurnAnchorActivityScene!=='function') return null; try{ - return _anchorApi.projectAssistantTurnAnchorActivityScene(_anchorRegistry,{mode:'compact_worklog'}); + return _anchorApi.projectAssistantTurnAnchorActivityScene(_anchorRegistry,{mode:_anchorSceneActiveMode()}); }catch(_){ return null; } @@ -3457,6 +3480,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } } const base=(projectedScene&&typeof projectedScene==='object')?projectedScene:{}; + const sceneMode=base.mode==='transparent_stream'?'transparent_stream':_anchorSceneActiveMode(); const messageFinalAnswer=_anchorSceneFinalAnswerText(lastAsst); const finalAnswer=_anchorSceneCleanText(messageFinalAnswer) ? messageFinalAnswer @@ -3479,7 +3503,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(key&&seen.has(key)) return; if(key) seen.add(key); if(isTextual&&textKey) seenTextKeys.push(textKey); - rows.push({...row,order_index:rows.length,seq:rows.length}); + rows.push({ + ...row, + display_hint:_anchorSceneRowDisplayHintForMode(row,sceneMode), + order_index:rows.length, + seq:rows.length, + }); }; const projectedRows=Array.isArray(base.activity_rows)?base.activity_rows:[]; for(const row of projectedRows){ @@ -3496,7 +3525,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const scene={ ...base, version:'activity_scene_v1', - mode:'compact_worklog', + mode:sceneMode, identity:{ ...((base.identity&&typeof base.identity==='object')?base.identity:{}), source_message_refs:messages.slice(turnStart+1,lastAsstIndex+1) diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 206607de..bc3356a2 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -72,24 +72,143 @@ def _run_node_script(script): return json.loads(result.stdout) +_EXTRACT_FUNC_JS = """ +function extractFunc(name){ + const start = src.indexOf('function ' + name); + if(start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for(let i=params; i ({{ + role: row.role, + text: row.text, + status: row.status, + display_hint: row.display_hint, + compact_hint: row.display_hints && row.display_hints.compact_worklog, + transparent_hint: row.display_hints && row.display_hints.transparent_stream, + }})) : null, +}})); +""" + return _run_node_script(script) + + @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_dom_render_live_compression_row_transitions_to_settled_scene(): script = f""" const fs = require('fs'); const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); -function extractFunc(name){{ - const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); - const start = src.search(re); - if(start < 0) throw new Error(name + ' not found'); - let i = src.indexOf('{{', start) + 1; - let depth = 1; - while(depth > 0 && i < src.length){{ - if(src[i] === '{{') depth += 1; - else if(src[i] === '}}') depth -= 1; - i += 1; - }} - return src.slice(start, i); -}} +{_EXTRACT_FUNC_JS} class FakeElement {{ constructor(tag){{ this.tagName = String(tag || 'div').toUpperCase(); @@ -231,15 +350,68 @@ def test_live_ui_legacy_paths_exit_when_anchor_scene_owns_the_turn(): assert ':not([data-anchor-scene-owner="1"])' in remove -def test_anchor_scene_projection_stays_scoped_to_compact_worklog(): +@pytest.mark.skipif("_anchorSceneActiveMode()" not in MESSAGES_JS, reason="base branch lacks the active-mode helper") +def test_anchor_scene_projection_tracks_active_mode(): render_live = _function_body(MESSAGES_JS, "_renderAnchorLiveScene") project_live = _function_body(MESSAGES_JS, "_projectLiveAnchorActivityScene") - ui_live = _function_body(UI_JS, "_renderLiveAnchorActivitySceneForStream") - assert "mode:'compact_worklog'" in render_live - assert "mode:'compact_worklog'" in project_live - assert "(opts&&opts.mode)||'compact_worklog'" in ui_live - assert "if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false;" in _function_body(UI_JS, "renderLiveAnchorActivityScene") + assert "_anchorSceneActiveMode()" in render_live + assert "_anchorSceneActiveMode()" in project_live + assert "mode:'compact_worklog'" not in render_live + assert "mode:'compact_worklog'" not in project_live + + +@pytest.mark.skipif("_anchorSceneActiveMode()" not in MESSAGES_JS, reason="base branch lacks the active-mode helper") +def test_anchor_scene_active_mode_falls_back_when_primary_accessor_throws(): + script = f""" +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "messages.js"))}, 'utf8'); +{_EXTRACT_FUNC_JS} +eval(extractFunc('_anchorSceneActiveMode')); +global.window = {{ + chatActivityMode(){{ throw new Error('mode unavailable'); }}, + _chatActivityDisplayMode:'transparent_stream', + _transparentStream:false, +}}; +const displayModeFallback = _anchorSceneActiveMode(); +global.window = {{ + chatActivityMode(){{ throw new Error('mode unavailable'); }}, + _chatActivityDisplayMode:'broken', + _transparentStream:true, +}}; +const booleanFallback = _anchorSceneActiveMode(); +process.stdout.write(JSON.stringify({{displayModeFallback, booleanFallback}})); +""" + result = _run_node_script(script) + + assert result["displayModeFallback"] == "transparent_stream" + assert result["booleanFallback"] == "transparent_stream" + + +def test_complete_settled_anchor_scene_tracks_transparent_stream_and_filters_duplicate_final_answer(): + result = _run_complete_anchor_settlement_case("transparent_stream") + + assert result["mode"] == "transparent_stream" + assert result["final_answer"] == "Final answer text" + assert result["final_message_ref"] == "assistant-1" + assert result["identity"]["source_message_refs"] == ["assistant-1"] + assert [row["role"] for row in result["activity_rows"]] == ["thinking", "tool", "terminal"] + assert [row["status"] for row in result["activity_rows"]] == ["completed", "completed", "completed"] + assert [row["display_hint"] for row in result["activity_rows"]] == [ + "chronological_activity", + "chronological_activity", + "chronological_activity", + ] + assert [row["compact_hint"] for row in result["activity_rows"]] == [ + "collapsed_thinking", + "tool_row", + "terminal_status_row", + ] + assert [row["transparent_hint"] for row in result["activity_rows"]] == [ + "chronological_activity", + "chronological_activity", + "chronological_activity", + ] def test_live_processed_anchor_renders_before_first_activity_row(): @@ -333,6 +505,53 @@ def test_live_processed_anchor_is_deduped_across_restore_paths(): assert "_dedupeLiveProcessedWorklogAnchors($('liveAssistantTurn'))" in shell +def test_complete_settled_anchor_scene_keeps_compact_worklog_rows_in_compact_mode(): + result = _run_complete_anchor_settlement_case("compact_worklog") + + assert result["mode"] == "compact_worklog" + assert result["final_answer"] == "Final answer text" + assert [row["role"] for row in result["activity_rows"]] == ["thinking", "tool", "terminal"] + assert [row["display_hint"] for row in result["activity_rows"]] == [ + "collapsed_thinking", + "tool_row", + "terminal_status_row", + ] + assert [row["compact_hint"] for row in result["activity_rows"]] == [ + "collapsed_thinking", + "tool_row", + "terminal_status_row", + ] + assert [row["transparent_hint"] for row in result["activity_rows"]] == [ + "chronological_activity", + "chronological_activity", + "chronological_activity", + ] + + +def test_anchor_scene_has_worklog_worthy_rows_rejects_prose_only_and_terminal_only_scenes(): + body = _function_body(MESSAGES_JS, "_anchorSceneHasWorklogWorthyRows") + + assert "role==='tool'||role==='thinking'" in body + assert "source==='compressing'||source==='compressed'" in body + + script = f""" +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "messages.js"))}, 'utf8'); +{_EXTRACT_FUNC_JS} +eval(extractFunc('_anchorSceneHasWorklogWorthyRows')); +const proseOnly = {{activity_rows:[{{role:'prose'}}, {{role:'terminal', source_event_type:'done'}}]}}; +const terminalOnly = {{activity_rows:[{{role:'terminal', source_event_type:'done'}}]}}; +process.stdout.write(JSON.stringify({{ + proseOnly: _anchorSceneHasWorklogWorthyRows(proseOnly), + terminalOnly: _anchorSceneHasWorklogWorthyRows(terminalOnly), +}})); +""" + result = _run_node_script(script) + + assert result["proseOnly"] is False + assert result["terminalOnly"] is False + + def test_scene_renderer_coalesces_row_updates_and_renders_in_scene_order(): rows = _function_body(UI_JS, "_anchorSceneRowsForRendering") render = _function_body(UI_JS, "_renderAnchorSceneRowsIntoWorklog") @@ -355,29 +574,7 @@ def test_live_anchor_scene_dedupes_exact_duplicate_process_prose_only_live(): script = f""" const fs = require('fs'); const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); -function extractFunc(name){{ - const start = src.indexOf('function ' + name); - if(start === -1) throw new Error(name + ' not found'); - const params = src.indexOf('(', start); - let depth = 0, close = -1; - for(let i=params; i