// 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); return { olderButton: button, render(snapshot, windows) { const error = snapshot.error; const changedView = error?.body?.reason_code === 'history_view_changed'; const noteText = error ? String(error.message || error) : snapshot.olderExhausted ? 'Beginning of saved history' : ''; const fields = [ [button, { textContent: snapshot.loading ? 'Loading…' : changedView ? 'Refresh history' : error ? 'Retry loading messages' : 'Load older messages', disabled: Boolean(snapshot.loading), hidden: !error && !snapshot.canOlder }], [note, { textContent: noteText, hidden: !noteText }], ]; for (const [node, values] of fields) { for (const [key, value] of Object.entries(values)) if (node[key] !== value) node[key] = value; } if ((snapshot.initialized || error) && !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, }); }