mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-21 14:12:28 +00:00
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>
192 lines
7.9 KiB
JavaScript
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.',
|
|
};
|
|
}
|