diff options
Diffstat (limited to 'web/app.js')
| -rw-r--r-- | web/app.js | 481 |
1 files changed, 471 insertions, 10 deletions
@@ -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'); |
