Files
Vito 00bda8f442 feat(inspector): concept prompt blocks, side panel and viewer details (#260)
* feat(workspace): concept prompt blocks

Shared prompt blocks (Images panel, viewer details, Library cards and
saved texts, recorded runs) now render the approved mock's markup
(app.js promptBlock a298-302, concept.css s550-588), #249 D22:

- section.prompt-section.<role>[aria-label="<Role> prompt"] >
  .prompt-heading (span.role-label "+ Positive prompt" / "- Negative
  prompt"; .prompt-actions: copy button.icon-btn "Copy <role> prompt",
  then send button.send.icon-btn "Send <role> prompt to ComfyUI") +
  p.prompt-text.
- No per-block source line; a file's evidence moves to the panel badge
  (D2) and "File & provenance" (D3). Recorded runs keep every field's
  source in "Frozen field provenance".
- Empty text reads "No negative prompt (explicitly empty)" and still
  sends an explicit clear. Absent, ambiguous and unresolved roles render
  p.prompt-text.muted with the existing status strings and no actions;
  unknown role names render nothing and cannot be sent or copied.
- Copy shows the shared toast "Prompt copied."; without a clipboard it
  opens the concept's "Copy prompt" select-to-copy dialog.
- The data-send-role / data-copy-role contract is unchanged.

The D22 interim overrides (26px actions, prompt-text margins, copy
status, source line) are retired so the concept's rules apply; the
action buttons measure the mock's 31x31. pre.prompt-text styling stays
only for the Library editor's Archive dialog and frozen field
provenance, which keep their own markup until their page rebuilds.

Test selector mapping:
  .prompt-block / .prompt-positive  -> .prompt-section(.positive)
  .prompt-block-heading h3          -> .prompt-heading span.role-label
  region pre / pre.prompt-text      -> p.prompt-text
  "Empty negative prompt"           -> "No negative prompt (explicitly empty)"
  "Send <role> prompt"              -> "Send <role> prompt to ComfyUI"
  .prompt-source / copy status line -> removed (toast or Copy prompt dialog)
  copy button 30x26                 -> 31x31 (measured in the mock)

* feat(inspector): concept side panel

The Images side panel is the concept's aside#image-inspector.detail-panel in the
order of app.js imageDetails: plain cover, heading with the format chip, filename,
the D21 evidence badge and category chip, prompt blocks, tag chips, the six facts
and the footer (Send to ComfyUI pair, Prompt history). Provenance, raw records and
downloads stay in the viewer's full details. Positive-scope tag chips set the
Images Tags filter to that tag.

* fix(inspector): mark the side panel's overlay rules with their D2 decision

The overlay lint (tests/vendor/concept-css.test.mjs) requires a /* D<n> */
marker on every workspace.css rule that overrides a concept selector; the
three .detail-panel rules from D2 lacked one.

* feat(inspector): full details in the viewer

The viewer's #viewer-details renders the concept's imageDetails(i, true):
the side panel content without the cover in a class-less wrapper, then
"File & provenance" and "Workflow & raw metadata" (pre[tabindex=0] > code
and the two downloads with their unavailable reasons), then the pair send
("Send to ComfyUI", labelled "Send prompt pair to ComfyUI: <title>") and
Prompt history buttons. One renderer now serves both variants.

Provenance wording (maintainer, 2026-10-20): the six per-fact
"<Fact>: Configuration evidence · <source>" lines and FACT_SOURCES are
gone; the concept's "Prompt evidence: <badge>" line carries the D21 badge
in lower case and is left out without a badge. The honest facts stay
(current-file note, Source, Path, Size, Dimensions, Format, Media type,
Modified, occurrence, association, Download original). An unverified
format reads "PNG extension", no stray leading dot.

inspector.css and images.css are deleted; only rules with a live
consumer moved to the overlay under #249 D3 (inspector wrapping, the
inert aria-disabled download, the narrow Images container, the Library
and recorded-run prompt blocks, the Archive negative's prompt-block, the
prompt sender) plus a D20 wrap rule for the viewer's buttons at 200% zoom.

D1 review follow-ups: library.css no longer restyles recorded-run
p.prompt-text.muted (m2); the copy fallback has a seam and unit tests for
a missing clipboard and a refused write (m3); a stale test title is
renamed (m4); the fallback trigger falls back to the same-role button
when the clicked one was detached by a re-render (m7); TESTING.md records
the D20 and D22 deviations (m1).

Selector mapping in tests: .file-inspector-inner -> .panel-heading,
.generation-facts -> .meta-grid (first), .file-facts -> details .meta-grid,
.file-name -> #viewer-details > div > p.small, .file-inspector-footer ->
#viewer-details, viewer "Send prompt pair" -> /^Send prompt pair to ComfyUI: /.

* test(comfyui): real-host specs read the concept prompt blocks

library-sender: an explicitly empty saved negative now reads the concept's
"No negative prompt (explicitly empty)" (D22). The prompt-sender spec's
panel and viewer selectors (complementary "Selected image details", the
card image opening the viewer, .meta-grid facts, the labelled pair send)
moved with the D3 commit.

* test(comfyui): the frozen run's prompt regions read the concept prompt text

* fix(inspector): coarse viewer-detail targets and Prompt history on the shown route

- Prompt history closes the viewer and, when its target is the route
  already shown, re-renders it as the concept's go() does (navigateTo
  plus a router refresh event), instead of doing nothing.
- The viewer details' disclosure summaries and "Download original" keep
  40px targets on coarse pointers (#249 D34), as TESTING.md promises.
- Drop the dead `.prompt-positive` rules and merge the two
  `pre.prompt-text` rules.
- TESTING.md records the side panel's D20/D21 deviations.
2026-10-20 20:15:21 -07:00

247 lines
21 KiB
JavaScript
Raw Permalink 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 {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, values) {
const list = el('dl', undefined, 'recorded-run-facts');
for (const [label, value] of entries) {const row = el('div'), description = el('dd', value ?? 'Not recorded');row.append(el('dt', label), description);list.append(row);values.set(label, description);}
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 = '', initialOutputQueued = false, inspected = null, viewerOpen = false;
const receiptNodes = new Map();
const evidenceValues = new Map();
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), 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);
const row = receiptNodes.get(key);(row?.view.hidden ? row.select : row?.view)?.focus({preventScroll:true});
}
const makeViewer = () => createMediaViewer({container, parentScope:local, inspectionController:inspection, sender, adapter:reportedLocationAdapter,
onSelect:entry => {
if (closed || !latest.actionsEnabled) return;
inspected = occurrenceKey(entry.occurrence);controller.selectReceipt(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;
inspected = occurrenceKey(receipt.occurrence);controller.selectReceipt(receipt.occurrence);
inspection.select(receipt.current_candidate.media);showViewer(triggerButton);
}
function selectReceipt(receipt) {
if (!latest.actionsEnabled || closed) return;
inspected = null;controller.selectReceipt(receipt.occurrence);inspection.clear();if (viewerOpen) resetViewer();
}
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],[data-select-receipt],[data-retry-receipt]');if (!button || button.disabled) return;
const key = button.dataset.viewReceipt ?? button.dataset.selectReceipt ?? button.dataset.retryReceipt;
const receipt = latest.outputs.items.find(item => occurrenceKey(item.occurrence) === key);if (!receipt) return;
if (button.hasAttribute('data-view-receipt')) chooseReceipt(receipt, button);
else if (button.hasAttribute('data-select-receipt')) selectReceipt(receipt);
else {selectReceipt(receipt);void controller.retryReceipt(receipt.occurrence);}
});
local.cleanup(inspection.subscribe(state => {
if (closed) return;
const target = selectedReceipt()?.current_candidate.media;
if (target && inspected === occurrenceKey(target.occurrence) && state.error && state.refreshRequired) {
controller.inspectionFailed(target, state);
if (inspected === null || !latest.selection.receipt) return;
}
inspector.setState(state);
for (const receipt of receiptNodes.values()) receipt.view.disabled = !latest.actionsEnabled || latest.outputs.loading || 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) {
inspected = null;inspection.clear();resetViewer();blocks.setState({actionsEnabled:false});proof.replaceChildren();current.hidden = true;
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'],
], evidenceValues));
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;
const outputEvidence = output.capture ? {...output.capture, retained_occurrence_count:output.retained_occurrence_count, list_status:output.list_status} : detail.output_summary;
evidenceValues.get('Output capture').textContent = `${outputEvidence.state} · ${outputEvidence.reason ?? 'not captured'}`;
outputState.setAttribute('role', output.error ? 'alert' : 'status');
outputState.textContent = `${outputSummary(outputEvidence)}${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'), select = createButton('Select reported output'), view = createButton('View current file'), retry = createButton('Retry current availability');
select.dataset.selectReceipt = view.dataset.viewReceipt = retry.dataset.retryReceipt = key;
select.title = `Select output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal}`;
node.append(description, select, view, retry);receipts.append(node);record = {node, description, select, view, retry};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.select.setAttribute('aria-pressed', record.node.getAttribute('aria-current'));record.select.disabled = !enabled;
record.retry.hidden = candidate.status === 'available';record.retry.disabled = !enabled || output.loading;
record.view.hidden = candidate.status !== 'available';record.view.disabled = !enabled || output.loading || inspection.state.refreshRequired;
}
const selected = selectedReceipt(), selectedKey = selected && occurrenceKey(selected.occurrence);
current.hidden = !selected || inspected !== selectedKey || selected.current_candidate.status !== 'available';
if (inspected !== null && inspected !== selectedKey) {inspected = null;inspection.clear();if (viewerOpen) resetViewer();}
if (!enabled && viewerOpen) resetViewer();
if (enabled && !initialOutputQueued && !output.capture && !output.loading && !output.error) {
initialOutputQueued = true;
queueMicrotask(() => {
initialOutputQueued = false;
if (!closed && !local.signal.aborted && latest.selection.run_id === runId && latest.actionsEnabled && latest.run.detail && !latest.run.loading && !latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error) controller.loadOutputs();
});
}
}
active = {close:closeRun, render};
dialog.showModal();
render(latest);
const reusable = latest.actionsEnabled && latest.selection.run_id === runId && latest.run.detail && latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error;
if (reusable) {
const context = latest.run.detail.contexts.find(context => (anchorId === null || context.anchor_id === anchorId) && (contextId === null || context.context_id === contextId));
if (context && context.anchor_id !== latest.selection.anchor_id) controller.selectContext(context.anchor_id);
} else void controller.selectRun(runId, {anchorId, contextId});
}
function setState(state) {if (scope.signal.aborted) return;latest = state;active?.render(state);}
return {open, setState, dispose};
}