* fix(library): close recipe editor follow-ups from the native editor slice - After restored storage, Create text revision and Archive are disabled so they cannot re-read an old numeric ID; only Reload Library remains. - The storage-changed exit in Edit, revision and Add negative states that Reload Library closes the dialog and discards the entries (the concept has no discard confirmation, so the copy stays inline). - When Save becomes disabled after an unconfirmed write or a conflict, focus moves to the recovery action instead of falling to the body; the archive confirmation does the same. - A reuse toast only says the entered parent metadata was not applied when the controller found it differs from the stored metadata. - Prompt history opens on its first negative variant, as the concept does, so the heading Send starts enabled. - "1 linked image" is singular on cards, variant lanes and the embedded gallery count. * fix(library): treat a blank category as equal to a stored uncategorized one A reuse that left the category blank sent "Uncategorized" and compared it with the stored NULL, so the toast claimed the entered metadata was not applied. Both sides now normalize blank and "Uncategorized" the same way before comparing; a genuinely different category still discloses it. * test(coverage): raise frontend coverage floors after editor follow-ups * fix(library): singular counts for one item and honest discard copy - One shared countOf/noun helper (web/js/workspace/plural.mjs) now words linked images, saved negative variants (card chip and history count), distinct recorded runs and the Images toolbar count in the singular for exactly one; every other count keeps its existing wording. - The restored-storage status says "Leaving this dialog discards your entries." because Cancel, the close button and Escape discard the draft too, not only Reload Library. - The new helper gets its coverage floor; no other floor changes. * fix(library): open every prompt history visit on its first negative The concept resets to the first negative variant on every navigation to a prompt (navigate: state.variant = 0) and selects another pair only right after a save names it. The history no longer remembers a clicked variant or an explicit All images choice across visits: the per-visit memory is replaced by a one-shot saved selection that the next history mount (or the same visit's reconciliation) consumes, and a verified pair outside the loaded page is kept only for that saved selection. Within a visit, clicks and All images behave as before.
223 lines
18 KiB
JavaScript
223 lines
18 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'})`;
|
||
}
|
||
|
||
// 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}) {
|
||
const scope = createScope(), dispose = () => scope.dispose();
|
||
if (parentScope.signal.aborted) {dispose();return {dispose};}
|
||
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);}
|
||
}});
|
||
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));
|
||
column.position.textContent = 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`;
|
||
button.textContent = 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`;
|
||
branch.firstChild.textContent = `${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 exact frozen positive raw text, 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;reload.textContent = 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();
|
||
return {dispose};
|
||
}
|