ouroboros/web/modules/chat_render_batch.js
Anton Razzhigaev 472abc8c27 WIP(C+M): card-set durable-truth reconcile, sole status writer, and the project_thread Main fan-out gate
Squashed: the two client-touching streams share one BYTE_DEBT update (chat.js 228191 -> 228080 net; shrink-only forbids an intermediate rise, baseline untouched). Band rationale for projects_registry introduced here in its final wording.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-08-24 01:31:57 +03:00

192 lines
7.9 KiB
JavaScript

// perf2 P4 (RENDER-BATCH): pure helpers behind chat.js's rebuildAll replay
// batch and the "Load older" window escalation. Dependency-free at import time
// so node tests can exercise them directly.
/**
* 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 {
schedule() {
if (isReplayActive()) return false;
if (timer != null) 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.)
*/
export function insertTimelineNode(messages, node, typing = null, { stickToBottom = false } = {}) {
const previousScrollTop = Number(messages?.scrollTop) || 0;
const previousScrollHeight = Number(messages?.scrollHeight) || 0;
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) {
before = child;
break;
}
}
}
let insertedAboveViewport = false;
if (
before
&& !stickToBottom
&& typeof before.getBoundingClientRect === 'function'
&& typeof messages.getBoundingClientRect === 'function'
) {
insertedAboveViewport = before.getBoundingClientRect().top <= messages.getBoundingClientRect().top;
}
if (before) messages.insertBefore(node, before);
else if (typing && typing.parentNode === messages) messages.insertBefore(node, typing);
else messages.appendChild(node);
const nextScrollHeight = Number(messages?.scrollHeight) || 0;
if (stickToBottom) {
messages.scrollTop = nextScrollHeight;
} else if (insertedAboveViewport) {
messages.scrollTop = previousScrollTop + Math.max(0, nextScrollHeight - previousScrollHeight);
}
return { before, insertedAboveViewport };
}
/**
* Sort key for a top-level timeline node: its stamped `data-ts` epoch, or
* +Infinity for timestamp-free nodes so they keep the historical "append at
* the end (before typing)" placement of insertTimelineNode.
*/
export function timelineNodeSortKey(node) {
const raw = node?.dataset?.ts;
const ts = raw == null || raw === '' ? NaN : Number(raw);
return Number.isFinite(ts) ? ts : Infinity;
}
/**
* Stable chronological order for a batch of collected timeline nodes: key is
* the stamped ts, tie-break is collection (arrival) order. This reproduces
* insertTimelineNode's semantics over an initially-empty feed — equal
* timestamps preserve arrival order (chat_chronology pin), undated nodes land
* at the end in arrival order.
*/
export function orderBatchNodes(nodes) {
return nodes
.map((node, index) => ({ node, index, key: timelineNodeSortKey(node) }))
.sort((a, b) => {
if (a.key === b.key) return a.index - b.index;
return a.key < b.key ? -1 : 1;
})
.map((entry) => entry.node);
}
/**
* One rebuildAll replay batch: collects top-level nodes destined for the feed
* (instead of per-row live-DOM chronological insertion), remembers which live
* cards need their one final meta/count/layout pass, and defers the per-frame
* typing-indicator write to a single application after the mount (the header
* badge is written only by chat.js's status reducer, once after the batch).
*
* mount() performs the ONE DOM insertion of the whole replay: a stable sort,
* one detached fragment, one insertBefore ahead of the typing indicator
* (typing stays last). No awaits happen between the feed clearing and this
* mount — the caller keeps the whole section synchronous [GPT#14].
*/
export function createRebuildBatch(doc = null) {
const nodes = [];
const seen = new Set();
const touched = new Set();
return {
touched,
typingHidden: false,
collect(node) {
if (!node || seen.has(node)) return;
seen.add(node);
nodes.push(node);
},
touch(record) {
if (record) touched.add(record);
},
mount(messages, typing = null) {
if (!messages) return;
const ordered = orderBatchNodes(nodes);
const ownerDoc = doc || messages.ownerDocument || globalThis.document;
const fragment = ownerDoc.createDocumentFragment();
for (const node of ordered) fragment.appendChild(node);
if (typing && typing.parentNode === messages) messages.insertBefore(fragment, typing);
else messages.appendChild(fragment);
},
};
}
// "Load older" quota escalation ladder (perf2 P4.5): the DEFAULT request sends
// no quota params (the server window governs); each click asks for explicitly
// larger n_human/n_progress until the server-side caps (1500/600).
export const LOAD_OLDER_QUOTA_STEPS = [
{ n_human: 400, n_progress: 240 },
{ n_human: 1500, n_progress: 600 },
];
/** Next explicit-quota step above `current` (null = server default window). */
export function nextQuotaEscalation(current = null) {
const currentHuman = Number(current?.n_human) || 0;
return LOAD_OLDER_QUOTA_STEPS.find((step) => step.n_human > currentHuman) || null;
}
/**
* Presentation state for the top-of-feed "Load older" control, driven by the
* SERVER's window truncation verdict (window.complete / window.truncated_by,
* perf2 P3.2 [Fable#2]) — never by a client guess:
* - hidden: the window is complete (or the server predates the field);
* - button: quota-truncated AND a larger explicit quota is still available;
* - notice: nothing more can be loaded from here — the honest boundary text
* names BOTH the on-disk archive floor and the subagent lineage cap
* [GPT#11], so a short-history user is never told about phantom archives.
*/
export function loadOlderControlState(windowInfo = null, quota = null) {
if (!windowInfo || typeof windowInfo !== 'object' || windowInfo.complete === true) {
return { mode: 'hidden', label: '' };
}
const causes = Array.isArray(windowInfo.truncated_by)
? windowInfo.truncated_by.map(String)
: [];
if (causes.includes('quota') && nextQuotaEscalation(quota)) {
return { mode: 'button', label: 'Load older messages' };
}
return {
mode: 'notice',
label: 'Older messages stay in on-disk archives, and deep subagent lineage '
+ 'is capped per window — this view is at its maximum depth.',
};
}