diff --git a/static/messages.js b/static/messages.js index 0c481b90b0..33a24eb34b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1735,8 +1735,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _streamFadePauseAfter(text, paragraphBreakIndex){ if(paragraphBreakIndex>=0) return 90; const trimmed=String(text||'').trimEnd(); - if(/[.!?]["')\]]*$/.test(trimmed)) return 45; - if(/[:;]["')\]]*$/.test(trimmed)) return 30; + if(/[.!?]["\x27)\]]*$/.test(trimmed)) return 45; + if(/[:;]["\x27)\]]*$/.test(trimmed)) return 30; return 0; } function _streamFadeNextText(targetText){ @@ -2195,7 +2195,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Throttling to 66ms intervals prevents this pileup without noticeable // visual degradation — streaming text updates still feel immediate. // performance.now() is monotonic so tab suspend/resume and NTP adjustments - // can't produce negative or enormous deltas. + // cannot produce negative or enormous deltas. const sinceLastMs=performance.now()-_lastRenderMs; const _doRender=()=>{ _pendingRafHandle=null; @@ -2336,9 +2336,45 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } _completeAutomaticCompressionOnLiveProgress(activeSid); ensureAssistantRow(true); + if(assistantRow) assistantRow.setAttribute('data-interim','1'); _flushPendingSegmentRender({force:true}); if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup(); + // Collapse old interim notes once more than INTERIM_COLLAPSE_THRESHOLD accumulate. + const INTERIM_COLLAPSE_THRESHOLD=3; + if(visibleInterimSnippets.length>INTERIM_COLLAPSE_THRESHOLD&&assistantRow){ + const blocks=assistantRow.parentElement; + if(blocks){ + const allInterim=Array.from(blocks.querySelectorAll('[data-interim="1"]')); + const toHide=allInterim.slice(0,allInterim.length-INTERIM_COLLAPSE_THRESHOLD); + let toggle=blocks.querySelector('.interim-collapse-toggle'); + if(!toggle){ + toggle=document.createElement('span'); + toggle.className='interim-collapse-toggle'; + toggle.addEventListener('click',()=>{ + const hidden=blocks.querySelectorAll('.interim-collapsed'); + if(hidden.length){ + hidden.forEach(el=>el.classList.remove('interim-collapsed')); + toggle.dataset.expanded='1'; + toggle.textContent='Collapse'; + } else { + const all=Array.from(blocks.querySelectorAll('[data-interim="1"]')); + const rehide=all.slice(0,all.length-INTERIM_COLLAPSE_THRESHOLD); + rehide.forEach(el=>el.classList.add('interim-collapsed')); + toggle.dataset.expanded=''; + toggle.textContent='Show '+rehide.length+' earlier update'+(rehide.length===1?'':'s'); + } + }); + if(toHide.length) toHide[0].before(toggle); + } + // Skip re-collapse when the user expanded manually; always update the stored count. + if(!toggle.dataset.expanded){ + toHide.forEach(el=>el.classList.add('interim-collapsed')); + } + const stillHidden=blocks.querySelectorAll('[data-interim="1"].interim-collapsed').length; + if(stillHidden) toggle.textContent='Show '+stillHidden+' earlier update'+(stillHidden===1?'':'s'); + } + } recordActivityBoundary(); _resetAssistantSegment(); _scheduleRender(); diff --git a/static/style.css b/static/style.css index 6245d86e58..22c9652988 100644 --- a/static/style.css +++ b/static/style.css @@ -5274,3 +5274,14 @@ main.main.showing-logs > #mainLogs{display:flex;} } #composerMobileCtxBadge { display: none !important; } + +/* Interim progress note collapse (#2403) */ +.interim-collapsed { display: none; } +.interim-collapse-toggle { + cursor: pointer; + color: var(--accent-color, #4a9eff); + font-size: 0.85em; + margin: 4px 0; + display: block; +} +.interim-collapse-toggle:hover { text-decoration: underline; } diff --git a/tests/test_issue2403_interim_collapse.py b/tests/test_issue2403_interim_collapse.py new file mode 100644 index 0000000000..07b32d2cec --- /dev/null +++ b/tests/test_issue2403_interim_collapse.py @@ -0,0 +1,151 @@ +"""Static-analysis tests for #2403 — collapse old interim progress notes. + +When more than INTERIM_COLLAPSE_THRESHOLD interim_assistant events arrive in +one turn, earlier rendered blocks are hidden behind a toggle so the viewport +stays focused on the latest progress note. + +These tests pin the structural invariants without a live browser, using the +same static-analysis pattern as test_issue2713_streaming_segment_flush.py. +""" +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent + + +def read(rel): + return (REPO / rel).read_text(encoding="utf-8") + + +def _extract_interim_handler(src): + """Return the full interim_assistant SSE handler body.""" + start_pattern = "source.addEventListener('interim_assistant'" + start = src.index(start_pattern) + end_marker = "\n });" + pos = start + while True: + idx = src.index(end_marker, pos + 1) + if idx > start + len(start_pattern) + 20: + return src[start : idx + len(end_marker)] + pos = idx + + +class TestInterimCollapseHandlerStructure: + """The interim_assistant handler must contain the collapse threshold and logic.""" + + def test_collapse_threshold_constant_present(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + assert "INTERIM_COLLAPSE_THRESHOLD" in fn, ( + "interim_assistant handler must define INTERIM_COLLAPSE_THRESHOLD " + "to avoid scattered magic numbers" + ) + + def test_threshold_is_three(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + # Constant must be assigned to 3 + assert re.search(r"INTERIM_COLLAPSE_THRESHOLD\s*=\s*3\b", fn), ( + "INTERIM_COLLAPSE_THRESHOLD must be set to 3" + ) + + def test_visibleInterimSnippets_length_comparison(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + assert "visibleInterimSnippets.length" in fn, ( + "collapse guard must compare visibleInterimSnippets.length" + ) + assert "INTERIM_COLLAPSE_THRESHOLD" in fn, ( + "collapse guard must reference INTERIM_COLLAPSE_THRESHOLD, not a magic number" + ) + + def test_interim_data_attribute_set(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + assert "data-interim" in fn, ( + "interim_assistant handler must mark each segment with data-interim " + "so collapse logic can query them" + ) + + def test_interim_collapsed_class_applied(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + assert "interim-collapsed" in fn, ( + "collapse logic must apply the interim-collapsed CSS class to hide old blocks" + ) + + def test_collapse_toggle_element_created(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + assert "interim-collapse-toggle" in fn, ( + "collapse logic must create an .interim-collapse-toggle element" + ) + + def test_toggle_text_references_count(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + # Toggle label must be dynamic: "Show N earlier update(s)" + assert "earlier update" in fn, ( + "collapse toggle text must reference 'earlier update' so the count is visible" + ) + + def test_attribute_set_before_flush(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + attr_pos = fn.index("setAttribute('data-interim','1')") + flush_pos = fn.rindex("_flushPendingSegmentRender({force:true})") + assert attr_pos < flush_pos, ( + "data-interim attribute must be set before _flushPendingSegmentRender " + "so the segment is marked before it is sealed" + ) + + def test_collapse_after_flush_before_reset(self): + src = read("static/messages.js") + fn = _extract_interim_handler(src) + flush_pos = fn.index("_flushPendingSegmentRender({force:true})") + collapse_pos = fn.index("INTERIM_COLLAPSE_THRESHOLD") + reset_pos = fn.index("_resetAssistantSegment()", collapse_pos) + assert flush_pos < collapse_pos < reset_pos, ( + "collapse logic must run after flush but before _resetAssistantSegment" + ) + + +class TestInterimCollapseCSS: + """CSS must define both .interim-collapsed and .interim-collapse-toggle.""" + + def test_interim_collapsed_rule_present(self): + css = read("static/style.css") + assert ".interim-collapsed" in css, ( + "style.css must define .interim-collapsed to hide collapsed blocks" + ) + + def test_interim_collapsed_uses_display_none(self): + css = read("static/style.css") + m = re.search(r"\.interim-collapsed\s*\{[^}]*\}", css) + assert m, ".interim-collapsed rule not found in style.css" + rule = m.group(0) + assert "display" in rule and "none" in rule, ( + ".interim-collapsed must set display:none" + ) + + def test_collapse_toggle_rule_present(self): + css = read("static/style.css") + assert ".interim-collapse-toggle" in css, ( + "style.css must define .interim-collapse-toggle" + ) + + def test_collapse_toggle_has_cursor_pointer(self): + css = read("static/style.css") + # Extract the first .interim-collapse-toggle rule block + m = re.search(r"\.interim-collapse-toggle\s*\{[^}]*\}", css) + assert m, ".interim-collapse-toggle rule not found" + rule = m.group(0) + assert "cursor" in rule and "pointer" in rule, ( + ".interim-collapse-toggle must set cursor:pointer" + ) + + def test_collapse_toggle_hover_rule_present(self): + css = read("static/style.css") + assert ".interim-collapse-toggle:hover" in css, ( + "style.css must define a :hover rule for .interim-collapse-toggle" + )