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' || task.state === 'BLOCKED' || 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 (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();
}
function renderQuestionFooter(task, footer) {
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);
}
}
// ── 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';
}
});
});