* feat(activity): shared session job registry with the scan as its first kind The output scan now runs as the first kind of an in-memory JobRegistry (py/api/jobs.py, replacing py/api/scan_job.py). Each job keeps exact counters, at most 200 outcome rows per group and a log capped at 2,000 lines, with root-relative paths and public root labels only. New routes list, inspect, follow (SSE) and cancel jobs and start the scan (POST /prompt_manager/jobs/scan, 409 busy while another record-changing job runs). The legacy /prompt_manager/scan routes attach to the same job and stream unchanged payloads. * 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. * feat(activity): Scan for metadata with its review dialog and job view Images -> Import keeps Rescan and adds the concept's "Review scan" row as "Scan for metadata". It opens the concept's "Review metadata scan" with the real source count, the fixed "All files" scope, a backup box that downloads the existing database backup, and Cancel / Start scan. Start scan starts the session scan job and opens its view; a start refused by another record-changing job toasts its title as a link. #/activity/<id> is now the approved job-view mockup: the heading with Download report (disabled until Activity-2) and Cancel scan, the Progress surface with the five counter tiles, the Results surface with the outcome list, path filter and capped rows (Added and Already known open the viewer through the listing identity), and the collapsed Scan log. Activity item titles link their views. The scan's progress line groups thousands. Deviations D55-D57 are recorded in TESTING.md. * fix(activity): stop the job store on pagehide and let proxied routes outlive teardown CI's touch coverage run failed "a resolver failure for the ComfyUI link never stops the shell from mounting" with "route.fulfill: Fetch response has been disposed": its trace shows two Library thumbnail requests started by the last route still in the spec's proxy-mount handler while the page fixture was torn down. - The proxy-mount handler ignores only that teardown error, and the workspace and Activity specs unroute with ignoreErrors before teardown. - The Activity store now ends its work on pagehide: the poll and backoff timers stop, the job streams close and a list read in flight is aborted (an older read is also aborted when a newer one starts). A pageshow from the back-forward cache reads the list again. * test(activity): compare the job view log's line height to the hundredth of a pixel WebKit reports the mockup's 11px/1.7 line height as 18.700001px, so the job view comparison failed on WebKit only. The measurement now rounds it, as other engine-dependent values are normalized. * fix(activity): final review round for the job store, job view and scan rows - Store: one failed list read no longer marks running jobs as ended; two in a row, or a good list without them, do (M1). A good read resets the backoff, except for a stream that keeps dropping while the list reads fine (M2). Finished jobs the server pruned are dropped (M3). Numbers group as en-US, like the server's lines (M13). - Job view: a job the store's list no longer has shows the not-found state (M3). Focus moves to the job heading when Cancel scan goes away, and to the item's title link in the list (M4). The detail is read only when its listed rows change or the open log grows, using the summary's new listed and log_length (M12). - Viewer rows open by exact identity: each scan row records its root's listing index, the source must still carry the row's label, and the workspace listing gains an exact `path` filter (M8, M9). - Scan rows name the prompt by its Library title, stored title first (M10). - A toast with a link stays 20 s, and any toast stays while hovered or focused (M14). - A real two-batch scan cancelled after batch 1 keeps partial counts (M7). - TESTING.md: D54, D56 and D57 updated (M5, M6). * test(activity): cover the held and linked toast and a job view pruned by the server Unit tests for the toast's 6.5 s and 20 s timeouts and its hover and focus hold, and a browser test for a finished job's open view after the server stops listing it. Coverage floors are only raised.
299 lines
15 KiB
JavaScript
299 lines
15 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 } 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';
|
|
|
|
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; /* D50 */ no "Needs review" row. The first row is
|
|
// the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51).
|
|
const [favoritesLink] = navLinks([['favorites', 'star']]);
|
|
savedNav.replaceChildren(favoritesLink);
|
|
const savedViews = createSavedViews(api, scope.signal);
|
|
scope.cleanup(savedViews.subscribe(({ views }) => {
|
|
savedNav.replaceChildren(favoritesLink, ...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));
|
|
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 (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();
|
|
});
|