summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-03 21:09:02 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-03 21:09:02 +0000
commitbdcc33f391441184c0b9cdbaecfdb8beb81b2652 (patch)
treedf170efc45149b781094563eee6360ed2338f419 /web
parent3881f800465a02895c7784c4932df6c094e66723 (diff)
Add clickable fold to expand hidden completed/failed tasks
Replace the static "N hidden tasks" label with a toggle button that expands an inline fold showing the hidden task cards dimmed at 0.6 opacity. Fold state is module-level so it survives poll cycles within a session but resets on reload. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js703
-rw-r--r--web/style.css536
2 files changed, 1234 insertions, 5 deletions
diff --git a/web/app.js b/web/app.js
index e66c878..6289d00 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1,4 +1,5 @@
-const API_BASE = window.location.origin;
+const BASE_PATH = document.querySelector('meta[name="base-path"]')?.content ?? '';
+const API_BASE = window.location.origin + BASE_PATH;
// ── Fetch ─────────────────────────────────────────────────────────────────────
@@ -8,6 +9,12 @@ async function fetchTasks() {
return res.json();
}
+async function fetchTemplates() {
+ const res = await fetch(`${API_BASE}/api/templates`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
// ── Render ────────────────────────────────────────────────────────────────────
function formatDate(iso) {
@@ -70,15 +77,48 @@ function createTaskCard(task) {
const btn = document.createElement('button');
btn.className = 'btn-run';
btn.textContent = 'Run';
- btn.addEventListener('click', () => handleRun(task.id, btn, footer));
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleRun(task.id, btn, footer);
+ });
footer.appendChild(btn);
card.appendChild(footer);
}
+ card.addEventListener('click', () => openTaskPanel(task.id));
+
return card;
}
+// ── Filter ────────────────────────────────────────────────────────────────────
+
+const HIDE_STATES = new Set(['COMPLETED', 'FAILED']);
+
+let showHiddenFold = false;
+
+function filterTasks(tasks, hideCompletedFailed = false) {
+ if (!hideCompletedFailed) return tasks;
+ return tasks.filter(t => !HIDE_STATES.has(t.state));
+}
+
+function getHideCompletedFailed() {
+ const stored = localStorage.getItem('hideCompletedFailed');
+ return stored === null ? true : stored === 'true';
+}
+
+function setHideCompletedFailed(val) {
+ localStorage.setItem('hideCompletedFailed', String(val));
+}
+
+function updateToggleButton() {
+ const btn = document.getElementById('btn-toggle-completed');
+ if (!btn) return;
+ btn.textContent = getHideCompletedFailed()
+ ? 'Show completed/failed'
+ : 'Hide completed/failed';
+}
+
function renderTaskList(tasks) {
const container = document.querySelector('.task-list');
@@ -87,11 +127,93 @@ function renderTaskList(tasks) {
return;
}
+ const hide = getHideCompletedFailed();
+ const visible = filterTasks(tasks, hide);
+ const hiddenCount = tasks.length - visible.length;
+
// Replace contents with task cards
container.innerHTML = '';
- for (const task of tasks) {
+ for (const task of visible) {
container.appendChild(createTaskCard(task));
}
+
+ if (hiddenCount > 0) {
+ const info = document.createElement('button');
+ info.className = 'hidden-tasks-info';
+ const arrow = showHiddenFold ? '▼' : '▶';
+ info.textContent = `${arrow} ${hiddenCount} hidden task${hiddenCount === 1 ? '' : 's'}`;
+ info.addEventListener('click', () => {
+ showHiddenFold = !showHiddenFold;
+ renderTaskList(tasks);
+ });
+ container.appendChild(info);
+
+ if (showHiddenFold) {
+ const fold = document.createElement('div');
+ fold.className = 'hidden-tasks-fold';
+ const hiddenTasks = tasks.filter(t => HIDE_STATES.has(t.state));
+ for (const task of hiddenTasks) {
+ fold.appendChild(createTaskCard(task));
+ }
+ container.appendChild(fold);
+ }
+ }
+}
+
+function createTemplateCard(tmpl) {
+ const card = document.createElement('div');
+ card.className = 'template-card';
+
+ const name = document.createElement('div');
+ name.className = 'template-name';
+ name.textContent = tmpl.name;
+ card.appendChild(name);
+
+ if (tmpl.description) {
+ const desc = document.createElement('div');
+ desc.className = 'template-description';
+ desc.textContent = tmpl.description;
+ card.appendChild(desc);
+ }
+
+ if (tmpl.tags && tmpl.tags.length > 0) {
+ const tagsEl = document.createElement('div');
+ tagsEl.className = 'template-tags';
+ for (const tag of tmpl.tags) {
+ const chip = document.createElement('span');
+ chip.className = 'tag-chip';
+ chip.textContent = tag;
+ tagsEl.appendChild(chip);
+ }
+ card.appendChild(tagsEl);
+ }
+
+ const footer = document.createElement('div');
+ footer.className = 'template-card-footer';
+
+ const delBtn = document.createElement('button');
+ delBtn.className = 'btn-danger btn-sm';
+ delBtn.textContent = 'Delete';
+ delBtn.addEventListener('click', () => deleteTemplate(tmpl.id));
+
+ footer.appendChild(delBtn);
+ card.appendChild(footer);
+
+ return card;
+}
+
+function renderTemplateList(templates) {
+ const container = document.querySelector('.template-list');
+
+ if (!templates || templates.length === 0) {
+ container.innerHTML = '<div id="loading">No templates yet.</div>';
+ return;
+ }
+
+ container.innerHTML = '';
+ for (const tmpl of templates) {
+ container.appendChild(createTemplateCard(tmpl));
+ }
}
// ── Run action ────────────────────────────────────────────────────────────────
@@ -130,6 +252,23 @@ async function handleRun(taskId, btn, footer) {
}
}
+// ── Delete template ────────────────────────────────────────────────────────────
+
+async function deleteTemplate(id) {
+ if (!window.confirm('Delete this template?')) return;
+
+ const res = await fetch(`${API_BASE}/api/templates/${id}`, { method: 'DELETE' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const body = await res.json(); msg = body.error || body.message || msg; } catch {}
+ alert(`Failed to delete: ${msg}`);
+ return;
+ }
+
+ const templates = await fetchTemplates();
+ renderTemplateList(templates);
+}
+
// ── Polling ───────────────────────────────────────────────────────────────────
async function poll() {
@@ -147,6 +286,562 @@ function startPolling(intervalMs = 10_000) {
setInterval(poll, intervalMs);
}
+// ── Elaborate (Draft with AI) ─────────────────────────────────────────────────
+
+async function elaborateTask(prompt) {
+ const res = await fetch(`${API_BASE}/api/tasks/elaborate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prompt }),
+ });
+ 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();
+}
+
+// ── Task modal ────────────────────────────────────────────────────────────────
+
+function openTaskModal() {
+ document.getElementById('task-modal').showModal();
+}
+
+function closeTaskModal() {
+ document.getElementById('task-modal').close();
+ document.getElementById('task-form').reset();
+ document.getElementById('elaborate-prompt').value = '';
+}
+
+async function createTask(formData) {
+ const body = {
+ name: formData.get('name'),
+ description: '',
+ claude: {
+ model: formData.get('model'),
+ instructions: formData.get('instructions'),
+ working_dir: formData.get('working_dir'),
+ max_budget_usd: parseFloat(formData.get('max_budget_usd')),
+ },
+ timeout: formData.get('timeout'),
+ priority: formData.get('priority'),
+ tags: [],
+ };
+
+ const res = await fetch(`${API_BASE}/api/tasks`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+
+ closeTaskModal();
+ const tasks = await fetchTasks();
+ renderTaskList(tasks);
+}
+
+// ── Template modal ────────────────────────────────────────────────────────────
+
+function openTemplateModal() {
+ document.getElementById('template-modal').showModal();
+}
+
+function closeTemplateModal() {
+ document.getElementById('template-modal').close();
+ document.getElementById('template-form').reset();
+}
+
+async function saveTemplate(formData) {
+ const splitTrim = val => val.split(',').map(s => s.trim()).filter(Boolean);
+
+ const body = {
+ name: formData.get('name'),
+ description: formData.get('description'),
+ claude: {
+ model: formData.get('model'),
+ instructions: formData.get('instructions'),
+ working_dir: formData.get('working_dir'),
+ max_budget_usd: parseFloat(formData.get('max_budget_usd')),
+ allowed_tools: splitTrim(formData.get('allowed_tools') || ''),
+ },
+ timeout: formData.get('timeout'),
+ priority: formData.get('priority'),
+ tags: splitTrim(formData.get('tags') || ''),
+ };
+
+ const res = await fetch(`${API_BASE}/api/templates`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+
+ closeTemplateModal();
+ const templates = await fetchTemplates();
+ renderTemplateList(templates);
+}
+
+// ── Task side panel ───────────────────────────────────────────────────────────
+
+// Format Go's task.Duration JSON value {"Duration": <nanoseconds>} to human string.
+function formatDurationNs(timeout) {
+ const ns = timeout && timeout.Duration;
+ if (!ns) return '—';
+ const secs = ns / 1e9;
+ if (secs < 60) return `${secs.toFixed(1)}s`;
+ const mins = Math.floor(secs / 60);
+ const remSecs = Math.floor(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`;
+}
+
+function formatDateLong(iso) {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleString(undefined, {
+ year: 'numeric', month: 'short', day: 'numeric',
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
+ });
+}
+
+function openTaskPanel(taskId) {
+ const panel = document.getElementById('task-panel');
+ const backdrop = document.getElementById('task-panel-backdrop');
+ const content = document.getElementById('task-panel-content');
+ document.getElementById('task-panel-title').textContent = 'Task Details';
+
+ content.innerHTML = '';
+ const loading = document.createElement('div');
+ loading.className = 'panel-loading';
+ loading.textContent = 'Loading…';
+ content.appendChild(loading);
+
+ backdrop.hidden = false;
+ panel.classList.add('open');
+
+ Promise.all([
+ fetch(`${API_BASE}/api/tasks/${taskId}`).then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+ }),
+ fetch(`${API_BASE}/api/tasks/${taskId}/executions`).then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+ }),
+ ]).then(([task, executions]) => {
+ renderTaskPanel(task, executions);
+ }).catch(err => {
+ content.innerHTML = '';
+ const errEl = document.createElement('div');
+ errEl.className = 'panel-fetch-error';
+ errEl.textContent = `Failed to load: ${err.message}`;
+ content.appendChild(errEl);
+ });
+}
+
+function closeTaskPanel() {
+ document.getElementById('task-panel').classList.remove('open');
+ document.getElementById('task-panel-backdrop').hidden = true;
+}
+
+function makeSection(title) {
+ const section = document.createElement('div');
+ section.className = 'panel-section';
+ const hdr = document.createElement('div');
+ hdr.className = 'panel-section-title';
+ hdr.textContent = title;
+ section.appendChild(hdr);
+ return section;
+}
+
+function makeMetaItem(label, valueText, opts = {}) {
+ const item = document.createElement('div');
+ item.className = 'meta-item' + (opts.fullWidth ? ' full-width' : '');
+
+ const lbl = document.createElement('div');
+ lbl.className = 'meta-label';
+ lbl.textContent = label;
+ item.appendChild(lbl);
+
+ if (opts.badge) {
+ const badge = document.createElement('span');
+ badge.className = 'state-badge';
+ badge.dataset.state = valueText;
+ badge.textContent = valueText.replace(/_/g, ' ');
+ item.appendChild(badge);
+ } else if (opts.code) {
+ const pre = document.createElement('pre');
+ pre.className = 'panel-code';
+ pre.textContent = valueText;
+ item.appendChild(pre);
+ } else if (opts.tags) {
+ const wrap = document.createElement('div');
+ if (opts.tags.length > 0) {
+ wrap.className = 'panel-tags';
+ for (const tag of opts.tags) {
+ const chip = document.createElement('span');
+ chip.className = 'tag-chip';
+ chip.textContent = tag;
+ wrap.appendChild(chip);
+ }
+ } else {
+ wrap.className = 'meta-value muted';
+ wrap.textContent = '—';
+ }
+ item.appendChild(wrap);
+ } else {
+ const val = document.createElement('div');
+ val.className = 'meta-value' + (opts.mono ? ' mono' : '') + (opts.muted ? ' muted' : '');
+ val.textContent = valueText || '—';
+ item.appendChild(val);
+ }
+ return item;
+}
+
+function renderTaskPanel(task, executions) {
+ document.getElementById('task-panel-title').textContent = task.name;
+ const content = document.getElementById('task-panel-content');
+ content.innerHTML = '';
+
+ // ── Overview ──
+ const overview = makeSection('Overview');
+ const overviewGrid = document.createElement('div');
+ overviewGrid.className = 'meta-grid';
+ overviewGrid.append(
+ makeMetaItem('State', task.state, { badge: true }),
+ makeMetaItem('Priority', task.priority),
+ makeMetaItem('Created', formatDateLong(task.created_at)),
+ makeMetaItem('Updated', formatDateLong(task.updated_at)),
+ makeMetaItem('ID', task.id, { fullWidth: true, mono: true }),
+ );
+ if (task.parent_task_id) {
+ overviewGrid.append(makeMetaItem('Parent Task', task.parent_task_id, { fullWidth: true, mono: true }));
+ }
+ if (task.tags && task.tags.length >= 0) {
+ overviewGrid.append(makeMetaItem('Tags', '', { fullWidth: true, tags: task.tags || [] }));
+ }
+ if (task.description) {
+ overviewGrid.append(makeMetaItem('Description', task.description, { fullWidth: true }));
+ }
+ overview.appendChild(overviewGrid);
+ content.appendChild(overview);
+
+ // ── Claude Config ──
+ const c = task.claude || {};
+ const claudeSection = makeSection('Claude Config');
+ const claudeGrid = document.createElement('div');
+ claudeGrid.className = 'meta-grid';
+ claudeGrid.append(
+ makeMetaItem('Model', c.model),
+ makeMetaItem('Max Budget', c.max_budget_usd != null ? `$${c.max_budget_usd.toFixed(2)}` : '—'),
+ makeMetaItem('Working Dir', c.working_dir),
+ makeMetaItem('Permission Mode', c.permission_mode || 'default'),
+ );
+ if (c.allowed_tools && c.allowed_tools.length > 0) {
+ claudeGrid.append(makeMetaItem('Allowed Tools', c.allowed_tools.join(', '), { fullWidth: true }));
+ }
+ if (c.disallowed_tools && c.disallowed_tools.length > 0) {
+ claudeGrid.append(makeMetaItem('Disallowed Tools', c.disallowed_tools.join(', '), { fullWidth: true }));
+ }
+ if (c.instructions) {
+ claudeGrid.append(makeMetaItem('Instructions', c.instructions, { fullWidth: true, code: true }));
+ }
+ if (c.system_prompt_append) {
+ claudeGrid.append(makeMetaItem('System Prompt Append', c.system_prompt_append, { fullWidth: true, code: true }));
+ }
+ claudeSection.appendChild(claudeGrid);
+ content.appendChild(claudeSection);
+
+ // ── Execution Settings ──
+ const settingsSection = makeSection('Execution Settings');
+ const settingsGrid = document.createElement('div');
+ settingsGrid.className = 'meta-grid';
+ settingsGrid.append(
+ makeMetaItem('Timeout', formatDurationNs(task.timeout)),
+ makeMetaItem('Retry Attempts', String(task.retry ? task.retry.max_attempts : 1)),
+ makeMetaItem('Backoff', task.retry ? task.retry.backoff : '—'),
+ );
+ if (task.depends_on && task.depends_on.length > 0) {
+ settingsGrid.append(makeMetaItem('Depends On', task.depends_on.join(', '), { fullWidth: true, mono: true }));
+ }
+ settingsSection.appendChild(settingsGrid);
+ content.appendChild(settingsSection);
+
+ // ── Executions ──
+ const execSection = makeSection('Executions');
+ if (!executions || executions.length === 0) {
+ const none = document.createElement('div');
+ none.className = 'meta-value muted';
+ none.textContent = 'No executions yet.';
+ execSection.appendChild(none);
+ } else {
+ const list = document.createElement('div');
+ list.className = 'executions-list';
+ // Newest first
+ for (const exec of [...executions].reverse()) {
+ const row = document.createElement('div');
+ row.className = 'execution-row';
+
+ const shortId = document.createElement('span');
+ shortId.className = 'execution-id';
+ shortId.textContent = exec.ID ? exec.ID.slice(0, 8) : '—';
+ row.appendChild(shortId);
+
+ const badge = document.createElement('span');
+ badge.className = 'state-badge';
+ badge.dataset.state = exec.Status || '';
+ badge.textContent = (exec.Status || '—').replace(/_/g, ' ');
+ row.appendChild(badge);
+
+ const times = document.createElement('span');
+ times.className = 'execution-times';
+ const start = exec.StartTime ? formatDate(exec.StartTime) : '?';
+ const end = exec.EndTime && exec.EndTime !== '0001-01-01T00:00:00Z' ? formatDate(exec.EndTime) : '…';
+ times.textContent = `${start} → ${end}`;
+ row.appendChild(times);
+
+ if (exec.CostUSD != null && exec.CostUSD > 0) {
+ const cost = document.createElement('span');
+ cost.className = 'execution-cost';
+ cost.textContent = `$${exec.CostUSD.toFixed(4)}`;
+ row.appendChild(cost);
+ }
+
+ const exitEl = document.createElement('span');
+ exitEl.className = 'execution-exit';
+ exitEl.textContent = `exit: ${exec.ExitCode ?? '—'}`;
+ row.appendChild(exitEl);
+
+ const logsBtn = document.createElement('button');
+ logsBtn.className = 'btn-view-logs';
+ logsBtn.textContent = 'View Logs';
+ logsBtn.addEventListener('click', () => handleViewLogs(exec.ID));
+ row.appendChild(logsBtn);
+
+ list.appendChild(row);
+ }
+ execSection.appendChild(list);
+ }
+ content.appendChild(execSection);
+}
+
+async function handleViewLogs(execId) {
+ const modal = document.getElementById('logs-modal');
+ const body = document.getElementById('logs-modal-body');
+ document.getElementById('logs-modal-title').textContent = `Execution ${execId.slice(0, 8)}`;
+ body.innerHTML = '<div class="panel-loading">Loading…</div>';
+ modal.showModal();
+
+ try {
+ const res = await fetch(`${API_BASE}/api/executions/${execId}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const exec = await res.json();
+
+ body.innerHTML = '';
+ const grid = document.createElement('div');
+ grid.className = 'meta-grid';
+
+ const entries = [
+ ['ID', exec.ID, { fullWidth: true, mono: true }],
+ ['Status', exec.Status, { badge: true }],
+ ['Exit Code', String(exec.ExitCode ?? '—'), {}],
+ ['Cost', exec.CostUSD > 0 ? `$${exec.CostUSD.toFixed(4)}` : '—', {}],
+ ['Start', formatDateLong(exec.StartTime), {}],
+ ['End', exec.EndTime && !exec.EndTime.startsWith('0001-') ? formatDateLong(exec.EndTime) : '—', {}],
+ ['Error', exec.ErrorMsg || '—', { fullWidth: true }],
+ ['Stdout', exec.StdoutPath || '—', { fullWidth: true, mono: true }],
+ ['Stderr', exec.StderrPath || '—', { fullWidth: true, mono: true }],
+ ];
+ for (const [label, value, opts] of entries) {
+ grid.appendChild(makeMetaItem(label, value, opts));
+ }
+ body.appendChild(grid);
+ } catch (err) {
+ body.innerHTML = `<div class="panel-fetch-error">Failed to load: ${err.message}</div>`;
+ }
+}
+
+// ── Tab switching ─────────────────────────────────────────────────────────────
+
+function switchTab(name) {
+ // Update tab button active state
+ document.querySelectorAll('.tab').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.tab === name);
+ });
+
+ // Show/hide panels
+ document.querySelectorAll('[data-panel]').forEach(panel => {
+ if (panel.dataset.panel === name) {
+ panel.removeAttribute('hidden');
+ } else {
+ panel.setAttribute('hidden', '');
+ }
+ });
+
+ // Show/hide the header New Task button (only relevant on tasks tab)
+ document.getElementById('btn-new-task').style.display =
+ name === 'tasks' ? '' : 'none';
+
+ if (name === 'templates') {
+ fetchTemplates().then(renderTemplateList).catch(() => {
+ document.querySelector('.template-list').innerHTML =
+ '<div id="loading">Could not reach server.</div>';
+ });
+ }
+}
+
// ── Boot ──────────────────────────────────────────────────────────────────────
-startPolling();
+document.addEventListener('DOMContentLoaded', () => {
+ updateToggleButton();
+ document.getElementById('btn-toggle-completed').addEventListener('click', async () => {
+ setHideCompletedFailed(!getHideCompletedFailed());
+ updateToggleButton();
+ await poll();
+ });
+
+ startPolling();
+
+ // Side panel close
+ document.getElementById('btn-close-panel').addEventListener('click', closeTaskPanel);
+ document.getElementById('task-panel-backdrop').addEventListener('click', closeTaskPanel);
+
+ // Execution logs modal close
+ document.getElementById('btn-close-logs').addEventListener('click', () => {
+ document.getElementById('logs-modal').close();
+ });
+
+ // Tab bar
+ document.querySelectorAll('.tab').forEach(btn => {
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
+ });
+
+ // Task modal
+ document.getElementById('btn-new-task').addEventListener('click', openTaskModal);
+ document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal);
+
+ // Draft with AI button
+ const btnElaborate = document.getElementById('btn-elaborate');
+ btnElaborate.addEventListener('click', async () => {
+ const prompt = document.getElementById('elaborate-prompt').value.trim();
+ if (!prompt) {
+ const form = document.getElementById('task-form');
+ // Remove previous error
+ const prev = form.querySelector('.form-error');
+ if (prev) prev.remove();
+ const errEl = document.createElement('p');
+ errEl.className = 'form-error';
+ errEl.textContent = 'Please enter a description before drafting.';
+ form.querySelector('.elaborate-section').appendChild(errEl);
+ return;
+ }
+
+ btnElaborate.disabled = true;
+ btnElaborate.textContent = 'Drafting…';
+
+ // Remove any previous errors or banners
+ const form = document.getElementById('task-form');
+ form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove());
+
+ try {
+ const result = await elaborateTask(prompt);
+
+ // Populate form fields
+ const f = document.getElementById('task-form');
+ if (result.name)
+ f.querySelector('[name="name"]').value = result.name;
+ if (result.claude && result.claude.instructions)
+ f.querySelector('[name="instructions"]').value = result.claude.instructions;
+ if (result.claude && result.claude.working_dir)
+ f.querySelector('[name="working_dir"]').value = result.claude.working_dir;
+ if (result.claude && result.claude.model)
+ f.querySelector('[name="model"]').value = result.claude.model;
+ if (result.claude && result.claude.max_budget_usd != null)
+ f.querySelector('[name="max_budget_usd"]').value = result.claude.max_budget_usd;
+ if (result.timeout)
+ f.querySelector('[name="timeout"]').value = result.timeout;
+ if (result.priority) {
+ const sel = f.querySelector('[name="priority"]');
+ if ([...sel.options].some(o => o.value === result.priority)) {
+ sel.value = result.priority;
+ }
+ }
+
+ // Show success banner
+ const banner = document.createElement('p');
+ banner.className = 'elaborate-banner';
+ banner.textContent = 'AI draft ready — review and submit.';
+ document.getElementById('task-form').querySelector('.elaborate-section').appendChild(banner);
+ } catch (err) {
+ const errEl = document.createElement('p');
+ errEl.className = 'form-error';
+ errEl.textContent = `Elaboration failed: ${err.message}`;
+ document.getElementById('task-form').querySelector('.elaborate-section').appendChild(errEl);
+ } finally {
+ btnElaborate.disabled = false;
+ btnElaborate.textContent = 'Draft with AI ✦';
+ }
+ });
+
+ document.getElementById('task-form').addEventListener('submit', async e => {
+ e.preventDefault();
+
+ // Remove any previous error
+ const prev = e.target.querySelector('.form-error');
+ if (prev) prev.remove();
+
+ const btn = e.submitter;
+ btn.disabled = true;
+ btn.textContent = 'Creating…';
+
+ try {
+ await createTask(new FormData(e.target));
+ } catch (err) {
+ const errEl = document.createElement('p');
+ errEl.className = 'form-error';
+ errEl.textContent = err.message;
+ e.target.appendChild(errEl);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Create & Queue';
+ }
+ });
+
+ // Template modal
+ document.getElementById('btn-new-template').addEventListener('click', openTemplateModal);
+ document.getElementById('btn-cancel-template').addEventListener('click', closeTemplateModal);
+
+ document.getElementById('template-form').addEventListener('submit', async e => {
+ e.preventDefault();
+
+ // Remove any previous error
+ const prev = e.target.querySelector('.form-error');
+ if (prev) prev.remove();
+
+ const btn = e.submitter;
+ btn.disabled = true;
+ btn.textContent = 'Saving…';
+
+ try {
+ await saveTemplate(new FormData(e.target));
+ } catch (err) {
+ const errEl = document.createElement('p');
+ errEl.className = 'form-error';
+ errEl.textContent = err.message;
+ e.target.appendChild(errEl);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Save Template';
+ }
+ });
+});
diff --git a/web/style.css b/web/style.css
index d868fdf..de8ce83 100644
--- a/web/style.css
+++ b/web/style.css
@@ -39,6 +39,10 @@ header {
position: sticky;
top: 0;
z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ max-width: 100%;
}
header h1 {
@@ -47,7 +51,40 @@ header h1 {
color: var(--accent);
letter-spacing: -0.01em;
max-width: 640px;
- margin: 0 auto;
+ margin: 0 auto 0 0;
+ flex: 1;
+}
+
+/* Tab bar */
+.tab-bar {
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ gap: 0;
+ padding: 0 1rem;
+ max-width: 100%;
+}
+
+.tab {
+ font-size: 0.88rem;
+ font-weight: 600;
+ padding: 0.65em 1.25em;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+ margin-bottom: -1px;
+}
+
+.tab:hover {
+ color: var(--text);
+}
+
+.tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
}
/* Main layout */
@@ -57,6 +94,28 @@ main {
padding: 1rem;
}
+/* Panel header */
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+}
+
+.panel-header h2 {
+ font-size: 1rem;
+ font-weight: 700;
+}
+
+/* Task list toolbar */
+.task-list-toolbar {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem 0;
+ margin-bottom: 0.75rem;
+ border-bottom: 1px solid var(--border);
+}
+
/* Task list */
.task-list {
display: flex;
@@ -170,3 +229,478 @@ main {
font-size: 0.78rem;
color: var(--state-failed);
}
+
+.hidden-tasks-info {
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ text-align: center;
+ padding: 0.5rem 0;
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ width: 100%;
+}
+
+.hidden-tasks-info:hover {
+ color: var(--text);
+}
+
+.hidden-tasks-fold {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ opacity: 0.6;
+ margin-top: 0.5rem;
+}
+
+/* Primary button */
+.btn-primary {
+ font-size: 0.85rem;
+ font-weight: 600;
+ padding: 0.4em 1em;
+ border-radius: 0.375rem;
+ border: none;
+ cursor: pointer;
+ background: #2563eb;
+ color: #fff;
+ transition: opacity 0.15s;
+}
+
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Secondary button */
+.btn-secondary {
+ font-size: 0.85rem;
+ font-weight: 600;
+ padding: 0.4em 1em;
+ border-radius: 0.375rem;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+
+.btn-secondary:hover {
+ background: var(--border);
+ color: var(--text);
+}
+
+.btn-secondary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Danger button */
+.btn-danger {
+ font-size: 0.85rem;
+ font-weight: 600;
+ padding: 0.4em 1em;
+ border-radius: 0.375rem;
+ border: none;
+ cursor: pointer;
+ background: var(--state-failed);
+ color: #0f172a;
+ transition: opacity 0.15s;
+}
+
+.btn-danger:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-sm {
+ font-size: 0.78rem;
+ padding: 0.3em 0.75em;
+}
+
+/* Modal dialog */
+dialog {
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1.5rem;
+ max-width: 480px;
+ width: 100%;
+}
+
+dialog::backdrop {
+ background: rgba(0, 0, 0, 0.6);
+}
+
+dialog h2 {
+ font-size: 1.1rem;
+ font-weight: 700;
+ margin-bottom: 1rem;
+}
+
+/* Form labels and inputs */
+dialog label {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+}
+
+dialog label input,
+dialog label textarea,
+dialog label select {
+ display: block;
+ width: 100%;
+ margin-top: 4px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ color: var(--text);
+ padding: 0.4em 0.6em;
+ font-size: 0.9rem;
+ font-family: inherit;
+}
+
+dialog label input:focus,
+dialog label textarea:focus,
+dialog label select:focus {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* Form actions row */
+.form-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 16px;
+}
+
+.form-actions button[type="button"] {
+ font-size: 0.85rem;
+ padding: 0.4em 1em;
+ border-radius: 0.375rem;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+
+.form-actions button[type="button"]:hover {
+ background: var(--border);
+ color: var(--text);
+}
+
+/* Inline form error */
+.form-error {
+ color: var(--state-failed);
+ font-size: 0.82rem;
+ margin-top: 8px;
+}
+
+/* Template list */
+.template-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+/* Template card */
+.template-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 0.875rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.template-name {
+ font-weight: 600;
+ font-size: 0.95rem;
+}
+
+.template-description {
+ font-size: 0.82rem;
+ color: var(--text-muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.template-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+ margin-top: 0.125rem;
+}
+
+.tag-chip {
+ font-size: 0.7rem;
+ font-weight: 600;
+ padding: 0.2em 0.55em;
+ border-radius: 999px;
+ background: var(--border);
+ color: var(--text-muted);
+}
+
+.template-card-footer {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+}
+
+/* ── Side panel ───────────────────────────────────────────────────────────── */
+
+.panel-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 20;
+}
+
+.task-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: min(480px, 100vw);
+ height: 100dvh;
+ background: var(--surface);
+ border-left: 1px solid var(--border);
+ z-index: 30;
+ display: flex;
+ flex-direction: column;
+ transform: translateX(100%);
+ transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
+ overflow: hidden;
+}
+
+.task-panel.open {
+ transform: translateX(0);
+}
+
+.task-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.task-panel-header h2 {
+ font-size: 1rem;
+ font-weight: 700;
+ margin: 0;
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.btn-close-panel {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 1.3rem;
+ line-height: 1;
+ padding: 0.15em 0.35em;
+ margin-left: 0.5rem;
+ border-radius: 0.25rem;
+ flex-shrink: 0;
+}
+
+.btn-close-panel:hover {
+ color: var(--text);
+ background: var(--border);
+}
+
+.task-panel-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.25rem;
+}
+
+/* Panel sections */
+.panel-section {
+ margin-bottom: 1.5rem;
+}
+
+.panel-section:last-child {
+ margin-bottom: 0;
+}
+
+.panel-section-title {
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-bottom: 0.625rem;
+ padding-bottom: 0.375rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.meta-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem 1rem;
+}
+
+.meta-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.meta-item.full-width {
+ grid-column: 1 / -1;
+}
+
+.meta-label {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.meta-value {
+ font-size: 0.875rem;
+ word-break: break-all;
+}
+
+.meta-value.mono {
+ font-family: monospace;
+ font-size: 0.8rem;
+}
+
+.meta-value.muted {
+ color: var(--text-muted);
+}
+
+.panel-code {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ padding: 0.625rem 0.75rem;
+ font-family: monospace;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text);
+ max-height: 220px;
+ overflow-y: auto;
+ margin-top: 0.25rem;
+}
+
+.panel-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+ margin-top: 0.1rem;
+}
+
+/* Clickable task cards */
+.task-card {
+ cursor: pointer;
+}
+
+.task-card:hover {
+ border-color: var(--accent);
+}
+
+/* Panel loading / error */
+.panel-loading {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ padding: 2rem 0;
+ text-align: center;
+}
+
+.panel-fetch-error {
+ color: var(--state-failed);
+ font-size: 0.85rem;
+ padding: 1rem 0;
+}
+
+/* Executions list in panel */
+.executions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.execution-row {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ padding: 0.625rem 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem 0.75rem;
+ flex-wrap: wrap;
+}
+
+.execution-id {
+ font-family: monospace;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.execution-times {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ flex: 1;
+ min-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.execution-cost {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+
+.execution-exit {
+ font-family: monospace;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+
+.btn-view-logs {
+ font-size: 0.72rem;
+ font-weight: 600;
+ padding: 0.2em 0.55em;
+ border-radius: 0.25rem;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.btn-view-logs:hover {
+ background: var(--border);
+ color: var(--text);
+}
+
+/* Execution logs modal */
+.logs-modal-body {
+ display: flex;
+ flex-direction: column;
+}
+
+.logs-modal-body .meta-grid {
+ row-gap: 0.625rem;
+}