mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-29 13:10:17 +00:00
Merge PR #1392 from dso2ng: anchor active sessions per browser tab via /session/<id> URLs
This commit is contained in:
+1
-1
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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/">'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user