From 1345ccccee56fa6a40c2036ac1aa729a73294028 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 13 Apr 2026 22:26:05 -0700 Subject: [PATCH] feat: relative time labels in session sidebar (#406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add relative time to session sidebar (cherry picked from commit 272be9787fdff75d3da2dbc73175820477a3390e) * fix: address session sidebar relative-time review feedback * docs: v0.50.27 release — version badge and CHANGELOG --------- Co-authored-by: Jordan SkyLF Co-authored-by: Nathan Esquenazi --- CHANGELOG.md | 9 ++ static/i18n.js | 22 ++++ static/index.html | 2 +- static/sessions.js | 102 +++++++++++++- static/style.css | 10 +- tests/test_session_sidebar_relative_time.py | 139 ++++++++++++++++++++ 6 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 tests/test_session_sidebar_relative_time.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3687037..5b0c26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Hermes Web UI -- Changelog +## [v0.50.27] feat: relative time labels in session sidebar (#394) + +- `static/sessions.js`: new `_sessionCalendarBoundaries()` (DST-safe via `new Date(y,m,d)` construction), `_localDayOrdinal()`, `_formatSessionDate()` (includes year for dates from prior years); `_formatRelativeSessionTime()` now uses calendar midnight boundaries consistent with `_sessionTimeBucketLabel()` — no more label/bucket mismatch; all relative time strings call `t()` for localization; meta row only appended when non-empty (removes redundant group-header fallback); dead `ONE_DAY` constant removed +- `static/style.css`: add `session-item.active .session-title{color:#1a5a8a}` to light-theme block (fixes active title color in light mode) +- `static/i18n.js`: 11 new i18n keys (`session_time_*`) in both English and Spanish locale blocks; callable keys use arrow-function pattern consistent with existing `n_messages` +- `tests/test_session_sidebar_relative_time.py`: 5 tests — structural presence checks, behavioral Node.js tests via subprocess (yesterday/week boundary correctness, `just now` threshold, year-in-date for old sessions, full i18n key coverage for en+es) +- Original PR by @Jordan-SkyLF (two-pass review: blocking issues fixed in second commit) +- 1027 tests total (up from 1022) + ## [v0.50.26] fix(sessions): redact sensitive titles in session list and search responses [SECURITY] (#400) - `api/routes.py`: apply `_redact_text()` to session titles in all four response paths — `/api/sessions` merged list, `/api/sessions/search` empty-q, title-match, and content-match; use `dict(s)` copy before mutating to avoid corrupting the in-memory session cache diff --git a/static/i18n.js b/static/i18n.js index 75e53f6..a142d1c 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -166,6 +166,17 @@ const LOCALES = { tab_todos: 'Todos', new_conversation: 'New conversation', filter_conversations: 'Filter conversations...', + session_time_unknown: 'Unknown', + session_time_just_now: 'just now', + session_time_minutes_ago: (n) => `${n} minute${n === 1 ? '' : 's'} ago`, + session_time_hours_ago: (n) => `${n} hour${n === 1 ? '' : 's'} ago`, + session_time_days_ago: (n) => `${n} day${n === 1 ? '' : 's'} ago`, + session_time_last_week: 'last week', + 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', scheduled_jobs: 'Scheduled jobs', new_job: 'New job', loading: 'Loading...', @@ -433,6 +444,17 @@ const LOCALES = { tab_todos: 'Todos', new_conversation: 'Nueva conversación', filter_conversations: 'Filtrar conversaciones...', + session_time_unknown: 'Desconocido', + session_time_just_now: 'justo ahora', + session_time_minutes_ago: (n) => `hace ${n} minuto${n === 1 ? '' : 's'}`, + session_time_hours_ago: (n) => `hace ${n} hora${n === 1 ? '' : 's'}`, + session_time_days_ago: (n) => `hace ${n} día${n === 1 ? '' : 's'}`, + session_time_last_week: 'la semana pasada', + session_time_bucket_today: 'Hoy', + session_time_bucket_yesterday: 'Ayer', + session_time_bucket_this_week: 'Esta semana', + session_time_bucket_last_week: 'La semana pasada', + session_time_bucket_older: 'Más antiguo', scheduled_jobs: 'Tareas programadas', new_job: 'Nueva tarea', loading: 'Cargando...', diff --git a/static/index.html b/static/index.html index a61aa0c..f78c416 100644 --- a/static/index.html +++ b/static/index.html @@ -535,7 +535,7 @@
System
- v0.50.26 + v0.50.27
diff --git a/static/sessions.js b/static/sessions.js index 78974cb..091e0a7 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -344,6 +344,72 @@ function filterSessions(){ }, 350); } +function _sessionTimestampMs(session) { + const raw = Number(session && (session.updated_at || session.created_at || 0)); + return Number.isFinite(raw) ? raw * 1000 : 0; +} + +function _localDayOrdinal(timestampMs) { + const date = new Date(timestampMs); + return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000); +} + +function _sessionCalendarBoundaries(nowMs = Date.now()) { + const now = new Date(nowMs); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const startOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + const startOfWeek = new Date(startOfToday); + startOfWeek.setDate(startOfWeek.getDate() - ((startOfWeek.getDay() + 6) % 7)); + const startOfLastWeek = new Date(startOfWeek); + startOfLastWeek.setDate(startOfLastWeek.getDate() - 7); + return { + startOfToday: startOfToday.getTime(), + startOfYesterday: startOfYesterday.getTime(), + startOfWeek: startOfWeek.getTime(), + startOfLastWeek: startOfLastWeek.getTime(), + }; +} + +function _formatSessionDate(timestampMs, nowMs = Date.now()) { + const date = new Date(timestampMs); + const now = new Date(nowMs); + const options = {month:'short', day:'numeric'}; + if (date.getFullYear() !== now.getFullYear()) options.year = 'numeric'; + return date.toLocaleDateString(undefined, options); +} + +function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) { + if (!timestampMs) return t('session_time_unknown'); + const diffMs = Math.max(0, nowMs - timestampMs); + const minute = 60 * 1000; + const hour = 60 * minute; + const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs); + const dayDiff = Math.max(0, _localDayOrdinal(nowMs) - _localDayOrdinal(timestampMs)); + if (timestampMs >= startOfToday) { + if (diffMs < minute) return t('session_time_just_now'); + if (diffMs < hour) { + const minutes = Math.floor(diffMs / minute); + return t('session_time_minutes_ago', minutes); + } + const hours = Math.floor(diffMs / hour); + return t('session_time_hours_ago', hours); + } + if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday'); + if (timestampMs >= startOfWeek) return t('session_time_days_ago', dayDiff); + if (timestampMs >= startOfLastWeek) return t('session_time_last_week'); + return _formatSessionDate(timestampMs, nowMs); +} + +function _sessionTimeBucketLabel(timestampMs, nowMs = Date.now()) { + if (!timestampMs) return t('session_time_bucket_older'); + const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs); + if (timestampMs >= startOfToday) return t('session_time_bucket_today'); + if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday'); + if (timestampMs >= startOfWeek) return t('session_time_bucket_this_week'); + if (timestampMs >= startOfLastWeek) return t('session_time_bucket_last_week'); + return t('session_time_bucket_older'); +} + function renderSessionListFromCache(){ // Don't re-render while user is actively renaming a session (would destroy the input) if(_renamingSid) return; @@ -430,12 +496,12 @@ function renderSessionListFromCache(){ empty.textContent='No sessions in this project yet.'; list.appendChild(empty); } + const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); // Separate pinned from unpinned - const pinned=sessions.filter(s=>s.pinned); - const unpinned=sessions.filter(s=>!s.pinned); - // Date grouping: Pinned / Today / Yesterday / Earlier + const pinned=orderedSessions.filter(s=>s.pinned); + const unpinned=orderedSessions.filter(s=>!s.pinned); + // Date grouping: Pinned / Today / Yesterday / This week / Last week / Older const now=Date.now(); - const ONE_DAY=86400000; // Collapse state persisted in localStorage let _groupCollapsed={}; try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){} @@ -445,8 +511,8 @@ function renderSessionListFromCache(){ let curLabel=null,curItems=[]; if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true}); for(const s of unpinned){ - const ts=(s.updated_at||s.created_at||0)*1000; - const label=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier'; + const ts=_sessionTimestampMs(s); + const label=_sessionTimeBucketLabel(ts, now); if(label!==curLabel){ if(curItems.length) groups.push({label:curLabel,items:curItems}); curLabel=label;curItems=[s]; @@ -491,10 +557,32 @@ function renderSessionListFromCache(){ const rawTitle=s.title||'Untitled'; const tags=(rawTitle.match(/#[\w-]+/g)||[]); const cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle; + const sessionText=document.createElement('div'); + sessionText.className='session-text'; + const titleRow=document.createElement('div'); + titleRow.className='session-title-row'; const title=document.createElement('span'); title.className='session-title'; title.textContent=cleanTitle||'Untitled'; title.title='Double-click to rename'; + const tsMs=_sessionTimestampMs(s); + const timeLabel=document.createElement('span'); + timeLabel.className='session-time'; + timeLabel.textContent=_formatRelativeSessionTime(tsMs, now); + if(tsMs) timeLabel.title=new Date(tsMs).toLocaleString(); + titleRow.appendChild(title); + titleRow.appendChild(timeLabel); + const metaBits=[]; + if(s.is_cli_session && s.source_tag) metaBits.push(s.source_tag); + 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'); @@ -561,7 +649,7 @@ function renderSessionListFromCache(){ title.appendChild(dot); } } - el.appendChild(title); + el.appendChild(sessionText); // Single trigger button that opens a shared dropdown menu const actions=document.createElement('div'); actions.className='session-actions'; diff --git a/static/style.css b/static/style.css index ffc3836..ecd375b 100644 --- a/static/style.css +++ b/static/style.css @@ -34,6 +34,7 @@ :root[data-theme="light"] .session-item{color:#5a544a;} :root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;} :root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;} + :root[data-theme="light"] .session-item.active .session-title{color:#1a5a8a;} :root[data-theme="light"] .session-pin-indicator{color:#996b15;} :root[data-theme="light"] .session-date-header.pinned{color:#996b15;} :root[data-theme="light"] .session-actions-trigger.active, @@ -129,10 +130,15 @@ .session-search input::placeholder{color:var(--muted);opacity:.7;} /* Inline session title edit */ .session-title-input{flex:1;background:var(--surface);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;} - .session-item{padding:8px 40px 8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s;display:flex;align-items:center;gap:6px;min-width:0;position:relative;} + .session-item{padding:8px 40px 8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;} .session-item:hover{background:var(--hover-bg);color:var(--text);} .session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;} - .session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .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{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);} + .session-item.active .session-title{color:#e8a030;} + .session-time{flex-shrink:0;font-size:11px;line-height:1.4;color:var(--muted);text-transform:lowercase;} + .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;} diff --git a/tests/test_session_sidebar_relative_time.py b/tests/test_session_sidebar_relative_time.py new file mode 100644 index 0000000..71c4269 --- /dev/null +++ b/tests/test_session_sidebar_relative_time.py @@ -0,0 +1,139 @@ +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 ( + "_localDayOrdinal", + "_sessionCalendarBoundaries", + "_formatSessionDate", + "_formatRelativeSessionTime", + "_sessionTimeBucketLabel", + ) + ) + script = textwrap.dedent( + f""" + process.env.TZ = 'UTC'; + const translations = {{ + session_time_unknown: 'Unknown', + session_time_just_now: 'just now', + session_time_minutes_ago: (n) => `${{n}} minute${{n === 1 ? '' : 's'}} ago`, + session_time_hours_ago: (n) => `${{n}} hour${{n === 1 ? '' : 's'}} ago`, + session_time_days_ago: (n) => `${{n}} day${{n === 1 ? '' : 's'}} ago`, + session_time_last_week: 'last week', + 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 _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(): + assert "session-time" in SESSIONS_JS + assert "session-meta" in SESSIONS_JS + 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 + + +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"] == "2 days ago" + assert result["bucket"] == "This week" + assert "2024" in result["oldDate"] + + +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"] == "just now" + assert result["yesterday"] == "Yesterday" + assert result["yesterdayBucket"] == "Yesterday" + + +def test_relative_time_strings_are_localized_in_english_and_spanish_bundles(): + for key in ( + "session_time_unknown", + "session_time_just_now", + "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