Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/index.mjs
T
Vito Sansevero 52c6efcbe4 feat(review): re-read an open Needs review page when its queue changes
An open Needs review page now follows the queue without polling. A new
server-sent event stream, GET /prompt_manager/review/events, sends
"ready" when it opens and "change" after every committed queue change:
an item the scan or the monitor recorded, a spelling refresh, a decision
from any tab, and a restore. database/review_changes.py notifies after
the commit from whichever thread wrote it. The page re-reads its first
page on each event, keeps the open item, its choice and an unanswered
decision, and re-reads the SAVED VIEWS count with it. When the open item
left the queue, the next one opens with a note. A keepalive comment
every 25 s lets the server drop a closed tab; shutdown ends every stream.

Leaving the page mid-decision now releases that decision's capture.

D-NEW-F1 is renumbered D119 in markers and TESTING.md.
2026-10-22 19:23:37 -07:00

326 lines
16 KiB
JavaScript

import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages, onFavoriteChange } from './images.mjs';
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
import { renderHeading, pageHeading } from './heading.mjs';
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
import { createActivityStore } from './activity-store.mjs';
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
import { mountActivityJobView } from './activity-job-view.mjs';
import { mountInspect } from './inspect.mjs';
import { mountReview } from './review.mjs';
import { createPromptSender } from './prompt-sender.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
function element(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function legacyLink(route) {
const link = element('a', route.action, 'workspace-link');
link.href = resolveUrl(route.href);
return link;
}
function mountWorkspace() {
const scope = createScope();
const api = createApiClient();
const main = document.getElementById('workspace-main');
const primaryNav = document.getElementById('primary-nav');
const savedNav = document.getElementById('saved-nav');
const systemNav = document.getElementById('system-nav');
scope.listen(document.querySelector('.skip'), 'click', event => {
event.preventDefault();
main.focus();
});
// D4: the concept's Theme select; "System" keeps following ComfyUI, then the OS.
const theme = mountWorkspaceTheme({ root: document.body, scope });
const preference = document.getElementById('theme');
preference.value = theme.preference;
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
// D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (a send is not an Activity job).
let images = null;
scope.cleanup(bindCanvasLink(document.querySelector('.topbar .canvas-link'), {
href: canvasHref(resolveUrl, window.location.pathname),
getSelectedPair: () => images?.selectedPair?.() ?? null,
sender: {open: (roles, trigger) => images?.sender.open(roles, trigger)},
}));
// The concept's nav(): icon, label text, then a `.nav-count` only for a loaded real count.
const navLinks = ids => ids.map(([id, icon]) => {
const route = routes.find(item => item.id === id);
const link = element('a');
link.append(createWorkspaceIcon(icon), document.createTextNode(route.label));
link.href = `#/${route.id}`;
link.dataset.route = route.id;
return link;
});
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
// The concept's tools-and-system group: Activity, System, then (D28) the production Overview hub.
systemNav.replaceChildren(...navLinks([['activity', 'activity'], ['system', 'settings'], ['overview', 'overview']]));
// Activity (#249 Activity-1): one store of this session's jobs feeds the top-bar button and pill, the sidebar
// row's count and the Activity page, on every route.
const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) });
mountActivityTopbar({ store: activity, scope });
activity.load();
const sidebar = document.querySelector('aside.sidebar');
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
let currentKey = null;
const markCurrent = () => {
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
if (link.dataset.route === currentKey) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
};
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
// re-read after every change. /* D49 */ no "PROPOSED" badge. The first rows are the concept's Favorites (star icon,
// count, D51) and Needs review (review icon, the exact pending count, ruling R5), kept across every re-read so
// their counts stay.
const [favoritesLink, reviewLink] = navLinks([['favorites', 'star'], ['review', 'review']]);
savedNav.replaceChildren(favoritesLink, reviewLink);
const savedViews = createSavedViews(api, scope.signal);
scope.cleanup(savedViews.subscribe(({ views }) => {
savedNav.replaceChildren(favoritesLink, reviewLink, ...views.map(view => {
const link = element('a');
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
link.href = savedViewHash(view.id);
link.dataset.route = `saved/${view.id}`;
return link;
}));
markCurrent();
}));
savedViews.load();
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
onSave: name => savedViews.create(page, name, state) });
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
const countRequests = new Map();
function refreshCounts(ids) {
for (const id of ids) {
countRequests.get(id)?.abort();
const request = new AbortController();
countRequests.set(id, request);
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
loadNavCounts(api, request.signal, (_, count) => {
if (request.signal.aborted) return;
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
link?.querySelector('.nav-count')?.remove();
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
}, [id]);
}
}
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
// Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51), once
// after a run of quick toggles settles rather than once per click (#249 M1).
let favoriteCountTimer = 0;
scope.cleanup(onFavoriteChange(() => {
clearTimeout(favoriteCountTimer);
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
}));
scope.cleanup(() => clearTimeout(favoriteCountTimer));
// A metadata scan that ends may have added items to Needs review: re-read its count once per ended scan.
const runningScans = new Set();
scope.cleanup(activity.subscribe(() => {
let ended = false;
for (const job of activity.jobs()) {
if (job.kind !== 'scan') continue;
if (job.state === 'running') runningScans.add(job.id);
else if (runningScans.delete(job.id)) ended = true;
}
if (ended) refreshCounts(REVIEW_WRITE_COUNTS);
}));
refreshCounts(NAV_COUNT_ROUTES);
let viewScope;
let firstRender = true;
scope.cleanup(() => viewScope?.dispose());
function render(route) {
// A shared page dialog never stays open over the next route.
closeWorkspaceDialog();
viewScope?.dispose();
images = null;
viewScope = createScope();
const view = viewScope;
currentKey = routeKey(route);
markCurrent();
if (route.id === 'saved') {
openSavedView(route, view);
return;
}
renderPage(route, view);
}
/** A saved view opens its page with its state; the heading, breadcrumb and title are the view's name. */
async function openSavedView(route, view) {
main.replaceChildren();
await savedViews.settled();
if (view.signal.aborted) return;
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
await savedViews.load();
if (view.signal.aborted) return;
}
const saved = savedViews.find(route.savedId);
if (saved) {
const page = routes.find(item => item.id === saved.page);
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
mountEditAction(saved, view);
return;
}
if (savedViews.status === 'ready') {
// A removed or unknown view: the shell's default page, without a history entry for the dead link.
window.location.replace('#/images');
return;
}
const {description} = renderHeading(main, {title:route.label});
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
description.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', () => {
savedViews.load();
openSavedView(route, view);
});
main.append(retry);
}
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
function mountEditAction(saved, view) {
// renderPage has just written the heading.
const edit = createButton('Edit');
pageHeading(main).actions.prepend(edit);
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
onRename: async name => {
const renamed = await savedViews.rename(saved.id, name);
if (view.signal.aborted) return;
saved = renamed;
retitle(renamed.name);
},
onDelete: async () => {
await savedViews.remove(saved.id);
window.location.replace(`#/${saved.page}`);
} }));
}
function retitle(name) {
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
pageHeading(main).h1.textContent = name;
document.getElementById('workspace-breadcrumb').textContent = name;
document.title = `${name} · Prompt Manager`;
}
function renderPage(route, view, saved = null) {
document.title = `${routeTitle(route)} · Prompt Manager`;
// The concept's heading(); each view fills its description and actions.
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
const { description, h1 } = renderHeading(main, { title: route.label });
if (!firstRender) h1.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (route.id === 'images' || route.id === 'favorites') {
mountNewPromptAction(main, api, view);
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
onSaveView:saveView('images', view), activity});
return;
}
if (route.id === 'library') {
mountLibrary(main, api, view, {positiveId:route.positiveId, savedState:saved?.state, onSaveView:saveView('library', view)});
return;
}
if (route.id === 'tags') {
mountVocabulary(main, api, view);
return;
}
if (route.id === 'activity') {
// D56: `#/activity/<id>` is that job's own view; `#/activity` lists the session's jobs.
if (route.jobId) mountActivityJobView(main, { store: activity, scope: view, api, jobId: route.jobId });
else mountActivityPage(main, { store: activity, scope: view });
return;
}
if (route.id === 'review') {
// Needs review (ruling R5): a recorded decision, and a change the open page hears of, re-read the row's count.
mountReview(main, { api, scope: view, onDecision: () => refreshCounts(REVIEW_WRITE_COUNTS),
onQueueChange: () => refreshCounts(REVIEW_WRITE_COUNTS) });
return;
}
if (route.id === 'inspect') {
// The concept's Inspect a file (app.js renderMetadata): a local file, read only in this browser.
description.textContent = route.description;
mountInspect(main, { parentScope: view, api, sender: createPromptSender({ scope: view }) });
return;
}
if (isHandoff(route)) {
description.textContent = route.description;
const panel = element('section', undefined, 'surface handoff');
panel.append(element('h2', 'Your current tools are ready'),
element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'),
legacyLink(route));
main.append(panel);
return;
}
description.textContent = 'A home for your prompts, images and creative history.';
const stats = element('section', undefined, 'overview-stats');
stats.setAttribute('aria-label', 'Library statistics');
main.append(stats);
const tools = element('section', undefined, 'surface');
tools.append(element('h2', 'Continue creating'), element('p', 'Your existing tools remain available while the new workspaces take shape.'));
const cards = element('div', undefined, 'tool-grid');
for (const target of routes.filter(item => item.href)) {
const card = element('article', undefined, 'tool-card');
card.append(element('h3', target.label), element('p', target.description, 'small'), legacyLink(target));
cards.append(card);
}
tools.append(cards);
main.append(tools);
async function loadStats() {
const status = element('p', 'Loading library statistics…');
status.setAttribute('role', 'status');
stats.replaceChildren(status);
try {
const result = await api.get('/prompt_manager/stats', { signal: view.signal });
if (view.signal.aborted) return;
const values = summarizeStats(result.stats);
const list = element('dl', undefined, 'stat-grid');
for (const [label, count] of values) {
const stat = element('div', undefined, 'stat');
stat.append(element('dt', label), element('dd', count.toLocaleString()));
list.append(stat);
}
stats.replaceChildren(list);
if (values.every(([, count]) => count === 0)) {
stats.append(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
}
} catch (error) {
if (view.signal.aborted) return;
const alert = element('p', 'Library statistics could not be loaded. Your saved data has not changed.');
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', loadStats);
stats.replaceChildren(alert, retry);
}
}
loadStats();
}
scope.cleanup(mountRouter(window, render));
scope.listen(window, 'pagehide', () => scope.dispose());
return () => scope.dispose();
}
mountWorkspace();
window.addEventListener('pageshow', event => {
if (event.persisted) mountWorkspace();
});