mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-28 04:30:18 +00:00
v0.50.225: cron attention, image lightbox, pytest isolation (#1137)
* feat: attention state for broken cron jobs + Korean i18n (#1133, @franksong2702) * fix: pytest state isolation for direct session saves (#1136, @franksong2702) * fix(#1095): image thumbnails in composer + lightbox in chat (#1135) * fix(css): restore cron attention + detail-alert rules overwritten by style.css merge (absorb) * docs: v0.50.225 release notes and version bump --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Recurring cron jobs with no next run need attention** — the Tasks panel now
|
||||
distinguishes anomalous recurring jobs (`enabled=false`, `state=completed`,
|
||||
`next_run_at=null`) from ordinary off jobs, shows a warning with recovery
|
||||
actions, and lets users copy diagnostics for scheduler/runtime failures.
|
||||
(`static/panels.js`, `static/style.css`, `static/i18n.js`,
|
||||
`tests/test_cron_needs_attention.py`)
|
||||
- **Legacy `@provider:model` session models** — persisted sessions with an
|
||||
old explicit provider hint (for example `@copilot:gpt-5.5`) now pass through
|
||||
the same stale-model compatibility recovery as slash-prefixed session models,
|
||||
@@ -14,6 +20,17 @@
|
||||
provider without a manual container-side install. (`docker_init.bash`,
|
||||
`tests/test_issue926_hindsight_docker_dependency.py`) Closes #926.
|
||||
|
||||
|
||||
## v0.50.225 — 2026-04-27
|
||||
|
||||
### Added
|
||||
- **Cron job attention state** — recurring jobs that land in a broken state (`enabled=false`, `state=completed`, `next_run_at=null`) now show an amber "needs attention" badge instead of the misleading "off" badge. Detail panel shows a warning banner with Resume & recalculate, Run once, and Copy diagnostics actions. Korean locale translated. (`static/panels.js`, `static/style.css`, `static/i18n.js`) [#1133 @franksong2702]
|
||||
|
||||
### Fixed
|
||||
- **Image attachments: composer tray thumbnails** — pasted/dragged images now show as 56×56 thumbnail chips in the composer instead of paperclip pills. Blob URL revoked on remove. (`static/ui.js`, `static/style.css`) [#1135]
|
||||
- **Image attachments: chat history inline** — uploaded images in sent messages now load correctly via `api/file/raw?session_id=SID&path=FILENAME` instead of the broken `api/media?path=FILENAME` path. Click any image to open a lightbox overlay (dark backdrop, 90vw/90vh, × or Escape to close). (`static/ui.js`, `static/style.css`) [#1135] Closes #1095
|
||||
- **pytest state isolation** — `conftest.py` now uses direct assignment for `HERMES_WEBUI_STATE_DIR` / `HERMES_HOME` / `HERMES_WEBUI_DEFAULT_WORKSPACE` so tests importing `api.config` in the pytest process cannot inherit the real `~/.hermes/webui` state tree. (`tests/conftest.py`) [#1136 @franksong2702]
|
||||
|
||||
## v0.50.223 — 2026-04-26
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.50.221 (April 26, 2026) — 2511 tests collected
|
||||
> Last updated: v0.50.225 (April 26, 2026) — 2591 tests collected
|
||||
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
|
||||
> Source: <repo>/
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated coverage: 2511 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Automated coverage: 2591 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
>
|
||||
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
|
||||
|
||||
@@ -504,6 +504,13 @@ const LOCALES = {
|
||||
cron_status_error: 'error',
|
||||
cron_status_active: 'active',
|
||||
cron_status_running: 'running\u2026',
|
||||
cron_status_needs_attention: 'needs attention',
|
||||
cron_attention_desc: 'This recurring job has no next run time. The scheduler may have failed to compute its next run.',
|
||||
cron_attention_croniter_hint: 'The Gateway runtime may be missing the croniter package. Restart the Gateway with cron support, then resume this job.',
|
||||
cron_attention_resume: 'Resume and recalculate',
|
||||
cron_attention_run_once: 'Run once now',
|
||||
cron_attention_copy_diagnostics: 'Copy diagnostics',
|
||||
cron_diagnostics_copied: 'Cron diagnostics copied',
|
||||
cron_next: 'Next',
|
||||
cron_last: 'Last',
|
||||
cron_run_now: 'Run now',
|
||||
@@ -1028,6 +1035,13 @@ const LOCALES = {
|
||||
cron_status_error: 'ошибка',
|
||||
cron_status_active: 'активно',
|
||||
cron_status_running: 'выполняется\u2026',
|
||||
cron_status_needs_attention: 'требует внимания',
|
||||
cron_attention_desc: 'У этого повторяющегося задания нет времени следующего запуска. Планировщик мог не суметь вычислить следующий запуск.',
|
||||
cron_attention_croniter_hint: 'В окружении Gateway может отсутствовать пакет croniter. Перезапустите Gateway с поддержкой cron, затем возобновите это задание.',
|
||||
cron_attention_resume: 'Возобновить и пересчитать',
|
||||
cron_attention_run_once: 'Запустить один раз сейчас',
|
||||
cron_attention_copy_diagnostics: 'Скопировать диагностику',
|
||||
cron_diagnostics_copied: 'Диагностика cron скопирована',
|
||||
cron_next: 'Следующий',
|
||||
cron_last: 'Последний',
|
||||
cron_run_now: 'Запустить сейчас',
|
||||
@@ -1631,6 +1645,13 @@ const LOCALES = {
|
||||
cron_status_error: 'error',
|
||||
cron_status_active: 'active',
|
||||
cron_status_running: 'running\u2026',
|
||||
cron_status_needs_attention: 'needs attention',
|
||||
cron_attention_desc: 'This recurring job has no next run time. The scheduler may have failed to compute its next run.',
|
||||
cron_attention_croniter_hint: 'The Gateway runtime may be missing the croniter package. Restart the Gateway with cron support, then resume this job.',
|
||||
cron_attention_resume: 'Resume and recalculate',
|
||||
cron_attention_run_once: 'Run once now',
|
||||
cron_attention_copy_diagnostics: 'Copy diagnostics',
|
||||
cron_diagnostics_copied: 'Cron diagnostics copied',
|
||||
cron_next: 'Next',
|
||||
cron_last: 'Last',
|
||||
cron_run_now: 'Run now',
|
||||
@@ -2582,6 +2603,13 @@ const LOCALES = {
|
||||
cron_status_error: '错误',
|
||||
cron_status_active: '运行中',
|
||||
cron_status_running: '执行中\u2026',
|
||||
cron_status_needs_attention: '需要处理',
|
||||
cron_attention_desc: '这个重复定时任务没有下次运行时间。调度器可能没能计算出下一次运行。',
|
||||
cron_attention_croniter_hint: 'Gateway 运行环境可能缺少 croniter 包。请用支持 cron 的环境重启 Gateway,然后恢复这个任务。',
|
||||
cron_attention_resume: '恢复并重新计算',
|
||||
cron_attention_run_once: '立即运行一次',
|
||||
cron_attention_copy_diagnostics: '复制诊断信息',
|
||||
cron_diagnostics_copied: '定时任务诊断信息已复制',
|
||||
cron_next: '下次',
|
||||
cron_last: '上次',
|
||||
cron_run_now: '立即运行',
|
||||
@@ -3368,6 +3396,13 @@ const LOCALES = {
|
||||
cron_status_error: '\u932f\u8aa4',
|
||||
cron_status_off: '\u672a\u555f\u7528',
|
||||
cron_status_paused: '\u5df2\u66ab\u505c',
|
||||
cron_status_needs_attention: '\u9700\u8981\u8655\u7406',
|
||||
cron_attention_desc: '\u9019\u500b\u91cd\u8907\u6392\u7a0b\u4efb\u52d9\u6c92\u6709\u4e0b\u6b21\u57f7\u884c\u6642\u9593\u3002\u6392\u7a0b\u5668\u53ef\u80fd\u7121\u6cd5\u8a08\u7b97\u4e0b\u4e00\u6b21\u57f7\u884c\u3002',
|
||||
cron_attention_croniter_hint: 'Gateway \u57f7\u884c\u74b0\u5883\u53ef\u80fd\u7f3a\u5c11 croniter \u5957\u4ef6\u3002\u8acb\u7528\u652f\u63f4 cron \u7684\u74b0\u5883\u91cd\u555f Gateway\uff0c\u7136\u5f8c\u6062\u5fa9\u9019\u500b\u4efb\u52d9\u3002',
|
||||
cron_attention_resume: '\u6062\u5fa9\u4e26\u91cd\u65b0\u8a08\u7b97',
|
||||
cron_attention_run_once: '\u7acb\u5373\u57f7\u884c\u4e00\u6b21',
|
||||
cron_attention_copy_diagnostics: '\u8907\u88fd\u8a3a\u65b7\u8cc7\u8a0a',
|
||||
cron_diagnostics_copied: '\u6392\u7a0b\u4efb\u52d9\u8a3a\u65b7\u8cc7\u8a0a\u5df2\u8907\u88fd',
|
||||
providers_empty: '\u627e\u4e0d\u5230\u53ef\u8a2d\u5b9a\u7684\u63d0\u4f9b\u8005\u3002',
|
||||
providers_enter_key: '\u8acb\u8f38\u5165 API \u91d1\u9470',
|
||||
providers_key_placeholder_new: 'sk-...',
|
||||
@@ -3903,6 +3938,13 @@ const LOCALES = {
|
||||
cron_status_error: 'error',
|
||||
cron_status_active: 'active',
|
||||
cron_status_running: 'running\u2026',
|
||||
cron_status_needs_attention: '주의 필요',
|
||||
cron_attention_desc: '이 반복 작업에 다음 실행 시간이 없습니다. 스케줄러가 다음 실행을 계산하지 못했을 수 있습니다.',
|
||||
cron_attention_croniter_hint: 'Gateway 런타임에 croniter 패키지가 없을 수 있습니다. cron 지원이 포함된 환경으로 Gateway를 재시작한 후 이 작업을 재개하세요.',
|
||||
cron_attention_resume: '재개 및 재계산',
|
||||
cron_attention_run_once: '지금 한 번 실행',
|
||||
cron_attention_copy_diagnostics: '진단 정보 복사',
|
||||
cron_diagnostics_copied: '크론 진단 정보가 복사되었습니다',
|
||||
cron_next: 'Next',
|
||||
cron_last: 'Last',
|
||||
cron_run_now: 'Run now',
|
||||
|
||||
+114
-7
@@ -177,6 +177,87 @@ async function switchPanel(name, opts = {}) {
|
||||
}
|
||||
|
||||
// ── Cron panel ──
|
||||
function _isRecurringCronJob(job) {
|
||||
const kind = job && job.schedule && job.schedule.kind;
|
||||
return kind === 'cron' || kind === 'interval';
|
||||
}
|
||||
|
||||
function _hasUnlimitedRepeat(job) {
|
||||
return !!(job && job.repeat && job.repeat.times == null);
|
||||
}
|
||||
|
||||
function _isCronNeedsAttention(job) {
|
||||
return _isRecurringCronJob(job) &&
|
||||
_hasUnlimitedRepeat(job) &&
|
||||
job.enabled === false &&
|
||||
job.state === 'completed' &&
|
||||
!job.next_run_at;
|
||||
}
|
||||
|
||||
function _isCronScheduleError(job) {
|
||||
return _isRecurringCronJob(job) &&
|
||||
!job.next_run_at &&
|
||||
(job.state === 'error' || job.last_status === 'error');
|
||||
}
|
||||
|
||||
function _cronStatusMeta(job) {
|
||||
if (_isCronNeedsAttention(job)) return {
|
||||
state: 'needs_attention',
|
||||
listClass: 'attention',
|
||||
detailClass: 'warn',
|
||||
label: t('cron_status_needs_attention'),
|
||||
};
|
||||
if (_isCronScheduleError(job)) return {
|
||||
state: 'schedule_error',
|
||||
listClass: 'attention',
|
||||
detailClass: 'warn',
|
||||
label: t('cron_status_needs_attention'),
|
||||
};
|
||||
if (job.state === 'paused') return {
|
||||
state: 'paused',
|
||||
listClass: 'paused',
|
||||
detailClass: 'warn',
|
||||
label: t('cron_status_paused'),
|
||||
};
|
||||
if (job.enabled === false) return {
|
||||
state: 'off',
|
||||
listClass: 'disabled',
|
||||
detailClass: 'warn',
|
||||
label: t('cron_status_off'),
|
||||
};
|
||||
if (job.last_status === 'error') return {
|
||||
state: 'error',
|
||||
listClass: 'error',
|
||||
detailClass: 'err',
|
||||
label: t('cron_status_error'),
|
||||
};
|
||||
return {
|
||||
state: 'active',
|
||||
listClass: 'active',
|
||||
detailClass: 'ok',
|
||||
label: t('cron_status_active'),
|
||||
};
|
||||
}
|
||||
|
||||
function _cronDiagnostics(job) {
|
||||
const fields = {
|
||||
id: job.id,
|
||||
name: job.name || null,
|
||||
schedule: job.schedule || null,
|
||||
schedule_display: job.schedule_display || null,
|
||||
enabled: job.enabled,
|
||||
state: job.state,
|
||||
next_run_at: job.next_run_at || null,
|
||||
last_run_at: job.last_run_at || null,
|
||||
last_status: job.last_status || null,
|
||||
last_error: job.last_error || null,
|
||||
last_delivery_error: job.last_delivery_error || null,
|
||||
repeat: job.repeat || null,
|
||||
deliver: job.deliver || null,
|
||||
};
|
||||
return JSON.stringify(fields, null, 2);
|
||||
}
|
||||
|
||||
async function loadCrons(animate) {
|
||||
const box = $('cronList');
|
||||
const refreshBtn = $('cronRefreshBtn');
|
||||
@@ -197,12 +278,11 @@ async function loadCrons(animate) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'cron-item';
|
||||
item.id = 'cron-' + job.id;
|
||||
const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
|
||||
const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active');
|
||||
const status = _cronStatusMeta(job);
|
||||
item.innerHTML = `
|
||||
<div class="cron-header">
|
||||
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
|
||||
<span class="cron-status ${statusClass}">${esc(statusLabel)}</span>
|
||||
<span class="cron-status ${status.listClass}">${esc(status.label)}</span>
|
||||
</div>`;
|
||||
item.onclick = () => openCronDetail(job.id, item);
|
||||
if (_currentCronDetail && _currentCronDetail.id === job.id) item.classList.add('active');
|
||||
@@ -230,19 +310,34 @@ function _renderCronDetail(job){
|
||||
const empty = $('taskDetailEmpty');
|
||||
if (!title || !body) return;
|
||||
title.textContent = job.name || job.schedule_display || '(unnamed)';
|
||||
const statusClass = job.enabled === false ? 'warn' : job.state === 'paused' ? 'warn' : job.last_status === 'error' ? 'err' : 'ok';
|
||||
const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active');
|
||||
const status = _cronStatusMeta(job);
|
||||
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available');
|
||||
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never');
|
||||
const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || '';
|
||||
const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—';
|
||||
const deliver = job.deliver || 'local';
|
||||
const lastError = job.last_error ? `<div class="detail-row"><div class="detail-row-label">${esc(t('error_prefix').replace(/:\s*$/,''))}</div><div class="detail-row-value" style="color:var(--accent-text)">${esc(job.last_error)}</div></div>` : '';
|
||||
const attention = status.state === 'needs_attention' || status.state === 'schedule_error';
|
||||
const croniterHint = job.last_error && /croniter/i.test(job.last_error)
|
||||
? `<p>${esc(t('cron_attention_croniter_hint'))}</p>`
|
||||
: '';
|
||||
const attentionBanner = attention ? `
|
||||
<div class="detail-alert cron-attention-panel">
|
||||
<div class="detail-alert-title">${esc(t('cron_status_needs_attention'))}</div>
|
||||
<p>${esc(t('cron_attention_desc'))}</p>
|
||||
${croniterHint}
|
||||
<div class="detail-alert-actions">
|
||||
<button type="button" class="cron-btn run" onclick="resumeCurrentCron()">${esc(t('cron_attention_resume'))}</button>
|
||||
<button type="button" class="cron-btn" onclick="runCurrentCron()">${esc(t('cron_attention_run_once'))}</button>
|
||||
<button type="button" class="cron-btn" onclick="copyCurrentCronDiagnostics()">${esc(t('cron_attention_copy_diagnostics'))}</button>
|
||||
</div>
|
||||
</div>` : '';
|
||||
body.innerHTML = `
|
||||
<div class="main-view-content">
|
||||
${attentionBanner}
|
||||
<div class="detail-card">
|
||||
<div class="detail-card-title">${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}</div>
|
||||
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value"><span class="detail-badge ${statusClass}">${esc(statusLabel)}</span></div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value"><span class="detail-badge ${status.detailClass}">${esc(status.label)}</span></div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">Schedule</div><div class="detail-row-value"><code>${esc(schedule)}</code></div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_next'))}</div><div class="detail-row-value">${esc(nextRun)}</div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_last'))}</div><div class="detail-row-value">${esc(lastRun)}</div></div>
|
||||
@@ -279,7 +374,12 @@ function _setCronHeaderButtons(mode, job) {
|
||||
const show = b => b && (b.style.display = '');
|
||||
if (mode === 'read') {
|
||||
show(runBtn);
|
||||
if (job && job.state === 'paused') { hide(pauseBtn); show(resumeBtn); }
|
||||
const status = job ? _cronStatusMeta(job) : null;
|
||||
const resumable = job && (
|
||||
job.state === 'paused' ||
|
||||
(status && (status.state === 'needs_attention' || status.state === 'schedule_error'))
|
||||
);
|
||||
if (resumable) { hide(pauseBtn); show(resumeBtn); }
|
||||
else { show(pauseBtn); hide(resumeBtn); }
|
||||
show(editBtn); show(delBtn); hide(cancelBtn); hide(saveBtn);
|
||||
} else if (mode === 'create' || mode === 'edit') {
|
||||
@@ -339,6 +439,13 @@ function _clearCronDetail(){
|
||||
async function runCurrentCron(){ if (_currentCronDetail) await cronRun(_currentCronDetail.id); }
|
||||
async function pauseCurrentCron(){ if (_currentCronDetail) await cronPause(_currentCronDetail.id); }
|
||||
async function resumeCurrentCron(){ if (_currentCronDetail) await cronResume(_currentCronDetail.id); }
|
||||
async function copyCurrentCronDiagnostics(){
|
||||
if (!_currentCronDetail) return;
|
||||
try {
|
||||
await _copyText(_cronDiagnostics(_currentCronDetail));
|
||||
showToast(t('cron_diagnostics_copied'));
|
||||
} catch(e) { showToast(t('copy_failed'), 4000); }
|
||||
}
|
||||
function editCurrentCron(){
|
||||
if (!_currentCronDetail) return;
|
||||
openCronEdit(_currentCronDetail);
|
||||
|
||||
+17
-2
@@ -200,6 +200,7 @@
|
||||
:root:not(.dark) .token.italic{font-style:italic;}
|
||||
:root:not(.dark) .nav-tab:hover::after{background:var(--surface);border-color:var(--accent-bg-strong);color:var(--accent-text);}
|
||||
:root:not(.dark) .cron-status.disabled{background:rgba(0,0,0,.05);}
|
||||
:root:not(.dark) .cron-status.attention{background:rgba(217,119,6,.14);color:#b45309;}
|
||||
:root:not(.dark) .cron-btn{background:rgba(0,0,0,.04);}
|
||||
:root:not(.dark) .cron-btn:hover{background:rgba(0,0,0,.08);}
|
||||
/* ── Smooth dark mode transitions ── */
|
||||
@@ -517,6 +518,7 @@
|
||||
.cron-status.paused{background:var(--accent-bg-strong);color:var(--accent-text);}
|
||||
.cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);}
|
||||
.cron-status.error{background:rgba(239,83,80,.12);color:var(--error);}
|
||||
.cron-status.attention{background:rgba(245,158,11,.16);color:rgba(245,158,11,.95);}
|
||||
.cron-body{display:none;padding:0 12px 10px;border-top:1px solid var(--border);overflow:hidden;}
|
||||
.cron-body.open{display:block;}
|
||||
.cron-schedule{font-size:11px;color:var(--muted);margin:8px 0 6px;}
|
||||
@@ -615,8 +617,13 @@
|
||||
.msg-files{display:flex;flex-wrap:wrap;gap:6px;padding-left:30px;margin-bottom:10px;}
|
||||
.msg-file-badge{display:flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 9px;font-size:12px;color:var(--accent-text);}
|
||||
/* MEDIA: inline image rendering (feat #450) */
|
||||
.msg-media-img{display:block;max-width:min(480px,100%);max-height:400px;border-radius:8px;margin:6px 0;cursor:zoom-in;object-fit:contain;border:1px solid var(--border);}
|
||||
.msg-media-img--full{max-width:100%;max-height:none;cursor:zoom-out;}
|
||||
.msg-media-img{display:inline-block;width:120px;height:90px;border-radius:6px;margin:3px 4px 3px 0;cursor:zoom-in;object-fit:cover;border:1px solid var(--border);vertical-align:bottom;transition:opacity .15s;}
|
||||
.msg-media-img:hover{opacity:.85;}
|
||||
.img-lightbox{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.82);display:flex;align-items:center;justify-content:center;cursor:zoom-out;animation:lb-in .15s ease;}
|
||||
@keyframes lb-in{from{opacity:0}to{opacity:1}}
|
||||
.img-lightbox img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;}
|
||||
.img-lightbox-close{position:absolute;top:16px;right:20px;width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s;}
|
||||
.img-lightbox-close:hover{background:rgba(255,255,255,.22);}
|
||||
.msg-media-link{display:inline-flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 10px;font-size:13px;color:var(--accent-text);text-decoration:none;}
|
||||
.msg-media-link:hover{background:var(--accent-bg-strong);}
|
||||
.thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;}
|
||||
@@ -642,6 +649,9 @@
|
||||
.attach-chip{display:flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:500;color:var(--accent-text);}
|
||||
.attach-chip button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;line-height:1;padding:0 0 0 3px;}
|
||||
.attach-chip button:hover{color:var(--accent);}
|
||||
/* Image attachment chips show a thumbnail preview instead of a paperclip chip */
|
||||
.attach-chip--image{background:transparent;border-color:var(--border);padding:3px;border-radius:6px;}
|
||||
.attach-thumb{width:56px;height:56px;object-fit:cover;border-radius:4px;display:block;cursor:default;}
|
||||
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
|
||||
textarea#msg::placeholder{color:var(--muted);}
|
||||
.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;}
|
||||
@@ -2241,6 +2251,11 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.detail-badge.running{background:rgba(59,130,246,.12);color:rgba(96,165,250,.95);border-color:rgba(96,165,250,.3);}
|
||||
.detail-badge.running::before{content:'';display:inline-block;width:10px;height:10px;border:2px solid rgba(96,165,250,.4);border-top-color:rgba(96,165,250,.95);border-radius:50%;margin-right:6px;vertical-align:middle;animation:cron-spinner .6s linear infinite;}
|
||||
@keyframes cron-spinner{to{transform:rotate(360deg);}}
|
||||
.detail-alert{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);border-radius:8px;padding:12px 14px;margin-bottom:16px;color:var(--text);}
|
||||
.detail-alert-title{font-size:12px;font-weight:700;color:rgba(245,158,11,.98);text-transform:uppercase;letter-spacing:0;margin-bottom:6px;}
|
||||
.detail-alert p{margin:0 0 8px;font-size:13px;line-height:1.45;color:var(--text);}
|
||||
.detail-alert p:last-child{margin-bottom:0;}
|
||||
.detail-alert-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
|
||||
.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-run-item{border-top:1px solid var(--border);padding:8px 0;}
|
||||
.detail-run-item:first-child{border-top:none;}
|
||||
|
||||
+54
-9
@@ -50,6 +50,37 @@ function _setCompressionSessionLock(sid){
|
||||
window._compressionLockSid=sid||null;
|
||||
}
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
/* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */
|
||||
function _openImgLightbox(src, alt) {
|
||||
const lb = document.createElement('div');
|
||||
lb.className = 'img-lightbox';
|
||||
lb.setAttribute('role', 'dialog');
|
||||
lb.setAttribute('aria-label', alt || 'Image');
|
||||
const img = document.createElement('img');
|
||||
img.src = src;
|
||||
img.alt = alt || '';
|
||||
img.onclick = e => e.stopPropagation();
|
||||
const cls = document.createElement('button');
|
||||
cls.className = 'img-lightbox-close';
|
||||
cls.setAttribute('aria-label', 'Close');
|
||||
cls.textContent = '×';
|
||||
cls.onclick = () => _closeImgLightbox(lb);
|
||||
lb.appendChild(img);
|
||||
lb.appendChild(cls);
|
||||
lb.onclick = () => _closeImgLightbox(lb);
|
||||
document.body.appendChild(lb);
|
||||
// Close on Escape
|
||||
lb._escHandler = e => { if(e.key==='Escape') _closeImgLightbox(lb); };
|
||||
document.addEventListener('keydown', lb._escHandler);
|
||||
}
|
||||
function _closeImgLightbox(lb) {
|
||||
if(!lb || !lb.parentNode) return;
|
||||
document.removeEventListener('keydown', lb._escHandler);
|
||||
lb.style.animation = 'lb-in .12s ease reverse';
|
||||
setTimeout(() => lb.parentNode && lb.parentNode.removeChild(lb), 120);
|
||||
}
|
||||
|
||||
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
|
||||
|
||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||
@@ -815,7 +846,7 @@ function renderMd(raw){
|
||||
// backticks stays protected as a \x00C token and is never rendered as <img>.
|
||||
// Must run before _code_stash restore and before _link_stash so the image
|
||||
// is not consumed by the [label](url) link regex.
|
||||
t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`);
|
||||
t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`);
|
||||
// Stash rendered <img> tags so autolink never matches URLs inside src=
|
||||
const _img_stash=[];
|
||||
t=t.replace(/(<img\b[^>]*>)/g,m=>{_img_stash.push(m);return `\x00G${_img_stash.length-1}\x00`;});
|
||||
@@ -894,7 +925,7 @@ function renderMd(raw){
|
||||
// #487: Outer image pass — handles  in plain paragraphs (outside tables/lists).
|
||||
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
|
||||
// Runs BEFORE the outer [label](url) link pass so the image is not consumed as a plain link.
|
||||
s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`);
|
||||
s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`);
|
||||
// Outer link pass for labeled links in plain paragraphs (outside table cells).
|
||||
// Runs AFTER the table pass so table cells are processed by inlineMd() only.
|
||||
// Stash existing <a> tags first to avoid re-linking already-linked URLs.
|
||||
@@ -958,14 +989,14 @@ function renderMd(raw){
|
||||
// Render all https:// URLs as <img> — extension check would miss extensionless
|
||||
// CDN paths like fal.media content-addressed URLs (closes #853).
|
||||
if(_IMAGE_EXTS.test(src.split('?')[0]) || /^https?:\/\//i.test(src)){
|
||||
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
|
||||
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`;
|
||||
}
|
||||
return `<a href="${esc(src)}" target="_blank" rel="noopener">${esc(src)}</a>`;
|
||||
}
|
||||
// Local file path
|
||||
const apiUrl='api/media?path='+encodeURIComponent(ref);
|
||||
if(_IMAGE_EXTS.test(ref)){
|
||||
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
|
||||
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`;
|
||||
}
|
||||
// Non-image local file — show download link with filename
|
||||
const fname=esc(ref.split('/').pop()||ref);
|
||||
@@ -2255,11 +2286,14 @@ function renderMessages(){
|
||||
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
|
||||
let filesHtml='';
|
||||
if(m.attachments&&m.attachments.length){
|
||||
const _attachSid=(S.session&&S.session.session_id)||'';
|
||||
filesHtml=`<div class="msg-files">${m.attachments.map(f=>{
|
||||
const fname=f.split('/').pop()||f;
|
||||
if(_IMAGE_EXTS.test(fname)){
|
||||
const imgUrl='api/media?path='+encodeURIComponent(f);
|
||||
return `<img class="msg-media-img" src="${esc(imgUrl)}" alt="${esc(fname)}" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
|
||||
// Use api/file/raw which resolves filename relative to the session workspace.
|
||||
// api/media expects a full absolute path which we don't store on the client side.
|
||||
const imgUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
|
||||
return `<img class="msg-media-img" src="${esc(imgUrl)}" alt="${esc(fname)}" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`;
|
||||
}
|
||||
return `<div class="msg-file-badge">${li('paperclip',12)} ${esc(fname)}</div>`;
|
||||
}).join('')}</div>`;
|
||||
@@ -3235,13 +3269,24 @@ function renderTray(){
|
||||
updateSendBtn();
|
||||
S.pendingFiles.forEach((f,i)=>{
|
||||
const chip=document.createElement('div');chip.className='attach-chip';
|
||||
chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} <button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||||
chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();};
|
||||
// Image files get a thumbnail preview; other files keep the paperclip chip
|
||||
if(_IMAGE_EXTS.test(f.name)){
|
||||
const blobUrl=URL.createObjectURL(f);
|
||||
chip.className='attach-chip attach-chip--image';
|
||||
chip.dataset.blobUrl=blobUrl;
|
||||
chip.innerHTML=`<img class="attach-thumb" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||||
} else {
|
||||
chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} <button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||||
}
|
||||
chip.querySelector('button').onclick=()=>{
|
||||
// Revoke blob URL to avoid memory leak before removing
|
||||
if(chip.dataset.blobUrl) URL.revokeObjectURL(chip.dataset.blobUrl);
|
||||
S.pendingFiles.splice(i,1);renderTray();
|
||||
};
|
||||
tray.appendChild(chip);
|
||||
});
|
||||
}
|
||||
function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();}
|
||||
|
||||
async function uploadPendingFiles(){
|
||||
if(!S.pendingFiles.length||!S.session)return[];
|
||||
const names=[];let failures=0;
|
||||
|
||||
+12
-4
@@ -57,10 +57,18 @@ TEST_STATE_DIR = pathlib.Path(os.getenv(
|
||||
))
|
||||
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
|
||||
|
||||
# Publish at module level so _pytest_port.py (imported at collection time)
|
||||
# and any test file using os.environ sees the right values immediately.
|
||||
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
|
||||
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
|
||||
# Publish at module level so api.config, _pytest_port.py, and any test module
|
||||
# importing stateful API code during collection see the isolated test paths.
|
||||
#
|
||||
# Direct assignment is intentional for production-risk paths: tests that import
|
||||
# api.config/api.models in the pytest process must never inherit the real
|
||||
# ~/.hermes state tree before the server subprocess fixture starts.
|
||||
os.environ['HERMES_WEBUI_TEST_PORT'] = str(TEST_PORT)
|
||||
os.environ['HERMES_WEBUI_TEST_STATE_DIR'] = str(TEST_STATE_DIR)
|
||||
os.environ['HERMES_WEBUI_STATE_DIR'] = str(TEST_STATE_DIR)
|
||||
os.environ['HERMES_WEBUI_DEFAULT_WORKSPACE'] = str(TEST_WORKSPACE)
|
||||
os.environ['HERMES_HOME'] = str(TEST_STATE_DIR)
|
||||
os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR)
|
||||
|
||||
# ── Server script: always relative to repo root ───────────────────────────
|
||||
SERVER_SCRIPT = REPO_ROOT / 'server.py'
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Regression coverage for anomalous recurring cron UI state."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PANELS_JS = ROOT / "static" / "panels.js"
|
||||
STYLE_CSS = ROOT / "static" / "style.css"
|
||||
I18N_JS = ROOT / "static" / "i18n.js"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
|
||||
def _cron_helper_source() -> str:
|
||||
src = PANELS_JS.read_text(encoding="utf-8")
|
||||
start = src.index("function _isRecurringCronJob")
|
||||
end = src.index("async function loadCrons", start)
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def test_legacy_broken_recurring_cron_is_needs_attention_not_off():
|
||||
script = _cron_helper_source() + r"""
|
||||
function t(key){ return key; }
|
||||
const legacyBroken = {
|
||||
id: 'legacy-broken',
|
||||
schedule: {kind: 'cron', expr: '0 7,15,23 * * *'},
|
||||
repeat: {times: null, completed: 17},
|
||||
enabled: false,
|
||||
state: 'completed',
|
||||
next_run_at: null,
|
||||
last_status: 'ok',
|
||||
};
|
||||
const oneShotCompleted = {
|
||||
id: 'oneshot-done',
|
||||
schedule: {kind: 'once', run_at: '2026-04-01T00:00:00+00:00'},
|
||||
repeat: {times: 1, completed: 1},
|
||||
enabled: false,
|
||||
state: 'completed',
|
||||
next_run_at: null,
|
||||
last_status: 'ok',
|
||||
};
|
||||
const scheduleError = {
|
||||
id: 'schedule-error',
|
||||
schedule: {kind: 'cron', expr: '0 7 * * *'},
|
||||
repeat: {times: null, completed: 1},
|
||||
enabled: true,
|
||||
state: 'error',
|
||||
next_run_at: null,
|
||||
last_status: 'error',
|
||||
};
|
||||
console.log(JSON.stringify({
|
||||
legacyBroken: _cronStatusMeta(legacyBroken),
|
||||
oneShotCompleted: _cronStatusMeta(oneShotCompleted),
|
||||
scheduleError: _cronStatusMeta(scheduleError),
|
||||
}));
|
||||
"""
|
||||
states = json.loads(_run_node(script))
|
||||
|
||||
assert states["legacyBroken"]["state"] == "needs_attention"
|
||||
assert states["legacyBroken"]["listClass"] == "attention"
|
||||
assert states["legacyBroken"]["label"] == "cron_status_needs_attention"
|
||||
assert states["oneShotCompleted"]["state"] == "off"
|
||||
assert states["oneShotCompleted"]["listClass"] == "disabled"
|
||||
assert states["scheduleError"]["state"] == "schedule_error"
|
||||
assert states["scheduleError"]["listClass"] == "attention"
|
||||
|
||||
|
||||
def test_cron_attention_ui_has_recovery_and_diagnostics_actions():
|
||||
panels = PANELS_JS.read_text(encoding="utf-8")
|
||||
style = STYLE_CSS.read_text(encoding="utf-8")
|
||||
i18n = I18N_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert "cron_status_needs_attention" in panels
|
||||
assert "resumeCurrentCron()" in panels
|
||||
assert "runCurrentCron()" in panels
|
||||
assert "copyCurrentCronDiagnostics()" in panels
|
||||
assert "_cronDiagnostics(_currentCronDetail)" in panels
|
||||
assert ".cron-status.attention" in style
|
||||
assert ".detail-alert" in style
|
||||
assert "cron_attention_resume: 'Resume and recalculate'" in i18n
|
||||
assert "cron_attention_copy_diagnostics: 'Copy diagnostics'" in i18n
|
||||
@@ -11,6 +11,7 @@ def test_resolve_default_workspace_falls_back_to_existing_home_work(monkeypatch,
|
||||
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
|
||||
|
||||
resolved = config.resolve_default_workspace("/definitely/not/usable")
|
||||
|
||||
@@ -28,6 +29,7 @@ def test_save_settings_rewrites_bad_default_workspace_to_fallback(monkeypatch, t
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", preferred)
|
||||
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
|
||||
|
||||
saved = config.save_settings({"default_workspace": "/definitely/not/usable"})
|
||||
on_disk = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
@@ -41,6 +43,7 @@ def test_resolve_default_workspace_creates_home_workspace_when_missing(monkeypat
|
||||
state_dir = tmp_path / "state"
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
|
||||
# Neither ~/work nor ~/workspace exists yet
|
||||
resolved = config.resolve_default_workspace(None)
|
||||
assert resolved == (tmp_path / "workspace").resolve()
|
||||
@@ -145,4 +148,3 @@ def test_env_var_wins_over_settings_json_on_startup(monkeypatch, tmp_path):
|
||||
f"Expected {env_ws.resolve()}, got {current_ws}. "
|
||||
"settings.json must not override HERMES_WEBUI_DEFAULT_WORKSPACE."
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Tests for #1095 — pasted images render as inline previews, not paperclip badges."""
|
||||
"""Tests for #1095 — full fix covering both bugs:
|
||||
|
||||
Bug 1: Composer tray shows paperclip chip for images instead of thumbnail preview.
|
||||
Bug 2: Chat history renders uploaded images as broken <img> (wrong endpoint / dead URL).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import pytest
|
||||
@@ -9,44 +13,136 @@ def _read_js(name):
|
||||
return f.read()
|
||||
|
||||
|
||||
class TestAttachmentImageRendering:
|
||||
"""User message attachments with image extensions should render as <img>, not paperclip badges."""
|
||||
def _read_css():
|
||||
with open(os.path.join('static', 'style.css')) as f:
|
||||
return f.read()
|
||||
|
||||
def test_attachments_block_uses_image_check(self):
|
||||
ui = _read_js('ui.js')
|
||||
# Find the attachments rendering block
|
||||
assert 'm.attachments' in ui
|
||||
# Must check file extension before rendering
|
||||
assert '_IMAGE_EXTS.test(' in ui, '_IMAGE_EXTS not used in attachment rendering'
|
||||
|
||||
def test_image_attachments_use_img_tag(self):
|
||||
"""Image attachments should produce <img> with api/media?path=, not paperclip badge."""
|
||||
ui = _read_js('ui.js')
|
||||
# Find the attachments section
|
||||
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
|
||||
assert m, 'attachments rendering block not found'
|
||||
body = ui[m.start():m.start() + 1000]
|
||||
# Should have img tag with api/media
|
||||
assert 'msg-media-img' in body, 'attachments must render images with msg-media-img class'
|
||||
assert 'api/media?path=' in body, 'image attachments must use api/media endpoint'
|
||||
# ── Bug 1: Composer tray thumbnail previews ────────────────────────────────
|
||||
|
||||
def test_non_image_attachments_keep_paperclip(self):
|
||||
"""Non-image attachments must still show paperclip badge."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
|
||||
body = ui[m.start():m.start() + 1000]
|
||||
assert "msg-file-badge" in body, 'non-image attachments must still use paperclip badge'
|
||||
class TestComposerTrayThumbnails:
|
||||
"""renderTray() must show thumbnail previews for image files, not paperclip chips."""
|
||||
|
||||
def test_image_click_to_full(self):
|
||||
"""Inline image attachments should support click-to-fullscreen (toggle class)."""
|
||||
def test_rendertray_checks_image_extension(self):
|
||||
"""renderTray must branch on _IMAGE_EXTS for the file object in S.pendingFiles."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
|
||||
body = ui[m.start():m.start() + 1000]
|
||||
assert "msg-media-img--full" in body, 'image attachments should toggle full-screen on click'
|
||||
# Find renderTray function body
|
||||
idx = ui.find('function renderTray()')
|
||||
assert idx >= 0, 'renderTray() not found in ui.js'
|
||||
body = ui[idx:idx + 800]
|
||||
assert '_IMAGE_EXTS.test(' in body, 'renderTray must check _IMAGE_EXTS for thumbnail vs chip'
|
||||
|
||||
def test_uses_filename_not_full_path(self):
|
||||
"""Non-image badge should display filename, not full path."""
|
||||
def test_rendertray_uses_createobjecturl_for_images(self):
|
||||
"""Image files must use URL.createObjectURL(f) to generate a blob URL for the thumbnail."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
|
||||
body = ui[m.start():m.start() + 1000]
|
||||
assert ".split('/').pop()" in body, 'should extract filename from path for display'
|
||||
idx = ui.find('function renderTray()')
|
||||
body = ui[idx:idx + 800]
|
||||
assert 'URL.createObjectURL(' in body, 'renderTray must use URL.createObjectURL for image thumbnails'
|
||||
|
||||
def test_rendertray_revokes_blob_url_on_remove(self):
|
||||
"""Blob URLs must be revoked when a file is removed to prevent memory leaks."""
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function renderTray()')
|
||||
body = ui[idx:idx + 1200]
|
||||
assert 'URL.revokeObjectURL(' in body, 'renderTray must revoke blob URL when chip is removed'
|
||||
|
||||
def test_rendertray_uses_attach_thumb_class(self):
|
||||
"""Image chips must use attach-thumb class for the thumbnail <img> element."""
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function renderTray()')
|
||||
body = ui[idx:idx + 800]
|
||||
assert 'attach-thumb' in body, 'renderTray image chip must use attach-thumb class'
|
||||
|
||||
def test_rendertray_non_image_still_uses_paperclip(self):
|
||||
"""Non-image files must still get the paperclip chip (not thumbnail)."""
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function renderTray()')
|
||||
body = ui[idx:idx + 800]
|
||||
assert 'paperclip' in body, 'non-image files must still use paperclip chip in renderTray'
|
||||
|
||||
def test_attach_thumb_css_present(self):
|
||||
"""CSS must define .attach-thumb with width/height/object-fit for the thumbnail."""
|
||||
css = _read_css()
|
||||
assert '.attach-thumb' in css, '.attach-thumb CSS class must be defined'
|
||||
# Find the rule
|
||||
idx = css.find('.attach-thumb')
|
||||
rule = css[idx:idx + 200]
|
||||
assert 'object-fit' in rule, '.attach-thumb must set object-fit to crop image to square'
|
||||
|
||||
def test_attach_chip_image_variant_css(self):
|
||||
"""CSS must define .attach-chip--image for the image chip variant."""
|
||||
css = _read_css()
|
||||
assert '.attach-chip--image' in css, '.attach-chip--image CSS variant must be defined'
|
||||
|
||||
def test_adfiles_function_still_present(self):
|
||||
"""addFiles() must still exist after renderTray refactor."""
|
||||
ui = _read_js('ui.js')
|
||||
assert 'function addFiles(' in ui, 'addFiles() must not be removed from ui.js'
|
||||
|
||||
|
||||
# ── Bug 2: Chat history image rendering ───────────────────────────────────
|
||||
|
||||
class TestChatHistoryImageRendering:
|
||||
"""Uploaded images in chat history must render via a working HTTP endpoint, not a dead path."""
|
||||
|
||||
def test_attachment_render_uses_file_raw_not_media(self):
|
||||
"""Image attachments in chat history must use api/file/raw, not api/media.
|
||||
|
||||
api/media expects a full absolute filesystem path (e.g. /home/hermes/.hermes/...).
|
||||
We only store the filename in m.attachments — feeding just a filename to api/media
|
||||
results in a broken image (path not in allowed roots → 404).
|
||||
|
||||
api/file/raw resolves the filename relative to the session's workspace, which is
|
||||
exactly where the upload endpoint stores the file.
|
||||
"""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
assert m, 'attachments rendering block not found in ui.js'
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
assert 'api/file/raw' in body, (
|
||||
'Image attachments in chat history must use api/file/raw endpoint '
|
||||
'(resolves filename relative to session workspace). '
|
||||
'api/media requires a full absolute path which is not stored on the client.'
|
||||
)
|
||||
assert 'api/media?path=' not in body, (
|
||||
'api/media?path= must not be used for user-uploaded image attachments — '
|
||||
'it expects a full absolute path, but only filenames are stored in m.attachments.'
|
||||
)
|
||||
|
||||
def test_attachment_render_includes_session_id(self):
|
||||
"""api/file/raw URL must include session_id parameter for workspace resolution."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
assert 'session_id' in body, (
|
||||
'api/file/raw URL in attachment rendering must include session_id '
|
||||
'so the server can resolve the filename against the correct workspace.'
|
||||
)
|
||||
|
||||
def test_attachment_render_image_uses_msg_media_img(self):
|
||||
"""Image attachments must still render with msg-media-img class for consistent styling."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
assert 'msg-media-img' in body, 'Image attachment <img> must use msg-media-img class'
|
||||
|
||||
def test_attachment_render_click_to_fullscreen(self):
|
||||
"""Click-to-fullscreen must still work on chat history images."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
assert '_openImgLightbox' in body, 'Chat history images must open lightbox on click'
|
||||
|
||||
def test_attachment_render_non_image_keeps_paperclip(self):
|
||||
"""Non-image attachments in chat history must still show paperclip badge."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
assert 'msg-file-badge' in body, 'Non-image attachments must still use msg-file-badge in chat history'
|
||||
|
||||
def test_attachment_render_extracts_filename(self):
|
||||
"""Filename extraction (.split('/').pop()) must still be present for display."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
assert ".split('/').pop()" in body, 'Must extract filename from path for display'
|
||||
|
||||
@@ -36,7 +36,8 @@ class TestMediaUrlRendersInline:
|
||||
js = _read("static/ui.js")
|
||||
# The img tag is constructed with the existing class + onclick toggle
|
||||
assert "msg-media-img" in js
|
||||
assert "msg-media-img--full" in js
|
||||
# PR #1135: CSS class toggle replaced by lightbox. Class removed; _openImgLightbox handles zoom.
|
||||
assert "_openImgLightbox" in js, "Image click must open lightbox overlay"
|
||||
|
||||
|
||||
# ── #857: thinking-preamble stripping in auto-title ──────────────────────────
|
||||
|
||||
@@ -76,8 +76,9 @@ class TestMediaRenderMdStash(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_zoom_toggle_on_click(self):
|
||||
self.assertIn("msg-media-img--full", UI_JS,
|
||||
"Clicking the image must toggle msg-media-img--full class for zoom")
|
||||
# PR #1135: CSS class toggle replaced by proper lightbox overlay
|
||||
self.assertIn("_openImgLightbox", UI_JS,
|
||||
"Clicking the image must open lightbox overlay (_openImgLightbox)")
|
||||
|
||||
|
||||
# ── Static analysis: CSS ──────────────────────────────────────────────────────
|
||||
@@ -90,14 +91,16 @@ class TestMediaCSS(unittest.TestCase):
|
||||
self.assertIn(".msg-media-img", self.CSS)
|
||||
|
||||
def test_msg_media_img_max_width(self):
|
||||
# Should have a max-width to prevent huge images breaking layout
|
||||
# PR #1135: resting thumbnail is 120x90px (fixed size); no max-width needed.
|
||||
# Lightbox shows full-size. Check width is set instead.
|
||||
idx = self.CSS.find(".msg-media-img{")
|
||||
self.assertGreater(idx, 0)
|
||||
rule = self.CSS[idx:idx+200]
|
||||
self.assertIn("max-width", rule)
|
||||
self.assertIn("width:120px", rule, "Thumbnail must have fixed 120px width")
|
||||
|
||||
def test_msg_media_img_full_class_defined(self):
|
||||
self.assertIn(".msg-media-img--full", self.CSS,
|
||||
# PR #1135: .msg-media-img--full removed; lightbox replaces inline zoom.
|
||||
self.assertIn(".img-lightbox", self.CSS,
|
||||
"Full-size toggle class must exist for zoom-on-click")
|
||||
|
||||
def test_msg_media_link_class_defined(self):
|
||||
|
||||
@@ -293,6 +293,15 @@ def _server_reachable() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _flush_server_config_cache() -> None:
|
||||
# GET /api/personalities always calls reload_config(), giving us a cheap
|
||||
# way to flush cached provider state without restarting the test server.
|
||||
try:
|
||||
_http_get("/api/personalities")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# No collection-time skip guard — conftest.py starts the server via its
|
||||
# autouse session fixture BEFORE tests run. A collection-time check always
|
||||
# sees no server and turns every test into a skip. Server reachability is
|
||||
@@ -313,19 +322,13 @@ class TestOnboardingGateIntegration:
|
||||
hermes_home = _server_hermes_home()
|
||||
for rel in ("config.yaml", ".env"):
|
||||
(hermes_home / rel).unlink(missing_ok=True)
|
||||
_http_post("/api/settings", {"onboarding_completed": False})
|
||||
_flush_server_config_cache()
|
||||
yield
|
||||
for rel in ("config.yaml", ".env"):
|
||||
(hermes_home / rel).unlink(missing_ok=True)
|
||||
# Force the server to reload its in-memory config after file deletion.
|
||||
# apply_onboarding_setup() calls reload_config() which caches provider
|
||||
# state in the server process. Deleting files on disk does not clear
|
||||
# that cache — the next test would see provider_configured=True.
|
||||
# GET /api/personalities always calls reload_config(), giving us a
|
||||
# cheap way to flush the cache without a server restart.
|
||||
try:
|
||||
_http_get("/api/personalities")
|
||||
except Exception:
|
||||
pass
|
||||
_http_post("/api/settings", {"onboarding_completed": False})
|
||||
_flush_server_config_cache()
|
||||
|
||||
def test_no_config_wizard_fires(self):
|
||||
"""No config.yaml → completed=False."""
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Regression tests for pytest-process state isolation.
|
||||
|
||||
Some tests import api.config/api.models during collection and directly write
|
||||
sessions from the pytest process. conftest must publish the test state env vars
|
||||
before those imports, not only for the server subprocess.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_api_config_uses_pytest_state_dir():
|
||||
import api.config as config
|
||||
from tests.conftest import TEST_STATE_DIR
|
||||
|
||||
test_state_dir = TEST_STATE_DIR.resolve()
|
||||
production_state_dir = (Path.home() / ".hermes" / "webui").resolve()
|
||||
|
||||
assert config.STATE_DIR == test_state_dir
|
||||
assert config.SESSION_DIR == test_state_dir / "sessions"
|
||||
assert config.STATE_DIR != production_state_dir
|
||||
assert production_state_dir not in config.SESSION_DIR.resolve().parents
|
||||
Reference in New Issue
Block a user