diff --git a/api/routes.py b/api/routes.py
index 6abd5a2b..4237f37d 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -848,7 +848,7 @@ button:hover{background:rgba(124,185,255,.25)}
def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
- if parsed.path in ("/", "/index.html"):
+ if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"):
from urllib.parse import quote
from api.updates import WEBUI_VERSION
version_token = quote(WEBUI_VERSION, safe="")
diff --git a/static/boot.js b/static/boot.js
index f3cf8ea6..a15b1792 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -2,7 +2,7 @@ async function cancelStream(){
const streamId = S.activeStreamId;
if(!streamId) return;
try{
- await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{credentials:'include'});
+ await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
}catch(e){/* cancel request failed — cleanup below still runs */}
// Clear status unconditionally after the cancel request completes.
// The SSE cancel event may also fire, but if the connection is already
@@ -939,7 +939,8 @@ function applyBotName(){
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
// Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load)
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
- const saved=localStorage.getItem('hermes-webui-session');
+ const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
+ const saved=urlSession||localStorage.getItem('hermes-webui-session');
if(saved){
try{
await loadSession(saved);
diff --git a/static/commands.js b/static/commands.js
index 8dc5fa6f..dc806f19 100644
--- a/static/commands.js
+++ b/static/commands.js
@@ -397,6 +397,7 @@ async function _runManualCompression(focusTopic){
S.toolCalls=data.session.tool_calls||[];
clearLiveToolCards();
localStorage.setItem('hermes-webui-session',S.session.session_id);
+ if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
syncTopbar();
renderMessages();
await renderSessionList();
diff --git a/static/index.html b/static/index.html
index ce5db283..b89c3263 100644
--- a/static/index.html
+++ b/static/index.html
@@ -14,7 +14,7 @@
-
+
diff --git a/static/messages.js b/static/messages.js
index c05d4dd5..d9ea8fba 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -1009,7 +1009,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(st.active){
setComposerStatus('Reconnected');
- _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{withCredentials:true}));
+ _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true}));
return;
}
}catch(_){}
@@ -1160,7 +1160,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
}catch(_){}
}
- _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{withCredentials:true}));
+ _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true}));
})();
}
diff --git a/static/sessions.js b/static/sessions.js
index 52250285..b460c6d7 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -282,6 +282,7 @@ async function newSession(flash){
S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id);
+ _setActiveSessionUrl(S.session.session_id);
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
// Sync chat-header dropdown to the session's model so the UI reflects
// the default model the server actually used (#872).
@@ -362,6 +363,7 @@ async function loadSession(sid){
_setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0));
_clearSessionCompletionUnread(S.session.session_id);
localStorage.setItem('hermes-webui-session',S.session.session_id);
+ _setActiveSessionUrl(S.session.session_id);
const activeStreamId=S.session.active_stream_id||null;
@@ -648,6 +650,41 @@ let _sessionActionMenu = null;
let _sessionActionAnchor = null;
let _sessionActionSessionId = null;
+function _sessionIdFromLocation(){
+ if(typeof window==='undefined'||!window.location) return null;
+ const marker='/session/';
+ const path=window.location.pathname||'';
+ const idx=path.indexOf(marker);
+ if(idx>=0){
+ const raw=path.slice(idx+marker.length).split('/')[0];
+ if(raw){try{return decodeURIComponent(raw);}catch(_e){return raw;}}
+ }
+ try{
+ const qs=new URLSearchParams(window.location.search||'');
+ return qs.get('session')||null;
+ }catch(_e){return null;}
+}
+function _sessionUrlForSid(sid){
+ const encoded=encodeURIComponent(sid);
+ let base;
+ try{base=new URL(`session/${encoded}`, document.baseURI||window.location.origin+'/');}
+ catch(_e){base=new URL(`/session/${encoded}`, window.location.origin);}
+ try{
+ const current=new URL(window.location.href);
+ current.searchParams.delete('session');
+ base.search=current.searchParams.toString();
+ base.hash=current.hash;
+ }catch(_e){}
+ return base.pathname+base.search+base.hash;
+}
+function _setActiveSessionUrl(sid){
+ if(typeof window==='undefined'||!window.history||!sid) return;
+ const next=_sessionUrlForSid(sid);
+ if(next && next!==(window.location.pathname+window.location.search+window.location.hash)){
+ window.history.replaceState({session_id:sid},'',next);
+ }
+}
+
// ── Batch select mode ──
function toggleSessionSelectMode(){
_sessionSelectMode=!_sessionSelectMode;
@@ -1216,6 +1253,13 @@ function _sessionLineageKey(s){
return s._lineage_root_id || s.lineage_root_id || s.parent_session_id || null;
}
+function _sessionLineageContainsSession(s, sid){
+ if(!s||!sid) return false;
+ if(s.session_id===sid) return true;
+ if(!Array.isArray(s._lineage_segments)) return false;
+ return s._lineage_segments.some(seg=>seg&&seg.session_id===sid);
+}
+
function _collapseSessionLineageForSidebar(sessions){
const result=[];
const groups=new Map();
@@ -1227,17 +1271,25 @@ function _collapseSessionLineageForSidebar(sessions){
}
for(const items of groups.values()){
if(items.length<=1){result.push(items[0]);continue;}
- const chosen=[...items].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a))[0];
- result.push({...chosen,_lineage_collapsed_count:items.length});
+ const sorted=[...items].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
+ const chosen=sorted[0];
+ result.push({...chosen,_lineage_collapsed_count:items.length,_lineage_segments:sorted});
}
return result;
}
+function _activeSessionIdForSidebar(){
+ if(S.session&&S.session.session_id) return S.session.session_id;
+ if(typeof _sessionIdFromLocation==='function') return _sessionIdFromLocation();
+ return null;
+}
+
function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
closeSessionActionMenu();
const q=($('sessionSearch').value||'').toLowerCase();
+ const activeSidForSidebar=_activeSessionIdForSidebar();
const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions;
// Merge content matches (deduped): content matches appended after title matches
const titleIds=new Set(titleMatches.map(s=>s.session_id));
@@ -1246,7 +1298,7 @@ function renderSessionListFromCache(){
// real once the first message is sent. The server already filters them, but this
// guard ensures a brand-new active session doesn't flash into the list while
// _allSessions is stale from a prior render (#1171).
- const withMessages=allMatched.filter(s=>(s.message_count||0)>0 || (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0));
+ const withMessages=allMatched.filter(s=>(s.message_count||0)>0 || (activeSidForSidebar&&s.session_id===activeSidForSidebar) || (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0));
// Filter by active profile (unless "All profiles" is toggled on)
// Server backfills profile='default' for legacy sessions, so every session has a profile.
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
@@ -1407,7 +1459,7 @@ function renderSessionListFromCache(){
// Note: declared after the groups loop but available via function hoisting.
function _renderOneSession(s, isPinnedGroup=false){
const el=document.createElement('div');
- const isActive=S.session&&s.session_id===S.session.session_id;
+ const isActive=_sessionLineageContainsSession(s,activeSidForSidebar);
const isStreaming=_isSessionEffectivelyStreaming(s);
_rememberRenderedStreamingState(s, isStreaming);
_rememberRenderedSessionSnapshot(s);
@@ -1671,18 +1723,18 @@ function renderSessionListFromCache(){
async function _handleActiveSessionStorageEvent(e){
if(!e || e.key !== 'hermes-webui-session') return;
- const sid = e.newValue;
- if(!sid || (S.session && S.session.session_id === sid)) return;
- if(S.busy){
- if(typeof showToast==='function') showToast('Active session changed in another tab. Finish the current turn before switching.',3000);
- return;
- }
- await loadSession(sid);
- renderSessionListFromCache();
+ // Do not treat localStorage as a global active-session bus. Each tab owns its
+ // active conversation via its URL (/session/), so another tab switching
+ // sessions must not force this tab to navigate away from an in-flight turn.
+ if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
}
if(typeof window!=='undefined'){
window.addEventListener('storage', (e) => { void _handleActiveSessionStorageEvent(e); });
+ window.addEventListener('popstate', () => {
+ const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
+ if(sid && (!S.session || S.session.session_id!==sid)) void loadSession(sid);
+ });
}
async function deleteSession(sid){
diff --git a/static/terminal.js b/static/terminal.js
index f2ae5b07..3b78fb53 100644
--- a/static/terminal.js
+++ b/static/terminal.js
@@ -373,7 +373,7 @@ function _connectTerminalOutput(){
try{TERMINAL_UI.source.close();}catch(_){}
TERMINAL_UI.source=null;
}
- const url=new URL('api/terminal/output',location.href);
+ const url=new URL('api/terminal/output',document.baseURI||location.href);
url.searchParams.set('session_id',sid);
const source=new EventSource(url.href,{withCredentials:true});
TERMINAL_UI.source=source;
@@ -605,7 +605,7 @@ async function _resizeComposerTerminal(){
window.addEventListener('beforeunload',()=>{
if(TERMINAL_UI.source)try{TERMINAL_UI.source.close();}catch(_){}
if(TERMINAL_UI.sessionId){
- const url=new URL('api/terminal/close',location.href).href;
+ const url=new URL('api/terminal/close',document.baseURI||location.href).href;
const body=JSON.stringify({session_id:TERMINAL_UI.sessionId});
try{
navigator.sendBeacon(url,new Blob([body],{type:'application/json'}));
diff --git a/static/ui.js b/static/ui.js
index 9db74a7c..ad0a1da3 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -287,7 +287,7 @@ async function populateModelDropdown(){
const sel=$('modelSelect');
if(!sel) return;
try{
- const _modelsRes=await fetch(new URL('api/models',location.href).href,{credentials:'include'});
+ const _modelsRes=await fetch(new URL('api/models',document.baseURI||location.href).href,{credentials:'include'});
if(_redirectIfUnauth(_modelsRes)) return;
const data=await _modelsRes.json();
if(!data.groups||!data.groups.length) return; // keep HTML defaults
@@ -405,7 +405,7 @@ async function _fetchLiveModels(provider, sel){
}
_liveModelFetchPending.add(provider);
try{
- const url=new URL('api/models/live',location.href);
+ const url=new URL('api/models/live',document.baseURI||location.href);
url.searchParams.set('provider',provider);
const _liveRes=await fetch(url.href,{credentials:'include'});
if(_redirectIfUnauth(_liveRes)) return;
@@ -2757,7 +2757,7 @@ function syncTopbar(){
if(!deferModelCorrection){
S.session.model=first.value;
// Persist the correction so the session doesn't re-inject on next load.
- fetch(new URL('api/session/update',location.href).href,{
+ fetch(new URL('api/session/update',document.baseURI||location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value})
@@ -5012,7 +5012,7 @@ async function uploadPendingFiles(){
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
try{
const isArchive=_ARCHIVE_EXTS.test(f.name);
- const url=new URL(isArchive?'api/upload/extract':'api/upload',location.href).href;
+ const url=new URL(isArchive?'api/upload/extract':'api/upload',document.baseURI||location.href).href;
const res=await fetch(url,{method:'POST',credentials:'include',body:fd});
if(_redirectIfUnauth(res)) return;
if(!res.ok){const err=await res.text();throw new Error(err);}
diff --git a/static/workspace.js b/static/workspace.js
index 611774fa..c81ed5df 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -1,7 +1,7 @@
async function api(path,opts={}){
// Strip leading slash so URL resolves relative to location.href (supports subpath mounts)
const rel = path.startsWith('/') ? path.slice(1) : path;
- const url=new URL(rel,location.href);
+ const url=new URL(rel,document.baseURI||location.href);
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
// Server errors (4xx/5xx) are NOT retried — only connection failures.
let lastErr;
diff --git a/tests/test_issue1103_reasoning_chip_visibility.py b/tests/test_issue1103_reasoning_chip_visibility.py
index 91d57c2d..31f83606 100644
--- a/tests/test_issue1103_reasoning_chip_visibility.py
+++ b/tests/test_issue1103_reasoning_chip_visibility.py
@@ -16,8 +16,9 @@ def test_boot_call_before_session_load():
"""fetchReasoningChip() should be called before session load in boot sequence."""
with open("static/boot.js") as f:
src = f.read()
- # Find the boot session load: "const saved=localStorage.getItem('hermes-webui-session')"
- boot_marker = "const saved=localStorage.getItem('hermes-webui-session')"
+ # Find the boot session load; URL-anchored tabs may prefer a URL session id
+ # before falling back to the stored session id.
+ boot_marker = "localStorage.getItem('hermes-webui-session')"
boot_pos = src.index(boot_marker)
fetch_pos = src.index("fetchReasoningChip()")
# fetchReasoningChip must be called just before the saved session load
diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py
index 82e956ff..febb85a8 100644
--- a/tests/test_pwa_manifest_sw.py
+++ b/tests/test_pwa_manifest_sw.py
@@ -163,7 +163,9 @@ class TestIndexHtmlIntegration:
def test_index_route_url_encodes_asset_version(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('parsed.path in ("/", "/index.html")')
- assert idx != -1, "routes.py must handle / and /index.html"
+ if idx == -1:
+ idx = src.find('parsed.path.startswith("/session/")')
+ assert idx != -1, "routes.py must handle /, /index.html, and /session/"
block = src[idx:idx + 800]
assert "quote(WEBUI_VERSION, safe=\"\")" in block, (
"index route must URL-encode the cache-busting version token before "
diff --git a/tests/test_session_cross_tab_sync.py b/tests/test_session_cross_tab_sync.py
index bf7ec5d0..38cf81b2 100644
--- a/tests/test_session_cross_tab_sync.py
+++ b/tests/test_session_cross_tab_sync.py
@@ -1,8 +1,16 @@
-"""Regression tests for cross-tab active session synchronization."""
+"""Regression tests for cross-tab active session behavior."""
+import json
+import re
+import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent.resolve()
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
+BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
+COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
+MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
+INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
+ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
def test_sessions_js_listens_for_active_session_storage_changes():
@@ -11,6 +19,66 @@ def test_sessions_js_listens_for_active_session_storage_changes():
assert "_handleActiveSessionStorageEvent" in SESSIONS_JS
-def test_storage_sync_does_not_switch_while_busy():
- marker = "if(S.busy)"
- assert marker in SESSIONS_JS, "cross-tab storage sync must not switch sessions during an active turn"
+def test_storage_event_does_not_globally_switch_tabs():
+ handler_pos = SESSIONS_JS.find("async function _handleActiveSessionStorageEvent")
+ assert handler_pos != -1
+ next_pos = SESSIONS_JS.find("if(typeof window", handler_pos)
+ assert next_pos != -1
+ block = SESSIONS_JS[handler_pos:next_pos]
+ assert "loadSession(sid)" not in block
+ assert "Each tab owns its" in block
+ assert "renderSessionListFromCache" in block
+
+
+def test_session_switch_updates_url_path_for_tab_local_anchor():
+ assert "function _sessionIdFromLocation()" in SESSIONS_JS
+ assert "function _setActiveSessionUrl(sid)" in SESSIONS_JS
+ assert "'/session/'" in SESSIONS_JS
+ assert "_setActiveSessionUrl(S.session.session_id)" in SESSIONS_JS
+ assert "_setActiveSessionUrl(S.session.session_id)" in COMMANDS_JS
+ assert "addEventListener('popstate'" in SESSIONS_JS or 'addEventListener("popstate"' in SESSIONS_JS
+
+
+def test_boot_prefers_url_session_over_local_storage_session():
+ assert "const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;" in BOOT_JS
+ assert "const saved=urlSession||localStorage.getItem('hermes-webui-session');" in BOOT_JS
+
+
+def test_api_helper_resolves_against_document_base_not_session_path():
+ workspace_js = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
+ assert "new URL(rel,document.baseURI||location.href)" in workspace_js
+ assert "new URL(rel,location.href)" not in workspace_js
+
+
+def test_long_lived_stream_urls_resolve_against_document_base():
+ for rel in ("static/messages.js", "static/boot.js", "static/terminal.js"):
+ src = (REPO_ROOT / rel).read_text(encoding="utf-8")
+ assert "document.baseURI||location.href" in src
+
+
+def test_session_url_route_serves_index_and_base_href_handles_session_path():
+ assert 'parsed.path.startswith("/session/")' in ROUTES_PY
+ assert "marker='/session/'" in INDEX_HTML
+ assert "path.slice(0,i+1)" in INDEX_HTML
+
+
+def _evaluate_base_href_for_path(path: str) -> str:
+ script_match = re.search(r"", INDEX_HTML)
+ assert script_match, "index.html should include the dynamic base href script"
+ script = script_match.group(0).removeprefix("")
+ node = f"""
+const location={{origin:'https://example.test', pathname:{json.dumps(path)}}};
+let written='';
+const document={{write:(s)=>{{written+=s;}}}};
+{script}
+console.log(written);
+"""
+ return subprocess.check_output(["node", "-e", node], text=True).strip()
+
+
+def test_base_href_resolution_handles_session_urls_under_subpath_mounts():
+ assert _evaluate_base_href_for_path("/session/abc123") == ''
+ assert _evaluate_base_href_for_path("/myapp/session/abc123") == ''
+ assert _evaluate_base_href_for_path("/myapp/session/abc123/extra") == ''
+ assert _evaluate_base_href_for_path("/session-tools/session/abc123") == ''
+ assert _evaluate_base_href_for_path("/session-tools/page") == ''
diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py
index 5b3fd729..f1dba6cd 100644
--- a/tests/test_session_lineage_collapse.py
+++ b/tests/test_session_lineage_collapse.py
@@ -1,4 +1,5 @@
"""Regression tests for sidebar lineage collapse helpers."""
+import json
import shutil
import subprocess
from pathlib import Path
@@ -53,8 +54,67 @@ const sessions = [
const collapsed = _collapseSessionLineageForSidebar(sessions);
console.log(JSON.stringify(collapsed));
"""
- collapsed = _run_node(source)
- assert '"session_id":"tip"' in collapsed
- assert '"session_id":"root"' not in collapsed
- assert '"_lineage_collapsed_count":2' in collapsed
- assert '"session_id":"solo"' in collapsed
+ collapsed = json.loads(_run_node(source))
+ by_sid = {row["session_id"]: row for row in collapsed}
+ assert set(by_sid) == {"tip", "solo"}
+ assert by_sid["tip"]["_lineage_collapsed_count"] == 2
+ assert [seg["session_id"] for seg in by_sid["tip"]["_lineage_segments"]] == ["tip", "root"]
+
+
+def test_sidebar_active_state_can_fall_back_to_url_session_during_boot():
+ 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);
+}}
+global.S = {{ session: null }};
+global.window = {{ location: {{ pathname: '/session/url-active', search: '', hash: '' }} }};
+eval(extractFunc('_sessionIdFromLocation'));
+eval(extractFunc('_activeSessionIdForSidebar'));
+console.log(_activeSessionIdForSidebar());
+"""
+ assert _run_node(source) == "url-active"
+
+
+def test_collapsed_lineage_contains_active_hidden_segment():
+ 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);
+}}
+eval(extractFunc('_sessionTimestampMs'));
+eval(extractFunc('_sessionLineageKey'));
+eval(extractFunc('_collapseSessionLineageForSidebar'));
+eval(extractFunc('_sessionLineageContainsSession'));
+const sessions = [
+ {{session_id:'root', title:'Hermes WebUI', message_count:10, updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
+ {{session_id:'tip', title:'Hermes WebUI', message_count:20, updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
+];
+const collapsed = _collapseSessionLineageForSidebar(sessions);
+console.log(JSON.stringify({{sid: collapsed[0].session_id, containsRoot: _sessionLineageContainsSession(collapsed[0], 'root')}}));
+"""
+ result = _run_node(source)
+ assert '"sid":"tip"' in result
+ assert '"containsRoot":true' in result