diff --git a/static/sessions.js b/static/sessions.js index 80aaaa2..2635dce 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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'); diff --git a/static/style.css b/static/style.css index 483707e..69a9903 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/tests/test_issue429.py b/tests/test_issue429.py deleted file mode 100644 index 13f9001..0000000 --- a/tests/test_issue429.py +++ /dev/null @@ -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}" - ) diff --git a/tests/test_session_sidebar_relative_time.py b/tests/test_session_sidebar_relative_time.py index 9b3a6b2..13936e1 100644 --- a/tests/test_session_sidebar_relative_time.py +++ b/tests/test_session_sidebar_relative_time.py @@ -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 diff --git a/tests/test_sprint40_ui_polish.py b/tests/test_sprint40_ui_polish.py index da8156f..1939bd6 100644 --- a/tests/test_sprint40_ui_polish.py +++ b/tests/test_sprint40_ui_polish.py @@ -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() diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py index 465fdba..e79c283 100644 --- a/tests/test_sprint42.py +++ b/tests/test_sprint42.py @@ -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()