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
Instance version and access controls.
- 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