feat(review): link an image to a library prompt from Needs review (#536) (#540)

Closes #536
This commit is contained in:
Vito Sansevero
2026-10-30 16:52:42 -07:00
parent 07c2442e23
commit 06ea91da66
20 changed files with 1330 additions and 239 deletions
+183
View File
@@ -0,0 +1,183 @@
/**
* Link to a prompt (#536, D181; the concept has no picker): search the Library, choose the prompt that made this image
* and, when the prompt has recipes, its negative. The server's refusals stay in the dialog. No negative is taken from
* the file: one recipe is preselected and visible, several must be chosen between, none means no negative.
*/
import { createButton } from '../shared/toolkit/core.js';
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
export const SEARCH_DELAY_MS = 250;
export const RESULT_LIMIT = 20;
export const VARIANT_LIMIT = 40;
export const NO_NEGATIVE = 'none';
const LIBRARY_URL = '/prompt_manager/library';
const CHOOSE = '';
export const LINK_COPY = Object.freeze({
title: 'Link to a prompt',
sentence: name => `Choose the prompt that made ${name}. This records your choice; the file's metadata does not confirm it.`,
search: 'Search the Library',
searching: 'Searching…',
empty: 'No prompt in the Library matches.',
failed: 'The Library could not be searched. Try again.',
negative: 'Negative prompt',
chooseNegative: 'Choose a negative…',
noNegative: 'No negative',
negativeFailed: "This prompt's negatives could not be read. Choose it again.",
action: 'Link image',
cancel: 'Cancel',
});
const titleOf = prompt => prompt.title?.trim() || prompt.text;
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The Negative options of a prompt's recipes: its value is the recipe's pair id, or `NO_NEGATIVE`. */
export function negativeOptions(variants) {
const recipes = variants.filter(variant => !variant.explicit_empty)
.map(variant => ({ value: String(variant.id), label: variant.title?.trim() || variant.negative_text }));
const bare = variants.find(variant => variant.explicit_empty);
const none = { value: bare ? String(bare.id) : NO_NEGATIVE, label: LINK_COPY.noNegative };
if (recipes.length > 1) return [{ value: CHOOSE, label: LINK_COPY.chooseNegative }, ...recipes, none];
return [...recipes, none];
}
/** The body fields of the chosen Negative value. */
export const pairIdOf = value => (value === NO_NEGATIVE ? null : Number(value));
/** Open the dialog; `controller.link` records the choice and the dialog closes once it is recorded. */
export function openPromptLink({ api, scope, controller, filename, trigger }) {
// A re-read may open another item meanwhile (decided elsewhere): the dialog names this one, so it closes.
const itemId = controller.state.selectedId;
const stopWatching = controller.subscribe(state => { if (state.selectedId !== itemId && !closed) closeWorkspaceDialog(); });
let timer = null, search = null, variantsRead = null, chosen = null, closed = false;
const sentence = el('p', LINK_COPY.sentence(filename), 'small');
const input = el('input');
input.type = 'search';
const results = el('div', undefined, 'review-link-results');
results.setAttribute('role', 'group');
results.setAttribute('aria-label', 'Matching prompts');
const status = el('p', '', 'small');
status.setAttribute('role', 'status');
const select = el('select');
const negative = field(LINK_COPY.negative, select, 'review-link-negative');
negative.hidden = true;
const warning = el('p', '', 'warning-note');
warning.setAttribute('role', 'status');
warning.hidden = true;
const actions = el('div', undefined, 'dialog-actions review-link-actions');
const cancel = createButton(LINK_COPY.cancel);
const submit = createButton(LINK_COPY.action, 'ca-primary');
actions.append(cancel, submit);
const busy = () => controller.state.phase !== 'idle';
const ready = () => chosen !== null && !negative.hidden && select.value !== CHOOSE;
function syncSubmit() {
if (ready() && !busy()) submit.removeAttribute('aria-disabled');
else submit.setAttribute('aria-disabled', 'true');
}
function warn(text) {
warning.textContent = text;
warning.hidden = !text;
}
/** Every active recipe of the prompt, page by page. */
async function readVariants(id, signal) {
const variants = [];
let cursor = null;
do {
const query = new URLSearchParams({ variant_limit: String(VARIANT_LIMIT) });
if (cursor) query.set('variant_cursor', cursor);
const detail = await api.get(`${LIBRARY_URL}/${encodeURIComponent(id)}?${query}`, { signal, cache: 'no-store' });
variants.push(...(detail.variants ?? []));
cursor = detail.variants_next_cursor ?? null;
} while (cursor);
return variants;
}
async function choose(prompt, button) {
variantsRead?.abort();
const read = variantsRead = new AbortController();
chosen = prompt;
for (const node of results.children) node.setAttribute('aria-pressed', String(node === button));
negative.hidden = true;
warn('');
syncSubmit();
try {
const variants = await readVariants(prompt.id, read.signal);
if (read !== variantsRead || closed) return;
const options = negativeOptions(variants);
select.replaceChildren(...options.map(option => {
const node = el('option', option.label);
node.value = option.value;
return node;
}));
select.value = options[0].value;
negative.hidden = false;
} catch (error) {
if (error?.name === 'AbortError' || read !== variantsRead || closed) return;
warn(LINK_COPY.negativeFailed);
}
syncSubmit();
}
function show(items) {
status.textContent = items.length ? '' : LINK_COPY.empty;
results.replaceChildren(...items.map(prompt => {
const button = createButton(titleOf(prompt));
button.title = prompt.text;
button.setAttribute('aria-pressed', String(prompt.id === chosen?.id));
button.addEventListener('click', () => choose(prompt, button));
return button;
}));
}
async function find() {
search?.abort();
const read = search = new AbortController();
status.textContent = LINK_COPY.searching;
try {
const query = new URLSearchParams({ text: input.value.trim(), limit: String(RESULT_LIMIT) });
const page = await api.get(`${LIBRARY_URL}?${query}`, { signal: read.signal, cache: 'no-store' });
if (read === search && !closed) show(page.items ?? []);
} catch (error) {
if (error?.name !== 'AbortError' && read === search && !closed) status.textContent = LINK_COPY.failed;
}
}
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(find, SEARCH_DELAY_MS);
});
select.addEventListener('change', syncSubmit);
cancel.addEventListener('click', () => { if (!busy()) closeWorkspaceDialog(); });
submit.addEventListener('click', async () => {
if (submit.getAttribute('aria-disabled') === 'true') return;
warn('');
submit.setAttribute('aria-disabled', 'true');
const done = await controller.link({ itemId, positiveId: chosen.id, pairId: pairIdOf(select.value), title: titleOf(chosen) });
if (closed) return;
if (done) { closeWorkspaceDialog(); return; }
warn(controller.state.warning);
syncSubmit();
});
openContentDialog({ scope, title: LINK_COPY.title, trigger, focusTarget: input,
nodes: [sentence, field(LINK_COPY.search, input, 'review-link-search'), status, results, negative, warning, actions],
canClose: () => !busy(),
onClose: () => {
closed = true;
stopWatching();
clearTimeout(timer);
search?.abort();
variantsRead?.abort();
} });
syncSubmit();
find();
}
+133 -66
View File
@@ -5,12 +5,16 @@
* - The queue is read 40 at a time, oldest first; `total` is the exact pending count. The cursor is bound to the
* storage epoch only, so Load more keeps working while new items arrive.
* - Selecting an item reads its detail: one "Sampler <id> · <positive> / <negative>" option per candidate, unusable
* ones disabled, the first workable one chosen, then "Leave unresolved" (rulings R2/R3).
* ones disabled with the server's reason, the first workable one chosen (rulings R2/R3). "Leave unresolved" is its
* own action (#536 D179, `leaveUnresolved()`). With nothing workable `unreadable` says why (D180).
* - `link(choice)` (#536) records the Library prompt the reviewer chose, under the same capture and retry rules.
* - A recorded decision opens the item after it (else the one before) and re-reads the queue quietly: the page stays
* (#536, overturning ruling R7). `decisions` counts them, for the page's toast.
* - Apply takes a fresh capture of the file (`observe`), then posts the decision; the server releases a capture it
* used. A refused decision releases the capture and keeps the selection with the server's sentence as the warning;
* a restored storage or a changed queue reads the queue again. A request that got no answer keeps its capture and
* its exact body, and the next Apply of the same choice (`retry()`) resends that body once.
* - Leave for later writes nothing. Tagging the image (the shared Tag dialog) never changes the item.
* - Leave for later writes nothing and opens the next item, wrapping. Tagging the image (the shared Tag dialog) never changes the item.
* - `refresh()` re-reads the queue quietly when the server says the queue changed (F2: the review change stream:
* a scan or the monitor added items, another tab decided one). The open item, its choice and an unanswered decision
* stay; when the open item has left the queue the next one opens with a note. Refreshes asked for while one runs
@@ -47,7 +51,10 @@ export const COPY = Object.freeze({
detailLoading: 'Loading sampler paths…',
detailFailed: 'This item could not be read. Select it again to retry.',
moreFailed: 'More items could not be loaded. Try again.',
limited: 'This file has too many sampler paths to list. You can leave it unresolved.',
limited: 'This file has too many sampler paths to list. Link it to a prompt or leave it unresolved.',
// #536 D180: nothing to apply.
unreadable: count => `${count === 1 ? "This file's sampler path" : `None of this file's ${count} sampler paths`} could be traced to a complete prompt, so there is nothing to apply. Choose the prompt yourself, or leave it unresolved.`,
lostLink: 'The link may not have been saved. Press Link image again to send the same choice once more.',
unavailable: 'This file is not under a current image source, so it cannot be opened. You can leave it unresolved.',
lost: 'The decision may not have been saved. Apply again to send the same decision once more.',
storageChanged: 'Prompt storage was restored; reload the review queue.',
@@ -57,6 +64,7 @@ export const COPY = Object.freeze({
failed: 'The decision could not be saved. Try again.',
applied: 'Association accepted.',
leftUnresolved: 'Left unresolved.',
linked: title => `Linked to ${title}.`,
});
// Answers after which the queue itself is out of date: read it again.
@@ -76,19 +84,34 @@ export function optionLabel(candidate) {
const workable = candidate => candidate?.state === 'resolved' && typeof candidate.positive?.text === 'string' &&
typeof candidate.negative?.text === 'string';
/** The Sampler context options of a detail: its candidates (unusable ones disabled), then Leave unresolved. */
/** The Sampler context options of a detail: its candidates, unusable ones disabled with the server's reason. */
export function contextOptions(detail) {
const usable = detail.target !== null && !detail.unavailable;
const options = (Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => Object.freeze({
value: candidate.hash, label: optionLabel(candidate), disabled: !usable || !workable(candidate), candidate,
return Object.freeze((Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => {
const readable = workable(candidate);
return Object.freeze({
value: candidate.hash, candidate, disabled: !usable || !readable,
label: readable || !candidate.reason ? optionLabel(candidate) : `${optionLabel(candidate)} (${candidate.reason})`,
});
}));
options.push(Object.freeze({ value: UNRESOLVED, label: COPY.leaveUnresolved, disabled: false, candidate: null }));
return Object.freeze(options);
}
/** The prompt blocks of the chosen option: its two role texts, or the ambiguous status for Leave unresolved. */
/**
* When no candidate of an available, listed file can be applied: their count and each distinct reason with its count
* (first seen first). Null otherwise.
*/
export function unreadableOf(detail) {
const candidates = Array.isArray(detail.candidates) ? detail.candidates : [];
if (detail.limited || detail.unavailable || detail.target === null || candidates.some(workable)) return null;
const reasons = new Map();
for (const { reason } of candidates) if (reason) reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
return Object.freeze({ count: candidates.length,
reasons: Object.freeze([...reasons].map(([reason, count]) => Object.freeze({ reason, count }))) });
}
/** The prompt blocks of the chosen option: its two role texts, or nothing without a choice. */
function promptsOf(option) {
if (!option?.candidate) return { positive: { status: 'ambiguous' }, negative: { status: 'ambiguous' } };
if (!option?.candidate) return null;
const { positive, negative } = option.candidate;
return { positive: { status: 'resolved', text: positive.text }, negative: { status: 'resolved', text: negative.text } };
}
@@ -106,13 +129,13 @@ const isLost = error => !isAbort(error) && !error?.code && !(error?.status >= 40
const initial = () => ({
status: 'idle', epoch: null, total: 0, items: Object.freeze([]), nextCursor: null, more: 'idle',
selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
phase: 'idle', warning: '', canRetry: false, toast: null,
unreadable: null, phase: 'idle', warning: '', canRetry: false, toast: null, decisions: 0,
});
/**
* @param {{api: {get: Function, post: Function, del: Function}}} options
* @returns the controller: `state`, `subscribe(listener)`, `load()`, `refresh()`, `loadMore()`, `select(id)`, `choose(value)`,
* `sync(version)`, `apply()`, `retry()`, `leave()`, `dispose()`.
* `sync(version)`, `apply()`, `leaveUnresolved()`, `link(choice)`, `retry()`, `leave()`, `dispose()`.
*/
export function createReviewController({ api }) {
let state = Object.freeze(initial());
@@ -218,15 +241,16 @@ export function createReviewController({ api }) {
if (keep && !stale && (quiet || state.detail)) return;
if (quiet && left && !keep && pending?.id === left.id) {
// M5: the decision that got no answer was recorded (the server used its capture).
const choice = pending.body.choice;
const { toast } = pending;
pending = null;
set({ phase: 'done', warning: '', canRetry: false,
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
set({ warning: '', canRetry: false, toast, decisions: state.decisions + 1 });
if (items.length) await select(items[0].id);
else clearSelection();
} else if (quiet && left && !keep && items.length) {
set({ warning: COPY.decidedElsewhere(left.filename) });
await select(items[0].id, { keepWarning: true });
} else if (items.length) await select((keep ?? items[0]).id, { keepWarning });
else set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null });
else clearSelection();
} catch (error) {
if (read !== listRead || disposed || isAbort(error)) return;
// M7: a click this read took over can be made again.
@@ -244,6 +268,11 @@ export function createReviewController({ api }) {
}
}
function clearSelection() {
set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
unreadable: null });
}
/** The stream's `ready`: re-read only when the queue shown is older than `version`. */
function sync(version) {
if (disposed) return Promise.resolve();
@@ -320,14 +349,14 @@ export function createReviewController({ api }) {
dropPending();
const read = detailRead = new AbortController();
set({ selectedId: id, detail: null, detailStatus: 'loading', options: Object.freeze([]), choice: null, prompts: null,
canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
unreadable: null, canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
try {
const detail = await api.get(`${REVIEW_URL}/${encodeURIComponent(id)}`, { signal: signalOf(read), cache: 'no-store' });
if (read !== detailRead || disposed) return;
const options = contextOptions(detail);
const chosen = options.find(option => !option.disabled);
set({ detail: Object.freeze(detail), detailStatus: 'ready', options, choice: chosen.value, prompts: promptsOf(chosen),
warning: (keepWarning && state.warning) || warningOf(detail) });
set({ detail: Object.freeze(detail), detailStatus: 'ready', options, choice: chosen?.value ?? null,
prompts: promptsOf(chosen), unreadable: unreadableOf(detail), warning: (keepWarning && state.warning) || warningOf(detail) });
} catch (error) {
if (read !== detailRead || disposed || isAbort(error)) return;
if (RELOAD.has(error?.code)) { set({ warning: error.message }); await load({ keepWarning: true, stale: true }); return; }
@@ -339,7 +368,7 @@ export function createReviewController({ api }) {
const option = state.options.find(entry => entry.value === value);
if (!option || option.disabled || state.phase === 'applying') return;
// Another choice is another decision: an unanswered one is no longer resent.
if (pending && pending.body.choice !== value) { dropPending(); set({ canRetry: false }); }
if (pending && pending.key !== value) { dropPending(); set({ canRetry: false }); }
set({ choice: value, prompts: promptsOf(option) });
}
@@ -350,24 +379,35 @@ export function createReviewController({ api }) {
if (owed && !reloading && !disposed) refresh();
}
/** Send `body`; true once the decision is recorded. */
async function send(body) {
/** The item recorded: it leaves the page's queue, the one after it opens and the queue is read again. */
async function advance(id, total, toast) {
const index = state.items.findIndex(entry => entry.id === id);
const items = Object.freeze(state.items.filter(entry => entry.id !== id));
const next = items[index] ?? items[index - 1] ?? null;
set({ phase: 'idle', items, total, warning: '', toast, decisions: state.decisions + 1 });
owedRefresh = false;
if (next) await select(next.id);
else clearSelection();
// Not refresh(): an answer already on its way may predate the decision, and this read aborts it.
await load({ keepWarning: true, quiet: true });
}
/** Send one decision; true once it is recorded. */
async function send({ path, body, key, toast, lost = COPY.lost }) {
const id = state.selectedId;
const choice = body.choice;
set({ phase: 'applying', canRetry: false });
try {
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/decision`, body, { signal: lifetime.signal });
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/${path}`, body, { signal: lifetime.signal });
pending = null;
set({ phase: 'done', total: result.total, warning: '',
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
await advance(id, result.total, toast);
return true;
} catch (error) {
// Leaving the page mid-request: the server may never see it, so the capture is released here (a release
// of a capture the server already used is a harmless 404).
if (disposed || isAbort(error)) { release(body.capture_token); return false; }
if (isLost(error)) {
pending = { id, body };
set({ phase: 'idle', warning: COPY.lost, canRetry: true });
pending = { id, path, body, key, toast, lost };
set({ phase: 'idle', warning: lost, canRetry: true });
idleAgain(false);
return false;
}
@@ -380,55 +420,82 @@ export function createReviewController({ api }) {
}
}
async function apply() {
const { detail, choice } = state;
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail || !choice) return false;
// After an unanswered request, Apply resends that same decision once (its capture and body).
if (pending?.id === state.selectedId && pending.body.choice === choice) return retry();
/** A fresh capture of the open file, or null when there is no target; `false` when the decision must stop. */
async function capture(detail) {
if (!detail.target) return null;
let observed;
try {
// M4: not aborted on leave, so the capture it answers can be released.
observed = await api.post(OBSERVE_URL, { target: detail.target });
} catch (error) {
if (disposed || isAbort(error)) return false;
set({ phase: 'idle', warning: error?.message || COPY.failed });
idleAgain(RELOAD.has(error?.code));
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
return false;
}
const taken = observed.capture;
if (disposed) { release(taken?.token); return false; }
// M3: the file was observed under another storage than the detail was read from.
if (observed.library_epoch !== detail.library_epoch) {
release(taken?.token);
set({ phase: 'idle', warning: COPY.storageChanged });
idleAgain(true);
await load({ keepWarning: true, stale: true });
return false;
}
return taken ?? null;
}
/** One decision from the idle page: resend an unanswered same one, else capture and send. */
async function decide({ key, path, fields, toast, lost }) {
const { detail } = state;
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail) return false;
// After an unanswered request, the same decision resends that body once (its capture and body).
if (pending?.id === state.selectedId && pending.key === key) return retry();
dropPending();
set({ phase: 'applying', warning: '', canRetry: false });
let capture = null;
if (detail.target) {
let observed;
try {
// M4: not aborted on leave, so the capture it answers can be released.
observed = await api.post(OBSERVE_URL, { target: detail.target });
} catch (error) {
if (disposed || isAbort(error)) return false;
set({ phase: 'idle', warning: error?.message || COPY.failed });
idleAgain(RELOAD.has(error?.code));
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
return false;
}
capture = observed.capture;
if (disposed) { release(capture?.token); return false; }
// M3: the file was observed under another storage than the detail was read from.
if (observed.library_epoch !== detail.library_epoch) {
release(capture?.token);
set({ phase: 'idle', warning: COPY.storageChanged });
idleAgain(true);
await load({ keepWarning: true, stale: true });
return false;
}
}
return send({
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: capture?.token ?? null,
sha256: capture?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, choice,
});
const taken = await capture(detail);
if (taken === false) return false;
return send({ path, key, toast, lost, body: {
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: taken?.token ?? null,
sha256: taken?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, ...fields,
} });
}
function apply() {
const { choice } = state;
if (!choice) return Promise.resolve(false);
return decide({ key: choice, path: 'decision', fields: { choice }, toast: COPY.applied });
}
function leaveUnresolved() {
return decide({ key: UNRESOLVED, path: 'decision', fields: { choice: UNRESOLVED }, toast: COPY.leftUnresolved });
}
/** Link item `itemId`, which must still be the open one, to prompt `positiveId` with recipe `pairId` (null: no negative). */
function link({ itemId, positiveId, pairId, title }) {
if (state.selectedId !== itemId || !state.detail?.target) return Promise.resolve(false);
return decide({ key: `link:${positiveId}:${pairId}`, path: 'link', toast: COPY.linked(title), lost: COPY.lostLink,
fields: { positive_id: positiveId, pair_id: pairId } });
}
/** Resend the unanswered decision once, with its own capture; false when there is nothing to resend. */
async function retry() {
if (!pending || state.phase !== 'idle' || pending.id !== state.selectedId) return false;
const { body } = pending;
const decision = pending;
pending = null;
return send(body);
return send(decision);
}
/** Leave for later: nothing is written; the next item opens, wrapping. */
function leave() {
if (state.phase === 'applying') return;
if (state.phase === 'applying') return Promise.resolve();
dropPending();
set({ phase: 'done', toast: null, canRetry: false });
const index = state.items.findIndex(entry => entry.id === state.selectedId);
const next = state.items[index + 1] ?? state.items[0];
if (!next || next.id === state.selectedId) { set({ canRetry: false, warning: '' }); return Promise.resolve(); }
return select(next.id);
}
return {
@@ -437,7 +504,7 @@ export function createReviewController({ api }) {
listeners.add(listener);
return () => listeners.delete(listener);
},
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, retry, leave,
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, leaveUnresolved, link, retry, leave,
dispose() {
if (disposed) return;
dropPending();
+48 -22
View File
@@ -7,8 +7,11 @@
* "No images need a decision" and the scan/new-generation sentence, and nothing else.
* - `.gallery-layout`: the queue's concept media cards (image-card.mjs; the image opens the shared viewer, Info selects
* the item, Tag opens the shared Tag image dialog with the review scope note), then `section.surface` "Resolve the
* association": filename, Tag image, Sampler context, the chosen path's prompt blocks and the actions Leave for later
* and Apply association. Both return to Images (ruling R7).
* association": filename, Tag image, Link to a prompt…, Sampler context, the chosen path's prompt blocks and the
* actions Leave for later, Leave unresolved and Apply association. Each opens the next item (#536, overturning R7).
* - D179: Leave unresolved is a button, not the select's last option; a disabled option names the server's reason.
* - D180: with nothing to apply the select, the prompt blocks and Apply are gone: the panel says why and Link to a
* prompt… is the primary action.
* - States the concept lacks (loading, a failed read, a refused or unanswered decision, a limited or unavailable file)
* are text: `p.warning-note` in the panel, or the page's status line (D119).
* - The open page re-reads its queue on each `change` of the review change stream (`/prompt_manager/review/events`,
@@ -29,6 +32,7 @@
import { createButton } from '../shared/toolkit/core.js';
import { createReviewController, COPY, noticeTitle } from './review-state.mjs';
import { createImageCard } from './image-card.mjs';
import { openPromptLink } from './review-link.mjs';
import { isTagButton, openImageTagging } from './image-tagging.mjs';
import { mountPromptBlocks } from './prompt-blocks.mjs';
import { createPromptSender } from './prompt-sender.mjs';
@@ -38,6 +42,7 @@ import { createWorkspaceIcon } from './icons.mjs';
import { field } from './workspace-dialogs.mjs';
import { pageHeading } from './heading.mjs';
import { navigateTo } from './state.mjs';
import { noun } from './plural.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imageKey } from './images-state.mjs';
@@ -123,17 +128,22 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
const tagRow = el('div', undefined, 'row-actions spaced-15');
const tagButton = createButton('');
tagButton.append(createWorkspaceIcon('tagImage'), ' Tag image');
tagRow.append(tagButton);
const linkButton = createButton('Link to a prompt…');
linkButton.classList.add('review-link-open');
tagRow.append(tagButton, linkButton);
const select = el('select');
const contextField = field('Sampler context', select, 'review-context');
const unreadable = el('div', undefined, 'review-unreadable'); // D180
const warning = el('p', '', 'warning-note'); // D119: the states the concept lacks
warning.setAttribute('role', 'status');
const blocks = el('div');
const actions = el('div', undefined, 'dialog-actions');
const later = createButton('Leave for later');
const leaveUnresolved = createButton('Leave unresolved');
leaveUnresolved.classList.add('review-leave-unresolved');
const apply = createButton('Apply association', 'ca-primary');
actions.append(later, apply);
panel.append(panelTitle, filename, tagRow, contextField, warning, blocks, actions);
actions.append(later, leaveUnresolved, apply);
panel.append(panelTitle, filename, tagRow, contextField, unreadable, warning, blocks, actions);
layout.append(grid, panel);
const live = el('p', NOT_LIVE, 'small review-live'); // D120: the change stream is down
@@ -155,7 +165,7 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
sender, onSelect: image => inspection.select(image) });
const cards = new Map();
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail;
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail, shownUnreadable = null, shownDecisions = 0;
function createCard(item) {
// D119: no selection checkbox and no star on a queue card.
@@ -192,10 +202,26 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
}
}
/** D180: nothing to apply, so say why and lead with the picker. */
function renderUnreadable(state) {
const none = state.detailStatus === 'ready' && !state.choice;
contextField.hidden = blocks.hidden = apply.hidden = none;
linkButton.classList.toggle('ca-primary', none && Boolean(state.detail?.target));
unreadable.hidden = !state.unreadable;
if (state.unreadable === shownUnreadable) return;
shownUnreadable = state.unreadable;
if (!state.unreadable) { unreadable.replaceChildren(); return; }
const { count, reasons } = state.unreadable;
unreadable.replaceChildren(el('p', COPY.unreadable(count)),
...reasons.map(({ reason, count: paths }) => el('p', `${reason} (${paths} ${noun(paths, 'path')})`, 'small')));
}
function renderPanel(state) {
const item = state.items.find(entry => entry.id === state.selectedId);
setText(filename, item?.filename ?? '');
setDisabled(tagButton, !item?.target || state.phase === 'applying');
setDisabled(linkButton, !item?.target || state.detailStatus !== 'ready' || state.phase !== 'idle');
renderUnreadable(state);
if (state.options !== shownOptions) {
shownOptions = state.options;
select.replaceChildren(...state.options.map(option => {
@@ -212,16 +238,22 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
if (state.prompts !== shownPrompts || state.detailStatus !== shownDetail) {
shownPrompts = state.prompts;
shownDetail = state.detailStatus;
if (state.detailStatus === 'ready') prompts.setState({ prompts: state.prompts });
if (state.detailStatus === 'ready' && state.prompts) prompts.setState({ prompts: state.prompts });
else if (state.detailStatus === 'ready') prompts.setState({ prompts: null });
else prompts.setState({ loading: state.detailStatus === 'loading', error: state.detailStatus === 'error' });
}
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle');
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle' || !state.choice);
setDisabled(leaveUnresolved, state.detailStatus !== 'ready' || state.phase !== 'idle');
setDisabled(later, state.phase === 'applying');
}
function render(state) {
if (scope.signal.aborted) return;
if (state.phase === 'done') return finish(state);
if (state.decisions !== shownDecisions) {
shownDecisions = state.decisions;
showWorkspaceToast(state.toast);
onDecision();
}
const ready = state.status === 'ready';
if (!ready) {
setText(status, state.status === 'error' ? COPY.loadFailed : COPY.loading);
@@ -249,23 +281,17 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
} else continuation.remove();
}
let finished = false;
function finish(state) {
if (finished) return;
finished = true;
if (state.toast) {
showWorkspaceToast(state.toast);
onDecision();
}
// Ruling R7: Apply association and Leave for later both return to Images.
navigateTo('#/images');
}
scope.listen(retryLoad, 'click', () => controller.load());
scope.listen(more, 'click', () => { if (!isDisabled(more)) controller.loadMore(); });
scope.listen(select, 'change', () => controller.choose(select.value));
scope.listen(apply, 'click', () => { if (!isDisabled(apply)) controller.apply(); });
scope.listen(later, 'click', () => { if (!isDisabled(later)) controller.leave(); });
scope.listen(leaveUnresolved, 'click', () => { if (!isDisabled(leaveUnresolved)) controller.leaveUnresolved(); });
scope.listen(linkButton, 'click', () => {
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
if (isDisabled(linkButton) || !item?.target) return;
openPromptLink({ api, scope, controller, filename: item.filename, trigger: linkButton });
});
scope.listen(tagButton, 'click', () => {
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
if (isDisabled(tagButton) || !item?.target) return;
@@ -292,7 +318,7 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
let shownTotal = null;
const reportTotal = state => {
if (state.status !== 'ready' || state.phase === 'done' || state.total === shownTotal) return;
if (state.status !== 'ready' || state.total === shownTotal) return;
shownTotal = state.total;
onTotal?.(state.total);
};
+7
View File
@@ -449,6 +449,13 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.review-queue .image-open[aria-disabled="true"] { cursor: not-allowed; aspect-ratio: 1.33; /* D119 */ }
/* D119: a long file name in the panel wraps inside the panel, where the concept's sample name never overflows. */
.review-panel > p.small { min-inline-size: 0; overflow-wrap: anywhere; /* D119 */ }
/* #536: the concept's rows hidden when there is nothing to apply (D180), and the picker (D181). */
.review-panel > [hidden], .review-panel .dialog-actions > [hidden], #modal .field[hidden] { display: none; }
.review-unreadable { display: grid; gap: 6px; margin-block: 12px; overflow-wrap: anywhere; }
.review-link-results { display: grid; gap: 6px; max-block-size: 40vh; overflow-y: auto; }
.review-link-results > button { justify-content: flex-start; inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.review-link-results > button[aria-pressed="true"] { border-color: var(--ca-accent); background: var(--soft-blue); }
.review-panel button[aria-disabled="true"], .review-link-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group
in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text
field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */