Merge pull request #5590 from nesquena/release/stage-5558

release #5558: keep Transparent Stream final replies visible after settle
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-04 15:23:21 -07:00
commit 2cc2eb2b3c
3 files changed
+274 -70

No files matched your search

+1
View File
@@ -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)
+33 -4
View File
@@ -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)
+240 -66
View File
@@ -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<src.length; i++){
if(src[i] === '(') depth++;
else if(src[i] === ')'){
depth--;
if(depth === 0){ close = i; break; }
}
}
const brace = src.indexOf('{', close);
depth = 0;
for(let i=brace; i<src.length; i++){
if(src[i] === '{') depth++;
else if(src[i] === '}'){
depth--;
if(depth === 0) return src.slice(start, i + 1);
}
}
throw new Error(name + ' body did not close');
}
""".strip()
def _run_complete_anchor_settlement_case(active_mode):
script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "messages.js"))}, 'utf8');
{_EXTRACT_FUNC_JS}
const activeMode = {json.dumps(active_mode)};
global.window = {{
chatActivityMode(){{ return activeMode; }},
_chatActivityDisplayMode: activeMode === 'transparent_stream' ? 'compact_worklog' : 'transparent_stream',
_transparentStream: activeMode === 'transparent_stream' ? false : true,
}};
global.S = {{ session: {{}} }};
eval(extractFunc('_anchorSceneCleanText'));
eval(extractFunc('_anchorSceneTextKey'));
eval(extractFunc('_anchorSceneExistingRowKey'));
eval(extractFunc('_anchorSceneRowHasLiveIdentity'));
eval(extractFunc('_anchorSceneSettleLiveRunningRow'));
eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer'));
eval(extractFunc('_anchorSceneRowTextOverlapsExisting'));
eval(extractFunc('_anchorSceneMessageRowsHaveThinking'));
if(src.indexOf('function _anchorSceneActiveMode') !== -1){{
eval(extractFunc('_anchorSceneActiveMode'));
}}else{{
eval("function _anchorSceneActiveMode(){{ return activeMode; }}");
}}
if(src.indexOf('function _anchorSceneRowDisplayHintForMode') !== -1){{
eval(extractFunc('_anchorSceneRowDisplayHintForMode'));
}}else{{
eval("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 _anchorSceneFinalAnswerText(message){{ return message && (message.final_answer || message.content || ''); }}
function _anchorSceneRowsByMessageIndex(){{ return new Map(); }}
function _anchorSceneMessageRef(message){{ return String(message && message.id || ''); }}
function _anchorSceneTurnDurationForSettlement(_lastAsst, base){{ return base && base.turn_duration ? base.turn_duration : 0; }}
eval(extractFunc('_completeSettledAnchorSceneForTurn'));
const messages = [
{{role:'user', content:'Prompt', id:'user-1'}},
{{role:'assistant', content:'Final answer text', id:'assistant-1'}},
];
const projectedScene = {{
mode:'compact_worklog',
final_answer:'Final answer text',
identity:{{source_message_refs:['legacy']}},
lifecycle:{{terminal_state:'done'}},
turn_duration:42,
activity_rows:[
{{
role:'prose',
text:'Final answer text',
status:'running',
row_id:'live-prose-final',
local_id:'live-prose-final',
display_hint:'main_prose',
display_hints:{{compact_worklog:'main_prose', transparent_stream:'chronological_activity'}},
}},
{{
role:'thinking',
text:'Working through the result',
status:'running',
row_id:'live-thinking-1',
local_id:'live-thinking-1',
display_hint:'collapsed_thinking',
display_hints:{{compact_worklog:'collapsed_thinking', transparent_stream:'chronological_activity'}},
}},
{{
role:'tool',
text:'Fetched docs',
status:'running',
row_id:'live-tool-1',
local_id:'live-tool-1',
tool_call_id:'tool-1',
display_hint:'tool_row',
display_hints:{{compact_worklog:'tool_row', transparent_stream:'chronological_activity'}},
}},
{{
role:'terminal',
text:'done',
status:'completed',
row_id:'terminal-1',
local_id:'terminal-1',
display_hint:'terminal_status_row',
display_hints:{{compact_worklog:'terminal_status_row', transparent_stream:'chronological_activity'}},
}},
],
}};
const scene = _completeSettledAnchorSceneForTurn(messages, 1, projectedScene);
process.stdout.write(JSON.stringify({{
mode: scene && scene.mode,
final_answer: scene && scene.final_answer,
final_message_ref: scene && scene.final_message_ref,
identity: scene && scene.identity,
activity_rows: scene && scene.activity_rows ? scene.activity_rows.map(row => ({{
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<src.length; i++){{
if(src[i] === '(') depth++;
else if(src[i] === ')'){{
depth--;
if(depth === 0){{ close = i; break; }}
}}
}}
const brace = src.indexOf('{{', close);
depth = 0;
for(let i=brace; i<src.length; i++){{
if(src[i] === '{{') depth++;
else if(src[i] === '}}'){{
depth--;
if(depth === 0) return src.slice(start, i + 1);
}}
}}
throw new Error(name + ' body did not close');
}}
{_EXTRACT_FUNC_JS}
function _anchorSceneToolRowLogicalKey(){{ return ''; }}
function _anchorSceneMergeToolRows(a,b){{ return b; }}
function _anchorSceneIsSettledSuccessfulCompression(){{ return false; }}
@@ -846,30 +1043,7 @@ def test_live_anchor_scene_transparent_snapshot_render_is_idempotent_and_hides_l
const assert = require('assert');
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');
const params = src.indexOf('(', start);
let depth = 0, close = -1;
for(let i=params; i<src.length; i++){{
if(src[i] === '(') depth++;
else if(src[i] === ')'){{
depth--;
if(depth === 0){{ close = i; break; }}
}}
}}
const brace = src.indexOf('{{', close);
depth = 0;
for(let i=brace; i<src.length; i++){{
if(src[i] === '{{') depth++;
else if(src[i] === '}}'){{
depth--;
if(depth === 0) return src.slice(start, i + 1);
}}
}}
throw new Error(name + ' body did not close');
}}
{_EXTRACT_FUNC_JS}
class FakeElement {{
constructor(tag='div'){{