// bg-tasks.js — 하트비트 태스크 카드 + 백그라운드 태스크 패널(생성/일시정지/채팅/삭제). // extracted from app.js (v4.3.19-3 split). let hbJobs = []; let hbConfig = { enabled: false, intervalMinutes: 30, activeHoursStart: 8, activeHoursEnd: 22 }; let editingTaskId = null; async function loadTasksFromServer() { try { const data = await api('/api/tasks'); if (data.success) { hbJobs = data.jobs || []; hbConfig = data.config || hbConfig; renderHeartbeatPage(); } } catch (err) { console.error('[HB] Load failed:', err); } } function renderHeartbeatPage() { // Update topbar const dot = document.getElementById('hb-dot'); const label = document.getElementById('hb-label'); const meta = document.getElementById('hb-meta'); const btn = document.getElementById('hb-toggle-btn'); if (dot) dot.className = 'hb-status-dot ' + (hbConfig.enabled ? 'active' : ''); if (label) label.textContent = hbConfig.enabled ? '하트비트 ON' : '하트비트 OFF'; if (meta) meta.textContent = `Every ${hbConfig.intervalMinutes}m \u00b7 ${hbConfig.activeHoursStart}:00\u2013${hbConfig.activeHoursEnd}:00`; if (btn) { btn.textContent = hbConfig.enabled ? 'Pause' : 'Enable'; btn.className = 'hb-btn' + (hbConfig.enabled ? ' danger' : ' primary'); } // Split jobs into sections const running = hbJobs.filter(j => j.status === 'running'); const queued = hbJobs.filter(j => j.enabled && j.status !== 'running' && j.status !== 'completed' && j.status !== 'paused').sort((a,b) => a.priority - b.priority); const paused = hbJobs.filter(j => j.status === 'paused'); const completed = hbJobs.filter(j => j.status === 'completed' || j.type === 'one-shot' && j.lastRun).slice(0, 15); const empty = document.getElementById('hb-empty'); if (empty) empty.style.display = hbJobs.length === 0 ? '' : 'none'; renderHBSection('hb-section-running', 'hb-running-cards', running, 'running'); renderHBSection('hb-section-queued', 'hb-queued-cards', [...queued, ...paused], 'queued'); renderHBSection('hb-section-scheduled', 'hb-scheduled-cards', queued.filter(j => j.schedule), 'scheduled'); renderHBSection('hb-section-completed', 'hb-completed-cards', completed, 'completed'); } function renderHBSection(sectionId, cardsId, jobs, sectionType) { const section = document.getElementById(sectionId); const container = document.getElementById(cardsId); if (!section || !container) return; section.style.display = jobs.length > 0 ? '' : 'none'; container.innerHTML = jobs.map(j => renderTaskCard(j, sectionType)).join(''); } function renderTaskCard(job, sectionType) { const statusClass = job.status === 'running' ? 'running' : job.status === 'completed' ? 'completed' : job.status === 'paused' ? 'paused' : ''; const scheduleText = job.schedule ? cronToHuman(job.schedule) : (job.runAt ? 'One-shot: ' + new Date(job.runAt).toLocaleString() : 'Manual'); const lastRunText = job.lastRun ? timeAgo(new Date(job.lastRun).getTime()) : 'Never'; const nextRunText = job.nextRun ? new Date(job.nextRun).toLocaleTimeString('en-US', {hour:'2-digit',minute:'2-digit'}) : '-'; const resultSnippet = job.lastResult ? (job.lastResult.length > 60 ? job.lastResult.slice(0,60)+'...' : job.lastResult) : ''; const isOk = /^\s*HEARTBEAT_OK\s*$/i.test(job.lastResult || ''); return `
${sectionType === 'completed' ? '' : '\u2261'}
${escHtml(job.name)}
${scheduleText} Next: ${nextRunText} Last: ${lastRunText} ${resultSnippet && !isOk ? `${escHtml(resultSnippet)}` : ''} ${isOk ? '\u2713 OK' : ''}
${job.status !== 'running' ? `` : 'Running...'} ${job.sessionId ? `` : ''}
`; } function cronToHuman(expr) { if (!expr) return 'Manual'; const p = expr.split(/\s+/); if (p.length !== 5) return expr; const [min, hr, dom, , dow] = p; if (min.startsWith('*/')) return `Every ${min.slice(2)}m`; if (hr === '*') return `Every minute`; if (dom === '*' && dow === '*') return `Daily at ${hr}:${min.padStart(2,'0')}`; const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; if (dow !== '*') return `${days[Number(dow)] || dow} at ${hr}:${min.padStart(2,'0')}`; return expr; } async function toggleHeartbeatEnabled() { try { const data = await api('/api/tasks/config', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ enabled: !hbConfig.enabled }) }); if (data.success) { hbConfig = data.config; renderHeartbeatPage(); } } catch (err) { console.error('[HB] Toggle failed:', err); } } async function runTaskNow(id) { try { await api(`/api/tasks/${id}/run`, { method: 'POST' }); } catch {} } async function deleteTask(id) { if (!confirm('Delete this task?')) return; try { await api(`/api/tasks/${id}`, { method: 'DELETE' }); hbJobs = hbJobs.filter(j => j.id !== id); renderHeartbeatPage(); } catch {} } async function toggleTaskPause(id) { const job = hbJobs.find(j => j.id === id); if (!job) return; const newStatus = job.status === 'paused' ? 'scheduled' : 'paused'; try { const data = await api(`/api/tasks/${id}`, { method: 'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ status: newStatus, enabled: newStatus !== 'paused' }) }); if (data.success) { Object.assign(job, data.job); renderHeartbeatPage(); } } catch {} } function viewTaskSession(sessionId) { // Switch to chat mode and open the automated session const session = chatSessions.find(s => s.id === sessionId); if (session) { setMode('chat'); openSession(sessionId); } } function openTaskModal(taskId) { editingTaskId = taskId || null; const job = taskId ? hbJobs.find(j => j.id === taskId) : null; const modal = document.getElementById('task-modal'); modal.style.display = ''; modal.innerHTML = `

${job ? 'Edit Task' : 'Add Task'}

Examples: */30 * * * * (every 30m), 0 9 * * * (daily 9am), 0 9 * * 1 (Mon 9am)
`; } function editTask(id) { openTaskModal(id); } function closeTaskModal() { document.getElementById('task-modal').style.display = 'none'; editingTaskId = null; } async function saveTask() { const name = document.getElementById('tm-name').value.trim(); const prompt = document.getElementById('tm-prompt').value.trim(); const type = document.getElementById('tm-type').value; const schedule = document.getElementById('tm-schedule').value.trim(); if (!name || !prompt) { alert('Name and prompt are required'); return; } try { if (editingTaskId) { const data = await api(`/api/tasks/${editingTaskId}`, { method: 'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, prompt, type, schedule }) }); if (data.success) { const idx = hbJobs.findIndex(j => j.id === editingTaskId); if (idx >= 0) hbJobs[idx] = data.job; } } else { const data = await api('/api/tasks', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, prompt, type, schedule }) }); if (data.success) hbJobs.push(data.job); } closeTaskModal(); renderHeartbeatPage(); } catch (err) { alert('Failed to save: ' + err.message); } } function updateHbNotifyLabel() { const on = document.getElementById('settings-hb-notify')?.checked; const label = document.getElementById('settings-hb-notify-label'); if (label) label.textContent = on ? '알림 받기' : '알림 끄기'; } async function saveHbNotifyConfig() { const notifyEnabled = !!document.getElementById('settings-hb-notify')?.checked; try { const data = await api('/api/heartbeat/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notifyEnabled }), }); if (data?.success) { updateHbNotifyLabel(); addProcessEntry('final', notifyEnabled ? '허트비트 알림 켜짐' : '허트비트 알림 꺼짐'); } } catch {} } let bgtTasks = []; // all task records from server let bgtOpenTaskId = null; // currently open panel task id let bgtHeartbeatConfig = { enabled: true, interval_minutes: 10 }; function openNewBgTaskModal() { const modal = document.getElementById('bgt-new-task-modal'); if (modal) { modal.style.display = 'flex'; document.getElementById('bgt-new-title').focus(); } } function closeNewBgTaskModal() { const modal = document.getElementById('bgt-new-task-modal'); if (modal) modal.style.display = 'none'; document.getElementById('bgt-new-title').value = ''; document.getElementById('bgt-new-prompt').value = ''; } async function submitNewBgTask() { const title = document.getElementById('bgt-new-title').value.trim(); const prompt = document.getElementById('bgt-new-prompt').value.trim(); if (!title || !prompt) { alert('제목과 작업 내용을 입력해주세요.'); return; } const btn = document.getElementById('bgt-new-submit-btn'); btn.textContent = '시작 중...'; btn.disabled = true; try { const data = await api('/api/bg-tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, prompt }) }); if (data.success) { closeNewBgTaskModal(); await refreshBgTasks(); } else { alert('태스크 생성 실패: ' + (data.error || 'unknown')); } } catch (err) { alert('오류: ' + err.message); } finally { btn.textContent = '▶ 시작'; btn.disabled = false; } } function openBgtHeartbeatModal() { const modal = document.getElementById('bgt-heartbeat-modal'); const enabledEl = document.getElementById('bgt-hb-enabled'); const intervalEl = document.getElementById('bgt-hb-interval'); if (!modal || !enabledEl || !intervalEl) return; enabledEl.checked = !!bgtHeartbeatConfig.enabled; intervalEl.value = String(Math.max(1, Number(bgtHeartbeatConfig.interval_minutes) || 10)); modal.style.display = 'flex'; } function closeBgtHeartbeatModal() { const modal = document.getElementById('bgt-heartbeat-modal'); if (modal) modal.style.display = 'none'; } async function saveBgtHeartbeatConfig() { const enabledEl = document.getElementById('bgt-hb-enabled'); const intervalEl = document.getElementById('bgt-hb-interval'); if (!enabledEl || !intervalEl) return; const enabled = !!enabledEl.checked; const interval = Math.max(1, Math.min(1440, Number(intervalEl.value) || 10)); try { const data = await api('/api/bg-tasks/heartbeat/config', { method: 'PUT', body: JSON.stringify({ enabled, interval_minutes: interval }), }); if (data?.success && data.config) { bgtHeartbeatConfig = data.config; renderBgTasks(); closeBgtHeartbeatModal(); bgtToast('Heartbeat updated', enabled ? `Every ${interval} minute(s)` : 'Task heartbeat disabled'); return; } bgtToast('Heartbeat update failed', String(data?.error || 'Unknown error')); } catch (err) { bgtToast('Heartbeat update failed', String(err?.message || err)); } } document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; const modal = document.getElementById('bgt-heartbeat-modal'); if (modal && modal.style.display === 'flex') closeBgtHeartbeatModal(); }); document.addEventListener('click', (e) => { const modal = document.getElementById('bgt-heartbeat-modal'); if (!modal || modal.style.display !== 'flex') return; if (e.target === modal) closeBgtHeartbeatModal(); }); // --- Columns config --------------------------------------------------------- const BGT_COLUMNS = [ { key: 'running', label: '● In Progress', color: '#0d4faf', bg: '#eaf2ff' }, { key: 'queued', label: '◷ Queued', color: '#7c4d00', bg: '#fff8e1' }, { key: 'paused', label: '‖ Paused', color: '#555', bg: '#f5f5f5' }, { key: 'stalled', label: '▲ Stalled', color: '#9c1a1a', bg: '#fff0f0' }, { key: 'needs_assistance', label: '⚠️ Needs You', color: '#6d2d9e', bg: '#f5eeff' }, { key: 'complete', label: '✓ Complete', color: '#1a6e35', bg: '#efffea' }, { key: 'failed', label: '✕ Failed', color: '#9c1a1a', bg: '#fff0f0' }, ]; const STATUS_ICON = { running: '●', queued: '◷', paused: '‖', stalled: '▲', needs_assistance: '⚠️', complete: '✓', failed: '✕', }; // --- Fetch & Render ---------------------------------------------------------- async function refreshBgTasks() { try { const data = await api('/api/bg-tasks'); if (data.success) { bgtTasks = data.tasks || []; bgtHeartbeatConfig = data.heartbeatConfig || bgtHeartbeatConfig; } } catch (err) { console.error('[BGT] refreshBgTasks error:', err); } renderBgTasks(); } function renderBgTasks() { const board = document.getElementById('bgt-board'); if (!board) return; // Update count badge const countEl = document.getElementById('bgt-count'); if (countEl) { const active = bgtTasks.filter(t => t.status !== 'complete' && t.status !== 'failed').length; countEl.textContent = `${active} active`; } // Update heartbeat label const hbLabel = document.getElementById('bgt-heartbeat-label'); if (hbLabel) { const mins = bgtHeartbeatConfig.interval_minutes || 10; const on = bgtHeartbeatConfig.enabled; hbLabel.textContent = on ? `• heartbeat: ${mins}min` : '• heartbeat: off'; hbLabel.style.color = on ? 'var(--brand)' : 'var(--muted)'; } // Only render non-empty columns const byStatus = {}; for (const col of BGT_COLUMNS) byStatus[col.key] = []; for (const t of bgtTasks) { if (byStatus[t.status]) byStatus[t.status].push(t); else if (byStatus['stalled']) byStatus['stalled'].push(t); } board.innerHTML = ''; for (const col of BGT_COLUMNS) { const tasks = byStatus[col.key]; if (tasks.length === 0 && (col.key === 'complete' || col.key === 'failed' || col.key === 'stalled' || col.key === 'needs_assistance')) continue; const colEl = document.createElement('div'); colEl.style.cssText = 'min-width:240px;max-width:280px;flex:0 0 auto;display:flex;flex-direction:column;gap:0;'; colEl.innerHTML = `
${col.label} ${tasks.length}
${tasks.map(t => bgtCardHTML(t, col)).join('')}
`; board.appendChild(colEl); if (tasks.length === 0) { // Empty placeholder colEl.querySelector('.bgt-col-cards').innerHTML = `
No ${col.label.split(' ').slice(1).join(' ').toLowerCase()} tasks
`; } } // If no tasks at all if (bgtTasks.length === 0) { board.innerHTML = `
작업
No background tasks yet
Ask HomeClaw to do something in the background and it'll appear here.
`; } } function bgtCardHTML(t, col) { const mins = Math.round((Date.now() - (t.lastProgressAt || t.startedAt)) / 60000); const timeAgo = mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : `${Math.round(mins/60)}h ago`; const stepsDone = (t.plan || []).filter(s => s.status === 'done' || s.status === 'skipped').length; const totalSteps = (t.plan || []).length; const pct = totalSteps > 0 ? Math.round(stepsDone / totalSteps * 100) : 0; const currentStep = (t.plan || [])[t.currentStepIndex || 0]; const channel = t.channel === 'telegram' ? 'Telegram' : 'Web UI'; return `
${escHtml(t.title || 'Untitled Task')}
${STATUS_ICON[t.status] || '?'}
${currentStep ? `
Step ${(t.currentStepIndex||0)+1}/${totalSteps}: ${escHtml(currentStep.description.slice(0,60))}${currentStep.description.length>60?'…':''}
` : ''} ${totalSteps > 0 ? `
` : ''}
${channel} ${timeAgo} ${t.status === 'needs_assistance' ? `Needs you` : ''} ${t.status === 'paused' ? `${t.pauseReason || 'paused'}` : ''}
`; } // --- Side Panel -------------------------------------------------------------- async function openBgtPanel(taskId) { bgtOpenTaskId = taskId; const panel = document.getElementById('bgt-panel'); if (panel) panel.style.display = 'flex'; // Clear chat input when switching tasks const chatInput = document.getElementById('bgt-chat-input'); if (chatInput) { chatInput.value = ''; chatInput.disabled = false; chatInput.style.opacity = ''; } await bgtRefreshOpenPanel(); // Highlight the card renderBgTasks(); } function closeBgtPanel() { bgtOpenTaskId = null; const panel = document.getElementById('bgt-panel'); if (panel) panel.style.display = 'none'; renderBgTasks(); } async function bgtRefreshOpenPanel() { if (!bgtOpenTaskId) return; let task; try { const data = await api(`/api/bg-tasks/${bgtOpenTaskId}`); if (data.success) task = data.task; } catch {} if (!task) { task = bgtTasks.find(t => t.id === bgtOpenTaskId); } if (!task) return; const titleEl = document.getElementById('bgt-panel-title'); if (titleEl) titleEl.textContent = task.title || '작업 상세'; const pauseBtn = document.getElementById('bgt-panel-pause'); if (pauseBtn) { if (task.status === 'running') { pauseBtn.textContent = '일시정지'; pauseBtn.style.display = ''; } else if (task.status === 'paused' || task.status === 'queued') { pauseBtn.textContent = '재개'; pauseBtn.style.display = ''; } else { pauseBtn.style.display = 'none'; } } const body = document.getElementById('bgt-panel-body'); if (!body) return; const plan = task.plan || []; const journal = task.journal || []; const stepsDone = plan.filter(s => s.status === 'done' || s.status === 'skipped').length; const lastTool = task.lastToolCall || '—'; const lastToolAt = task.lastToolCallAt ? new Date(task.lastToolCallAt).toLocaleTimeString() : '—'; const channel = task.channel === 'telegram' ? 'Telegram' : 'Web UI'; const startedAt = task.startedAt ? new Date(task.startedAt).toLocaleString() : '—'; const statusColor = { running:'#0d4faf', queued:'#7c4d00', paused:'#555', stalled:'#9c1a1a', needs_assistance:'#6d2d9e', complete:'#1a6e35', failed:'#9c1a1a' }; const sc = statusColor[task.status] || '#555'; // Build plan steps HTML const stepsHTML = plan.map((step, i) => { const icon = step.status === 'done' ? '✓' : step.status === 'failed' ? '✕' : step.status === 'skipped' ? '↷' : i === (task.currentStepIndex || 0) && task.status === 'running' ? '▶' : '○'; const isCurrent = i === (task.currentStepIndex || 0) && task.status !== 'complete'; return `
${icon}
${escHtml(step.description)}
${step.notes ? `
${escHtml(step.notes.slice(0,120))}
` : ''}
`; }).join(''); // Build journal HTML (last 30 entries) const recentJournal = journal.slice(-30).reverse(); const journalHTML = recentJournal.map(entry => { const time = new Date(entry.t).toLocaleTimeString(); const typeColor = { tool_call:'#0d4faf', tool_result:'#1a6e35', error:'#9c1a1a', plan_mutation:'#6d2d9e', status_push:'#555', pause:'#7c4d00', resume:'#7c4d00', advisor_decision:'#555', heartbeat:'var(--brand)' }; const tc = typeColor[entry.type] || '#888'; return `
${time} ${entry.type} ${escHtml(entry.content)}
`; }).join(''); // Build assistance block outside template literal to avoid backtick nesting issues let assistanceHTML = ''; const taskStatusNorm = String(task.status || '').trim().toLowerCase(); console.log('[BGT panel] task.status =', JSON.stringify(task.status), 'norm =', taskStatusNorm); if (taskStatusNorm === 'needs_assistance' || taskStatusNorm === 'paused' || taskStatusNorm === 'stalled' || taskStatusNorm === 'failed') { const lastPause = [...journal].reverse().find(e => e.type === 'pause' || e.type === 'error'); const lastStatusPush = [...journal].reverse().find(e => e.type === 'status_push' && e.content); const pauseMsg = escHtml((lastPause?.content || lastStatusPush?.content || 'Task paused and waiting for input.').replace(/^Task paused for assistance:\s*/i, '')); const pauseDetail = lastPause?.detail ? escHtml(lastPause.detail.slice(0, 300)) : ''; const tid = escHtml(task.id); assistanceHTML = '
' + '
⚠️ Task needs your input
' + '
' + pauseMsg + '
' + (pauseDetail ? '
' + pauseDetail + '
' : '') + '
' + '
' + '
💬 Reply to agent
' + '
' + '' + '' + '
'; } body.innerHTML = `
${STATUS_ICON[task.status]} ${task.status.replace(/_/g,' ')} ${channel} Started ${startedAt}
${task.finalSummary ? `
✓ Summary
${escHtml(task.finalSummary)}
` : ''} ${assistanceHTML}
Progress
${stepsDone}/${plan.length}
steps done
Last Tool
${escHtml(lastTool.slice(0,30))}
${lastToolAt}
${plan.length > 0 ? `
Execution Plan
${stepsHTML}
` : ''}
Process Log (latest first)
${journalHTML || '
No entries yet.
'}
${(task.status === 'complete' || task.status === 'failed') ? ` ` : ''} `; // Wire up the reply send button via JS (avoids inline onclick escaping issues) const sendBtn = document.getElementById('task-reply-send'); if (sendBtn) { sendBtn.addEventListener('click', () => bgtSendReply(sendBtn.dataset.taskid)); } // Also allow Enter (without Shift) in the textarea to send const replyInput = document.getElementById('task-reply-input'); if (replyInput) { replyInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); bgtSendReply(sendBtn ? sendBtn.dataset.taskid : bgtOpenTaskId); } }); } } // --- Actions ----------------------------------------------------------------- async function bgtPauseResume() { if (!bgtOpenTaskId) return; const task = bgtTasks.find(t => t.id === bgtOpenTaskId); if (!task) return; if (task.status === 'running') { // Pause try { await api(`/api/bg-tasks/${bgtOpenTaskId}/pause`, { method: 'POST' }); } catch {} } else if (task.status === 'paused' || task.status === 'queued') { // Resume try { await api(`/api/bg-tasks/${bgtOpenTaskId}/resume`, { method: 'POST' }); } catch {} } await refreshBgTasks(); await bgtRefreshOpenPanel(); } async function bgtSendReply(taskId) { const input = document.getElementById('task-reply-input'); if (!input) return; const message = input.value.trim(); if (!message) return; input.value = ''; input.disabled = true; try { const result = await api(`/api/bg-tasks/${taskId}/message`, { method: 'POST', body: JSON.stringify({ message }) }); if (result.success) { // Refresh the panel after a short delay to show the journal update setTimeout(() => bgtRefreshOpenPanel(), 800); } else { alert('Failed to send: ' + (result.error || 'unknown error')); } } catch (e) { alert('Error sending message: ' + e.message); } finally { input.disabled = false; input.focus(); } } async function bgtChatSend() { const input = document.getElementById('bgt-chat-input'); if (!input || !bgtOpenTaskId) return; const message = input.value.trim(); if (!message) return; input.value = ''; input.disabled = true; input.style.opacity = '0.5'; try { const result = await api(`/api/bg-tasks/${bgtOpenTaskId}/message`, { method: 'POST', body: JSON.stringify({ message }) }); if (result.success) { // Show optimistic sent indicator in journal area const body = document.getElementById('bgt-panel-body'); if (body) { const sentDiv = document.createElement('div'); sentDiv.style.cssText = 'background:var(--brand);color:#fff;border-radius:8px;padding:6px 10px;font-size:12px;align-self:flex-end;max-width:80%;word-break:break-word;opacity:0.85'; sentDiv.textContent = '\u2192 ' + message; body.appendChild(sentDiv); body.scrollTop = body.scrollHeight; } // Refresh panel after short delay to show journal update setTimeout(() => bgtRefreshOpenPanel(), 1000); } else { alert('Failed to send: ' + (result.error || 'unknown error')); } } catch (e) { alert('Error: ' + e.message); } finally { input.disabled = false; input.style.opacity = ''; input.focus(); } } async function bgtDeleteTask(taskId, status) { const isActive = status && status !== 'complete' && status !== 'failed'; const prompt = isActive ? 'This task is still active. Remove it from the board anyway?' : 'Remove this task from the board?'; if (!confirm(prompt)) return; try { await api(`/api/bg-tasks/${taskId}`, { method: 'DELETE' }); } catch {} if (bgtOpenTaskId === taskId) closeBgtPanel(); await refreshBgTasks(); } // --- Toast notification ------------------------------------------------------- function bgtToast(title, body) { const toast = document.createElement('div'); toast.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:9999;background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:12px 16px;min-width:240px;max-width:360px;box-shadow:0 4px 24px rgba(0,0,0,0.12);font-family:var(--font)`; toast.innerHTML = `
${escHtml(title)}
${body ? `
${escHtml(body)}
` : ''}`; document.body.appendChild(toast); setTimeout(() => toast.remove(), 6000); }