feat(i18n): complete zh-CN hardening and locale consistency
This commit is contained in:
committed by
Nathan Esquenazi
parent
6a513f49b2
commit
c4efe96725
263
static/panels.js
263
static/panels.js
@@ -24,7 +24,7 @@ async function loadCrons() {
|
||||
try {
|
||||
const data = await api('/api/crons');
|
||||
if (!data.jobs || !data.jobs.length) {
|
||||
box.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No scheduled jobs found.</div>';
|
||||
box.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('cron_no_jobs'))}</div>`;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = '';
|
||||
@@ -33,42 +33,42 @@ async function loadCrons() {
|
||||
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 ? 'off' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
|
||||
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A';
|
||||
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'never';
|
||||
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 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');
|
||||
item.innerHTML = `
|
||||
<div class="cron-header" onclick="toggleCron('${job.id}')">
|
||||
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
|
||||
<span class="cron-status ${statusClass}">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="cron-body" id="cron-body-${job.id}">
|
||||
<div class="cron-schedule">${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')} | Next: ${esc(nextRun)} | Last: ${esc(lastRun)}</div>
|
||||
<div class="cron-schedule">${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')} | ${esc(t('cron_next'))}: ${esc(nextRun)} | ${esc(t('cron_last'))}: ${esc(lastRun)}</div>
|
||||
<div class="cron-prompt">${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}</div>
|
||||
<div class="cron-actions">
|
||||
<button class="cron-btn run" onclick="cronRun('${job.id}')">${li('play',12)} Run now</button>
|
||||
${statusLabel==='paused'
|
||||
? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} Resume</button>`
|
||||
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} Pause</button>`}
|
||||
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'"')})">${li('pencil',12)} Edit</button>
|
||||
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} Delete</button>
|
||||
<button class="cron-btn run" onclick="cronRun('${job.id}')">${li('play',12)} ${esc(t('cron_run_now'))}</button>
|
||||
${job.state==='paused'
|
||||
? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} ${esc(t('cron_resume'))}</button>`
|
||||
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} ${esc(t('cron_pause'))}</button>`}
|
||||
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'"')})">${li('pencil',12)} ${esc(t('edit'))}</button>
|
||||
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} ${esc(t('delete_title'))}</button>
|
||||
</div>
|
||||
<!-- Inline edit form, hidden by default -->
|
||||
<div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px">
|
||||
<input id="cron-edit-name-${job.id}" placeholder="Job name" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:5px;box-sizing:border-box">
|
||||
<input id="cron-edit-schedule-${job.id}" placeholder="Schedule" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:5px;box-sizing:border-box">
|
||||
<textarea id="cron-edit-prompt-${job.id}" rows="3" placeholder="Prompt" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:5px;box-sizing:border-box"></textarea>
|
||||
<input id="cron-edit-name-${job.id}" placeholder="${esc(t('cron_job_name_placeholder'))}" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:5px;box-sizing:border-box">
|
||||
<input id="cron-edit-schedule-${job.id}" placeholder="${esc(t('cron_schedule_placeholder'))}" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:5px;box-sizing:border-box">
|
||||
<textarea id="cron-edit-prompt-${job.id}" rows="3" placeholder="${esc(t('cron_prompt_placeholder'))}" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:5px;box-sizing:border-box"></textarea>
|
||||
<div id="cron-edit-err-${job.id}" style="font-size:11px;color:var(--accent);display:none;margin-bottom:5px"></div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="cronEditSave('${job.id}')">Save</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="cronEditClose('${job.id}')">Cancel</button>
|
||||
<button class="cron-btn run" style="flex:1" onclick="cronEditSave('${job.id}')">${esc(t('save'))}</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="cronEditClose('${job.id}')">${esc(t('cancel'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cron-output-${job.id}">
|
||||
<div class="cron-last-header" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span>Last output</span>
|
||||
<button class="cron-btn" style="padding:1px 8px;font-size:10px" onclick="loadCronHistory('${job.id}',this)">All runs</button>
|
||||
<span>${esc(t('cron_last_output'))}</span>
|
||||
<button class="cron-btn" style="padding:1px 8px;font-size:10px" onclick="loadCronHistory('${job.id}',this)">${esc(t('cron_all_runs'))}</button>
|
||||
</div>
|
||||
<div class="cron-last" id="cron-out-text-${job.id}" style="color:var(--muted);font-size:11px">Loading…</div>
|
||||
<div class="cron-last" id="cron-out-text-${job.id}" style="color:var(--muted);font-size:11px">${esc(t('loading'))}</div>
|
||||
<div id="cron-history-${job.id}" style="display:none"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -76,7 +76,7 @@ async function loadCrons() {
|
||||
// Eagerly load last output for visible items
|
||||
loadCronOutput(job.id);
|
||||
}
|
||||
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
|
||||
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
let _cronSelectedSkills=[];
|
||||
@@ -164,18 +164,18 @@ async function submitCronCreate(){
|
||||
const deliver=$('cronFormDeliver').value;
|
||||
const errEl=$('cronFormError');
|
||||
errEl.style.display='none';
|
||||
if(!schedule){errEl.textContent='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;}
|
||||
if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;}
|
||||
if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;}
|
||||
if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;}
|
||||
try{
|
||||
const body={schedule,prompt,deliver};
|
||||
if(name)body.name=name;
|
||||
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
|
||||
await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)});
|
||||
toggleCronForm();
|
||||
showToast('Job created');
|
||||
showToast(t('cron_job_created'));
|
||||
await loadCrons();
|
||||
}catch(e){
|
||||
errEl.textContent='Error: '+e.message;errEl.style.display='';
|
||||
errEl.textContent=t('error_prefix')+e.message;errEl.style.display='';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ async function loadCronOutput(jobId) {
|
||||
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`);
|
||||
const el = $('cron-out-text-' + jobId);
|
||||
if (!el) return;
|
||||
if (!data.outputs || !data.outputs.length) { el.textContent = '(no runs yet)'; return; }
|
||||
if (!data.outputs || !data.outputs.length) { el.textContent = t('cron_no_runs_yet'); return; }
|
||||
const out = data.outputs[0];
|
||||
const ts = out.filename.replace('.md','').replace(/_/g,' ');
|
||||
el.textContent = ts + '\n\n' + _cronOutputSnippet(out.content);
|
||||
@@ -205,14 +205,14 @@ async function loadCronHistory(jobId, btn) {
|
||||
// Toggle: if already open, close it
|
||||
if (histEl.style.display !== 'none') {
|
||||
histEl.style.display = 'none';
|
||||
if (btn) btn.textContent = 'All runs';
|
||||
if (btn) btn.textContent = t('cron_all_runs');
|
||||
return;
|
||||
}
|
||||
if (btn) btn.textContent = 'Loading…';
|
||||
if (btn) btn.textContent = t('loading');
|
||||
try {
|
||||
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`);
|
||||
if (!data.outputs || !data.outputs.length) {
|
||||
histEl.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 0">(no runs yet)</div>';
|
||||
histEl.innerHTML = `<div style="font-size:11px;color:var(--muted);padding:4px 0">${esc(t('cron_no_runs_yet'))}</div>`;
|
||||
} else {
|
||||
histEl.innerHTML = data.outputs.map((out, i) => {
|
||||
const ts = out.filename.replace('.md','').replace(/_/g,' ');
|
||||
@@ -228,9 +228,9 @@ async function loadCronHistory(jobId, btn) {
|
||||
}).join('');
|
||||
}
|
||||
histEl.style.display = '';
|
||||
if (btn) btn.textContent = 'Hide runs';
|
||||
if (btn) btn.textContent = t('cron_hide_runs');
|
||||
} catch(e) {
|
||||
if (btn) btn.textContent = 'All runs';
|
||||
if (btn) btn.textContent = t('cron_all_runs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,25 +242,25 @@ function toggleCron(id) {
|
||||
async function cronRun(id) {
|
||||
try {
|
||||
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||
showToast('Job triggered');
|
||||
showToast(t('cron_job_triggered'));
|
||||
setTimeout(() => loadCronOutput(id), 5000);
|
||||
} catch(e) { showToast('Run failed: ' + e.message, 4000); }
|
||||
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
|
||||
}
|
||||
|
||||
async function cronPause(id) {
|
||||
try {
|
||||
await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||
showToast('Job paused');
|
||||
showToast(t('cron_job_paused'));
|
||||
await loadCrons();
|
||||
} catch(e) { showToast('Pause failed: ' + e.message, 4000); }
|
||||
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
|
||||
}
|
||||
|
||||
async function cronResume(id) {
|
||||
try {
|
||||
await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||
showToast('Job resumed');
|
||||
showToast(t('cron_job_resumed'));
|
||||
await loadCrons();
|
||||
} catch(e) { showToast('Resume failed: ' + e.message, 4000); }
|
||||
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
|
||||
}
|
||||
|
||||
function cronEditOpen(id, job) {
|
||||
@@ -284,25 +284,25 @@ async function cronEditSave(id) {
|
||||
const schedule = $('cron-edit-schedule-' + id).value.trim();
|
||||
const prompt = $('cron-edit-prompt-' + id).value.trim();
|
||||
const errEl = $('cron-edit-err-' + id);
|
||||
if (!schedule) { errEl.textContent = 'Schedule is required'; errEl.style.display = ''; return; }
|
||||
if (!prompt) { errEl.textContent = 'Prompt is required'; errEl.style.display = ''; return; }
|
||||
if (!schedule) { errEl.textContent = t('cron_schedule_required'); errEl.style.display = ''; return; }
|
||||
if (!prompt) { errEl.textContent = t('cron_prompt_required'); errEl.style.display = ''; return; }
|
||||
try {
|
||||
const updates = {job_id: id, schedule, prompt};
|
||||
if (name) updates.name = name;
|
||||
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
|
||||
showToast('Job updated');
|
||||
showToast(t('cron_job_updated'));
|
||||
await loadCrons();
|
||||
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
|
||||
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
|
||||
}
|
||||
|
||||
async function cronDelete(id) {
|
||||
const _delCron=await showConfirmDialog({title:'Delete cron job',message:'This cannot be undone.',confirmLabel:'Delete',danger:true,focusCancel:true});
|
||||
const _delCron=await showConfirmDialog({title:t('cron_delete_confirm_title'),message:t('cron_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
|
||||
if(!_delCron) return;
|
||||
try {
|
||||
await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||
showToast('Job deleted');
|
||||
showToast(t('cron_job_deleted'));
|
||||
await loadCrons();
|
||||
} catch(e) { showToast('Delete failed: ' + e.message, 4000); }
|
||||
} catch(e) { showToast(t('delete_failed') + e.message, 4000); }
|
||||
}
|
||||
|
||||
function loadTodos() {
|
||||
@@ -324,7 +324,7 @@ function loadTodos() {
|
||||
}
|
||||
}
|
||||
if (!todos.length) {
|
||||
panel.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:4px 0">No active task list in this session.</div>';
|
||||
panel.innerHTML = `<div style="color:var(--muted);font-size:12px;padding:4px 0">${esc(t('todos_no_active'))}</div>`;
|
||||
return;
|
||||
}
|
||||
const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
|
||||
@@ -341,7 +341,7 @@ function loadTodos() {
|
||||
|
||||
async function clearConversation() {
|
||||
if(!S.session) return;
|
||||
const _clrMsg=await showConfirmDialog({title:'Clear conversation',message:'Clear all messages? This cannot be undone.',confirmLabel:'Clear',danger:true,focusCancel:true});
|
||||
const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true});
|
||||
if(!_clrMsg) return;
|
||||
try {
|
||||
const data = await api('/api/session/clear', {method:'POST',
|
||||
@@ -351,8 +351,8 @@ async function clearConversation() {
|
||||
S.toolCalls = [];
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
showToast('Conversation cleared');
|
||||
} catch(e) { setStatus('Clear failed: ' + e.message); }
|
||||
showToast(t('conversation_cleared'));
|
||||
} catch(e) { setStatus(t('clear_failed') + e.message); }
|
||||
}
|
||||
|
||||
// ── Skills panel ──
|
||||
@@ -382,7 +382,7 @@ function renderSkills(skills) {
|
||||
}
|
||||
const box = $('skillsList');
|
||||
box.innerHTML = '';
|
||||
if (!filtered.length) { box.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:12px">No skills match.</div>'; return; }
|
||||
if (!filtered.length) { box.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:12px">${esc(t('skills_no_match'))}</div>`; return; }
|
||||
for (const [cat, items] of Object.entries(cats).sort()) {
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'skills-category';
|
||||
@@ -418,7 +418,7 @@ async function openSkill(name, el) {
|
||||
const lf = data.linked_files || {};
|
||||
const categories = Object.entries(lf).filter(([,files]) => files && files.length > 0);
|
||||
if (categories.length) {
|
||||
html += '<div class="skill-linked-files"><div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">Linked Files</div>';
|
||||
html += `<div class="skill-linked-files"><div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">${esc(t('linked_files'))}</div>`;
|
||||
for (const [cat, files] of categories) {
|
||||
html += `<div class="skill-linked-section"><h4>${esc(cat)}</h4>`;
|
||||
for (const f of files) {
|
||||
@@ -435,7 +435,7 @@ async function openSkill(name, el) {
|
||||
});
|
||||
$('previewArea').classList.add('visible');
|
||||
$('fileTree').style.display = 'none';
|
||||
} catch(e) { setStatus('Could not load skill: ' + e.message); }
|
||||
} catch(e) { setStatus(t('skill_load_failed') + e.message); }
|
||||
}
|
||||
|
||||
async function openSkillFile(skillName, filePath) {
|
||||
@@ -453,7 +453,7 @@ async function openSkillFile(skillName, filePath) {
|
||||
$('previewCode').textContent = data.content || '';
|
||||
requestAnimationFrame(() => highlightCode());
|
||||
}
|
||||
} catch(e) { setStatus('Could not load file: ' + e.message); }
|
||||
} catch(e) { setStatus(t('skill_file_load_failed') + e.message); }
|
||||
}
|
||||
|
||||
// ── Skill create/edit form ──
|
||||
@@ -479,15 +479,15 @@ async function submitSkillSave() {
|
||||
const content = $('skillFormContent').value;
|
||||
const errEl = $('skillFormError');
|
||||
errEl.style.display = 'none';
|
||||
if (!name) { errEl.textContent = 'Skill name is required'; errEl.style.display = ''; return; }
|
||||
if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; }
|
||||
if (!name) { errEl.textContent = t('skill_name_required'); errEl.style.display = ''; return; }
|
||||
if (!content.trim()) { errEl.textContent = t('content_required'); errEl.style.display = ''; return; }
|
||||
try {
|
||||
await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})});
|
||||
showToast(_editingSkillName ? 'Skill updated' : 'Skill created');
|
||||
showToast(_editingSkillName ? t('skill_updated') : t('skill_created'));
|
||||
_skillsData = null;
|
||||
toggleSkillForm();
|
||||
await loadSkills();
|
||||
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
|
||||
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
|
||||
}
|
||||
|
||||
// ── Memory inline edit ──
|
||||
@@ -498,7 +498,7 @@ function toggleMemoryEdit() {
|
||||
if (!form) return;
|
||||
const open = form.style.display !== 'none';
|
||||
if (open) { form.style.display = 'none'; return; }
|
||||
$('memEditSection').textContent = 'memory (notes)';
|
||||
$('memEditSection').textContent = t('memory_notes_label');
|
||||
$('memEditContent').value = _memoryData ? (_memoryData.memory || '') : '';
|
||||
$('memEditError').style.display = 'none';
|
||||
form.style.display = '';
|
||||
@@ -515,10 +515,10 @@ async function submitMemorySave() {
|
||||
errEl.style.display = 'none';
|
||||
try {
|
||||
await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})});
|
||||
showToast('Memory saved');
|
||||
showToast(t('memory_saved'));
|
||||
closeMemoryEdit();
|
||||
await loadMemory(true);
|
||||
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
|
||||
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
|
||||
}
|
||||
|
||||
// ── Workspace management ──
|
||||
@@ -550,7 +550,7 @@ function syncWorkspaceDisplays(){
|
||||
if(composerLabel) composerLabel.textContent=label;
|
||||
if(composerChip){
|
||||
composerChip.disabled=!hasSession;
|
||||
composerChip.title=hasSession?ws:'No active workspace';
|
||||
composerChip.title=hasSession?ws:t('no_workspace');
|
||||
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
|
||||
}
|
||||
}
|
||||
@@ -610,15 +610,15 @@ function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
|
||||
}
|
||||
dd.appendChild(document.createElement('div')).className='ws-divider';
|
||||
dd.appendChild(_renderWorkspaceAction(
|
||||
'Choose workspace path',
|
||||
'Add a validated path and switch this conversation',
|
||||
t('workspace_choose_path'),
|
||||
t('workspace_choose_path_meta'),
|
||||
li('folder',12),
|
||||
()=>promptWorkspacePath()
|
||||
));
|
||||
const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div);
|
||||
dd.appendChild(_renderWorkspaceAction(
|
||||
'Manage workspaces',
|
||||
'Open the Spaces panel',
|
||||
t('workspace_manage'),
|
||||
t('workspace_manage_meta'),
|
||||
li('settings',12),
|
||||
()=>{closeWsDropdown();mobileSwitchPanel('workspaces');}
|
||||
));
|
||||
@@ -693,19 +693,19 @@ function renderWorkspacesPanel(workspaces){
|
||||
<div class="ws-row-path">${esc(w.path)}</div>
|
||||
</div>
|
||||
<div class="ws-row-actions">
|
||||
<button class="ws-action-btn" title="Use in current session" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">${li('arrow-right',12)} Use</button>
|
||||
<button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">${li('x',12)}</button>
|
||||
<button class="ws-action-btn" title="${esc(t('workspace_use_title'))}" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">${li('arrow-right',12)} ${esc(t('workspace_use'))}</button>
|
||||
<button class="ws-action-btn danger" title="${esc(t('remove'))}" onclick="removeWorkspace('${esc(w.path)}')">${li('x',12)}</button>
|
||||
</div>`;
|
||||
panel.appendChild(row);
|
||||
}
|
||||
const addRow=document.createElement('div');addRow.className='ws-add-row';
|
||||
addRow.innerHTML=`
|
||||
<input id="wsAddInput" placeholder="Add workspace path (e.g. /home/user/my-project)" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
|
||||
<button class="ws-action-btn" onclick="addWorkspace()">${li('plus',12)} Add</button>`;
|
||||
<input id="wsAddInput" placeholder="${esc(t('workspace_add_path_placeholder'))}" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
|
||||
<button class="ws-action-btn" onclick="addWorkspace()">${li('plus',12)} ${esc(t('add'))}</button>`;
|
||||
panel.appendChild(addRow);
|
||||
const hint=document.createElement('div');
|
||||
hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px';
|
||||
hint.textContent='Paths are validated as existing directories before saving.';
|
||||
hint.textContent=t('workspace_paths_validated_hint');
|
||||
panel.appendChild(hint);
|
||||
}
|
||||
|
||||
@@ -718,28 +718,28 @@ async function addWorkspace(){
|
||||
_workspaceList=data.workspaces;
|
||||
renderWorkspacesPanel(data.workspaces);
|
||||
if(input)input.value='';
|
||||
showToast('Workspace added');
|
||||
}catch(e){setStatus('Add failed: '+e.message);}
|
||||
showToast(t('workspace_added'));
|
||||
}catch(e){setStatus(t('add_failed')+e.message);}
|
||||
}
|
||||
|
||||
async function removeWorkspace(path){
|
||||
const _rmWs=await showConfirmDialog({title:'Remove workspace',message:`Remove "${path}"?`,confirmLabel:'Remove',danger:true,focusCancel:true});
|
||||
const _rmWs=await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true});
|
||||
if(!_rmWs) return;
|
||||
try{
|
||||
const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})});
|
||||
_workspaceList=data.workspaces;
|
||||
renderWorkspacesPanel(data.workspaces);
|
||||
showToast('Workspace removed');
|
||||
}catch(e){setStatus('Remove failed: '+e.message);}
|
||||
showToast(t('workspace_removed'));
|
||||
}catch(e){setStatus(t('remove_failed')+e.message);}
|
||||
}
|
||||
|
||||
async function promptWorkspacePath(){
|
||||
if(!S.session)return;
|
||||
const value=await showPromptDialog({
|
||||
title:'Switch workspace',
|
||||
message:'Enter an absolute workspace path to add and switch this conversation to.',
|
||||
confirmLabel:'Switch',
|
||||
placeholder:'/Users/you/project',
|
||||
title:t('workspace_switch_prompt_title'),
|
||||
message:t('workspace_switch_prompt_message'),
|
||||
confirmLabel:t('workspace_switch_prompt_confirm'),
|
||||
placeholder:t('workspace_switch_prompt_placeholder'),
|
||||
value:S.session.workspace||''
|
||||
});
|
||||
const path=(value||'').trim();
|
||||
@@ -748,27 +748,27 @@ async function promptWorkspacePath(){
|
||||
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})});
|
||||
_workspaceList=data.workspaces||[];
|
||||
const target=_workspaceList[_workspaceList.length-1];
|
||||
if(!target) throw new Error('Workspace was not added');
|
||||
if(!target) throw new Error(t('workspace_not_added'));
|
||||
await switchToWorkspace(target.path,target.name);
|
||||
}catch(e){
|
||||
if(String(e.message||'').includes('Workspace already in list')){
|
||||
showToast('Workspace already saved — choose it from the list');
|
||||
showToast(t('workspace_already_saved'));
|
||||
return;
|
||||
}
|
||||
showToast('Workspace switch failed: '+e.message);
|
||||
showToast(t('workspace_switch_failed')+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function switchToWorkspace(path,name){
|
||||
if(!S.session)return;
|
||||
if(S.busy){
|
||||
showToast('Cannot switch workspace while agent is running');
|
||||
showToast(t('workspace_busy_switch'));
|
||||
return;
|
||||
}
|
||||
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
||||
const discard=await showConfirmDialog({
|
||||
title:'Discard file edits?',
|
||||
message:'Switching workspaces will discard unsaved file edits in the preview.',
|
||||
title:t('discard_file_edits_title'),
|
||||
message:t('discard_file_edits_message'),
|
||||
confirmLabel:t('discard'),
|
||||
danger:true
|
||||
});
|
||||
@@ -784,8 +784,8 @@ async function switchToWorkspace(path,name){
|
||||
S.session.workspace=path;
|
||||
syncTopbar();
|
||||
await loadDir('.');
|
||||
showToast(`Switched to ${name||getWorkspaceFriendlyName(path)}`);
|
||||
}catch(e){setStatus('Switch failed: '+e.message);}
|
||||
showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path)));
|
||||
}catch(e){setStatus(t('switch_failed')+e.message);}
|
||||
}
|
||||
|
||||
// ── Profile panel + dropdown ──
|
||||
@@ -799,7 +799,7 @@ async function loadProfilesPanel() {
|
||||
_profilesCache = data;
|
||||
panel.innerHTML = '';
|
||||
if (!data.profiles || !data.profiles.length) {
|
||||
panel.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No profiles found.</div>';
|
||||
panel.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('profiles_no_profiles'))}</div>`;
|
||||
return;
|
||||
}
|
||||
for (const p of data.profiles) {
|
||||
@@ -809,20 +809,20 @@ async function loadProfilesPanel() {
|
||||
if (p.model) meta.push(p.model.split('/').pop());
|
||||
if (p.provider) meta.push(p.provider);
|
||||
if (p.skill_count) meta.push(p.skill_count + ' skill' + (p.skill_count !== 1 ? 's' : ''));
|
||||
if (p.has_env) meta.push('API keys configured');
|
||||
if (p.has_env) meta.push(t('profile_api_keys_configured'));
|
||||
const gwDot = p.gateway_running
|
||||
? '<span class="profile-opt-badge running" title="Gateway running"></span>'
|
||||
: '<span class="profile-opt-badge stopped" title="Gateway stopped"></span>';
|
||||
? `<span class="profile-opt-badge running" title="${esc(t('profile_gateway_running'))}"></span>`
|
||||
: `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`;
|
||||
const isActive = p.name === data.active;
|
||||
const activeBadge = isActive ? '<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">ACTIVE</span>' : '';
|
||||
const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : '';
|
||||
card.innerHTML = `
|
||||
<div class="profile-card-header">
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div>
|
||||
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : '<div class="profile-card-meta">No configuration</div>'}
|
||||
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : `<div class="profile-card-meta">${esc(t('profile_no_configuration'))}</div>`}
|
||||
</div>
|
||||
<div class="profile-card-actions">
|
||||
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''}
|
||||
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="${esc(t('profile_switch_title'))}">${esc(t('profile_use'))}</button>` : ''}
|
||||
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">${li('x',12)}</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -859,7 +859,7 @@ function renderProfileDropdown(data) {
|
||||
// Divider + Manage link
|
||||
const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div);
|
||||
const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage';
|
||||
mgmt.innerHTML = `${li('settings',12)} Manage profiles`;
|
||||
mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`;
|
||||
mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); };
|
||||
dd.appendChild(mgmt);
|
||||
}
|
||||
@@ -876,7 +876,7 @@ function toggleProfileDropdown() {
|
||||
_positionProfileDropdown();
|
||||
const chip=$('profileChip');
|
||||
if(chip) chip.classList.add('active');
|
||||
}).catch(e => { showToast('Failed to load profiles'); });
|
||||
}).catch(e => { showToast(t('profiles_load_failed')); });
|
||||
}
|
||||
|
||||
function closeProfileDropdown() {
|
||||
@@ -894,7 +894,7 @@ window.addEventListener('resize',()=>{
|
||||
});
|
||||
|
||||
async function switchToProfile(name) {
|
||||
if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; }
|
||||
if (S.busy) { showToast(t('profiles_busy_switch')); return; }
|
||||
|
||||
// Determine whether the current session has any messages.
|
||||
// A session with messages is "in progress" and belongs to the current profile —
|
||||
@@ -948,12 +948,12 @@ async function switchToProfile(name) {
|
||||
// Start a new session for the new profile so nothing gets cross-tagged.
|
||||
await newSession(false);
|
||||
await renderSessionList();
|
||||
showToast('Switched to profile: ' + name + ' — new conversation started');
|
||||
showToast(t('profile_switched_new_conversation', name));
|
||||
} else {
|
||||
// No messages yet — just refresh the list and topbar in place
|
||||
await renderSessionList();
|
||||
syncTopbar();
|
||||
showToast('Switched to profile: ' + name);
|
||||
showToast(t('profile_switched', name));
|
||||
}
|
||||
|
||||
// ── Sidebar panels ─────────────────────────────────────────────────────
|
||||
@@ -963,7 +963,7 @@ async function switchToProfile(name) {
|
||||
if (_currentPanel === 'profiles') await loadProfilesPanel();
|
||||
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
|
||||
|
||||
} catch (e) { showToast('Switch failed: ' + e.message); }
|
||||
} catch (e) { showToast(t('switch_failed') + e.message); }
|
||||
}
|
||||
|
||||
function toggleProfileForm() {
|
||||
@@ -985,13 +985,13 @@ async function submitProfileCreate() {
|
||||
const name = ($('profileFormName').value || '').trim().toLowerCase();
|
||||
const cloneConfig = $('profileFormClone').checked;
|
||||
const errEl = $('profileFormError');
|
||||
if (!name) { errEl.textContent = 'Name is required'; errEl.style.display = ''; return; }
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = 'Lowercase letters, numbers, hyphens, underscores only'; errEl.style.display = ''; return; }
|
||||
if (!name) { errEl.textContent = t('name_required'); errEl.style.display = ''; return; }
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = t('profile_name_rule'); errEl.style.display = ''; return; }
|
||||
try {
|
||||
const baseUrl = (($('profileFormBaseUrl') && $('profileFormBaseUrl').value) || '').trim();
|
||||
const apiKey = (($('profileFormApiKey') && $('profileFormApiKey').value) || '').trim();
|
||||
if (baseUrl && !/^https?:\/\//.test(baseUrl)) {
|
||||
errEl.textContent = 'Base URL must start with http:// or https://'; errEl.style.display = ''; return;
|
||||
errEl.textContent = t('profile_base_url_rule'); errEl.style.display = ''; return;
|
||||
}
|
||||
const payload = { name, clone_config: cloneConfig };
|
||||
if (baseUrl) payload.base_url = baseUrl;
|
||||
@@ -999,18 +999,18 @@ async function submitProfileCreate() {
|
||||
await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) });
|
||||
toggleProfileForm();
|
||||
await loadProfilesPanel();
|
||||
showToast('Profile created: ' + name);
|
||||
} catch (e) { errEl.textContent = e.message || 'Create failed'; errEl.style.display = ''; }
|
||||
showToast(t('profile_created', name));
|
||||
} catch (e) { errEl.textContent = e.message || 'Create failed'; errEl.style.display = ''; }
|
||||
}
|
||||
|
||||
async function deleteProfile(name) {
|
||||
const _delProf=await showConfirmDialog({title:`Delete profile "${name}"?`,message:'This removes all config, skills, memory, and sessions for this profile.',confirmLabel:'Delete',danger:true,focusCancel:true});
|
||||
const _delProf=await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
|
||||
if(!_delProf) return;
|
||||
try {
|
||||
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
|
||||
await loadProfilesPanel();
|
||||
showToast('Profile deleted: ' + name);
|
||||
} catch (e) { showToast('Delete failed: ' + e.message); }
|
||||
showToast(t('profile_deleted', name));
|
||||
} catch (e) { showToast(t('delete_failed') + e.message); }
|
||||
}
|
||||
|
||||
// ── Memory panel ──
|
||||
@@ -1023,23 +1023,23 @@ async function loadMemory(force) {
|
||||
panel.innerHTML = `
|
||||
<div class="memory-section">
|
||||
<div class="memory-section-title">
|
||||
<span style="display:inline-flex;align-items:center;gap:6px">${li('brain',14)} My Notes</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:6px">${li('brain',14)} ${esc(t('my_notes'))}</span>
|
||||
<span class="memory-mtime">${fmtTime(data.memory_mtime)}</span>
|
||||
</div>
|
||||
${data.memory
|
||||
? `<div class="memory-content preview-md">${renderMd(data.memory)}</div>`
|
||||
: '<div class="memory-empty">No notes yet.</div>'}
|
||||
: `<div class="memory-empty">${esc(t('no_notes_yet'))}</div>`}
|
||||
</div>
|
||||
<div class="memory-section">
|
||||
<div class="memory-section-title">
|
||||
<span style="display:inline-flex;align-items:center;gap:6px">${li('user',14)} User Profile</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:6px">${li('user',14)} ${esc(t('user_profile'))}</span>
|
||||
<span class="memory-mtime">${fmtTime(data.user_mtime)}</span>
|
||||
</div>
|
||||
${data.user
|
||||
? `<div class="memory-content preview-md">${renderMd(data.user)}</div>`
|
||||
: '<div class="memory-empty">No profile yet.</div>'}
|
||||
: `<div class="memory-empty">${esc(t('no_profile_yet'))}</div>`}
|
||||
</div>`;
|
||||
} catch(e) { panel.innerHTML = `<div style="color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
|
||||
} catch(e) { panel.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
@@ -1074,12 +1074,12 @@ function switchSettingsSection(name){
|
||||
function _syncHermesPanelSessionActions(){
|
||||
const hasSession=!!S.session;
|
||||
const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0;
|
||||
const title=hasSession?(S.session.title||'Untitled'):'No active conversation selected.';
|
||||
const title=hasSession?(S.session.title||t('untitled')):t('active_conversation_none');
|
||||
const meta=$('hermesSessionMeta');
|
||||
if(meta){
|
||||
meta.textContent=hasSession
|
||||
? `${title} · ${visibleMessages} message${visibleMessages===1?'':'s'}`
|
||||
: 'No active conversation selected.';
|
||||
? t('active_conversation_meta', title, visibleMessages)
|
||||
: t('active_conversation_none');
|
||||
}
|
||||
const setDisabled=(id,disabled)=>{
|
||||
const el=$(id);
|
||||
@@ -1148,10 +1148,10 @@ function _showSettingsUnsavedBar(){
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'settingsUnsavedBar';
|
||||
bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;';
|
||||
bar.innerHTML = '<span style="color:var(--text)">You have unsaved changes.</span>'
|
||||
bar.innerHTML = `<span style="color:var(--text)">${esc(t('settings_unsaved_changes'))}</span>`
|
||||
+ '<span style="display:flex;gap:8px">'
|
||||
+ '<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">Discard</button>'
|
||||
+ '<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">Save</button>'
|
||||
+ `<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">${esc(t('discard'))}</button>`
|
||||
+ `<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">${esc(t('save'))}</button>`
|
||||
+ '</span>';
|
||||
const body = document.querySelector('.settings-main') || document.querySelector('.settings-body') || document.querySelector('.settings-panel');
|
||||
if(body) body.prepend(bar);
|
||||
@@ -1171,8 +1171,14 @@ function _markSettingsDirty(){
|
||||
async function loadSettingsPanel(){
|
||||
try{
|
||||
const settings=await api('/api/settings');
|
||||
// Apply server-persisted locale immediately (overrides localStorage boot default)
|
||||
if(settings.language && typeof setLocale==='function') setLocale(settings.language);
|
||||
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
|
||||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
|
||||
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
|
||||
// Keep settings modal and current page strings in sync with the resolved locale.
|
||||
if(typeof setLocale==='function'){
|
||||
setLocale(resolvedLanguage);
|
||||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||||
}
|
||||
// Populate model dropdown from /api/models
|
||||
const modelSel=$('settingsModel');
|
||||
if(modelSel){
|
||||
@@ -1210,7 +1216,7 @@ async function loadSettingsPanel(){
|
||||
langSel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
langSel.value=settings.language||'en';
|
||||
langSel.value=resolvedLanguage;
|
||||
langSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||
}
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
@@ -1287,7 +1293,7 @@ async function saveSettings(andClose){
|
||||
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
||||
_hideSettingsPanel();
|
||||
return;
|
||||
}catch(e){showToast('Save failed: '+e.message);return;}
|
||||
}catch(e){showToast(t('settings_save_failed')+e.message);return;}
|
||||
}
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
@@ -1319,23 +1325,23 @@ async function signOut(){
|
||||
await api('/api/auth/logout',{method:'POST',body:'{}'});
|
||||
window.location.href='/login';
|
||||
}catch(e){
|
||||
showToast('Sign out failed: '+e.message);
|
||||
showToast(t('sign_out_failed')+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAuth(){
|
||||
const _disAuth=await showConfirmDialog({title:'Disable password protection',message:'Anyone will be able to access this instance.',confirmLabel:'Disable',danger:true,focusCancel:true});
|
||||
const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true});
|
||||
if(!_disAuth) return;
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
|
||||
showToast('Auth disabled — password protection removed');
|
||||
showToast(t('auth_disabled'));
|
||||
// Hide both auth buttons since auth is now off
|
||||
const disableBtn=$('btnDisableAuth');
|
||||
if(disableBtn) disableBtn.style.display='none';
|
||||
const signOutBtn=$('btnSignOut');
|
||||
if(signOutBtn) signOutBtn.style.display='none';
|
||||
}catch(e){
|
||||
showToast('Failed to disable auth: '+e.message);
|
||||
showToast(t('disable_auth_failed')+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,7 +1365,7 @@ function startCronPolling(){
|
||||
const data=await api(`/api/crons/recent?since=${_cronPollSince}`);
|
||||
if(data.completions&&data.completions.length>0){
|
||||
for(const c of data.completions){
|
||||
showToast(`Cron "${c.name}" ${c.status==='error'?'failed':'completed'}`,4000);
|
||||
showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000);
|
||||
_cronPollSince=Math.max(_cronPollSince,c.completed_at);
|
||||
}
|
||||
_cronUnreadCount+=data.completions.length;
|
||||
@@ -1404,7 +1410,7 @@ const _backgroundErrors=[]; // {session_id, title, message, ts}
|
||||
function trackBackgroundError(sessionId, title, message){
|
||||
// Only track if user is NOT currently viewing this session
|
||||
if(S.session&&S.session.session_id===sessionId) return;
|
||||
_backgroundErrors.push({session_id:sessionId, title:title||'Untitled', message, ts:Date.now()});
|
||||
_backgroundErrors.push({session_id:sessionId, title:title||t('untitled'), message, ts:Date.now()});
|
||||
showErrorBanner();
|
||||
}
|
||||
|
||||
@@ -1421,7 +1427,8 @@ function showErrorBanner(){
|
||||
const latest=_backgroundErrors[0]; // FIFO: show oldest (first) error
|
||||
if(!latest){banner.style.display='none';return;}
|
||||
const count=_backgroundErrors.length;
|
||||
banner.innerHTML=`<span>\u26a0 ${count>1?count+' sessions have':'"'+esc(latest.title)+'" has'} encountered an error</span><div style="display:flex;gap:6px;flex-shrink:0"><button class="reconnect-btn" onclick="navigateToErrorSession()">View</button><button class="reconnect-btn" onclick="dismissErrorBanner()">Dismiss</button></div>`;
|
||||
const msg=count>1?t('bg_error_multi',count):t('bg_error_single',latest.title);
|
||||
banner.innerHTML=`<span>\u26a0 ${esc(msg)}</span><div style="display:flex;gap:6px;flex-shrink:0"><button class="reconnect-btn" onclick="navigateToErrorSession()">${esc(t('view'))}</button><button class="reconnect-btn" onclick="dismissErrorBanner()">${esc(t('dismiss'))}</button></div>`;
|
||||
banner.style.display='';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user