stage #4451 (santastabber): bubble hidden lineage stream state

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes committed 2026-06-23 06:06:23 +00:00
commit c8ccfa93b1
6 files changed
+542 -22

No files matched your search

+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Fixed
- Sidebar lineage rows now keep their running spinner, latest activity ordering, and unread/attention state when the active continuation is hidden as an archived lineage child. Pin/unpin actions also update the sidebar ordering immediately before the next server refresh.
## [v0.51.600] — 2026-06-23 — Release VG (suppress footer jitter during virtual-scroll measurement)
### Fixed
+46 -13
View File
@@ -368,7 +368,11 @@ function _isSessionLocallyStreaming(s) {
}
function _isSessionEffectivelyStreaming(s) {
return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s)));
return Boolean(s && (
s.is_streaming ||
_hasPendingUserMessageSignal(s) ||
_isSessionLocallyStreaming(s)
));
}
function _hasPendingUserMessageSignal(s) {
@@ -376,7 +380,7 @@ function _hasPendingUserMessageSignal(s) {
}
function _isServerIdleSessionRow(s) {
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message && !s.has_pending_user_message);
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message && !s.has_pending_user_message && !s.pending_started_at);
}
function _reconcileActiveSessionIdleStateFromList(serverRows) {
@@ -642,7 +646,9 @@ function _markPollingCompletionUnreadTransitions(sessions) {
const observedStreaming = _getSessionObservedStreaming()[sid];
const messageCount = Number(s.message_count || 0);
const lastMessageAt = Number(s.last_message_at || 0);
const completedObservedStream = wasStreaming === true && !isStreaming;
const hasServerRunSignal=Boolean(s.is_streaming||_hasPendingUserMessageSignal(s));
const canMarkCompletedStream=Boolean(hasServerRunSignal||previousSnapshot||observedStreaming);
const completedObservedStream = canMarkCompletedStream&&wasStreaming === true && !isStreaming;
const completedWithNewMessages = Boolean(
(previousSnapshot || observedStreaming)
&& !isStreaming
@@ -3345,8 +3351,11 @@ function _openSessionActionMenu(session, anchorEl){
try{
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
session.pinned=newPinned;
const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);
if(cached) cached.pinned=newPinned;
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
renderSessionList();
renderSessionListFromCache();
void renderSessionList();
}catch(err){
showToast(t('session_pin_failed')+err.message);
await renderSessionList();
@@ -3768,7 +3777,7 @@ function _applySessionListPayload(sessData, projData){
_sessionListLoadError = null;
_sessionListHasLoadedOnce = true;
_markPollingCompletionUnreadTransitions(_allSessions);
const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming));
const isStreaming = _allSessions.some(s => _isSessionEffectivelyStreaming(s));
if (isStreaming) {
startStreamingPoll();
} else {
@@ -4597,7 +4606,7 @@ function filterSessions(){
}
function _sessionTimestampMs(session) {
const raw = Number(session && (session.last_message_at || session.updated_at || session.created_at || 0));
const raw = Number(session && (session._sidebar_activity_at || session.last_message_at || session.updated_at || session.created_at || 0));
return Number.isFinite(raw) ? raw * 1000 : 0;
}
@@ -4991,6 +5000,8 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
delete row._child_session_streaming;
delete row._child_session_has_unread;
delete row._child_session_attention;
delete row._child_session_latest_at;
delete row._sidebar_activity_at;
return row;
};
const rows=(collapsedRows||[])
@@ -5005,6 +5016,14 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
const bubbleSidebarState=(parentRow, childRow)=>{
if(isChildStreaming(childRow)) parentRow._child_session_streaming=true;
if(childHasUnread(childRow)) parentRow._child_session_has_unread=true;
const childActivityRaw=childRow
? (childRow._sidebar_activity_at??childRow.last_message_at??childRow.updated_at??childRow.created_at??0)
: 0;
const childActivitySec=Number(childActivityRaw);
if(Number.isFinite(childActivitySec)&&childActivitySec>Number(parentRow._child_session_latest_at||0)){
parentRow._child_session_latest_at=childActivitySec;
parentRow._sidebar_activity_at=Math.max(Number(parentRow.last_message_at||parentRow.updated_at||parentRow.created_at||0), childActivitySec);
}
const childAttention=childRow&&childRow.attention&&typeof childRow.attention==='object'?childRow.attention:null;
if(!childAttention||!childAttention.kind||!Number.isFinite(Number(childAttention.count))||Number(childAttention.count)<=0) return;
const priorityFor=(kind)=>kind==='approval'?3:(kind==='clarify'?2:1);
@@ -5060,12 +5079,21 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
return false;
};
const orphans=[];
const attachQueue=[...(rawSessions||[])].sort((a,b)=>attachDepthFor(a)-attachDepthFor(b));
const renderableChildIds=new Set((rawSessions||[]).map(s=>s&&s.session_id).filter(Boolean));
const attachQueueById=new Map();
for(const candidate of [...(rawSessions||[]),...(referenceSessions||[])]){
if(candidate&&candidate.session_id&&!attachQueueById.has(candidate.session_id)) attachQueueById.set(candidate.session_id,candidate);
}
const attachQueue=[...attachQueueById.values()].sort((a,b)=>attachDepthFor(a)-attachDepthFor(b));
for(const child of attachQueue){
const childRenderable=!!(child&&child.session_id&&renderableChildIds.has(child.session_id));
if(child&&child.session_id&&visibleBySid.has(child.session_id)) continue;
const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned);
if(!_isChildSession(child)&&!isForkChild) continue;
const childLineageKey=child&&(child._lineage_root_id||child.lineage_root_id||child.parent_session_id);
const isHiddenLineageReferenceChild=!!(child&&child.archived&&child.parent_session_id&&childLineageKey&&!child.pinned&&!childRenderable);
if(!_isChildSession(child)&&!isForkChild&&!isHiddenLineageReferenceChild) continue;
if(!isForkChild&&child._cross_surface_child_session){
orphans.push({...child,_orphan_child_session:true});
if(childRenderable) orphans.push({...child,_orphan_child_session:true});
continue;
}
const parentSid=child.parent_session_id;
@@ -5079,22 +5107,27 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
if(!parentRow&&child._parent_lineage_root_id){
parentRow=visibleByLineageKey.get(child._parent_lineage_root_id)||null;
}
if(!parentRow){
parentRow=visibleByLineageKey.get(childLineageKey||parentSid)||null;
}
if(!parentRow&&hasHiddenArchivedAncestor(child)){
hiddenArchivedChildTree.add(child.session_id);
continue;
}
if(parentRow){
if(!Array.isArray(parentRow._child_sessions)) parentRow._child_sessions=[];
const childCopy={...child};
if(parentSegment){
childCopy._parent_segment_id=parentSegment.session_id;
childCopy._parent_segment_title=_sessionDisplayTitle(parentSegment)||child.parent_title||'Untitled';
}
parentRow._child_sessions.push(childCopy);
parentRow._child_session_count=parentRow._child_sessions.length;
if(childRenderable&&!isHiddenLineageReferenceChild){
if(!Array.isArray(parentRow._child_sessions)) parentRow._child_sessions=[];
parentRow._child_sessions.push(childCopy);
parentRow._child_session_count=parentRow._child_sessions.length;
}
bubbleSidebarState(parentRow, childCopy);
visibleBySegmentSid.set(childCopy.session_id,{row: parentRow, seg: childCopy});
} else {
} else if(childRenderable) {
orphans.push({...child,_orphan_child_session:true});
}
}
@@ -43,6 +43,7 @@ def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state():
assert "!s.is_streaming" in helper_body, "server idle helper must require is_streaming=false"
assert "!s.active_stream_id" in helper_body, "server idle helper must require no active stream id"
assert "!s.pending_user_message" in helper_body, "server idle helper must require no pending user text"
assert "!s.pending_started_at" in helper_body, "server idle helper must require no pending turn marker"
assert "S.busy=false" in body, "stale local busy state must be cleared"
assert "S.activeStreamId=null" in body, "stale active stream id must be cleared"
assert "delete INFLIGHT[sid]" in body, "stale active-session inflight cache must be purged"
@@ -59,6 +60,41 @@ def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state():
)
def test_effective_streaming_ignores_stale_raw_active_stream_ids():
local_body = _function_body(SESSIONS_SRC, "function _isSessionLocallyStreaming(")
pending_body = _function_body(SESSIONS_SRC, "function _hasPendingUserMessageSignal(")
effective_body = _function_body(SESSIONS_SRC, "function _isSessionEffectivelyStreaming(")
assert "s.active_stream_id" not in effective_body
assert "_hasPendingUserMessageSignal(s)" in effective_body
assert "s.pending_started_at" not in effective_body
script = f"""
let S = {{ session: null, busy: false }};
function _isSessionLocallyStreaming(s) {{{local_body}}}
function _hasPendingUserMessageSignal(s) {{{pending_body}}}
function _isSessionEffectivelyStreaming(s) {{{effective_body}}}
const rows = {{
serverStreaming: _isSessionEffectivelyStreaming({{session_id:'child', is_streaming:true, active_stream_id:'stream-1'}}),
staleActiveStream: _isSessionEffectivelyStreaming({{session_id:'child', is_streaming:false, active_stream_id:'dead-stream'}}),
pendingUser: _isSessionEffectivelyStreaming({{session_id:'child', pending_user_message:'working'}}),
pendingFlag: _isSessionEffectivelyStreaming({{session_id:'child', has_pending_user_message:true}}),
pendingStartedOnly: _isSessionEffectivelyStreaming({{session_id:'child', pending_started_at:123}}),
idle: _isSessionEffectivelyStreaming({{session_id:'child'}}),
}};
console.log(JSON.stringify(rows));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"serverStreaming": True,
"staleActiveStream": False,
"pendingUser": True,
"pendingFlag": True,
"pendingStartedOnly": False,
"idle": False,
}
def test_active_session_idle_reconcile_schedules_forced_transcript_reload():
helper_body = _function_body(SESSIONS_SRC, "function _scheduleActiveSessionIdleReload(")
@@ -1,5 +1,6 @@
"""Regression checks for #856 background completion unread markers."""
import json
from pathlib import Path
@@ -24,6 +25,21 @@ def _sessions_function_block(name: str, next_name: str) -> str:
return SESSIONS_JS[start:end]
def _function_body(block: str) -> str:
brace = block.find("{")
assert brace != -1, "function opening brace not found"
depth = 0
for i in range(brace, len(block)):
ch = block[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return block[brace + 1 : i]
raise AssertionError("function closing brace not found")
def test_background_completion_unread_uses_explicit_marker_not_message_delta():
"""A background completion must stay unread even when message_count has no delta."""
assert "SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'" in SESSIONS_JS
@@ -117,10 +133,10 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
"_markPollingCompletionUnreadTransitions",
"newSession",
)
effective_block = _sessions_function_block(
effective_block = _function_body(_sessions_function_block(
"_isSessionEffectivelyStreaming",
"_markPollingCompletionUnreadTransitions",
)
))
render_idx = SESSIONS_JS.find("async function renderSessionList")
assert render_idx != -1, "renderSessionList not found"
refresh_idx = SESSIONS_JS.find("async function _runRenderSessionListRefresh")
@@ -134,7 +150,11 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
assert "const _sessionStreamingById = new Map();" in SESSIONS_JS
assert "const wasStreaming = _sessionStreamingById.get(sid);" in transition_block
assert "const isStreaming = _isSessionEffectivelyStreaming(s);" in transition_block
assert "s.is_streaming || _isSessionLocallyStreaming(s)" in effective_block
assert "s.is_streaming" in effective_block
assert "s.active_stream_id" not in effective_block
assert "_hasPendingUserMessageSignal(s)" in effective_block
assert "s.pending_started_at" not in effective_block
assert "_isSessionLocallyStreaming(s)" in effective_block
assert "wasStreaming === true && !isStreaming" in transition_block, (
"polling fallback must only fire on an observed streaming -> stopped transition"
)
@@ -142,6 +162,102 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block
assert "_applySessionListPayload(sessData,projData);" in refresh_block
assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block
assert "_allSessions.some(s => _isSessionEffectivelyStreaming(s))" in apply_block, (
"the streaming poll fallback must stay active for the same server-confirmed "
"streaming states that can render a sidebar spinner"
)
def test_polling_transition_ignores_stale_active_stream_id_without_server_streaming():
local_body = _function_body(_sessions_function_block(
"_isSessionLocallyStreaming",
"_isSessionEffectivelyStreaming",
))
effective_body = _function_body(_sessions_function_block(
"_isSessionEffectivelyStreaming",
"_markPollingCompletionUnreadTransitions",
))
transition_body = _function_body(_sessions_function_block(
"_markPollingCompletionUnreadTransitions",
"newSession",
))
script = f"""
let S = {{ session: null, busy: false }};
const unread = [];
const _sessionStreamingById = new Map([['stale', true]]);
const _sessionListSnapshotById = new Map();
function _isSessionLocallyStreaming(s) {{{local_body}}}
function _hasPendingUserMessageSignal(s) {{ return !!(s && (s.pending_user_message || s.has_pending_user_message)); }}
function _isSessionEffectivelyStreaming(s) {{{effective_body}}}
function _hasSessionCompletionUnread() {{ return false; }}
function _markSessionCompletionUnread(sid) {{ unread.push(sid); }}
function _isSessionActivelyViewedForList() {{ return false; }}
function _rememberObservedStreamingSession() {{}}
function _forgetObservedStreamingSession() {{}}
function _getSessionObservedStreaming() {{ return {{}}; }}
function _markPollingCompletionUnreadTransitions(sessions) {{{transition_body}}}
_markPollingCompletionUnreadTransitions([{{
session_id:'stale',
is_streaming:false,
active_stream_id:'dead-stream',
message_count:5,
last_message_at:10,
updated_at:10,
}}]);
console.log(JSON.stringify({{unread, observed:_sessionStreamingById.get('stale')}}));
"""
import subprocess
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"unread": [], "observed": False}
# The observed-streaming marker is persisted across reloads, unlike in-memory snapshots.
def test_polling_transition_marks_persisted_observed_stream_after_reload():
local_body = _function_body(_sessions_function_block(
"_isSessionLocallyStreaming",
"_isSessionEffectivelyStreaming",
))
effective_body = _function_body(_sessions_function_block(
"_isSessionEffectivelyStreaming",
"_markPollingCompletionUnreadTransitions",
))
transition_body = _function_body(_sessions_function_block(
"_markPollingCompletionUnreadTransitions",
"newSession",
))
script = f"""
let S = {{ session: null, busy: false }};
const unread = [];
const _sessionStreamingById = new Map();
const _sessionListSnapshotById = new Map();
function _isSessionLocallyStreaming(s) {{{local_body}}}
function _hasPendingUserMessageSignal(s) {{ return !!(s && (s.pending_user_message || s.has_pending_user_message)); }}
function _isSessionEffectivelyStreaming(s) {{{effective_body}}}
function _hasSessionCompletionUnread() {{ return false; }}
function _markSessionCompletionUnread(sid) {{ unread.push(sid); }}
function _isSessionActivelyViewedForList() {{ return false; }}
let observed = {{
done: {{message_count: 5, last_message_at: 10}}
}};
function _rememberObservedStreamingSession() {{}}
function _forgetObservedStreamingSession(sid) {{ delete observed[sid]; }}
function _getSessionObservedStreaming() {{ return observed; }}
function _markPollingCompletionUnreadTransitions(sessions) {{{transition_body}}}
_markPollingCompletionUnreadTransitions([{{
session_id:'done',
is_streaming:false,
active_stream_id:null,
message_count:5,
last_message_at:10,
updated_at:10,
}}]);
console.log(JSON.stringify({{unread, observed, streaming:_sessionStreamingById.get('done')}}));
"""
import subprocess
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"unread": ["done"], "observed": {}, "streaming": False}
def test_polling_transition_does_not_mark_historical_first_render():
@@ -187,17 +303,21 @@ def test_polling_transition_tracks_the_same_effective_streaming_state_as_sidebar
"_isSessionLocallyStreaming",
"_isSessionEffectivelyStreaming",
)
effective_block = _sessions_function_block(
effective_block = _function_body(_sessions_function_block(
"_isSessionEffectivelyStreaming",
"_markPollingCompletionUnreadTransitions",
)
))
render_idx = SESSIONS_JS.find("function _renderOneSession")
assert render_idx != -1, "_renderOneSession not found"
render_block = SESSIONS_JS[render_idx:SESSIONS_JS.find("const hasUnread=", render_idx)]
assert "isActive && Boolean(S.busy)" in local_block
assert "INFLIGHT && INFLIGHT[s.session_id]" not in local_block
assert "s.is_streaming || _isSessionLocallyStreaming(s)" in effective_block
assert "s.is_streaming" in effective_block
assert "s.active_stream_id" not in effective_block
assert "_hasPendingUserMessageSignal(s)" in effective_block
assert "s.pending_started_at" not in effective_block
assert "_isSessionLocallyStreaming(s)" in effective_block
assert "const ownStreaming=_isSessionEffectivelyStreaming(s)" in render_block, (
"the row spinner and polling completion transition must use the same "
"effective streaming source, including local INFLIGHT-only streams"
+305 -3
View File
@@ -730,7 +730,7 @@ console.log(JSON.stringify(rows));
assert "_child_sessions" not in rows[0]
def test_nested_fork_keeps_parent_timestamp_for_sorting():
def test_nested_fork_bubbles_latest_child_timestamp_for_sorting():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
@@ -755,11 +755,313 @@ eval(extractFunc('_attachChildSessionsToSidebarRows'));
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', updated_at:20, last_message_at:20}};
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
console.log(JSON.stringify({{row: rows[0], timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert row["session_id"] == "parent"
# Keep the parent row's persisted timestamp intact, but use newest attached
# child/subagent activity for sidebar sorting/date grouping.
assert row["last_message_at"] == 10
assert row["_child_session_latest_at"] == 20
assert row["_sidebar_activity_at"] == 20
assert result["timestampMs"] == 20_000
def test_hidden_archived_lineage_child_bubbles_running_state_and_activity_to_visible_parent():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
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);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.active_stream_id));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const visibleTip = {{
session_id:'visible-tip',
title:'Visible lineage row',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
updated_at:10,
last_message_at:10,
}};
const archivedTip = {{
session_id:'archived-tip',
title:'Archived running tip',
parent_session_id:'parent-root',
session_source:'webui',
archived:true,
_lineage_root_id:'parent-root',
is_streaming:true,
active_stream_id:'stream-1',
updated_at:30,
last_message_at:30,
}};
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert result["count"] == 1
assert row["session_id"] == "visible-tip"
assert "_child_sessions" not in row
assert row["_child_session_streaming"] is True
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
def test_archived_lineage_child_without_root_id_falls_back_to_parent_for_bubbling():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
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);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.active_stream_id));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const visibleTip = {{
session_id:'visible-tip',
title:'Visible lineage row',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
updated_at:10,
last_message_at:10,
}};
const archivedTip = {{
session_id:'archived-tip',
title:'Archived running tip',
parent_session_id:'parent-root',
session_source:'webui',
archived:true,
is_streaming:true,
active_stream_id:'stream-1',
updated_at:30,
last_message_at:30,
}};
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert result["count"] == 1
assert row["session_id"] == "visible-tip"
assert "_child_sessions" not in row
assert row["_child_session_streaming"] is True
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
def test_non_archived_reference_only_lineage_row_does_not_bubble_to_visible_parent():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
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);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.active_stream_id));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const visibleTip = {{
session_id:'visible-tip',
title:'Visible lineage row',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
updated_at:10,
last_message_at:10,
}};
const filteredReferenceOnly = {{
session_id:'filtered-tip',
title:'Filtered non-archived tip',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
archived:false,
is_streaming:true,
active_stream_id:'stream-1',
updated_at:30,
last_message_at:30,
}};
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, filteredReferenceOnly]);
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert result["count"] == 1
assert row["session_id"] == "visible-tip"
assert "_child_sessions" not in row
assert "_child_session_streaming" not in row
assert "_child_session_latest_at" not in row
assert "_sidebar_activity_at" not in row
assert result["timestampMs"] == 10_000
def test_stale_active_stream_id_on_hidden_archived_child_does_not_bubble_streaming_state():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
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);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.pending_user_message || session.has_pending_user_message));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const visibleTip = {{
session_id:'visible-tip',
title:'Visible lineage row',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
updated_at:10,
last_message_at:10,
}};
const archivedTip = {{
session_id:'archived-tip',
title:'Archived stale tip',
parent_session_id:'parent-root',
session_source:'webui',
archived:true,
_lineage_root_id:'parent-root',
is_streaming:false,
active_stream_id:'dead-stream',
updated_at:30,
last_message_at:30,
}};
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert result["count"] == 1
assert row["session_id"] == "visible-tip"
assert "_child_sessions" not in row
assert "_child_session_streaming" not in row
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
def test_collapsed_lineage_segments_do_not_render_as_child_sessions():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
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);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.active_stream_id));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
const root = {{session_id:'root', title:'Root', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'root'}};
const mid = {{session_id:'mid', title:'Middle', parent_session_id:'root', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'mid'}};
const tip = {{session_id:'tip', title:'Tip', parent_session_id:'root', updated_at:30, last_message_at:30, _lineage_root_id:'root', _lineage_tip_id:'tip'}};
const rows = _renderSidebarRowsFromRawSessions([root, mid, tip], [root, mid, tip]);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert rows[0]["session_id"] == "parent"
assert rows[0]["last_message_at"] == 10
assert [row["session_id"] for row in rows] == ["tip"]
assert rows[0]["_lineage_collapsed_count"] == 3
assert [seg["session_id"] for seg in rows[0]["_lineage_segments"]] == ["tip", "mid", "root"]
assert "_child_sessions" not in rows[0]
assert "_child_session_count" not in rows[0]
def test_nested_fork_bubbles_parent_attention_state():
@@ -0,0 +1,25 @@
"""Regression coverage for immediate sidebar pin/unpin reordering."""
from pathlib import Path
SESSIONS_JS = (Path(__file__).resolve().parents[1] / "static" / "sessions.js").read_text(encoding="utf-8")
def test_pin_action_updates_local_cache_and_renders_before_refetch():
"""Pin/unpin should move rows immediately, not only after a browser refresh."""
api_idx = SESSIONS_JS.find("await api('/api/session/pin'")
assert api_idx != -1, "pin action API call not found"
snippet = SESSIONS_JS[api_idx:api_idx + 700]
cached_idx = snippet.find("const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);")
update_idx = snippet.find("if(cached) cached.pinned=newPinned;")
active_idx = snippet.find("if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;")
cache_render_idx = snippet.find("renderSessionListFromCache();")
refetch_idx = snippet.find("void renderSessionList();")
assert cached_idx != -1, "pin action must find the cached sidebar row"
assert update_idx != -1, "pin action must update cached row.pinned"
assert active_idx != -1, "pin action must update the active session if pinned"
assert cache_render_idx != -1, "pin action must render from cache immediately"
assert refetch_idx != -1, "pin action should still reconcile with the server"
assert cached_idx < update_idx < active_idx < cache_render_idx < refetch_idx