Merge PR #1392 from dso2ng: anchor active sessions per browser tab via /session/<id> URLs

This commit is contained in:
nesquena-hermes
2026-05-01 16:10:31 +00:00
13 changed files with 222 additions and 37 deletions
+1 -1
View File
@@ -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="")
+3 -2
View File
@@ -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);
+1
View File
@@ -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();
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="apple-mobile-web-app-title" content="Hermes">
<link rel="apple-touch-icon" href="static/favicon.svg">
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var path=location.pathname,marker='/session/',i=path.indexOf(marker),p;i>=0?p=(path.slice(0,i+1)||'/'):p=(path.endsWith('/')?path:(path.replace(/\/[^\/]*$/,'/')||'/'));document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
+2 -2
View File
@@ -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}));
})();
}
+64 -12
View File
@@ -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/<id>), 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){
+2 -2
View File
@@ -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'}));
+4 -4
View File
@@ -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);}
+1 -1
View File
@@ -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;
@@ -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
+3 -1
View File
@@ -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/<id>"
block = src[idx:idx + 800]
assert "quote(WEBUI_VERSION, safe=\"\")" in block, (
"index route must URL-encode the cache-busting version token before "
+72 -4
View File
@@ -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"<script>\(function\(\).*?</script>", INDEX_HTML)
assert script_match, "index.html should include the dynamic base href script"
script = script_match.group(0).removeprefix("<script>").removesuffix("</script>")
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") == '<base href="https://example.test/">'
assert _evaluate_base_href_for_path("/myapp/session/abc123") == '<base href="https://example.test/myapp/">'
assert _evaluate_base_href_for_path("/myapp/session/abc123/extra") == '<base href="https://example.test/myapp/">'
assert _evaluate_base_href_for_path("/session-tools/session/abc123") == '<base href="https://example.test/session-tools/">'
assert _evaluate_base_href_for_path("/session-tools/page") == '<base href="https://example.test/session-tools/">'
+65 -5
View File
@@ -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