feat: separate CLI sessions in sidebar

This commit is contained in:
Michael Lam
2026-05-17 20:40:20 -07:00
parent 718a4c7615
commit a48e47dd1c
6 changed files with 96 additions and 3 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- **PR #2506** by @Michaelyklam (refs #2351) — Add 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.
## [v0.51.91] — 2026-05-18 — Release BO (stage-384 — 5-PR full sweep batch — reasoning-replay history fix + archive-extract per-session inbox + fallback streaming warnings + sanitized custom-provider env hints + Slice 3c queue/goal adapter routing)
### Fixed
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+56 -3
View File
@@ -847,6 +847,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 };
@@ -1433,6 +1456,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;
@@ -2726,6 +2751,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
@@ -2733,7 +2766,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.
@@ -2768,6 +2801,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);
@@ -2850,9 +2898,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);
}
+5
View File
@@ -3024,6 +3024,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);}
@@ -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