feat(history): render recorded executions and frozen run details (#209)
This commit is contained in:
@@ -157,6 +157,21 @@ const emptyRun = () => ({detail:null, loading:false, error:null});
|
||||
const emptyOutputs = () => ({items:[], capture:null, capture_fingerprint:null, retained_occurrence_count:null, list_status:null, next_cursor:null, has_more:false, loading:false, error:null});
|
||||
const emptySelection = mode => ({run_id:null, anchor_id:null, context_id:null, mode, receipt:null});
|
||||
|
||||
/** Fixed run positions; an off-screen focused column remains in semantic order. */
|
||||
export function visibleHistoryColumns(count, left, width, focusedIndex = -1) {
|
||||
requireValue(Number.isSafeInteger(count) && count >= 0, 'Invalid history column count');
|
||||
if (count <= 120) return Array.from({length:count}, (_, index) => index);
|
||||
const start = Math.max(0, Math.floor((Number.isFinite(left) ? Math.max(0, left) : 0) / 64) - 3);
|
||||
const right = (Number.isFinite(left) ? Math.max(0, left) : 0) + (Number.isFinite(width) ? Math.max(1, width) : 1);
|
||||
const end = Math.min(count - 1, Math.ceil(right / 64) - 1 + 3);
|
||||
const result = [];
|
||||
for (let index = start; index <= end; index++) result.push(index);
|
||||
if (Number.isSafeInteger(focusedIndex) && focusedIndex >= 0 && focusedIndex < count && !result.includes(focusedIndex)) {
|
||||
result.push(focusedIndex);result.sort((a, b) => a - b);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createHistoryController({api, positiveId, onChange = () => {}, restore}) {
|
||||
requireValue(id(positiveId), 'Invalid saved positive ID');
|
||||
const intent = {search:typeof restore?.search === 'string' ? restore.search : '', scrollTop:typeof restore?.scrollTop === 'number' && Number.isFinite(restore.scrollTop) ? Math.max(0, restore.scrollTop) : 0};
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {createHistoryController, visibleHistoryColumns} from './history-state.mjs';
|
||||
import {mountRecordedRun} from './recorded-run.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'}`;
|
||||
}
|
||||
|
||||
// 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');
|
||||
toolbar.append(earlier, refresh, retry, reload);
|
||||
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', 'Positive raw text · unchanged', 'history-positive-lane');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;
|
||||
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 ? '−' : '';
|
||||
button.setAttribute('aria-label', `Recorded run ${index + 1} of ${items.length} loaded · ${preview(context.summary)} · context ${anchor.context_id.slice(0, 8)} · anchor ${anchor.anchor_id}`);
|
||||
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 = 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 (!initialized && items.length) {timeline.scrollLeft = canvas.offsetWidth;timeline.scrollTop = state.intent.scrollTop;initialized = true;}
|
||||
else 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 ? `${window.recorded_run_count} distinct recorded runs 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 ? '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.';
|
||||
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;renderColumns();if (anchor) restoreAnchor(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;controller.refresh();});
|
||||
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(() => {
|
||||
Object.assign(visit, {mode:state.selection.mode, runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id, scrollTop:timeline.scrollTop});
|
||||
observer?.disconnect();cancelAnimationFrame(frame);runView.dispose();controller.dispose();columns.clear();branchNodes.clear();section.remove();
|
||||
});
|
||||
render(state);
|
||||
const reentry = Object.hasOwn(visit, 'mode');
|
||||
void controller.load().then(() => {if (reentry && !scope.signal.aborted) return controller.revalidate();});
|
||||
return {dispose};
|
||||
}
|
||||
@@ -46,3 +46,67 @@ main.library-main { --library-positive: #73dda9; }
|
||||
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } }
|
||||
|
||||
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
|
||||
|
||||
/* Role colors and branch geometry follow the approved history surface. */
|
||||
main.library-main { --library-negative: #ff8c98; }
|
||||
[data-ca-theme="light"] main.library-main { --library-negative: #b71931; }
|
||||
.library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; }
|
||||
.library-main .library-branches { overflow-x: auto; position: relative; }
|
||||
.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; }
|
||||
.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: var(--ca-field); text-align: center; font-size: 16px; line-height: 25px; }
|
||||
.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
|
||||
.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
|
||||
.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; }
|
||||
.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: var(--ca-field); }
|
||||
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; }
|
||||
.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
|
||||
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.history-scope summary { cursor: pointer; }
|
||||
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); }
|
||||
.history-canvas { position: relative; margin: 18px 20px; }
|
||||
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--library-positive); color: var(--library-positive); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; }
|
||||
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; }
|
||||
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
|
||||
.history-context-label { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; inline-size: 370px; max-inline-size: calc(100% - 43px); font-size: 10px; line-height: 1.5; color: var(--ca-muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: clip; }
|
||||
.history-run-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.history-run-column { position: absolute; inset-block: 0; inline-size: 64px; pointer-events: none; }
|
||||
.history-position { position: absolute; inset-block-start: 41px; font-size: 10px; color: var(--ca-muted); }
|
||||
.ca-tools .history-run-marker { position: absolute; pointer-events: auto; transform: translate(-50%, -50%); inline-size: 12px; block-size: 12px; min-inline-size: 12px; min-block-size: 12px; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); padding: 0; color: var(--ca-field); font-size: 16px; line-height: 1; }
|
||||
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--library-negative); }
|
||||
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; }
|
||||
.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border: 1px solid var(--ca-line); border-radius: 7px; color: var(--ca-text); background: var(--ca-surface); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
|
||||
.recorded-run-dialog::backdrop { background: #0009; }
|
||||
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.recorded-run-header h2 { margin: 0; font-size: 20px; }
|
||||
.recorded-run-body, .recorded-run-body > section { min-inline-size: 0; }
|
||||
.recorded-run-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; font-size: 12px; }
|
||||
.recorded-run-facts dt { color: var(--ca-muted); font-size: 10px; }
|
||||
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; }
|
||||
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; }
|
||||
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-run-dialog :where(.muted, .prompt-source, .recorded-output-status) { font-size: 11px; line-height: 1.65; }
|
||||
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; }
|
||||
.recorded-provenance summary { cursor: pointer; }
|
||||
.recorded-run-dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 100%; }
|
||||
.recorded-outputs { margin-block: 24px; }
|
||||
.recorded-receipts { list-style: decimal; padding-inline-start: 24px; }
|
||||
.recorded-receipt { border: 1px solid var(--ca-line); border-radius: 5px; padding: 12px; margin-block: 10px; font-size: 12px; }
|
||||
.recorded-receipt > div { margin-block-end: 10px; }
|
||||
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; }
|
||||
@media (max-width: 700px) {
|
||||
.recorded-run-dialog { padding: 16px; }
|
||||
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; }
|
||||
.ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); }
|
||||
.ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: var(--ca-field); background: var(--library-negative); }
|
||||
.ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; }
|
||||
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
|
||||
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import {mountImages} from './images.mjs';
|
||||
import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
const variantSelections=new Map();
|
||||
const historyVisits=new Map();
|
||||
function el(tag,text,className) {
|
||||
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
|
||||
}
|
||||
@@ -242,27 +244,37 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
detail=state.detail;main.querySelector('h1').textContent=title(detail.positive.text);
|
||||
const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro');
|
||||
const summary=el('article',undefined,'library-surface');summary.append(el('span',detail.positive.category || 'Uncategorized','library-chip'));
|
||||
const summary=el('article',undefined,'library-surface');const categoryChip=el('span',detail.positive.category || 'Uncategorized','library-chip');summary.append(categoryChip);
|
||||
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
|
||||
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
|
||||
const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved);
|
||||
const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0;
|
||||
contentScope.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
|
||||
const all=createButton('All images for this positive');
|
||||
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
|
||||
variants.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
|
||||
const branches=el('div',undefined,'library-branches');branches.append(el('p','+ Positive prompt · unchanged','library-positive-lane'));
|
||||
const branches=el('div',undefined,'library-branches');branches.append(el('p','Positive prompt · unchanged','library-positive-lane'));
|
||||
const choices=[];
|
||||
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
|
||||
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
|
||||
const more=createButton('Load more negative variants');const retry=createButton('Retry negative variants');retry.hidden=true;
|
||||
contentScope.listen(more,'click',()=>loader.loadMore());contentScope.listen(retry,'click',()=>loader.loadMore());
|
||||
contentScope.listen(more,'click',()=>{if(savedReadState==='current')loader.loadMore();});contentScope.listen(retry,'click',()=>{if(savedReadState==='current')loader.loadMore();});
|
||||
updateBranches=state=>{
|
||||
const known=new Set(choices.map(choice=>choice.variant.id));
|
||||
for(const [index,variant] of detail.variants.entries()) if(!known.has(variant.id)) {
|
||||
const lane=el('div',undefined,'library-variant-lane');const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
|
||||
const lane=el('div',undefined,'library-variant-lane');lane.style.setProperty('--lane-offset',`${38+index*47}px`);const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
|
||||
button.setAttribute('aria-pressed',String(variantSelections.get(positiveId)===variant.id));
|
||||
lane.append(button,el('span',`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`));branches.append(lane);choices.push({button,variant});
|
||||
const label=el('span');lane.append(button,label);branches.append(lane);choices.push({button,variant,label});
|
||||
contentScope.listen(button,'click',()=>select(variant.id));
|
||||
}
|
||||
for(const [index,choice] of choices.entries()) {
|
||||
choice.variant=detail.variants.find(variant=>variant.id===choice.variant.id) || choice.variant;
|
||||
const variant=choice.variant;choice.label.textContent=`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`;
|
||||
choice.button.disabled=savedReadState!=='current';
|
||||
}
|
||||
variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`;
|
||||
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants));retry.hidden=!state.variantsError;
|
||||
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants || savedReadState!=='current'));retry.hidden=!state.variantsError;
|
||||
more.disabled=retry.disabled=savedReadState!=='current';all.disabled=savedReadState!=='current';
|
||||
variantError.hidden=!state.variantsError;variantError.textContent=state.variantsError?'Negative variants could not be loaded. Retry to continue.':'';
|
||||
};
|
||||
updateBranches(state);
|
||||
@@ -271,11 +283,51 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const context=el('p','','library-variant-context');context.setAttribute('role','status');
|
||||
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
|
||||
const images=el('div',undefined,'library-linked-images');gallery.append(images);let activePairId;
|
||||
body.append(subtitle,summary,variants,gallery);
|
||||
const recorded=el('div',undefined,'library-recorded-container');
|
||||
body.append(subtitle,summary,variants,recorded,gallery);
|
||||
function setParentState(parent) {
|
||||
if(contentScope.signal.aborted || (!parent.loading && !parent.error && !parent.detail)) return;
|
||||
parentStatus.hidden=false;parentStatus.setAttribute('role',parent.error?'alert':'status');retryParent.hidden=true;reloadSaved.hidden=true;
|
||||
if(parent.loading) {savedReadState='validating';parentStatus.textContent='Revalidating saved Library details…';}
|
||||
else if(parent.error) {savedReadState='error';parentStatus.textContent='Saved Library details could not be revalidated. The saved selection and gallery have been retained.';retryParent.hidden=false;}
|
||||
else {
|
||||
const fresh=parent.detail;
|
||||
if(fresh.positive.id!==positiveId || fresh.positive.text!==ownerText) {
|
||||
savedReadState='stale';parentStatus.textContent='Saved Library owner changed. This view retains the previous prompt and gallery. Reload the saved Library view to inspect current data.';reloadSaved.hidden=false;
|
||||
} else {
|
||||
const observed=new Map(fresh.variants.map(variant=>[variant.id,variant]));const loaded=new Set(detail.variants.map(variant=>variant.id));const additional=fresh.variants.some(variant=>!loaded.has(variant.id));let changed=false,unobserved=false;
|
||||
const variants=detail.variants.map(variant=>{
|
||||
const current=observed.get(variant.id);
|
||||
if(!current) {unobserved=true;return variant;}
|
||||
if(current.negative_text!==variant.negative_text || current.explicit_empty!==variant.explicit_empty) {changed=true;return variant;}
|
||||
return {...variant,...current};
|
||||
});
|
||||
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';
|
||||
savedReadState=changed || unobserved || additional?'stale':'current';
|
||||
parentStatus.textContent=changed?'A saved negative changed identity. This view retains its previous selection and gallery. Reload the saved Library view to inspect current data.':unobserved?(fresh.variants_has_more?'Some loaded saved negatives were not observed in this bounded revalidation page. Reload the saved Library view to validate them.':'Some loaded saved negatives were not present in the revalidation result. This view retains its previous selection and gallery.'):additional?'Saved Library choices changed. This view retains its previous selection and gallery. Reload the saved Library view to inspect current choices.':'Saved Library details revalidated.';
|
||||
reloadSaved.hidden=savedReadState==='current';
|
||||
}
|
||||
}
|
||||
updateBranches(loader.state);blocks?.setState({prompts:libraryPromptRoles(detail,variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});
|
||||
}
|
||||
async function retrySavedParent() {
|
||||
parentRequest?.abort();const request=parentRequest=new AbortController(),generation=++parentGeneration;setParentState({loading:true});
|
||||
try {
|
||||
const fresh=await api.get(`/prompt_manager/library/${positiveId}?variant_limit=40`,{signal:request.signal});
|
||||
if(contentScope.signal.aborted || generation!==parentGeneration) return;
|
||||
if(!fresh?.positive || !Number.isSafeInteger(fresh.positive.id) || typeof fresh.positive.text!=='string' || !Array.isArray(fresh.variants) || fresh.variants.some(variant=>!Number.isSafeInteger(variant.id) || typeof variant.negative_text!=='string' || typeof variant.explicit_empty!=='boolean' || variant.explicit_empty!==(variant.negative_text===''))) throw new TypeError('Invalid saved Library details');
|
||||
setParentState({detail:fresh});
|
||||
} catch(error) {if(!contentScope.signal.aborted && generation===parentGeneration)setParentState({error});}
|
||||
}
|
||||
contentScope.listen(retryParent,'click',retrySavedParent);contentScope.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);});
|
||||
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
|
||||
mountRecordedHistory(recorded,{api,positiveId,parentScope:contentScope,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
|
||||
if(contentScope.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
|
||||
}});
|
||||
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:contentScope});
|
||||
function select(pairId) {
|
||||
if(galleryScope && activePairId===pairId) return;activePairId=pairId;
|
||||
variantSelections.set(positiveId,pairId);blocks.update(libraryPromptRoles(detail,pairId));
|
||||
if(savedReadState!=='current' || (galleryScope && activePairId===pairId)) return;activePairId=pairId;
|
||||
variantSelections.set(positiveId,pairId);blocks.setState({prompts:libraryPromptRoles(detail,pairId),actionsEnabled:true});
|
||||
for(const choice of choices) choice.button.setAttribute('aria-pressed',String(choice.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
|
||||
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`Negative variant ${detail.variants.findIndex(variant=>variant.id===pairId)+1} · same positive prompt`;
|
||||
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
|
||||
|
||||
@@ -18,6 +18,12 @@ const statuses = {
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
const fieldSources = {
|
||||
runtime_return: 'Runtime return',
|
||||
host_cache_output: 'Host cache output',
|
||||
graph_literal: 'Inferred graph literal',
|
||||
graph_semantics: 'Inferred graph semantics',
|
||||
};
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -39,6 +45,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
const scope = childScope(parentScope);
|
||||
container.classList.add('prompt-inspector');
|
||||
scope.listen(container, 'click', async event => {
|
||||
if (!actionsEnabled) return;
|
||||
const send = event.target.closest('[data-send-role]');
|
||||
if (send) {
|
||||
const role = send.dataset.sendRole;
|
||||
@@ -57,10 +64,11 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
}
|
||||
if (event.target.closest('[data-retry-prompts]')) onRetry?.();
|
||||
});
|
||||
let roles = {};
|
||||
let roles = {}, actionsEnabled = true;
|
||||
function render(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
roles = {};
|
||||
actionsEnabled = state.actionsEnabled !== false;
|
||||
container.replaceChildren();
|
||||
if (state.loading || state.error) {
|
||||
const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted');
|
||||
@@ -83,7 +91,8 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
heading.append(el('h3', title));
|
||||
block.append(heading);
|
||||
if (Object.hasOwn(roles, role)) {
|
||||
block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
const proof = record.evidence;
|
||||
block.append(el('p', proof ? `${fieldSources[proof.source] || 'Source unknown'} · ${proof.source}` : sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
|
||||
else {
|
||||
const text = el('pre', roles[role], 'prompt-text');
|
||||
@@ -93,11 +102,13 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
const actions = el('div', undefined, 'prompt-actions');
|
||||
const send = createSendButton(`Send ${role} prompt`);
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
const copy = createButton('', 'ca-icon-button');
|
||||
copy.setAttribute('aria-label', `Copy ${role} prompt`);
|
||||
copy.title = `Copy ${role} prompt`;
|
||||
copy.append(createWorkspaceIcon('copy'));
|
||||
copy.dataset.copyRole = role;
|
||||
copy.disabled = !actionsEnabled;
|
||||
actions.append(copy, send);
|
||||
heading.append(actions);
|
||||
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
|
||||
@@ -107,6 +118,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
}
|
||||
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
pair.disabled = !actionsEnabled;
|
||||
container.append(pair);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {frozenPromptRoles} from './history-state.mjs';
|
||||
import {mountPromptBlocks} from './prompt-blocks.mjs';
|
||||
import {createInspectionController} from './inspection-state.mjs';
|
||||
import {mountFileInspector} from './file-inspector.mjs';
|
||||
import {createMediaViewer} from './media-viewer.mjs';
|
||||
import {distinctCurrentMedia, occurrenceKey, reportedLocationAdapter} from './history-media.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 facts(entries) {
|
||||
const list = el('dl', undefined, 'recorded-run-facts');
|
||||
for (const [label, value] of entries) {const row = el('div');row.append(el('dt', label), el('dd', value ?? 'Not recorded'));list.append(row);}
|
||||
return list;
|
||||
}
|
||||
function observed(value) {
|
||||
const time = Number(value);
|
||||
return Number.isSafeInteger(time) && time <= 8640000000000000 ? new Date(time).toISOString() : 'Recorded time outside display range';
|
||||
}
|
||||
function outputSummary(summary) {
|
||||
if (summary.state === 'not_captured') return 'Reported outputs not captured';
|
||||
const count = summary.retained_occurrence_count;
|
||||
return summary.list_status === 'complete' ? `${count} reported outputs · complete` : `${count} retained reported output${count === 1 ? '' : 's'} · incomplete · ${summary.state}`;
|
||||
}
|
||||
|
||||
export function mountRecordedRun(container, {api, controller, sender, parentScope, onClose, onRestoreFocus}) {
|
||||
const scope = createScope(), dispose = () => scope.dispose();
|
||||
let active = null, latest = controller.state;
|
||||
if (parentScope.signal.aborted) {dispose();return {open() {}, setState() {}, dispose};}
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
scope.cleanup(() => active?.close(false));
|
||||
|
||||
function open({runId, anchorId = null, contextId = null, trigger = document.activeElement}) {
|
||||
if (scope.signal.aborted) return;
|
||||
active?.close(false);
|
||||
const local = createScope(), dialog = el('dialog', undefined, 'recorded-run-dialog');dialog.setAttribute('aria-label', 'Recorded run');
|
||||
const header = el('header', undefined, 'recorded-run-header');
|
||||
const close = createButton('×');close.setAttribute('aria-label', 'Close recorded run');close.autofocus = true;
|
||||
header.append(el('h2', 'Recorded run'), close);
|
||||
const message = el('p', '', 'recorded-run-status muted');message.setAttribute('role', 'status');
|
||||
const retry = createButton('Retry run details'), reload = createButton('Reload recorded history');
|
||||
const body = el('div', undefined, 'recorded-run-body');
|
||||
const metadata = el('section');metadata.setAttribute('aria-label', 'Run evidence');
|
||||
const anchors = el('div', undefined, 'recorded-run-anchors');anchors.setAttribute('role', 'group');anchors.setAttribute('aria-label', 'Recorded anchors');
|
||||
const mode = el('div', undefined, 'recorded-run-mode');mode.setAttribute('role', 'group');mode.setAttribute('aria-label', 'Frozen text field');
|
||||
const raw = createButton('Raw'), effective = createButton('Effective');mode.append(raw, effective);
|
||||
const promptSection = el('section');promptSection.setAttribute('aria-label', 'Frozen run prompt texts');
|
||||
promptSection.append(el('h3', 'Frozen prompt fields'), el('p', 'Only the selected stored field is shown. Role assignment: inferred. Field evidence is disclosed separately.', 'muted'));
|
||||
const prompts = el('div');promptSection.append(prompts);
|
||||
const provenance = el('details', undefined, 'recorded-provenance');provenance.append(el('summary', 'Frozen field provenance'));
|
||||
const proof = el('div');provenance.append(proof);promptSection.append(provenance);
|
||||
const outputs = el('section', undefined, 'recorded-outputs');outputs.setAttribute('aria-label', 'Reported outputs');
|
||||
const outputState = el('p', '', 'recorded-output-status muted');outputState.setAttribute('role', 'status');
|
||||
const receipts = el('ol', undefined, 'recorded-receipts');receipts.setAttribute('aria-label', 'Ordered reported output receipts');
|
||||
const more = createButton('Load more reported outputs'), retryOutputs = createButton('Retry reported outputs');
|
||||
outputs.append(el('h3', 'Reported outputs'), el('p', 'Host-reported locations · file identity unverified · newly generated unknown · Context membership unknown.', 'muted'), outputState, receipts, more, retryOutputs);
|
||||
const current = el('section', undefined, 'recorded-current-file');current.setAttribute('aria-label', 'Current reported file');
|
||||
current.append(el('h3', 'Current file at reported location'), el('p', 'Current file prompts and facts are separate from frozen run fields.', 'muted'));
|
||||
const inspectorContainer = el('div');current.append(inspectorContainer);current.hidden = true;
|
||||
body.append(metadata, anchors, mode, promptSection, outputs, current);dialog.append(header, message, retry, reload, body);container.append(dialog);
|
||||
const inspection = createInspectionController({api, extractFacts:window.ComfyMetadata.extractGenerationFacts, adapter:reportedLocationAdapter});
|
||||
let closed = false, renderedDetail = null, renderedRoles = '', outputsRequested = false, inspected = null, viewerOpen = false;
|
||||
const receiptNodes = new Map();
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
|
||||
function selectedReceipt() {
|
||||
const selected = latest.selection.receipt;
|
||||
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
|
||||
}
|
||||
function restoreReceiptFocus() {
|
||||
const key = latest.selection.receipt && occurrenceKey(latest.selection.receipt);
|
||||
receiptNodes.get(key)?.view?.focus({preventScroll:true});
|
||||
}
|
||||
const makeViewer = () => createMediaViewer({container, parentScope:local, inspectionController:inspection, sender, adapter:reportedLocationAdapter,
|
||||
onSelect:entry => {
|
||||
if (closed || !latest.actionsEnabled) return;
|
||||
controller.selectReceipt(entry.occurrence);inspected = occurrenceKey(entry.occurrence);inspection.select(entry);
|
||||
}, onRefresh:refreshCurrent, onRestoreFocus:restoreReceiptFocus,
|
||||
onClose:() => {viewerOpen = false;restoreReceiptFocus();},
|
||||
});
|
||||
let viewer = makeViewer();
|
||||
function resetViewer() {viewer.dispose();viewerOpen = false;viewer = makeViewer();}
|
||||
function refreshCurrent() {
|
||||
if (closed) return;
|
||||
resetViewer();inspection.clear();inspected = null;
|
||||
if (latest.refreshRequired === 'history-changed') controller.refresh();
|
||||
else controller.revalidate();
|
||||
}
|
||||
function showViewer(triggerButton) {
|
||||
if (!latest.actionsEnabled || inspection.state.refreshRequired || closed) return;
|
||||
const receipt = selectedReceipt();if (receipt?.current_candidate.status !== 'available') return;
|
||||
const items = distinctCurrentMedia(latest.outputs.items, latest.selection.receipt);
|
||||
viewerOpen = true;viewer.open({items, selectedKey:reportedLocationAdapter.key(receipt.current_candidate.media), trigger:triggerButton || receiptNodes.get(occurrenceKey(receipt.occurrence))?.view});
|
||||
}
|
||||
function chooseReceipt(receipt, triggerButton) {
|
||||
if (!latest.actionsEnabled || closed) return;
|
||||
controller.selectReceipt(receipt.occurrence);inspected = occurrenceKey(receipt.occurrence);
|
||||
inspection.select(receipt.current_candidate.media);showViewer(triggerButton);
|
||||
}
|
||||
function closeRun(restore = true) {
|
||||
if (closed) return;closed = true;active = null;local.dispose();
|
||||
if (restore && !scope.signal.aborted) {
|
||||
if (trigger?.isConnected) trigger.focus({preventScroll:true});
|
||||
else onRestoreFocus?.({runId, anchorId, contextId});
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
local.cleanup(() => {viewer.dispose();inspection.dispose();inspector.dispose();blocks.dispose();receiptNodes.clear();if (dialog.open) dialog.close();dialog.remove();});
|
||||
local.listen(close, 'click', () => closeRun());local.listen(dialog, 'close', () => closeRun());
|
||||
local.listen(dialog, 'cancel', event => {event.preventDefault();closeRun();});
|
||||
local.listen(retry, 'click', () => controller.retryRun());
|
||||
local.listen(reload, 'click', () => {
|
||||
if (['capture-changed', 'candidate-stale'].includes(latest.refreshRequired)) controller.loadOutputs();
|
||||
else {closeRun();controller.refresh();}
|
||||
});
|
||||
local.listen(raw, 'click', () => {if (latest.actionsEnabled) controller.setMode('raw');});
|
||||
local.listen(effective, 'click', () => {if (latest.actionsEnabled) controller.setMode('effective');});
|
||||
local.listen(anchors, 'click', event => {const button = event.target.closest('[data-select-anchor]');if (button && latest.actionsEnabled) controller.selectContext(button.dataset.selectAnchor);});
|
||||
local.listen(more, 'click', () => controller.loadOutputs());local.listen(retryOutputs, 'click', () => controller.retryOutputs());
|
||||
local.listen(receipts, 'click', event => {
|
||||
const button = event.target.closest('[data-view-receipt]');if (!button || button.disabled) return;
|
||||
const receipt = latest.outputs.items.find(item => occurrenceKey(item.occurrence) === button.dataset.viewReceipt);
|
||||
if (receipt) chooseReceipt(receipt, button);
|
||||
});
|
||||
local.cleanup(inspection.subscribe(state => {
|
||||
if (closed) return;inspector.setState(state);
|
||||
for (const receipt of receiptNodes.values()) receipt.view.disabled = !latest.actionsEnabled || state.refreshRequired;
|
||||
}));
|
||||
function render(next) {
|
||||
if (closed || local.signal.aborted) return;
|
||||
const selectedRun = next.selection.run_id === runId, detail = selectedRun ? next.run.detail : null;
|
||||
const enabled = next.actionsEnabled && selectedRun;
|
||||
message.setAttribute('role', next.run.error || next.refreshRequired ? 'alert' : 'status');
|
||||
message.textContent = next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : next.validating ? 'Revalidating recorded run…' : next.run.error && selectedRun ? 'Recorded run details could not be loaded.' : !detail ? 'Loading recorded run…' : '';
|
||||
retry.hidden = !selectedRun || !next.run.error || !!next.refreshRequired;reload.hidden = !next.refreshRequired;reload.textContent = next.refreshLabel || 'Reload recorded history';
|
||||
body.hidden = !detail;raw.disabled = effective.disabled = !enabled;
|
||||
if (!detail) {if (renderedDetail) {inspection.clear();renderedDetail = null;renderedRoles = '';metadata.replaceChildren();anchors.replaceChildren();receipts.replaceChildren();receiptNodes.clear();}return;}
|
||||
raw.setAttribute('aria-pressed', String(next.selection.mode === 'raw'));effective.setAttribute('aria-pressed', String(next.selection.mode === 'effective'));
|
||||
if (detail !== renderedDetail) {
|
||||
renderedDetail = detail;const run = detail.run;
|
||||
metadata.replaceChildren(facts([
|
||||
['Run ID', run.run_id], ['Session ID', run.session_id], ['Queue item ID', run.queue_item_id], ['Job / correlation ID', run.job_id],
|
||||
['Observed dequeue', observed(run.observed_dequeue_at)], ['Observed dequeue · exact decimal milliseconds', run.observed_dequeue_at],
|
||||
['Outcome', run.outcome], ['Outcome reason', run.reason], ['Context capture', `${detail.context_capture.state} · ${detail.context_capture.reason ?? 'not captured'}`],
|
||||
['Output capture', `${detail.output_summary.state} · ${detail.output_summary.reason ?? 'not captured'}`],
|
||||
['Safe diagnostic node', run.node_id], ['Safe diagnostic node type', run.node_type], ['Safe exception type', run.exception_type],
|
||||
['Diagnostics omitted', run.diagnostics_omitted ? 'Yes' : 'No'],
|
||||
]));
|
||||
if (detail.context_capture.state === 'conflicting') metadata.append(el('p', 'First captured context · conflicting evidence', 'muted'));
|
||||
anchors.replaceChildren();
|
||||
for (const context of detail.contexts) {
|
||||
const button = createButton(`Anchor ${context.anchor_id} · context ${context.context_id.slice(0, 8)}`);button.dataset.selectAnchor = context.anchor_id;anchors.append(button);
|
||||
}
|
||||
}
|
||||
for (const button of anchors.querySelectorAll('button')) {button.disabled = !enabled;button.setAttribute('aria-pressed', String(button.dataset.selectAnchor === next.selection.anchor_id));}
|
||||
const context = detail.contexts.find(item => item.anchor_id === next.selection.anchor_id);
|
||||
const roleKey = `${next.selection.anchor_id}:${next.selection.mode}:${enabled}:${detail.context_capture.state}`;
|
||||
if (roleKey !== renderedRoles) {
|
||||
renderedRoles = roleKey;
|
||||
blocks.setState({prompts:context ? frozenPromptRoles(context, next.selection.mode) : undefined, actionsEnabled:enabled});
|
||||
proof.replaceChildren(el('p', 'Role assignment: inferred. Sources describe each field, not blanket execution proof.', 'muted'));
|
||||
if (context) for (const role of ['positive', 'negative']) {
|
||||
const section = el('section');section.append(el('h4', `${role[0].toUpperCase() + role.slice(1)} fields`));
|
||||
for (const field of ['raw', 'effective', 'prepend', 'append']) {
|
||||
const value = context[role]?.[field] ?? null, evidence = context.evidence[role]?.[field];
|
||||
section.append(el('p', `${field[0].toUpperCase() + field.slice(1)} · ${evidence?.provenance || 'unknown'} · ${evidence?.source || 'unknown'}${evidence?.reason ? ` · reason: ${evidence.reason}` : ''}`, 'muted'));
|
||||
section.append(el('pre', value === null ? 'Unknown · not recorded' : value === '' ? '(explicitly empty)' : value, 'prompt-text'));
|
||||
}
|
||||
proof.append(section);
|
||||
}
|
||||
}
|
||||
const output = next.outputs;
|
||||
outputState.setAttribute('role', output.error ? 'alert' : 'status');
|
||||
outputState.textContent = `${outputSummary(detail.output_summary)}${output.loading ? ' · Loading reported outputs…' : output.error ? ' · Reported outputs could not be loaded.' : output.capture && !output.has_more ? ' · All retained receipts loaded.' : ''}`;
|
||||
more.hidden = !output.has_more;more.disabled = output.loading || !enabled;
|
||||
retryOutputs.hidden = !output.error;retryOutputs.disabled = !!next.refreshRequired;
|
||||
const keep = new Set(output.items.map(item => occurrenceKey(item.occurrence)));
|
||||
for (const [key, record] of receiptNodes) if (!keep.has(key)) {record.node.remove();receiptNodes.delete(key);}
|
||||
for (const receipt of output.items) {
|
||||
const key = occurrenceKey(receipt.occurrence), candidate = receipt.current_candidate;
|
||||
let record = receiptNodes.get(key);
|
||||
if (!record) {
|
||||
const node = el('li', undefined, 'recorded-receipt');node.dataset.receipt = key;node.dataset.outputNodeId = receipt.occurrence.output_node_id;node.dataset.imageOrdinal = String(receipt.occurrence.image_ordinal);
|
||||
const description = el('div'), view = createButton('View current file');view.dataset.viewReceipt = key;node.append(description, view);receipts.append(node);record = {node, description, view};receiptNodes.set(key, record);
|
||||
}
|
||||
const reference = receipt.reference;
|
||||
record.description.textContent = `Output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal} · ${reference.folder_type}/${[reference.subfolder, reference.filename].filter(Boolean).join('/')} · ${reference.path_style} · root namespace ${reference.root_key ?? 'not recorded'} · ${receipt.transient ? 'Transient' : 'Non-transient locator'} · capture ${output.capture?.state || detail.output_summary.state} · Current candidate: ${candidate.status}${candidate.reason ? ` · ${candidate.reason}` : ''}`;
|
||||
record.node.setAttribute('aria-current', String(next.selection.receipt !== null && key === occurrenceKey(next.selection.receipt)));
|
||||
record.view.hidden = candidate.status !== 'available';record.view.disabled = !enabled || inspection.state.refreshRequired;
|
||||
}
|
||||
const selected = selectedReceipt();current.hidden = !selected;
|
||||
if (!selected && inspected !== null) {inspected = null;inspection.clear();if (viewerOpen) resetViewer();}
|
||||
if (!enabled && viewerOpen) resetViewer();
|
||||
if (!outputsRequested && !output.capture && !output.loading && !output.error) {
|
||||
outputsRequested = true;
|
||||
queueMicrotask(() => {if (!closed && latest.selection.run_id === runId && !local.signal.aborted) controller.loadOutputs();});
|
||||
}
|
||||
}
|
||||
active = {close:closeRun, render};
|
||||
dialog.showModal();
|
||||
render(latest);
|
||||
void controller.selectRun(runId, {anchorId, contextId});
|
||||
}
|
||||
function setState(state) {if (scope.signal.aborted) return;latest = state;active?.render(state);}
|
||||
return {open, setState, dispose};
|
||||
}
|
||||
Reference in New Issue
Block a user