feat: relative time labels in session sidebar (#406)

* 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 <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-13 22:26:05 -07:00
committed by GitHub
parent 4de4ed9a15
commit 1345ccccee
6 changed files with 274 additions and 10 deletions

View File

@@ -1,5 +1,14 @@
# Hermes Web UI -- Changelog # 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) ## [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 - `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

View File

@@ -166,6 +166,17 @@ const LOCALES = {
tab_todos: 'Todos', tab_todos: 'Todos',
new_conversation: 'New conversation', new_conversation: 'New conversation',
filter_conversations: 'Filter conversations...', 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', scheduled_jobs: 'Scheduled jobs',
new_job: 'New job', new_job: 'New job',
loading: 'Loading...', loading: 'Loading...',
@@ -433,6 +444,17 @@ const LOCALES = {
tab_todos: 'Todos', tab_todos: 'Todos',
new_conversation: 'Nueva conversación', new_conversation: 'Nueva conversación',
filter_conversations: 'Filtrar conversaciones...', 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', scheduled_jobs: 'Tareas programadas',
new_job: 'Nueva tarea', new_job: 'Nueva tarea',
loading: 'Cargando...', loading: 'Cargando...',

View File

@@ -535,7 +535,7 @@
<div class="settings-section-title">System</div> <div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div> <div class="settings-section-meta">Instance version and access controls.</div>
</div> </div>
<span class="settings-version-badge">v0.50.26</span> <span class="settings-version-badge">v0.50.27</span>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label> <label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -344,6 +344,72 @@ function filterSessions(){
}, 350); }, 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(){ function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input) // Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return; if(_renamingSid) return;
@@ -430,12 +496,12 @@ function renderSessionListFromCache(){
empty.textContent='No sessions in this project yet.'; empty.textContent='No sessions in this project yet.';
list.appendChild(empty); list.appendChild(empty);
} }
const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
// Separate pinned from unpinned // Separate pinned from unpinned
const pinned=sessions.filter(s=>s.pinned); const pinned=orderedSessions.filter(s=>s.pinned);
const unpinned=sessions.filter(s=>!s.pinned); const unpinned=orderedSessions.filter(s=>!s.pinned);
// Date grouping: Pinned / Today / Yesterday / Earlier // Date grouping: Pinned / Today / Yesterday / This week / Last week / Older
const now=Date.now(); const now=Date.now();
const ONE_DAY=86400000;
// Collapse state persisted in localStorage // Collapse state persisted in localStorage
let _groupCollapsed={}; let _groupCollapsed={};
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){} try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
@@ -445,8 +511,8 @@ function renderSessionListFromCache(){
let curLabel=null,curItems=[]; let curLabel=null,curItems=[];
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true}); if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
for(const s of unpinned){ for(const s of unpinned){
const ts=(s.updated_at||s.created_at||0)*1000; const ts=_sessionTimestampMs(s);
const label=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier'; const label=_sessionTimeBucketLabel(ts, now);
if(label!==curLabel){ if(label!==curLabel){
if(curItems.length) groups.push({label:curLabel,items:curItems}); if(curItems.length) groups.push({label:curLabel,items:curItems});
curLabel=label;curItems=[s]; curLabel=label;curItems=[s];
@@ -491,10 +557,32 @@ function renderSessionListFromCache(){
const rawTitle=s.title||'Untitled'; const rawTitle=s.title||'Untitled';
const tags=(rawTitle.match(/#[\w-]+/g)||[]); const tags=(rawTitle.match(/#[\w-]+/g)||[]);
const cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle; 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'); const title=document.createElement('span');
title.className='session-title'; title.className='session-title';
title.textContent=cleanTitle||'Untitled'; title.textContent=cleanTitle||'Untitled';
title.title='Double-click to rename'; 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 // Append tag chips after the title text
for(const tag of tags){ for(const tag of tags){
const chip=document.createElement('span'); const chip=document.createElement('span');
@@ -561,7 +649,7 @@ function renderSessionListFromCache(){
title.appendChild(dot); title.appendChild(dot);
} }
} }
el.appendChild(title); el.appendChild(sessionText);
// Single trigger button that opens a shared dropdown menu // Single trigger button that opens a shared dropdown menu
const actions=document.createElement('div'); const actions=document.createElement('div');
actions.className='session-actions'; actions.className='session-actions';

View File

@@ -34,6 +34,7 @@
:root[data-theme="light"] .session-item{color:#5a544a;} :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: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{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-pin-indicator{color:#996b15;}
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;} :root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
:root[data-theme="light"] .session-actions-trigger.active, :root[data-theme="light"] .session-actions-trigger.active,
@@ -129,10 +130,15 @@
.session-search input::placeholder{color:var(--muted);opacity:.7;} .session-search input::placeholder{color:var(--muted);opacity:.7;}
/* Inline session title edit */ /* 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-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:hover{background:var(--hover-bg);color:var(--text);}
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;} .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 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-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;} .session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;}

View File

@@ -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