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();
}
// 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', 'BUDGET_EXCEEDED']);
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;
}
/**
* Returns true if the user is currently editing a text field or has a modal open.
* Used to avoid destructive DOM refreshes during polling.
*/
export function isUserEditing(activeEl = (typeof document !== 'undefined' ? document.activeElement : null)) {
if (!activeEl) return false;
const tag = activeEl.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
if (activeEl.isContentEditable) return true;
if (activeEl.closest('[role="dialog"]') || activeEl.closest('dialog')) return true;
// Also block re-renders when any modal/panel is open, even without focus.
if (typeof document !== 'undefined') {
if (document.querySelector('dialog[open]')) return true;
if (document.getElementById('task-panel')?.classList.contains('open')) return true;
}
return false;
}
/**
* Partitions tasks into 'running' and 'ready' arrays for the Active tab view.
* Both arrays are sorted by created_at ascending (oldest first).
*/
export function partitionActivePaneTasks(tasks) {
const running = tasks
.filter(t => t.state === 'RUNNING')
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const ready = tasks
.filter(t => t.state === 'READY')
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
return { running, ready };
}
// ── Sort ──────────────────────────────────────────────────────────────────────
function sortTasksByDate(tasks, descend = false) {
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;
const diff = new Date(a.created_at) - new Date(b.created_at);
return descend ? -diff : diff;
});
}
// ── Filter ────────────────────────────────────────────────────────────────────
const HIDE_STATES = new Set(['COMPLETED', 'FAILED']);
const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY']);
const INTERRUPTED_STATES = new Set(['CANCELLED', 'FAILED', 'BUDGET_EXCEEDED', 'BLOCKED']);
const DONE_STATES = new Set(['COMPLETED', 'TIMED_OUT']);
// 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 === 'interrupted') return tasks.filter(t => INTERRUPTED_STATES.has(t.state));
if (tab === 'done') {
const now = new Date();
const twentyFourHoursAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
return tasks.filter(t => {
if (!DONE_STATES.has(t.state)) return false;
if (!t.created_at) return true; // keep if no date
return new Date(t.created_at) > twentyFourHoursAgo;
});
}
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 tab = getTaskFilterTab();
const descend = (tab === 'done' || tab === 'interrupted');
const visible = sortTasksByDate(filterTasksByTab(tasks, tab), descend);
// 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));
}
}
// ── 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 a = task.agent || {};
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: a.instructions || '' }));
form.appendChild(makeField('Project Directory', 'input', { type: 'text', name: 'project_dir', value: a.project_dir || a.working_dir || '', placeholder: '/path/to/repo' }));
form.appendChild(makeField('Max Budget (USD)', 'input', { type: 'number', name: 'max_budget_usd', step: '0.01', value: a.max_budget_usd != null ? String(a.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'),
agent: {
instructions: get('instructions'),
project_dir: get('project_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);
}
}
// ── Polling ───────────────────────────────────────────────────────────────────
async function poll() {
try {
const tasks = await fetchTasks();
if (isUserEditing()) return;
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, project_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 project_dir = f.querySelector('#project-select').value;
const allowedToolsEl = f.querySelector('[name="allowed_tools"]');
const allowed_tools = allowedToolsEl
? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean)
: [];
return { name, agent: { instructions, project_dir, 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');
if (!select) return;
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('project_dir');
const workingDir = selectVal === '__new__'
? document.getElementById('new-project-input').value.trim()
: selectVal;
const body = {
name: formData.get('name'),
description: '',
agent: {
instructions: formData.get('instructions'),
project_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);
}
// ── 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);
// ── Agent Config ──
const a = task.agent || {};
const agentSection = makeSection('Agent Config');
const agentGrid = document.createElement('div');
agentGrid.className = 'meta-grid';
agentGrid.append(
makeMetaItem('Type', a.type || 'claude'),
makeMetaItem('Model', a.model),
makeMetaItem('Max Budget', a.max_budget_usd != null ? `$${a.max_budget_usd.toFixed(2)}` : '—'),
makeMetaItem('Project Dir', a.project_dir || a.working_dir),
makeMetaItem('Permission Mode', a.permission_mode || 'default'),
);
if (a.allowed_tools && a.allowed_tools.length > 0) {
agentGrid.append(makeMetaItem('Allowed Tools', a.allowed_tools.join(', '), { fullWidth: true }));
}
if (a.disallowed_tools && a.disallowed_tools.length > 0) {
agentGrid.append(makeMetaItem('Disallowed Tools', a.disallowed_tools.join(', '), { fullWidth: true }));
}
if (a.instructions) {
agentGrid.append(makeMetaItem('Instructions', a.instructions, { fullWidth: true, code: true }));
}
if (a.system_prompt_append) {
agentGrid.append(makeMetaItem('System Prompt Append', a.system_prompt_append, { fullWidth: true, code: true }));
}
agentSection.appendChild(agentGrid);
content.appendChild(agentSection);
// ── 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 === '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('tasks');
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.agent && result.agent.instructions)
f.querySelector('[name="instructions"]').value = result.agent.instructions;
if (result.agent && (result.agent.project_dir || result.agent.working_dir)) {
const pDir = result.agent.project_dir || result.agent.working_dir;
const pSel = document.getElementById('project-select');
const exists = [...pSel.options].some(o => o.value === pDir);
if (exists) {
pSel.value = pDir;
} else {
pSel.value = '__new__';
document.getElementById('new-project-row').hidden = false;
document.getElementById('new-project-input').value = pDir;
}
}
if (result.agent && result.agent.max_budget_usd != null)
f.querySelector('[name="max_budget_usd"]').value = result.agent.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';
}
});
});