summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js416
-rw-r--r--web/index.html14
-rw-r--r--web/style.css68
-rw-r--r--web/test/active-pane.test.mjs81
-rw-r--r--web/test/filter-tabs.test.mjs38
-rw-r--r--web/test/focus-preserve.test.mjs170
-rw-r--r--web/test/is-user-editing.test.mjs65
-rw-r--r--web/test/render-dedup.test.mjs125
8 files changed, 953 insertions, 24 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)
diff --git a/web/index.html b/web/index.html
index 629b248..a2800b0 100644
--- a/web/index.html
+++ b/web/index.html
@@ -15,14 +15,16 @@
<button id="btn-new-task" class="btn-primary">New Task</button>
</header>
<nav class="tab-bar">
- <button class="tab active" data-tab="tasks">Tasks</button>
+ <button class="tab" data-tab="tasks">Tasks</button>
<button class="tab" data-tab="templates">Templates</button>
<button class="tab" data-tab="active">Active</button>
+ <button class="tab active" data-tab="running">Running</button>
</nav>
<main id="app">
- <div data-panel="tasks">
+ <div data-panel="tasks" hidden>
<div class="task-list-toolbar">
<button class="filter-tab active" data-filter="active">Active</button>
+ <button class="filter-tab" data-filter="interrupted">Interrupted</button>
<button class="filter-tab" data-filter="done">Done</button>
<button class="filter-tab" data-filter="all">All</button>
</div>
@@ -40,6 +42,10 @@
<div data-panel="active" hidden>
<div class="active-task-list"></div>
</div>
+ <div data-panel="running">
+ <div class="running-current"></div>
+ <div class="running-history"></div>
+ </div>
</main>
<dialog id="task-modal">
@@ -57,7 +63,7 @@
</div>
<hr class="form-divider">
<label>Project
- <select id="project-select" name="working_dir">
+ <select name="project_dir" id="project-select">
<option value="/workspace/claudomator" selected>/workspace/claudomator</option>
<option value="__new__">Create new project…</option>
</select>
@@ -113,7 +119,7 @@
<label>Model <input name="model" value="sonnet" placeholder="e.g. sonnet, gemini-2.0-flash"></label>
</div>
<label>Instructions <textarea name="instructions" rows="6" required></textarea></label>
- <label>Working Directory <input name="working_dir" placeholder="/path/to/repo"></label>
+ <label>Project Directory <input name="project_dir" placeholder="/path/to/repo"></label>
<label>Max Budget (USD) <input name="max_budget_usd" type="number" step="0.01" value="1.00"></label>
<label>Allowed Tools <input name="allowed_tools" placeholder="Bash, Read, Write"></label>
<label>Timeout <input name="timeout" value="15m"></label>
diff --git a/web/style.css b/web/style.css
index 106ae04..9cfe140 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1057,6 +1057,74 @@ dialog label select:focus {
color: #94a3b8;
}
+/* ── Running tab ─────────────────────────────────────────────────────────────── */
+
+.running-current {
+ margin-bottom: 2rem;
+}
+
+.running-current h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 1rem;
+}
+
+.running-elapsed {
+ font-size: 0.85rem;
+ color: var(--state-running);
+ font-variant-numeric: tabular-nums;
+}
+
+.running-log {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.75rem;
+ font-family: monospace;
+ font-size: 0.8rem;
+ max-height: 300px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.running-history {
+ margin-top: 1.5rem;
+ overflow-x: auto;
+}
+
+.running-history h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 1rem;
+}
+
+.history-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.history-table th {
+ text-align: left;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.history-table td {
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--border);
+ vertical-align: middle;
+}
+
/* ── Task delete button ──────────────────────────────────────────────────── */
.task-card {
diff --git a/web/test/active-pane.test.mjs b/web/test/active-pane.test.mjs
new file mode 100644
index 0000000..37bb8c5
--- /dev/null
+++ b/web/test/active-pane.test.mjs
@@ -0,0 +1,81 @@
+// active-pane.test.mjs — Tests for Active pane partition logic.
+//
+// Run with: node --test web/test/active-pane.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { partitionActivePaneTasks } from '../app.js';
+
+function makeTask(id, state, created_at) {
+ return { id, name: `task-${id}`, state, created_at: created_at ?? `2024-01-01T00:0${id}:00Z` };
+}
+
+const ALL_STATES = [
+ 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED',
+ 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+describe('partitionActivePaneTasks', () => {
+ it('running contains only RUNNING tasks', () => {
+ const tasks = ALL_STATES.map((s, i) => makeTask(String(i), s));
+ const { running } = partitionActivePaneTasks(tasks);
+ assert.equal(running.length, 1);
+ assert.equal(running[0].state, 'RUNNING');
+ });
+
+ it('ready contains only READY tasks', () => {
+ const tasks = ALL_STATES.map((s, i) => makeTask(String(i), s));
+ const { ready } = partitionActivePaneTasks(tasks);
+ assert.equal(ready.length, 1);
+ assert.equal(ready[0].state, 'READY');
+ });
+
+ it('excludes QUEUED, BLOCKED, PENDING, COMPLETED, FAILED and all other states', () => {
+ const tasks = ALL_STATES.map((s, i) => makeTask(String(i), s));
+ const { running, ready } = partitionActivePaneTasks(tasks);
+ const allReturned = [...running, ...ready];
+ assert.equal(allReturned.length, 2);
+ assert.ok(allReturned.every(t => t.state === 'RUNNING' || t.state === 'READY'));
+ });
+
+ it('returns empty arrays for empty input', () => {
+ const { running, ready } = partitionActivePaneTasks([]);
+ assert.deepEqual(running, []);
+ assert.deepEqual(ready, []);
+ });
+
+ it('handles multiple RUNNING tasks sorted by created_at ascending', () => {
+ const tasks = [
+ makeTask('b', 'RUNNING', '2024-01-01T00:02:00Z'),
+ makeTask('a', 'RUNNING', '2024-01-01T00:01:00Z'),
+ makeTask('c', 'RUNNING', '2024-01-01T00:03:00Z'),
+ ];
+ const { running } = partitionActivePaneTasks(tasks);
+ assert.equal(running.length, 3);
+ assert.equal(running[0].id, 'a');
+ assert.equal(running[1].id, 'b');
+ assert.equal(running[2].id, 'c');
+ });
+
+ it('handles multiple READY tasks sorted by created_at ascending', () => {
+ const tasks = [
+ makeTask('y', 'READY', '2024-01-01T00:02:00Z'),
+ makeTask('x', 'READY', '2024-01-01T00:01:00Z'),
+ ];
+ const { ready } = partitionActivePaneTasks(tasks);
+ assert.equal(ready.length, 2);
+ assert.equal(ready[0].id, 'x');
+ assert.equal(ready[1].id, 'y');
+ });
+
+ it('returns both sections independently when both states present', () => {
+ const tasks = [
+ makeTask('r1', 'RUNNING', '2024-01-01T00:01:00Z'),
+ makeTask('d1', 'READY', '2024-01-01T00:02:00Z'),
+ makeTask('r2', 'RUNNING', '2024-01-01T00:03:00Z'),
+ ];
+ const { running, ready } = partitionActivePaneTasks(tasks);
+ assert.equal(running.length, 2);
+ assert.equal(ready.length, 1);
+ });
+});
diff --git a/web/test/filter-tabs.test.mjs b/web/test/filter-tabs.test.mjs
index 44cfaf6..3a4e569 100644
--- a/web/test/filter-tabs.test.mjs
+++ b/web/test/filter-tabs.test.mjs
@@ -1,9 +1,5 @@
// filter-tabs.test.mjs — TDD contract tests for filterTasksByTab
//
-// filterTasksByTab is defined inline here to establish expected behaviour.
-// Once filterTasksByTab is exported from web/app.js, remove the inline
-// definition and import it instead.
-//
// Run with: node --test web/test/filter-tabs.test.mjs
import { describe, it } from 'node:test';
@@ -45,15 +41,45 @@ describe('filterTasksByTab — active tab', () => {
});
});
+describe('filterTasksByTab — interrupted tab', () => {
+ it('includes CANCELLED and FAILED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'interrupted');
+ for (const state of ['CANCELLED', 'FAILED']) {
+ assert.ok(result.some(t => t.state === state), `${state} should be included`);
+ }
+ });
+
+ it('excludes all non-interrupted states', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'interrupted');
+ for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED', 'COMPLETED', 'TIMED_OUT', 'BUDGET_EXCEEDED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterTasksByTab([], 'interrupted'), []);
+ });
+});
+
describe('filterTasksByTab — done tab', () => {
- it('includes COMPLETED, FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED', () => {
+ it('includes COMPLETED, TIMED_OUT, BUDGET_EXCEEDED', () => {
const tasks = ALL_STATES.map(makeTask);
const result = filterTasksByTab(tasks, 'done');
- for (const state of ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ for (const state of ['COMPLETED', 'TIMED_OUT', 'BUDGET_EXCEEDED']) {
assert.ok(result.some(t => t.state === state), `${state} should be included`);
}
});
+ it('excludes CANCELLED and FAILED (moved to interrupted tab)', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'done');
+ for (const state of ['CANCELLED', 'FAILED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded from done`);
+ }
+ });
+
it('excludes PENDING, QUEUED, RUNNING, READY, BLOCKED', () => {
const tasks = ALL_STATES.map(makeTask);
const result = filterTasksByTab(tasks, 'done');
diff --git a/web/test/focus-preserve.test.mjs b/web/test/focus-preserve.test.mjs
new file mode 100644
index 0000000..8acf73c
--- /dev/null
+++ b/web/test/focus-preserve.test.mjs
@@ -0,0 +1,170 @@
+// focus-preserve.test.mjs — contract tests for captureFocusState / restoreFocusState
+//
+// These pure helpers fix the focus-stealing bug: poll() calls renderTaskList /
+// renderActiveTaskList which do container.innerHTML='' on every tick, destroying
+// any focused answer input (task-answer-input or question-input).
+//
+// captureFocusState(container, activeEl)
+// Returns {taskId, className, value} if activeEl is a focusable answer input
+// inside a .task-card within container. Returns null otherwise.
+//
+// restoreFocusState(container, state)
+// Finds the equivalent input after rebuild and restores .value + .focus().
+//
+// Run with: node --test web/test/focus-preserve.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Inline implementations (contract) ─────────────────────────────────────────
+
+function captureFocusState(container, activeEl) {
+ if (!activeEl || !container.contains(activeEl)) return null;
+ const card = activeEl.closest('.task-card');
+ if (!card || !card.dataset || !card.dataset.taskId) return null;
+ return {
+ taskId: card.dataset.taskId,
+ className: activeEl.className,
+ value: activeEl.value || '',
+ };
+}
+
+function restoreFocusState(container, state) {
+ if (!state) return;
+ const card = container.querySelector(`.task-card[data-task-id="${state.taskId}"]`);
+ if (!card) return;
+ const el = card.querySelector(`.${state.className}`);
+ if (!el) return;
+ el.value = state.value;
+ el.focus();
+}
+
+// ── DOM-like mock helpers ──────────────────────────────────────────────────────
+
+function makeInput(className, value = '', taskId = 't1') {
+ const card = {
+ dataset: { taskId },
+ _children: [],
+ querySelector(sel) {
+ const cls = sel.replace(/^\./, '');
+ return this._children.find(c => c.className === cls) || null;
+ },
+ closest(sel) {
+ return sel === '.task-card' ? this : null;
+ },
+ };
+ const input = {
+ className,
+ value,
+ _focused: false,
+ focus() { this._focused = true; },
+ closest(sel) { return card.closest(sel); },
+ };
+ card._children.push(input);
+ return { card, input };
+}
+
+function makeContainer(cards = []) {
+ const allInputs = cards.flatMap(c => c._children);
+ return {
+ contains(el) { return allInputs.includes(el); },
+ querySelector(sel) {
+ const m = sel.match(/\.task-card\[data-task-id="([^"]+)"\]/);
+ if (!m) return null;
+ return cards.find(c => c.dataset.taskId === m[1]) || null;
+ },
+ };
+}
+
+// ── Tests: captureFocusState ───────────────────────────────────────────────────
+
+describe('captureFocusState', () => {
+ it('returns null when activeEl is null', () => {
+ assert.strictEqual(captureFocusState(makeContainer([]), null), null);
+ });
+
+ it('returns null when activeEl is undefined', () => {
+ assert.strictEqual(captureFocusState(makeContainer([]), undefined), null);
+ });
+
+ it('returns null when activeEl is outside the container', () => {
+ const { input } = makeInput('task-answer-input', 'hello', 't1');
+ const container = makeContainer([]); // empty — input not in it
+ assert.strictEqual(captureFocusState(container, input), null);
+ });
+
+ it('returns null when activeEl has no .task-card ancestor', () => {
+ const input = {
+ className: 'task-answer-input',
+ value: 'hi',
+ closest() { return null; },
+ };
+ const container = { contains() { return true; }, querySelector() { return null; } };
+ assert.strictEqual(captureFocusState(container, input), null);
+ });
+
+ it('returns state for task-answer-input inside a task card', () => {
+ const { card, input } = makeInput('task-answer-input', 'partial answer', 't42');
+ const state = captureFocusState(makeContainer([card]), input);
+ assert.deepStrictEqual(state, {
+ taskId: 't42',
+ className: 'task-answer-input',
+ value: 'partial answer',
+ });
+ });
+
+ it('returns state for question-input inside a task card', () => {
+ const { card, input } = makeInput('question-input', 'my answer', 'q99');
+ const state = captureFocusState(makeContainer([card]), input);
+ assert.deepStrictEqual(state, {
+ taskId: 'q99',
+ className: 'question-input',
+ value: 'my answer',
+ });
+ });
+
+ it('returns empty string value when input is empty', () => {
+ const { card, input } = makeInput('task-answer-input', '', 't1');
+ const state = captureFocusState(makeContainer([card]), input);
+ assert.strictEqual(state.value, '');
+ });
+});
+
+// ── Tests: restoreFocusState ───────────────────────────────────────────────────
+
+describe('restoreFocusState', () => {
+ it('is a no-op when state is null', () => {
+ restoreFocusState(makeContainer([]), null); // must not throw
+ });
+
+ it('is a no-op when state is undefined', () => {
+ restoreFocusState(makeContainer([]), undefined); // must not throw
+ });
+
+ it('is a no-op when task card is no longer in container', () => {
+ const state = { taskId: 'gone', className: 'task-answer-input', value: 'hi' };
+ restoreFocusState(makeContainer([]), state); // must not throw
+ });
+
+ it('restores value and focuses task-answer-input', () => {
+ const { card, input } = makeInput('task-answer-input', '', 't1');
+ const state = { taskId: 't1', className: 'task-answer-input', value: 'restored text' };
+ restoreFocusState(makeContainer([card]), state);
+ assert.strictEqual(input.value, 'restored text');
+ assert.ok(input._focused, 'input should have been focused');
+ });
+
+ it('restores value and focuses question-input', () => {
+ const { card, input } = makeInput('question-input', '', 'q7');
+ const state = { taskId: 'q7', className: 'question-input', value: 'type answer' };
+ restoreFocusState(makeContainer([card]), state);
+ assert.strictEqual(input.value, 'type answer');
+ assert.ok(input._focused);
+ });
+
+ it('is a no-op when element className is not found in rebuilt card', () => {
+ const { card } = makeInput('task-answer-input', '', 't1');
+ const state = { taskId: 't1', className: 'nonexistent-class', value: 'hi' };
+ restoreFocusState(makeContainer([card]), state); // must not throw
+ });
+});
diff --git a/web/test/is-user-editing.test.mjs b/web/test/is-user-editing.test.mjs
new file mode 100644
index 0000000..844d3cd
--- /dev/null
+++ b/web/test/is-user-editing.test.mjs
@@ -0,0 +1,65 @@
+// is-user-editing.test.mjs — contract tests for isUserEditing()
+//
+// isUserEditing(activeEl) returns true when the browser has focus in an element
+// that a poll-driven DOM refresh would destroy: INPUT, TEXTAREA, contenteditable,
+// or any element inside a [role="dialog"].
+//
+// Run with: node --test web/test/is-user-editing.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { isUserEditing } from '../app.js';
+
+// ── Mock helpers ───────────────────────────────────────────────────────────────
+
+function makeEl(tagName, extras = {}) {
+ return {
+ tagName: tagName.toUpperCase(),
+ isContentEditable: false,
+ closest(sel) { return null; },
+ ...extras,
+ };
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe('isUserEditing', () => {
+ it('returns false for null', () => {
+ assert.strictEqual(isUserEditing(null), false);
+ });
+
+ it('returns false for undefined', () => {
+ assert.strictEqual(isUserEditing(undefined), false);
+ });
+
+ it('returns true for INPUT element', () => {
+ assert.strictEqual(isUserEditing(makeEl('INPUT')), true);
+ });
+
+ it('returns true for TEXTAREA element', () => {
+ assert.strictEqual(isUserEditing(makeEl('TEXTAREA')), true);
+ });
+
+ it('returns true for contenteditable element', () => {
+ assert.strictEqual(isUserEditing(makeEl('DIV', { isContentEditable: true })), true);
+ });
+
+ it('returns true for element inside [role="dialog"]', () => {
+ const el = makeEl('SPAN', {
+ closest(sel) { return sel === '[role="dialog"]' ? {} : null; },
+ });
+ assert.strictEqual(isUserEditing(el), true);
+ });
+
+ it('returns false for a non-editing BUTTON', () => {
+ assert.strictEqual(isUserEditing(makeEl('BUTTON')), false);
+ });
+
+ it('returns false for a non-editing DIV without contenteditable', () => {
+ assert.strictEqual(isUserEditing(makeEl('DIV')), false);
+ });
+
+ it('returns false for a non-editing SPAN not inside a dialog', () => {
+ assert.strictEqual(isUserEditing(makeEl('SPAN')), false);
+ });
+});
diff --git a/web/test/render-dedup.test.mjs b/web/test/render-dedup.test.mjs
new file mode 100644
index 0000000..f13abb2
--- /dev/null
+++ b/web/test/render-dedup.test.mjs
@@ -0,0 +1,125 @@
+// render-dedup.test.mjs — contract tests for renderTaskList dedup logic
+//
+// Verifies the invariant: renderTaskList must never leave two .task-card elements
+// with the same data-task-id in the container. When a card already exists but
+// has no input field, the old card must be removed before inserting the new one.
+//
+// This file uses inline implementations that mirror the contract, not the actual
+// DOM (which requires a browser). The test defines the expected behaviour so that
+// a regression in app.js would motivate a failing test.
+//
+// Run with: node --test web/test/render-dedup.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Inline DOM mock ────────────────────────────────────────────────────────────
+
+function makeCard(taskId, hasInput = false) {
+ const card = {
+ dataset: { taskId },
+ _removed: false,
+ _hasInput: hasInput,
+ remove() { this._removed = true; },
+ querySelector(sel) {
+ if (!this._hasInput) return null;
+ // simulate .task-answer-input or .question-input being present
+ if (sel === '.task-answer-input, .question-input') {
+ return { className: 'task-answer-input', value: 'partial' };
+ }
+ return null;
+ },
+ };
+ return card;
+}
+
+// Minimal container mirroring what renderTaskList works with.
+function makeContainer(existingCards = []) {
+ const cards = [...existingCards];
+ const inserted = [];
+ return {
+ _cards: cards,
+ _inserted: inserted,
+ querySelectorAll(sel) {
+ if (sel === '.task-card') return [...cards];
+ return [];
+ },
+ querySelector(sel) {
+ const m = sel.match(/\.task-card\[data-task-id="([^"]+)"\]/);
+ if (!m) return null;
+ return cards.find(c => c.dataset.taskId === m[1] && !c._removed) || null;
+ },
+ insertBefore(node, ref) {
+ inserted.push(node);
+ if (!cards.includes(node)) cards.push(node);
+ },
+ get firstChild() { return cards[0] || null; },
+ };
+}
+
+// The fixed dedup logic extracted from renderTaskList (the contract we enforce).
+function selectCardForTask(task, container) {
+ const existing = container.querySelector(`.task-card[data-task-id="${task.id}"]`);
+ const hasInput = existing?.querySelector('.task-answer-input, .question-input');
+
+ let node;
+ if (existing && hasInput) {
+ node = existing; // reuse — preserves in-progress input
+ } else {
+ if (existing) existing.remove(); // <-- the fix: remove old before inserting new
+ node = makeCard(task.id, false); // simulates createTaskCard(task)
+ }
+ return node;
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe('renderTaskList dedup logic', () => {
+ it('creates a new card when no existing card in DOM', () => {
+ const container = makeContainer([]);
+ const task = { id: 't1' };
+ const node = selectCardForTask(task, container);
+ assert.equal(node.dataset.taskId, 't1');
+ assert.equal(node._removed, false);
+ });
+
+ it('removes old card and creates new when existing has no input', () => {
+ const old = makeCard('t2', false);
+ const container = makeContainer([old]);
+ const task = { id: 't2' };
+ const node = selectCardForTask(task, container);
+
+ // Old card must be removed to prevent duplication.
+ assert.equal(old._removed, true, 'old card should be marked removed');
+ // New card returned is not the old card.
+ assert.notEqual(node, old);
+ assert.equal(node.dataset.taskId, 't2');
+ });
+
+ it('reuses existing card when it has an input (preserves typing)', () => {
+ const existing = makeCard('t3', true); // has input
+ const container = makeContainer([existing]);
+ const task = { id: 't3' };
+ const node = selectCardForTask(task, container);
+
+ assert.equal(node, existing, 'should reuse the existing card');
+ assert.equal(existing._removed, false, 'existing card should NOT be removed');
+ });
+
+ it('never produces two cards for the same task id', () => {
+ // Simulate two poll cycles.
+ const old = makeCard('t4', false);
+ const container = makeContainer([old]);
+ const task = { id: 't4' };
+
+ // First "refresh" — old card has no input, so remove and insert new.
+ const newCard = selectCardForTask(task, container);
+ // Simulate insert: mark old as removed (done by remove()), add new.
+ container._cards.splice(container._cards.indexOf(old), 1);
+ if (!container._cards.includes(newCard)) container._cards.push(newCard);
+
+ // Verify at most one card with this id exists.
+ const survivors = container._cards.filter(c => c.dataset.taskId === 't4' && !c._removed);
+ assert.equal(survivors.length, 1, 'exactly one card for t4 should remain');
+ });
+});