Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/history.mjs
T
Vito b4c0189225 fix(history): keep a marker tap when the timeline re-renders mid-press (#279)
The recorded history timeline rewrote every marker's textContent on each
render, so a scroll-triggered re-render replaced the text node under the
pointer. When that frame landed between the press and the release of a
tap, Chromium touch dropped the click and the Recorded run dialog never
opened. Text is now written only when it changes.

Adds a deterministic regression test that re-renders the timeline between
mouse down and mouse up.
2026-10-22 09:55:15 -07:00

239 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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;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};
}