summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-05 18:51:50 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-05 18:51:50 +0000
commitcf83444a9d341ae362e65a9f995100c69176887c (patch)
tree0dc12aea9510d10d9e60e9c58473cbdb9db5db47 /web
parent680e5f668637248073c1f8f7e3547810ab1ada36 (diff)
Rescue work from claudomator-work: question/answer, ratelimit, start-next-task
Merges features developed in /site/doot.terst.org/claudomator-work (a stale clone) into the canonical repo: - executor: QuestionRegistry for human-in-the-loop answers, rate limit detection and exponential backoff retry (ratelimit.go, question.go) - executor/claude.go: process group isolation (SIGKILL orphans on cancel), os.Pipe for reliable stdout drain, backoff retry on rate limits - api/scripts.go: POST /api/scripts/start-next-task handler - api/server.go: startNextTaskScript field, answer-question route, BroadcastQuestion for WebSocket question events - web: Cancel/Restart buttons, question banner UI, log viewer, validate section, WebSocket auto-connect All tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js481
-rw-r--r--web/index.html6
-rw-r--r--web/style.css262
-rw-r--r--web/test/start-next-task.test.mjs84
-rw-r--r--web/test/task-actions.test.mjs53
5 files changed, 876 insertions, 10 deletions
diff --git a/web/app.js b/web/app.js
index 6289d00..6d2a029 100644
--- a/web/app.js
+++ b/web/app.js
@@ -69,20 +69,41 @@ function createTaskCard(task) {
card.appendChild(desc);
}
- // Footer: Run button (only for PENDING / FAILED)
- if (task.state === 'PENDING' || task.state === 'FAILED') {
+ // Footer: action buttons based on state
+ const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']);
+ if (task.state === 'PENDING' || task.state === 'RUNNING' || RESTART_STATES.has(task.state)) {
const footer = document.createElement('div');
footer.className = 'task-card-footer';
- const btn = document.createElement('button');
- btn.className = 'btn-run';
- btn.textContent = 'Run';
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- handleRun(task.id, btn, footer);
- });
+ if (task.state === 'PENDING') {
+ const btn = document.createElement('button');
+ btn.className = 'btn-run';
+ btn.textContent = 'Run';
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleRun(task.id, btn, footer);
+ });
+ footer.appendChild(btn);
+ } else if (task.state === 'RUNNING') {
+ const btn = document.createElement('button');
+ btn.className = 'btn-cancel';
+ btn.textContent = 'Cancel';
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleCancel(task.id, btn, footer);
+ });
+ footer.appendChild(btn);
+ } else if (RESTART_STATES.has(task.state)) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-restart';
+ btn.textContent = 'Restart';
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleRestart(task.id, btn, footer);
+ });
+ footer.appendChild(btn);
+ }
- footer.appendChild(btn);
card.appendChild(footer);
}
@@ -252,6 +273,66 @@ async function handleRun(taskId, btn, footer) {
}
}
+// ── Cancel / Restart actions ──────────────────────────────────────────────────
+
+async function cancelTask(taskId) {
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/cancel`, { method: 'POST' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+async function restartTask(taskId) {
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/restart`, { method: 'POST' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+async function handleCancel(taskId, btn, footer) {
+ btn.disabled = true;
+ btn.textContent = 'Cancelling…';
+ const prev = footer.querySelector('.task-error');
+ if (prev) prev.remove();
+
+ try {
+ await cancelTask(taskId);
+ await poll();
+ } catch (err) {
+ btn.disabled = false;
+ btn.textContent = 'Cancel';
+ const errEl = document.createElement('span');
+ errEl.className = 'task-error';
+ errEl.textContent = `Failed: ${err.message}`;
+ footer.appendChild(errEl);
+ }
+}
+
+async function handleRestart(taskId, btn, footer) {
+ btn.disabled = true;
+ btn.textContent = 'Restarting…';
+ const prev = footer.querySelector('.task-error');
+ if (prev) prev.remove();
+
+ try {
+ await restartTask(taskId);
+ await poll();
+ } catch (err) {
+ btn.disabled = false;
+ btn.textContent = 'Restart';
+ const errEl = document.createElement('span');
+ errEl.className = 'task-error';
+ errEl.textContent = `Failed: ${err.message}`;
+ footer.appendChild(errEl);
+ }
+}
+
// ── Delete template ────────────────────────────────────────────────────────────
async function deleteTemplate(id) {
@@ -286,6 +367,147 @@ function startPolling(intervalMs = 10_000) {
setInterval(poll, intervalMs);
}
+
+
+// ── WebSocket (real-time events) ──────────────────────────────────────────────
+
+let ws = null;
+let activeLogSource = null;
+
+function connectWebSocket() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const url = `${protocol}//${window.location.host}${BASE_PATH}/api/ws`;
+ ws = new WebSocket(url);
+
+ ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ handleWsEvent(data);
+ } catch { /* ignore parse errors */ }
+ };
+
+ ws.onclose = () => {
+ // Reconnect after 3 seconds.
+ setTimeout(connectWebSocket, 3000);
+ };
+
+ ws.onerror = () => {
+ ws.close();
+ };
+}
+
+function handleWsEvent(data) {
+ switch (data.type) {
+ case 'task_completed':
+ poll(); // refresh task list
+ break;
+ case 'task_question':
+ showQuestionBanner(data);
+ break;
+ }
+}
+
+// ── Question UI ───────────────────────────────────────────────────────────────
+
+function showQuestionBanner(data) {
+ const taskId = data.task_id;
+ const questionId = data.question_id;
+ const questionData = data.data || {};
+ const questions = questionData.questions || [];
+
+ // Find the task card for this task.
+ const card = document.querySelector(`.task-card[data-task-id="${taskId}"]`);
+ if (!card) return;
+
+ // Remove any existing question banner on this card.
+ const existing = card.querySelector('.question-banner');
+ if (existing) existing.remove();
+
+ const banner = document.createElement('div');
+ banner.className = 'question-banner';
+
+ for (const q of questions) {
+ const qDiv = document.createElement('div');
+ qDiv.className = 'question-item';
+
+ const label = document.createElement('div');
+ label.className = 'question-text';
+ label.textContent = q.question || 'The agent has a question';
+ qDiv.appendChild(label);
+
+ const options = q.options || [];
+ if (options.length > 0) {
+ const btnGroup = document.createElement('div');
+ btnGroup.className = 'question-options';
+ for (const opt of options) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-question-option';
+ btn.textContent = opt.label;
+ if (opt.description) btn.title = opt.description;
+ btn.addEventListener('click', () => {
+ submitAnswer(taskId, questionId, opt.label, banner);
+ });
+ btnGroup.appendChild(btn);
+ }
+ qDiv.appendChild(btnGroup);
+ }
+
+ // Always show a free-text input as fallback.
+ const inputRow = document.createElement('div');
+ inputRow.className = 'question-input-row';
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.className = 'question-input';
+ input.placeholder = 'Type an answer…';
+ const sendBtn = document.createElement('button');
+ sendBtn.className = 'btn-question-send';
+ sendBtn.textContent = 'Send';
+ sendBtn.addEventListener('click', () => {
+ const val = input.value.trim();
+ if (val) submitAnswer(taskId, questionId, val, banner);
+ });
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ const val = input.value.trim();
+ if (val) submitAnswer(taskId, questionId, val, banner);
+ }
+ });
+ inputRow.append(input, sendBtn);
+ qDiv.appendChild(inputRow);
+
+ banner.appendChild(qDiv);
+ }
+
+ card.appendChild(banner);
+}
+
+async function submitAnswer(taskId, questionId, answer, banner) {
+ // Disable all buttons in the banner.
+ banner.querySelectorAll('button').forEach(b => { b.disabled = true; });
+ banner.querySelector('.question-input')?.setAttribute('disabled', '');
+
+ try {
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/answer`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ question_id: questionId, answer }),
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error || `HTTP ${res.status}`);
+ }
+ banner.remove();
+ } catch (err) {
+ const errEl = document.createElement('div');
+ errEl.className = 'question-error';
+ errEl.textContent = `Failed: ${err.message}`;
+ banner.appendChild(errEl);
+ // Re-enable buttons.
+ banner.querySelectorAll('button').forEach(b => { b.disabled = false; });
+ banner.querySelector('.question-input')?.removeAttribute('disabled');
+ }
+}
+
// ── Elaborate (Draft with AI) ─────────────────────────────────────────────────
async function elaborateTask(prompt) {
@@ -302,6 +524,86 @@ async function elaborateTask(prompt) {
return res.json();
}
+// ── Validate ──────────────────────────────────────────────────────────────────
+
+async function validateTask(payload) {
+ const res = await fetch(`${API_BASE}/api/tasks/validate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) {
+ let msg = res.statusText;
+ try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+function buildValidatePayload() {
+ const f = document.getElementById('task-form');
+ const name = f.querySelector('[name="name"]').value;
+ const instructions = f.querySelector('[name="instructions"]').value;
+ const working_dir = f.querySelector('[name="working_dir"]').value;
+ const model = f.querySelector('[name="model"]').value;
+ const allowedToolsEl = f.querySelector('[name="allowed_tools"]');
+ const allowed_tools = allowedToolsEl
+ ? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean)
+ : [];
+ return { name, claude: { instructions, working_dir, model, allowed_tools } };
+}
+
+function renderValidationResult(result) {
+ const container = document.getElementById('validate-result');
+ container.removeAttribute('hidden');
+ container.dataset.clarity = result.clarity;
+
+ let icon;
+ if (result.ready === true) {
+ icon = '✓';
+ } else if (result.clarity === 'ambiguous') {
+ icon = '⚠';
+ } else {
+ icon = '✗';
+ }
+
+ container.innerHTML = '';
+
+ const header = document.createElement('div');
+ header.className = 'validate-header';
+ const iconSpan = document.createElement('span');
+ iconSpan.className = 'validate-icon';
+ iconSpan.textContent = icon;
+ const summarySpan = document.createElement('span');
+ summarySpan.textContent = ' ' + (result.summary || '');
+ header.append(iconSpan, summarySpan);
+ container.appendChild(header);
+
+ if (result.questions && result.questions.length > 0) {
+ const ul = document.createElement('ul');
+ ul.className = 'validate-questions';
+ for (const q of result.questions) {
+ const li = document.createElement('li');
+ li.className = q.severity === 'blocking' ? 'validate-blocking' : 'validate-minor';
+ li.textContent = q.text;
+ ul.appendChild(li);
+ }
+ container.appendChild(ul);
+ }
+
+ if (result.suggestions && result.suggestions.length > 0) {
+ const ul = document.createElement('ul');
+ ul.className = 'validate-suggestions';
+ for (const s of result.suggestions) {
+ const li = document.createElement('li');
+ li.className = 'validate-suggestion';
+ li.textContent = s;
+ ul.appendChild(li);
+ }
+ container.appendChild(ul);
+ }
+}
+
// ── Task modal ────────────────────────────────────────────────────────────────
function openTaskModal() {
@@ -312,6 +614,10 @@ function closeTaskModal() {
document.getElementById('task-modal').close();
document.getElementById('task-form').reset();
document.getElementById('elaborate-prompt').value = '';
+ const validateResult = document.getElementById('validate-result');
+ validateResult.setAttribute('hidden', '');
+ validateResult.innerHTML = '';
+ validateResult.removeAttribute('data-clarity');
}
async function createTask(formData) {
@@ -671,6 +977,127 @@ async function handleViewLogs(execId) {
}
}
+// ── Log viewer ────────────────────────────────────────────────────────────────
+
+function openLogViewer(execId, containerEl) {
+ // Save original children so Back can restore them (with event listeners intact)
+ const originalChildren = [...containerEl.childNodes];
+
+ containerEl.innerHTML = '';
+
+ const viewer = document.createElement('div');
+ viewer.className = 'log-viewer';
+
+ // Back button
+ const backBtn = document.createElement('button');
+ backBtn.className = 'log-back-btn';
+ backBtn.textContent = '← Back';
+ backBtn.addEventListener('click', () => {
+ closeLogViewer();
+ containerEl.innerHTML = '';
+ for (const node of originalChildren) containerEl.appendChild(node);
+ });
+ viewer.appendChild(backBtn);
+
+ // Pulsing status indicator
+ const statusEl = document.createElement('div');
+ statusEl.className = 'log-status-indicator';
+ statusEl.textContent = 'Streaming...';
+ viewer.appendChild(statusEl);
+
+ // Log output area
+ const logOutput = document.createElement('div');
+ logOutput.className = 'log-output';
+ logOutput.style.fontFamily = 'monospace';
+ logOutput.style.overflowY = 'auto';
+ logOutput.style.maxHeight = '400px';
+ viewer.appendChild(logOutput);
+
+ containerEl.appendChild(viewer);
+
+ let userScrolled = false;
+ logOutput.addEventListener('scroll', () => {
+ const nearBottom = logOutput.scrollHeight - logOutput.scrollTop - logOutput.clientHeight < 50;
+ if (!nearBottom) userScrolled = true;
+ });
+
+ const source = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`);
+ activeLogSource = source;
+
+ source.onmessage = (event) => {
+ let data;
+ try { data = JSON.parse(event.data); } catch { return; }
+
+ const line = document.createElement('div');
+ line.className = 'log-line';
+
+ switch (data.type) {
+ case 'text': {
+ line.classList.add('log-text');
+ line.textContent = data.text ?? data.content ?? '';
+ break;
+ }
+ case 'tool_use': {
+ line.classList.add('log-tool-use');
+ const toolName = document.createElement('span');
+ toolName.className = 'tool-name';
+ toolName.textContent = `[${data.name ?? 'Tool'}]`;
+ line.appendChild(toolName);
+ const inputStr = data.input ? JSON.stringify(data.input) : '';
+ const inputPreview = document.createElement('span');
+ inputPreview.textContent = ' ' + inputStr.slice(0, 120);
+ line.appendChild(inputPreview);
+ break;
+ }
+ case 'tool_result': {
+ line.classList.add('log-tool-result');
+ line.style.opacity = '0.6';
+ const content = Array.isArray(data.content)
+ ? data.content.map(c => c.text ?? '').join(' ')
+ : (data.content ?? '');
+ line.textContent = String(content).slice(0, 120);
+ break;
+ }
+ case 'cost': {
+ line.classList.add('log-cost');
+ const cost = data.total_cost ?? data.cost ?? 0;
+ line.textContent = `Cost: $${Number(cost).toFixed(3)}`;
+ break;
+ }
+ default:
+ return;
+ }
+
+ logOutput.appendChild(line);
+ if (!userScrolled) {
+ logOutput.scrollTop = logOutput.scrollHeight;
+ }
+ };
+
+ source.addEventListener('done', () => {
+ source.close();
+ activeLogSource = null;
+ userScrolled = false;
+ statusEl.classList.remove('log-status-indicator');
+ statusEl.textContent = 'Stream complete';
+ });
+
+ source.onerror = () => {
+ source.close();
+ activeLogSource = null;
+ statusEl.hidden = true;
+ const errEl = document.createElement('div');
+ errEl.className = 'log-line log-error';
+ errEl.textContent = 'Connection error. Stream closed.';
+ logOutput.appendChild(errEl);
+ };
+}
+
+function closeLogViewer() {
+ activeLogSource?.close();
+ activeLogSource = null;
+}
+
// ── Tab switching ─────────────────────────────────────────────────────────────
function switchTab(name) {
@@ -711,6 +1138,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
startPolling();
+ connectWebSocket();
// Side panel close
document.getElementById('btn-close-panel').addEventListener('click', closeTaskPanel);
@@ -730,6 +1158,25 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-new-task').addEventListener('click', openTaskModal);
document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal);
+ // Validate button
+ document.getElementById('btn-validate').addEventListener('click', async () => {
+ const btn = document.getElementById('btn-validate');
+ const resultDiv = document.getElementById('validate-result');
+ btn.disabled = true;
+ btn.textContent = 'Checking…';
+ try {
+ const payload = buildValidatePayload();
+ const result = await validateTask(payload);
+ renderValidationResult(result);
+ } catch (err) {
+ resultDiv.removeAttribute('hidden');
+ resultDiv.textContent = 'Validation failed: ' + err.message;
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Validate Instructions';
+ }
+ });
+
// Draft with AI button
const btnElaborate = document.getElementById('btn-elaborate');
btnElaborate.addEventListener('click', async () => {
@@ -782,6 +1229,14 @@ document.addEventListener('DOMContentLoaded', () => {
banner.className = 'elaborate-banner';
banner.textContent = 'AI draft ready — review and submit.';
document.getElementById('task-form').querySelector('.elaborate-section').appendChild(banner);
+
+ // Auto-validate after elaboration
+ try {
+ const result = await validateTask(buildValidatePayload());
+ renderValidationResult(result);
+ } catch (_) {
+ // silent - elaboration already succeeded, validation is bonus
+ }
} catch (err) {
const errEl = document.createElement('p');
errEl.className = 'form-error';
@@ -805,6 +1260,12 @@ document.addEventListener('DOMContentLoaded', () => {
btn.textContent = 'Creating…';
try {
+ const validateResult = document.getElementById('validate-result');
+ if (!validateResult.hasAttribute('hidden') && validateResult.dataset.clarity && validateResult.dataset.clarity !== 'clear') {
+ if (!window.confirm('The validator flagged issues. Create task anyway?')) {
+ return;
+ }
+ }
await createTask(new FormData(e.target));
} catch (err) {
const errEl = document.createElement('p');
diff --git a/web/index.html b/web/index.html
index 6d7f23b..482b9a9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -50,6 +50,12 @@
<hr class="form-divider">
<label>Name <input name="name" required></label>
<label>Instructions <textarea name="instructions" rows="6" required></textarea></label>
+ <div class="validate-section">
+ <button type="button" id="btn-validate" class="btn-secondary">
+ Validate Instructions
+ </button>
+ <div id="validate-result" hidden></div>
+ </div>
<label>Working Directory <input name="working_dir" placeholder="/path/to/repo"></label>
<label>Model <input name="model" value="sonnet"></label>
<label>Max Budget (USD) <input name="max_budget_usd" type="number" step="0.01" value="1.00"></label>
diff --git a/web/style.css b/web/style.css
index de8ce83..268f80c 100644
--- a/web/style.css
+++ b/web/style.css
@@ -225,6 +225,40 @@ main {
cursor: not-allowed;
}
+.btn-cancel {
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.35em 0.85em;
+ border-radius: 0.375rem;
+ border: none;
+ cursor: pointer;
+ background: var(--state-failed);
+ color: #fff;
+ transition: opacity 0.15s;
+}
+
+.btn-cancel:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-restart {
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.35em 0.85em;
+ border-radius: 0.375rem;
+ border: none;
+ cursor: pointer;
+ background: var(--text-muted);
+ color: #0f172a;
+ transition: opacity 0.15s;
+}
+
+.btn-restart:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
.task-error {
font-size: 0.78rem;
color: var(--state-failed);
@@ -704,3 +738,231 @@ dialog label select:focus {
.logs-modal-body .meta-grid {
row-gap: 0.625rem;
}
+
+/* ── Question banner ───────────────────────────────────────────────────────── */
+
+.question-banner {
+ margin-top: 0.75rem;
+ padding: 0.75rem;
+ background: #1e293b;
+ border: 1px solid #f59e0b;
+ border-radius: 0.5rem;
+}
+
+.question-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.question-text {
+ font-weight: 600;
+ color: #f59e0b;
+ font-size: 0.9rem;
+}
+
+.question-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.btn-question-option {
+ padding: 0.375rem 0.75rem;
+ border: 1px solid #475569;
+ border-radius: 0.375rem;
+ background: #334155;
+ color: #e2e8f0;
+ cursor: pointer;
+ font-size: 0.8rem;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.btn-question-option:hover:not(:disabled) {
+ background: #475569;
+ border-color: #f59e0b;
+}
+
+.btn-question-option:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.question-input-row {
+ display: flex;
+ gap: 0.375rem;
+}
+
+.question-input {
+ flex: 1;
+ padding: 0.375rem 0.5rem;
+ border: 1px solid #475569;
+ border-radius: 0.375rem;
+ background: #0f172a;
+ color: #e2e8f0;
+ font-size: 0.8rem;
+}
+
+.question-input:focus {
+ outline: none;
+ border-color: #f59e0b;
+}
+
+.btn-question-send {
+ padding: 0.375rem 0.75rem;
+ border: 1px solid #f59e0b;
+ border-radius: 0.375rem;
+ background: #f59e0b;
+ color: #0f172a;
+ font-weight: 600;
+ cursor: pointer;
+ font-size: 0.8rem;
+}
+
+.btn-question-send:hover:not(:disabled) {
+ background: #fbbf24;
+}
+
+.btn-question-send:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.question-error {
+ color: #f87171;
+ font-size: 0.8rem;
+ margin-top: 0.25rem;
+}
+
+/* ── Log Viewer ──────────────────────────────────────────────────────────── */
+
+.log-viewer {
+ width: 100%;
+ padding: 0;
+}
+
+.log-back-btn {
+ font-size: 0.78rem;
+ font-weight: 600;
+ padding: 0.3em 0.75em;
+ border-radius: 0.375rem;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ margin-bottom: 1rem;
+ display: inline-flex;
+ align-items: center;
+}
+
+.log-back-btn:hover {
+ background: var(--border);
+ color: var(--text);
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+
+.log-status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ margin-bottom: 0.75rem;
+}
+
+.log-status-indicator::before {
+ content: '';
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--state-running);
+ flex-shrink: 0;
+ animation: pulse-dot 1.4s ease-in-out infinite;
+}
+
+.log-output {
+ font-family: monospace;
+ font-size: 0.8rem;
+ overflow-y: auto;
+ max-height: 400px;
+ background: var(--bg);
+ padding: 0.625rem 0.75rem;
+ border-radius: 0.375rem;
+ border: 1px solid var(--border);
+}
+
+.log-line {
+ padding: 2px 0;
+ line-height: 1.5;
+}
+
+.log-text {
+ color: var(--text);
+}
+
+.log-tool-use {
+ background: rgba(56, 189, 248, 0.1);
+ padding: 4px 8px;
+ border-radius: 3px;
+ margin: 2px 0;
+}
+
+.tool-name {
+ color: var(--accent);
+ font-weight: bold;
+ margin-right: 6px;
+}
+
+.log-tool-result {
+ color: var(--text-muted);
+ opacity: 0.6;
+}
+
+.log-cost {
+ color: var(--state-running);
+ font-weight: bold;
+ margin-top: 8px;
+}
+
+/* ── Validate section ────────────────────────────────────────────────────── */
+
+.validate-section {
+ margin-top: 8px;
+}
+
+#validate-result {
+ border-left: 3px solid transparent;
+ padding: 8px 12px;
+ margin-top: 8px;
+ font-size: 0.85rem;
+}
+
+#validate-result[data-clarity="clear"] {
+ border-color: var(--state-completed);
+}
+
+#validate-result[data-clarity="ambiguous"] {
+ border-color: var(--state-running);
+}
+
+#validate-result[data-clarity="unclear"] {
+ border-color: var(--state-failed);
+}
+
+.validate-blocking {
+ color: var(--state-failed);
+}
+
+.validate-minor {
+ color: var(--state-running);
+}
+
+.validate-suggestion {
+ color: #94a3b8;
+}
diff --git a/web/test/start-next-task.test.mjs b/web/test/start-next-task.test.mjs
new file mode 100644
index 0000000..6863f7e
--- /dev/null
+++ b/web/test/start-next-task.test.mjs
@@ -0,0 +1,84 @@
+// start-next-task.test.mjs — contract tests for startNextTask fetch helper
+// Run: node --test web/test/start-next-task.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Contract: startNextTask(basePath, fetchFn) ─────────────────────────────────
+// POSTs to ${basePath}/api/scripts/start-next-task
+// Returns {output, exit_code} on HTTP 2xx
+// Throws on HTTP error
+
+async function startNextTask(basePath, fetchFn) {
+ const res = await fetchFn(`${basePath}/api/scripts/start-next-task`, { method: 'POST' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+describe('startNextTask', () => {
+ it('POSTs to /api/scripts/start-next-task', async () => {
+ let captured = null;
+ const mockFetch = (url, opts) => {
+ captured = { url, opts };
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ output: 'claudomator start abc-123\n', exit_code: 0 }),
+ });
+ };
+
+ await startNextTask('http://localhost:8484', mockFetch);
+ assert.equal(captured.url, 'http://localhost:8484/api/scripts/start-next-task');
+ assert.equal(captured.opts.method, 'POST');
+ });
+
+ it('returns output and exit_code on success', async () => {
+ const mockFetch = () => Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ output: 'claudomator start abc-123\n', exit_code: 0 }),
+ });
+
+ const result = await startNextTask('', mockFetch);
+ assert.equal(result.output, 'claudomator start abc-123\n');
+ assert.equal(result.exit_code, 0);
+ });
+
+ it('returns output when no task available', async () => {
+ const mockFetch = () => Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ output: 'No task to start.\n', exit_code: 0 }),
+ });
+
+ const result = await startNextTask('', mockFetch);
+ assert.equal(result.output, 'No task to start.\n');
+ });
+
+ it('throws with server error message on HTTP error', async () => {
+ const mockFetch = () => Promise.resolve({
+ ok: false,
+ status: 500,
+ json: () => Promise.resolve({ error: 'script execution failed' }),
+ });
+
+ await assert.rejects(
+ () => startNextTask('', mockFetch),
+ /script execution failed/,
+ );
+ });
+
+ it('throws with HTTP status on non-JSON error response', async () => {
+ const mockFetch = () => Promise.resolve({
+ ok: false,
+ status: 503,
+ json: () => Promise.reject(new Error('not json')),
+ });
+
+ await assert.rejects(
+ () => startNextTask('', mockFetch),
+ /HTTP 503/,
+ );
+ });
+});
diff --git a/web/test/task-actions.test.mjs b/web/test/task-actions.test.mjs
new file mode 100644
index 0000000..f2c21c4
--- /dev/null
+++ b/web/test/task-actions.test.mjs
@@ -0,0 +1,53 @@
+// task-actions.test.mjs — button visibility logic for Cancel/Restart actions
+//
+// Run with: node --test web/test/task-actions.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Logic under test ──────────────────────────────────────────────────────────
+
+const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']);
+
+function getCardAction(state) {
+ if (state === 'PENDING') return 'run';
+ if (state === 'RUNNING') return 'cancel';
+ if (RESTART_STATES.has(state)) return 'restart';
+ return null;
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('task card action buttons', () => {
+ it('shows Run button for PENDING', () => {
+ assert.equal(getCardAction('PENDING'), 'run');
+ });
+
+ it('shows Cancel button for RUNNING', () => {
+ assert.equal(getCardAction('RUNNING'), 'cancel');
+ });
+
+ it('shows Restart button for FAILED', () => {
+ assert.equal(getCardAction('FAILED'), 'restart');
+ });
+
+ it('shows Restart button for TIMED_OUT', () => {
+ assert.equal(getCardAction('TIMED_OUT'), 'restart');
+ });
+
+ it('shows Restart button for CANCELLED', () => {
+ assert.equal(getCardAction('CANCELLED'), 'restart');
+ });
+
+ it('shows no button for COMPLETED', () => {
+ assert.equal(getCardAction('COMPLETED'), null);
+ });
+
+ it('shows no button for QUEUED', () => {
+ assert.equal(getCardAction('QUEUED'), null);
+ });
+
+ it('shows no button for BUDGET_EXCEEDED', () => {
+ assert.equal(getCardAction('BUDGET_EXCEEDED'), null);
+ });
+});