From 912925d6e81f1d29dbe34deae267f1e94da294c3 Mon Sep 17 00:00:00 2001 From: santastabber <197905764+santastabber@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:47:18 +1000 Subject: [PATCH] fix(sidebar): bubble hidden lineage stream state --- CHANGELOG.md | 4 + static/sessions.js | 59 +++- .../test_issue2454_active_session_spinner.py | 36 ++ ...t_issue856_background_completion_unread.py | 132 +++++++- tests/test_session_lineage_collapse.py | 308 +++++++++++++++++- tests/test_sidebar_pin_immediate_reorder.py | 25 ++ 6 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 tests/test_sidebar_pin_immediate_reorder.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a145d3c..65742208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.523] — 2026-06-19 — Release SH (cache trusted wiki page listings on browser reads) ### Changed diff --git a/static/sessions.js b/static/sessions.js index 631e9253..e2926567 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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) { @@ -605,7 +609,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 @@ -3278,8 +3284,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(); @@ -3606,7 +3615,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 { @@ -4365,7 +4374,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; } @@ -4741,6 +4750,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||[]) @@ -4755,6 +4766,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); @@ -4810,12 +4829,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; @@ -4829,22 +4857,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}); } } diff --git a/tests/test_issue2454_active_session_spinner.py b/tests/test_issue2454_active_session_spinner.py index b2c9ffa6..e9872996 100644 --- a/tests/test_issue2454_active_session_spinner.py +++ b/tests/test_issue2454_active_session_spinner.py @@ -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(") diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index e23bf79d..35c07d5b 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -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" diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index f1a7712d..9d441245 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -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(): diff --git a/tests/test_sidebar_pin_immediate_reorder.py b/tests/test_sidebar_pin_immediate_reorder.py new file mode 100644 index 00000000..1497fdd9 --- /dev/null +++ b/tests/test_sidebar_pin_immediate_reorder.py @@ -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