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.
This commit is contained in:
Vito Sansevero
2026-10-21 10:00:06 -07:00
parent 8dd22f9347
commit 1b7f50e885
18 changed files with 1392 additions and 51 deletions
+55 -4
View File
@@ -430,10 +430,11 @@ the first nav link and heading button.
Recorded shell deviations:
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
palette, then the OS.
- **D5**: no Activity button or Activity row until an activity center exists.
- **D5**: retired by Activity (#249 Activity-1). The concept's Activity button and
Activity row are shown; their differences are D52 to D54.
- **D28**: no COLLECTIONS, and no Design review. The bottom nav is
System plus the production Overview hub (its own outline home glyph, not the concept's
`activity` pulse, and no count).
the concept's Activity and System, plus the production Overview hub (its own
outline home glyph, not the concept's `activity` pulse, and no count).
The connection reads "Your library, on your server" with a neutral muted dot.
- **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED"
badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are
@@ -484,6 +485,55 @@ Recorded shell deviations:
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
route, star updates, Save view on Favorites, Edit, and the failure paths;
`tests/test_image_favorites.py` covers `favorites=1`.
- **D52** (Activity, #249 Activity-1): the top-bar `button#activity-open` and the
sidebar's Activity row (the concept's `activity` pulse, first in `system-nav`) count
this session's *running* jobs. The concept counts
every sample job. `span.count#activity-count` is `hidden` at 0 (its text stays "0"),
and the row has no `.nav-count` then. The Activity heading has no "Unified activity ·
proposed" chip (prototype copy). At `max-width: 700px` the full-width `.top-actions`
row may wrap (`flex-wrap: wrap`), so the button, Theme and ComfyUI stay inside a
zoomed phone. The shell comparison hides the concept's count at runtime
(`#activity-count { display: none }`), so the concept's own CSS lays out its button
without it, and maps the row's count to none, the button's count to "0" and the
phone row's `flexWrap` to `wrap`.
- **D53**: while a job runs, the approved Activity mockup's progress pill sits before
the Activity button on every page: `a#activity-pill.activity-pill`, "Scanning · 41% ·
8,240 / 19,622" (the verb by job kind, the server's floored percentage, and the file
counts once the total is known), a 70px mini bar, `href="#/activity/<id>"` and the same
text as its `aria-label`. With no running job it is `hidden`, which the shell
comparison maps as a hidden item before the button. At `max-width: 700px` it keeps the
verb and percentage only; on coarse pointers it is 40px tall like the concept's
buttons. Its rules are in `workspace.css` under D53.
- **D54**: each `.activity-item` is a real job of this session (`GET
/prompt_manager/jobs`), running first, then finished newest first. The concept's
structure and CSS stay: `.panel-heading` > `h3` "✓ <title>" for a completed job and
"○ <title>" otherwise, plus `span.chip`, then `p`, and for a running job `<progress
max=100>` ("<title> progress") and `.row-actions`. The chip is green "Completed" or
blue "Running", "Cancelled", "Failed" or "Ended". The line reads "Started HH:MM · K
output folders · closing this view does not cancel it" while running, "Finished HH:MM
· N added · M errors", "Cancelled HH:MM at a file boundary · N added · completed work
is kept", "Stopped on an error at HH:MM · N added · M errors", or "This job ended when
ComfyUI restarted." The running actions are "Cancel job" only (no "Simulate
completion"); a failed cancel toasts "The job could not be cancelled. Try again.". With
no job the section says "No jobs have run since ComfyUI started. Scans and other long
operations appear here." `#/activity/<id>` shows the list with that job's item marked
`aria-current="true"` until the job view (Activity-1 Task 3).
The Activity store reads the list once, then follows each running job over
`/prompt_manager/jobs/<id>/events`. A dropped stream re-reads the list after 1 s,
doubling to at most 30 s; a received event resets the wait. A running job the server
no longer lists, or one whose server cannot be read, is shown as ended, so no pill
stays. While the tab is visible the list is read again every 15 s (and whenever
Activity opens), so a job started in another tab or on the legacy admin page appears
without a reload. Toasts: "Scan started." after this tab's own start; "Scan finished
· N added · M errors", "Scan cancelled · N added" or "Scan failed · N added · M
errors" when a job this tab saw running ends (plural helper for "error").
`tests/js/workspace-activity-store.test.js` covers the store, its backoff and the
copy; `tests/browser/workspace-activity.spec.mjs` covers the list against the
concept's own items (`renderActivity` and `startJob`), the pill and counts, a phone,
reload and a second tab, Cancel job, a restart and an unreachable server, and the
toasts, with fake jobs from `tests/browser/workspace_activity_fixture.py`
(`/__test__/jobs/*`, including a simulated restart that drops the job streams).
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
library.", and the title has no " concept" suffix. Save view's dialog action reads
@@ -570,7 +620,8 @@ Recorded deviations on this page (spec §8):
footer, masonry tops may differ by sub-pixel snapping (at most one layout unit per
card above in the column), and a List row's info takes its natural height, centred
beside the 160×95 art.
- **D5**, **D29**: no Activity button and no prototype copy (shell).
- **D29**: no prototype copy (shell). The Activity button and pill are the shell's
(D52, D53).
- **D7**: the star is a real persisted favorite with "Added to favorites." /
"Removed from favorites." and a rollback toast on failure.
- **D8**: every Images card has the concept's select checkbox before the star; the
+1 -1
View File
@@ -7,7 +7,7 @@ import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
// columns, the 275px and 310px panel). The topbar and sidebar (D5, D28, D29) are the shell spec's.
// columns, the 275px and 310px panel). The topbar and sidebar (D28, D29, D52, D53) are the shell spec's.
// Paired captures are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
+2
View File
@@ -14,6 +14,7 @@ from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_activity_fixture import register_activity_commands
from tests.browser.workspace_concept_fixture import (
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
@@ -159,6 +160,7 @@ def create_app(root: Path) -> web.Application:
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
register_activity_commands(app, routes, api)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
+24 -9
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept as openConceptPage,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
// Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the
// concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes,
@@ -16,7 +16,16 @@ const FULL_HEADING=new Set(['tags']);
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity'));
const removedItem=item=>String(item.className).includes('concept-badge');
/**
* The concept with its Activity count hidden (D52): production counts running jobs only and hides the count at 0, as
* the comparison fixture has none. The concept's own CSS then lays out its button without the count.
*/
async function openConcept(page,route,theme,motion) {
await openConceptPage(page,route,theme,motion);
await mutate(page,{css:'#activity-count { display: none }'});
}
/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */
function packedLefts(items,width,justify,gap) {
@@ -37,10 +46,12 @@ function remainingTopActions(actions) {
const gap=parseFloat(actions.columnGap);
// A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items.
const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1);
// At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with
// Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes.
// At <=700px the concept spreads its shown items with `space-between`; the hidden pill (D53) takes no place.
// Offsets come from the concept's own sizes.
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
return {...actions,height,width,
// D52: the full-width row (<=700px) wraps its items when a zoomed phone is narrower than they are.
flexWrap:actions.fullWidth?'wrap':actions.flexWrap,
items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)};
}
@@ -53,7 +64,7 @@ function remainingFooter(footer,spans) {
}
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
// its `activity` pulse belongs to the Activity row, so the two entries never share one glyph.
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
/**
@@ -76,7 +87,9 @@ function mapShellConcept(reference) {
if(nav.id!=='system-nav')return nav;
const system=rowNamed(nav,'System');
return {...nav,rows:[
// D5: no Activity row until an activity center exists. D28: no Design review (prototype page).
// D52: the Activity row counts running jobs only, so with none it has no count.
{...rowNamed(nav,'Activity'),count:null},
// D28: no Design review (prototype page).
system,
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
@@ -95,9 +108,11 @@ function mapShellConcept(reference) {
small:'Your library, on your server',
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
// D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items'
// centered offsets follow from the concept's own item sizes.
topActions:remainingTopActions(chrome.topActions),
// D29: no "Interactive concept" badge. D53: the progress pill before the Activity button, hidden with no running
// job. D52: the button's count is hidden at 0. The row's size and the remaining items' centered offsets follow
// from the concept's own item sizes.
topActions:remainingTopActions({...chrome.topActions,items:chrome.topActions.items.flatMap(item=>
item.tag==='button'&&item.text==='Activity'?[{tag:'a',className:'activity-pill',shown:false},{...item,count:'0'}]:[item])}),
footer:(()=>{
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
// (a prototype review page); the footer then holds only its span.
+312
View File
@@ -0,0 +1,312 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
// (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress
// pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py.
const workspace = '/prompt_manager/workspace';
const main = page => page.locator('#workspace-main');
const pill = page => page.locator('.top-actions #activity-pill');
const activityButton = page => page.locator('.top-actions button#activity-open');
const topCount = page => page.locator('#activity-count');
const navRow = page => page.locator('#system-nav a[data-route="activity"]');
const toast = page => page.locator('#workspace-toast');
const items = page => main(page).locator('section.surface > .activity-item');
const LOST = 'This job ended when ComfyUI restarted.';
async function startJob(request, data = {}) {
const response = await request.post('/__test__/jobs/start', { data: { total: 19622, ...data } });
await expect(response).toBeOK();
return (await response.json()).job;
}
async function step(request, id, data) {
await expect(await request.post(`/__test__/jobs/${id}/step`, { data })).toBeOK();
}
async function finish(request, id, state = 'finished') {
const response = await request.post(`/__test__/jobs/${id}/finish`, { data: { state } });
await expect(response).toBeOK();
return (await response.json()).job;
}
async function listed(request) {
const response = await request.get('/prompt_manager/jobs');
await expect(response).toBeOK();
return (await response.json()).jobs;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(await request.post('/__test__/reset')).toBeOK();
page.activityErrors = [];
page.on('pageerror', error => page.activityErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
expect(page.activityErrors).toEqual([]);
await request.post('/__test__/jobs/restart');
});
/** The concept's item structure and the computed styles its CSS gives it, measured the same way in both documents. */
function measureItems(selector) {
return [...document.querySelectorAll(selector)].map(item => {
const css = (node, ...keys) => node && Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
const tree = node => ({ tag: node.localName, className: [...node.classList].sort().join(' '), children: [...node.children].map(tree) });
const progress = item.querySelector('progress');
return {
tree: tree(item),
title: item.querySelector('h3').textContent,
chip: item.querySelector('.chip').textContent,
buttons: [...item.querySelectorAll('.row-actions button')].map(button => button.textContent),
styles: {
item: css(item, 'paddingTop', 'paddingBottom', 'borderBottomWidth', 'borderBottomStyle', 'borderBottomColor'),
heading: css(item.querySelector('.panel-heading'), 'display', 'justifyContent', 'alignItems', 'columnGap'),
h3: css(item.querySelector('h3'), 'fontSize', 'fontWeight', 'color', 'marginTop', 'marginBottom'),
chip: css(item.querySelector('.chip'), 'fontSize', 'color', 'backgroundColor', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius'),
p: css(item.querySelector('p'), 'fontSize', 'color', 'marginTop', 'lineHeight'),
progress: progress && { ...css(progress, 'height', 'marginTop', 'marginBottom', 'accentColor'),
fills: Math.abs(progress.getBoundingClientRect().width - item.getBoundingClientRect().width) < 1,
max: progress.max, label: Boolean(progress.getAttribute('aria-label')) },
actions: css(item.querySelector('.row-actions'), 'display', 'columnGap', 'marginTop'),
button: css(item.querySelector('.row-actions button'), 'fontSize', 'paddingTop', 'paddingLeft', 'minHeight', 'borderTopLeftRadius'),
},
};
});
}
function measurePage(mainSelector) {
const root = document.querySelector(mainSelector);
const section = root.querySelector(':scope > section.surface');
return {
h1: root.querySelector('.heading h1').textContent,
description: root.querySelector('.heading p.muted').textContent,
actions: [...root.querySelector('.heading .heading-actions').children].map(node => node.textContent),
h2: section.querySelector(':scope > h2').textContent,
surface: Object.fromEntries(['paddingTop', 'borderTopWidth', 'backgroundColor', 'borderTopLeftRadius']
.map(key => [key, getComputedStyle(section)[key]])),
};
}
for (const theme of ['dark', 'light']) test(`activity page matches the concept list markup ${theme}`, async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await seedConcept(request);
// The concept: its finished sample, then a running job started from System (startJob, app.js 940–952).
await openConcept(page, 'activity', theme);
const conceptPage = await page.evaluate(measurePage, '#main');
const [conceptDone] = await page.evaluate(measureItems, '#main .activity-item');
await page.goto('/__test__/canonical/workspace#system');
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await page.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
await expect(page.locator('#main .activity-item progress')).toHaveCount(1);
const [conceptRunning] = await page.evaluate(measureItems, '#main .activity-item');
const done = await startJob(request);
await step(request, done.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
await finish(request, done.id);
const running = await startJob(request);
await step(request, running.id, { processed: 8240 });
await openProduction(page, 'activity', theme);
await expect(items(page)).toHaveCount(2);
const productionPage = await page.evaluate(measurePage, '#workspace-main');
const [productionRunning, productionDone] = await page.evaluate(measureItems, '#workspace-main .activity-item');
// D52: no "Unified activity · proposed" chip (prototype copy); the rest of the heading and the section are the concept's.
expect(conceptPage.actions).toEqual(['Unified activity · proposed']);
expect(productionPage).toEqual({ ...conceptPage, actions: [] });
// A completed job is the concept's completed item.
expect(conceptDone.title).toBe('✓ Library metadata enriched');
expect(productionDone).toEqual({ ...conceptDone, title: '✓ Scan for metadata' });
// D54: a running job's chip names its state ("Running", where the concept's sample reads "Needs attention"), and
// its actions are Cancel job only (no "Simulate completion").
expect(conceptRunning.buttons).toEqual(['Simulate completion', 'Cancel job']);
const [, ...cancelOnly] = conceptRunning.tree.children.at(-1).children;
const conceptActions = { ...conceptRunning.tree.children.at(-1), children: cancelOnly };
expect(productionRunning).toEqual({ ...conceptRunning,
tree: { ...conceptRunning.tree, children: [...conceptRunning.tree.children.slice(0, -1), conceptActions] },
title: '○ Scan for metadata', chip: 'Running', buttons: ['Cancel job'] });
await expect(items(page).nth(1).locator('p')).toHaveText(/^Finished \d\d:\d\d · 1,420 added · 18 errors$/);
await expect(items(page).first().locator('p')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/);
await expect(items(page).first().locator('progress')).toHaveAttribute('aria-label', 'Scan for metadata progress');
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(41);
});
test('pill shows while a job runs and opens its view', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
// D52: no running job, so neither count shows; the button is the concept's.
await expect(activityButton(page)).toHaveAccessibleName('Activity');
await expect(topCount(page)).toBeHidden();
await expect(navRow(page)).toHaveText('Activity');
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
await expect(pill(page)).toBeHidden();
const job = await startJob(request);
// The list is read again on its own (15 s), or at once on a reload or when Activity opens.
await page.reload();
await expect(pill(page)).toBeVisible();
await expect(pill(page)).toHaveText('Scanning · 0% · 0 / 19,622');
await step(request, job.id, { processed: 8240 });
const text = 'Scanning · 41% · 8,240 / 19,622';
await expect(pill(page)).toHaveText(text);
await expect(pill(page)).toHaveAttribute('aria-label', text);
await expect(pill(page)).toHaveAttribute('href', `#/activity/${job.id}`);
expect(await pill(page).locator('.activity-pill-bar i').evaluate(node => node.style.width)).toBe('41%');
// The pill sits before the button, in the mockup's place, and both counts read 1.
expect(await page.locator('.top-actions > *').evaluateAll(nodes => nodes.map(node => node.id || node.className)))
.toEqual(['activity-pill', 'activity-open', 'theme-label', 'canvas-link']);
await expect(topCount(page)).toBeVisible();
await expect(topCount(page)).toHaveText('1');
await expect(navRow(page).locator('.nav-count')).toHaveText('1');
await pill(page).click();
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Activity');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Activity');
await expect(page).toHaveTitle('Activity · Prompt Manager');
await expect(navRow(page)).toHaveAttribute('aria-current', 'page');
await expect(items(page).first()).toHaveAttribute('aria-current', 'true');
await expect(items(page).first().locator('h3')).toHaveText('○ Scan for metadata');
// The Activity button opens the list itself from any page.
await page.locator('nav [data-route="library"]').click();
await activityButton(page).click();
await expect(page).toHaveURL(/#\/activity$/);
await expect(items(page)).toHaveCount(1);
await finish(request, job.id);
await expect(pill(page)).toBeHidden();
await expect(topCount(page)).toBeHidden();
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
await expect(items(page).first().locator('.chip.green')).toHaveText('Completed');
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
});
test('a phone keeps the pill and its controls inside the topbar', async ({ page, request }) => {
await page.setViewportSize({ width: 390, height: 844 });
const job = await startJob(request);
await step(request, job.id, { processed: 8240 });
await page.goto(`${workspace}#/images`);
await expect(pill(page)).toHaveText('Scanning · 41% · 8,240 / 19,622');
await expect(pill(page)).toHaveAttribute('aria-label', 'Scanning · 41% · 8,240 / 19,622');
await expect(pill(page).locator('.activity-pill-files, .activity-pill-bar')).toHaveCount(2);
await expect(pill(page).locator('.activity-pill-files')).toBeHidden();
const layout = await page.locator('.top-actions').evaluate(row => ({
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
inside: [...row.children].filter(node => node.getBoundingClientRect().width)
.every(node => node.getBoundingClientRect().right <= row.getBoundingClientRect().right + 0.5
&& node.getBoundingClientRect().left >= row.getBoundingClientRect().left - 0.5),
}));
expect(layout).toEqual({ scroll: true, inside: true });
});
test('reload during a scan reattaches to the running job', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 1000 });
await page.goto(`${workspace}#/activity`);
await expect(items(page)).toHaveCount(1);
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
await page.reload();
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
// The reloaded page follows the same job live and started nothing.
await step(request, job.id, { processed: 9811 });
await expect(pill(page)).toHaveText('Scanning · 50% · 9,811 / 19,622');
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(50);
await expect(items(page)).toHaveCount(1);
expect((await listed(request)).map(item => [item.id, item.state])).toEqual([[job.id, 'running']]);
// A second tab attaches to the same job and sees the same progress.
const other = await page.context().newPage();
await other.goto(`${workspace}#/activity`);
await expect(pill(other)).toHaveText('Scanning · 50% · 9,811 / 19,622');
await expect(other.locator('#workspace-main .activity-item')).toHaveCount(1);
await other.close();
expect((await listed(request)).length).toBe(1);
// Cancel job asks the server to stop at a file boundary; the job ends cancelled with its partial counts.
await step(request, job.id, { counters: { added: 7 } });
await items(page).first().getByRole('button', { name: 'Cancel job', exact: true }).click();
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
await expect(items(page).first().locator('.chip.blue')).toHaveText('Cancelled');
await expect(items(page).first().locator('p')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
await expect(pill(page)).toBeHidden();
});
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 4000 });
await page.goto(`${workspace}#/activity`);
await expect(pill(page)).toBeVisible();
await expect(topCount(page)).toHaveText('1');
const reads = [];
page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs') reads.push(Date.now()); });
// ComfyUI restarts: the stream drops without a final event and the new registry is empty.
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(pill(page)).toBeHidden();
await expect(topCount(page)).toBeHidden();
await expect(items(page).first().locator('.chip.blue')).toHaveText('Ended');
await expect(items(page).first().locator('p')).toHaveText(LOST);
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished.
await page.waitForTimeout(2500);
expect(reads.length).toBeLessThanOrEqual(2);
await expect(toast(page)).toHaveText('');
// A server that cannot be read at all also ends the running job, then is read again with a growing backoff.
const second = await startJob(request);
await page.reload();
await expect(pill(page)).toBeVisible();
const failed = [];
await page.route('**/prompt_manager/jobs**', route => { failed.push(Date.now()); return route.abort(); });
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(pill(page)).toBeHidden();
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
await page.waitForTimeout(3500);
// 1 s, then 2 s: at most two re-reads in 3.5 s.
expect(failed.length).toBeLessThanOrEqual(2);
await page.unroute('**/prompt_manager/jobs**');
expect(second.id).not.toBe(job.id);
});
test('start and finish toasts', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await page.goto(`${workspace}#/images`);
await expect(pill(page)).toBeVisible();
await step(request, job.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
await finish(request, job.id);
await expect(toast(page)).toHaveText('Scan finished · 1,420 added · 18 errors');
await expect(pill(page)).toBeHidden();
const single = await startJob(request);
await page.reload();
await expect(pill(page)).toBeVisible();
await step(request, single.id, { processed: 19622, counters: { added: 1, errors: 1 } });
await finish(request, single.id, 'failed');
await expect(toast(page)).toHaveText('Scan failed · 1 added · 1 error');
// The store's own start: the real metadata scan over this server's outputs, announced, followed and finished.
await page.goto(`${workspace}#/activity`);
const started = await page.evaluate(async () => {
const { createActivityStore } = await import('/prompt_manager/js/workspace/activity-store.mjs');
const controller = new AbortController();
const store = createActivityStore({ api: window.ApiClient.createApiClient(), pollMs: 0,
scope: { signal: controller.signal, cleanup: () => {} } });
const result = await store.start('scan');
const busy = await store.start('scan').catch(error => ({ error: error.message }));
return { id: result.job.id, title: result.job.title, toast: document.getElementById('workspace-toast').textContent,
busy: busy.busy?.id ?? null, message: busy.message ?? null };
});
expect(started.title).toBe('Scan for metadata');
expect(started.toast).toBe('Scan started.');
// A second start while the scan runs is refused and names it (unless the small fixture scan already ended).
if (started.busy !== null) expect(started.busy).toBe(started.id);
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
await expect.poll(async () => (await listed(request)).find(item => item.id === started.id)?.state, { timeout: 15000 }).toBe('finished');
});
+4 -3
View File
@@ -8,7 +8,7 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
const primary = ['images', 'library', 'tags', 'inspect'];
const tools = ['system', 'overview'];
const tools = ['activity', 'system', 'overview'];
async function noOverflow(page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
@@ -556,7 +556,7 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
await page.goto(`${workspace}#/images`);
const icons = await page.evaluate(async () => {
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
const names = ['images', 'library', 'tags', 'upload', 'overview', 'activity', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
const otherDocument = document.implementation.createHTMLDocument();
const rendered = names.map(name => {
const icon = createWorkspaceIcon(name, otherDocument);
@@ -575,7 +575,8 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
expect(icon).toMatchObject({ svg: true, className: 'icon', owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
expect(icon.path.length).toBeGreaterThan(10);
}
// D28 ruling: Overview's own outline home glyph, not the concept's `activity` pulse ('M3 12h4l3-8 4 16 3-8h4').
// D28 ruling: Overview's own outline home glyph; the concept's `activity` pulse is the Activity row's.
expect(icons.rendered.find(icon => icon.name === 'activity').path).toBe('M3 12h4l3-8 4 16 3-8h4');
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6');
expect(new Set(icons.rendered.map(icon => icon.path)).size).toBe(icons.rendered.length);
});
+19 -10
View File
@@ -20,6 +20,8 @@ const routes = [
['system', 'System', '/prompt_manager/admin'],
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
['favorites', 'Favorites', null],
// #249 Activity-1: the concept's Activity page in the tools-and-system group.
['activity', 'Activity', null],
];
async function countThemeWrites(page) {
@@ -176,7 +178,8 @@ test('theme System follows ComfyUI and persists an explicit light or dark prefer
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
});
// Task A4 (D4, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity.
// Task A4 (D4, D29): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge; Activity
// (D52, D53) shows its button, and its pill only while a job runs.
test('topbar matches the concept actions', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
@@ -204,7 +207,7 @@ test('topbar matches the concept actions', async ({ page }) => {
expect(shape).toEqual({
children: ['div.breadcrumbs', 'div.top-actions'],
crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'],
actions: ['label.theme-label', 'a.canvas-link'],
actions: ['a.activity-pill', 'button.', 'label.theme-label', 'a.canvas-link'],
label: 'Theme', labelFor: 'theme',
options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']],
arrow: [['↗', 'true']],
@@ -218,11 +221,12 @@ test('topbar matches the concept actions', async ({ page }) => {
await expect(link).toHaveAttribute('href', '/');
expect(await link.evaluate(node => node.href === `${location.origin}/`)).toBe(true);
await expect(page.getByRole('combobox', { name: 'Appearance' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Activity/ })).toHaveCount(0);
await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Activity', exact: true })).toHaveCount(1);
await expect(page.locator('#activity-pill')).toBeHidden();
await expect(page.locator('.concept-badge')).toHaveCount(0);
await page.locator('nav [data-route="library"]').click();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
await expect(topbar.locator('.top-actions > *')).toHaveCount(2);
await expect(topbar.locator('.top-actions > *')).toHaveCount(4);
});
// Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme
@@ -242,13 +246,18 @@ test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) =>
expected: bar.width - 28,
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
select: getComputedStyle(node.querySelector('#theme')).fontSize,
labelLeft: box(node.querySelector('.theme-label')).left - bar.left,
activityLeft: box(node.querySelector('#activity-open')).left - bar.left,
actionsGap: [box(node.querySelector('#activity-open')).right, box(node.querySelector('.theme-label')).left,
box(node.querySelector('.theme-label')).right, box(node.querySelector('.canvas-link')).left],
linkRight: bar.right - box(node.querySelector('.canvas-link')).right,
};
});
expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' });
expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1);
expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1);
// The concept's three items spread with space-between: Activity first, Theme between equal gaps, ComfyUI last.
expect(Math.abs(layout.activityLeft - 14)).toBeLessThanOrEqual(1);
const [activityRight, themeLeft, themeRight, linkLeft] = layout.actionsGap;
expect(Math.abs((themeLeft - activityRight) - (linkLeft - themeRight))).toBeLessThanOrEqual(1);
expect(Math.abs(layout.linkRight - 14)).toBeLessThanOrEqual(1);
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toBeVisible();
await theme.selectOption('light');
@@ -506,18 +515,18 @@ test('a slow Images count does not delay the Library and Tags badges', async ({
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
});
test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => {
test('sidebar bottom holds Activity, System and Overview with a neutral connection', async ({ page }) => {
await page.goto(`${workspace}#/system`);
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
const system = page.locator('nav#system-nav');
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
['#/system', 'System'], ['#/overview', 'Overview'],
['#/activity', 'Activity'], ['#/system', 'System'], ['#/overview', 'Overview'],
]);
await expect(system.locator('.nav-count')).toHaveCount(0);
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
await expect(system.locator('svg.icon')).toHaveCount(2);
await expect(system.locator('svg.icon')).toHaveCount(3);
const connection = bottom.locator('.connection');
expect(await childShape(connection)).toEqual(['i', 'div']);
await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server');
+141
View File
@@ -0,0 +1,141 @@
"""Fake Activity jobs for the browser tests (#249 Activity-1).
A fake job is a real registry job whose runner waits for commands instead of
reading files, so a test sets its progress and counters exactly:
- ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``,
``output_folders``) and returns its summary, or 409 while one runs;
- ``POST /__test__/jobs/{id}/step`` sets ``processed`` (and ``total``) and
merges ``counters``, then publishes the progress to every subscriber;
- ``POST /__test__/jobs/{id}/finish`` ends it as ``finished``, ``cancelled``
or ``failed``; a cancel through the real route ends it ``cancelled``;
- ``POST /__test__/jobs/restart`` acts out a ComfyUI restart: the open job
streams drop without a final event and the registry starts empty.
"""
import asyncio
import re
from aiohttp import web
EVENTS_PATH = re.compile(r"^/prompt_manager/jobs/[0-9a-f]{32}/events$")
FINAL_STATES = {"finished", "cancelled", "failed"}
POLL_SECONDS = 0.02
def register_activity_commands(app, routes, api):
from py.api.jobs import SCAN_GROUP_NAMES, JobBusy, JobRegistry
streams = set()
endings = {}
@web.middleware
async def track_streams(request, handler):
if not EVENTS_PATH.match(request.path):
return await handler(request)
transport = request.transport
streams.add(transport)
try:
return await handler(request)
finally:
streams.discard(transport)
app.middlewares.append(track_streams)
def fake_runner(ending):
async def run(job):
while not ending.done() and not job.cancelled:
await asyncio.sleep(POLL_SECONDS)
state = ending.result() if ending.done() else "cancelled"
job.publish({"type": "complete", "progress": job.progress})
job.finish(state)
return run
def job_or_404(request):
job = api._jobs.get(request.match_info["job_id"])
if job is None or job.id not in endings:
raise web.HTTPNotFound()
return job
async def body(request):
data = await request.json() if request.can_read_body else {}
if not isinstance(data, dict):
raise web.HTTPBadRequest()
return data
def count(value, default=0):
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise web.HTTPBadRequest()
return value
@routes.post("/__test__/jobs/start")
async def start_job(request):
data = await body(request)
ending = asyncio.get_running_loop().create_future()
try:
job = api._jobs.start(
str(data.get("kind", "scan")),
str(data.get("title", "Scan for metadata")),
record_changing=True,
scope={
"mode": "all",
"output_folders": count(data.get("output_folders"), 2),
},
runner=fake_runner(ending),
groups=SCAN_GROUP_NAMES,
)
except JobBusy:
raise web.HTTPConflict()
job.update(total=count(data.get("total")))
endings[job.id] = ending
return web.json_response({"success": True, "job": job.summary()})
@routes.post("/__test__/jobs/{job_id}/step")
async def step_job(request):
job = job_or_404(request)
data = await body(request)
total = count(data.get("total"), job.total)
processed = count(data.get("processed"), job.processed)
counters = data.get("counters", {})
if not isinstance(counters, dict) or set(counters) - set(job.counters):
raise web.HTTPBadRequest()
for key, value in counters.items():
job.counters[key] = count(value)
progress = processed * 100 // total if total else 0
job.update(
progress=progress,
processed=processed,
total=total,
status_line=f"Processing file {processed:,} of {total:,}",
)
job.publish({"type": "progress", "progress": progress})
return web.json_response({"success": True, "job": job.summary()})
@routes.post("/__test__/jobs/{job_id}/finish")
async def finish_job(request):
job = job_or_404(request)
state = (await body(request)).get("state", "finished")
if state not in FINAL_STATES:
raise web.HTTPBadRequest()
ending = endings[job.id]
if not ending.done():
ending.set_result(state)
while job.running:
await asyncio.sleep(POLL_SECONDS)
return web.json_response({"success": True, "job": job.summary()})
@routes.post("/__test__/jobs/restart")
async def restart(request):
old = api._jobs
api._jobs = JobRegistry(api.logger)
endings.clear()
for transport in list(streams):
transport.close()
tasks = [job.task for job in old.list() if job.task and not job.task.done()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
return web.json_response({"success": True})
+283
View File
@@ -0,0 +1,283 @@
// Activity (#249 Activity-1): the session job store behind the top-bar button and pill and the Activity page.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/activity-store.mjs');
const ID_A = 'a'.repeat(32);
const ID_B = 'b'.repeat(32);
const ID_C = 'c'.repeat(32);
function summary(overrides = {}) {
return { id: ID_A, kind: 'scan', title: 'Scan for metadata', state: 'running', started_at: 1000, finished_at: null,
progress: 0, status_line: '', processed: 0, total: 0,
counters: { added: 0, known: 0, review: 0, no_metadata: 0, errors: 0 }, scope: { mode: 'all', output_folders: 2 },
...overrides };
}
function apiError(status, message, code) {
return Object.assign(new Error(message), { status, code });
}
/** A fake world: the API's answers, every opened stream, the pending timers and the toasts. */
function harness() {
const world = { list: [], listError: null, posts: [], postResult: null, postError: null, streams: [], timers: [],
toasts: [], gets: 0 };
const cleanups = [];
const controller = new AbortController();
const scope = { signal: controller.signal, cleanup: fn => cleanups.push(fn) };
const api = {
async get(url, options) {
assert.equal(url, '/prompt_manager/jobs');
assert.equal(options.cache, 'no-store');
world.gets += 1;
if (world.listError) throw world.listError;
return { success: true, jobs: world.list };
},
async post(url, body) {
world.posts.push([url, body]);
if (world.postError) throw world.postError;
return world.postResult;
},
};
const connect = url => {
const stream = { url, closed: false, onmessage: null, onerror: null, close() { this.closed = true; } };
world.streams.push(stream);
return stream;
};
const timers = {
setTimeout(fn, delay) { const timer = { fn, delay, cleared: false }; world.timers.push(timer); return timer; },
clearTimeout(timer) { timer.cleared = true; },
};
world.dispose = () => { controller.abort(); cleanups.forEach(fn => fn()); };
world.fire = async () => {
const timer = world.timers.find(item => !item.cleared && !item.fired);
timer.fired = true;
timer.fn();
await new Promise(resolve => setImmediate(resolve));
return timer.delay;
};
world.open = () => world.streams.filter(stream => !stream.closed);
return { world, api, scope, connect, timers, pollMs: 0, notify: message => world.toasts.push(message) };
}
const send = (stream, job) => stream.onmessage({ data: JSON.stringify(job) });
test('store merges list and SSE updates and caps backoff', async () => {
const { createActivityStore, BACKOFF_MAX_MS, LOST_DETAIL } = await source;
const { world, ...deps } = harness();
world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900, counters: { added: 3, errors: 0 } })];
const store = createActivityStore(deps);
let changes = 0;
store.subscribe(() => { changes += 1; });
await store.load();
assert.equal(store.status, 'ready');
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_A, 'running'], [ID_B, 'finished']]);
assert.deepEqual(store.running().map(job => job.id), [ID_A]);
// One stream per running job only; a finished job is not followed.
assert.deepEqual(world.streams.map(stream => stream.url), [`/prompt_manager/jobs/${ID_A}/events`]);
assert.ok(changes >= 1);
// An SSE summary replaces the listed one; garbage and another job's summary are ignored.
const [stream] = world.streams;
send(stream, summary({ progress: 42, processed: 8240, total: 19622 }));
stream.onmessage({ data: 'not json' });
send(stream, summary({ id: ID_B, progress: 99 }));
assert.equal(store.find(ID_A).progress, 42);
assert.equal(store.find(ID_B).state, 'finished');
assert.deepEqual(world.toasts, []);
// The end of the job: the finish toast, and the stream is closed.
send(stream, summary({ state: 'finished', progress: 100, finished_at: 2000, counters: { added: 1420, errors: 18 } }));
assert.deepEqual(world.toasts, ['Scan finished · 1,420 added · 18 errors']);
assert.equal(stream.closed, true);
assert.deepEqual(store.running(), []);
// A new running job from another tab, then the server goes away: every re-read waits longer, up to 30 s.
world.list = [summary({ id: ID_C, state: 'running' })];
await store.load();
const second = world.open()[0];
assert.equal(second.url, `/prompt_manager/jobs/${ID_C}/events`);
world.listError = new Error('connection refused');
second.onerror();
assert.equal(second.closed, true);
const delays = [];
for (let index = 0; index < 8; index += 1) delays.push(await world.fire());
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
assert.ok(delays.every(delay => delay <= BACKOFF_MAX_MS));
// While the server cannot be read the running job shows as ended, so no pill stays.
assert.equal(store.status, 'error');
assert.equal(store.find(ID_C).state, 'lost');
assert.deepEqual(store.running(), []);
assert.equal(typeof LOST_DETAIL, 'string');
// The server is back without the job (a restart): it stays ended, earlier finished jobs stay listed.
world.listError = null;
world.list = [];
await world.fire();
assert.equal(store.status, 'ready');
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'lost'], [ID_A, 'finished'], [ID_B, 'finished']]);
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 0, 'no retry after a good read');
assert.equal(world.toasts.length, 1, 'an ended job has no finish toast');
world.dispose();
});
test('a dropped stream of a still running job reattaches after the backoff, then resets it', async () => {
const { createActivityStore } = await source;
const { world, ...deps } = harness();
world.list = [summary()];
const store = createActivityStore(deps);
await store.load();
world.streams[0].onerror();
assert.equal(await world.fire(), 1000);
const again = world.open();
assert.equal(again.length, 1);
assert.equal(store.find(ID_A).state, 'running');
again[0].onerror();
assert.equal(await world.fire(), 2000);
send(world.open()[0], summary({ progress: 5 }));
world.open()[0].onerror();
assert.equal(await world.fire(), 1000, 'a received message resets the backoff');
// A second drop while a re-read is already scheduled does not schedule another.
world.open()[0].onerror();
store.load();
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1);
await new Promise(resolve => setImmediate(resolve));
world.dispose();
assert.ok(world.streams.every(stream => stream.closed), 'dispose closes every stream');
assert.ok(world.timers.every(timer => timer.cleared || timer.fired), 'dispose clears the pending re-read');
});
test('start returns busy with the running job', async () => {
const { createActivityStore } = await source;
const { world, ...deps } = harness();
const store = createActivityStore(deps);
world.postResult = { success: true, job: summary() };
const started = await store.start('scan');
assert.equal(started.job.id, ID_A);
assert.deepEqual(world.posts, [['/prompt_manager/jobs/scan', {}]]);
assert.deepEqual(world.toasts, ['Scan started.']);
assert.equal(world.open().length, 1, 'the started job is followed');
world.postError = apiError(409, 'Scan for metadata is already running', 'busy');
world.list = [summary()];
const busy = await store.start('scan');
assert.equal(busy.busy.id, ID_A);
assert.equal(busy.busy.title, 'Scan for metadata');
assert.equal(busy.message, 'Scan for metadata is already running');
assert.equal(world.toasts.length, 1, 'a refused start has no start toast');
world.postError = apiError(500, 'boom', null);
await assert.rejects(store.start('scan'), /boom/);
world.postError = null;
world.postResult = { success: true, job: { id: 'nope' } };
await assert.rejects(store.start('scan'), /Invalid job/);
world.postResult = { success: true, cancellation_requested: true };
assert.equal(await store.cancel(ID_A), true);
assert.deepEqual(world.posts.at(-1), [`/prompt_manager/jobs/${ID_A}/cancel`, {}]);
world.postResult = { success: true, cancellation_requested: false };
assert.equal(await store.cancel(ID_A), false);
world.dispose();
});
test('a list that is not a job list counts as a failed read', async () => {
const { createActivityStore } = await source;
const { world, api, ...deps } = harness();
const store = createActivityStore({ ...deps, api: { ...api, get: async () => ({ success: true }) } });
await store.load();
assert.equal(store.status, 'error');
assert.equal(world.timers.length, 1);
world.dispose();
await store.load();
assert.equal(store.status, 'error', 'a disposed store reads nothing');
});
test('a visible tab reads the list again every 15 s; a hidden one skips its turn', async () => {
const { createActivityStore, POLL_MS } = await source;
const { world, ...deps } = harness();
let visible = true;
const store = createActivityStore({ ...deps, pollMs: POLL_MS, isVisible: () => visible });
await store.load();
assert.equal(world.gets, 1);
// A job started in another tab appears on the next read.
world.list = [summary()];
assert.equal(await world.fire(), 15000);
assert.equal(world.gets, 2);
assert.deepEqual(store.running().map(job => job.id), [ID_A]);
visible = false;
assert.equal(await world.fire(), 15000);
assert.equal(world.gets, 2, 'a hidden tab does not read');
visible = true;
await world.fire();
assert.equal(world.gets, 3);
// A manual read replaces the pending poll rather than adding one.
await store.load();
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1);
world.dispose();
assert.ok(world.timers.every(timer => timer.cleared || timer.fired));
});
test('a late or older answer never brings an ended job back, so it is announced once', async () => {
const { createActivityStore } = await source;
const { world, api, ...deps } = harness();
const answers = [];
const slowApi = { ...api, get: () => new Promise(resolve => answers.push(resolve)) };
const store = createActivityStore({ ...deps, api: slowApi });
const first = store.load();
const second = store.load();
// The newer read answers first; the older one, answering later with stale data, is dropped.
answers[1]({ success: true, jobs: [summary()] });
await second;
answers[0]({ success: true, jobs: [] });
await first;
assert.deepEqual(store.jobs().map(job => job.state), ['running']);
send(world.open()[0], summary({ state: 'finished', counters: { added: 2, errors: 0 } }));
assert.deepEqual(world.toasts, ['Scan finished · 2 added · 0 errors']);
// A list read sent before the end still says running: the job stays finished and no stream reopens.
const third = store.load();
answers[2]({ success: true, jobs: [summary()] });
await third;
assert.equal(store.find(ID_A).state, 'finished');
assert.equal(world.open().length, 0);
assert.equal(world.toasts.length, 1);
// A stale failure is dropped too.
const failing = store.load();
const newest = store.load();
answers[4]({ success: true, jobs: [] });
await newest;
answers[3](Promise.reject(new Error('late failure')));
await failing;
assert.equal(store.status, 'ready');
world.dispose();
});
test('summaries are validated and the copy follows the spec', async () => {
const { parseJob, finishToast, startToast, pillParts, outcomeLine, backoffDelay } = await source;
for (const raw of [null, [], { id: 'x', state: 'running' }, summary({ state: 'lost' }), summary({ id: ID_A.toUpperCase() })]) {
assert.equal(parseJob(raw), null);
}
const loose = parseJob({ id: ID_A, state: 'running', progress: 140.4, processed: -1, total: 1.5, counters: { added: 'x', errors: 2 },
title: ' ', started_at: 'x', scope: [] });
assert.deepEqual(loose, { id: ID_A, kind: '', title: 'Background job', state: 'running', started_at: null, finished_at: null,
progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {} });
assert.equal(parseJob(summary({ progress: -3 })).progress, 0);
const scan = parseJob(summary({ progress: 42, processed: 8240, total: 19622, counters: { added: 1, errors: 1 } }));
assert.deepEqual(pillParts(scan), { label: 'Scanning · 42%', files: ' · 8,240 / 19,622', text: 'Scanning · 42% · 8,240 / 19,622' });
assert.equal(pillParts({ ...scan, total: 0 }).text, 'Scanning · 42%');
assert.equal(pillParts({ ...scan, kind: 'thumbnails', title: 'Thumbnails' }).text, 'Thumbnails · 42% · 8,240 / 19,622');
assert.equal(finishToast({ ...scan, state: 'finished' }), 'Scan finished · 1 added · 1 error');
assert.equal(finishToast({ ...scan, state: 'cancelled' }), 'Scan cancelled · 1 added');
assert.equal(finishToast({ ...scan, state: 'failed' }), 'Scan failed · 1 added · 1 error');
assert.equal(finishToast({ ...scan, counters: {}, state: 'finished' }), 'Scan finished · 0 added · 0 errors');
const other = { ...scan, kind: 'backup', title: 'Backup' };
assert.equal(finishToast({ ...other, state: 'finished' }), 'Backup finished.');
assert.equal(finishToast({ ...other, state: 'cancelled' }), 'Backup was cancelled.');
assert.equal(finishToast({ ...other, state: 'failed' }), 'Backup failed.');
assert.equal(startToast(scan), 'Scan started.');
assert.equal(startToast(other), 'Backup started.');
assert.equal(outcomeLine(scan), '1 added · 1 error');
assert.equal(outcomeLine(other), '');
assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]);
});
+26 -3
View File
@@ -4,10 +4,10 @@ const source = import('../../web/js/workspace/state.mjs');
test('workspace routes resolve only supported deep links', async () => {
const { resolveRoute } = await source;
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system']) {
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system', 'activity']) {
assert.equal(resolveRoute(`#/${route}`).id, route);
}
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) {
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>', '#/activity/abc', `#/activity/${'A'.repeat(32)}`]) {
assert.equal(resolveRoute(hash).id, 'images');
}
});
@@ -23,10 +23,33 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
assert.equal(routeTitle(route), route.label);
}
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
for (const id of ['images', 'library', 'tags', 'overview']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
for (const id of ['images', 'library', 'tags', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
});
test('a job of the Activity page is its own deep link', async () => {
const { resolveRoute, routeTitle, isHandoff } = await source;
const id = '0123456789abcdef'.repeat(2);
const route = resolveRoute(`#/activity/${id}`);
assert.deepEqual([route.id, route.jobId, route.hash, routeTitle(route), isHandoff(route)],
['activity', id, `#/activity/${id}`, 'Activity', false]);
assert.equal(resolveRoute('#/activity').jobId, undefined);
assert.equal(route.href, undefined, 'Activity has no legacy card on Overview');
// The router renders again when only the job changes, and not for the same job.
const { mountRouter } = await source;
const other = 'f'.repeat(32);
const win = new EventTarget();
win.location = { hash: `#/activity/${id}` };
win.history = { state: null, replaceState(state, unused, hash) { win.location.hash = hash; } };
const seen = [];
const dispose = mountRouter(win, next => seen.push([next.id, next.jobId]));
win.dispatchEvent(new Event('hashchange'));
win.location.hash = `#/activity/${other}`; win.dispatchEvent(new Event('hashchange'));
win.location.hash = '#/activity'; win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen, [['activity', id], ['activity', other], ['activity', undefined]]);
dispose();
});
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
const { summarizeStats } = await source;
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
+26 -14
View File
@@ -155,12 +155,24 @@
"functions": 98,
"statements": 98
},
"workspace/file-inspector.mjs": {
"workspace/activity-store.mjs": {
"lines": 98,
"branches": 94,
"functions": 95,
"statements": 98
},
"workspace/activity.mjs": {
"lines": 95,
"branches": 92,
"functions": 88,
"branches": 86,
"functions": 98,
"statements": 95
},
"workspace/file-inspector.mjs": {
"lines": 96,
"branches": 92,
"functions": 92,
"statements": 96
},
"workspace/heading.mjs": {
"lines": 98,
"branches": 98,
@@ -229,7 +241,7 @@
},
"workspace/index.mjs": {
"lines": 98,
"branches": 96,
"branches": 97,
"functions": 98,
"statements": 98
},
@@ -264,10 +276,10 @@
"statements": 98
},
"workspace/library.mjs": {
"lines": 95,
"lines": 96,
"branches": 85,
"functions": 96,
"statements": 95
"statements": 96
},
"workspace/media-viewer.mjs": {
"lines": 98,
@@ -282,10 +294,10 @@
"statements": 98
},
"workspace/prompt-blocks.mjs": {
"lines": 89,
"branches": 89,
"lines": 90,
"branches": 92,
"functions": 98,
"statements": 89
"statements": 90
},
"workspace/prompt-inspector.mjs": {
"lines": 98,
@@ -302,7 +314,7 @@
"workspace/recorded-run.mjs": {
"lines": 95,
"branches": 83,
"functions": 77,
"functions": 80,
"statements": 95
},
"workspace/saved-views.mjs": {
@@ -325,15 +337,15 @@
},
"workspace/toast.mjs": {
"lines": 98,
"branches": 64,
"branches": 73,
"functions": 98,
"statements": 98
},
"workspace/toolbar.mjs": {
"lines": 94,
"branches": 93,
"lines": 96,
"branches": 96,
"functions": 98,
"statements": 94
"statements": 96
},
"workspace/topbar.mjs": {
"lines": 98,
+267
View File
@@ -0,0 +1,267 @@
/**
* The Activity store (#249 Activity-1): this server session's jobs, for the top-bar button and pill and the Activity
* page. One `GET /prompt_manager/jobs` read, then one SSE stream (`/jobs/<id>/events`) per running job.
*
* - A dropped stream re-reads the list after a backoff that doubles from 1 s and is capped at 30 s, so a stopped
* server never sees a retry storm. A successful stream message resets the backoff.
* - While the tab is visible the list is read again every 15 s, so a job started elsewhere (another tab, the legacy
* admin page) appears without a reload.
* - Jobs live only in server memory. A running job the server no longer lists (ComfyUI restarted), or one whose
* server cannot be read at all, is shown as ended (`lost`), so no pill stays stuck.
* - Toasts announce what happened; Activity is where progress lives. "Scan started." follows this tab's own start;
* the finish toast follows any job this store saw running when it ends.
*/
import { showWorkspaceToast } from './toast.mjs';
import { noun } from './plural.mjs';
export const JOBS_URL = '/prompt_manager/jobs';
export const BACKOFF_FIRST_MS = 1000;
export const BACKOFF_MAX_MS = 30000;
export const POLL_MS = 15000;
export const LOST_DETAIL = 'This job ended when ComfyUI restarted.';
const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']);
const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']);
const JOB_ID = /^[0-9a-f]{32}$/;
// The pill's verb for each job kind; a kind without one shows its title.
const PILL_VERBS = Object.freeze({ scan: 'Scanning' });
const START_TOASTS = Object.freeze({ scan: 'Scan started.' });
/** The wait before the `attempt`-th re-read (0-based): 1 s, 2 s, 4 s … never more than 30 s. */
export function backoffDelay(attempt) {
const step = Number.isSafeInteger(attempt) && attempt > 0 ? Math.min(attempt, 16) : 0;
return Math.min(BACKOFF_MAX_MS, BACKOFF_FIRST_MS * 2 ** step);
}
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : 0;
const time = value => typeof value === 'number' && Number.isFinite(value) ? value : null;
const plainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
/** A job summary from the API, or null when it is not one (unknown state, bad id). */
export function parseJob(raw) {
if (!plainObject(raw) || typeof raw.id !== 'string' || !JOB_ID.test(raw.id) || !SERVER_STATES.has(raw.state)) return null;
const counters = {};
if (plainObject(raw.counters)) for (const [key, value] of Object.entries(raw.counters)) counters[key] = count(value);
const progress = typeof raw.progress === 'number' && Number.isFinite(raw.progress) ? Math.round(raw.progress) : 0;
return {
id: raw.id,
kind: typeof raw.kind === 'string' ? raw.kind : '',
title: typeof raw.title === 'string' && raw.title.trim() ? raw.title : 'Background job',
state: raw.state,
started_at: time(raw.started_at),
finished_at: time(raw.finished_at),
progress: Math.max(0, Math.min(100, progress)),
status_line: typeof raw.status_line === 'string' ? raw.status_line : '',
processed: count(raw.processed),
total: count(raw.total),
counters,
scope: plainObject(raw.scope) ? { ...raw.scope } : {},
};
}
const number = value => value.toLocaleString();
const errorsOf = job => `${number(job.counters.errors ?? 0)} ${noun(job.counters.errors ?? 0, 'error')}`;
/** "1,420 added". */
export const addedOf = job => `${number(job.counters.added ?? 0)} added`;
/** The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added". */
export function finishToast(job) {
if (job.kind === 'scan') {
if (job.state === 'cancelled') return `Scan cancelled · ${addedOf(job)}`;
if (job.state === 'failed') return `Scan failed · ${addedOf(job)} · ${errorsOf(job)}`;
return `Scan finished · ${addedOf(job)} · ${errorsOf(job)}`;
}
if (job.state === 'cancelled') return `${job.title} was cancelled.`;
if (job.state === 'failed') return `${job.title} failed.`;
return `${job.title} finished.`;
}
/** The toast after this tab starts a job. */
export function startToast(job) {
return START_TOASTS[job.kind] ?? `${job.title} started.`;
}
/** The pill's text: "Scanning · 42% · 8,240 / 19,622"; the file counts only once the total is known. */
export function pillParts(job) {
const label = `${PILL_VERBS[job.kind] ?? job.title} · ${job.progress}%`;
const files = job.total > 0 ? ` · ${number(job.processed)} / ${number(job.total)}` : '';
return { label, files, text: label + files };
}
/** The scan's counters as one line: "N added · M errors"; other kinds have none yet. */
export function outcomeLine(job) {
return job.kind === 'scan' ? `${addedOf(job)} · ${errorsOf(job)}` : '';
}
function defaultConnect(url) {
const resolve = globalThis.ApiClient?.resolveUrl ?? (value => value);
return new EventSource(resolve(url));
}
/**
* @param {{api: {get: Function, post: Function}, scope: {signal: AbortSignal, cleanup: Function},
* connect?: (url: string) => {onmessage: Function|null, onerror: Function|null, close: Function},
* notify?: (message: string) => void, timers?: {setTimeout: Function, clearTimeout: Function},
* pollMs?: number, isVisible?: () => boolean}} options
*/
export function createActivityStore({ api, scope, connect = defaultConnect, notify = showWorkspaceToast,
timers = globalThis, pollMs = POLL_MS, isVisible = () => globalThis.document?.visibilityState !== 'hidden' } = {}) {
let jobs = [];
let status = 'idle';
let attempt = 0;
let retryTimer = null;
let pollTimer = null;
let reads = 0;
let disposed = false;
const streams = new Map();
const listeners = new Set();
const emit = () => { for (const listener of [...listeners]) listener(store); };
const find = id => jobs.find(job => job.id === id);
function announce(previous, next) {
if (previous?.state === 'running' && FINAL_STATES.has(next.state)) notify(finishToast(next));
}
function closeStream(id) {
const source = streams.get(id);
if (!source) return;
streams.delete(id);
source.onmessage = null;
source.onerror = null;
source.close();
}
function follow() {
for (const id of [...streams.keys()]) if (find(id)?.state !== 'running') closeStream(id);
for (const job of jobs) if (job.state === 'running' && !streams.has(job.id)) openStream(job.id);
}
function openStream(id) {
const source = connect(`${JOBS_URL}/${id}/events`);
streams.set(id, source);
source.onmessage = event => {
let job;
try { job = parseJob(JSON.parse(event.data)); } catch { job = null; }
if (!job || job.id !== id) return;
attempt = 0;
upsert(job);
};
source.onerror = () => {
closeStream(id);
if (find(id)?.state === 'running') scheduleResync();
};
}
/** A job never runs again once it has ended, whatever an older read in flight still says. */
const settled = (previous, next) => FINAL_STATES.has(previous?.state) && next.state === 'running' ? previous : next;
function upsert(job) {
const previous = find(job.id);
job = settled(previous, job);
jobs = previous ? jobs.map(item => item.id === job.id ? job : item) : [job, ...jobs];
announce(previous, job);
follow();
emit();
}
/** Replace the jobs with the server's list; a running job it no longer has has ended with the server. */
function merge(list) {
const next = list.map(parseJob).filter(Boolean).map(job => settled(find(job.id), job));
const ids = new Set(next.map(job => job.id));
const carried = jobs.filter(job => !ids.has(job.id))
.map(job => job.state === 'running' ? { ...job, state: 'lost' } : job);
for (const job of next) announce(find(job.id), job);
jobs = [...next, ...carried];
follow();
}
function markLost() {
jobs = jobs.map(job => job.state === 'running' ? { ...job, state: 'lost' } : job);
follow();
}
function scheduleResync() {
if (disposed || retryTimer !== null) return;
const delay = backoffDelay(attempt);
attempt += 1;
retryTimer = timers.setTimeout(() => {
retryTimer = null;
load();
}, delay);
}
/** The next periodic read; a hidden tab skips its turn. */
function schedulePoll() {
if (disposed || pollMs <= 0) return;
if (pollTimer !== null) timers.clearTimeout(pollTimer);
pollTimer = timers.setTimeout(() => {
pollTimer = null;
if (isVisible()) load();
else schedulePoll();
}, pollMs);
}
/** Read this session's jobs. A failed read marks running jobs ended and tries again after the backoff. */
async function load() {
if (disposed) return;
const read = ++reads;
try {
const result = await api.get(JOBS_URL, { signal: scope.signal, cache: 'no-store' });
// Only the newest read applies; an older one answering late is dropped.
if (disposed || read !== reads) return;
if (!Array.isArray(result?.jobs)) throw new TypeError('Invalid job list');
merge(result.jobs);
status = 'ready';
schedulePoll();
} catch (error) {
if (disposed || scope.signal?.aborted || read !== reads) return;
markLost();
status = 'error';
scheduleResync();
}
emit();
}
const store = {
get status() { return status; },
jobs: () => [...jobs],
running: () => jobs.filter(job => job.state === 'running'),
find,
load,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Start a job of `kind`: {job}, or {busy, message} naming the running job that holds its slot. */
async start(kind, body = {}) {
try {
const result = await api.post(`${JOBS_URL}/${encodeURIComponent(kind)}`, body, { signal: scope.signal });
const job = parseJob(result?.job);
if (!job) throw new TypeError('Invalid job');
upsert(job);
notify(startToast(job));
return { job };
} catch (error) {
if (error?.status !== 409 || error?.code !== 'busy') throw error;
await load();
return { busy: store.running()[0] ?? null, message: error.message };
}
},
/** Ask a running job to stop at its next file boundary; false once it has ended. */
async cancel(id) {
const result = await api.post(`${JOBS_URL}/${encodeURIComponent(id)}/cancel`, {}, { signal: scope.signal });
return result?.cancellation_requested === true;
},
};
scope.cleanup(() => {
disposed = true;
if (retryTimer !== null) timers.clearTimeout(retryTimer);
if (pollTimer !== null) timers.clearTimeout(pollTimer);
retryTimer = null;
pollTimer = null;
for (const id of [...streams.keys()]) closeStream(id);
listeners.clear();
});
return store;
}
+192
View File
@@ -0,0 +1,192 @@
/**
* 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>`.
* - 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").
*/
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.';
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');
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);
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];
title.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 {
progress.remove();
actions.remove();
}
};
return item;
}
/** The Activity page: the concept's "Recent work" list of this session's jobs. `jobId` marks one job's item. */
export function mountActivityPage(main, { store, scope, jobId = null }) {
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();
let revealed = false;
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);
if (job.id === jobId) item.setAttribute('aria-current', 'true');
// 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);
const marked = jobId && items.get(jobId);
if (marked && !revealed) {
revealed = true;
marked.scrollIntoView({ block: 'nearest' });
}
};
scope.cleanup(store.subscribe(render));
render();
// Opening Activity reads the list at once instead of waiting for the next periodic read.
store.load();
}
+2 -1
View File
@@ -3,8 +3,9 @@ const paths = Object.freeze({
"library": "M4 4h5v16H4z M11 4h4v16h-4z M17 5l3-1 3 15-3 1z",
"tags": "M3 3h8l10 10-8 8L3 11V3ZM7 7h.01",
"upload": "M12 16V3m-5 5 5-5 5 5M3 15v6h18v-6",
// D28 ruling: an outline home glyph of its own; the concept's `activity` pulse stays with the Activity row D5 omits.
// D28 ruling: an outline home glyph of its own; the concept's `activity` pulse belongs to the Activity row.
"overview": "M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6",
"activity": "M3 12h4l3-8 4 16 3-8h4",
"settings": "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2",
"grid": "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z",
"list": "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01",
+14 -3
View File
@@ -11,6 +11,8 @@ 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';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -43,7 +45,7 @@ function mountWorkspace() {
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 (D5: no Activity).
// 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),
@@ -60,8 +62,13 @@ function mountWorkspace() {
return link;
});
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
// 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;
@@ -221,6 +228,10 @@ function mountWorkspace() {
mountVocabulary(main, api, view);
return;
}
if (route.id === 'activity') {
mountActivityPage(main, { store: activity, scope: view, jobId: route.jobId });
return;
}
if (isHandoff(route)) {
description.textContent = route.description;
const panel = element('section', undefined, 'surface handoff');
+8 -2
View File
@@ -11,11 +11,13 @@ export const routes = [
{ 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']);
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' });
@@ -33,6 +35,9 @@ export function isHandoff(route) {
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};
@@ -110,7 +115,8 @@ export function mountRouter(win, onRoute) {
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) return;
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId &&
current?.jobId === route.jobId) return;
current = route;
onRoute(route);
};
+15
View File
@@ -17,6 +17,21 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
}
/* D53: the running job's progress pill before the Activity button (the approved Activity mockup's `.pill`), in the
concept's tokens. At <=700px it keeps its verb and percentage; the file counts and bar stay in its accessible name. */
.activity-pill:not([hidden]) { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--line);
border-radius: 999px; font-size: 12px; white-space: nowrap; color: var(--ca-text); background: var(--ca-surface); /* D53 */ }
.activity-pill:hover { border-color: var(--ca-accent); /* D53 */ }
.activity-pill-bar { display: block; inline-size: 70px; block-size: 5px; border-radius: 3px; background: var(--line); overflow: hidden; /* D53 */ }
.activity-pill-bar i { display: block; block-size: 100%; inline-size: 0; background: var(--ca-accent); /* D53 */ }
@media (pointer: coarse) { .activity-pill:not([hidden]) { min-block-size: 40px; /* D53 */ } }
@media (max-width: 700px) {
/* D52: with the Activity button the full-width row can be wider than a zoomed phone, so its items wrap. */
.top-actions { flex-wrap: wrap; /* D52 */ }
.activity-pill:not([hidden]) { gap: 0; padding-inline: 10px; /* D53 */ }
.activity-pill-files, .activity-pill-bar { display: none; /* D53 */ }
}
/* Overview: real library statistics and the legacy tool cards inside a concept `.surface`. */
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; }
.stat { padding: 22px; background: var(--ca-surface); border: 1px solid var(--line); border-radius: 7px; }
+1 -1
View File
@@ -32,7 +32,7 @@
<div class="workspace">
<header class="topbar">
<div class="breadcrumbs">ComfyAssets <span>/</span> <span id="workspace-breadcrumb">Images</span></div>
<div class="top-actions"><label class="theme-label" for="theme">Theme
<div class="top-actions"><!-- D53: the progress pill, shown while a job runs. D52: the count shows running jobs, hidden at 0. --><a id="activity-pill" class="activity-pill" hidden></a><button id="activity-open" type="button">Activity <span class="count" id="activity-count" hidden>0</span></button><label class="theme-label" for="theme">Theme
<select id="theme" aria-label="Theme">
<option value="dark">Dark</option>
<option value="light">Light</option>