feat(tasks): refresh button in cron panel + auto-refresh on job creation (closes #835)
* feat(tasks): refresh button in cron panel + hermes:cron_created event Add a ↺ refresh button to the Scheduled Jobs header so the job list can be reloaded without a full page refresh. Closes #835. - static/index.html: ↺ button with cronRefreshBtn id, calls loadCrons(true) - static/panels.js: loadCrons(animate) dims+disables the button while fetching, restores it in finally; hermes:cron_created window event auto-refreshes list when the agent creates a job from chat * test: add regression tests for cron refresh button + event listener The PR shipped without automated coverage (pure UI wiring). Filling that gap with 8 source-level tests: - Refresh button element exists with aria-label + title (icon-only a11y) - Button wires onclick to loadCrons(true) for the dim animation - Button sits in the same header row as "New job" - loadCrons() now accepts an animate parameter - loadCrons() restores the button's opacity/disabled in finally (so a throwing fetch doesn't leave the button stuck) - hermes:cron_created window listener is registered at module scope - Listener calls loadCrons() when dispatched Also rebased onto master (CHANGELOG conflict resolved — v0.50.143 → v0.50.142 since master's top is currently v0.50.141). Full suite: 1750 passed, 0 new failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.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>
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,15 +1,14 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
## [v0.50.143] — 2026-04-22
|
## [v0.50.144] — 2026-04-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Font size setting in Appearance** — users can now choose between Small (12px),
|
- **Refresh button in Tasks/Scheduled Jobs panel** — a ↺ button next to "+ New job"
|
||||||
Default (14px), and Large (16px) text size from the Appearance settings tab. The choice
|
reloads the job list without a full page reload. Dims while fetching and restores on
|
||||||
is stored in `localStorage` and applied via a `data-font-size` attribute on `<html>` at
|
completion (using `finally`). Also wires a `hermes:cron_created` window event so the
|
||||||
boot time (no FOUC). Follows the same three-button visual pattern as the Theme picker.
|
list auto-refreshes when a job is created from chat. Closes #835. (#837)
|
||||||
Localized for all 6 supported locales. Closes #833. (#834)
|
|
||||||
|
|
||||||
## [v0.50.142] — 2026-04-22
|
## [v0.50.143] — 2026-04-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Stale model no longer shows as "(unavailable)" in the model picker** — users with
|
- **Stale model no longer shows as "(unavailable)" in the model picker** — users with
|
||||||
@@ -24,6 +23,7 @@
|
|||||||
rendered provider heading in the picker due to missing `<optgroup>` context. Fixed to
|
rendered provider heading in the picker due to missing `<optgroup>` context. Fixed to
|
||||||
silently reset to the first available model instead. Closes #829. (#831)
|
silently reset to the first available model instead. Closes #829. (#831)
|
||||||
|
|
||||||
|
|
||||||
## [v0.50.141] — 2026-04-22
|
## [v0.50.141] — 2026-04-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -48,8 +48,11 @@
|
|||||||
<div class="panel-view" id="panelTasks">
|
<div class="panel-view" id="panelTasks">
|
||||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||||
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
|
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
|
||||||
|
<div style="display:flex;gap:4px;align-items:center">
|
||||||
|
<button class="cron-btn" id="cronRefreshBtn" style="padding:3px 7px;font-size:10px;line-height:1" onclick="loadCrons(true)" title="Refresh job list" aria-label="Refresh job list"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
|
||||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
|
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Create job form (hidden by default) -->
|
<!-- Create job form (hidden by default) -->
|
||||||
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||||
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ async function switchPanel(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Cron panel ──
|
// ── Cron panel ──
|
||||||
async function loadCrons() {
|
async function loadCrons(animate) {
|
||||||
const box = $('cronList');
|
const box = $('cronList');
|
||||||
|
const refreshBtn = $('cronRefreshBtn');
|
||||||
|
if (animate && refreshBtn) {
|
||||||
|
refreshBtn.style.opacity = '0.5';
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/crons');
|
const data = await api('/api/crons');
|
||||||
if (!data.jobs || !data.jobs.length) {
|
if (!data.jobs || !data.jobs.length) {
|
||||||
@@ -77,6 +82,12 @@ async function loadCrons() {
|
|||||||
loadCronOutput(job.id);
|
loadCronOutput(job.id);
|
||||||
}
|
}
|
||||||
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
|
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
|
||||||
|
finally {
|
||||||
|
if (animate && refreshBtn) {
|
||||||
|
refreshBtn.style.opacity = '';
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _cronSelectedSkills=[];
|
let _cronSelectedSkills=[];
|
||||||
@@ -1472,6 +1483,12 @@ let _cronPollSince=Date.now()/1000; // track from page load
|
|||||||
let _cronPollTimer=null;
|
let _cronPollTimer=null;
|
||||||
let _cronUnreadCount=0;
|
let _cronUnreadCount=0;
|
||||||
|
|
||||||
|
// Auto-refresh the cron list when a job is created from chat or any external source.
|
||||||
|
// The chat path dispatches this event when the agent response mentions cron creation.
|
||||||
|
window.addEventListener('hermes:cron_created', () => {
|
||||||
|
if ($('cronList')) loadCrons();
|
||||||
|
});
|
||||||
|
|
||||||
function startCronPolling(){
|
function startCronPolling(){
|
||||||
if(_cronPollTimer) return;
|
if(_cronPollTimer) return;
|
||||||
_cronPollTimer=setInterval(async()=>{
|
_cronPollTimer=setInterval(async()=>{
|
||||||
|
|||||||
110
tests/test_cron_refresh_button_835.py
Normal file
110
tests/test_cron_refresh_button_835.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Tests for #835 — refresh button in Tasks / Scheduled Jobs panel."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||||
|
|
||||||
|
|
||||||
|
def _read(name):
|
||||||
|
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCronRefreshButtonHtml:
|
||||||
|
"""index.html must expose a refresh button in the Tasks panel header."""
|
||||||
|
|
||||||
|
def test_refresh_button_present(self):
|
||||||
|
html = _read("static/index.html")
|
||||||
|
assert 'id="cronRefreshBtn"' in html, (
|
||||||
|
"Tasks panel must have a #cronRefreshBtn element"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refresh_button_has_accessibility_labels(self):
|
||||||
|
"""Icon-only buttons need aria-label + title so screen readers and
|
||||||
|
hover tooltips work."""
|
||||||
|
html = _read("static/index.html")
|
||||||
|
m = re.search(r'<button[^>]*id="cronRefreshBtn"[^>]*>', html)
|
||||||
|
assert m, "cronRefreshBtn tag not found"
|
||||||
|
tag = m.group(0)
|
||||||
|
assert 'aria-label=' in tag, (
|
||||||
|
"#cronRefreshBtn is icon-only and must have aria-label"
|
||||||
|
)
|
||||||
|
assert 'title=' in tag, (
|
||||||
|
"#cronRefreshBtn should have a title tooltip"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refresh_button_calls_load_crons_with_animate(self):
|
||||||
|
html = _read("static/index.html")
|
||||||
|
m = re.search(r'<button[^>]*id="cronRefreshBtn"[^>]*>', html)
|
||||||
|
assert m
|
||||||
|
tag = m.group(0)
|
||||||
|
assert 'loadCrons(true)' in tag, (
|
||||||
|
"#cronRefreshBtn must call loadCrons(true) to enable the dim-while-fetching animation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refresh_button_sits_next_to_new_job_button(self):
|
||||||
|
"""Refresh button should appear in the same header row as the New Job
|
||||||
|
button so the header layout stays tight."""
|
||||||
|
html = _read("static/index.html")
|
||||||
|
ref_pos = html.find('id="cronRefreshBtn"')
|
||||||
|
newjob_pos = html.find('toggleCronForm()')
|
||||||
|
assert ref_pos != -1 and newjob_pos != -1
|
||||||
|
# Must be close enough to be in the same header row (single SVG-inline
|
||||||
|
# button can be around 500 chars by itself due to inline styles/attrs).
|
||||||
|
assert abs(ref_pos - newjob_pos) < 1000, (
|
||||||
|
"Refresh button and New Job button should be in the same header row"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadCronsAnimateFlag:
|
||||||
|
"""panels.js loadCrons() must accept an optional animate flag that dims
|
||||||
|
the refresh button while fetching."""
|
||||||
|
|
||||||
|
def test_load_crons_accepts_animate_param(self):
|
||||||
|
js = _read("static/panels.js")
|
||||||
|
assert re.search(r'async function loadCrons\s*\(\s*animate\s*\)', js), (
|
||||||
|
"loadCrons must accept an `animate` parameter"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_load_crons_restores_button_in_finally(self):
|
||||||
|
"""The opacity/disabled restore MUST be in a finally block so a
|
||||||
|
throwing fetch doesn't leave the button stuck at 0.5 / disabled."""
|
||||||
|
js = _read("static/panels.js")
|
||||||
|
m = re.search(r'async function loadCrons\(.*?\n\}', js, re.DOTALL)
|
||||||
|
assert m, "loadCrons body not found"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert 'finally' in fn, (
|
||||||
|
"loadCrons must restore the refresh button's opacity/disabled state "
|
||||||
|
"in a finally block so errors during fetch don't leave the button stuck"
|
||||||
|
)
|
||||||
|
# The restore block sets opacity='' (not '1') so CSS cascade wins
|
||||||
|
assert "opacity = ''" in fn or "opacity=''" in fn, (
|
||||||
|
"restore must use opacity='' to clear the inline override"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCronCreatedEventListener:
|
||||||
|
"""A global `hermes:cron_created` listener must be registered so
|
||||||
|
future chat paths can trigger the cron list refresh."""
|
||||||
|
|
||||||
|
def test_listener_registered_at_module_scope(self):
|
||||||
|
js = _read("static/panels.js")
|
||||||
|
assert re.search(
|
||||||
|
r"addEventListener\(\s*['\"]hermes:cron_created['\"]",
|
||||||
|
js,
|
||||||
|
), (
|
||||||
|
"panels.js must register a window-level 'hermes:cron_created' event listener"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_listener_triggers_load_crons(self):
|
||||||
|
js = _read("static/panels.js")
|
||||||
|
m = re.search(
|
||||||
|
r"addEventListener\(\s*['\"]hermes:cron_created['\"].*?\}\s*\)",
|
||||||
|
js,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert m, "hermes:cron_created listener body not found"
|
||||||
|
body = m.group(0)
|
||||||
|
assert 'loadCrons' in body, (
|
||||||
|
"hermes:cron_created listener must call loadCrons() to refresh the list"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user