diff options
| author | Claudomator Agent <agent@claudomator.local> | 2026-07-06 09:46:45 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator.local> | 2026-07-06 09:46:45 +0000 |
| commit | 4344e4d49a94d5d636f4667812b556566ef71849 (patch) | |
| tree | 77987ab218d9988548aeb108dfb2393a4d6b3ec4 | |
| parent | d2cbe9aa07afdc0aac33c959803d6a6aaef69b8e (diff) | |
feat(web): add cardContentSignature to skip log-tail in render dedup (task 5)
Prevents renderTasksIntoContainer from tearing down and rebuilding a task
card on every poll tick just because its .task-log-tail has accumulated
streamed log lines. Exports cardContentSignature which walks the card
element tree and returns a stable fingerprint that ignores .task-log-tail
and .running-log subtrees. Also preserves the existing log-tail node
across a real content-change swap so live EventSource streams survive.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | web/app.js | 34 | ||||
| -rw-r--r-- | web/test/tasks-board.test.mjs | 22 |
2 files changed, 50 insertions, 6 deletions
@@ -744,6 +744,24 @@ function updateToggleButton() { // Shared helper: renders an array of tasks as cards into a container element. // Now updated to be non-destructive by reusing/updating existing task-card elements. +// cardContentSignature returns a structural fingerprint of a task card, +// excluding any .task-log-tail/.running-log subtree — live-streamed log +// content must never cause renderTasksIntoContainer to think the card +// "changed" and tear it down (see Task 5 of the tasks-board plan). +export function cardContentSignature(cardEl) { + function walk(el) { + if (!el) return ''; + const isLogTail = el.className && ( + el.className.split(' ').includes('task-log-tail') || + el.className.split(' ').includes('running-log') + ); + if (isLogTail) return `<logtail:${el.dataset && el.dataset.logTarget || ''}>`; + const childSig = (el.children || []).map(walk).join(''); + return `<${el.tag || ''} class="${el.className || ''}" data-state="${(el.dataset && el.dataset.state) || ''}">${el.textContent || ''}${childSig}`; + } + return walk(cardEl); +} + function renderTasksIntoContainer(tasks, container, emptyMsg) { if (!tasks || tasks.length === 0) { container.innerHTML = `<div class="task-empty">${emptyMsg}</div>`; @@ -775,12 +793,16 @@ function renderTasksIntoContainer(tasks, container, emptyMsg) { const newCard = createTaskCard(task); if (card) { - // If the content is exactly the same, we could skip replacing, - // but createTaskCard is fast and ensures we have the latest state. - // We replace the card in-place to preserve its position if possible. - if (card.innerHTML !== newCard.innerHTML) { - // Special case: if user is interacting with THIS card, we might want to skip or merge. - // For now, createTaskCard ensures we don't disrupt if NOT editing. + // Compare everything except live-streamed log content (see + // cardContentSignature) so an appending .task-log-tail never triggers + // a teardown-and-rebuild of the whole card on its own. + if (cardContentSignature(card) !== cardContentSignature(newCard)) { + // Preserve the existing (potentially already-streaming) log-tail + // element across the swap so its EventSource's target node stays + // attached and its accumulated content survives. + const oldLogTail = card.querySelector('.task-log-tail'); + const newLogTail = newCard.querySelector('.task-log-tail'); + if (oldLogTail && newLogTail) newLogTail.replaceWith(oldLogTail); container.replaceChild(newCard, card); } } else { diff --git a/web/test/tasks-board.test.mjs b/web/test/tasks-board.test.mjs index db61880..1e35e18 100644 --- a/web/test/tasks-board.test.mjs +++ b/web/test/tasks-board.test.mjs @@ -11,6 +11,7 @@ import { columnForTaskState, groupTasksByColumn, createTaskCard, + cardContentSignature, } from '../app.js'; describe('columnForTaskState', () => { @@ -227,3 +228,24 @@ describe('createTaskCard elapsed timer', () => { assert.equal(ready.querySelector('.task-elapsed[data-started-at]'), null); }); }); + +// ── renderTasksIntoContainer: log-tail preservation across re-renders ────── + +describe('cardContentSignature', () => { + it('excludes .task-log-tail content from the signature', () => { + const doc = makeMockDoc(); + const cardA = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc); + const cardB = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc); + // Simulate cardB having accumulated live log lines that cardA (freshly built) doesn't have. + const tailB = cardB.querySelector('.task-log-tail'); + tailB.children.push({ tag: 'div', className: 'log-line', textContent: 'some streamed output', children: [] }); + assert.equal(cardContentSignature(cardA), cardContentSignature(cardB)); + }); + + it('still differs when a non-log field changes', () => { + const doc = makeMockDoc(); + const cardA = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc); + const cardB = createTaskCard({ id: 't1', name: 'Task', state: 'FAILED' }, doc); + assert.notEqual(cardContentSignature(cardA), cardContentSignature(cardB)); + }); +}); |
