mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Add cron detail expansion controls
This commit is contained in:
@@ -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 |
@@ -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
@@ -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)}`);
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user