diff --git a/CHANGELOG.md b/CHANGELOG.md index a18d46cd..945cf609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Added - WebUI can now opt into a `webui_prefill_messages_script` / `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` hook for dynamic browser-turn prefill context from local notes or recall systems. The script output is capped at 256 KiB, normalized to ephemeral prefill messages, and browser status still hides message bodies while redacting script errors. +- Added a read-only WebUI/CLI session source switch in the chat sidebar when agent session sync is enabled. WebUI conversations stay in the default list, while imported CLI/agent sessions are surfaced under a separate `CLI sessions` tab with counts so large CLI histories do not clutter the normal conversation list. (Refs #2351) ## [v0.51.140] — 2026-05-26 — Release DL (stage-batch22 — 5-PR hold-bucket reassessment) diff --git a/docs/pr-media/2351/after-source-tabs.png b/docs/pr-media/2351/after-source-tabs.png new file mode 100644 index 00000000..2279fd30 Binary files /dev/null and b/docs/pr-media/2351/after-source-tabs.png differ diff --git a/docs/pr-media/2351/before-cli-mixed.png b/docs/pr-media/2351/before-cli-mixed.png new file mode 100644 index 00000000..2279fd30 Binary files /dev/null and b/docs/pr-media/2351/before-cli-mixed.png differ diff --git a/static/sessions.js b/static/sessions.js index 0188327e..0c101cff 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -906,6 +906,29 @@ function _isCliSession(session) { return session.is_cli_session === true; } +function _sessionSourceLabel(filter, count) { + const n = Number(count) || 0; + return filter === 'cli' ? `CLI sessions (${n})` : `WebUI sessions (${n})`; +} + +function _setSessionSourceFilter(filter) { + const next = filter === 'cli' ? 'cli' : 'webui'; + if (_sessionSourceFilter === next) return; + _sessionSourceFilter = next; + _activeProject = null; + _selectedSessions.clear(); + _sessionSelectMode = false; + try { localStorage.setItem('hermes-session-source-filter', next); } catch (_e) {} + renderSessionListFromCache(); +} + +function _restoreSessionSourceFilter() { + try { + const raw = localStorage.getItem('hermes-session-source-filter'); + if (raw === 'cli' || raw === 'webui') _sessionSourceFilter = raw; + } catch (_e) {} +} + function _normalizeMessageForCliImportComparison(message) { if (!message || typeof message !== 'object') return message; const clone = { ...message }; @@ -1551,6 +1574,8 @@ const NO_PROJECT_FILTER = '__none__'; let _activeProject = null; // project_id filter (null = show all, NO_PROJECT_FILTER = unassigned only) let _showAllProfiles = false; // false = filter to active profile only let _otherProfileCount = 0; // count of sessions from other profiles (server-reported) +let _sessionSourceFilter = 'webui'; // 'webui' keeps WebUI chats separate from read-only CLI sessions +_restoreSessionSourceFilter(); let _sessionActionMenu = null; let _sessionActionAnchor = null; let _sessionActionSessionId = null; @@ -3238,6 +3263,14 @@ function renderSessionListFromCache(){ (activeSidForSidebar&&s.session_id===activeSidForSidebar) || (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0) ); + const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length; + const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length; + if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){ + _sessionSourceFilter='webui'; + } + const sourceFiltered = _sessionSourceFilter==='cli' + ? withMessages.filter(s=>_isCliSession(s)) + : withMessages.filter(s=>!_isCliSession(s)); // The server is authoritative for profile scoping (#1611): it filters by // active profile when no query param is set, and returns the aggregate when // we send ?all_profiles=1. The renamed-root cross-alias (a row tagged @@ -3245,7 +3278,7 @@ function renderSessionListFromCache(){ // in _profiles_match, and a strict-equality client filter would reject those // rows incorrectly. So we trust the wire data and skip the redundant client // filter entirely. - const profileFiltered=withMessages; + const profileFiltered=sourceFiltered; // Filter by active project. NO_PROJECT_FILTER sentinel asks for sessions // with no project_id; otherwise filter to the matching project_id, or // pass through when no filter is active. @@ -3285,6 +3318,21 @@ function renderSessionListFromCache(){ list.appendChild(batchBar); if(_sessionSelectMode&&_selectedSessions.size>0){batchBar.style.display='flex';_renderBatchActionBar();} else{batchBar.style.display='none';} + if(window._showCliSessions || cliSessionCount>0){ + const sourceTabs=document.createElement('div'); + sourceTabs.className='session-source-tabs'; + for(const filter of ['webui','cli']){ + const count=filter==='cli'?cliSessionCount:webuiSessionCount; + const btn=document.createElement('button'); + btn.type='button'; + btn.className='session-source-tab'+(_sessionSourceFilter===filter?' active':''); + btn.textContent=_sessionSourceLabel(filter,count); + btn.setAttribute('aria-pressed', _sessionSourceFilter===filter?'true':'false'); + btn.onclick=()=>_setSessionSourceFilter(filter); + sourceTabs.appendChild(btn); + } + list.appendChild(sourceTabs); + } // Project filter bar — show when there are real projects OR there are // unassigned sessions (so the Unassigned chip has something to filter to). const hasUnprojected=profileFiltered.some(s=>!s.project_id); @@ -3367,9 +3415,14 @@ function renderSessionListFromCache(){ list.appendChild(toggle); } // Empty state for active project filter - if(_activeProject&&sessions.length===0){ + if(_sessionSourceFilter==='cli'&&sessions.length===0){ const empty=document.createElement('div'); - empty.style.cssText='padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;'; + empty.className='session-empty-note'; + empty.textContent=window._showCliSessions?'No CLI sessions found.':'Enable Show agent sessions in Settings to list CLI sessions here.'; + list.appendChild(empty); + } else if(_activeProject&&sessions.length===0){ + const empty=document.createElement('div'); + empty.className='session-empty-note'; empty.textContent=_activeProject===NO_PROJECT_FILTER?'No unassigned sessions.':'No sessions in this project yet.'; list.appendChild(empty); } diff --git a/static/style.css b/static/style.css index 5afc85eb..7e607f9f 100644 --- a/static/style.css +++ b/static/style.css @@ -3353,6 +3353,11 @@ main.main.showing-logs > #mainLogs{display:flex;} .mermaid-rendered svg{max-width:100%;height:auto;} /* ── Session projects ── */ +.session-source-tabs{display:flex;gap:4px;padding:4px 10px 8px;flex-shrink:0;} +.session-source-tab{flex:1;min-width:0;border:1px solid var(--border2);border-radius:10px;background:var(--input-bg);color:var(--muted);font-size:10px;font-weight:700;line-height:1.2;padding:5px 6px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;} +.session-source-tab:hover{background:rgba(255,255,255,.08);color:var(--text);} +.session-source-tab.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);} +.session-empty-note{padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;} .project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;} .project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;} .project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);} diff --git a/tests/test_issue2351_cli_session_source_filter.py b/tests/test_issue2351_cli_session_source_filter.py new file mode 100644 index 00000000..efe2a8f6 --- /dev/null +++ b/tests/test_issue2351_cli_session_source_filter.py @@ -0,0 +1,31 @@ +"""Regression coverage for issue #2351 CLI session list separation.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = ROOT / "static" / "sessions.js" +STYLE_CSS = ROOT / "static" / "style.css" + + +def test_sidebar_has_separate_webui_and_cli_session_source_tabs(): + src = SESSIONS_JS.read_text(encoding="utf-8") + assert "let _sessionSourceFilter = 'webui'" in src + assert "hermes-session-source-filter" in src + assert "session-source-tabs" in src + assert "WebUI sessions" in src + assert "CLI sessions" in src + assert "_sessionSourceFilter==='cli'" in src + + +def test_cli_filter_keeps_cli_rows_out_of_default_webui_list(): + src = SESSIONS_JS.read_text(encoding="utf-8") + assert "const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length" in src + assert "const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length" in src + assert "? withMessages.filter(s=>_isCliSession(s))" in src + assert ": withMessages.filter(s=>!_isCliSession(s))" in src + + +def test_session_source_tabs_have_dedicated_sidebar_styles(): + css = STYLE_CSS.read_text(encoding="utf-8") + assert ".session-source-tabs" in css + assert ".session-source-tab.active" in css + assert ".session-empty-note" in css