summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-08 21:03:50 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-08 21:03:50 +0000
commit632ea5a44731af94b6238f330a3b5440906c8ae7 (patch)
treed8c780412598d66b89ef390b5729e379fdfd9d5b /web/app.js
parent406247b14985ab57902e8e42898dc8cb8960290d (diff)
parent93a4c852bf726b00e8014d385165f847763fa214 (diff)
merge: pull latest from master and resolve conflicts
- Resolve conflicts in API server, CLI, and executor. - Maintain Gemini classification and assignment logic. - Update UI to use generic agent config and project_dir. - Fix ProjectDir/WorkingDir inconsistencies in Gemini runner. - All tests passing after merge.
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js416
1 files changed, 402 insertions, 14 deletions
diff --git a/web/app.js b/web/app.js
index 05f548a..e935ff0 100644
--- a/web/app.js
+++ b/web/app.js
@@ -15,6 +15,61 @@ async function fetchTemplates() {
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) {
@@ -173,9 +228,10 @@ function sortTasksByDate(tasks) {
// ── Filter ────────────────────────────────────────────────────────────────────
-const HIDE_STATES = new Set(['COMPLETED', 'FAILED']);
-const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']);
-const DONE_STATES = new Set(['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']);
+const HIDE_STATES = new Set(['COMPLETED', 'FAILED']);
+const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']);
+const INTERRUPTED_STATES = new Set(['CANCELLED', 'FAILED']);
+const DONE_STATES = new Set(['COMPLETED', 'TIMED_OUT', 'BUDGET_EXCEEDED']);
// filterActiveTasks uses its own set (excludes PENDING — tasks "in-flight" only)
const _PANEL_ACTIVE_STATES = new Set(['RUNNING', 'READY', 'QUEUED', 'BLOCKED']);
@@ -190,8 +246,9 @@ export function filterActiveTasks(tasks) {
}
export function filterTasksByTab(tasks, tab) {
- if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state));
- if (tab === 'done') return tasks.filter(t => DONE_STATES.has(t.state));
+ 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') return tasks.filter(t => DONE_STATES.has(t.state));
return tasks;
}
@@ -477,7 +534,7 @@ function createEditForm(task) {
form.appendChild(typeLabel);
form.appendChild(makeField('Model', 'input', { type: 'text', name: 'model', value: a.model || 'sonnet' }));
- form.appendChild(makeField('Working Directory', 'input', { type: 'text', name: 'working_dir', value: a.working_dir || '', placeholder: '/path/to/repo' }));
+ 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' }));
@@ -530,7 +587,7 @@ async function handleEditSave(taskId, form, saveBtn) {
type: get('type'),
model: get('model'),
instructions: get('instructions'),
- working_dir: get('working_dir'),
+ project_dir: get('project_dir'),
max_budget_usd: parseFloat(get('max_budget_usd')),
},
timeout: get('timeout'),
@@ -812,6 +869,15 @@ async function poll() {
const tasks = await fetchTasks();
renderTaskList(tasks);
renderActiveTaskList(tasks);
+ if (isRunningTabActive()) {
+ renderRunningView(tasks);
+ fetchRecentExecutions(BASE_PATH, fetch)
+ .then(execs => renderRunningHistory(execs))
+ .catch(() => {
+ const histEl = document.querySelector('.running-history');
+ if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
+ });
+ }
} catch {
document.querySelector('.task-list').innerHTML =
'<div id="loading">Could not reach server.</div>';
@@ -970,7 +1036,7 @@ async function elaborateTask(prompt, workingDir) {
const res = await fetch(`${API_BASE}/api/tasks/elaborate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ prompt, working_dir: workingDir }),
+ body: JSON.stringify({ prompt, project_dir: workingDir }),
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
@@ -1000,14 +1066,14 @@ 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 project_dir = f.querySelector('[name="project_dir"]').value;
const model = f.querySelector('[name="model"]').value;
const type = f.querySelector('[name="type"]').value;
const allowedToolsEl = f.querySelector('[name="allowed_tools"]');
const allowed_tools = allowedToolsEl
? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean)
: [];
- return { name, agent: { type, instructions, working_dir, model, allowed_tools } };
+ return { name, agent: { type, instructions, project_dir, model, allowed_tools } };
}
function renderValidationResult(result) {
@@ -1121,7 +1187,7 @@ function closeTaskModal() {
}
async function createTask(formData) {
- const selectVal = formData.get('working_dir');
+ const selectVal = formData.get('project_dir');
const workingDir = selectVal === '__new__'
? document.getElementById('new-project-input').value.trim()
: selectVal;
@@ -1132,7 +1198,7 @@ async function createTask(formData) {
type: formData.get('type'),
model: formData.get('model'),
instructions: formData.get('instructions'),
- working_dir: workingDir,
+ project_dir: workingDir,
max_budget_usd: parseFloat(formData.get('max_budget_usd')),
},
timeout: formData.get('timeout'),
@@ -1177,7 +1243,7 @@ async function saveTemplate(formData) {
type: formData.get('type'),
model: formData.get('model'),
instructions: formData.get('instructions'),
- working_dir: formData.get('working_dir'),
+ project_dir: formData.get('project_dir'),
max_budget_usd: parseFloat(formData.get('max_budget_usd')),
allowed_tools: splitTrim(formData.get('allowed_tools') || ''),
},
@@ -1358,7 +1424,7 @@ function renderTaskPanel(task, executions) {
makeMetaItem('Type', a.type || 'claude'),
makeMetaItem('Model', a.model),
makeMetaItem('Max Budget', a.max_budget_usd != null ? `$${a.max_budget_usd.toFixed(2)}` : '—'),
- makeMetaItem('Working Dir', a.working_dir),
+ 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) {
@@ -1609,6 +1675,288 @@ function closeLogViewer() {
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) {
@@ -1636,6 +1984,19 @@ function switchTab(name) {
'<div id="loading">Could not reach server.</div>';
});
}
+
+ if (name === 'running') {
+ fetchTasks().then(renderRunningView).catch(() => {
+ const currentEl = document.querySelector('.running-current');
+ if (currentEl) currentEl.innerHTML = '<p class="task-meta">Could not reach server.</p>';
+ });
+ fetchRecentExecutions(BASE_PATH, fetch)
+ .then(execs => renderRunningHistory(execs))
+ .catch(() => {
+ const histEl = document.querySelector('.running-history');
+ if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
+ });
+ }
}
// ── Boot ──────────────────────────────────────────────────────────────────────
@@ -1655,6 +2016,7 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded
handleStartNextTask(this);
});
+ switchTab('running');
startPolling();
connectWebSocket();
@@ -1730,17 +2092,43 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded
const f = document.getElementById('task-form');
if (result.name)
f.querySelector('[name="name"]').value = result.name;
+<<<<<<< HEAD
if (result.agent && result.agent.instructions)
f.querySelector('[name="instructions"]').value = result.agent.instructions;
if (result.agent && result.agent.working_dir) {
const pSel = document.getElementById('project-select');
const exists = [...pSel.options].some(o => o.value === result.agent.working_dir);
+||||||| cad057f
+ if (result.claude && result.claude.instructions)
+ f.querySelector('[name="instructions"]').value = result.claude.instructions;
+ if (result.claude && result.claude.working_dir) {
+ const sel = document.getElementById('project-select');
+ const exists = [...sel.options].some(o => o.value === result.claude.working_dir);
+=======
+ if (result.claude && result.claude.instructions)
+ f.querySelector('[name="instructions"]').value = result.claude.instructions;
+ if (result.claude && result.claude.project_dir) {
+ const sel = document.getElementById('project-select');
+ const exists = [...sel.options].some(o => o.value === result.claude.project_dir);
+>>>>>>> master
if (exists) {
+<<<<<<< HEAD
pSel.value = result.agent.working_dir;
+||||||| cad057f
+ sel.value = result.claude.working_dir;
+=======
+ sel.value = result.claude.project_dir;
+>>>>>>> master
} else {
pSel.value = '__new__';
document.getElementById('new-project-row').hidden = false;
+<<<<<<< HEAD
document.getElementById('new-project-input').value = result.agent.working_dir;
+||||||| cad057f
+ document.getElementById('new-project-input').value = result.claude.working_dir;
+=======
+ document.getElementById('new-project-input').value = result.claude.project_dir;
+>>>>>>> master
}
}
if (result.agent && result.agent.model)