diff --git a/CHANGELOG.md b/CHANGELOG.md index 328683ef..d68217fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/pr-media/2289/cron-output-collapsed.png b/docs/pr-media/2289/cron-output-collapsed.png new file mode 100644 index 00000000..a863cd3a Binary files /dev/null and b/docs/pr-media/2289/cron-output-collapsed.png differ diff --git a/docs/pr-media/2289/cron-output-expanded.png b/docs/pr-media/2289/cron-output-expanded.png new file mode 100644 index 00000000..ef9741d1 Binary files /dev/null and b/docs/pr-media/2289/cron-output-expanded.png differ diff --git a/static/i18n.js b/static/i18n.js index db0ca5b0..21dd9e8d 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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)', diff --git a/static/panels.js b/static/panels.js index 42123ba5..441c7ede 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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){ ` : ''; 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 = `
${attentionBanner} @@ -519,8 +560,11 @@ function _renderCronDetail(job){ ${lastError}
-
Prompt
-
${esc(job.prompt || '')}
+
+ Prompt + +
+
${esc(job.prompt || '')}
${esc(t('cron_last_output'))}
@@ -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 `
${esc(ts)} ${esc(sizeStr)} - + + + +
-
${esc(t('loading'))}
+
${esc(t('loading'))}
`; }).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 = `${esc(t('loading'))}`; try { const data = await api(`/api/crons/run?job_id=${encodeURIComponent(jobId)}&filename=${encodeURIComponent(filename)}`); diff --git a/static/style.css b/static/style.css index d4e93324..f158c1df 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/tests/test_issue2289_cron_detail_expansion.py b/tests/test_issue2289_cron_detail_expansion.py new file mode 100644 index 00000000..d3d83ee2 --- /dev/null +++ b/tests/test_issue2289_cron_detail_expansion.py @@ -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