fix(sidebar): declutter session items — drop message count, model, and source-tag badges (v0.50.64)
Squash-merges PR #584 by @aronprins. Drops the meta row (message count, model slug, source-tag badge) from every sidebar session item. Each session now renders as a single title line — visible session count roughly doubles at typical viewport height. Changes merged verbatim from contributor branch, plus maintainer additions: - CHANGELOG entry for v0.50.64 - Version badge bump to v0.50.64 - New test: test_relative_time_today_bucket (closes minor coverage gap from code review) Co-authored-by: aronprins <aronprins@users.noreply.github.com>
This commit is contained in:
@@ -590,26 +590,17 @@ function renderSessionListFromCache(){
|
||||
}
|
||||
// ── Render session items (extracted for group body use) ──
|
||||
// Note: declared after the groups loop but available via function hoisting.
|
||||
function _formatSourceTag(tag){
|
||||
// #429: return null for unknown/unrecognised tags so callers can suppress display.
|
||||
// Previously returned the raw tag string, causing 'N/A' or other junk values
|
||||
// from older hermes-agent state.db records to surface in the session list.
|
||||
const names={telegram:'via Telegram',discord:'via Discord',slack:'via Slack',cli:'CLI',feishu:'via Feishu',weixin:'via WeChat'};
|
||||
return names[tag]||null;
|
||||
}
|
||||
function _renderOneSession(s){
|
||||
const el=document.createElement('div');
|
||||
const isActive=S.session&&s.session_id===S.session.session_id;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(s.is_cli_session?' cli-session':'');
|
||||
if(s.source_tag) el.dataset.source=s.source_tag;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'');
|
||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||
const rawTitle=s.title||'Untitled';
|
||||
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
||||
let cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
|
||||
// Guard: system prompt content must never surface as a visible session title
|
||||
const _SOURCE_DISPLAY={telegram:'Telegram',discord:'Discord',slack:'Slack',cli:'CLI',feishu:'Feishu',weixin:'WeChat'};
|
||||
if(cleanTitle.startsWith('[SYSTEM:')){
|
||||
cleanTitle=(_SOURCE_DISPLAY[s.source_tag]||'Gateway')+' session';
|
||||
cleanTitle='Session';
|
||||
}
|
||||
const sessionText=document.createElement('div');
|
||||
sessionText.className='session-text';
|
||||
@@ -621,17 +612,7 @@ function renderSessionListFromCache(){
|
||||
title.title='Double-click to rename';
|
||||
const tsMs=_sessionTimestampMs(s);
|
||||
titleRow.appendChild(title);
|
||||
const metaBits=[];
|
||||
if(s.is_cli_session && s.source_tag){const _stLabel=_formatSourceTag(s.source_tag);if(_stLabel)metaBits.push(_stLabel);}
|
||||
if(s.message_count) metaBits.push(t('n_messages', s.message_count));
|
||||
if(s.model) metaBits.push(String(s.model).split('/').pop());
|
||||
sessionText.appendChild(titleRow);
|
||||
if(metaBits.length){
|
||||
const meta=document.createElement('div');
|
||||
meta.className='session-meta';
|
||||
meta.textContent=metaBits.join(' · ');
|
||||
sessionText.appendChild(meta);
|
||||
}
|
||||
// Append tag chips after the title text
|
||||
for(const tag of tags){
|
||||
const chip=document.createElement('span');
|
||||
|
||||
@@ -170,11 +170,10 @@
|
||||
.session-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;}
|
||||
.session-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow:hidden;}
|
||||
.session-title-row{display:flex;align-items:flex-start;gap:8px;min-width:0;}
|
||||
.session-title-row{display:flex;align-items:center;gap:6px;min-width:0;}
|
||||
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);}
|
||||
.session-item.active .session-title{color:var(--gold);}
|
||||
.session-time{display:none;}
|
||||
.session-meta{font-size:11px;line-height:1.35;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
/* ── Session action trigger + dropdown ── */
|
||||
.session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;}
|
||||
.session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;}
|
||||
@@ -1123,32 +1122,3 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
|
||||
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}
|
||||
|
||||
/* ── CLI / Agent session items in sidebar ── */
|
||||
.session-item.cli-session {
|
||||
padding-right: 40px; /* make room for the session actions trigger */
|
||||
}
|
||||
.session-item.cli-session::after {
|
||||
content: attr(data-source);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--gold);
|
||||
opacity: .5;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none; /* don't block clicks on session-actions beneath */
|
||||
}
|
||||
.session-item.cli-session:hover::after {
|
||||
display: none; /* hide badge on hover so the session menu trigger stays clear */
|
||||
}
|
||||
.session-item.cli-session.menu-open::after {
|
||||
display: none;
|
||||
}
|
||||
/* Source-specific colors for gateway sessions */
|
||||
.session-item.cli-session[data-source="telegram"] { border-left-color: rgba(0, 136, 204, 0.55); }
|
||||
.session-item.cli-session[data-source="telegram"]::after { color: rgba(0, 136, 204, 0.55); }
|
||||
.session-item.cli-session[data-source="discord"] { border-left-color: #5865F2; }
|
||||
.session-item.cli-session[data-source="discord"]::after { color: #5865F2; }
|
||||
.session-item.cli-session[data-source="slack"] { border-left-color: #4A154B; }
|
||||
.session-item.cli-session[data-source="slack"]::after { color: #4A154B; }
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"""
|
||||
Tests for issue #429 — Feishu/WeChat sessions show 'N/A' source_tag
|
||||
instead of a platform name or nothing.
|
||||
|
||||
Root cause: sessions in hermes-agent's state.db may have source field
|
||||
set to NULL, empty string, or a legacy/unknown value (e.g. 'N/A').
|
||||
The WebUI was displaying whatever raw value it received.
|
||||
|
||||
Fix: in static/sessions.js:
|
||||
- _formatSourceTag() returns null for unknown/unrecognised tags
|
||||
(previously returned the raw tag string, surfacing 'N/A' etc.)
|
||||
- metaBits push is guarded: only push if _formatSourceTag returns
|
||||
a non-null value
|
||||
- [SYSTEM:] title fallback uses _SOURCE_DISPLAY map only, falls
|
||||
back to 'Gateway' -- never surfaces an unknown raw source_tag
|
||||
|
||||
Tests verify via JS source inspection (structural) only — no live
|
||||
server needed.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text()
|
||||
|
||||
|
||||
# ── Source-level structural checks ───────────────────────────────────────────
|
||||
|
||||
def test_format_source_tag_returns_null_for_unknown():
|
||||
"""_formatSourceTag must return null (not the raw tag) for unrecognised values."""
|
||||
# The fixed function must have a null/falsy fallback, not return the raw tag
|
||||
# Pattern: names[tag] || tag → names[tag] || null
|
||||
# Find the _formatSourceTag function body
|
||||
start = SESSIONS_JS.find('function _formatSourceTag(')
|
||||
assert start != -1, "_formatSourceTag not found in sessions.js"
|
||||
fn_window = SESSIONS_JS[start:start+300]
|
||||
# Must NOT return the raw tag as fallback — old pattern was: return names[tag]||tag
|
||||
assert 'return names[tag]||tag' not in fn_window, (
|
||||
"_formatSourceTag must not return the raw tag for unknown values — "
|
||||
"this causes 'N/A' or other garbage to appear in the session list"
|
||||
)
|
||||
|
||||
|
||||
def test_format_source_tag_has_null_fallback():
|
||||
"""_formatSourceTag must return null (or falsy) for unknown tags."""
|
||||
start = SESSIONS_JS.find('function _formatSourceTag(')
|
||||
assert start != -1
|
||||
fn_window = SESSIONS_JS[start:start+500] # wider to cover full function body
|
||||
# Should have: return names[tag] || null
|
||||
assert 'return names[tag]||null' in fn_window or 'return names[tag] || null' in fn_window, (
|
||||
"_formatSourceTag should return null for unknown tags to suppress display"
|
||||
)
|
||||
|
||||
|
||||
def test_metabits_push_is_guarded():
|
||||
"""metaBits push of _formatSourceTag result must be guarded against null."""
|
||||
# The fix uses a temp variable pattern:
|
||||
# const _stLabel = _formatSourceTag(s.source_tag); if(_stLabel) metaBits.push(_stLabel)
|
||||
idx = SESSIONS_JS.find('_stLabel')
|
||||
assert idx != -1, (
|
||||
"_stLabel guard variable not found — metaBits.push(_formatSourceTag()) "
|
||||
"must check the return value before pushing to avoid null/N/A entries"
|
||||
)
|
||||
context = SESSIONS_JS[idx:idx+120]
|
||||
assert 'if(_stLabel)' in context or 'if (_stLabel)' in context, (
|
||||
f"_stLabel must be checked before pushing. Context: {context!r}"
|
||||
)
|
||||
assert 'metaBits.push(_stLabel)' in context, (
|
||||
f"Expected metaBits.push(_stLabel). Context: {context!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_known_platforms_still_display():
|
||||
"""Known platform tags (telegram, feishu, weixin, etc.) must still appear."""
|
||||
start = SESSIONS_JS.find('function _formatSourceTag(')
|
||||
assert start != -1
|
||||
fn_window = SESSIONS_JS[start:start+500] # wider to cover full function body
|
||||
for platform in ('telegram', 'feishu', 'weixin', 'discord', 'slack'):
|
||||
assert platform in fn_window, (
|
||||
f"Platform '{platform}' missing from _formatSourceTag names map"
|
||||
)
|
||||
|
||||
|
||||
def test_system_prompt_title_fallback_no_raw_source():
|
||||
"""[SYSTEM:] title fallback must use display map or 'Gateway', not raw source_tag."""
|
||||
# Find the [SYSTEM:] guard block
|
||||
idx = SESSIONS_JS.find("cleanTitle.startsWith('[SYSTEM:')")
|
||||
assert idx != -1, "[SYSTEM:] guard not found in sessions.js"
|
||||
block = SESSIONS_JS[idx:idx+200]
|
||||
# The fallback must end with ||'Gateway' and must look up via _SOURCE_DISPLAY
|
||||
# It must NOT just use s.source_tag directly as a fallback
|
||||
# Old broken pattern: (_SOURCE_DISPLAY[s.source_tag]||s.source_tag||'Gateway')
|
||||
# Fixed pattern: (_SOURCE_DISPLAY[s.source_tag]||'Gateway')
|
||||
assert "||s.source_tag||" not in block, (
|
||||
"System prompt title fallback must not use s.source_tag directly — "
|
||||
"this would surface 'N/A' as a session title for unknown source values. "
|
||||
f"Found: {block!r}"
|
||||
)
|
||||
assert "'Gateway'" in block, (
|
||||
"System prompt title fallback must have 'Gateway' as the final fallback"
|
||||
)
|
||||
|
||||
|
||||
def test_source_tag_guard_before_dataset_set():
|
||||
"""el.dataset.source assignment must be guarded (only set for known/non-empty tags)."""
|
||||
# This is already guarded in the original: if(s.source_tag) el.dataset.source=...
|
||||
# Verify it's still there
|
||||
idx = SESSIONS_JS.find('el.dataset.source=s.source_tag')
|
||||
assert idx != -1, "dataset.source assignment not found"
|
||||
context = SESSIONS_JS[max(0, idx-40):idx+50]
|
||||
assert 'if(' in context or '&&' in context, (
|
||||
"el.dataset.source assignment must be guarded against null/empty source_tag"
|
||||
)
|
||||
|
||||
|
||||
def test_na_string_not_in_known_names():
|
||||
"""'N/A' must not appear as a value in the _formatSourceTag names map."""
|
||||
start = SESSIONS_JS.find('function _formatSourceTag(')
|
||||
assert start != -1
|
||||
fn_window = SESSIONS_JS[start:start+500]
|
||||
# Find where the const names = {...} map ends (closing brace)
|
||||
map_start = fn_window.find('const names={')
|
||||
map_end = fn_window.find('};', map_start)
|
||||
names_map = fn_window[map_start:map_end+2] if map_end != -1 else fn_window[map_start:map_start+200]
|
||||
assert "'N/A'" not in names_map and '"N/A"' not in names_map, (
|
||||
f"'N/A' must not be a value in the source tag names map. Found: {names_map!r}"
|
||||
)
|
||||
@@ -76,14 +76,12 @@ def test_session_sidebar_js_has_dynamic_relative_time_helpers():
|
||||
def test_session_sidebar_renders_relative_time_and_meta_rows():
|
||||
# session-time element was removed from sessions.js in v0.50.40 to
|
||||
# give session titles full width — the CSS class is kept but set to display:none.
|
||||
assert "session-time" not in SESSIONS_JS or True # intentionally removed from JS
|
||||
assert "session-meta" in SESSIONS_JS
|
||||
# session-meta / metaBits were removed when we dropped message-count, model, and
|
||||
# source-tag badges from the sidebar (design round 2).
|
||||
assert "orderedSessions" in SESSIONS_JS
|
||||
assert ".session-time" in STYLE_CSS
|
||||
assert ".session-meta" in STYLE_CSS
|
||||
assert ".session-title-row" in STYLE_CSS
|
||||
assert ".session-item.active .session-title" in STYLE_CSS
|
||||
assert "metaBits.join(' · ')" in SESSIONS_JS
|
||||
assert "|| _sessionTimeBucketLabel" not in SESSIONS_JS
|
||||
assert "const ONE_DAY=86400000;" not in SESSIONS_JS
|
||||
|
||||
|
||||
@@ -118,85 +118,6 @@ class TestGatewaySessionNullModel(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
# ── #453 telegram badge ─────────────────────────────────────────────
|
||||
class TestTelegramBadgeMutedColor(unittest.TestCase):
|
||||
|
||||
def test_telegram_badge_uses_muted_color(self):
|
||||
"""Telegram badge rules must use rgba(0, 136, 204, 0.55) not #0088cc."""
|
||||
# Extract only the telegram-related CSS block
|
||||
telegram_lines = [
|
||||
line for line in STYLE_CSS.splitlines()
|
||||
if 'data-source="telegram"' in line or "data-source='telegram'" in line
|
||||
]
|
||||
self.assertTrue(
|
||||
len(telegram_lines) >= 2,
|
||||
"Expected at least 2 telegram badge CSS rules"
|
||||
)
|
||||
muted_color = "rgba(0, 136, 204, 0.55)"
|
||||
for line in telegram_lines:
|
||||
self.assertIn(
|
||||
muted_color, line,
|
||||
f"Telegram CSS rule should use {muted_color!r}, got: {line!r}"
|
||||
)
|
||||
self.assertNotIn(
|
||||
"#0088cc", line,
|
||||
f"Telegram CSS rule must not use saturated #0088cc, got: {line!r}"
|
||||
)
|
||||
|
||||
def test_telegram_border_left_color_muted(self):
|
||||
"""The border-left-color rule for telegram uses rgba."""
|
||||
pattern = r'\.session-item\.cli-session\[data-source=["\']telegram["\']\]\s*\{[^}]*border-left-color:\s*rgba\(0,\s*136,\s*204,\s*0\.55\)'
|
||||
self.assertRegex(STYLE_CSS, pattern,
|
||||
"border-left-color for telegram should be rgba(0, 136, 204, 0.55)")
|
||||
|
||||
def test_telegram_after_color_muted(self):
|
||||
"""The ::after color rule for telegram uses rgba."""
|
||||
pattern = r'\.session-item\.cli-session\[data-source=["\']telegram["\']\]::after\s*\{[^}]*color:\s*rgba\(0,\s*136,\s*204,\s*0\.55\)'
|
||||
self.assertRegex(STYLE_CSS, pattern,
|
||||
"::after color for telegram should be rgba(0, 136, 204, 0.55)")
|
||||
|
||||
|
||||
class TestFormatSourceTagHelper(unittest.TestCase):
|
||||
|
||||
def test_format_source_tag_helper_exists(self):
|
||||
"""_formatSourceTag function must be defined in sessions.js."""
|
||||
self.assertIn("function _formatSourceTag(", SESSIONS_JS,
|
||||
"_formatSourceTag helper function not found in sessions.js")
|
||||
|
||||
def test_format_source_tag_maps_telegram(self):
|
||||
"""_formatSourceTag maps 'telegram' to 'via Telegram'."""
|
||||
self.assertIn("telegram:'via Telegram'", SESSIONS_JS,
|
||||
"sessions.js should map telegram -> 'via Telegram'")
|
||||
|
||||
def test_format_source_tag_maps_discord(self):
|
||||
"""_formatSourceTag maps 'discord' to 'via Discord'."""
|
||||
self.assertIn("discord:'via Discord'", SESSIONS_JS,
|
||||
"sessions.js should map discord -> 'via Discord'")
|
||||
|
||||
def test_format_source_tag_maps_slack(self):
|
||||
"""_formatSourceTag maps 'slack' to 'via Slack'."""
|
||||
self.assertIn("slack:'via Slack'", SESSIONS_JS,
|
||||
"sessions.js should map slack -> 'via Slack'")
|
||||
|
||||
def test_metabits_uses_format_helper(self):
|
||||
"""The metaBits push for source_tag should use _formatSourceTag with a null guard."""
|
||||
# Fix #429: the push now uses a temp variable guard to suppress null/N/A results:
|
||||
# const _stLabel=_formatSourceTag(s.source_tag); if(_stLabel) metaBits.push(_stLabel)
|
||||
# The old direct push pattern is gone; verify the guarded pattern is present.
|
||||
self.assertIn("_formatSourceTag(s.source_tag)", SESSIONS_JS,
|
||||
"metaBits push should still use _formatSourceTag() for source_tag display")
|
||||
self.assertIn("metaBits.push(_stLabel)", SESSIONS_JS,
|
||||
"metaBits push should use guarded _stLabel variable (fix #429)")
|
||||
|
||||
def test_raw_source_tag_not_pushed_directly(self):
|
||||
"""The old raw metaBits.push(s.source_tag) should not exist."""
|
||||
self.assertNotIn("metaBits.push(s.source_tag)", SESSIONS_JS,
|
||||
"Raw s.source_tag should not be pushed directly to metaBits")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -206,17 +206,6 @@ def test_system_prompt_title_guard_exists():
|
||||
"sessions.js must have: cleanTitle.startsWith('[SYSTEM:') guard expression"
|
||||
|
||||
|
||||
def test_source_display_map_defined():
|
||||
"""The _SOURCE_DISPLAY lookup map must be present and include core gateway platforms."""
|
||||
content = _read_sessions_js()
|
||||
assert '_SOURCE_DISPLAY' in content, \
|
||||
"sessions.js must define _SOURCE_DISPLAY mapping for platform name lookup"
|
||||
# Verify key platform entries are present
|
||||
for platform in ("telegram:'Telegram'", "discord:'Discord'", "cli:'CLI'"):
|
||||
assert platform in content, \
|
||||
f"_SOURCE_DISPLAY must include entry for {platform}"
|
||||
|
||||
|
||||
def test_cleanTitle_is_let_not_const():
|
||||
"""cleanTitle must be declared with let (not const) to allow reassignment in the guard."""
|
||||
content = _read_sessions_js()
|
||||
|
||||
Reference in New Issue
Block a user