const BASE_PATH = document.querySelector('meta[name="base-path"]')?.content ?? ''; const API_BASE = window.location.origin + BASE_PATH; // ── Fetch ───────────────────────────────────────────────────────────────────── async function fetchTasks() { const res = await fetch(`${API_BASE}/api/tasks`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } async function fetchTemplates() { const res = await fetch(`${API_BASE}/api/templates`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } // ── Render ──────────────────────────────────────────────────────────────────── function formatDate(iso) { if (!iso) return ''; return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } function createTaskCard(task) { const card = document.createElement('div'); card.className = 'task-card'; card.dataset.taskId = task.id; // Header: name + state badge const header = document.createElement('div'); header.className = 'task-card-header'; const name = document.createElement('span'); name.className = 'task-name'; name.textContent = task.name; const badge = document.createElement('span'); badge.className = 'state-badge'; badge.dataset.state = task.state; badge.textContent = task.state.replace(/_/g, ' '); header.append(name, badge); card.appendChild(header); // Meta: priority + created_at const meta = document.createElement('div'); meta.className = 'task-meta'; if (task.priority) { const prio = document.createElement('span'); prio.textContent = task.priority; meta.appendChild(prio); } if (task.created_at) { const when = document.createElement('span'); when.textContent = formatDate(task.created_at); meta.appendChild(when); } if (meta.children.length) card.appendChild(meta); // Description (truncated via CSS) if (task.description) { const desc = document.createElement('div'); desc.className = 'task-description'; desc.textContent = task.description; card.appendChild(desc); } // Footer: action buttons based on state const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']); if (task.state === 'PENDING' || task.state === 'RUNNING' || task.state === 'READY' || RESTART_STATES.has(task.state)) { const footer = document.createElement('div'); footer.className = 'task-card-footer'; if (task.state === 'PENDING') { const btn = document.createElement('button'); btn.className = 'btn-run'; btn.textContent = 'Run'; btn.addEventListener('click', (e) => { e.stopPropagation(); handleRun(task.id, btn, footer); }); footer.appendChild(btn); } else if (task.state === 'RUNNING') { const btn = document.createElement('button'); btn.className = 'btn-cancel'; btn.textContent = 'Cancel'; btn.addEventListener('click', (e) => { e.stopPropagation(); handleCancel(task.id, btn, footer); }); footer.appendChild(btn); } else if (task.state === 'READY') { const acceptBtn = document.createElement('button'); acceptBtn.className = 'btn-accept'; acceptBtn.textContent = 'Accept'; acceptBtn.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(task.id, acceptBtn, footer); }); const rejectBtn = document.createElement('button'); rejectBtn.className = 'btn-reject'; rejectBtn.textContent = 'Reject'; rejectBtn.addEventListener('click', (e) => { e.stopPropagation(); handleReject(task.id, rejectBtn, footer); }); footer.appendChild(acceptBtn); footer.appendChild(rejectBtn); } else if (RESTART_STATES.has(task.state)) { const btn = document.createElement('button'); btn.className = 'btn-restart'; btn.textContent = 'Restart'; btn.addEventListener('click', (e) => { e.stopPropagation(); handleRestart(task.id, btn, footer); }); footer.appendChild(btn); } card.appendChild(footer); } card.addEventListener('click', () => openTaskPanel(task.id)); return card; } // ── Filter ──────────────────────────────────────────────────────────────────── const HIDE_STATES = new Set(['COMPLETED', 'FAILED']); let showHiddenFold = false; function filterTasks(tasks, hideCompletedFailed = false) { if (!hideCompletedFailed) return tasks; return tasks.filter(t => !HIDE_STATES.has(t.state)); } function getHideCompletedFailed() { const stored = localStorage.getItem('hideCompletedFailed'); return stored === null ? true : stored === 'true'; } function setHideCompletedFailed(val) { localStorage.setItem('hideCompletedFailed', String(val)); } function updateToggleButton() { const btn = document.getElementById('btn-toggle-completed'); if (!btn) return; btn.textContent = getHideCompletedFailed() ? 'Show completed/failed' : 'Hide completed/failed'; } function renderTaskList(tasks) { const container = document.querySelector('.task-list'); if (!tasks || tasks.length === 0) { container.innerHTML = '
No tasks found.
'; return; } const hide = getHideCompletedFailed(); const visible = filterTasks(tasks, hide); const hiddenCount = tasks.length - visible.length; // Replace contents with task cards container.innerHTML = ''; for (const task of visible) { container.appendChild(createTaskCard(task)); } if (hiddenCount > 0) { const info = document.createElement('button'); info.className = 'hidden-tasks-info'; const arrow = showHiddenFold ? '▼' : '▶'; info.textContent = `${arrow} ${hiddenCount} hidden task${hiddenCount === 1 ? '' : 's'}`; info.addEventListener('click', () => { showHiddenFold = !showHiddenFold; renderTaskList(tasks); }); container.appendChild(info); if (showHiddenFold) { const fold = document.createElement('div'); fold.className = 'hidden-tasks-fold'; const hiddenTasks = tasks.filter(t => HIDE_STATES.has(t.state)); for (const task of hiddenTasks) { fold.appendChild(createTaskCard(task)); } container.appendChild(fold); } } } function createTemplateCard(tmpl) { const card = document.createElement('div'); card.className = 'template-card'; const name = document.createElement('div'); name.className = 'template-name'; name.textContent = tmpl.name; card.appendChild(name); if (tmpl.description) { const desc = document.createElement('div'); desc.className = 'template-description'; desc.textContent = tmpl.description; card.appendChild(desc); } if (tmpl.tags && tmpl.tags.length > 0) { const tagsEl = document.createElement('div'); tagsEl.className = 'template-tags'; for (const tag of tmpl.tags) { const chip = document.createElement('span'); chip.className = 'tag-chip'; chip.textContent = tag; tagsEl.appendChild(chip); } card.appendChild(tagsEl); } const footer = document.createElement('div'); footer.className = 'template-card-footer'; const delBtn = document.createElement('button'); delBtn.className = 'btn-danger btn-sm'; delBtn.textContent = 'Delete'; delBtn.addEventListener('click', () => deleteTemplate(tmpl.id)); footer.appendChild(delBtn); card.appendChild(footer); return card; } function renderTemplateList(templates) { const container = document.querySelector('.template-list'); if (!templates || templates.length === 0) { container.innerHTML = '
No templates yet.
'; return; } container.innerHTML = ''; for (const tmpl of templates) { container.appendChild(createTemplateCard(tmpl)); } } // ── Run action ──────────────────────────────────────────────────────────────── async function runTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/run`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } async function handleRun(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Queuing…'; // Remove any previous error const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await runTask(taskId); // Refresh list immediately so state flips to QUEUED const tasks = await fetchTasks(); renderTaskList(tasks); } catch (err) { btn.disabled = false; btn.textContent = 'Run'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed to queue: ${err.message}`; footer.appendChild(errEl); } } // ── Cancel / Restart actions ────────────────────────────────────────────────── async function cancelTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/cancel`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } async function restartTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/run`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } async function handleCancel(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Cancelling…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await cancelTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Cancel'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } async function handleRestart(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Restarting…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await restartTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Restart'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } // ── Accept / Reject actions ──────────────────────────────────────────────────── async function acceptTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/accept`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} throw new Error(msg); } return res.json(); } async function rejectTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: '' }), }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} throw new Error(msg); } return res.json(); } async function handleAccept(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Accepting…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await acceptTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Accept'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } async function handleReject(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Rejecting…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await rejectTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Reject'; const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } // ── Start-next-task ───────────────────────────────────────────────────────────── async function startNextTask() { const res = await fetch(`${API_BASE}/api/scripts/start-next-task`, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} throw new Error(msg); } return res.json(); } async function handleStartNextTask(btn) { btn.disabled = true; btn.textContent = 'Starting…'; try { const result = await startNextTask(); const output = (result.output || '').trim(); btn.textContent = output || 'No task to start'; setTimeout(() => { btn.textContent = 'Start Next'; btn.disabled = false; }, 3000); if (output && output !== 'No task to start.') await poll(); } catch (err) { btn.textContent = `Error: ${err.message}`; setTimeout(() => { btn.textContent = 'Start Next'; btn.disabled = false; }, 3000); } } // ── Delete template ──────────────────────────────────────────────────────────── async function deleteTemplate(id) { if (!window.confirm('Delete this template?')) return; const res = await fetch(`${API_BASE}/api/templates/${id}`, { method: 'DELETE' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} alert(`Failed to delete: ${msg}`); return; } const templates = await fetchTemplates(); renderTemplateList(templates); } // ── Polling ─────────────────────────────────────────────────────────────────── async function poll() { try { const tasks = await fetchTasks(); renderTaskList(tasks); } catch { document.querySelector('.task-list').innerHTML = '
Could not reach server.
'; } } function startPolling(intervalMs = 10_000) { poll(); setInterval(poll, intervalMs); } // ── WebSocket (real-time events) ────────────────────────────────────────────── let ws = null; let activeLogSource = null; function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${protocol}//${window.location.host}${BASE_PATH}/api/ws`; ws = new WebSocket(url); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); handleWsEvent(data); } catch { /* ignore parse errors */ } }; ws.onclose = () => { // Reconnect after 3 seconds. setTimeout(connectWebSocket, 3000); }; ws.onerror = () => { ws.close(); }; } function handleWsEvent(data) { switch (data.type) { case 'task_completed': poll(); // refresh task list break; case 'task_question': showQuestionBanner(data); break; } } // ── Question UI ─────────────────────────────────────────────────────────────── function showQuestionBanner(data) { const taskId = data.task_id; const questionId = data.question_id; const questionData = data.data || {}; const questions = questionData.questions || []; // Find the task card for this task. const card = document.querySelector(`.task-card[data-task-id="${taskId}"]`); if (!card) return; // Remove any existing question banner on this card. const existing = card.querySelector('.question-banner'); if (existing) existing.remove(); const banner = document.createElement('div'); banner.className = 'question-banner'; for (const q of questions) { const qDiv = document.createElement('div'); qDiv.className = 'question-item'; const label = document.createElement('div'); label.className = 'question-text'; label.textContent = q.question || 'The agent has a question'; qDiv.appendChild(label); const options = q.options || []; if (options.length > 0) { const btnGroup = document.createElement('div'); btnGroup.className = 'question-options'; for (const opt of options) { const btn = document.createElement('button'); btn.className = 'btn-question-option'; btn.textContent = opt.label; if (opt.description) btn.title = opt.description; btn.addEventListener('click', () => { submitAnswer(taskId, questionId, opt.label, banner); }); btnGroup.appendChild(btn); } qDiv.appendChild(btnGroup); } // Always show a free-text input as fallback. const inputRow = document.createElement('div'); inputRow.className = 'question-input-row'; const input = document.createElement('input'); input.type = 'text'; input.className = 'question-input'; input.placeholder = 'Type an answer…'; const sendBtn = document.createElement('button'); sendBtn.className = 'btn-question-send'; sendBtn.textContent = 'Send'; sendBtn.addEventListener('click', () => { const val = input.value.trim(); if (val) submitAnswer(taskId, questionId, val, banner); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const val = input.value.trim(); if (val) submitAnswer(taskId, questionId, val, banner); } }); inputRow.append(input, sendBtn); qDiv.appendChild(inputRow); banner.appendChild(qDiv); } card.appendChild(banner); } async function submitAnswer(taskId, questionId, answer, banner) { // Disable all buttons in the banner. banner.querySelectorAll('button').forEach(b => { b.disabled = true; }); banner.querySelector('.question-input')?.setAttribute('disabled', ''); try { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question_id: questionId, answer }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } banner.remove(); } catch (err) { const errEl = document.createElement('div'); errEl.className = 'question-error'; errEl.textContent = `Failed: ${err.message}`; banner.appendChild(errEl); // Re-enable buttons. banner.querySelectorAll('button').forEach(b => { b.disabled = false; }); banner.querySelector('.question-input')?.removeAttribute('disabled'); } } // ── Elaborate (Draft with AI) ───────────────────────────────────────────────── async function elaborateTask(prompt) { const res = await fetch(`${API_BASE}/api/tasks/elaborate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }), }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} throw new Error(msg); } return res.json(); } // ── Validate ────────────────────────────────────────────────────────────────── async function validateTask(payload) { const res = await fetch(`${API_BASE}/api/tasks/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { let msg = res.statusText; try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} throw new Error(msg); } return res.json(); } function buildValidatePayload() { const f = document.getElementById('task-form'); const name = f.querySelector('[name="name"]').value; const instructions = f.querySelector('[name="instructions"]').value; const working_dir = f.querySelector('[name="working_dir"]').value; const model = f.querySelector('[name="model"]').value; const allowedToolsEl = f.querySelector('[name="allowed_tools"]'); const allowed_tools = allowedToolsEl ? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean) : []; return { name, claude: { instructions, working_dir, model, allowed_tools } }; } function renderValidationResult(result) { const container = document.getElementById('validate-result'); container.removeAttribute('hidden'); container.dataset.clarity = result.clarity; let icon; if (result.ready === true) { icon = '✓'; } else if (result.clarity === 'ambiguous') { icon = '⚠'; } else { icon = '✗'; } container.innerHTML = ''; const header = document.createElement('div'); header.className = 'validate-header'; const iconSpan = document.createElement('span'); iconSpan.className = 'validate-icon'; iconSpan.textContent = icon; const summarySpan = document.createElement('span'); summarySpan.textContent = ' ' + (result.summary || ''); header.append(iconSpan, summarySpan); container.appendChild(header); if (result.questions && result.questions.length > 0) { const ul = document.createElement('ul'); ul.className = 'validate-questions'; for (const q of result.questions) { const li = document.createElement('li'); li.className = q.severity === 'blocking' ? 'validate-blocking' : 'validate-minor'; li.textContent = q.text; ul.appendChild(li); } container.appendChild(ul); } if (result.suggestions && result.suggestions.length > 0) { const ul = document.createElement('ul'); ul.className = 'validate-suggestions'; for (const s of result.suggestions) { const li = document.createElement('li'); li.className = 'validate-suggestion'; li.textContent = s; ul.appendChild(li); } container.appendChild(ul); } } // ── Task modal ──────────────────────────────────────────────────────────────── function openTaskModal() { document.getElementById('task-modal').showModal(); } function closeTaskModal() { document.getElementById('task-modal').close(); document.getElementById('task-form').reset(); document.getElementById('elaborate-prompt').value = ''; const validateResult = document.getElementById('validate-result'); validateResult.setAttribute('hidden', ''); validateResult.innerHTML = ''; validateResult.removeAttribute('data-clarity'); } async function createTask(formData) { const body = { name: formData.get('name'), description: '', claude: { model: formData.get('model'), instructions: formData.get('instructions'), working_dir: formData.get('working_dir'), max_budget_usd: parseFloat(formData.get('max_budget_usd')), }, timeout: formData.get('timeout'), priority: formData.get('priority'), tags: [], }; const res = await fetch(`${API_BASE}/api/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } closeTaskModal(); const tasks = await fetchTasks(); renderTaskList(tasks); } // ── Template modal ──────────────────────────────────────────────────────────── function openTemplateModal() { document.getElementById('template-modal').showModal(); } function closeTemplateModal() { document.getElementById('template-modal').close(); document.getElementById('template-form').reset(); } async function saveTemplate(formData) { const splitTrim = val => val.split(',').map(s => s.trim()).filter(Boolean); const body = { name: formData.get('name'), description: formData.get('description'), claude: { model: formData.get('model'), instructions: formData.get('instructions'), working_dir: formData.get('working_dir'), max_budget_usd: parseFloat(formData.get('max_budget_usd')), allowed_tools: splitTrim(formData.get('allowed_tools') || ''), }, timeout: formData.get('timeout'), priority: formData.get('priority'), tags: splitTrim(formData.get('tags') || ''), }; const res = await fetch(`${API_BASE}/api/templates`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } closeTemplateModal(); const templates = await fetchTemplates(); renderTemplateList(templates); } // ── Task side panel ─────────────────────────────────────────────────────────── // Format Go's task.Duration JSON value {"Duration": } to human string. function formatDurationNs(timeout) { const ns = timeout && timeout.Duration; if (!ns) return '—'; const secs = ns / 1e9; if (secs < 60) return `${secs.toFixed(1)}s`; const mins = Math.floor(secs / 60); const remSecs = Math.floor(secs % 60); if (mins < 60) return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`; const hrs = Math.floor(mins / 60); const remMins = mins % 60; return remMins > 0 ? `${hrs}h ${remMins}m` : `${hrs}h`; } function formatDateLong(iso) { if (!iso) return '—'; return new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } function openTaskPanel(taskId) { const panel = document.getElementById('task-panel'); const backdrop = document.getElementById('task-panel-backdrop'); const content = document.getElementById('task-panel-content'); document.getElementById('task-panel-title').textContent = 'Task Details'; content.innerHTML = ''; const loading = document.createElement('div'); loading.className = 'panel-loading'; loading.textContent = 'Loading…'; content.appendChild(loading); backdrop.hidden = false; panel.classList.add('open'); Promise.all([ fetch(`${API_BASE}/api/tasks/${taskId}`).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }), fetch(`${API_BASE}/api/tasks/${taskId}/executions`).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }), ]).then(([task, executions]) => { renderTaskPanel(task, executions); }).catch(err => { content.innerHTML = ''; const errEl = document.createElement('div'); errEl.className = 'panel-fetch-error'; errEl.textContent = `Failed to load: ${err.message}`; content.appendChild(errEl); }); } function closeTaskPanel() { document.getElementById('task-panel').classList.remove('open'); document.getElementById('task-panel-backdrop').hidden = true; } function makeSection(title) { const section = document.createElement('div'); section.className = 'panel-section'; const hdr = document.createElement('div'); hdr.className = 'panel-section-title'; hdr.textContent = title; section.appendChild(hdr); return section; } function makeMetaItem(label, valueText, opts = {}) { const item = document.createElement('div'); item.className = 'meta-item' + (opts.fullWidth ? ' full-width' : ''); const lbl = document.createElement('div'); lbl.className = 'meta-label'; lbl.textContent = label; item.appendChild(lbl); if (opts.badge) { const badge = document.createElement('span'); badge.className = 'state-badge'; badge.dataset.state = valueText; badge.textContent = valueText.replace(/_/g, ' '); item.appendChild(badge); } else if (opts.code) { const pre = document.createElement('pre'); pre.className = 'panel-code'; pre.textContent = valueText; item.appendChild(pre); } else if (opts.tags) { const wrap = document.createElement('div'); if (opts.tags.length > 0) { wrap.className = 'panel-tags'; for (const tag of opts.tags) { const chip = document.createElement('span'); chip.className = 'tag-chip'; chip.textContent = tag; wrap.appendChild(chip); } } else { wrap.className = 'meta-value muted'; wrap.textContent = '—'; } item.appendChild(wrap); } else { const val = document.createElement('div'); val.className = 'meta-value' + (opts.mono ? ' mono' : '') + (opts.muted ? ' muted' : ''); val.textContent = valueText || '—'; item.appendChild(val); } return item; } function renderTaskPanel(task, executions) { document.getElementById('task-panel-title').textContent = task.name; const content = document.getElementById('task-panel-content'); content.innerHTML = ''; // ── Overview ── const overview = makeSection('Overview'); const overviewGrid = document.createElement('div'); overviewGrid.className = 'meta-grid'; overviewGrid.append( makeMetaItem('State', task.state, { badge: true }), makeMetaItem('Priority', task.priority), makeMetaItem('Created', formatDateLong(task.created_at)), makeMetaItem('Updated', formatDateLong(task.updated_at)), makeMetaItem('ID', task.id, { fullWidth: true, mono: true }), ); if (task.parent_task_id) { overviewGrid.append(makeMetaItem('Parent Task', task.parent_task_id, { fullWidth: true, mono: true })); } if (task.tags && task.tags.length >= 0) { overviewGrid.append(makeMetaItem('Tags', '', { fullWidth: true, tags: task.tags || [] })); } if (task.description) { overviewGrid.append(makeMetaItem('Description', task.description, { fullWidth: true })); } overview.appendChild(overviewGrid); content.appendChild(overview); // ── Claude Config ── const c = task.claude || {}; const claudeSection = makeSection('Claude Config'); const claudeGrid = document.createElement('div'); claudeGrid.className = 'meta-grid'; claudeGrid.append( makeMetaItem('Model', c.model), makeMetaItem('Max Budget', c.max_budget_usd != null ? `$${c.max_budget_usd.toFixed(2)}` : '—'), makeMetaItem('Working Dir', c.working_dir), makeMetaItem('Permission Mode', c.permission_mode || 'default'), ); if (c.allowed_tools && c.allowed_tools.length > 0) { claudeGrid.append(makeMetaItem('Allowed Tools', c.allowed_tools.join(', '), { fullWidth: true })); } if (c.disallowed_tools && c.disallowed_tools.length > 0) { claudeGrid.append(makeMetaItem('Disallowed Tools', c.disallowed_tools.join(', '), { fullWidth: true })); } if (c.instructions) { claudeGrid.append(makeMetaItem('Instructions', c.instructions, { fullWidth: true, code: true })); } if (c.system_prompt_append) { claudeGrid.append(makeMetaItem('System Prompt Append', c.system_prompt_append, { fullWidth: true, code: true })); } claudeSection.appendChild(claudeGrid); content.appendChild(claudeSection); // ── Execution Settings ── const settingsSection = makeSection('Execution Settings'); const settingsGrid = document.createElement('div'); settingsGrid.className = 'meta-grid'; settingsGrid.append( makeMetaItem('Timeout', formatDurationNs(task.timeout)), makeMetaItem('Retry Attempts', String(task.retry ? task.retry.max_attempts : 1)), makeMetaItem('Backoff', task.retry ? task.retry.backoff : '—'), ); if (task.depends_on && task.depends_on.length > 0) { settingsGrid.append(makeMetaItem('Depends On', task.depends_on.join(', '), { fullWidth: true, mono: true })); } settingsSection.appendChild(settingsGrid); content.appendChild(settingsSection); // ── Executions ── const execSection = makeSection('Executions'); if (!executions || executions.length === 0) { const none = document.createElement('div'); none.className = 'meta-value muted'; none.textContent = 'No executions yet.'; execSection.appendChild(none); } else { const list = document.createElement('div'); list.className = 'executions-list'; // Newest first for (const exec of [...executions].reverse()) { const row = document.createElement('div'); row.className = 'execution-row'; const shortId = document.createElement('span'); shortId.className = 'execution-id'; shortId.textContent = exec.ID ? exec.ID.slice(0, 8) : '—'; row.appendChild(shortId); const badge = document.createElement('span'); badge.className = 'state-badge'; badge.dataset.state = exec.Status || ''; badge.textContent = (exec.Status || '—').replace(/_/g, ' '); row.appendChild(badge); const times = document.createElement('span'); times.className = 'execution-times'; const start = exec.StartTime ? formatDate(exec.StartTime) : '?'; const end = exec.EndTime && exec.EndTime !== '0001-01-01T00:00:00Z' ? formatDate(exec.EndTime) : '…'; times.textContent = `${start} → ${end}`; row.appendChild(times); if (exec.CostUSD != null && exec.CostUSD > 0) { const cost = document.createElement('span'); cost.className = 'execution-cost'; cost.textContent = `$${exec.CostUSD.toFixed(4)}`; row.appendChild(cost); } const exitEl = document.createElement('span'); exitEl.className = 'execution-exit'; exitEl.textContent = `exit: ${exec.ExitCode ?? '—'}`; row.appendChild(exitEl); const logsBtn = document.createElement('button'); logsBtn.className = 'btn-view-logs'; logsBtn.textContent = 'View Logs'; logsBtn.addEventListener('click', () => handleViewLogs(exec.ID)); row.appendChild(logsBtn); list.appendChild(row); } execSection.appendChild(list); } content.appendChild(execSection); } async function handleViewLogs(execId) { const modal = document.getElementById('logs-modal'); const body = document.getElementById('logs-modal-body'); document.getElementById('logs-modal-title').textContent = `Execution ${execId.slice(0, 8)}`; body.innerHTML = '
Loading…
'; modal.showModal(); try { const res = await fetch(`${API_BASE}/api/executions/${execId}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const exec = await res.json(); body.innerHTML = ''; const grid = document.createElement('div'); grid.className = 'meta-grid'; const entries = [ ['ID', exec.ID, { fullWidth: true, mono: true }], ['Status', exec.Status, { badge: true }], ['Exit Code', String(exec.ExitCode ?? '—'), {}], ['Cost', exec.CostUSD > 0 ? `$${exec.CostUSD.toFixed(4)}` : '—', {}], ['Start', formatDateLong(exec.StartTime), {}], ['End', exec.EndTime && !exec.EndTime.startsWith('0001-') ? formatDateLong(exec.EndTime) : '—', {}], ['Error', exec.ErrorMsg || '—', { fullWidth: true }], ['Stdout', exec.StdoutPath || '—', { fullWidth: true, mono: true }], ['Stderr', exec.StderrPath || '—', { fullWidth: true, mono: true }], ]; for (const [label, value, opts] of entries) { grid.appendChild(makeMetaItem(label, value, opts)); } body.appendChild(grid); } catch (err) { body.innerHTML = `
Failed to load: ${err.message}
`; } } // ── Log viewer ──────────────────────────────────────────────────────────────── function openLogViewer(execId, containerEl) { // Save original children so Back can restore them (with event listeners intact) const originalChildren = [...containerEl.childNodes]; containerEl.innerHTML = ''; const viewer = document.createElement('div'); viewer.className = 'log-viewer'; // Back button const backBtn = document.createElement('button'); backBtn.className = 'log-back-btn'; backBtn.textContent = '← Back'; backBtn.addEventListener('click', () => { closeLogViewer(); containerEl.innerHTML = ''; for (const node of originalChildren) containerEl.appendChild(node); }); viewer.appendChild(backBtn); // Pulsing status indicator const statusEl = document.createElement('div'); statusEl.className = 'log-status-indicator'; statusEl.textContent = 'Streaming...'; viewer.appendChild(statusEl); // Log output area const logOutput = document.createElement('div'); logOutput.className = 'log-output'; logOutput.style.fontFamily = 'monospace'; logOutput.style.overflowY = 'auto'; logOutput.style.maxHeight = '400px'; viewer.appendChild(logOutput); containerEl.appendChild(viewer); let userScrolled = false; logOutput.addEventListener('scroll', () => { const nearBottom = logOutput.scrollHeight - logOutput.scrollTop - logOutput.clientHeight < 50; if (!nearBottom) userScrolled = true; }); const source = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`); activeLogSource = source; source.onmessage = (event) => { let data; try { data = JSON.parse(event.data); } catch { return; } const line = document.createElement('div'); line.className = 'log-line'; switch (data.type) { case 'text': { line.classList.add('log-text'); line.textContent = data.text ?? data.content ?? ''; break; } case 'tool_use': { line.classList.add('log-tool-use'); const toolName = document.createElement('span'); toolName.className = 'tool-name'; toolName.textContent = `[${data.name ?? 'Tool'}]`; line.appendChild(toolName); const inputStr = data.input ? JSON.stringify(data.input) : ''; const inputPreview = document.createElement('span'); inputPreview.textContent = ' ' + inputStr.slice(0, 120); line.appendChild(inputPreview); break; } case 'tool_result': { line.classList.add('log-tool-result'); line.style.opacity = '0.6'; const content = Array.isArray(data.content) ? data.content.map(c => c.text ?? '').join(' ') : (data.content ?? ''); line.textContent = String(content).slice(0, 120); break; } case 'cost': { line.classList.add('log-cost'); const cost = data.total_cost ?? data.cost ?? 0; line.textContent = `Cost: $${Number(cost).toFixed(3)}`; break; } default: return; } logOutput.appendChild(line); if (!userScrolled) { logOutput.scrollTop = logOutput.scrollHeight; } }; source.addEventListener('done', () => { source.close(); activeLogSource = null; userScrolled = false; statusEl.classList.remove('log-status-indicator'); statusEl.textContent = 'Stream complete'; }); source.onerror = () => { source.close(); activeLogSource = null; statusEl.hidden = true; const errEl = document.createElement('div'); errEl.className = 'log-line log-error'; errEl.textContent = 'Connection error. Stream closed.'; logOutput.appendChild(errEl); }; } function closeLogViewer() { activeLogSource?.close(); activeLogSource = null; } // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { // Update tab button active state document.querySelectorAll('.tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === name); }); // Show/hide panels document.querySelectorAll('[data-panel]').forEach(panel => { if (panel.dataset.panel === name) { panel.removeAttribute('hidden'); } else { panel.setAttribute('hidden', ''); } }); // Show/hide the header New Task button (only relevant on tasks tab) document.getElementById('btn-new-task').style.display = name === 'tasks' ? '' : 'none'; if (name === 'templates') { fetchTemplates().then(renderTemplateList).catch(() => { document.querySelector('.template-list').innerHTML = '
Could not reach server.
'; }); } } // ── Boot ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { updateToggleButton(); document.getElementById('btn-toggle-completed').addEventListener('click', async () => { setHideCompletedFailed(!getHideCompletedFailed()); updateToggleButton(); await poll(); }); document.getElementById('btn-start-next').addEventListener('click', function() { handleStartNextTask(this); }); startPolling(); connectWebSocket(); // Side panel close document.getElementById('btn-close-panel').addEventListener('click', closeTaskPanel); document.getElementById('task-panel-backdrop').addEventListener('click', closeTaskPanel); // Execution logs modal close document.getElementById('btn-close-logs').addEventListener('click', () => { document.getElementById('logs-modal').close(); }); // Tab bar document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); // Task modal document.getElementById('btn-new-task').addEventListener('click', openTaskModal); document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal); // Validate button document.getElementById('btn-validate').addEventListener('click', async () => { const btn = document.getElementById('btn-validate'); const resultDiv = document.getElementById('validate-result'); btn.disabled = true; btn.textContent = 'Checking…'; try { const payload = buildValidatePayload(); const result = await validateTask(payload); renderValidationResult(result); } catch (err) { resultDiv.removeAttribute('hidden'); resultDiv.textContent = 'Validation failed: ' + err.message; } finally { btn.disabled = false; btn.textContent = 'Validate Instructions'; } }); // Draft with AI button const btnElaborate = document.getElementById('btn-elaborate'); btnElaborate.addEventListener('click', async () => { const prompt = document.getElementById('elaborate-prompt').value.trim(); if (!prompt) { const form = document.getElementById('task-form'); // Remove previous error const prev = form.querySelector('.form-error'); if (prev) prev.remove(); const errEl = document.createElement('p'); errEl.className = 'form-error'; errEl.textContent = 'Please enter a description before drafting.'; form.querySelector('.elaborate-section').appendChild(errEl); return; } btnElaborate.disabled = true; btnElaborate.textContent = 'Drafting…'; // Remove any previous errors or banners const form = document.getElementById('task-form'); form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove()); try { const result = await elaborateTask(prompt); // Populate form fields const f = document.getElementById('task-form'); if (result.name) f.querySelector('[name="name"]').value = result.name; if (result.claude && result.claude.instructions) f.querySelector('[name="instructions"]').value = result.claude.instructions; if (result.claude && result.claude.working_dir) f.querySelector('[name="working_dir"]').value = result.claude.working_dir; if (result.claude && result.claude.model) f.querySelector('[name="model"]').value = result.claude.model; if (result.claude && result.claude.max_budget_usd != null) f.querySelector('[name="max_budget_usd"]').value = result.claude.max_budget_usd; if (result.timeout) f.querySelector('[name="timeout"]').value = result.timeout; if (result.priority) { const sel = f.querySelector('[name="priority"]'); if ([...sel.options].some(o => o.value === result.priority)) { sel.value = result.priority; } } // Show success banner const banner = document.createElement('p'); banner.className = 'elaborate-banner'; banner.textContent = 'AI draft ready — review and submit.'; document.getElementById('task-form').querySelector('.elaborate-section').appendChild(banner); // Auto-validate after elaboration try { const result = await validateTask(buildValidatePayload()); renderValidationResult(result); } catch (_) { // silent - elaboration already succeeded, validation is bonus } } catch (err) { const errEl = document.createElement('p'); errEl.className = 'form-error'; errEl.textContent = `Elaboration failed: ${err.message}`; document.getElementById('task-form').querySelector('.elaborate-section').appendChild(errEl); } finally { btnElaborate.disabled = false; btnElaborate.textContent = 'Draft with AI ✦'; } }); document.getElementById('task-form').addEventListener('submit', async e => { e.preventDefault(); // Remove any previous error const prev = e.target.querySelector('.form-error'); if (prev) prev.remove(); const btn = e.submitter; btn.disabled = true; btn.textContent = 'Creating…'; try { const validateResult = document.getElementById('validate-result'); if (!validateResult.hasAttribute('hidden') && validateResult.dataset.clarity && validateResult.dataset.clarity !== 'clear') { if (!window.confirm('The validator flagged issues. Create task anyway?')) { return; } } await createTask(new FormData(e.target)); } catch (err) { const errEl = document.createElement('p'); errEl.className = 'form-error'; errEl.textContent = err.message; e.target.appendChild(errEl); } finally { btn.disabled = false; btn.textContent = 'Create & Queue'; } }); // Template modal document.getElementById('btn-new-template').addEventListener('click', openTemplateModal); document.getElementById('btn-cancel-template').addEventListener('click', closeTemplateModal); document.getElementById('template-form').addEventListener('submit', async e => { e.preventDefault(); // Remove any previous error const prev = e.target.querySelector('.form-error'); if (prev) prev.remove(); const btn = e.submitter; btn.disabled = true; btn.textContent = 'Saving…'; try { await saveTemplate(new FormData(e.target)); } catch (err) { const errEl = document.createElement('p'); errEl.className = 'form-error'; errEl.textContent = err.message; e.target.appendChild(errEl); } finally { btn.disabled = false; btn.textContent = 'Save Template'; } }); });