diff options
| -rw-r--r-- | web/app.js | 237 |
1 files changed, 232 insertions, 5 deletions
@@ -70,8 +70,8 @@ function createTaskCard(task) { } // Footer: action buttons based on state - const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']); - if (task.state === 'PENDING' || task.state === 'RUNNING' || task.state === 'READY' || task.state === 'BLOCKED' || RESTART_STATES.has(task.state)) { + const RESTART_STATES = new Set(['FAILED', 'CANCELLED']); + if (task.state === 'PENDING' || task.state === 'RUNNING' || task.state === 'READY' || task.state === 'BLOCKED' || task.state === 'TIMED_OUT' || RESTART_STATES.has(task.state)) { const footer = document.createElement('div'); footer.className = 'task-card-footer'; @@ -112,6 +112,15 @@ function createTaskCard(task) { footer.appendChild(rejectBtn); } else if (task.state === 'BLOCKED') { renderQuestionFooter(task, footer); + } else if (task.state === 'TIMED_OUT') { + const btn = document.createElement('button'); + btn.className = 'btn-resume'; + btn.textContent = 'Resume'; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + handleResume(task.id, btn, footer); + }); + footer.appendChild(btn); } else if (RESTART_STATES.has(task.state)) { const btn = document.createElement('button'); btn.className = 'btn-restart'; @@ -126,7 +135,27 @@ function createTaskCard(task) { card.appendChild(footer); } - card.addEventListener('click', () => openTaskPanel(task.id)); + if (!NON_DELETABLE_STATES.has(task.state)) { + const delBtn = document.createElement('button'); + delBtn.className = 'btn-delete-task'; + delBtn.title = 'Delete task'; + delBtn.textContent = '✕'; + delBtn.addEventListener('click', (e) => { + e.stopPropagation(); + handleDelete(task.id, card); + }); + card.appendChild(delBtn); + } + + if (EDITABLE_STATES.has(task.state)) { + card.classList.add('task-card--editable'); + const editForm = createEditForm(task); + editForm.hidden = true; + card.appendChild(editForm); + card.addEventListener('click', () => { editForm.hidden = !editForm.hidden; }); + } else { + card.addEventListener('click', () => openTaskPanel(task.id)); + } return card; } @@ -314,7 +343,182 @@ async function restartTask(taskId) { return res.json(); } +async function resumeTask(taskId) { + const res = await fetch(`${API_BASE}/api/tasks/${taskId}/resume`, { 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(); +} + +const NON_DELETABLE_STATES = new Set(['RUNNING', 'QUEUED']); +const EDITABLE_STATES = new Set(['PENDING', 'FAILED', 'CANCELLED', 'TIMED_OUT', 'BUDGET_EXCEEDED']); + +// Convert Duration JSON {"Duration": <ns>} to a human string for a text input (e.g. "15m"). +function formatDurationForInput(timeout) { + const ns = timeout && timeout.Duration; + if (!ns) return ''; + const secs = Math.round(ns / 1e9); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + const remSecs = secs % 60; + if (mins < 60) return remSecs > 0 ? `${mins}m${remSecs}s` : `${mins}m`; + const hrs = Math.floor(mins / 60); + const remMins = mins % 60; + return remMins > 0 ? `${hrs}h${remMins}m` : `${hrs}h`; +} + +async function deleteTask(taskId) { + const res = await fetch(`${API_BASE}/api/tasks/${taskId}`, { method: 'DELETE' }); + 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); + } +} + +async function handleDelete(taskId, card) { + if (!confirm('Delete this task? This cannot be undone.')) return; + try { + await deleteTask(taskId); + card.remove(); + } catch (err) { + alert(`Failed to delete: ${err.message}`); + } +} + +// ── Inline task editor ──────────────────────────────────────────────────────── + +async function updateTask(taskId, body) { + const res = await fetch(`${API_BASE}/api/tasks/${taskId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { const b = await res.json(); msg = b.error || b.message || msg; } catch {} + throw new Error(msg); + } + return res.json(); +} + +function createEditForm(task) { + const c = task.claude || {}; + + const form = document.createElement('div'); + form.className = 'task-inline-edit'; + // Prevent card-level click from toggling this form while user interacts inside it. + form.addEventListener('click', (e) => e.stopPropagation()); + + function makeField(labelText, tag, attrs) { + const label = document.createElement('label'); + label.textContent = labelText; + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'value') el.value = v; + else el.setAttribute(k, v); + } + label.appendChild(el); + return label; + } + + form.appendChild(makeField('Name', 'input', { type: 'text', name: 'name', value: task.name || '' })); + form.appendChild(makeField('Description', 'textarea', { name: 'description', rows: '2', value: task.description || '' })); + form.appendChild(makeField('Instructions', 'textarea', { name: 'instructions', rows: '4', value: c.instructions || '' })); + form.appendChild(makeField('Model', 'input', { type: 'text', name: 'model', value: c.model || 'sonnet' })); + form.appendChild(makeField('Working Directory', 'input', { type: 'text', name: 'working_dir', value: c.working_dir || '', placeholder: '/path/to/repo' })); + form.appendChild(makeField('Max Budget (USD)', 'input', { type: 'number', name: 'max_budget_usd', step: '0.01', value: c.max_budget_usd != null ? String(c.max_budget_usd) : '1.00' })); + form.appendChild(makeField('Timeout', 'input', { type: 'text', name: 'timeout', value: formatDurationForInput(task.timeout) || '15m', placeholder: '15m' })); + + const prioLabel = document.createElement('label'); + prioLabel.textContent = 'Priority'; + const prioSel = document.createElement('select'); + prioSel.name = 'priority'; + for (const val of ['high', 'normal', 'low']) { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = val.charAt(0).toUpperCase() + val.slice(1); + if (val === (task.priority || 'normal')) opt.selected = true; + prioSel.appendChild(opt); + } + prioLabel.appendChild(prioSel); + form.appendChild(prioLabel); + + const errEl = document.createElement('div'); + errEl.className = 'inline-edit-error'; + errEl.hidden = true; + form.appendChild(errEl); + + const actions = document.createElement('div'); + actions.className = 'inline-edit-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', () => { form.hidden = true; }); + + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'btn-primary btn-sm'; + saveBtn.textContent = 'Save'; + saveBtn.addEventListener('click', () => handleEditSave(task.id, form, saveBtn)); + + actions.append(cancelBtn, saveBtn); + form.appendChild(actions); + + return form; +} + +async function handleEditSave(taskId, form, saveBtn) { + const get = name => form.querySelector(`[name="${name}"]`)?.value ?? ''; + + const body = { + name: get('name'), + description: get('description'), + claude: { + model: get('model'), + instructions: get('instructions'), + working_dir: get('working_dir'), + max_budget_usd: parseFloat(get('max_budget_usd')), + }, + timeout: get('timeout'), + priority: get('priority'), + }; + + const errEl = form.querySelector('.inline-edit-error'); + errEl.hidden = true; + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + + try { + await updateTask(taskId, body); + form.hidden = true; + + // Brief success flash on the card + const card = form.closest('.task-card'); + const flash = document.createElement('div'); + flash.className = 'inline-edit-success'; + flash.textContent = 'Saved'; + card.appendChild(flash); + setTimeout(() => flash.remove(), 2000); + + await poll(); + } catch (err) { + errEl.textContent = `Failed to save: ${err.message}`; + errEl.hidden = false; + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + } +} + function renderQuestionFooter(task, footer) { + // Prevent any tap inside the question footer from opening the detail panel. + footer.addEventListener('click', (e) => e.stopPropagation()); + let question = { text: 'Waiting for your input.', options: [] }; if (task.question) { try { question = JSON.parse(task.question); } catch {} @@ -425,6 +629,25 @@ async function handleRestart(taskId, btn, footer) { } } +async function handleResume(taskId, btn, footer) { + btn.disabled = true; + btn.textContent = 'Resuming…'; + const prev = footer.querySelector('.task-error'); + if (prev) prev.remove(); + + try { + await resumeTask(taskId); + await poll(); + } catch (err) { + btn.disabled = false; + btn.textContent = 'Resume'; + const errEl = document.createElement('span'); + errEl.className = 'task-error'; + errEl.textContent = `Failed: ${err.message}`; + footer.appendChild(errEl); + } +} + // ── Accept / Reject actions ──────────────────────────────────────────────────── async function acceptTask(taskId) { @@ -939,6 +1162,7 @@ function openTaskPanel(taskId) { } function closeTaskPanel() { + closeLogViewer(); document.getElementById('task-panel').classList.remove('open'); document.getElementById('task-panel-backdrop').hidden = true; } @@ -1114,7 +1338,10 @@ function renderTaskPanel(task, executions) { const logsBtn = document.createElement('button'); logsBtn.className = 'btn-view-logs'; logsBtn.textContent = 'View Logs'; - logsBtn.addEventListener('click', () => handleViewLogs(exec.ID)); + logsBtn.addEventListener('click', () => { + const panelContent = document.getElementById('task-panel-content'); + openLogViewer(exec.ID, panelContent); + }); row.appendChild(logsBtn); list.appendChild(row); @@ -1201,7 +1428,7 @@ function openLogViewer(execId, containerEl) { let userScrolled = false; logOutput.addEventListener('scroll', () => { const nearBottom = logOutput.scrollHeight - logOutput.scrollTop - logOutput.clientHeight < 50; - if (!nearBottom) userScrolled = true; + userScrolled = !nearBottom; }); const source = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`); |
