Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/activity.mjs
T
Vito Sansevero 9a7bdb2f09 feat(review): Needs review page, SAVED VIEWS row and Activity hook
The concept's Metadata review page at #/review: the notice with the
exact pending count, the queue's media cards (shared with Images through
image-card.mjs), and the Resolve the association panel with the sampler
context, the chosen path's prompt blocks, Leave for later and Apply
association. Apply takes a fresh capture; refusals keep the selection
and explain inline; an unanswered decision is resent once. SAVED VIEWS
lists Needs review after Favorites with its count (retires D50), and an
ended scan that recorded review items offers Review metadata in Activity
(D87). Queue items carry the card's media url.
2026-10-22 19:01:17 -07:00

211 lines
9.6 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.
/**
* Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
* (`button#activity-open` with `span.count#activity-count`, index.html 51–52) and its sidebar row (app.js ~341), fed
* by the session job store (activity-store.mjs).
*
* - D52: both counts show the running jobs only and are hidden at 0; the heading has no "Unified activity · proposed"
* chip (prototype copy).
* - D53: while a job runs, the progress pill (`a.activity-pill`, the approved mockup's) sits before the button on every
* page: "Scanning · 42% · 8,240 / 19,622" and a mini bar, linking `#/activity/<id>`.
* - D56: each item's title links its job's own view (activity-job-view.mjs).
* - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running",
* "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and
* "Cancel job" (no "Simulate completion").
* - D87 (plan D64): an ended scan that recorded Needs review items carries the concept's **Review metadata**
* (`go-review`, app.js a738) after its line, which opens `#/review`; its chip still names its state (D54).
*/
import { createButton } from '../shared/toolkit/core.js';
import { navigateTo } from './state.mjs';
import { pageHeading } from './heading.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { countOf } from './plural.mjs';
import { pillParts, outcomeLine, addedOf, LOST_DETAIL } from './activity-store.mjs';
export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.';
export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.';
export const LOAD_FAILED = 'Activity could not be read. It is tried again shortly.';
export const CANCEL_FAILED_TOAST = 'The job could not be cancelled. Try again.';
/** Whether an ended job recorded items for Needs review (a metadata scan's `scope.review_items`). */
export function hasReviewItems(job) {
const count = job?.scope?.review_items;
return job?.state !== 'running' && Number.isSafeInteger(count) && count > 0;
}
/** The concept's **Review metadata** (D87): opens Needs review. */
export function createReviewMetadataButton(scope) {
const button = createButton('Review metadata');
scope.listen(button, 'click', () => navigateTo('#/review'));
return button;
}
const CHIPS = Object.freeze({
running: ['blue', 'Running'], finished: ['green', 'Completed'], cancelled: ['blue', 'Cancelled'],
failed: ['blue', 'Failed'], lost: ['blue', 'Ended'],
});
function element(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** "09:41" in the browser's local time; an unknown time is empty. */
export function clockTime(seconds) {
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return '';
const date = new Date(seconds * 1000);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
const join = parts => parts.filter(Boolean).join(' · ');
/** What a job covers: "2 output folders" for a scan. */
function scopeLine(job) {
const folders = job.scope?.output_folders;
return Number.isSafeInteger(folders) && folders >= 0 ? countOf(folders, 'output folder') : '';
}
/** The item's line under its title, by state. */
export function jobDetail(job) {
const at = clockTime(job.finished_at);
if (job.state === 'running') {
return join([`Started ${clockTime(job.started_at)}`, scopeLine(job), 'closing this view does not cancel it']);
}
if (job.state === 'lost') return LOST_DETAIL;
if (job.state === 'cancelled') {
return join([`Cancelled ${at} at a file boundary`, job.kind === 'scan' ? addedOf(job) : '',
'completed work is kept']);
}
if (job.state === 'failed') return join([`Stopped on an error at ${at}`, outcomeLine(job)]);
return join([`Finished ${at}`, outcomeLine(job)]);
}
/** The shell's Activity indicators: the top-bar count and pill, and the sidebar row's count. */
export function mountActivityTopbar({ store, scope, doc = document }) {
const button = doc.getElementById('activity-open');
const count = doc.getElementById('activity-count');
const pill = doc.getElementById('activity-pill');
scope.listen(button, 'click', () => navigateTo('#/activity'));
const label = element('span', '', 'activity-pill-label');
const files = element('span', '', 'activity-pill-files');
const bar = element('span', undefined, 'activity-pill-bar');
const fill = element('i');
bar.setAttribute('aria-hidden', 'true');
bar.append(fill);
pill.replaceChildren(label, files, bar);
const render = () => {
const running = store.running();
count.textContent = String(running.length);
count.hidden = running.length === 0;
const row = doc.querySelector('#system-nav a[data-route="activity"]');
row?.querySelector('.nav-count')?.remove();
if (row && running.length) row.append(element('span', running.length.toLocaleString(), 'nav-count'));
const [job] = running;
pill.hidden = !job;
if (!job) {
pill.removeAttribute('href');
pill.removeAttribute('aria-label');
return;
}
const parts = pillParts(job);
label.textContent = parts.label;
files.textContent = parts.files;
fill.style.width = `${job.progress}%`;
pill.href = `#/activity/${job.id}`;
pill.setAttribute('aria-label', parts.text);
};
scope.cleanup(store.subscribe(render));
render();
}
function itemFor(job, scope) {
const item = element('div', undefined, 'activity-item');
item.dataset.jobId = job.id;
const heading = element('div', undefined, 'panel-heading');
const title = element('h3');
// D56: the title opens the job's own view.
const link = element('a');
link.href = `#/activity/${job.id}`;
title.append(link);
const chip = element('span');
heading.append(title, chip);
const detail = element('p');
const progress = element('progress');
progress.max = 100;
const actions = element('div', undefined, 'row-actions');
const cancel = createButton('Cancel job');
actions.append(cancel);
const review = createReviewMetadataButton(scope); // D87
item.append(heading, detail, progress, actions);
scope.listen(cancel, 'click', async () => {
if (cancel.getAttribute('aria-disabled') === 'true') return;
cancel.setAttribute('aria-disabled', 'true');
try {
await item.cancelJob();
} catch (error) {
if (scope.signal.aborted) return;
cancel.removeAttribute('aria-disabled');
showWorkspaceToast(CANCEL_FAILED_TOAST);
}
});
item.update = next => {
const [tone, state] = CHIPS[next.state];
link.textContent = `${next.state === 'finished' ? '✓' : '○'} ${next.title}`;
chip.className = `chip ${tone}`;
chip.textContent = state;
detail.textContent = jobDetail(next);
const running = next.state === 'running';
if (running) {
progress.value = next.progress;
progress.textContent = `${next.progress}%`;
progress.setAttribute('aria-label', `${next.title} progress`);
if (!progress.isConnected) item.insertBefore(progress, actions.isConnected ? actions : null);
if (!actions.isConnected) item.append(actions);
} else {
// A focused Cancel job that goes away hands its focus to the item's title link, never to the page body.
if (actions.contains(document.activeElement)) link.focus();
progress.remove();
actions.remove();
}
if (hasReviewItems(next)) { if (!review.isConnected) detail.after(review); } // D87
else review.remove();
};
return item;
}
/** The Activity page: the concept's "Recent work" list of this session's jobs (one job's view is D56). */
export function mountActivityPage(main, { store, scope }) {
pageHeading(main).description.textContent = ACTIVITY_DESCRIPTION;
const section = element('section', undefined, 'surface');
const status = element('p', undefined, 'empty-state');
section.append(element('h2', 'Recent work'));
main.append(section);
const items = new Map();
const render = () => {
const jobs = store.jobs();
for (const [id, item] of items) if (!jobs.some(job => job.id === id)) { item.remove(); items.delete(id); }
jobs.forEach((job, index) => {
let item = items.get(job.id);
if (!item) {
item = itemFor(job, scope);
item.cancelJob = () => store.cancel(job.id);
items.set(job.id, item);
}
item.update(job);
// Only a node out of place moves, so a focused Cancel job keeps its focus across updates.
const at = section.children[index + 1];
if (at !== item) section.insertBefore(item, at ?? null);
});
const failed = store.status === 'error' && !jobs.length;
status.textContent = failed ? LOAD_FAILED : EMPTY_ACTIVITY;
if (failed) status.setAttribute('role', 'alert');
else status.removeAttribute('role');
if (jobs.length || store.status === 'idle') status.remove();
else if (!status.isConnected) section.append(status);
};
scope.cleanup(store.subscribe(render));
render();
// Opening Activity reads the list at once instead of waiting for the next periodic read.
store.load();
}