summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js237
1 files changed, 232 insertions, 5 deletions
diff --git a/web/app.js b/web/app.js
index 271a18e..ce1394d 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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`);