diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 21:03:50 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 21:03:50 +0000 |
| commit | 632ea5a44731af94b6238f330a3b5440906c8ae7 (patch) | |
| tree | d8c780412598d66b89ef390b5729e379fdfd9d5b /web | |
| parent | 406247b14985ab57902e8e42898dc8cb8960290d (diff) | |
| parent | 93a4c852bf726b00e8014d385165f847763fa214 (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')
| -rw-r--r-- | web/app.js | 416 | ||||
| -rw-r--r-- | web/index.html | 14 | ||||
| -rw-r--r-- | web/style.css | 68 | ||||
| -rw-r--r-- | web/test/active-pane.test.mjs | 81 | ||||
| -rw-r--r-- | web/test/filter-tabs.test.mjs | 38 | ||||
| -rw-r--r-- | web/test/focus-preserve.test.mjs | 170 | ||||
| -rw-r--r-- | web/test/is-user-editing.test.mjs | 65 | ||||
| -rw-r--r-- | web/test/render-dedup.test.mjs | 125 |
8 files changed, 953 insertions, 24 deletions
@@ -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'); + }); +}); |
