stage #4451 (santastabber): bubble hidden lineage stream state
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
c8ccfa93b1
6 files changed
+542
-22
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user