summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js481
1 files changed, 471 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');