The Library stores text.strip() while a run keeps its exact raw text. The history window, run detail scope id, newest pair run and prompt hints now compare the raw text's stored form (one rule, utils/hashing.py, mirrored in SQLite with the same code points). Hints also keep matching the exact raw text, so older rows that kept outer whitespace stay hinted and protected.
239 lines
19 KiB
JavaScript
239 lines
19 KiB
JavaScript
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||
import {createHistoryController, visibleHistoryColumns} from './history-state.mjs';
|
||
import {mountRecordedRun} from './recorded-run.mjs';
|
||
import {countOf} from './plural.mjs';
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
function preview(summary) {
|
||
const raw = summary.negative_raw;
|
||
return raw.state === 'unknown' ? 'Negative unknown' : raw.state === 'explicit_empty' ? 'Explicitly empty negative' : `${raw.preview}${raw.truncated ? '…' : ''}`;
|
||
}
|
||
function distinction(summary) {
|
||
const sources = [...new Set(summary.negative_raw.evidence.map(e => e.source))];
|
||
return `Frozen composition · ${sources.join(', ') || 'unknown evidence'}`;
|
||
}
|
||
function observedLabel(value) {
|
||
const time = Number(value);
|
||
return `Observed dequeue ${value} ms (${Number.isSafeInteger(time) && time <= 8640000000000000 ? new Date(time).toISOString() : 'outside display range'})`;
|
||
}
|
||
|
||
// Writing textContent replaces the text node even when the text is unchanged. A marker re-rendered between the press and
|
||
// the release of a tap would lose the node under the pointer, and Chromium touch then drops the click.
|
||
function setText(node, text) {
|
||
if (node.textContent !== text) node.textContent = text;
|
||
}
|
||
|
||
// New nodes use ordinary insertion; connected moves preserve browser state where supported.
|
||
function place(parent, node, before) {
|
||
if (node === before || (node.parentNode === parent && node.nextSibling === before)) return;
|
||
if (typeof parent.moveBefore === 'function' && parent.isConnected && node.isConnected && parent.ownerDocument === node.ownerDocument) parent.moveBefore(node, before);
|
||
else parent.insertBefore(node, before);
|
||
}
|
||
|
||
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate, onRecordedCount}) {
|
||
const scope = createScope(), dispose = () => scope.dispose();
|
||
// openRun is a hoisted declaration: on an aborted parent it answers false before reading any later state.
|
||
if (parentScope.signal.aborted) {dispose();return {dispose, openRun};}
|
||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||
const section = el('section', undefined, 'library-surface recorded-history');
|
||
section.setAttribute('aria-label', 'Recorded executions');
|
||
section.append(el('h2', 'Recorded executions'), el('p', 'same positive raw text · frozen contexts can differ in composition and evidence.', 'muted'));
|
||
const count = el('p', '', 'history-count muted');count.setAttribute('role', 'status');
|
||
const disclosure = el('details', undefined, 'history-scope');disclosure.append(el('summary', 'Recorded history scope'));
|
||
const scopeText = el('p', '', 'muted');disclosure.append(scopeText);
|
||
const toolbar = el('div', undefined, 'history-toolbar');
|
||
const earlier = createButton('Load earlier runs'), refresh = createButton('Refresh history'), retry = createButton('Retry recorded history');
|
||
const reload = createButton('Reload recorded history'), selected = createButton('View selected run');selected.hidden = true;
|
||
toolbar.append(earlier, refresh, retry, reload, selected);
|
||
const status = el('p', '', 'history-status muted');status.setAttribute('role', 'status');
|
||
const timeline = el('div', undefined, 'history-timeline');timeline.setAttribute('aria-label', 'Chronological branch timeline');
|
||
const canvas = el('div', undefined, 'history-canvas');
|
||
const edge = el('span', undefined, 'history-earlier-edge');edge.setAttribute('aria-hidden', 'true');
|
||
const positive = el('div', undefined, 'history-positive-lane');positive.append(el('span', 'Positive raw text · unchanged'));positive.setAttribute('aria-hidden', 'true');
|
||
const branches = el('div', undefined, 'history-context-branches');branches.setAttribute('aria-hidden', 'true');
|
||
const positions = el('ol', undefined, 'history-run-list');positions.setAttribute('aria-label', 'Recorded run positions');
|
||
canvas.append(edge, positive, branches, positions);timeline.append(canvas);
|
||
section.append(count, disclosure, toolbar, status, timeline, el('p', 'Green + positive · red − negative · small dot repeated frozen context. Colors identify prompt roles.', 'history-legend muted'));
|
||
container.append(section);
|
||
let state, runView, frame = 0, observer, items = null, summaries = [], indexes = new Map(), contexts = new Map();
|
||
let initialized = false, focusedRun = null, activeParent = null;
|
||
let restorePosition = typeof visit.resumeCursor === 'string' && Number.isFinite(visit.scrollLeft) && visit.scrollLeft >= 0;
|
||
const columns = new Map(), branchNodes = new Map();
|
||
const controller = createHistoryController({api, positiveId, restore:visit, onChange:next => {
|
||
render(next);runView?.setState(next);
|
||
if (next.parent !== activeParent) {activeParent = next.parent;onParentRevalidate?.(next.parent);}
|
||
// The history summary's "N tracked runs" (#249 D24): a completed window read's count; a read in flight keeps the last.
|
||
const window = next.window;
|
||
if (!window.loading) onRecordedCount?.(window.history_epoch && !window.error ? window.recorded_run_count : null);
|
||
}});
|
||
state = controller.state;
|
||
runView = mountRecordedRun(container, {api, controller, sender, parentScope:scope, onRestoreFocus:restoreMarker});
|
||
|
||
function restoreMarker(selection) {
|
||
const index = indexes.get(selection?.runId || state.selection.run_id);
|
||
if (index === undefined) {refresh.focus({preventScroll:true});return;}
|
||
focusedRun = items[index].run_id;renderColumns();
|
||
const column = columns.get(focusedRun);
|
||
const button = [...column.node.querySelectorAll('button')].find(node => node.dataset.contextId === (selection?.contextId || state.selection.context_id)) || column.node.querySelector('button');
|
||
button?.focus({preventScroll:true});
|
||
}
|
||
function captureAnchor() {
|
||
const bounds = timeline.getBoundingClientRect(), active = document.activeElement;
|
||
let button = timeline.contains(active) && active.matches('[data-run-id]') ? active : null;
|
||
if (!button) for (const column of columns.values()) {
|
||
const box = column.node.getBoundingClientRect();
|
||
if (box.right > bounds.left + 1 && box.left < bounds.right) {button = column.node.querySelector('button');break;}
|
||
}
|
||
if (!button) return null;
|
||
const box = button.getBoundingClientRect();
|
||
return {run:button.dataset.runId, context:button.dataset.contextId, x:box.left - bounds.left, y:box.top - bounds.top};
|
||
}
|
||
function restoreAnchor(anchor) {
|
||
const column = columns.get(anchor?.run);
|
||
const button = column && [...column.buttons.values()].find(node => node.dataset.contextId === anchor.context);
|
||
if (!button) return;
|
||
const box = button.getBoundingClientRect(), bounds = timeline.getBoundingClientRect();
|
||
timeline.scrollLeft += box.left - bounds.left - anchor.x;
|
||
timeline.scrollTop += box.top - bounds.top - anchor.y;
|
||
}
|
||
function columnFor(run) {
|
||
const node = el('li', undefined, 'history-run-column');node.dataset.historyRun = run.run_id;
|
||
const position = el('span', '', 'history-position');position.setAttribute('aria-hidden', 'true');node.append(position);
|
||
return {node, position, buttons:new Map()};
|
||
}
|
||
function renderColumns() {
|
||
if (scope.signal.aborted || !items) return;
|
||
const active = document.activeElement;
|
||
const focused = indexes.get(timeline.contains(active) ? active.closest('[data-history-run]')?.dataset.historyRun : focusedRun || state.selection.run_id) ?? -1;
|
||
const visible = visibleHistoryColumns(items.length, Math.max(0, timeline.scrollLeft - 38), timeline.clientWidth, focused);
|
||
const keep = new Set(visible.map(index => items[index].run_id));
|
||
for (const [id, column] of columns) if (!keep.has(id)) {column.node.remove();columns.delete(id);}
|
||
let before = positions.firstChild;
|
||
for (const index of visible) {
|
||
const run = items[index];let column = columns.get(run.run_id);
|
||
if (!column) {column = columnFor(run);columns.set(run.run_id, column);}
|
||
const {node, buttons} = column;
|
||
node.style.insetInlineStart = `${38 + index * 64}px`;node.value = index + 1;
|
||
node.setAttribute('aria-posinset', String(index + 1));node.setAttribute('aria-setsize', String(items.length));
|
||
setText(column.position, String(index + 1));
|
||
const anchors = new Map();for (const anchor of run.matching_anchors) if (!anchors.has(anchor.context_id)) anchors.set(anchor.context_id, anchor);
|
||
for (const anchor of anchors.values()) {
|
||
let button = buttons.get(anchor.context_id);
|
||
if (!button) {
|
||
button = createButton('', 'history-run-marker');button.dataset.runId = run.run_id;button.dataset.anchorId = anchor.anchor_id;button.dataset.contextId = anchor.context_id;
|
||
buttons.set(anchor.context_id, button);node.append(button);
|
||
}
|
||
const context = contexts.get(anchor.context_id), first = context.first === index;
|
||
button.classList.toggle('history-context-anchor', first);
|
||
button.style.insetBlockStart = `${80 + context.row * 57}px`;
|
||
setText(button, first ? '−' : '');
|
||
const label = `Recorded run ${index + 1} of ${items.length} loaded · ${observedLabel(run.observed_dequeue_at)} · Outcome ${run.outcome} · ${preview(context.summary)} · context ${anchor.context_id.slice(0, 8)} · anchor ${anchor.anchor_id}`;
|
||
button.setAttribute('aria-label', label);button.title = label;
|
||
button.setAttribute('aria-pressed', String(state.selection.run_id === run.run_id && state.selection.context_id === anchor.context_id));
|
||
// Disabling a focused native button drops focus during a prepend read.
|
||
button.setAttribute('aria-disabled', String(!state.actionsEnabled));
|
||
}
|
||
place(positions, node, before);before = node.nextSibling;
|
||
}
|
||
if (timeline.contains(active) && active.isConnected && document.activeElement !== active) active.focus({preventScroll:true});
|
||
}
|
||
function render(next) {
|
||
if (scope.signal.aborted) return;
|
||
const anchor = initialized && items !== next.window.items ? captureAnchor() : null;
|
||
state = next;
|
||
const window = next.window;
|
||
if (items !== window.items) {
|
||
items = window.items;indexes = new Map(items.map((run, index) => [run.run_id, index]));
|
||
const firsts = new Map();
|
||
for (const [index, run] of items.entries()) for (const anchor of run.matching_anchors) if (!firsts.has(anchor.context_id)) firsts.set(anchor.context_id, index);
|
||
summaries = [...window.context_summaries].sort((a, b) => firsts.get(a.context_id) - firsts.get(b.context_id) || (a.context_id < b.context_id ? -1 : 1));
|
||
contexts = new Map(summaries.map((summary, row) => [summary.context_id, {summary, row, first:firsts.get(summary.context_id)}]));
|
||
canvas.style.inlineSize = `${Math.max(520, 76 + items.length * 64)}px`;
|
||
canvas.style.blockSize = `${Math.max(150, 115 + summaries.length * 57)}px`;
|
||
const keep = new Set(contexts.keys());
|
||
for (const [id, node] of branchNodes) if (!keep.has(id)) {node.remove();branchNodes.delete(id);}
|
||
let before = branches.firstChild;
|
||
for (const [id, context] of contexts) {
|
||
let branch = branchNodes.get(id);
|
||
if (!branch) {branch = el('div', undefined, 'history-context-branch');branch.dataset.contextBranch = id;branch.append(el('span', '', 'history-context-label'));branchNodes.set(id, branch);}
|
||
branch.style.insetBlockStart = `${80 + context.row * 57}px`;branch.style.insetInlineStart = `${38 + context.first * 64}px`;
|
||
setText(branch.firstChild, `${preview(context.summary)} · ${id.slice(0, 8)} · ${distinction(context.summary)}`);
|
||
place(branches, branch, before);before = branch.nextSibling;
|
||
}
|
||
if (anchor) {
|
||
const index = indexes.get(anchor.run);
|
||
if (index !== undefined) timeline.scrollLeft = Math.max(0, 38 + index * 64 - anchor.x);
|
||
}
|
||
}
|
||
count.textContent = window.group ? `${countOf(window.recorded_run_count, 'distinct recorded run')} as of this read · ${window.items.length} loaded positions` : '';
|
||
const group = window.group;
|
||
scopeText.textContent = group?.scope_status === 'uncapturable_text' ? 'This saved positive cannot be matched within frozen-text capture limits.' : 'Runs are grouped by frozen positive raw text that is this saved text apart from outer whitespace, independently of saved pairs. Effective text, prepend/append composition and evidence can differ. Reported output locations do not establish context membership or historical file identity.';
|
||
status.setAttribute('role', window.error || next.run.error ? 'alert' : 'status');
|
||
status.textContent = next.validating ? 'Revalidating recorded history…' : window.loading ? 'Loading recorded history…' : window.error ? 'Recorded history could not be loaded.' : next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : !items.length ? 'No matching recorded executions.' : window.has_more ? 'Newest bounded window · displayed in chronological order.' : 'All matching recorded runs in this window are loaded.';
|
||
if (next.run.error && !next.selection.run_id && !next.refreshRequired) status.textContent += ' The previous recorded selection could not be restored.';
|
||
const outside = !!next.selection.run_id && !indexes.has(next.selection.run_id);
|
||
if (outside && !next.validating && !next.refreshRequired) status.textContent += ` Selected run ${next.selection.run_id} is outside this loaded timeline window.`;
|
||
selected.hidden = !outside;selected.disabled = next.validating || window.loading || !!next.refreshRequired;
|
||
earlier.hidden = !window.has_more;earlier.disabled = window.loading || !!next.refreshRequired;earlier.setAttribute('aria-disabled', String(earlier.disabled));
|
||
refresh.disabled = window.loading || next.validating;retry.hidden = !window.error || !!next.refreshRequired;
|
||
reload.hidden = !next.refreshRequired;setText(reload, next.refreshLabel || 'Reload recorded history');
|
||
timeline.hidden = !items.length;
|
||
let initialAnchor = null;
|
||
if (!initialized && items.length && !window.loading && !next.validating && !next.refreshRequired) {
|
||
timeline.scrollLeft = restorePosition ? state.intent.scrollLeft : canvas.offsetWidth;
|
||
timeline.scrollTop = restorePosition ? state.intent.scrollTop : 0;
|
||
if (restorePosition && state.intent.anchor) initialAnchor = {run:state.intent.anchor.runId, context:state.intent.anchor.contextId, x:state.intent.anchor.x, y:state.intent.anchor.y};
|
||
initialized = true;restorePosition = false;
|
||
}
|
||
if (next.refreshRequired === 'history-changed') {initialized = false;restorePosition = false;}
|
||
renderColumns();if (initialAnchor || anchor) restoreAnchor(initialAnchor || anchor);
|
||
}
|
||
const schedule = () => {if (!frame) frame = requestAnimationFrame(() => {frame = 0;renderColumns();});};
|
||
scope.listen(timeline, 'scroll', schedule);scope.listen(window, 'resize', schedule);
|
||
scope.listen(positions, 'focusin', event => {focusedRun = event.target.closest('[data-history-run]')?.dataset.historyRun || focusedRun;});
|
||
scope.listen(positions, 'click', event => {
|
||
const button = event.target.closest('button[data-run-id]');if (!button || !state.actionsEnabled) return;
|
||
runView.open({runId:button.dataset.runId, anchorId:button.dataset.anchorId, contextId:button.dataset.contextId, trigger:button});
|
||
});
|
||
scope.listen(positions, 'keydown', event => {
|
||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key) || !state.actionsEnabled) return;
|
||
const current = event.target.closest('[data-history-run]');if (!current) return;
|
||
const index = indexes.get(current.dataset.historyRun), next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : index + (event.key === 'ArrowLeft' ? -1 : 1);
|
||
if (next < 0 || next >= items.length) return;
|
||
event.preventDefault();focusedRun = items[next].run_id;renderColumns();
|
||
const button = columns.get(focusedRun).node.querySelector('button');button.focus({preventScroll:true});button.scrollIntoView({block:'nearest', inline:'nearest'});renderColumns();
|
||
});
|
||
scope.listen(earlier, 'click', () => controller.loadEarlier());scope.listen(refresh, 'click', () => {initialized = false;restorePosition = false;controller.refresh();});
|
||
scope.listen(selected, 'click', () => {
|
||
if (!selected.disabled && state.selection.run_id) runView.open({runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id, trigger:selected});
|
||
});
|
||
scope.listen(retry, 'click', () => controller.load());scope.listen(reload, 'click', () => state.refreshRequired === 'history-changed' || state.refreshRequired === 'revalidation-failed' ? controller.refresh() : controller.loadOutputs());
|
||
if (typeof IntersectionObserver === 'function') {
|
||
observer = new IntersectionObserver(entries => {if (entries.some(entry => entry.isIntersecting) && state.actionsEnabled && state.window.has_more) controller.loadEarlier();}, {root:timeline});observer.observe(edge);
|
||
}
|
||
scope.listen(window, 'pagehide', dispose);
|
||
scope.cleanup(() => {
|
||
const anchor = captureAnchor();
|
||
Object.assign(visit, {mode:state.selection.mode, runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id,
|
||
historyEpoch:state.window.history_epoch, resumeCursor:state.window.resume_cursor, loadedCount:state.window.items.length,
|
||
outputCount:state.outputs.items.length, outputsLoaded:state.outputs.capture !== null, receipt:state.selection.receipt && {...state.selection.receipt},
|
||
scrollLeft:timeline.scrollLeft, scrollTop:timeline.scrollTop, anchor:anchor && {runId:anchor.run, contextId:anchor.context, x:anchor.x, y:anchor.y}});
|
||
observer?.disconnect();cancelAnimationFrame(frame);runView.dispose();controller.dispose();columns.clear();branchNodes.clear();section.remove();
|
||
});
|
||
render(state);
|
||
void controller.load();
|
||
/** Open Recorded run for one run (the history run dot, #249 D26); false while recorded actions are unavailable. */
|
||
function openRun({runId, anchorId = null, contextId = null, trigger}) {
|
||
if (scope.signal.aborted || !state.actionsEnabled) return false;
|
||
runView.open({runId, anchorId, contextId, trigger});
|
||
return true;
|
||
}
|
||
return {dispose, openRun};
|
||
}
|