Files
hermes-webui/tests/test_session_sidebar_relative_time.py
T
nesquena-hermes 01404ac062 v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels

* feat: adaptive session title refresh based on conversation evolution

Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.

Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
  with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
  refresh after stream_end (non-blocking)

Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}

Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)

Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.

* test: add 37 tests for adaptive title refresh helpers

Covers all five new functions introduced in this PR:
  _count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
  _run_background_title_refresh, _maybe_schedule_title_refresh

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>

* fix(settings): show selected state on theme/skin/font-size picker cards

The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.

Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.

Closes #1057

* fix: rename test file to match PR number, fix stale issue reference

* docs: v0.50.211 release notes and version bump

Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.

* docs(changelog): correct settings tab for adaptive title refresh

The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: create state dir before writing settings file

save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.

---------

Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:50:58 -07:00

172 lines
6.0 KiB
Python

import json
import pathlib
import subprocess
import textwrap
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
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")
def _extract_function(source: str, name: str) -> str:
marker = f"function {name}"
start = source.index(marker)
brace_start = source.index("{", start)
depth = 0
for idx in range(brace_start, len(source)):
ch = source[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : idx + 1]
raise AssertionError(f"Could not extract {name}")
def _run_session_time_case(script_body: str) -> dict:
functions = "\n\n".join(
_extract_function(SESSIONS_JS, name)
for name in (
"_sessionTimestampMs",
"_localDayOrdinal",
"_sessionCalendarBoundaries",
"_formatSessionDate",
"_formatRelativeSessionTime",
"_sessionTimeBucketLabel",
)
)
script = textwrap.dedent(
f"""
process.env.TZ = 'UTC';
const translations = {{
session_time_unknown: 'Unknown',
session_time_minutes_ago: (n) => `${{n}}m`,
session_time_hours_ago: (n) => `${{n}}h`,
session_time_days_ago: (n) => `${{n}}d`,
session_time_last_week: '1w',
session_time_bucket_today: 'Today',
session_time_bucket_yesterday: 'Yesterday',
session_time_bucket_this_week: 'This week',
session_time_bucket_last_week: 'Last week',
session_time_bucket_older: 'Older',
}};
function t(key, ...args) {{
const val = translations[key];
return typeof val === 'function' ? val(...args) : val;
}}
{functions}
{script_body}
"""
)
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(proc.stdout)
def test_session_sidebar_js_has_dynamic_relative_time_helpers():
assert "function _sessionTimestampMs" in SESSIONS_JS
assert "function _sessionCalendarBoundaries" in SESSIONS_JS
assert "function _formatRelativeSessionTime" in SESSIONS_JS
assert "function _sessionTimeBucketLabel" in SESSIONS_JS
assert "session_time_bucket_last_week" in SESSIONS_JS
assert "session_time_bucket_this_week" in SESSIONS_JS
assert "session_time_bucket_older" in SESSIONS_JS
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.
# 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-title-row" in STYLE_CSS
assert ".session-item.active .session-title" in STYLE_CSS
assert "|| _sessionTimeBucketLabel" not in SESSIONS_JS
assert "const ONE_DAY=86400000;" not in SESSIONS_JS
def test_session_timestamp_prefers_last_message_at_over_metadata_updated_at():
result = _run_session_time_case(
"""
const session = {
created_at: 1776441348,
updated_at: 1777086443,
last_message_at: 1776441972,
};
process.stdout.write(JSON.stringify({
timestampMs: _sessionTimestampMs(session),
}));
"""
)
assert result["timestampMs"] == 1776441972 * 1000
def test_relative_time_uses_calendar_boundaries_and_year_for_old_sessions():
result = _run_session_time_case(
"""
const now = Date.UTC(2026, 3, 15, 1, 0, 0);
const mondayLate = Date.UTC(2026, 3, 13, 23, 0, 0);
const oldSession = Date.UTC(2024, 2, 5, 12, 0, 0);
process.stdout.write(JSON.stringify({
relative: _formatRelativeSessionTime(mondayLate, now),
bucket: _sessionTimeBucketLabel(mondayLate, now),
oldDate: _formatRelativeSessionTime(oldSession, now),
}));
"""
)
assert result["relative"] == "2d"
assert result["bucket"] == "This week"
assert "2024" in result["oldDate"]
def test_relative_time_today_bucket():
"""Session from 2 hours ago should bucket as 'Today'."""
result = _run_session_time_case(
"""
const now = Date.UTC(2026, 3, 15, 14, 0, 0);
const twoHoursAgo = now - 2 * 60 * 60 * 1000;
process.stdout.write(JSON.stringify({
relative: _formatRelativeSessionTime(twoHoursAgo, now),
bucket: _sessionTimeBucketLabel(twoHoursAgo, now),
}));
"""
)
assert result["relative"] == "2h"
assert result["bucket"] == "Today"
def test_relative_time_handles_just_now_and_dst_safe_yesterday_boundary():
result = _run_session_time_case(
"""
const now = Date.UTC(2026, 2, 9, 12, 0, 0);
const justNow = now - 30 * 1000;
const yesterday = Date.UTC(2026, 2, 8, 23, 30, 0);
process.stdout.write(JSON.stringify({
justNow: _formatRelativeSessionTime(justNow, now),
yesterday: _formatRelativeSessionTime(yesterday, now),
yesterdayBucket: _sessionTimeBucketLabel(yesterday, now),
}));
"""
)
assert result["justNow"] == "1m"
assert result["yesterday"] == "1d"
assert result["yesterdayBucket"] == "Yesterday"
def test_relative_time_strings_are_localized_in_english_and_spanish_bundles():
for key in (
"session_time_unknown",
"session_time_minutes_ago",
"session_time_hours_ago",
"session_time_days_ago",
"session_time_last_week",
"session_time_bucket_today",
"session_time_bucket_yesterday",
"session_time_bucket_this_week",
"session_time_bucket_last_week",
"session_time_bucket_older",
):
assert key in I18N_JS