diff options
| author | Claudomator Agent <agent@claudomator.local> | 2026-07-06 09:54:23 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator.local> | 2026-07-06 09:54:23 +0000 |
| commit | 2f83075719bbca0eb3682c61dbf4fe397894baff (patch) | |
| tree | 4bd4a903eb1529af951818a5a5f4eb4046687f5f /web/app.js | |
| parent | 4344e4d49a94d5d636f4667812b556566ef71849 (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.js | 98 |
1 files changed, 98 insertions, 0 deletions
@@ -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() : []) |
