// 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 = ``;
}
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);
}