feat(ui): add sidebar density mode to session list (#764)

Adds compact/detailed toggle for the session list sidebar. Compact is the default (no behavior change for existing users). Detailed mode shows message count and model; profile names only appear when mixing sessions across profiles.

Fixes #673

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
Frank Song
2026-04-21 03:43:40 +08:00
committed by GitHub
parent a8979f74d5
commit 0dd5d6f21c
10 changed files with 277 additions and 14 deletions

View File

@@ -3,9 +3,9 @@ Tests for fixes:
- #569: docker_init.bash auto-detects WANTED_UID/WANTED_GID from mounted workspace
so macOS users (UID 501) don't need to manually set the env var.
- #579: Topbar message count already filters tool messages (role !== 'tool')
confirmed present. Closing as already fixed by #584 which removed the
sidebar meta row (the only place raw message_count was ever displayed).
- #579: Topbar message count already filters tool messages (role !== 'tool').
The legacy raw sidebar count was removed by #584, and later reintroduced
in a gated detailed-density mode by #673.
"""
import pathlib
import re
@@ -143,15 +143,21 @@ def test_579_topbar_filters_tool_messages():
)
def test_579_sidebar_no_longer_shows_raw_count():
"""sessions.js must not reference message_count in the render path (#579).
def test_579_sidebar_count_is_gated_behind_detailed_density():
"""sessions.js may only show sidebar count inside detailed density mode.
After PR #584, the sidebar no longer shows message_count at all,
eliminating the inconsistency between sidebar (raw) and topbar (filtered).
PR #584 removed the always-visible raw sidebar count to avoid mismatching the
topbar's filtered count. PR #673 later reintroduced message_count as
optional metadata, but only when the user explicitly opts into detailed
sidebar density.
"""
sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
# message_count should not appear in the client-side session renderer
assert "message_count" not in sessions_js, (
"sessions.js must not reference message_count — "
"the meta row that displayed it was removed in PR #584"
assert "const density=(window._sidebarDensity==='detailed'?'detailed':'compact');" in sessions_js, (
"sessions.js must normalize sidebar density before rendering metadata"
)
assert "if(density==='detailed'){" in sessions_js, (
"sessions.js must gate sidebar metadata behind detailed density mode"
)
assert "typeof s.message_count==='number'?s.message_count:0" in sessions_js, (
"message_count may be rendered only inside the detailed-density branch"
)

181
tests/test_issue673.py Normal file
View File

@@ -0,0 +1,181 @@
"""
Tests for issue #673 — sidebar density mode for the session list.
Covers:
- api/config.py: sidebar_density registered in defaults + enum validation
- static/index.html: settingsSidebarDensity field and i18n wiring present
- static/boot.js: boot path applies window._sidebarDensity with compact default
- static/panels.js: load/save settings wire sidebar_density correctly
- static/sessions.js: detailed mode renders message count + model, and profile
only when the "show all profiles" toggle is active
- static/i18n.js: locale keys exist for all shipped locales
- Integration: GET/POST /api/settings round-trip sidebar_density
"""
import json
import pathlib
import re
import unittest
import urllib.error
import urllib.request
REPO_ROOT = pathlib.Path(__file__).parent.parent
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
from tests._pytest_port import BASE
def _get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def _post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path, data=data, headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
class TestSidebarDensityConfig(unittest.TestCase):
def test_sidebar_density_in_defaults(self):
self.assertIn('"sidebar_density"', CONFIG_PY)
def test_sidebar_density_default_is_compact(self):
self.assertRegex(CONFIG_PY, r'"sidebar_density"\s*:\s*"compact"')
def test_sidebar_density_in_enum_values(self):
self.assertIn('"sidebar_density": {"compact", "detailed"}', CONFIG_PY)
class TestSidebarDensityHTML(unittest.TestCase):
def test_settings_select_present(self):
self.assertIn('id="settingsSidebarDensity"', INDEX_HTML)
def test_i18n_wiring_present(self):
for key in (
'data-i18n="settings_label_sidebar_density"',
'data-i18n="settings_desc_sidebar_density"',
'data-i18n="settings_sidebar_density_compact"',
'data-i18n="settings_sidebar_density_detailed"',
):
self.assertIn(key, INDEX_HTML)
class TestSidebarDensityBootAndPanels(unittest.TestCase):
def test_boot_applies_sidebar_density(self):
self.assertIn(
"window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');",
BOOT_JS,
)
def test_boot_fallback_is_compact(self):
self.assertIn("window._sidebarDensity='compact';", BOOT_JS)
def test_settings_panel_reads_sidebar_density(self):
self.assertIn("settingsSidebarDensity", PANELS_JS)
self.assertIn(
"settings.sidebar_density==='detailed'?'detailed':'compact'",
PANELS_JS,
)
def test_settings_save_writes_sidebar_density(self):
self.assertIn("body.sidebar_density=sidebarDensity;", PANELS_JS)
self.assertIn(
"window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact';",
PANELS_JS,
)
class TestSidebarDensitySessionRendering(unittest.TestCase):
def test_detailed_mode_branch_present(self):
self.assertIn(
"const density=(window._sidebarDensity==='detailed'?'detailed':'compact');",
SESSIONS_JS,
)
self.assertIn("if(density==='detailed')", SESSIONS_JS)
def test_detailed_mode_uses_message_count_and_model(self):
self.assertIn("typeof s.message_count==='number'?s.message_count:0", SESSIONS_JS)
self.assertIn("if(s.model) metaBits.push(s.model);", SESSIONS_JS)
self.assertIn("t('session_meta_messages', msgCount)", SESSIONS_JS)
def test_profile_only_when_show_all_profiles(self):
self.assertIn(
"if(_showAllProfiles&&s.profile) metaBits.push(s.profile);", SESSIONS_JS
)
def test_session_meta_css_hook_present(self):
self.assertIn(".session-meta", STYLE_CSS)
class TestSidebarDensityI18N(unittest.TestCase):
def _extract_locale_block(self, start_marker, end_marker):
start = I18N_JS.find(start_marker)
end = I18N_JS.find(end_marker, start)
self.assertGreater(start, -1)
self.assertGreater(end, start)
return I18N_JS[start:end]
def test_all_locale_blocks_have_sidebar_density_keys(self):
locale_ranges = [
("\n en: {", "\n ru: {"),
("\n ru: {", "\n es: {"),
("\n es: {", "\n de: {"),
("\n de: {", "\n zh: {"),
("\n zh: {", "\n // Traditional Chinese (zh-Hant)"),
("\n // Traditional Chinese (zh-Hant)\n 'zh-Hant': {", "\n};"),
]
required = (
"settings_label_sidebar_density",
"settings_desc_sidebar_density",
"settings_sidebar_density_compact",
"settings_sidebar_density_detailed",
"session_meta_messages",
)
for start, end in locale_ranges:
block = self._extract_locale_block(start, end)
for key in required:
self.assertIn(key, block, f"{key} missing from locale block {start}")
class TestSidebarDensitySettingsAPI(unittest.TestCase):
def test_sidebar_density_default_is_compact(self):
try:
data, status = _get("/api/settings")
except OSError:
self.skipTest("Server not running on test server port")
self.assertEqual(status, 200)
self.assertEqual(data.get("sidebar_density"), "compact")
def test_sidebar_density_round_trips_detailed(self):
try:
_, status = _post("/api/settings", {"sidebar_density": "detailed"})
except OSError:
self.skipTest("Server not running on test server port")
self.assertEqual(status, 200)
data, _ = _get("/api/settings")
self.assertEqual(data.get("sidebar_density"), "detailed")
_post("/api/settings", {"sidebar_density": "compact"})
def test_invalid_sidebar_density_is_ignored(self):
try:
_post("/api/settings", {"sidebar_density": "compact"})
data, status = _post("/api/settings", {"sidebar_density": "nope"})
except OSError:
self.skipTest("Server not running on test server port")
self.assertEqual(status, 200)
self.assertEqual(data.get("sidebar_density"), "compact")
current, _ = _get("/api/settings")
self.assertEqual(current.get("sidebar_density"), "compact")