const BASE_PATH = (typeof document !== 'undefined') ? document.querySelector('meta[name="base-path"]')?.content ?? '' : ''; const API_BASE = (typeof window !== 'undefined') ? 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(); } // Fetches recent executions (last 24h) from /api/executions?since=24h. // fetchFn defaults to window.fetch; injectable for tests. async function fetchRecentExecutions(basePath = BASE_PATH, fetchFn = fetch) { const res = await fetchFn(`${basePath}/api/executions?since=24h`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } // Returns only tasks currently in state RUNNING. function filterRunningTasks(tasks) { return tasks.filter(t => t.state === 'RUNNING'); } // Returns human-readable elapsed time from an ISO timestamp to now. function formatElapsed(startISO) { if (startISO == null) return ''; const elapsed = Math.floor((Date.now() - new Date(startISO).getTime()) / 1000); if (elapsed < 0) return '0s'; const h = Math.floor(elapsed / 3600); const m = Math.floor((elapsed % 3600) / 60); const s = elapsed % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } // Returns human-readable duration between two ISO timestamps. // If endISO is null, uses now (for in-progress tasks). // If startISO is null, returns '--'. function formatDuration(startISO, endISO) { if (startISO == null) return '--'; const start = new Date(startISO).getTime(); const end = endISO != null ? new Date(endISO).getTime() : Date.now(); const elapsed = Math.max(0, Math.floor((end - start) / 1000)); const h = Math.floor(elapsed / 3600); const m = Math.floor((elapsed % 3600) / 60); const s = elapsed % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } // Returns last max lines from array (for testability). function extractLogLines(lines, max = 500) { if (lines.length <= max) return lines; return lines.slice(lines.length - max); } // Returns a new array of executions sorted by started_at descending. function sortExecutionsDesc(executions) { return [...executions].sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime(), ); } // ── 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', 'CANCELLED']); if (task.state === 'PENDING' || task.state === 'RUNNING' || task.state === 'READY' || task.state === 'BLOCKED' || task.state === 'TIMED_OUT' || 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 (task.state === 'BLOCKED') { renderQuestionFooter(task, footer); } else if (task.state === 'TIMED_OUT') { const btn = document.createElement('button'); btn.className = 'btn-resume'; btn.textContent = 'Resume'; btn.addEventListener('click', (e) => { e.stopPropagation(); handleResume(task.id, btn, footer); }); footer.appendChild(btn); } 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); } if (!NON_DELETABLE_STATES.has(task.state)) { const delBtn = document.createElement('button'); delBtn.className = 'btn-delete-task'; delBtn.title = 'Delete task'; delBtn.textContent = '✕'; delBtn.addEventListener('click', (e) => { e.stopPropagation(); handleDelete(task.id, card); }); card.appendChild(delBtn); } if (EDITABLE_STATES.has(task.state)) { card.classList.add('task-card--editable'); const editForm = createEditForm(task); editForm.hidden = true; card.appendChild(editForm); card.addEventListener('click', () => { editForm.hidden = !editForm.hidden; }); } else { card.addEventListener('click', () => openTaskPanel(task.id)); } return card; } // ── Sort ────────────────────────────────────────────────────────────────────── function sortTasksByDate(tasks) { return [...tasks].sort((a, b) => { if (!a.created_at && !b.created_at) return 0; if (!a.created_at) return 1; if (!b.created_at) return -1; return new Date(a.created_at) - new Date(b.created_at); }); } // ── Filter ──────────────────────────────────────────────────────────────────── const HIDE_STATES = new Set(['COMPLETED', 'FAILED']); const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']); const DONE_STATES = new Set(['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']); // filterActiveTasks uses its own set (excludes PENDING — tasks "in-flight" only) const _PANEL_ACTIVE_STATES = new Set(['RUNNING', 'READY', 'QUEUED', 'BLOCKED']); export function filterTasks(tasks, hideCompletedFailed = false) { if (!hideCompletedFailed) return tasks; return tasks.filter(t => !HIDE_STATES.has(t.state)); } export function filterActiveTasks(tasks) { return tasks.filter(t => _PANEL_ACTIVE_STATES.has(t.state)); } export function filterTasksByTab(tasks, tab) { if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state)); if (tab === 'done') return tasks.filter(t => DONE_STATES.has(t.state)); return tasks; } export function getTaskFilterTab() { return localStorage.getItem('taskFilterTab') ?? 'active'; } export function setTaskFilterTab(tab) { localStorage.setItem('taskFilterTab', tab); } export function updateFilterTabs() { const current = getTaskFilterTab(); document.querySelectorAll('.filter-tab[data-filter]').forEach(el => { el.classList.toggle('active', el.dataset.filter === current); }); } 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 visible = sortTasksByDate(filterTasksByTab(tasks, getTaskFilterTab())); // Replace contents with task cards container.innerHTML = ''; for (const task of visible) { container.appendChild(createTaskCard(task)); } } function renderActiveTaskList(tasks) { const container = document.querySelector('.active-task-list'); if (!container) return; if (!tasks || tasks.length === 0) { container.innerHTML = '
No active tasks.
'; return; } const active = sortTasksByDate(filterActiveTasks(tasks)); container.innerHTML = ''; if (active.length === 0) { container.innerHTML = '
No active tasks.
'; return; } for (const task of active) { container.appendChild(createTaskCard(task)); } } 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 resumeTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/resume`, { 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(); } const NON_DELETABLE_STATES = new Set(['RUNNING', 'QUEUED']); const EDITABLE_STATES = new Set(['PENDING', 'FAILED', 'CANCELLED', 'TIMED_OUT', 'BUDGET_EXCEEDED']); // Convert Duration JSON {"Duration": } to a human string for a text input (e.g. "15m"). function formatDurationForInput(timeout) { const ns = timeout && timeout.Duration; if (!ns) return ''; const secs = Math.round(ns / 1e9); if (secs < 60) return `${secs}s`; const mins = Math.floor(secs / 60); const remSecs = 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`; } async function deleteTask(taskId) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}`, { method: 'DELETE' }); 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); } } async function handleDelete(taskId, card) { if (!confirm('Delete this task? This cannot be undone.')) return; try { await deleteTask(taskId); card.remove(); } catch (err) { alert(`Failed to delete: ${err.message}`); } } // ── Inline task editor ──────────────────────────────────────────────────────── async function updateTask(taskId, body) { const res = await fetch(`${API_BASE}/api/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const b = await res.json(); msg = b.error || b.message || msg; } catch {} throw new Error(msg); } return res.json(); } function createEditForm(task) { const c = task.claude || {}; const form = document.createElement('div'); form.className = 'task-inline-edit'; // Prevent card-level click from toggling this form while user interacts inside it. form.addEventListener('click', (e) => e.stopPropagation()); function makeField(labelText, tag, attrs) { const label = document.createElement('label'); label.textContent = labelText; const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'value') el.value = v; else el.setAttribute(k, v); } label.appendChild(el); return label; } form.appendChild(makeField('Name', 'input', { type: 'text', name: 'name', value: task.name || '' })); form.appendChild(makeField('Description', 'textarea', { name: 'description', rows: '2', value: task.description || '' })); form.appendChild(makeField('Instructions', 'textarea', { name: 'instructions', rows: '4', value: c.instructions || '' })); form.appendChild(makeField('Model', 'input', { type: 'text', name: 'model', value: c.model || 'sonnet' })); form.appendChild(makeField('Working Directory', 'input', { type: 'text', name: 'working_dir', value: c.working_dir || '', placeholder: '/path/to/repo' })); form.appendChild(makeField('Max Budget (USD)', 'input', { type: 'number', name: 'max_budget_usd', step: '0.01', value: c.max_budget_usd != null ? String(c.max_budget_usd) : '1.00' })); form.appendChild(makeField('Timeout', 'input', { type: 'text', name: 'timeout', value: formatDurationForInput(task.timeout) || '15m', placeholder: '15m' })); const prioLabel = document.createElement('label'); prioLabel.textContent = 'Priority'; const prioSel = document.createElement('select'); prioSel.name = 'priority'; for (const val of ['high', 'normal', 'low']) { const opt = document.createElement('option'); opt.value = val; opt.textContent = val.charAt(0).toUpperCase() + val.slice(1); if (val === (task.priority || 'normal')) opt.selected = true; prioSel.appendChild(opt); } prioLabel.appendChild(prioSel); form.appendChild(prioLabel); const errEl = document.createElement('div'); errEl.className = 'inline-edit-error'; errEl.hidden = true; form.appendChild(errEl); const actions = document.createElement('div'); actions.className = 'inline-edit-actions'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => { form.hidden = true; }); const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'btn-primary btn-sm'; saveBtn.textContent = 'Save'; saveBtn.addEventListener('click', () => handleEditSave(task.id, form, saveBtn)); actions.append(cancelBtn, saveBtn); form.appendChild(actions); return form; } async function handleEditSave(taskId, form, saveBtn) { const get = name => form.querySelector(`[name="${name}"]`)?.value ?? ''; const body = { name: get('name'), description: get('description'), claude: { model: get('model'), instructions: get('instructions'), working_dir: get('working_dir'), max_budget_usd: parseFloat(get('max_budget_usd')), }, timeout: get('timeout'), priority: get('priority'), }; const errEl = form.querySelector('.inline-edit-error'); errEl.hidden = true; saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; try { await updateTask(taskId, body); form.hidden = true; // Brief success flash on the card const card = form.closest('.task-card'); const flash = document.createElement('div'); flash.className = 'inline-edit-success'; flash.textContent = 'Saved'; card.appendChild(flash); setTimeout(() => flash.remove(), 2000); await poll(); } catch (err) { errEl.textContent = `Failed to save: ${err.message}`; errEl.hidden = false; } finally { saveBtn.disabled = false; saveBtn.textContent = 'Save'; } } function renderQuestionFooter(task, footer) { // Prevent any tap inside the question footer from opening the detail panel. footer.addEventListener('click', (e) => e.stopPropagation()); let question = { text: 'Waiting for your input.', options: [] }; if (task.question) { try { question = JSON.parse(task.question); } catch {} } const questionEl = document.createElement('p'); questionEl.className = 'task-question-text'; questionEl.textContent = question.text; footer.appendChild(questionEl); if (question.options && question.options.length > 0) { question.options.forEach(opt => { const btn = document.createElement('button'); btn.className = 'btn-answer'; btn.textContent = opt; btn.addEventListener('click', (e) => { e.stopPropagation(); handleAnswer(task.id, opt, footer); }); footer.appendChild(btn); }); } else { const row = document.createElement('div'); row.className = 'task-answer-row'; const input = document.createElement('input'); input.type = 'text'; input.className = 'task-answer-input'; input.placeholder = 'Your answer…'; const btn = document.createElement('button'); btn.className = 'btn-answer'; btn.textContent = 'Submit'; btn.addEventListener('click', (e) => { e.stopPropagation(); if (input.value.trim()) handleAnswer(task.id, input.value.trim(), footer); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { e.stopPropagation(); handleAnswer(task.id, input.value.trim(), footer); } }); row.append(input, btn); footer.appendChild(row); } } async function handleAnswer(taskId, answer, footer) { const btns = footer.querySelectorAll('button, input'); btns.forEach(el => { el.disabled = true; }); const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ answer }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } await poll(); } catch (err) { btns.forEach(el => { el.disabled = false; }); const errEl = document.createElement('span'); errEl.className = 'task-error'; errEl.textContent = `Failed: ${err.message}`; footer.appendChild(errEl); } } 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); } } async function handleResume(taskId, btn, footer) { btn.disabled = true; btn.textContent = 'Resuming…'; const prev = footer.querySelector('.task-error'); if (prev) prev.remove(); try { await resumeTask(taskId); await poll(); } catch (err) { btn.disabled = false; btn.textContent = 'Resume'; 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); renderActiveTaskList(tasks); if (isRunningTabActive()) { renderRunningView(tasks); fetchRecentExecutions(BASE_PATH, fetch) .then(execs => renderRunningHistory(execs)) .catch(() => { const histEl = document.querySelector('.running-history'); if (histEl) histEl.innerHTML = '

Could not load execution history.

'; }); } } 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, workingDir) { const res = await fetch(`${API_BASE}/api/tasks/elaborate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, working_dir: workingDir }), }); 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 ──────────────────────────────────────────────────────────────── async function openTaskModal() { document.getElementById('task-modal').showModal(); await populateProjectSelect(); } async function populateProjectSelect() { const select = document.getElementById('project-select'); const current = select.value; try { const res = await fetch(`${API_BASE}/api/workspaces`); const dirs = await res.json(); select.innerHTML = ''; dirs.forEach(dir => { const opt = document.createElement('option'); opt.value = dir; opt.textContent = dir; if (dir === current || dir === '/workspace/claudomator') opt.selected = true; select.appendChild(opt); }); } catch { // keep whatever options are already there } // Ensure "Create new project…" option is always last const newOpt = document.createElement('option'); newOpt.value = '__new__'; newOpt.textContent = 'Create new project…'; select.appendChild(newOpt); } function initProjectSelect() { const select = document.getElementById('project-select'); const newRow = document.getElementById('new-project-row'); const newInput = document.getElementById('new-project-input'); select.addEventListener('change', () => { if (select.value === '__new__') { newRow.hidden = false; newInput.required = true; newInput.focus(); } else { newRow.hidden = true; newInput.required = false; newInput.value = ''; } }); } 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 selectVal = formData.get('working_dir'); const workingDir = selectVal === '__new__' ? document.getElementById('new-project-input').value.trim() : selectVal; const body = { name: formData.get('name'), description: '', claude: { model: formData.get('model'), instructions: formData.get('instructions'), working_dir: workingDir, 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() { closeLogViewer(); 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', () => { const panelContent = document.getElementById('task-panel-content'); openLogViewer(exec.ID, panelContent); }); 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; userScrolled = !nearBottom; }); 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; } // ── Running view ─────────────────────────────────────────────────────────────── // Map of taskId → EventSource for live log streams in the Running tab. const runningViewLogSources = {}; function renderRunningView(tasks) { const currentEl = document.querySelector('.running-current'); if (!currentEl) return; const running = filterRunningTasks(tasks); // Close SSE streams for tasks that are no longer RUNNING. for (const [id, src] of Object.entries(runningViewLogSources)) { if (!running.find(t => t.id === id)) { src.close(); delete runningViewLogSources[id]; } } // Update elapsed spans in place if the same tasks are still running. const existingCards = currentEl.querySelectorAll('[data-task-id]'); const existingIds = new Set([...existingCards].map(c => c.dataset.taskId)); const unchanged = running.length === existingCards.length && running.every(t => existingIds.has(t.id)); if (unchanged) { updateRunningElapsed(); return; } // Full re-render. currentEl.innerHTML = ''; const h2 = document.createElement('h2'); h2.textContent = 'Currently Running'; currentEl.appendChild(h2); if (running.length === 0) { const empty = document.createElement('p'); empty.className = 'task-meta'; empty.textContent = 'No tasks are currently running.'; currentEl.appendChild(empty); return; } for (const task of running) { const card = document.createElement('div'); card.className = 'running-task-card task-card'; card.dataset.taskId = task.id; 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; const elapsed = document.createElement('span'); elapsed.className = 'running-elapsed'; elapsed.dataset.startedAt = task.updated_at ?? ''; elapsed.textContent = formatElapsed(task.updated_at); header.append(name, badge, elapsed); card.appendChild(header); // Parent context (async fetch) if (task.parent_task_id) { const parentEl = document.createElement('div'); parentEl.className = 'task-meta'; parentEl.textContent = 'Subtask of: …'; card.appendChild(parentEl); fetch(`${API_BASE}/api/tasks/${task.parent_task_id}`) .then(r => r.ok ? r.json() : null) .then(parent => { if (parent) parentEl.textContent = `Subtask of: ${parent.name}`; }) .catch(() => { parentEl.textContent = ''; }); } // Log area const logArea = document.createElement('div'); logArea.className = 'running-log'; logArea.dataset.logTarget = task.id; card.appendChild(logArea); // Footer with Cancel button const footer = document.createElement('div'); footer.className = 'task-card-footer'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn-cancel'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); handleCancel(task.id, cancelBtn, footer); }); footer.appendChild(cancelBtn); card.appendChild(footer); currentEl.appendChild(card); // Open SSE stream if not already streaming for this task. if (!runningViewLogSources[task.id]) { startRunningLogStream(task.id, logArea); } } } function startRunningLogStream(taskId, logArea) { fetch(`${API_BASE}/api/executions?task_id=${taskId}&limit=1`) .then(r => r.ok ? r.json() : []) .then(execs => { if (!execs || execs.length === 0) return; const execId = execs[0].id; let userScrolled = false; logArea.addEventListener('scroll', () => { const nearBottom = logArea.scrollHeight - logArea.scrollTop - logArea.clientHeight < 50; userScrolled = !nearBottom; }); const src = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`); runningViewLogSources[taskId] = src; src.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 'cost': { line.classList.add('log-cost'); const cost = data.total_cost ?? data.cost ?? 0; line.textContent = `Cost: $${Number(cost).toFixed(3)}`; break; } default: return; } logArea.appendChild(line); // Trim to last 500 lines. while (logArea.childElementCount > 500) { logArea.removeChild(logArea.firstElementChild); } if (!userScrolled) logArea.scrollTop = logArea.scrollHeight; }; src.addEventListener('done', () => { src.close(); delete runningViewLogSources[taskId]; }); src.onerror = () => { src.close(); delete runningViewLogSources[taskId]; const errEl = document.createElement('div'); errEl.className = 'log-line log-error'; errEl.textContent = 'Stream closed.'; logArea.appendChild(errEl); }; }) .catch(() => {}); } function updateRunningElapsed() { document.querySelectorAll('.running-elapsed[data-started-at]').forEach(el => { el.textContent = formatElapsed(el.dataset.startedAt || null); }); } function isRunningTabActive() { const panel = document.querySelector('[data-panel="running"]'); return panel && !panel.hasAttribute('hidden'); } function sortExecutionsByDate(executions) { return sortExecutionsDesc(executions); } function renderRunningHistory(executions) { const histEl = document.querySelector('.running-history'); if (!histEl) return; histEl.innerHTML = ''; const h2 = document.createElement('h2'); h2.textContent = 'Execution History (Last 24h)'; histEl.appendChild(h2); if (!executions || executions.length === 0) { const empty = document.createElement('p'); empty.className = 'task-meta'; empty.textContent = 'No executions in the last 24h'; histEl.appendChild(empty); return; } const sorted = sortExecutionsDesc(executions); const table = document.createElement('table'); table.className = 'history-table'; const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); for (const col of ['Date', 'Task', 'Status', 'Duration', 'Cost', 'Exit', 'Logs']) { const th = document.createElement('th'); th.textContent = col; headerRow.appendChild(th); } thead.appendChild(headerRow); table.appendChild(thead); const tbody = document.createElement('tbody'); for (const exec of sorted) { const tr = document.createElement('tr'); const tdDate = document.createElement('td'); tdDate.textContent = formatDate(exec.started_at); tr.appendChild(tdDate); const tdTask = document.createElement('td'); tdTask.textContent = exec.task_name || exec.task_id || '—'; tr.appendChild(tdTask); const tdStatus = document.createElement('td'); const stateBadge = document.createElement('span'); stateBadge.className = 'state-badge'; stateBadge.dataset.state = exec.state || ''; stateBadge.textContent = exec.state || '—'; tdStatus.appendChild(stateBadge); tr.appendChild(tdStatus); const tdDur = document.createElement('td'); tdDur.textContent = formatDuration(exec.started_at, exec.finished_at ?? null); tr.appendChild(tdDur); const tdCost = document.createElement('td'); tdCost.textContent = exec.cost_usd > 0 ? `$${exec.cost_usd.toFixed(4)}` : '—'; tr.appendChild(tdCost); const tdExit = document.createElement('td'); tdExit.textContent = exec.exit_code != null ? String(exec.exit_code) : '—'; tr.appendChild(tdExit); const tdLogs = document.createElement('td'); const viewBtn = document.createElement('button'); viewBtn.className = 'btn-sm'; viewBtn.textContent = 'View Logs'; viewBtn.addEventListener('click', () => openLogViewer(exec.id, histEl)); tdLogs.appendChild(viewBtn); tr.appendChild(tdLogs); tbody.appendChild(tr); } table.appendChild(tbody); histEl.appendChild(table); } // ── 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.
'; }); } if (name === 'running') { fetchTasks().then(renderRunningView).catch(() => { const currentEl = document.querySelector('.running-current'); if (currentEl) currentEl.innerHTML = '

Could not reach server.

'; }); fetchRecentExecutions(BASE_PATH, fetch) .then(execs => renderRunningHistory(execs)) .catch(() => { const histEl = document.querySelector('.running-history'); if (histEl) histEl.innerHTML = '

Could not load execution history.

'; }); } } // ── Boot ────────────────────────────────────────────────────────────────────── if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', () => { updateFilterTabs(); document.querySelectorAll(".filter-tab[data-filter]").forEach(btn => { btn.addEventListener("click", () => { setTaskFilterTab(btn.dataset.filter); updateFilterTabs(); poll(); }); }); document.getElementById('btn-start-next').addEventListener('click', function() { handleStartNextTask(this); }); switchTab('running'); 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); initProjectSelect(); // 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 sel = document.getElementById('project-select'); const workingDir = sel.value === '__new__' ? document.getElementById('new-project-input').value.trim() : sel.value; const result = await elaborateTask(prompt, workingDir); // 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) { const sel = document.getElementById('project-select'); const exists = [...sel.options].some(o => o.value === result.claude.working_dir); if (exists) { sel.value = result.claude.working_dir; } else { sel.value = '__new__'; document.getElementById('new-project-row').hidden = false; document.getElementById('new-project-input').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'; } }); });