Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/state.mjs
T
Vito Sansevero 1b7f50e885 feat(activity): concept Activity page, top-bar button and progress pill
The workspace gains the concept's Activity page (renderActivity), its
top-bar button and sidebar row, fed by one session job store: a /jobs
read, one SSE stream per running job, a 1-30 s capped backoff after a
dropped stream and a 15 s list poll while visible. A job the server no
longer lists shows as ended, so no pill stays stuck after a restart.

D52: the counts show running jobs and hide at 0; the phone top-bar row
may wrap. D53: the mockup's progress pill links the running job. D54:
real job items with state chips, detail lines and Cancel job. Start and
finish toasts follow the spec copy. Fake browser jobs come from
tests/browser/workspace_activity_fixture.py.
2026-10-21 10:00:06 -07:00

131 lines
6.7 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.
export const routes = [
{ id: 'images', label: 'Images', href: '/prompt_manager/gallery',
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open existing image gallery' },
{ id: 'library', label: 'Prompt library', href: '/prompt_manager/admin',
description: 'Find, edit and organize the prompts already in your library.', action: 'Open existing prompt library' },
// Native screen; `title` is the concept's breadcrumb/title, the href stays for Overview's card.
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '/prompt_manager/admin#/tags',
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
{ id: 'overview', label: 'Overview' },
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
{ id: 'favorites', label: 'Favorites' },
// The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card.
{ id: 'activity', label: 'Activity' },
{ id: 'system', label: 'System', href: '/prompt_manager/admin',
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
];
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites', 'activity']);
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
export function routeTitle(route) {
return route.title || route.label;
}
/** Routes the workspace does not render yet show the generic legacy handoff panel. */
export function isHandoff(route) {
return !NATIVE_ROUTES.has(route.id);
}
export function resolveRoute(hash) {
const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash);
if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash};
// One job of the Activity page (its id is the registry's uuid4 hex).
const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash);
if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash};
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
if (match && Number.isSafeInteger(Number(match[1]))) {
return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash};
}
return routes.find(route => hash === `#/${route.id}`) || routes[0];
}
export function summarizeStats(stats) {
return [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]
.map(([label, key]) => {
const count = stats?.[key];
if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid library statistics');
return [label, count];
});
}
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
// Favorites page's own listing, so its count is what that page lists.
const NAV_COUNT_SOURCES = Object.freeze([
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
['favorites', '/prompt_manager/images/output?mode=workspace&favorites=1&limit=1'],
['library', '/prompt_manager/library?limit=1'],
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
]);
/** Route id -> count for the sidebar links; null when a page is missing or its total is not a count. */
export function summarizeNavCounts(pages = {}) {
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
const total = pages?.[id]?.total;
return [id, Number.isSafeInteger(total) && total >= 0 ? total : null];
}));
}
/**
* Read the counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read settles, so a
* slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
*/
/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites). */
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
/** The count a confirmed favorite change can change. */
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
// Trailing delay that coalesces quick star toggles into one Favorites count read (#249 M1).
export const FAVORITE_COUNT_DELAY_MS = 300;
/** Read the given sidebar counts (all by default), reporting each as its own read settles. */
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
const pages = {};
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {
try { pages[id] = await api.get(url, { signal }); } catch { pages[id] = undefined; }
onCount(id, summarizeNavCounts(pages).get(id));
}));
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
}
/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */
const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh';
/** The concept's `go()` (app.js 292–297): a link to the route already shown re-renders it instead of doing nothing. */
export function navigateTo(hash, win = window) {
if (win.location.hash === hash) win.dispatchEvent(new Event(ROUTE_REFRESH_EVENT));
else win.location.hash = hash;
}
export function mountRouter(win, onRoute) {
let current;
const refresh = () => {
current = undefined;
update();
};
const update = () => {
const route = resolveRoute(win.location.hash);
const hash = route.hash || `#/${route.id}`;
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId &&
current?.jobId === route.jobId) return;
current = route;
onRoute(route);
};
win.addEventListener('hashchange', update);
win.addEventListener(ROUTE_REFRESH_EVENT, refresh);
update();
return () => {
win.removeEventListener('hashchange', update);
win.removeEventListener(ROUTE_REFRESH_EVENT, refresh);
};
}