Add cron detail expansion controls

This commit is contained in:
Frank Song
2026-05-15 18:04:53 +08:00
parent 5e518b1c10
commit 079d6b4e86
7 changed files with 152 additions and 4 deletions
+3
View File
@@ -4,6 +4,9 @@
### Added
- Cron detail prompt and output panels now have expand/collapse controls that
remove the inner scroll cap and remember the expanded state per job/run.
- **PR #2099** by @dobby-d-elf — Adds an opt-in `Settings → Preferences → Fade text effect` toggle (off by default). When enabled, newly streamed output tokens are revealed through an adaptive playout buffer and animated with an opacity-only fade similar to ChatGPT and other frontier LLM apps. Implementation details: fade locked per stream to avoid mid-stream toggle rewind; reduced-motion users get non-animated text; live cursor hidden while fade is active; custom renderer on `streaming-markdown` parser wraps only newly-appended words; animated spans replace themselves with plain text on `animationend` (no long-lived wrapper buildup in long responses); unsafe streamed `href`/`src` values blocked in fade renderer `set_attr` path. Performance tuning: 200ms base fade duration scaling to 350ms for fast output, 16ms word stagger, 320ms done-drain wait cap, 160 wps visual cap, max 2-3 words/frame, brief pauses after sentence punctuation. Default-off means existing users see no change. 293-line regression test pinning the contract.
- **PR #2165** by @starship-s — Pooled OpenAI Codex quota status surfaced in the Providers panel. Pre-fix, the Providers page presented Codex quota as if there were only one credential/account state, which was misleading when users authenticate through a credential pool with several usable credentials, temporarily exhausted credentials, failed probes, and different reset windows. Now the active provider quota card includes a credential-pool summary (available / exhausted / failed / checked counts), displays the best currently-available pool windows in the collapsed view as "Best of N", and exposes per-credential detail behind an expandable section. Exhausted credentials are intentionally NOT re-probed while their cooldown is active (matches credential-pool selection behavior, avoids generating failed quota calls from a status page). Manual refresh still means "probe now" but transient refresh failures preserve the last known-good snapshot. JWT decode (`_decode_jwt_claims_unverified`) is used only for token-shape classification (Codex OAuth JWT vs raw OpenAI API key), explicitly NOT for authorization — documented in the function docstring. Per-row plan labels only shown when verified account-limit data is available. Concurrent probing capped at `min(_CODEX_POOL_MAX_WORKERS=6, len(probe_items))` so page render time stays bounded on large pools. Transient `None` probe results are NOT cached (only known unavailable/exhausted states are cached); 32-test regression suite covering pool snapshot, concurrent probe, JWT detection, cache invalidation, transient-vs-known cache distinction, and i18n parity across all currently-supported locales. Scoped to OpenAI Codex (the only provider with the credential-pool/account-limit path needed to surface this accurately).
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+44
View File
@@ -980,6 +980,10 @@ const LOCALES = {
cron_schedule_placeholder: 'Schedule',
cron_prompt_placeholder: 'Prompt',
cron_last_output: 'Last output',
cron_expand_prompt: 'Expand prompt',
cron_collapse_prompt: 'Collapse prompt',
cron_expand_output: 'Expand output',
cron_collapse_output: 'Collapse output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
cron_no_runs_yet: '(no runs yet)',
@@ -2153,6 +2157,10 @@ const LOCALES = {
cron_schedule_placeholder: 'Pianificazione',
cron_prompt_placeholder: 'Prompt',
cron_last_output: 'Ultimo output',
cron_expand_prompt: 'Espandi prompt',
cron_collapse_prompt: 'Comprimi prompt',
cron_expand_output: 'Espandi output',
cron_collapse_output: 'Comprimi output',
cron_all_runs: 'Tutte le esecuzioni',
cron_hide_runs: 'Nascondi esecuzioni',
cron_no_runs_yet: '(nessuna esecuzione)',
@@ -3333,6 +3341,10 @@ const LOCALES = {
cron_schedule_placeholder: 'スケジュール',
cron_prompt_placeholder: 'プロンプト',
cron_last_output: '前回の出力',
cron_expand_prompt: 'プロンプトを展開',
cron_collapse_prompt: 'プロンプトを折りたたむ',
cron_expand_output: '出力を展開',
cron_collapse_output: '出力を折りたたむ',
cron_all_runs: 'すべての実行',
cron_hide_runs: '実行履歴を隠す',
cron_no_runs_yet: '(まだ実行されていません)',
@@ -4278,6 +4290,10 @@ const LOCALES = {
cron_schedule_placeholder: 'Расписание',
cron_prompt_placeholder: 'Промпт',
cron_last_output: 'Последний вывод',
cron_expand_prompt: 'Развернуть промпт',
cron_collapse_prompt: 'Свернуть промпт',
cron_expand_output: 'Развернуть вывод',
cron_collapse_output: 'Свернуть вывод',
cron_all_runs: 'Все запуски',
cron_hide_runs: 'Скрыть запуски',
cron_no_runs_yet: '(пока запусков нет)',
@@ -5397,6 +5413,10 @@ const LOCALES = {
cron_schedule_placeholder: 'Schedule',
cron_prompt_placeholder: 'Prompt',
cron_last_output: 'Last output',
cron_expand_prompt: 'Expand prompt',
cron_collapse_prompt: 'Collapse prompt',
cron_expand_output: 'Expand output',
cron_collapse_output: 'Collapse output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
cron_no_runs_yet: '(no runs yet)',
@@ -6684,6 +6704,10 @@ const LOCALES = {
cron_schedule_placeholder: '0 9 * * *',
cron_prompt_placeholder: 'Aufgabenbeschreibung',
cron_last_output: 'Letzte Ausgabe',
cron_expand_prompt: 'Prompt erweitern',
cron_collapse_prompt: 'Prompt einklappen',
cron_expand_output: 'Ausgabe erweitern',
cron_collapse_output: 'Ausgabe einklappen',
cron_all_runs: 'Alle Ausführungen',
cron_hide_runs: 'Ausführungen ausblenden',
cron_no_runs_yet: 'Noch keine Ausführungen.',
@@ -7623,6 +7647,10 @@ const LOCALES = {
cron_schedule_placeholder: '调度表达式',
cron_prompt_placeholder: '提示词',
cron_last_output: '最近输出',
cron_expand_prompt: '展开提示词',
cron_collapse_prompt: '收起提示词',
cron_expand_output: '展开输出',
cron_collapse_output: '收起输出',
cron_all_runs: '全部运行记录',
cron_hide_runs: '隐藏记录',
cron_no_runs_yet: '(暂无运行记录)',
@@ -8896,6 +8924,10 @@ const LOCALES = {
cron_job_updated: '\u6392\u7a0b\u4efb\u52d9\u5df2\u66f4\u65b0',
cron_last: '\u4e0a\u6b21',
cron_last_output: '\u6700\u5f8c\u8f38\u51fa',
cron_expand_prompt: '展開提示詞',
cron_collapse_prompt: '收起提示詞',
cron_expand_output: '展開輸出',
cron_collapse_output: '收起輸出',
cron_next: '\u4e0b\u6b21',
cron_no_jobs: '\u627e\u4e0d\u5230\u6392\u7a0b\u4efb\u52d9\u3002',
cron_no_runs_yet: '\uff08\u5c1a\u7121\u57f7\u884c\u8a18\u9304\uff09',
@@ -10008,6 +10040,10 @@ const LOCALES = {
cron_schedule_placeholder: 'Agendamento',
cron_prompt_placeholder: 'Prompt',
cron_last_output: 'Último output',
cron_expand_prompt: 'Expandir prompt',
cron_collapse_prompt: 'Contraer prompt',
cron_expand_output: 'Expandir saída',
cron_collapse_output: 'Contraer saída',
cron_all_runs: 'Todas execuções',
cron_hide_runs: 'Esconder execuções',
cron_no_runs_yet: '(sem execuções ainda)',
@@ -11085,6 +11121,10 @@ const LOCALES = {
cron_schedule_placeholder: 'Schedule',
cron_prompt_placeholder: 'Prompt',
cron_last_output: 'Last output',
cron_expand_prompt: 'Expand prompt',
cron_collapse_prompt: 'Collapse prompt',
cron_expand_output: 'Expand output',
cron_collapse_output: 'Collapse output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
cron_no_runs_yet: '(no runs yet)',
@@ -12183,6 +12223,10 @@ const LOCALES = {
cron_schedule_placeholder: 'Calendrier',
cron_prompt_placeholder: 'Rapide',
cron_last_output: 'Dernière sortie',
cron_expand_prompt: 'Développer le prompt',
cron_collapse_prompt: 'Réduire le prompt',
cron_expand_output: 'Développer la sortie',
cron_collapse_output: 'Réduire la sortie',
cron_all_runs: 'Toutes les courses',
cron_hide_runs: 'Masquer les courses',
cron_no_runs_yet: '(pas encore de courses)',
+54 -4
View File
@@ -461,6 +461,45 @@ async function loadCrons(animate) {
}
}
function _cronPanelExpandKey(jobId, suffix){
return `hermes-webui-cron-${suffix}-expanded-${encodeURIComponent(String(jobId||''))}`;
}
function _cronRunExpandKey(jobId, filename){
return `${_cronPanelExpandKey(jobId, 'run')}-${encodeURIComponent(String(filename||''))}`;
}
function _cronExpansionGet(key){
try { return localStorage.getItem(key) === '1'; } catch(_) { return false; }
}
function _cronExpansionSet(key, expanded){
try { localStorage.setItem(key, expanded ? '1' : '0'); } catch(_) {}
}
function toggleCronPromptExpanded(jobId){
const key = _cronPanelExpandKey(jobId, 'prompt');
_cronExpansionSet(key, !_cronExpansionGet(key));
if (_currentCronDetail && String(_currentCronDetail.id) === String(jobId)) {
_renderCronDetail(_currentCronDetail);
}
}
function toggleCronRunExpanded(jobId, filename, runId){
const key = _cronRunExpandKey(jobId, filename);
const expanded = !_cronExpansionGet(key);
_cronExpansionSet(key, expanded);
const item = document.getElementById(runId);
const body = item ? item.querySelector('.detail-run-body') : null;
const btn = item ? item.querySelector('.detail-expand-toggle') : null;
if (body) body.classList.toggle('expanded', expanded);
if (btn) {
btn.textContent = expanded ? '▴' : '▾';
btn.title = expanded ? (t('cron_collapse_output') || 'Collapse output') : (t('cron_expand_output') || 'Expand output');
btn.setAttribute('aria-label', btn.title);
}
}
function _renderCronDetail(job){
_currentCronDetail = job;
const title = $('taskDetailTitle');
@@ -501,6 +540,8 @@ function _renderCronDetail(job){
</div>
</div>` : '';
const toastNotifications = job.toast_notifications !== false;
const promptExpanded = _cronExpansionGet(_cronPanelExpandKey(job.id, 'prompt'));
const promptToggleLabel = promptExpanded ? (t('cron_collapse_prompt') || 'Collapse prompt') : (t('cron_expand_prompt') || 'Expand prompt');
body.innerHTML = `
<div class="main-view-content">
${attentionBanner}
@@ -519,8 +560,11 @@ function _renderCronDetail(job){
${lastError}
</div>
<div class="detail-card">
<div class="detail-card-title">Prompt</div>
<div class="detail-prompt">${esc(job.prompt || '')}</div>
<div class="detail-card-title detail-card-title-row">
<span>Prompt</span>
<button type="button" class="detail-expand-toggle" onclick="toggleCronPromptExpanded('${esc(job.id)}')" title="${esc(promptToggleLabel)}" aria-label="${esc(promptToggleLabel)}">${esc(promptExpanded ? '▴' : '▾')}</button>
</div>
<div class="detail-prompt ${promptExpanded ? 'expanded' : ''}">${esc(job.prompt || '')}</div>
</div>
<div class="detail-card ${_cronNewJobIds.has(String(job.id)) ? 'has-new-run' : ''}" id="cronDetailRuns">
<div class="detail-card-title">${esc(t('cron_last_output'))}</div>
@@ -579,12 +623,17 @@ async function _loadCronDetailRuns(jobId){
const sizeStr = run.size > 1024 ? (run.size/1024).toFixed(1)+' KB' : run.size+' B';
const dateStr = new Date(run.modified * 1000).toLocaleString();
const rid = `cron-det-run-${jobId}-${i}`;
const runExpanded = _cronExpansionGet(_cronRunExpandKey(jobId, run.filename));
const runToggleLabel = runExpanded ? (t('cron_collapse_output') || 'Collapse output') : (t('cron_expand_output') || 'Expand output');
return `<div class="detail-run-item" id="${rid}">
<div class="detail-run-head" onclick="_loadRunContent('${esc(jobId)}','${esc(run.filename)}','${rid}')">
<span><span style="opacity:.7">${esc(ts)}</span> <span style="opacity:.4;font-size:11px">${esc(sizeStr)}</span></span>
<span style="opacity:.6"></span>
<span class="detail-run-actions">
<button type="button" class="detail-expand-toggle" onclick="event.stopPropagation();toggleCronRunExpanded('${esc(jobId)}','${esc(run.filename)}','${rid}')" title="${esc(runToggleLabel)}" aria-label="${esc(runToggleLabel)}">${esc(runExpanded ? '▴' : '▾')}</button>
<span style="opacity:.6"></span>
</span>
</div>
<div class="detail-run-body" style="color:var(--muted);font-size:12px">${esc(t('loading'))}</div>
<div class="detail-run-body ${runExpanded ? 'expanded' : ''}" style="color:var(--muted);font-size:12px">${esc(t('loading'))}</div>
</div>`;
}).join('');
const countLabel = data.total > 50 ? ` (${data.total} runs, showing latest 50)` : ` (${data.total} runs)`;
@@ -599,6 +648,7 @@ async function _loadRunContent(jobId, filename, runId){
if (!item.classList.contains('open')) {
item.classList.add('open');
}
body.classList.toggle('expanded', _cronExpansionGet(_cronRunExpandKey(jobId, filename)));
body.innerHTML = `<span style="opacity:.5">${esc(t('loading'))}</span>`;
try {
const data = await api(`/api/crons/run?job_id=${encodeURIComponent(jobId)}&filename=${encodeURIComponent(filename)}`);
+6
View File
@@ -3341,12 +3341,18 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.cron-watch-spinner{width:14px;height:14px;border:2px solid rgba(59,130,246,.3);border-top-color:rgba(59,130,246,.9);border-radius:50%;animation:cron-spin .8s linear infinite;flex-shrink:0;}
@keyframes cron-spin{to{transform:rotate(360deg)}}
.cron-watch-elapsed{color:var(--muted);font-variant-numeric:tabular-nums;margin-left:auto;}
.detail-card-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px;}
.detail-expand-toggle{border:1px solid var(--border);background:var(--surface);color:var(--muted);border-radius:6px;padding:3px 8px;font-size:11px;line-height:1.3;cursor:pointer;}
.detail-expand-toggle:hover{color:var(--text);border-color:var(--accent);}
.detail-prompt{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:'SF Mono',ui-monospace,monospace;max-height:240px;overflow-y:auto;}
.detail-prompt.expanded{max-height:none;overflow-y:visible;}
.detail-run-item{border-top:1px solid var(--border);padding:8px 0;}
.detail-run-item:first-child{border-top:none;}
.detail-run-head{display:flex;align-items:center;justify-content:space-between;cursor:pointer;font-size:12px;color:var(--muted);}
.detail-run-actions{display:flex;align-items:center;gap:8px;flex-shrink:0;}
.detail-run-body{display:none;margin-top:6px;font-size:12px;color:var(--muted);white-space:pre-wrap;line-height:1.5;max-height:260px;overflow-y:auto;background:var(--sidebar);border:1px solid var(--border);border-radius:6px;padding:8px 10px;}
.detail-run-item.open .detail-run-body{display:block;}
.detail-run-body.expanded{max-height:none;overflow-y:visible;}
.cron-item.active,.ws-row.active,.profile-card.active{background:var(--accent-bg);}
.cron-item.active .cron-name,.ws-row.active .ws-row-name,.profile-card.active .profile-card-name{color:var(--accent-text);}
@@ -0,0 +1,45 @@
"""Static coverage for issue #2289 cron detail expansion controls."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
def test_cron_prompt_and_run_expansion_helpers_are_persisted():
assert "function _cronPanelExpandKey(jobId, suffix)" in PANELS_JS
assert "function _cronRunExpandKey(jobId, filename)" in PANELS_JS
assert "localStorage.setItem(key, expanded ? '1' : '0')" in PANELS_JS
assert "toggleCronPromptExpanded" in PANELS_JS
assert "toggleCronRunExpanded" in PANELS_JS
def test_cron_detail_renders_prompt_and_run_expand_buttons():
assert "cron_expand_prompt" in PANELS_JS
assert "cron_collapse_prompt" in PANELS_JS
assert "class=\"detail-card-title detail-card-title-row\"" in PANELS_JS
assert "class=\"detail-prompt ${promptExpanded ? 'expanded' : ''}\"" in PANELS_JS
assert "class=\"detail-run-body ${runExpanded ? 'expanded' : ''}\"" in PANELS_JS
assert "event.stopPropagation();toggleCronRunExpanded" in PANELS_JS
def test_cron_detail_expanded_state_removes_nested_scroll_caps():
assert ".detail-prompt.expanded{max-height:none;overflow-y:visible;}" in STYLE_CSS
assert ".detail-run-body.expanded{max-height:none;overflow-y:visible;}" in STYLE_CSS
assert ".detail-expand-toggle" in STYLE_CSS
def test_cron_expansion_i18n_keys_exist_in_every_locale():
locale_count = I18N_JS.count("cron_last_output:")
assert locale_count >= 9
for key in (
"cron_expand_prompt",
"cron_collapse_prompt",
"cron_expand_output",
"cron_collapse_output",
):
assert I18N_JS.count(f"{key}:") >= locale_count