Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

## [Unreleased]

## [v0.51.284] — 2026-06-05 — Release IZ (stage-w4 — sidebar status labels + cron-sessions toggle)

### Added
- **Manual session status labels (Todo / In Progress / Done).** Tag any session from its row's ⋯ menu with a colored status badge (blue Todo / amber In Progress / green Done), stored per-session in localStorage. The badge renders inline on the sidebar row and uses theme variables so it adapts to light/dark and skins. (#3570, @rodboev)
- **"Show cron sessions" preference** (Settings → Preferences). Surfaces cron-job output as conversations in the sidebar. Off by default and gated under "Show non-WebUI sessions" — only active once non-WebUI sessions are enabled — with a note that high-frequency jobs can flood the sidebar. (#3514, @rodboev; closes #2841)

## [v0.51.283] — 2026-06-05 — Release IY (stage-w2 — composer queue hint during auto-compaction)

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5076,6 +5076,7 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock:
"show_tps": False, # show tokens-per-second chip in assistant message headers
"fade_text_effect": False, # animate newly streamed words with a lightweight fade-in effect
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
"show_cron_sessions": False, # surface cron sessions in the sidebar (subordinate to show_cli_sessions)
"show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
Expand Down Expand Up @@ -5242,6 +5243,7 @@ def load_settings() -> dict:
"show_tps",
"fade_text_effect",
"show_cli_sessions",
"show_cron_sessions",
"show_previous_messaging_sessions",
"sync_to_insights",
"check_for_updates",
Expand Down
4 changes: 2 additions & 2 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2369,11 +2369,11 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
s.save()
return s

def _hide_from_default_sidebar(session: dict) -> bool:
def _hide_from_default_sidebar(session: dict, *, show_cron: bool = False) -> bool:
"""Return True for internal/background sessions hidden from the default list."""
sid = str(session.get('session_id') or '')
source = session.get('source_tag') or session.get('source')
if source == 'cron' or sid.startswith('cron_'):
if not show_cron and (source == 'cron' or sid.startswith('cron_')):
return True
if bool(session.get('pre_compression_snapshot')):
return not bool(session.get('_show_pre_compression_snapshot'))
Expand Down
7 changes: 4 additions & 3 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2716,7 +2716,7 @@ def _is_duplicate_webui_state_projection(session: dict, represented_webui_ids: s
return bool(_session_lineage_ids(session) & represented_webui_ids)


def _dedupe_cli_sidebar_sessions_for_api(cli: list[dict], represented_webui_ids: set[str]) -> list[dict]:
def _dedupe_cli_sidebar_sessions_for_api(cli: list[dict], represented_webui_ids: set[str], *, show_cron_sessions: bool = False) -> list[dict]:
"""Return CLI/state sidebar rows while preserving project-hidden cron rows.

Agent-side cron sessions come from state.db rather than the WebUI session
Expand All @@ -2735,7 +2735,7 @@ def _dedupe_cli_sidebar_sessions_for_api(cli: list[dict], represented_webui_ids:
and not _is_duplicate_webui_state_projection(s, represented_webui_ids)
and is_cli_session_row_visible(s)
]
visible = [s for s in candidates if not _cron_hide(s)]
visible = [s for s in candidates if not _cron_hide(s, show_cron=show_cron_sessions)]
return _include_project_hidden_background_sidebar_sessions(candidates, visible)


Expand Down Expand Up @@ -5395,7 +5395,8 @@ def handle_get(handler, parsed) -> bool:
represented_webui_ids = set()
for s in webui_sessions:
represented_webui_ids.update(_session_lineage_ids(s))
deduped_cli = _dedupe_cli_sidebar_sessions_for_api(cli, represented_webui_ids)
show_cron_sessions = bool(settings.get("show_cron_sessions"))
deduped_cli = _dedupe_cli_sidebar_sessions_for_api(cli, represented_webui_ids, show_cron_sessions=show_cron_sessions)
else:
diag.stage("filter_webui_sessions")
webui_sessions = [s for s in webui_sessions if not _is_cli_session_for_settings(s)]
Expand Down
72 changes: 72 additions & 0 deletions static/i18n.js

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,13 @@ <h2 data-i18n="empty_title">What can I help with?</h2>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_external_sessions">Show conversations from CLI, Telegram, Discord, Slack, and other channels in the session list. Click to import and continue.</div>
</div>
<div class="settings-field" id="settingsShowCronSessionsField" style="margin-left:24px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCronSessions" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_cron_sessions">Show cron sessions</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cron_sessions">Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowPreviousMessagingSessions" style="width:15px;height:15px;accent-color:var(--accent)">
Expand Down
16 changes: 16 additions & 0 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -6020,6 +6020,11 @@ function _preferencesPayloadFromUi(){
if(apiRedactCb) payload.api_redact_enabled=apiRedactCb.checked;
const showCliCb=$('settingsShowCliSessions');
if(showCliCb) payload.show_cli_sessions=showCliCb.checked;
const showCronCb=$('settingsShowCronSessions');
// Gate cron sessions on CLI sessions (the server short-circuits otherwise),
// identically to the explicit saveSettings() path, so neither save route can
// persist show_cron_sessions=true while show_cli_sessions=false. (#3514)
if(showCronCb) payload.show_cron_sessions=!!(showCliCb&&showCliCb.checked&&showCronCb.checked);
const showPreviousMessagingCb=$('settingsShowPreviousMessagingSessions');
if(showPreviousMessagingCb) payload.show_previous_messaging_sessions=showPreviousMessagingCb.checked;
const syncCb=$('settingsSyncInsights');
Expand Down Expand Up @@ -6332,6 +6337,13 @@ async function loadSettingsPanel(){
if(apiRedactCb){apiRedactCb.checked=settings.api_redact_enabled!==false;apiRedactCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showCliCb=$('settingsShowCliSessions');
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showCronCb=$('settingsShowCronSessions');
if(showCronCb){
showCronCb.checked=!!settings.show_cron_sessions;
showCronCb.disabled=showCliCb?!showCliCb.checked:true;
showCronCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});
if(showCliCb){showCliCb.addEventListener('change',function(){showCronCb.disabled=!showCliCb.checked;},{once:false});}
}
const showPreviousMessagingCb=$('settingsShowPreviousMessagingSessions');
if(showPreviousMessagingCb){showPreviousMessagingCb.checked=!!settings.show_previous_messaging_sessions;showPreviousMessagingCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const syncCb=$('settingsSyncInsights');
Expand Down Expand Up @@ -7704,6 +7716,7 @@ async function saveSettings(andClose){
const showTps=!!($('settingsShowTps')||{}).checked;
const fadeTextEffect=!!($('settingsFadeTextEffect')||{}).checked;
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const showCronSessions=!!($('settingsShowCronSessions')||{}).checked;
const showPreviousMessagingSessions=!!($('settingsShowPreviousMessagingSessions')||{}).checked;
const pinnedSessionsLimit=parseInt(($('settingsPinnedSessionsLimit')||{}).value,10)||3;
const pw=($('settingsPassword')||{}).value;
Expand All @@ -7730,6 +7743,9 @@ async function saveSettings(andClose){
body.terminal_auto_expand_on_output=!!($('settingsTerminalAutoExpand')||{}).checked;
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
body.show_cli_sessions=showCliSessions;
// Cron sessions are gated on CLI sessions (server short-circuits otherwise);
// mirror the autosave path so the explicit Save Settings button persists it too. (#3514)
body.show_cron_sessions=showCliSessions&&showCronSessions;
body.show_previous_messaging_sessions=showPreviousMessagingSessions;
body.pinned_sessions_limit=pinnedSessionsLimit;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
Expand Down
63 changes: 63 additions & 0 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ function _clearComposerDraft(sid) {
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';
const SESSION_MANUAL_STATUS_KEY = 'hermes-session-manual-status';
let _sessionViewedCounts = null;
let _sessionCompletionUnread = null;
let _sessionObservedStreaming = null;
Expand Down Expand Up @@ -353,6 +354,43 @@ function _isServerIdleSessionRow(s) {
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message);
}

// ── Manual session status (Todo / In Progress / Done) ─────────────────────
const _SESSION_STATUS_VALUES = ['todo', 'in-progress', 'done'];

function _getSessionManualStatuses() {
try {
const parsed = JSON.parse(localStorage.getItem(SESSION_MANUAL_STATUS_KEY) || '{}');
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_e) { return {}; }
}

function getSessionManualStatus(sid) {
if (!sid) return null;
const val = _getSessionManualStatuses()[sid];
return _SESSION_STATUS_VALUES.includes(val) ? val : null;
}

function setSessionManualStatus(sid, status) {
if (!sid) return;
const map = _getSessionManualStatuses();
if (status && _SESSION_STATUS_VALUES.includes(status)) {
map[sid] = status;
} else {
delete map[sid];
}
try { localStorage.setItem(SESSION_MANUAL_STATUS_KEY, JSON.stringify(map)); } catch (_e) {}
renderSessionListFromCache();
}
Comment on lines +373 to +383

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Manual-status entries are not pruned when a session is deleted

setSessionManualStatus writes to hermes-session-manual-status in localStorage, but deleteSession (line 5354) only calls _clearHandoffStorageForSession — it doesn't remove the entry for the deleted sid. Sessions that are deleted accumulate stale entries in this map indefinitely. Adding setSessionManualStatus(sid, null) inside deleteSession (mirroring how _clearSessionCompletionUnread / _clearSessionViewedCount are called elsewhere on session transitions) would keep the map bounded.


function _cycleSessionManualStatus(session) {
const current = getSessionManualStatus(session.session_id);
const idx = _SESSION_STATUS_VALUES.indexOf(current);
const next = idx === -1 ? _SESSION_STATUS_VALUES[0]
: idx === _SESSION_STATUS_VALUES.length - 1 ? null
: _SESSION_STATUS_VALUES[idx + 1];
setSessionManualStatus(session.session_id, next);
}

function _reconcileActiveSessionIdleStateFromList(serverRows) {
if (!S || !S.session || !S.session.session_id) return false;
if (typeof _sendInProgress !== 'undefined' && _sendInProgress) return false;
Expand Down Expand Up @@ -2616,6 +2654,22 @@ function _openSessionActionMenu(session, anchorEl){
}
));
}
// Manual status picker (before danger actions)
if (!isExternalSession) {
const currentStatus = getSessionManualStatus(session.session_id);
for (const status of _SESSION_STATUS_VALUES) {
menu.appendChild(_buildSessionAction(
t('session_status_' + status.replace(/-/g,'_')) || status,
'',
'',
() => {
closeSessionActionMenu();
setSessionManualStatus(session.session_id, currentStatus === status ? null : status);
},
currentStatus === status ? 'is-active' : ''
));
}
}
if(!isExternalSession){
if(session.worktree_path){
menu.appendChild(_buildSessionAction(
Expand Down Expand Up @@ -4589,6 +4643,15 @@ function renderSessionListFromCache(){
titleRow.appendChild(dot);
}
}
const manualStatus = getSessionManualStatus(s.session_id);
if (manualStatus) {
const statusBadge = document.createElement('span');
statusBadge.className = 'session-manual-status session-manual-status--' + manualStatus;
statusBadge.textContent = t('session_status_' + manualStatus.replace(/-/g,'_')) || manualStatus;
statusBadge.title = t('session_status_click_to_change') || 'Click to change status';
statusBadge.onclick = (e) => { e.stopPropagation(); _cycleSessionManualStatus(s); };
titleRow.appendChild(statusBadge);
}
const density=(window._sidebarDensity==='detailed'?'detailed':'compact');
const showLineageMetadata=density==='detailed';
const lineageKey=_sidebarLineageKeyForRow(s);
Expand Down
7 changes: 7 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3870,6 +3870,13 @@ main.main > #mainPlugin{display:none;}
the row's gap:6px handles spacing, no margin/vertical-align needed. */
.session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;}

/* Manual session status badge */
.session-manual-status{display:inline-flex;align-items:center;padding:1px 6px;border-radius:999px;font-size:9px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;flex-shrink:0;cursor:pointer;line-height:1.6;border:1px solid transparent;transition:opacity .12s;}
.session-manual-status:hover{opacity:.75;}
.session-manual-status--todo{background:rgba(99,179,237,.18);border-color:rgba(99,179,237,.35);color:#63b3ed;}
.session-manual-status--in-progress{background:rgba(246,173,85,.18);border-color:rgba(246,173,85,.35);color:#f6ad55;}
.session-manual-status--done{background:rgba(72,187,120,.18);border-color:rgba(72,187,120,.35);color:#48bb78;}
Comment on lines +3876 to +3878

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge colors hardcoded, not theme-variable-backed

The PR description says the badge "uses theme variables so it adapts to light/dark and skins," but the actual CSS rules use hardcoded hex/RGBA values (#63b3ed, rgba(99,179,237,.18), etc.) rather than var(--blue), var(--amber), or var(--green) custom properties. On light-background skins the fixed text colors (e.g. #63b3ed on near-white) may not meet contrast expectations, and custom palettes will ignore the intent entirely. The semi-transparent overlay partially mitigates this but doesn't substitute for the stated claim.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


/* ── Code copy button ── */
.code-copy-btn{background:var(--hover-bg);border:1px solid var(--border2);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
.code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);}
Expand Down
5 changes: 3 additions & 2 deletions tests/test_1466_sidebar_cancel_clarify.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self):
# to 5200 in #2111 for response-aware archive toast handling, then
# to 6400 in #2294 for the "Hide from list" action on external sessions,
# then to 7200 in #3223 for the "Regenerate title" action (gated on
# !session.is_imported) added between Stop-response and the worktree/delete block.
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 7200)
# !session.is_imported) added between Stop-response and the worktree/delete block,
# then to 8000 in #3199 for the manual status picker before the danger block.
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 8000)
assert "const isCliSession = _isCliSession(session);" in body
assert "const isExternalSession = isMessagingSession || isCliSession;" in body
assert "if(!isExternalSession)" in body
Expand Down
88 changes: 88 additions & 0 deletions tests/test_issue2841_show_cron_sessions_toggle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Tests for #2841: show_cron_sessions toggle to surface cron sessions in the sidebar."""
import pathlib

from api.models import _hide_from_default_sidebar

ROOT = pathlib.Path(__file__).parent.parent


def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")


# --- _hide_from_default_sidebar behaviour ---

def test_cron_hidden_by_default():
assert _hide_from_default_sidebar({'source_tag': 'cron', 'session_id': 'cron_abc'}) is True


def test_cron_visible_when_show_cron_true():
assert _hide_from_default_sidebar({'source_tag': 'cron', 'session_id': 'cron_abc'}, show_cron=True) is False


def test_pre_compression_always_hidden_regardless_of_show_cron():
assert _hide_from_default_sidebar({'pre_compression_snapshot': True}, show_cron=True) is True


def test_cron_hidden_with_explicit_false():
assert _hide_from_default_sidebar({'source_tag': 'cron', 'session_id': 'cron_abc'}, show_cron=False) is True


# --- api/config.py string-scan ---

def test_show_cron_sessions_in_defaults():
src = _read("api/config.py")
assert '"show_cron_sessions": False' in src, (
'"show_cron_sessions": False must appear in _SETTINGS_DEFAULTS'
)


def test_show_cron_sessions_in_bool_keys():
src = _read("api/config.py")
assert '"show_cron_sessions"' in src, (
'"show_cron_sessions" must appear in _SETTINGS_BOOL_KEYS'
)
# Verify it appears at least twice: once in _SETTINGS_DEFAULTS, once in _SETTINGS_BOOL_KEYS
assert src.count('"show_cron_sessions"') >= 2, (
'"show_cron_sessions" must appear in both _SETTINGS_DEFAULTS and _SETTINGS_BOOL_KEYS'
)


# --- api/routes.py string-scan ---

def test_show_cron_sessions_kwarg_passthrough():
src = _read("api/routes.py")
assert "show_cron_sessions=show_cron_sessions" in src, (
"show_cron_sessions kwarg must be forwarded at the _dedupe_cli_sidebar_sessions_for_api call site"
)


# --- static/index.html string-scan ---

def test_settings_show_cron_sessions_in_html():
src = _read("static/index.html")
assert "settingsShowCronSessions" in src, (
"settingsShowCronSessions checkbox must appear in static/index.html"
)


# --- static/panels.js string-scans ---

def test_panels_save_wiring():
src = _read("static/panels.js")
# Both save paths (autosave _preferencesPayloadFromUi + explicit saveSettings)
# must gate cron sessions on the CLI-sessions checkbox so neither can persist
# show_cron_sessions=true while show_cli_sessions=false (#3514).
assert "payload.show_cron_sessions=!!(showCliCb&&showCliCb.checked&&showCronCb.checked)" in src, (
"autosave wiring must gate show_cron_sessions on settingsShowCliSessions in static/panels.js"
)
assert "body.show_cron_sessions=showCliSessions&&showCronSessions" in src, (
"explicit saveSettings() must gate show_cron_sessions on showCliSessions in static/panels.js"
)


def test_panels_load_wiring():
src = _read("static/panels.js")
assert "show_cron_sessions" in src, (
"load wiring for show_cron_sessions must appear in static/panels.js"
)
Loading