summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
commit68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch)
tree14b73f21570a2f42ae729ca7e9676de6628d6819 /web/app.js
parentb28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff)
parent7388337d3be5cc7f65ef547e30b2e39884dd165b (diff)
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch: - Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state - Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them - Chatbot MCP server at /chatbot/mcp (requires api_token in config) - Event timeline: unified event stream replacing ad-hoc question/summary flows - Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose - repository_url required on task creation (enforces traceability) - Auto-checker: spawns verification task after each execution Conflict resolutions: - serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss - config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss - server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides - executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner) - container_test.go: added noopChannel + AgentChannel parameter to Run call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js803
1 files changed, 146 insertions, 657 deletions
diff --git a/web/app.js b/web/app.js
index ff8e381..7f96e09 100644
--- a/web/app.js
+++ b/web/app.js
@@ -115,6 +115,136 @@ export function renderDeploymentBadge(status, doc = (typeof document !== 'undefi
return span;
}
+// ── Event timeline ──────────────────────────────────────────────────────────
+// The observability event stream (GET /api/tasks/{id}/events) replaces the
+// ad-hoc question/summary panels. formatEventText turns one event into a human
+// line; renderEventTimeline builds the list element.
+
+// formatEventText returns a one-line human description of a task event.
+export function formatEventText(event) {
+ if (!event) return '';
+ const p = event.payload || {};
+ switch (event.kind) {
+ case 'state_change':
+ return `State: ${p.from || '?'} → ${p.to || '?'}`;
+ case 'summary':
+ return `Summary: ${p.text || p.summary || ''}`;
+ case 'clarification_request':
+ return `Question: ${p.text || ''}`;
+ case 'clarification_answer':
+ return `Answer: ${p.answer || ''}`;
+ case 'subtask_spawned':
+ return `Spawned subtask: ${p.name || p.subtask_id || ''}`;
+ case 'commit_made':
+ return `Commit: ${p.message || p.hash || ''}`;
+ case 'cost_report':
+ return `Cost: $${p.cost_usd != null ? p.cost_usd : 0}`;
+ case 'agent_message':
+ case 'human_message':
+ return p.text || p.message || '';
+ case 'execution_started':
+ return 'Execution started';
+ case 'execution_ended':
+ return p.status ? `Execution ended (${p.status})` : 'Execution ended';
+ default:
+ return event.kind || '';
+ }
+}
+
+// renderEventTimeline builds a <ul class="event-timeline"> from an events array.
+// Accepts an optional doc parameter for testability (defaults to document).
+export function renderEventTimeline(events, doc = (typeof document !== 'undefined' ? document : null)) {
+ if (doc == null) return null;
+ const ul = doc.createElement('ul');
+ ul.className = 'event-timeline';
+ if (!events || events.length === 0) {
+ const empty = doc.createElement('li');
+ empty.className = 'event-timeline__empty';
+ empty.textContent = 'No events yet.';
+ ul.appendChild(empty);
+ return ul;
+ }
+ for (const event of events) {
+ const li = doc.createElement('li');
+ li.className = `event-timeline__item event-timeline__item--${event.kind || 'unknown'}`;
+
+ const actor = doc.createElement('span');
+ actor.className = 'event-timeline__actor';
+ actor.textContent = event.actor || '';
+ li.appendChild(actor);
+
+ const text = doc.createElement('span');
+ text.className = 'event-timeline__text';
+ text.textContent = formatEventText(event);
+ li.appendChild(text);
+
+ ul.appendChild(li);
+ }
+ return ul;
+}
+
+// fetchTaskEvents loads a task's event stream. fetchImpl defaults to the global
+// fetch; pass a stub in tests. Returns [] on any error so the UI degrades
+// gracefully.
+export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) {
+ if (!fetchImpl) return [];
+ try {
+ const resp = await fetchImpl(`/api/tasks/${taskId}/events`);
+ if (!resp.ok) return [];
+ const data = await resp.json();
+ return Array.isArray(data) ? data : [];
+ } catch {
+ return [];
+ }
+}
+
+// ── Budget headroom ─────────────────────────────────────────────────────────
+// Renders the per-provider rolling-window spend headroom from GET /api/budget.
+
+// formatBudgetHeadroom turns one provider's headroom into a short label.
+// Returns '' for unlimited providers (nothing to show).
+export function formatBudgetHeadroom(h) {
+ if (!h || !h.limited) return '';
+ const pct = Math.round((h.fraction_remaining || 0) * 100);
+ const remaining = (h.remaining_usd || 0).toFixed(2);
+ const limit = (h.limit_usd || 0).toFixed(2);
+ const name = h.provider ? h.provider.charAt(0).toUpperCase() + h.provider.slice(1) : '?';
+ return `${name}: ${pct}% left ($${remaining} of $${limit})`;
+}
+
+// renderBudgetHeadroom builds a <span> chip per limited provider, flagging
+// providers under 20% remaining with a --low modifier. Returns the container.
+export function renderBudgetHeadroom(headrooms, doc = (typeof document !== 'undefined' ? document : null)) {
+ if (doc == null) return null;
+ const wrap = doc.createElement('div');
+ wrap.className = 'budget-bar';
+ for (const h of headrooms || []) {
+ if (!h || !h.limited) continue;
+ const chip = doc.createElement('span');
+ chip.className = 'budget-chip' + ((h.fraction_remaining || 0) < 0.2 ? ' budget-chip--low' : '');
+ chip.textContent = formatBudgetHeadroom(h);
+ wrap.appendChild(chip);
+ }
+ return wrap;
+}
+
+// loadBudget fetches headroom and injects chips into #budget-bar. Best-effort.
+async function loadBudget(fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) {
+ if (!fetchImpl || typeof document === 'undefined') return;
+ const slot = document.getElementById('budget-bar');
+ if (!slot) return;
+ try {
+ const resp = await fetchImpl(`${BASE_PATH}/api/budget`);
+ if (!resp.ok) return;
+ const headrooms = await resp.json();
+ const rendered = renderBudgetHeadroom(headrooms);
+ slot.innerHTML = '';
+ if (rendered) for (const c of rendered.children) slot.appendChild(c);
+ } catch {
+ // budget display is non-critical; ignore.
+ }
+}
+
function truncateToWordBoundary(text, maxLen = 120) {
if (!text || text.length <= maxLen) return text;
const cut = text.lastIndexOf(' ', maxLen);
@@ -382,9 +512,6 @@ export function filterActiveTasks(tasks) {
return tasks.filter(t => _PANEL_ACTIVE_STATES.has(t.state));
}
-// The New Task button is always visible regardless of active tab.
-export function newTaskButtonShouldShowOnTab(_tab) { return true; }
-
export function filterTasksByTab(tasks, tab) {
if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state));
if (tab === 'interrupted') return tasks.filter(t => INTERRUPTED_STATES.has(t.state));
@@ -531,90 +658,6 @@ export function computeExecutionStats(executions) {
};
}
-// ── Stories ───────────────────────────────────────────────────────────────────
-
-const STORY_STATUS_LABELS = {
- PENDING: 'Pending',
- IN_PROGRESS: 'In Progress',
- SHIPPABLE: 'Shippable',
- DEPLOYED: 'Deployed',
- VALIDATING: 'Validating',
- REVIEW_READY: 'Review Ready',
- NEEDS_FIX: 'Needs Fix',
-};
-
-export function storyStatusLabel(status) {
- return STORY_STATUS_LABELS[status] || status;
-}
-
-export function renderStoryCard(story, doc = document) {
- const card = doc.createElement('div');
- card.className = 'story-card';
- card.dataset.storyId = story.id;
-
- const header = doc.createElement('div');
- header.className = 'story-card-header';
-
- const name = doc.createElement('span');
- name.className = 'story-name';
- name.textContent = story.name;
- header.appendChild(name);
-
- const badge = doc.createElement('span');
- badge.className = 'story-status-badge';
- badge.dataset.status = story.status;
- badge.textContent = storyStatusLabel(story.status);
- header.appendChild(badge);
-
- card.appendChild(header);
-
- const meta = doc.createElement('div');
- meta.className = 'story-meta';
-
- const project = doc.createElement('span');
- project.className = 'story-project';
- project.textContent = story.project_id || '—';
- meta.appendChild(project);
-
- if (story.branch_name) {
- const branch = doc.createElement('span');
- branch.className = 'story-branch';
- branch.textContent = story.branch_name;
- meta.appendChild(branch);
- }
-
- card.appendChild(meta);
-
- // Ship button for SHIPPABLE stories.
- if (story.status === 'SHIPPABLE') {
- const shipBtn = doc.createElement('button');
- shipBtn.className = 'btn-primary story-ship-btn';
- shipBtn.textContent = 'Ship';
- shipBtn.addEventListener('click', async (e) => {
- e.stopPropagation();
- shipBtn.disabled = true;
- shipBtn.textContent = 'Shipping…';
- try {
- const res = await fetch(`${API_BASE}/api/stories/${story.id}/ship`, { method: 'POST' });
- if (!res.ok) {
- const body = await res.json().catch(() => ({}));
- alert(body.error || `Ship failed (${res.status})`);
- shipBtn.disabled = false;
- shipBtn.textContent = 'Ship';
- } else {
- renderStoriesPanel();
- }
- } catch {
- shipBtn.disabled = false;
- shipBtn.textContent = 'Ship';
- }
- });
- card.appendChild(shipBtn);
- }
-
- return card;
-}
-
export function updateFilterTabs() {
const current = getTaskFilterTab();
document.querySelectorAll('.filter-tab[data-filter]').forEach(el => {
@@ -1290,9 +1333,6 @@ function renderActiveTab(allTasks) {
.then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats))
.catch(() => {});
break;
- case 'stories':
- renderStoriesPanel();
- break;
case 'drops':
renderDropsPanel();
break;
@@ -1304,6 +1344,7 @@ function renderActiveTab(allTasks) {
async function poll() {
try {
+ loadBudget(); // fire-and-forget; budget can change independently of tasks
const health = await fetchHealth();
const serverUpdate = health.last_updated;
@@ -1559,152 +1600,6 @@ async function submitAnswer(taskId, questionId, answer, banner) {
// ── Elaborate (Draft with AI) ─────────────────────────────────────────────────
-async function elaborateTask(prompt, workingDir) {
- const res = await fetch(`${API_BASE}/api/tasks/elaborate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ prompt, project_dir: workingDir }),
- });
- 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();
-}
-
-// ── 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 repository_url = document.getElementById('repository-url').value;
- const container_image = document.getElementById('container-image').value;
- const allowedToolsEl = f.querySelector('[name="allowed_tools"]');
- const allowed_tools = allowedToolsEl
- ? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean)
- : [];
- return { name, repository_url, agent: { instructions, container_image, 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 ────────────────────────────────────────────────────────────────
-
-async function openTaskModal() {
- document.getElementById('task-modal').showModal();
-}
-
-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) {
- const repository_url = formData.get('repository_url');
- const container_image = formData.get('container_image');
- const elaboratePromptEl = document.getElementById('elaborate-prompt');
- const elaborationInput = elaboratePromptEl ? elaboratePromptEl.value.trim() : '';
- const body = {
- name: formData.get('name'),
- description: '',
- elaboration_input: elaborationInput || undefined,
- repository_url: repository_url,
- agent: {
- instructions: formData.get('instructions'),
- container_image: container_image,
- max_budget_usd: parseFloat(formData.get('max_budget_usd')),
- type: 'container',
- },
- 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();
- await poll();
-}
-
// ── Task side panel ───────────────────────────────────────────────────────────
// Format Go's task.Duration JSON value {"Duration": <nanoseconds>} to human string.
@@ -2055,6 +1950,21 @@ export function renderTaskPanel(task, executions) {
execSection.appendChild(list);
}
content.appendChild(execSection);
+
+ // ── Timeline ──
+ // Observability event stream, loaded asynchronously. Guarded so the sync
+ // panel render (and its unit tests, which have no fetch) is unaffected.
+ const timelineSection = makeSection('Timeline');
+ const timelineContainer = document.createElement('div');
+ timelineContainer.className = 'event-timeline-container';
+ timelineSection.appendChild(timelineContainer);
+ content.appendChild(timelineSection);
+ if (typeof fetch !== 'undefined') {
+ fetchTaskEvents(task.id).then(events => {
+ timelineContainer.innerHTML = '';
+ timelineContainer.appendChild(renderEventTimeline(events));
+ });
+ }
}
async function handleViewLogs(execId) {
@@ -3124,217 +3034,6 @@ async function fetchDrops() {
// ── Stories panel ─────────────────────────────────────────────────────────────
-async function renderStoriesPanel() {
- const panel = document.querySelector('[data-panel="stories"]');
- if (!panel) return;
-
- let stories;
- try {
- const res = await fetch(`${BASE_PATH}/api/stories`);
- stories = res.ok ? await res.json() : [];
- } catch {
- panel.innerHTML = '<p class="task-meta" style="padding:1rem">Failed to load stories.</p>';
- return;
- }
-
- panel.innerHTML = '';
-
- const toolbar = document.createElement('div');
- toolbar.className = 'stories-toolbar';
- const btnNew = document.createElement('button');
- btnNew.className = 'btn-primary';
- btnNew.textContent = 'New Story';
- btnNew.addEventListener('click', openStoryModal);
- toolbar.appendChild(btnNew);
- panel.appendChild(toolbar);
-
- if (!stories || stories.length === 0) {
- const empty = document.createElement('p');
- empty.className = 'task-empty';
- empty.textContent = 'No stories yet. Create one to get started.';
- panel.appendChild(empty);
- return;
- }
-
- const list = document.createElement('div');
- list.className = 'stories-list';
- for (const story of stories) {
- const card = renderStoryCard(story);
- card.addEventListener('click', () => openStoryDetail(story));
- list.appendChild(card);
- }
- panel.appendChild(list);
-}
-
-function openStoryDetail(story) {
- const modal = document.getElementById('story-detail-modal');
- if (!modal) return;
-
- document.getElementById('story-detail-name').textContent = story.name;
-
- const body = document.getElementById('story-detail-body');
- body.innerHTML = '';
-
- function addRow(label, value) {
- const row = document.createElement('div');
- row.className = 'meta-item';
- const lbl = document.createElement('div');
- lbl.className = 'meta-label';
- lbl.textContent = label;
- const val = document.createElement('div');
- val.className = 'meta-value';
- val.textContent = value || '—';
- row.appendChild(lbl);
- row.appendChild(val);
- body.appendChild(row);
- }
-
- const badge = document.createElement('span');
- badge.className = 'story-status-badge';
- badge.dataset.status = story.status;
- badge.textContent = storyStatusLabel(story.status);
-
- const statusRow = document.createElement('div');
- statusRow.className = 'meta-item';
- const statusLbl = document.createElement('div');
- statusLbl.className = 'meta-label';
- statusLbl.textContent = 'Status';
- statusRow.appendChild(statusLbl);
- statusRow.appendChild(badge);
- body.appendChild(statusRow);
-
- addRow('Project', story.project_id);
- addRow('Branch', story.branch_name);
- addRow('Created', story.created_at ? new Date(story.created_at).toLocaleString() : '—');
-
- // Load tasks for this story.
- const tasksSection = document.createElement('div');
- tasksSection.className = 'story-detail-tasks';
- tasksSection.innerHTML = '<p class="task-meta">Loading tasks…</p>';
- body.appendChild(tasksSection);
-
- fetch(`${API_BASE}/api/stories/${story.id}/tasks`)
- .then(r => r.ok ? r.json() : [])
- .then(async tasks => {
- tasksSection.innerHTML = '';
- const topLevel = tasks.filter(t => !t.parent_task_id);
- if (topLevel.length === 0) {
- tasksSection.innerHTML = '<p class="task-meta">No tasks yet.</p>';
- return;
- }
- const ol = document.createElement('ol');
- ol.className = 'story-detail-task-list';
- for (const t of topLevel) {
- const li = document.createElement('li');
- li.className = `story-detail-task story-detail-task-${t.state.toLowerCase()}`;
- li.textContent = `${STATE_EMOJI[t.state] || '•'} ${t.name}`;
- const subs = tasks.filter(s => s.parent_task_id === t.id);
- if (subs.length > 0) {
- const ul = document.createElement('ul');
- ul.className = 'story-detail-subtask-list';
- for (const s of subs) {
- const sli = document.createElement('li');
- sli.className = `subtask-item subtask-${s.state.toLowerCase()}`;
- sli.textContent = `${STATE_EMOJI[s.state] || '•'} ${s.name}`;
- ul.appendChild(sli);
- }
- li.appendChild(ul);
- }
- ol.appendChild(li);
- }
- tasksSection.appendChild(ol);
- })
- .catch(() => { tasksSection.innerHTML = '<p class="task-meta">Could not load tasks.</p>'; });
-
- modal.showModal();
-}
-
-function openStoryModal() {
- const modal = document.getElementById('story-modal');
- if (!modal) return;
-
- // Reset form state
- document.getElementById('story-goal').value = '';
- const planArea = document.getElementById('story-plan-area');
- planArea.innerHTML = '';
- planArea.setAttribute('hidden', '');
-
- const btnElaborate = document.getElementById('btn-story-elaborate');
- btnElaborate.disabled = false;
- btnElaborate.textContent = 'Elaborate with AI ✦';
-
- const btnApprove = document.getElementById('btn-story-approve');
- btnApprove.setAttribute('hidden', '');
- btnApprove._elaboratedPlan = null;
-
- // Populate project dropdown
- fetch(`${BASE_PATH}/api/projects`)
- .then(r => r.ok ? r.json() : [])
- .then(projects => {
- const sel = document.getElementById('story-project');
- sel.innerHTML = '';
- for (const p of projects) {
- const opt = document.createElement('option');
- opt.value = p.id;
- opt.textContent = p.name;
- sel.appendChild(opt);
- }
- })
- .catch(() => {});
-
- modal.showModal();
-}
-
-function renderElaboratedPlan(plan) {
- const planArea = document.getElementById('story-plan-area');
- planArea.innerHTML = '';
- planArea.removeAttribute('hidden');
-
- const nameEl = document.createElement('p');
- nameEl.className = 'story-plan-name';
- nameEl.textContent = `Story: ${plan.name}`;
- planArea.appendChild(nameEl);
-
- if (plan.branch_name) {
- const branchEl = document.createElement('p');
- branchEl.className = 'story-plan-branch';
- branchEl.textContent = `Branch: ${plan.branch_name}`;
- planArea.appendChild(branchEl);
- }
-
- if (plan.tasks && plan.tasks.length > 0) {
- const tasksHeader = document.createElement('p');
- tasksHeader.className = 'story-plan-section';
- tasksHeader.textContent = `Tasks (${plan.tasks.length}):`;
- planArea.appendChild(tasksHeader);
-
- const taskList = document.createElement('ol');
- taskList.className = 'story-plan-tasks';
- for (const t of plan.tasks) {
- const li = document.createElement('li');
- li.textContent = t.name;
- if (t.subtasks && t.subtasks.length > 0) {
- const subList = document.createElement('ul');
- for (const s of t.subtasks) {
- const subLi = document.createElement('li');
- subLi.textContent = s.name;
- subList.appendChild(subLi);
- }
- li.appendChild(subList);
- }
- taskList.appendChild(li);
- }
- planArea.appendChild(taskList);
- }
-
- if (plan.validation && plan.validation.type) {
- const valHeader = document.createElement('p');
- valHeader.className = 'story-plan-section';
- valHeader.textContent = `Validation: ${plan.validation.type}`;
- planArea.appendChild(valHeader);
- }
-}
-
async function renderDropsPanel() {
const panel = document.querySelector('[data-panel="drops"] .drops-panel');
if (!panel) return;
@@ -3447,9 +3146,6 @@ if (typeof document !== 'undefined') {
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);
// Push notifications button
const btnNotify = document.getElementById('btn-notifications');
@@ -3457,213 +3153,6 @@ if (typeof document !== 'undefined') {
btnNotify.addEventListener('click', () => enableNotifications(btnNotify));
}
- // 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 () => {
- 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 repoUrl = document.getElementById('repository-url').value.trim();
- const result = await elaborateTask(prompt, repoUrl);
-
- // Populate form fields
- const f = document.getElementById('task-form');
- if (result.name)
- f.querySelector('[name="name"]').value = result.name;
- if (result.agent && result.agent.instructions)
- f.querySelector('[name="instructions"]').value = result.agent.instructions;
- if (result.repository_url || result.agent?.repository_url) {
- document.getElementById('repository-url').value = result.repository_url || result.agent.repository_url;
- }
- if (result.agent && result.agent.container_image) {
- document.getElementById('container-image').value = result.agent.container_image;
- }
- if (result.agent && result.agent.max_budget_usd != null)
- f.querySelector('[name="max_budget_usd"]').value = result.agent.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);
-
- // 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';
- 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 {
- 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');
- errEl.className = 'form-error';
- errEl.textContent = err.message;
- e.target.appendChild(errEl);
- } finally {
- btn.disabled = false;
- btn.textContent = 'Create & Queue';
- }
- });
-
- // Story modal
- const storyModal = document.getElementById('story-modal');
- if (storyModal) {
- document.getElementById('btn-close-story-modal').addEventListener('click', () => storyModal.close());
-
- document.getElementById('btn-story-elaborate').addEventListener('click', async () => {
- const btn = document.getElementById('btn-story-elaborate');
- const goal = document.getElementById('story-goal').value.trim();
- const projectId = document.getElementById('story-project').value;
-
- if (!goal) {
- const errEl = document.createElement('p');
- errEl.className = 'form-error';
- errEl.textContent = 'Please enter a goal before elaborating.';
- storyModal.querySelector('.story-modal-body').appendChild(errEl);
- return;
- }
-
- storyModal.querySelectorAll('.form-error').forEach(el => el.remove());
- btn.disabled = true;
- btn.textContent = 'Elaborating…';
-
- try {
- const res = await fetch(`${BASE_PATH}/api/stories/elaborate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ goal, project_id: projectId }),
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({ error: res.statusText }));
- throw new Error(err.error || res.statusText);
- }
- const plan = await res.json();
- renderElaboratedPlan(plan);
-
- const btnApprove = document.getElementById('btn-story-approve');
- btnApprove._elaboratedPlan = { ...plan, project_id: projectId };
- btnApprove.removeAttribute('hidden');
- } catch (err) {
- const errEl = document.createElement('p');
- errEl.className = 'form-error';
- errEl.textContent = `Elaboration failed: ${err.message}`;
- storyModal.querySelector('.story-modal-body').appendChild(errEl);
- } finally {
- btn.disabled = false;
- btn.textContent = 'Elaborate with AI ✦';
- }
- });
-
- document.getElementById('btn-story-approve').addEventListener('click', async () => {
- const btn = document.getElementById('btn-story-approve');
- const plan = btn._elaboratedPlan;
- if (!plan) return;
-
- btn.disabled = true;
- btn.textContent = 'Approving…';
-
- try {
- const res = await fetch(`${BASE_PATH}/api/stories/approve`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(plan),
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({ error: res.statusText }));
- throw new Error(err.error || res.statusText);
- }
- storyModal.close();
- renderStoriesPanel();
- } catch (err) {
- const errEl = document.createElement('p');
- errEl.className = 'form-error';
- errEl.textContent = `Approve failed: ${err.message}`;
- storyModal.querySelector('.story-modal-body').appendChild(errEl);
- btn.disabled = false;
- btn.textContent = 'Approve & Queue';
- }
- });
- }
-
- // Story detail modal
- const storyDetailModal = document.getElementById('story-detail-modal');
- if (storyDetailModal) {
- document.getElementById('btn-close-story-detail').addEventListener('click', () => storyDetailModal.close());
- }
});
}