ouroboros/web/modules/chat_render_batch.js
Ouroboros d27471a3ab fix(chat): show the first history read, and its failure, instead of a blank panel
Opening a project panel awaited the history response with nothing
written into the empty message container, and web/app.js discarded any
rejection. A 13 second answer and a permanently failed one were
therefore indistinguishable: a white body under a green Online header,
with no spinner, no reason and no retry. The header is not evidence
either; it is set from an already-open WebSocket before the read
resolves. That is what turned a slow backend into a reported hang.

The recent-window read is the chat's own request rather than a pager
page, so createHistoryControls now carries its in-flight and failed
state and renders both through the same .chat-load-older control the
pager uses. There is no second retry affordance. beginRecent arms the
state only over an empty feed, or one already showing a failure, so an
ordinary refresh never puts chrome over a painted transcript; endRecent
replaces it with the failure in the same place; destroy clears it.

Un-swallowing app.js alone could not have surfaced this: syncHistory
catches its own error and returns false, and it stays deliberately
silent for an ordinary socket disconnect. The visible state is therefore
written from inside the sync, and app.js additionally stops discarding
the paint rejection. Retry runs the owner's open transaction through
onHistoryRetry (fetch, paint, ACK), so a recovered read also clears the
Project's unread mark, and a failed or unseen paint still acknowledges
no revision.

Verified in real browsers and inspected by eye: the project panel shows
Loading while the response is provably withheld, then the error text and
the existing Retry when it fails, then recovers on the reader's own
click. chromium and webkit, 2 passed. web/tests: 1490 passed.
2026-09-20 04:14:09 +03:00

691 lines
33 KiB
JavaScript

// Chat timeline ordering, keyed item reconciliation and reading anchors.
// These helpers own no history source, navigation or task authority.
import { compareHistoryPosition } from './chat_history_replay.js';
const nodePosition = node => node?.dataset?.historySource
? { source: node.dataset.historySource, offset: Number(node.dataset.historyOffset) } : null;
/**
* History chrome only; the chat instance retains navigation and reading state.
*
* There is no "Load newer" control. Whether a newer page is cached is a fact
* about the bounded page cache, not about what the reader can see, so a button
* driven by it appeared under a fully visible transcript and asked for one click
* per cached page. Loading newer pages is automatic at the bottom edge; the
* floating scroll-to-latest button remains the only return-to-present control.
*/
export function createHistoryControls(messagesDiv) {
const doc = messagesDiv.ownerDocument;
const root = doc.createElement('div');
root.className = 'chat-load-older';
const button = doc.createElement('button');
button.type = 'button';
button.className = 'chat-load-older-btn';
const note = doc.createElement('span');
note.className = 'chat-load-older-note';
root.append(button, note);
// The recent-window read is the chat's own request, not a pager page, so its
// in-flight and failed states are carried here and drawn by the same control
// (issue #1102: an empty feed under a green header read as a dead app, and a
// failed read looked identical to a slow one).
let recent = null;
const feedIsEmpty = () => Array.from(messagesDiv.children)
.every(node => node === root || node.classList.contains('typing-bubble'));
return {
olderButton: button,
// Only an EMPTY feed (or a failure already on screen) gets the loading
// state: an ordinary refresh never puts chrome over a painted transcript.
// Returns whether there is a state to draw, so a painted feed costs nothing.
beginRecent() {
if (recent?.error || feedIsEmpty()) recent = { loading: true };
return Boolean(recent);
},
// A failure is shown where the loading state was; elsewhere the reader
// keeps the transcript they have and the next sync reconciles it.
endRecent(error = null) { recent = error && recent ? { error } : null; },
recentFailed: () => Boolean(recent?.error),
render(snapshot, windows) {
const error = recent?.error || snapshot.error;
const hydrating = Boolean(recent?.loading);
const loading = hydrating || Boolean(snapshot.loading);
const changedView = error?.body?.reason_code === 'history_view_changed';
const noteText = hydrating ? ''
: recent?.error ? `Could not load messages: ${recent.error.message || recent.error}`
: error ? String(error.message || error)
: snapshot.olderExhausted ? 'Beginning of saved history' : '';
const buttonHidden = !error && !snapshot.canOlder && !hydrating;
const fields = [
[button, { textContent: loading ? 'Loading…'
: changedView ? 'Refresh history' : error ? 'Retry loading messages' : 'Load older messages',
disabled: loading, hidden: buttonHidden }],
[note, { textContent: noteText, hidden: !noteText }],
[root, { hidden: buttonHidden && !noteText }],
];
for (const [node, values] of fields) {
for (const [key, value] of Object.entries(values)) if (node[key] !== value) node[key] = value;
}
if (hydrating) root.setAttribute('aria-busy', 'true'); else root.removeAttribute('aria-busy');
if ((snapshot.initialized || error || hydrating) && !root.isConnected) messagesDiv.prepend(root);
const hasGaps = [...windows].some(value => (value?.truncated_by || [])
.some(cause => !['quota', 'archive_floor', 'lineage_cap', 'page'].includes(cause)));
return { complete: Boolean(snapshot.initialized && snapshot.olderExhausted
&& !snapshot.canNewer && !hasGaps && !error), truncated_by: hasGaps ? ['read_gap'] : [] };
},
};
}
/**
* The Main chat's live-card bound (issue #135). Main never runs destroy(), so its
* live task cards would accumulate for the whole session. Past `cap` cards BEYOND
* the population the last full rebuild produced, the next history sync replays
* durable history instead of folding into the existing cards - the transaction a
* reconnect already runs. The bound is RELATIVE because a history window mints
* cards itself (summary rows, progress rows, lineage rows) and can exceed the cap
* on its own; an absolute cap would rebuild on every later sync.
*
* Only the sync that STARTED with the arm up may consume it. A window fetched
* before the arm went up cannot answer for the cards that raised it: rebuilding
* from it would drop the newest cards and clear the arm without ever replaying a
* window that contains them. The three moments a sync passes through are therefore
* distinct: begin() before its fetch, beginReplay() when its SYNCHRONOUS replay
* starts (no live frame can interleave after that), and settle() when it lands.
* An arm raised between the first two came from live cards this window never saw
* and survives the rebuild; one raised during the replay came from the window's own
* rows and is answered by the floor that same rebuild sets.
*
* Accepted residual, shared with every rebuild the chat has always run (reconnect,
* first load, Load older): cards minted after a rebuild's fetch left are replaced by
* that rebuild and come back with their task's next frame or the next sync. A sync
* that already started armed does not re-arm on them: carrying a dirty bit across an
* armed rebuild would rebuild again after every busy rebuild, a storm by time where
* the relative floor removed the storm by size.
*/
export function createLiveCardBound(cap) {
let armed = false;
let floor = 0;
let inherited = false;
let raisedInFlight = false;
return {
isArmed: () => armed,
/** The offline bootstrap painted sessionStorage: the next sync must rebuild. */
arm() { armed = true; },
observe(size) { if (size > floor + cap) armed = true; },
begin() {
inherited = armed;
raisedInFlight = false;
return inherited;
},
beginReplay() { raisedInFlight = armed && !inherited; },
settle({ rebuilt, size }) {
if (!rebuilt) return;
armed = raisedInFlight;
floor = size;
},
};
}
/**
* perf2 P4 follow-up (double-fetch fix): the debounced post-completion resync
* behind chat.js's scheduleHistorySync. Finished transitions REPLAYED by
* syncHistory itself (pass 1 suppressed task summaries, pass 2 / terminal-
* resolution finishLiveCard) must NOT schedule the resync: those rows just
* arrived from the canonical history response, so the 700ms refetch was
* re-downloading the whole window after EVERY history load (Main bootstrap,
* project open, Load-older, reconnect rebuild). A LIVE completion — a WS
* frame arriving outside any replay — must keep scheduling a REAL fetch
* [GPT#12]: a lost task_done is healed only by refetching.
*/
export function createHistoryResyncScheduler({
isReplayActive,
run,
debounceMs = 700,
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (id) => clearTimeout(id),
}) {
let timer = null;
return {
/**
* @param {boolean} keepPending an armed full rebuild is a DEADLINE, not a
* best-effort refetch: it is the transaction a reconnect runs, and the live-card
* bound (issue #135) is only a bound if it actually happens. While one is armed,
* a later completion must not push the pending run out again — completions
* arriving faster than the debounce would otherwise starve it for as long as the
* traffic lasts, which is exactly the busy session the bound exists for. The
* deadline has a second hole the caller closes: a run that lands while an older
* history request is still in flight only JOINS it, spending the timer on a
* window fetched before the arm, so the caller re-arms when such a run settles
* with the bound still armed.
*/
schedule(keepPending = false) {
if (isReplayActive()) return false;
if (timer != null) {
if (keepPending) return true;
clearTimer(timer);
}
timer = setTimer(() => {
timer = null;
run();
}, debounceMs);
return true;
},
cancel() {
if (timer == null) return;
clearTimer(timer);
timer = null;
},
};
}
/**
* Insert a top-level timeline node chronologically while keeping typing last.
* Equal timestamps preserve arrival order; timestamp-free nodes append.
* (Moved verbatim from chat.js — that module sits at its byte ceiling.)
*/
// A focused text control keeps its own caret; Chromium mirrors it into the
// document Selection, so clearing and rebuilding document ranges around a
// timeline move collapses that caret (a typed wait-picker draft lost its
// selection on every reconnect). While such a control is focused, the document
// ranges are that mirror: leave them alone and restore the control's caret.
function textControlCaret(active) {
const tag = String(active?.tagName || active?.nodeName || '').toUpperCase();
if (tag !== 'INPUT' && tag !== 'TEXTAREA') return null;
try {
const { selectionStart, selectionEnd, selectionDirection } = active;
if (selectionStart == null || selectionEnd == null) return null;
return { selectionStart, selectionEnd, selectionDirection: selectionDirection || 'none' };
} catch {
return null; // input types without a caret (number, email, ...) throw on read
}
}
function restoreTextControlCaret(active, caret) {
if (!caret || typeof active?.setSelectionRange !== 'function') return;
try {
active.setSelectionRange(caret.selectionStart, caret.selectionEnd, caret.selectionDirection);
} catch {
// The control changed type or lost its value between capture and restore.
}
}
export function insertTimelineNode(messages, node, typing = null) {
const rawNodeTs = node?.dataset?.ts;
const nodeTs = rawNodeTs == null || rawNodeTs === '' ? NaN : Number(rawNodeTs);
let before = null;
if (Number.isFinite(nodeTs)) {
for (const child of Array.from(messages?.children || [])) {
if (child === node || child === typing) continue;
const rawChildTs = child?.dataset?.ts;
const childTs = rawChildTs == null || rawChildTs === '' ? NaN : Number(rawChildTs);
if (Number.isFinite(childTs) && (childTs > nodeTs
|| childTs === nodeTs && compareHistoryPosition(nodePosition(child), nodePosition(node)) > 0)) {
before = child;
break;
}
}
}
const target = before || (typing?.parentNode === messages ? typing : null);
if (node.parentNode === messages && node.nextElementSibling === target) return { before };
const doc = messages.ownerDocument;
const active = doc?.activeElement;
const caret = textControlCaret(active);
const selection = caret ? null : doc?.getSelection?.();
const ranges = Array.from({ length: selection?.rangeCount || 0 }, (_, index) => {
const range = selection.getRangeAt(index);
return [range.startContainer, range.startOffset, range.endContainer, range.endOffset];
});
if (target) messages.insertBefore(node, target);
else messages.appendChild(node);
if (active && node.contains?.(active) && doc.activeElement !== active) active.focus({ preventScroll: true });
if (caret) restoreTextControlCaret(active, caret);
if (ranges.length && ranges.every(([start, , end]) => start.isConnected && end.isConnected)) {
selection.removeAllRanges();
for (const [start, startOffset, end, endOffset] of ranges) {
const range = doc.createRange();
range.setStart(start, startOffset); range.setEnd(end, endOffset); selection.addRange(range);
}
}
return { before };
}
// Small, bounded projection used to decide whether an existing live-card
// mutation actually changed its connected presentation. Timeline rows and
// review groups report their own changes, so this never serializes a whole card.
export function captureLiveCardProjection(record) {
const root = record?.root;
if (!root?.isConnected) return null;
return [
root.parentNode, root.previousElementSibling, root.className,
root.dataset?.finished, root.dataset?.expanded, root.dataset?.subagentRole,
record.phaseEl?.hidden, record.phaseEl?.className, record.phaseEl?.textContent,
record.titleEl?.textContent, record.activityEl?.textContent,
record.metaEl?.innerHTML, record.countEl?.hidden, record.countEl?.textContent,
record.inlineTypingEl?.style?.display, record.toggleEl?.textContent,
record.summaryButtonEl?.getAttribute?.('aria-expanded'),
root.querySelector?.('.chat-live-actions')?.innerHTML || '',
];
}
export function liveCardProjectionChanged(before, record) {
const after = captureLiveCardProjection(record);
if (!before || !after) return before !== after;
return before.some((value, index) => value !== after[index]);
}
export function syncLiveCardToggle(record) {
if (!record?.toggleEl) return;
const expanded = record.root.dataset.expanded === '1';
const text = expanded ? 'Hide details' : 'Show details';
const ariaExpanded = expanded ? 'true' : 'false';
if (record.toggleEl.textContent !== text) record.toggleEl.textContent = text;
if (record.summaryButtonEl?.getAttribute('aria-expanded') !== ariaExpanded) {
record.summaryButtonEl?.setAttribute('aria-expanded', ariaExpanded);
}
}
// Incremental timeline DOM writes share the Chat viewport boundary but own no
// scroll state. Keeping them here also keeps the byte-capped instance factory
// focused on event projection rather than HTML replacement mechanics.
export function createLiveCardTimelineRenderer({ withStableViewport, buildTimelineItemHtml, isReplayActive = () => false }) {
// Remember generated markup, not the enhanced DOM: a timestamp update must
// not undo markdown controls or replace a body the reader has selected.
const rendered = new WeakMap();
const markup = (node) => node.outerHTML ?? node.nodeValue;
const remember = (node) => {
rendered.set(node, markup(node));
for (const child of Array.from(node.childNodes || [])) remember(child);
return node;
};
const patchNode = (current, next) => {
const source = markup(next);
if (rendered.get(current) === source) return false;
if (markup(current) === source) {
remember(current);
return false;
}
if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) {
current.parentNode.replaceChild(remember(next), current);
return true;
}
if (current.nodeType !== 1) {
current.nodeValue = next.nodeValue;
} else {
for (const attr of Array.from(current.attributes)) {
if (!next.hasAttribute(attr.name)) current.removeAttribute(attr.name);
}
for (const attr of Array.from(next.attributes)) {
if (current.getAttribute(attr.name) !== attr.value) current.setAttribute(attr.name, attr.value);
}
const children = Array.from(next.childNodes);
children.forEach((child, index) => {
const own = current.childNodes[index];
if (own) patchNode(own, child);
else current.appendChild(remember(child));
});
while (current.childNodes.length > children.length) current.lastChild.remove();
}
rendered.set(current, source);
return true;
};
const defer = (record) => {
if (!isReplayActive() && (!record?.isSubagent || record.root?.dataset?.expanded === '1')) return false;
record._timelineDirty = true;
return true;
};
const nodeFor = (item, record) => {
const doc = record.timelineEl?.ownerDocument || globalThis.document;
const wrapper = doc.createElement('div');
wrapper.innerHTML = buildTimelineItemHtml(item, record).trim();
return wrapper.firstElementChild;
};
const render = (record) => {
if (defer(record)) return false;
record._timelineDirty = false;
return withStableViewport(() => {
const el = record.timelineEl;
const pinned = el.scrollHeight - el.scrollTop - el.clientHeight <= 24;
const prevTop = el.scrollTop;
const byKey = new Map(Array.from(el.children).map((node) => [node.dataset.liveLineKey, node]));
const active = el.ownerDocument?.activeElement;
const caret = textControlCaret(active);
const selection = caret ? null : el.ownerDocument?.getSelection?.();
const ranges = Array.from({ length: selection?.rangeCount || 0 }, (_, index) => {
const range = selection.getRangeAt(index);
return [range.startContainer, range.startOffset, range.endContainer, range.endOffset];
});
let changed = false;
let moved = false;
record.items.forEach((item, index) => {
const next = nodeFor(item, record);
if (!next) return;
const current = byKey.get(String(item.lineKey || ''));
const node = current || remember(next);
if (current) {
byKey.delete(String(item.lineKey || ''));
changed = patchNode(current, next) || changed;
}
if (el.children[index] !== node) {
moved = moved || node.parentNode === el;
el.insertBefore(node, el.children[index] || null);
changed = true;
}
});
for (const node of byKey.values()) {
node.remove();
changed = true;
}
if (moved) {
if (el.contains(active) && el.ownerDocument.activeElement !== active) active.focus({ preventScroll: true });
if (caret) restoreTextControlCaret(active, caret);
const intact = ranges.filter(([start, , end]) => start.isConnected && end.isConnected);
if (intact.length) {
selection.removeAllRanges();
for (const [start, startOffset, end, endOffset] of intact) {
const range = el.ownerDocument.createRange();
range.setStart(start, startOffset);
range.setEnd(end, endOffset);
selection.addRange(range);
}
}
}
if (changed) el.scrollTop = pinned ? el.scrollHeight : prevTop;
return changed && Boolean(el.isConnected);
});
};
const append = (item, record) => {
if (defer(record)) return false;
if (record._timelineDirty) return render(record);
const pinned = record.timelineEl.scrollHeight
- record.timelineEl.scrollTop - record.timelineEl.clientHeight <= 24;
const node = nodeFor(item, record);
if (!node) return false;
record.timelineEl.appendChild(remember(node));
if (record.root.dataset.expanded === '1' && pinned) {
record.timelineEl.scrollTop = record.timelineEl.scrollHeight;
}
return Boolean(record.timelineEl.isConnected);
};
const replace = (item, record, current) => {
if (defer(record)) return false;
if (record._timelineDirty || !current) return render(record);
const node = nodeFor(item, record);
if (!node || !patchNode(current, node)) return false;
return Boolean(record.timelineEl.isConnected);
};
return {
renderLiveCardTimeline: render,
appendTimelineItem: append,
patchLastTimelineItem: (item, record) => replace(
item, record, record.timelineEl.lastElementChild,
),
patchTimelineItemAt: (item, record) => {
const current = Array.from(record.timelineEl.children)
.find((node) => node.dataset.liveLineKey === String(item.lineKey || ''));
return replace(item, record, current);
},
};
}
/**
* Timeline viewport anchors (extracted verbatim from chat.js at the byte
* ratchet): capture the first visible timestamped node and restore its exact
* offset after a mutation. Pure over the passed feed element.
*/
export function createTimelineAnchors({ messagesDiv, liveCardRecords }) {
function captureVisibleTimelineAnchor(excludeNode = null) {
// The Load-older control is excluded like .typing-bubble [GPT#13]:
// anchoring must land on the first visible TIMESTAMPED node, or a
// Load-older restore would pin the button itself and drift the view.
const nodes = Array.from(messagesDiv.children).filter(
(node) => node !== excludeNode
&& !excludeNode?.contains?.(node)
&& !node.classList.contains('typing-bubble')
&& !node.classList.contains('chat-load-older')
);
const messagesRect = messagesDiv.getBoundingClientRect();
let topNode = nodes.find((item) => {
const rect = item.getBoundingClientRect();
return rect.bottom > messagesRect.top && rect.top < messagesRect.bottom;
}) || null;
if (!topNode) return null;
// A live-card can span several screens while the reader is inside a
// child summary or timeline line. Preserve that visible boundary, not
// merely the root card whose own top may be far above the viewport.
let node = topNode;
if (topNode.classList.contains('chat-live-card')) {
const selector = [
'.chat-live-card',
'[data-live-summary-button]',
'[data-live-title]',
'[data-live-activity]',
'[data-live-meta]',
'.chat-live-actions',
'.chat-live-line',
'[data-review-section]',
'[data-review-section-toggle]',
'[data-review-hydrate-status]',
'[data-review-group]',
'[data-review-attempt]',
'[data-review-attempt-detail]',
'.chat-live-project-card-btn',
].join(',');
const candidates = [topNode, ...topNode.querySelectorAll(selector)]
.map((candidate) => {
let depth = 0;
let parent = candidate === topNode ? null : candidate.parentElement;
while (parent && topNode.contains(parent) && parent !== topNode) {
depth += 1;
parent = parent.parentElement;
}
return { node: candidate, rect: candidate.getBoundingClientRect(), depth };
})
.filter(({ node: candidate, rect }) => candidate.getClientRects().length
&& rect.width > 0
&& rect.height > 0
&& rect.bottom > messagesRect.top
&& rect.top < messagesRect.bottom);
const belowTop = candidates
.filter(({ rect }) => rect.top >= messagesRect.top)
.sort((a, b) => (a.rect.top - b.rect.top) || (b.depth - a.depth));
const crossing = candidates
.filter(({ rect }) => rect.top <= messagesRect.top && rect.bottom > messagesRect.top)
.sort((a, b) => b.depth - a.depth);
node = belowTop[0]?.node || crossing[0]?.node || topNode;
if (node === topNode && topNode.getBoundingClientRect().top < messagesRect.top) {
// The card's visible part holds nothing anchorable (a wait row, a
// block without work): keep the reader's view of what FOLLOWS the
// card. Pinning the card's own top, far above the viewport, would let
// the card's shrink or growth move the content the reader is on.
const following = nodes.find((item) => {
if (item === topNode) return false;
const rect = item.getBoundingClientRect();
return rect.top >= messagesRect.top && rect.top < messagesRect.bottom;
});
if (following) { topNode = following; node = following; }
}
}
const cardChain = [];
let card = node.classList.contains('chat-live-card')
? node
: node.closest?.('.chat-live-card');
while (card && messagesDiv.contains(card)) {
cardChain.push({
node: card,
taskId: card.dataset?.taskId || '',
offset: card.getBoundingClientRect().top - messagesRect.top,
});
card = card.parentElement?.closest?.('.chat-live-card') || null;
}
const ts = topNode.dataset?.ts || '';
const anchorRole = [
'[data-live-summary-button]',
'[data-live-title]',
'[data-live-activity]',
'[data-live-meta]',
'.chat-live-actions',
'.chat-live-project-card-btn',
].find((candidate) => node.matches?.(candidate)) || '';
return {
node,
cardChain,
lineKey: node.matches?.('.chat-live-line') ? (node.dataset?.liveLineKey || '') : '',
anchorRole,
topNode,
clientMessageId: topNode.dataset?.clientMessageId || '',
ts,
ordinal: ts ? nodes.filter((item) => item.dataset?.ts === ts).indexOf(topNode) : -1,
offset: node.getBoundingClientRect().top - messagesRect.top,
topOffset: topNode.getBoundingClientRect().top - messagesRect.top,
};
}
function restoreVisibleTimelineAnchor(anchor) {
if (!anchor) return false;
const isRendered = (node) => {
if (!node?.isConnected || !messagesDiv.contains(node)) return false;
const rect = node.getBoundingClientRect();
return node.getClientRects().length > 0 && rect.width > 0 && rect.height > 0;
};
const restoreNode = (node, offset) => {
if (!isRendered(node)) return false;
const currentOffset = node.getBoundingClientRect().top
- messagesDiv.getBoundingClientRect().top;
messagesDiv.scrollTop += currentOffset - offset;
return true;
};
if (restoreNode(anchor.node, anchor.offset)) return true;
const cardChain = Array.isArray(anchor.cardChain) && anchor.cardChain.length
? anchor.cardChain
: [];
const resolveCard = (entry) => {
if (isRendered(entry?.node)) return entry.node;
if (!entry?.taskId) return null;
const record = liveCardRecords.get(entry.taskId);
return isRendered(record?.root) ? record.root : null;
};
const ownerCard = resolveCard(cardChain[0]);
if (ownerCard && anchor.lineKey) {
const line = Array.from(ownerCard.querySelectorAll('.chat-live-line'))
.find((candidate) => candidate.dataset?.liveLineKey === anchor.lineKey
&& candidate.closest('.chat-live-card') === ownerCard);
if (restoreNode(line, anchor.offset)) return true;
}
if (ownerCard && anchor.anchorRole) {
const roleNode = Array.from(ownerCard.querySelectorAll(anchor.anchorRole))
.find((candidate) => candidate.closest('.chat-live-card') === ownerCard);
if (restoreNode(roleNode, anchor.offset)) return true;
}
for (const entry of cardChain) {
if (restoreNode(resolveCard(entry), entry.offset)) return true;
}
let node = isRendered(anchor.topNode) ? anchor.topNode : null;
if (!node && anchor.clientMessageId) {
node = Array.from(messagesDiv.children).find(
(item) => item.dataset?.clientMessageId === anchor.clientMessageId
) || null;
}
if (!node && anchor.ts) {
const matches = Array.from(messagesDiv.children).filter((item) => item.dataset?.ts === anchor.ts);
node = matches[anchor.ordinal] || matches[0] || null;
}
return restoreNode(node, anchor.topOffset ?? anchor.offset);
}
return { captureVisibleTimelineAnchor, restoreVisibleTimelineAnchor };
}
/** Update one live timeline item using the same keys the card's producer owns.
* Historical source-addressed insertions use mergeHistoricalTimelineItem; live
* lifecycle notes keep their existing in-place semantics and disclosure key.
*/
export function updateLiveTimelineItem(record, summary, { ts, rawTs, syntheticKey, headline, inPlaceByKey }) {
let timelineUpdate = 'none', patchIndex = -1;
const lastIdx = record.items.length - 1;
// Full-array dedup keeps routine history syncs from growing Notes.
const existingIdx = record.items.findIndex((it) => it.dedupeKey === syntheticKey);
if (existingIdx !== -1 && inPlaceByKey) {
const it = record.items[existingIdx];
const patch = {
phase: summary.phase || it.phase,
headline: headline || it.headline,
fullHeadline: summary.fullHeadline || headline || it.fullHeadline,
body: summary.body || '',
fullBody: summary.fullBody || summary.body || it.fullBody || '',
fullRef: summary.fullRef || it.fullRef || '',
truncated: summary.truncated || it.truncated || false,
// A call's failure frame replaces its receipt start: the row is
// content again once it reports an error.
receipt: Boolean(summary.receipt),
ts: ts || it.ts,
// Replay compares the child's current status with older pages.
// Its source time advances with the live status, not with narration.
...(syntheticKey.startsWith('subagent-lifecycle:') && rawTs ? { sourceTs: rawTs } : {}),
};
if (Object.entries(patch).some(([key, value]) => it[key] !== value)) {
Object.assign(it, patch);
patchIndex = existingIdx;
timelineUpdate = 'patch-at';
} else {
timelineUpdate = 'duplicate-skip';
}
} else if (existingIdx === lastIdx && existingIdx !== -1) {
const it = record.items[existingIdx];
const patch = {
ts: ts || it.ts,
fullHeadline: summary.fullHeadline || it.fullHeadline || it.headline,
fullBody: summary.fullBody || it.fullBody || it.body,
fullRef: summary.fullRef || it.fullRef || '',
truncated: summary.truncated || it.truncated || false,
};
if (Object.entries(patch).every(([key, value]) => it[key] === value)) {
timelineUpdate = 'duplicate-skip';
} else {
Object.assign(it, patch);
it.count += 1;
timelineUpdate = 'patch-last';
}
} else if (existingIdx !== -1) {
// An older duplicate only refreshes its timestamp.
const it = record.items[existingIdx];
it.ts = ts || it.ts;
timelineUpdate = 'duplicate-skip';
} else {
const lineKey = `line-${Date.now()}-${Math.random().toString(16).slice(2)}`;
record.items.push({
phase: summary.phase || 'working',
headline: headline || 'Update',
fullHeadline: summary.fullHeadline || headline || 'Update',
body: summary.body || '',
fullBody: summary.fullBody || summary.body || '',
fullRef: summary.fullRef || '',
truncated: summary.truncated || false,
receipt: Boolean(summary.receipt),
ts: ts || '',
sourceTs: rawTs,
count: 1,
dedupeKey: syntheticKey,
lineKey,
});
timelineUpdate = 'append';
}
return { timelineUpdate, patchIndex };
}
/** The block's folded tool evidence row. Live frames and the host's metrics
* reach it through the same keyed in-place upsert, so neither route can mint a
* second row, and the row keeps the position and timestamp of its first frame.
*/
export function upsertToolFoldRow(record, view, ts, rawTs) {
const syntheticKey = `tools|${record.groupId}`;
// Stationary: the row keeps the place and the time of the first frame it
// counted, so a burst of calls never walks it down the timeline.
const first = !record.items.some((item) => item.dedupeKey === syntheticKey);
return updateLiveTimelineItem(record, view, {
ts: first ? ts : '', rawTs, syntheticKey, headline: view.headline, inPlaceByKey: true,
});
}