Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -3403,6 +3403,29 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
}
}
function _anchorSceneHasWorklogWorthyRows(scene){
// A worklog (the collapsible "已处理 …" rail) is only meaningful when the turn
// actually DID worklog-worthy work — a tool call, a thinking/reasoning pass, or
// a compression lifecycle card. A turn that only streamed prose (e.g. a long
// plain-text answer, or a degeneration burst that flooded the body with repeated
// tokens) projects an activity scene whose rows are ALL `prose`/`terminal`. Folding
// such a turn into a collapsed worklog hides the whole answer and, at STREAM_DONE,
// shrinks the transcript by the full streamed height → the browser clamps a
// bottom-pinned viewport back to the top (the "jump back" report). Require at least
// one genuinely worklog-worthy row before promoting the turn to a worklog.
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
for(const row of rows){
if(!row||typeof row!=='object') continue;
const role=String(row.role||'');
if(role==='tool'||role==='thinking') return true;
if(role==='lifecycle'){
const source=String(row.source_event_type||'');
// compression cards are worklog-worthy; a bare terminal/done lifecycle is not.
if(source==='compressing'||source==='compressed') return true;
}
}
return false;
}
function _attachProjectedAnchorSceneToLastAssistant(messages){
if(!_anchorRegistry||!Array.isArray(messages)) return false;
let lastAsst=null;
Expand All @@ -3418,7 +3441,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!lastAsst) return false;
const projectedScene=_projectLiveAnchorActivityScene();
const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene);
if(scene&&Array.isArray(scene.activity_rows)&&scene.activity_rows.length){
if(scene&&Array.isArray(scene.activity_rows)&&scene.activity_rows.length
&&_anchorSceneHasWorklogWorthyRows(scene)){
lastAsst._anchor_stream_id=streamId;
lastAsst._anchor_activity_scene=scene;
_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);
Expand Down Expand Up @@ -5001,6 +5025,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function')
? _shouldFollowMessagesOnDomReplace()
: (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200)));
const _settledStreamId=isActiveSession?(S.activeStreamId||(d&&d.stream_id)||''):'';
if(isActiveSession){
S.activeStreamId=null;
}
Expand Down Expand Up @@ -5151,7 +5176,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// turn boundary so the following syncTopbar() refetches the authoritative
// effort exactly once (not per-token — the storm short-circuit is intact).
if(typeof _lastReasoningFetchKey!=='undefined') _lastReasoningFetchKey=null;
// Arm the one-shot keep-open token for JUST this settled turn so a
// pinned follower's worklog stays height-stable (no STREAM_DONE shrink
// jump); disarm right after the render so historical worklogs collapse
// compact as normal. Scoped to the just-settled stream id.
if(typeof _armKeepSettledWorklogOpen==='function') _armKeepSettledWorklogOpen(_settledStreamId);
syncTopbar();renderMessages({preserveScroll:true});
if(typeof _disarmKeepSettledWorklogOpen==='function') _disarmKeepSettledWorklogOpen();
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
if(typeof noteWorkspaceMutationsFromToolCalls==='function') noteWorkspaceMutationsFromToolCalls(S.toolCalls);
loadDir('.', { preservePreview: true });
Expand Down
68 changes: 66 additions & 2 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -10100,7 +10100,10 @@ function _anchorSceneWorklogGroup(blocks, opts){
let group=blocks.querySelector(`.tool-worklog-group[data-anchor-scene-owner="1"][data-tool-worklog-key="${CSS.escape(activityKey)}"]`);
if(!group){
group=ensureActivityGroup(blocks,{
collapsed:!live,
// Respect callers that need the settled activity group open. Round 6:
// pinned followers keep the just-settled worklog open so STREAM_DONE does
// not collapse hundreds of px of live worklog and visibly clamp the pane.
collapsed:(opts&&opts.collapsed!==undefined)?opts.collapsed:!live,
live,
activityKey,
beforeAnchor:!!(opts&&opts.beforeAnchor),
Expand Down Expand Up @@ -10327,8 +10330,28 @@ if(typeof window!=='undefined'){
window._projectLiveAnchorActivitySceneForStream=_projectLiveAnchorActivitySceneForStream;
window.isLiveAnchorActivitySceneOwner=isLiveAnchorActivitySceneOwner;
}
function _anchorSceneSceneHasWorklogWorthyRows(scene){
// Mirror of messages.js _anchorSceneHasWorklogWorthyRows for the RENDER side:
// a settled scene that was persisted (or hydrated from the backend) before the
// generation-side guard existed can still be all-prose. Such a scene must NOT be
// promoted to a collapsed worklog at render time (it would hide the whole answer
// and shrink the transcript at settle → bottom-pinned jump-back). Require at least
// one tool/thinking/compression row. (defense-in-depth for already-persisted scenes)
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
for(const row of rows){
if(!row||typeof row!=='object') continue;
const role=String(row.role||'');
if(role==='tool'||role==='thinking') return true;
if(role==='lifecycle'){
const source=String(row.source_event_type||'');
if(source==='compressing'||source==='compressed') return true;
}
}
return false;
}
function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx){
if(!message||!message._anchor_activity_scene||!segment) return false;
if(!_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)) return false;
const blocks=_assistantTurnBlocks(segment.closest('.assistant-turn'));
if(!blocks) return false;
const scene=message._anchor_activity_scene;
Expand Down Expand Up @@ -10365,8 +10388,48 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx
}
return wrote;
}
// One-shot token: the stream id of the turn that JUST settled at STREAM_DONE.
// The keep-open exception applies to ONLY this one turn's settled render, then
// is cleared so every other (historical) settled worklog renders compact even
// while the reader is pinned. Set right before the STREAM_DONE
// renderMessages({preserveScroll:true}) call and cleared after the settled-scene
// render pass; null at all other times.
let _keepSettledWorklogOpenForStreamId=null;
function _shouldKeepSettledWorklogOpenForPinnedFollow(streamId){
// Round 6 scroll-jump guard: while the reader is pinned at the live tail,
// collapsing the JUST-settled live worklog into a compact summary can shrink
// the transcript by hundreds of px at STREAM_DONE. The browser clamps scrollTop
// to the new max, which looks like a large backward jump even though pinned
// state is correct. Keep that one worklog open for pinned followers so the
// live->settled DOM swap is height-stable; unpinned readers still get compact
// settled worklogs and preserve their viewport normally. This intentionally
// wins over a transient user-collapsed live worklog while the reader remains
// pinned: avoiding the visible STREAM_DONE jump takes precedence for followers.
// SCOPING: the exception is gated on the one-shot token matching this turn's
// stream id, so it applies ONLY to the turn that just settled — not to every
// historical settled worklog on every pinned re-render (which would defeat the
// compact-worklog default for past turns). Pin flags use the sticky pin state
// because during live DOM rebuilds the raw bottom distance can transiently
// exceed a threshold even for a pinned follower.
if(!streamId||_keepSettledWorklogOpenForStreamId!==streamId) return false;
return !!(_scrollPinned && !_messageUserUnpinned);
}
// One-shot token set/clear API used by the STREAM_DONE handler (messages.js):
// arm the keep-open exception for exactly the turn that just settled, render,
// then disarm so subsequent re-renders collapse historical worklogs as normal.
function _armKeepSettledWorklogOpen(streamId){
_keepSettledWorklogOpenForStreamId=streamId?String(streamId):null;
}
function _disarmKeepSettledWorklogOpen(){
_keepSettledWorklogOpenForStreamId=null;
}
if(typeof window!=='undefined'){
window._armKeepSettledWorklogOpen=_armKeepSettledWorklogOpen;
window._disarmKeepSettledWorklogOpen=_disarmKeepSettledWorklogOpen;
}
function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){
if(!message||!message._anchor_activity_scene||!segment) return false;
if(!_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)) return false;
if(typeof isTransparentStream==='function'&&isTransparentStream()){
return _renderSettledAnchorSceneTransparentForMessage(message,segment,rawIdx);
}
Expand All @@ -10386,13 +10449,14 @@ function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){
});
blocks.querySelectorAll('.tool-worklog-group:not([data-anchor-scene-owner="1"]),.tool-call-group:not([data-anchor-scene-owner="1"]),.agent-activity-thinking:not([data-anchor-scene-row="1"]),.wl-reason').forEach(el=>el.remove());
const streamId=String(message._anchor_stream_id||scene.stream_id||scene.identity&&scene.identity.stream_id||'');
const keepSettledWorklogOpen=_shouldKeepSettledWorklogOpenForPinnedFollow(streamId);
const activityKey=`anchor-scene:${rawIdx}`;
if(streamId&&!_readActivityDisclosureState(activityKey)){
_copyActivityDisclosureState(`live:${streamId}`, activityKey);
}
const group=_anchorSceneWorklogGroup(blocks,{
live:false,
collapsed:true,
collapsed:!keepSettledWorklogOpen,
beforeAnchor:true,
anchor:segment,
activityKey,
Expand Down
95 changes: 95 additions & 0 deletions tests/test_issue4970_stream_done_shrink_regression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Regression locks for #4970 round 6: stream-end worklog collapse shrink jump.

Round 7 (scoping fix): the keep-open exception must apply to ONLY the turn that
just settled, gated on a one-shot stream-id token, NOT to every historical
settled worklog on every pinned re-render. These tests are BEHAVIORAL: they
extract the real `_shouldKeepSettledWorklogOpenForPinnedFollow` helper plus its
arm/disarm token API from static/ui.js and execute them in Node, then drive two
settled turns while pinned and assert the second (historical) turn collapses.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")


def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 0
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : idx]
raise AssertionError(f"function {name} body not found")


def _extract(name: str) -> str:
"""Return the full `function name(...){...}` text from ui.js."""
marker = f"function {name}"
start = UI_JS.index(marker)
body = _function_body(UI_JS, name)
sig = UI_JS[start : UI_JS.index("{", start)]
return f"{sig}{{{body}}}"


def test_helper_and_token_threaded_through_render():
# Structural: the helper takes a streamId and gates on the one-shot token,
# and the call site threads the message's stream id (not a no-arg call).
helper = _function_body(UI_JS, "_shouldKeepSettledWorklogOpenForPinnedFollow")
assert "_keepSettledWorklogOpenForStreamId" in helper
assert "_scrollPinned" in helper and "!_messageUserUnpinned" in helper
render_fn = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage")
assert "_shouldKeepSettledWorklogOpenForPinnedFollow(streamId)" in render_fn
assert "collapsed:!keepSettledWorklogOpen" in render_fn
group_fn = _function_body(UI_JS, "_anchorSceneWorklogGroup")
assert "collapsed:(opts&&opts.collapsed!==undefined)?opts.collapsed:!live" in group_fn
# The STREAM_DONE handler arms one-shot then disarms around the render.
assert "_armKeepSettledWorklogOpen(_settledStreamId)" in MESSAGES_JS
assert "_disarmKeepSettledWorklogOpen()" in MESSAGES_JS


@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
def test_only_just_settled_turn_stays_open_pinned_history_collapses():
"""Drive two settled turns while pinned; only the just-settled one stays open."""
helper = _extract("_shouldKeepSettledWorklogOpenForPinnedFollow")
arm = _extract("_armKeepSettledWorklogOpen")
disarm = _extract("_disarmKeepSettledWorklogOpen")
harness = textwrap.dedent(f"""
let _keepSettledWorklogOpenForStreamId=null;
let _scrollPinned=true, _messageUserUnpinned=false; // pinned follower
{helper}
{arm}
{disarm}
const out={{}};
// Turn A just settled: arm A, render A (open), render historical B (collapsed), disarm.
_armKeepSettledWorklogOpen('streamA');
out.A_open = _shouldKeepSettledWorklogOpenForPinnedFollow('streamA'); // expect true
out.B_history = _shouldKeepSettledWorklogOpenForPinnedFollow('streamB'); // expect false
_disarmKeepSettledWorklogOpen();
// After disarm, even A collapses on a later pinned re-render.
out.A_after_disarm = _shouldKeepSettledWorklogOpenForPinnedFollow('streamA'); // false
// Unpinned reader never keeps open even for the armed turn.
_armKeepSettledWorklogOpen('streamA'); _messageUserUnpinned=true; _scrollPinned=false;
out.unpinned = _shouldKeepSettledWorklogOpenForPinnedFollow('streamA'); // false
console.log(JSON.stringify(out));
""")
res = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=30)
assert res.returncode == 0, res.stderr
out = json.loads(res.stdout.strip())
assert out["A_open"] is True, "just-settled turn must keep worklog open for pinned follower"
assert out["B_history"] is False, "historical settled worklog must stay collapsed while pinned"
assert out["A_after_disarm"] is False, "exception must be one-shot, cleared after the render"
assert out["unpinned"] is False, "unpinned reader always gets compact settled worklog"
Loading
Loading