summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator.local>2026-07-06 09:54:23 +0000
committerClaudomator Agent <agent@claudomator.local>2026-07-06 09:54:23 +0000
commit2f83075719bbca0eb3682c61dbf4fe397894baff (patch)
tree4bd4a903eb1529af951818a5a5f4eb4046687f5f /web/app.js
parent4344e4d49a94d5d636f4667812b556566ef71849 (diff)
feat(web): add ensureTaskLogStream and taskLogStreams for task card log streaming (task 6)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js98
1 files changed, 98 insertions, 0 deletions
diff --git a/web/app.js b/web/app.js
index 70184bc..b22d352 100644
--- a/web/app.js
+++ b/web/app.js
@@ -2429,6 +2429,104 @@ function renderRunningView(tasks) {
}
}
+// taskId -> { source: EventSource, execId: string } — tracks which execution
+// each visible task's inline log-tail is currently attached to, so a poll-
+// driven re-render never reopens a stream for the same execution twice, and
+// correctly reopens when a Resume/Restart produces a fresh execution.
+export const taskLogStreams = {};
+
+// ensureTaskLogStream attaches (or leaves alone) a log stream for a task's
+// most recent execution into logAreaEl. Every column's card uses this same
+// mechanism — see docs/superpowers/specs/2026-07-06-tasks-board-design.md
+// section 4: the /api/executions/{id}/logs/stream endpoint already replays-
+// then-closes for a terminal execution and live-tails for a RUNNING one, so
+// there is no separate "static" vs. "live" code path.
+export async function ensureTaskLogStream(taskId, logAreaEl, {
+ fetchFn = fetch,
+ EventSourceImpl = (typeof EventSource !== 'undefined' ? EventSource : undefined),
+ apiBase = API_BASE,
+} = {}) {
+ let execs;
+ try {
+ const res = await fetchFn(`${apiBase}/api/executions?task_id=${taskId}&limit=1`);
+ execs = res.ok ? await res.json() : [];
+ } catch {
+ return;
+ }
+ if (!execs || execs.length === 0) return; // no execution yet (Queue)
+
+ const execId = execs[0].id;
+ const existing = taskLogStreams[taskId];
+ if (existing && existing.execId === execId) return; // already attached to this execution
+
+ if (existing) existing.source.close();
+ // .children is a read-only live collection on a real DOM element — the
+ // only correct way to clear it is innerHTML (the fake log-area mock in
+ // web/test/tasks-board.test.mjs mirrors this via an innerHTML setter).
+ logAreaEl.innerHTML = '';
+
+ const src = new EventSourceImpl(`${apiBase}/api/executions/${execId}/logs/stream`);
+ taskLogStreams[taskId] = { source: src, execId };
+
+ let userScrolled = false;
+ if (logAreaEl.addEventListener) {
+ logAreaEl.addEventListener('scroll', () => {
+ const nearBottom = logAreaEl.scrollHeight - logAreaEl.scrollTop - logAreaEl.clientHeight < 50;
+ userScrolled = !nearBottom;
+ });
+ }
+
+ src.onmessage = (event) => {
+ let data;
+ try { data = JSON.parse(event.data); } catch { return; }
+
+ const doc = (typeof document !== 'undefined') ? document : { createElement: (t) => ({ tag: t, className: '', textContent: '', children: [], appendChild(c) { this.children.push(c); } }) };
+ const line = doc.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 = doc.createElement('span');
+ toolName.className = 'tool-name';
+ toolName.textContent = `[${data.name ?? 'Tool'}]`;
+ line.appendChild(toolName);
+ const inputStr = data.input ? JSON.stringify(data.input) : '';
+ const inputPreview = doc.createElement('span');
+ inputPreview.textContent = ' ' + inputStr.slice(0, 120);
+ line.appendChild(inputPreview);
+ break;
+ }
+ case 'cost':
+ line.classList.add('log-cost');
+ line.textContent = `Cost: $${Number(data.total_cost ?? data.cost ?? 0).toFixed(3)}`;
+ break;
+ default:
+ return;
+ }
+
+ logAreaEl.appendChild(line);
+ while (logAreaEl.childElementCount > 200) {
+ logAreaEl.removeChild(logAreaEl.firstElementChild);
+ }
+ if (!userScrolled) logAreaEl.scrollTop = logAreaEl.scrollHeight;
+ };
+
+ src.addEventListener('done', () => {
+ src.close();
+ if (taskLogStreams[taskId] && taskLogStreams[taskId].source === src) delete taskLogStreams[taskId];
+ });
+
+ src.onerror = () => {
+ src.close();
+ if (taskLogStreams[taskId] && taskLogStreams[taskId].source === src) delete taskLogStreams[taskId];
+ };
+}
+
function startRunningLogStream(taskId, logArea) {
fetch(`${API_BASE}/api/executions?task_id=${taskId}&limit=1`)
.then(r => r.ok ? r.json() : [])