/** * 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/`. * - 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 `` 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). * - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors". * - D111: what a line says (scope, counters, what a cancel kept) is the job kind's descriptor (activity-kinds.mjs); a * job without a known total shows an indeterminate ``, and only a job the server can stop shows Cancel. */ import { createButton } from '../shared/toolkit/core.js'; import { navigateTo } from './state.mjs'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { pillParts, outcomeLine, LOST_DETAIL } from './activity-store.mjs'; import { viewOf, canCancel, showProgress, indeterminate } from './activity-kinds.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", "4 images") and what a cancelled one kept ("N added"), by kind (D111). */ const scopeLine = job => viewOf(job).scope(job); const keptOf = job => viewOf(job).kept(job); /** 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`, keptOf(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; // D111 (S5 review M5): no total, no fill: the bar sweeps instead of showing an empty determinate 0%. const unknown = indeterminate(job); bar.classList.toggle('indeterminate', unknown); fill.style.width = unknown ? '' : `${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'; // parentNode, not isConnected: on the first render the item is not in the page yet, so a job that had already // ended when Activity opened kept its Cancel job (and a running one's progress moved after its actions). if (running) { showProgress(progress, next); // D111: no value while the total is unknown progress.setAttribute('aria-label', `${next.title} progress`); if (!progress.parentNode) item.insertBefore(progress, actions.parentNode ? actions : null); } else { progress.remove(); } // Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel job. if (canCancel(next)) { if (!actions.parentNode) item.append(actions); } else if (actions.parentNode) { // 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(); 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(); }