feat(#4026): collapse paused cron jobs into a collapsible sidebar section

Paused/disabled cron jobs are partitioned out of the active list into a
collapsible 'Paused (N)' <details> section so they don't drown the active
jobs. _cronList stays the single source of truth — only the render is split,
so openCronDetail / _cronNewJobIds / detail-refresh are untouched. Reuses the
already-translated cron_status_paused label (zero new i18n keys). Collapsed
state persisted in localStorage.

Maintainer polish on absorb: wrapped both localStorage read/write in try/catch
(repo convention — a storage throw in private mode must not dump the cron
sidebar into the error banner) + added the CHANGELOG entry.

Co-authored-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-18 02:08:28 +00:00
parent f5efa1f89f
commit a195c9f589
4 changed files with 126 additions and 2 deletions

View file

@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.484] — 2026-06-18 — Release QT (collapsible paused cron section)
### Added
- **Paused cron jobs now collapse into their own section in the sidebar (#4026).** When you have paused/disabled scheduled jobs, they no longer clutter the active list — they're grouped under a collapsible "Paused (N)" section. The collapsed/expanded state is remembered (localStorage), and it reuses the existing translated "paused" label so it's localized in every language out of the box. Thanks @Sanjays2402.
## [v0.51.483] — 2026-06-18 — Release QS (virtual-scroll height + measurement scroll-jump fixes)
### Fixed

View file

@ -538,11 +538,19 @@ async function loadCrons(animate) {
return;
}
box.innerHTML = '';
// Partition active vs paused so paused jobs don't drown the list (#4026).
// _cronList stays the single source of truth — only the render is split,
// which keeps openCronDetail, _cronNewJobIds, and detail refresh untouched.
const _activeJobs = [];
const _pausedJobs = [];
for (const job of _cronList) {
const status = _cronStatusMeta(job);
(status.state === 'paused' ? _pausedJobs : _activeJobs).push({ job, status });
}
const _appendCronItem = (parent, { job, status }) => {
const item = document.createElement('div');
item.className = 'cron-item';
item.id = 'cron-' + job.id;
const status = _cronStatusMeta(job);
const isNewRun = _cronNewJobIds.has(String(job.id));
const isAgentMode = !job.no_agent;
const profileLabel = _cronProfileLabel(job.profile);
@ -557,7 +565,29 @@ async function loadCrons(animate) {
</div>`;
item.onclick = () => openCronDetail(job.id, item);
if (_currentCronDetail && _currentCronDetail.id === job.id) item.classList.add('active');
box.appendChild(item);
parent.appendChild(item);
};
for (const entry of _activeJobs) _appendCronItem(box, entry);
if (_pausedJobs.length) {
let collapsed = true;
try { collapsed = localStorage.getItem('cron-paused-collapsed') !== '0'; } catch (_e) {}
const details = document.createElement('details');
details.className = 'cron-paused-section';
if (!collapsed) details.open = true;
const pausedLabel = t('cron_status_paused') || 'paused';
const headerLabel = pausedLabel.charAt(0).toUpperCase() + pausedLabel.slice(1);
const summary = document.createElement('summary');
summary.className = 'cron-paused-summary';
summary.textContent = `${headerLabel} (${_pausedJobs.length})`;
details.appendChild(summary);
details.addEventListener('toggle', () => {
try { localStorage.setItem('cron-paused-collapsed', details.open ? '0' : '1'); } catch (_e) {}
});
const inner = document.createElement('div');
inner.className = 'cron-paused-inner';
details.appendChild(inner);
for (const entry of _pausedJobs) _appendCronItem(inner, entry);
box.appendChild(details);
}
// Re-render current detail with fresh data if we have one and we're not in a form
if (_currentCronDetail && _cronMode !== 'create' && _cronMode !== 'edit') {

View file

@ -1505,6 +1505,13 @@
.cron-gateway-notice{margin:8px 8px 0;}
.cron-item{width:100%;min-width:0;box-sizing:border-box;border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);cursor:pointer;}
.cron-item:hover{border-color:var(--border2);}
.cron-paused-section{margin:8px 0 0;border-top:1px solid var(--border);padding-top:6px;}
.cron-paused-summary{cursor:pointer;font-size:11px;font-weight:600;color:var(--muted);padding:6px 4px;letter-spacing:.02em;text-transform:uppercase;list-style:none;outline:none;user-select:none;}
.cron-paused-summary::-webkit-details-marker{display:none;}
.cron-paused-summary::before{content:"▸";display:inline-block;width:12px;color:var(--muted);transition:transform .12s ease;}
.cron-paused-section[open] > .cron-paused-summary::before{transform:rotate(90deg);}
.cron-paused-summary:hover{color:var(--text);}
.cron-paused-inner{margin-top:4px;}
.cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;}
.cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.cron-status{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;flex-shrink:0;}

View file

@ -0,0 +1,81 @@
"""Pin the cron-tasks paused-jobs collapsible section wiring (#4026).
Active vs paused cron jobs are now rendered into two buckets in `loadCrons`:
active items go straight into `#cronList`, and paused items go into a
collapsible `<details>` section at the bottom whose open/closed state is
persisted in `localStorage` under the `cron-paused-collapsed` key. Default
is collapsed so the active list isn't drowned by long-paused jobs.
This is a static-string check (same pattern as `test_issue4006_*` and
`test_issue3988_*`) — there's no headless browser harness in the repo, so
we verify the code paths and class names exist in panels.js + style.css.
"""
import pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def test_load_crons_partitions_active_and_paused():
src = _read("static/panels.js")
assert "_activeJobs" in src and "_pausedJobs" in src, (
"loadCrons must partition jobs into active vs paused buckets"
)
assert "status.state === 'paused' ? _pausedJobs : _activeJobs" in src, (
"partition predicate must read state from _cronStatusMeta (status.state)"
)
def test_paused_section_uses_details_element():
src = _read("static/panels.js")
# Native <details>/<summary> gives expand/collapse + a11y for free.
assert "createElement('details')" in src
assert "cron-paused-section" in src and "cron-paused-summary" in src
def test_paused_section_persists_collapse_state_in_localstorage():
src = _read("static/panels.js")
assert "localStorage.getItem('cron-paused-collapsed')" in src, (
"collapse state must be persisted across refreshes (#4026 AC#8)"
)
assert "localStorage.setItem('cron-paused-collapsed'" in src, (
"toggle handler must write the new collapse state"
)
def test_paused_section_defaults_collapsed():
"""AC#3: section must default to collapsed on first visit."""
src = _read("static/panels.js")
# The read uses `!== '0'` so an absent key (first visit) yields `collapsed=true`,
# and only an explicit '0' opens it. That's the default-collapsed contract.
assert "localStorage.getItem('cron-paused-collapsed') !== '0'" in src
# And the `<details>` element is only made open=true when collapsed===false.
assert "if (!collapsed) details.open = true;" in src
def test_paused_section_skipped_when_no_paused_jobs():
"""AC#9: zero-paused case must not render the section header."""
src = _read("static/panels.js")
assert "if (_pausedJobs.length) {" in src, (
"paused-section block must be guarded by _pausedJobs.length"
)
def test_cron_list_remains_single_source_of_truth():
"""openCronDetail / _cronNewJobIds / detail-refresh still read _cronList
(not the partitioned buckets) so click + new-run-dot + refresh keep working
unchanged when grouping is introduced."""
src = _read("static/panels.js")
# The detail-refresh lookup post-render must still scan the whole _cronList.
assert "_cronList.find(j => j.id === _currentCronDetail.id)" in src
def test_paused_section_styling_present():
css = _read("static/style.css")
assert ".cron-paused-section" in css
assert ".cron-paused-summary" in css
# Native marker hidden so the custom rotating triangle takes over.
assert ".cron-paused-summary::-webkit-details-marker{display:none;}" in css