Merge remote-tracking branch 'origin/main' into fix/530-layout-tags-system

This commit is contained in:
Vito Sansevero
2026-10-30 16:09:55 -07:00
29 changed files with 728 additions and 121 deletions
+7 -6
View File
@@ -251,9 +251,9 @@ match Classic and Focused.
- **PromptManagerText**: Look for "Prompt Manager Text" in the PromptManager/Text category - outputs STRING for any text node
5. **Access the web interface**:
- **Admin Dashboard**: `http://localhost:8188/prompt_manager/admin` - Full management interface
- **Metadata Viewer**: `http://localhost:8188/prompt_manager/metadata.html` - Standalone PNG analysis tool
- **Simple Interface**: `http://localhost:8188/prompt_manager/` - Basic prompt browser
- **Workspace**: click **PM** in ComfyUI's top bar or **Web UI** on a node, or open `http://localhost:8188/prompt_manager/workspace`
- **Legacy pages** still work at their URLs, and System's Overview section links to them:
`/prompt_manager/admin` (dashboard), `/prompt_manager/admin#/tags` (tags), `/prompt_manager/gallery` (gallery), `/prompt_manager/gallery.html` (metadata viewer)
### Prerequisites
@@ -324,9 +324,10 @@ The comprehensive gallery system provides complete access to all your ComfyUI ou
#### **📁 Accessing the Gallery**
1. **Open the admin interface** at `http://localhost:8188/prompt_manager/admin`
2. **Click the "🖼️ Gallery" button** to open the output-wide gallery
3. **Browse all content** from your ComfyUI output directory
1. **Open the workspace's Images page** at `http://localhost:8188/prompt_manager/workspace#/images`
2. **Browse all content** from your ComfyUI output directory
The legacy gallery stays at `http://localhost:8188/prompt_manager/gallery`.
#### **🎬 Video & Image Support**
+35 -6
View File
@@ -2641,8 +2641,8 @@ applied), D35 (singular captions), D36 (sorted categories), D40 (the continuatio
the grid) and D43 (sorted chips, checked against the concept with its chips sorted).
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open
existing prompt library") and the sidebar still link `/prompt_manager/admin`
"Current prompt library" link is removed from the shelf; the legacy Library stays at
`/prompt_manager/admin` by direct URL, linked only from System (#525, D165)
(`tests/browser/workspace.spec.mjs`). `library comparison detects
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
@@ -3140,8 +3140,8 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
### Inspect a file page and recorded deviations (#249 local Inspect, PR L2)
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
the legacy metadata page, which stays reachable from Overview's "Open existing metadata
inspector" card (`/prompt_manager/gallery.html`). The page is the concept's `#metadata`
the legacy metadata page, which stays at its URL
(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata`
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
@@ -4363,6 +4363,15 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
`system shell and overview match the concept` (the mapping inserts the row) and
`library notice matches the concept notice` with its mutation check
(system-concept.spec), and the D129 tests of workspace-system.spec.
- **D166** (one-time upgrade notice, #527): the concept's README puts the notice in the
new shell, while its `app.js` draws it in System › Sources. Production does both: the
Sources `.notice` is kept and compared as drawn, and `#older-images-notice`, styled as
`#library-banner`, shows the same notice above every other page until dismissed. The
shell hides on Sources, so they never show together. Dismissal is the legacy key
`pm_update_notice_dismissed`, shared with the admin modal and across tabs. Browser
contexts start dismissed (`tests/browser/fixtures.mjs`, `storageState`); a spec about
the notice sets `test.use({ olderImagesNotice: true })`. Pinned by
workspace-older-images-notice.spec.
- **D130** (Appearance, #317): the concept keeps "Thumbnail quality, size limit" in
Appearance (README) but draws no rows for them. Appearance gains a second
`section.surface`, "Thumbnails", after "Browsing & display", built from the concept's
@@ -7052,8 +7061,8 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
## Native vocabulary UI (#236)
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays reachable from Overview's
"Open existing tag manager" card for its own page; tag search and sort, the untagged
longer hands off to the legacy tag manager. That page stays at
`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
library's selection bar, D151). A prompt's category is changed in the Prompt library
@@ -7175,3 +7184,23 @@ until acceptance. Not compared: hover states and the dialog focus ring (the page
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
`vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive
floors in `frontend-coverage-baseline.json`.
## Cutover entry points (#525)
The top-bar PM button, both Web UI buttons and `/prompt_manager/web` open
`/prompt_manager/workspace#/library` (`web/js/workspace-entry.js`; `web/index.html` keeps its
own copy, pinned by `tests/js/workspace-entry.test.js`). Overview's cards open native hashes.
The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.html`) stay at
their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer
ruling; pinned by `legacy-system-links.spec.mjs`).
- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer"
(`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line;
`mapOverviewConcept` adds it to the page tree.
- **Load failure** (maintainer ruling 2026-10-30): `web/workspace.html` holds static
`#workspace-boot-failure` with the same four links, styled inline so it needs no stylesheet.
An inline guard shows it at `load` unless `index.mjs` set `data-workspace-booted`, so a failed
fetch of any module and a throw while starting are both covered; `workspace.spec.mjs` tests
both. Editing `workspace.html` needs a ComfyUI restart, because the server caches it. The PM button has no
page-fixture test; it is proven on the managed host (`tests/comfyui/prompt-manager.spec.mjs`).
+23 -1
View File
@@ -7,7 +7,7 @@ test.beforeEach(async ({page, browserName}) => {
page.classicErrors = [];
page.on('pageerror', error => page.classicErrors.push(error.message));
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => (window.fixtureApiBase ?? "") + path, queuePrompt: async (...args) => args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(() => {
@@ -64,6 +64,28 @@ test('Classic keeps immediate selection, search query and metadata serialization
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
});
test('Classic Web UI opens the workspace Library in the saved display mode, through the mount prefix (#525)',async({page})=>{
await page.evaluate(()=>{window.fixtureApiBase='/mounted';window.open=(...args)=>{window.openArgs=args;return {};};});
const webUi=page.getByRole('button',{name:'🌐 Web UI',exact:true});
for (const [mode,args] of [
['newtab',['/mounted/prompt_manager/workspace#/library','_blank']],
['popup',['/mounted/prompt_manager/workspace#/library','_blank','width=1200,height=800,scrollbars=yes,resizable=yes']],
]) {
await page.evaluate(value=>{node.properties.webuiDisplayMode=value;},mode);
await webUi.click();
expect(await page.evaluate(()=>window.openArgs)).toEqual(args);
}
});
test('Classic Web UI reports a blocked popup and keeps the canvas open (#525)',async({page})=>{
const before=page.url();
await page.evaluate(()=>{window.open=()=>null;});
await page.getByRole('button',{name:'🌐 Web UI',exact:true}).click();
await expect(page.getByText('Allow popups to open Web UI, then try again.')).toBeVisible();
expect(page.url()).toBe(before);
await expect(page.locator('.pm-classic')).toBeVisible();
});
test('Classic detaches native text synchronization on removal',async({page})=>{
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
+8
View File
@@ -8,6 +8,9 @@
// browser that exits abnormally prints one `pm-browser-exit|` line, and a test
// that fails on a closed or crashed target gets that log attached as
// `browser-stderr`.
// The one-time older-images notice (#527) shows above every workspace page and as
// a modal on the admin page until dismissed, so every context starts with it
// dismissed. A spec about the notice sets `test.use({ olderImagesNotice: true })`.
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs';
@@ -44,6 +47,11 @@ export const test = base.extend({
if (crash) throw crash;
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
baseURL: async ({ workerServer }, use) => use(workerServer.url),
olderImagesNotice: [false, { option: true }],
storageState: async ({ baseURL, olderImagesNotice }, use) => use(olderImagesNotice ? undefined : {
cookies: [],
origins: [{ origin: new URL(baseURL).origin, localStorage: [{ name: 'pm_update_notice_dismissed', value: '3.2.0' }] }],
}),
});
export { expect };
+1 -1
View File
@@ -485,7 +485,7 @@ test(`${variant} respects the existing Web UI display preference`,async({page})=
});
await page.evaluate(()=>node._focusedSettingsReady);
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/workspace#/library','_blank']);
});
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
+9 -1
View File
@@ -165,6 +165,8 @@ const number = value => value.toLocaleString('en-US');
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
// D165: the discreet legacy-pages line follows the settings layout.
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
const rows = {
@@ -257,7 +259,7 @@ const UNMAPPED_PATHS = [
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
'save.button.cursor', 'save.button.opacity', 'save.status.text',
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
].sort();
test('system comparison detects mutations', async ({ page, request }) => {
@@ -683,6 +685,8 @@ async function watchedSources(request) {
for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) {
const variant = notice ? 'with' : 'without';
test.describe(() => {
test.use({ olderImagesNotice: notice });
test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const server = await watchedSources(request);
@@ -693,8 +697,11 @@ for (const theme of THEMES) for (const [width, height] of SIZES) for (const noti
expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
});
}
test.describe(() => {
test.use({ olderImagesNotice: true });
test('sources comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const server = await watchedSources(request);
@@ -737,6 +744,7 @@ test('sources comparison detects mutations', async ({ page, request }) => {
await undoToken();
expect(diffInventory(expected, await measure())).toEqual([]);
});
});
/**
* Runs in the page: the open "Add image source" dialog (the concept's `simpleInput`, app.js 1138 and 1497): its title,
@@ -0,0 +1,153 @@
// The one-time older-images notice in the workspace shell (#527, D166). Its dismissal is the legacy admin page's key,
// so dismissing it in any place dismisses it everywhere.
import { test, expect } from './fixtures.mjs';
const WORKSPACE = '/prompt_manager/workspace';
const ADMIN = '/prompt_manager/admin';
const NOTICE_KEY = 'pm_update_notice_dismissed';
const shellNotice = page => page.locator('#older-images-notice > .notice');
const sourcesNotice = page => page.locator('#workspace-main .settings-layout > .notice');
const dialog = page => page.locator('dialog#modal[open]');
const adminModal = page => page.locator('#updateNoticeModal');
const stored = page => page.evaluate(key => localStorage.getItem(key), NOTICE_KEY);
async function openRoute(page, route) {
await page.goto(`${WORKSPACE}#/${route}`);
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
}
// Every spec on this worker's server shares its job list.
test.beforeEach(async ({ request }) => {
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
});
test.describe('with the notice not dismissed', () => {
test.use({ olderImagesNotice: true });
for (const route of ['library', 'images', 'tags', 'activity', 'system/overview']) {
test(`shows above the ${route} page with Later and Review scan`, async ({ page }) => {
await openRoute(page, route);
await expect(shellNotice(page).locator('h2')).toHaveText('Bring older images into your prompt history');
await expect(shellNotice(page).locator('.row-actions > button')).toHaveText(['Later', 'Review scan']);
await expect(page.locator('#older-images-notice')).toBeVisible();
});
}
test('Later dismisses it for good without a write or a scan', async ({ page, request }) => {
const writes = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
await openRoute(page, 'library');
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
await expect(shellNotice(page)).toHaveCount(0);
await expect(page.locator('#older-images-notice')).toBeHidden();
await expect(page.locator('#workspace-main')).toBeFocused();
expect(await stored(page)).toBe('3.2.0');
await expect(dialog(page)).toHaveCount(0);
await page.reload();
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
await expect(shellNotice(page)).toHaveCount(0);
expect(writes).toEqual([]);
expect((await (await request.get('/prompt_manager/jobs')).json()).jobs).toEqual([]);
});
test('Review scan dismisses it and opens the review of every source', async ({ page }) => {
await openRoute(page, 'library');
await shellNotice(page).getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
await expect(shellNotice(page)).toHaveCount(0);
expect(await stored(page)).toBe('3.2.0');
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator('#workspace-main')).toBeFocused();
});
test('Review scan keeps the notice and says so when the sources cannot be read', async ({ page }) => {
await openRoute(page, 'library');
await page.route('**/prompt_manager/settings', route => route.fulfill({ status: 500, json: { success: false } }));
await shellNotice(page).getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(page.locator('#workspace-toast')).toHaveText('Could not read the image sources. Try again.');
await expect(shellNotice(page)).toHaveCount(1);
await expect(shellNotice(page).getByRole('button', { name: 'Review scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
await expect(dialog(page)).toHaveCount(0);
expect(await stored(page)).toBeNull();
});
test('System > Sources shows one notice, its own, and dismissing it there clears the shell', async ({ page }) => {
await openRoute(page, 'system/overview');
await expect(shellNotice(page)).toHaveCount(1);
await page.goto(`${WORKSPACE}#/system/sources`);
await expect(page.locator('#workspace-main .settings-layout > .notice:visible')).toHaveCount(1);
await expect(shellNotice(page)).toHaveCount(0);
await sourcesNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
await expect(sourcesNotice(page)).toHaveCount(0);
await page.goto(`${WORKSPACE}#/library`);
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
await expect(shellNotice(page)).toHaveCount(0);
});
test('dismissing in the shell clears the copy System has already mounted', async ({ page }) => {
await openRoute(page, 'system/sources');
await expect(sourcesNotice(page)).toHaveCount(1);
await page.goto(`${WORKSPACE}#/system/overview`);
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
await page.goto(`${WORKSPACE}#/system/sources`);
await expect(page.locator('#workspace-main #source-list')).toBeVisible();
await expect(sourcesNotice(page)).toHaveCount(0);
});
test('dismissing in another tab clears it here', async ({ page, context }) => {
await openRoute(page, 'library');
await expect(shellNotice(page)).toHaveCount(1);
const other = await context.newPage();
await openRoute(other, 'images');
await shellNotice(other).getByRole('button', { name: 'Later', exact: true }).click();
await expect(shellNotice(page)).toHaveCount(0);
await other.close();
});
test('the admin modal still works: Later without the checkbox keeps the notice everywhere', async ({ page }) => {
await page.goto(ADMIN);
await expect(adminModal(page)).toBeVisible();
await page.locator('#updateNoticeDismiss').click();
await expect(adminModal(page)).toBeHidden();
expect(await stored(page)).toBeNull();
await openRoute(page, 'library');
await expect(shellNotice(page)).toHaveCount(1);
});
test('dismissing on the admin page dismisses it in the workspace', async ({ page }) => {
await page.goto(ADMIN);
await page.locator('#dismissUpdateNotice').check();
await page.locator('#updateNoticeDismiss').click();
await expect(adminModal(page)).toBeHidden();
await openRoute(page, 'library');
await expect(shellNotice(page)).toHaveCount(0);
});
test('dismissing in the workspace dismisses it on the admin page', async ({ page }) => {
await openRoute(page, 'library');
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
await page.goto(ADMIN);
await page.waitForLoadState('load');
await expect(adminModal(page)).toBeHidden();
});
test('the admin Scan still dismisses it and opens the scan modal', async ({ page }) => {
await page.goto(ADMIN);
await page.locator('#updateNoticeScan').click();
await expect(adminModal(page)).toBeHidden();
expect(await stored(page)).toBe('3.2.0');
});
});
test('specs start with the notice dismissed, on every page', async ({ page, browser, baseURL }) => {
await openRoute(page, 'library');
await expect(shellNotice(page)).toHaveCount(0);
const context = await browser.newContext({ baseURL });
const other = await context.newPage();
await other.goto(ADMIN);
await other.waitForLoadState('load');
await expect(adminModal(other)).toBeHidden();
await context.close();
});
@@ -41,6 +41,9 @@ async function serverJson(request, url) {
return response.json();
}
// `openSources` dismisses the notice itself unless a test asks for it.
test.use({ olderImagesNotice: true });
/** Open Sources with the update notice dismissed (or shown, `notice: true`) once the source list has loaded. */
async function openSources(page, { notice = false } = {}) {
if (!notice) await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
+3
View File
@@ -897,6 +897,8 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
// System section, found by role and accessible name (a `term` by its text, a disclosure by its summary). Collapsed
// `details` are opened first, so a control inside one counts once it is shown. tests/vendor/legacy-coverage.test.mjs
// checks the rest of each row.
test.describe(() => {
test.use({ olderImagesNotice: true });
test('every covered legacy System capability has its native control', async ({ page, request }) => {
// Download is offered for a model that is not on disk ("Download again" otherwise): the selected default's file is
// missing here.
@@ -907,6 +909,7 @@ test('every covered legacy System capability has its native control', async ({ p
await request.post('/__test__/tagging/reset', { data: {} });
}
});
});
async function findControls(page) {
const covered = readCoverageTable().filter(row => row.Status === 'covered')
+106 -17
View File
@@ -11,12 +11,12 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole
const sidebarLinks = page => page.locator('aside.sidebar nav a');
const routes = [
['overview', 'Overview', null],
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
// D29: Images no longer links the legacy gallery.
['images', 'Images', null],
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
// #249 E3: the shelf no longer links the legacy Library.
['library', 'Prompt library', null],
['tags', 'Tags & categories', null],
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
// #249 local Inspect: the native Inspect a file page.
['inspect', 'Inspect a file', null],
// #249 System S2: native System; every section native since S12 (D94 retired, asserted below).
['system', 'System', null],
@@ -93,20 +93,106 @@ test('all shell destinations deep-link, reload and expose working existing appli
await page.reload();
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
}
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
// #525: Overview's cards open the native pages, and no workspace page links a legacy one.
for (const [action, hash, title] of [
['Open Images', '#/images', 'Images'],
['Open Prompt library', '#/library', 'Prompt library'],
['Open Tags & categories', '#/tags', 'Tags & categories'],
['Open Inspect a file', '#/inspect', 'Inspect a file'],
['Open System', '#/system/overview', 'System'],
]) {
await page.goto(`${workspace}#/overview`);
const main = page.locator('#workspace-main');
const card = main.getByRole('link', { name: action, exact: true });
await expect(card).toHaveAttribute('href', hash);
await card.click();
await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace${hash}$`));
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
}
await page.goto(`${workspace}#/overview`);
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
await expect(await request.get('/prompt_manager/admin')).toBeOK();
// The legacy metadata page stays reachable from Overview beside the native Inspect page.
const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true });
await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html');
await expect(await request.get('/prompt_manager/gallery.html')).toBeOK();
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
await gallery.click();
await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
await expect(page.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true })).toBeVisible();
await expect(page.locator('a[href*="/prompt_manager/admin"], a[href*="/prompt_manager/gallery"]')).toHaveCount(0);
await expect(page.locator('#workspace-main').getByText('Your existing tools remain available')).toHaveCount(0);
// The legacy pages themselves stay at their URLs.
for (const legacy of ['/prompt_manager/admin', '/prompt_manager/gallery', '/prompt_manager/gallery.html']) {
await expect(await request.get(legacy)).toBeOK();
}
});
test('/prompt_manager/web lands on the workspace Library and Back does not bounce through it (#525)', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
await page.evaluate(() => { location.href = '/prompt_manager/web'; });
await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/overview$/);
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
});
// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages.
test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => {
await page.goto(`${workspace}#/system/overview`);
const line = page.locator('#workspace-main .legacy-pages');
await expect(line).toBeVisible();
await expect(line).toContainText('Legacy pages');
const links = line.getByRole('link');
await expect(links).toHaveCount(4);
for (const [name, url, ready] of [
['Dashboard', '/prompt_manager/admin', '#addPromptBtn'],
['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'],
['Gallery', '/prompt_manager/gallery', 'body'],
['Metadata viewer', '/prompt_manager/gallery.html', 'body'],
]) {
const opened = context.waitForEvent('page');
await line.getByRole('link', { name, exact: true }).click();
const tab = await opened;
await tab.waitForLoadState('domcontentloaded');
const target = new URL(tab.url());
expect(target.pathname + target.hash).toBe(url);
await expect(tab.locator(ready).first()).toBeAttached();
await tab.close();
}
// Only on System's Overview section, and gone with the page.
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(line).toBeHidden();
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0);
});
const bootFailure = page => page.locator('#workspace-boot-failure');
test('a healthy workspace never shows the load failure message (#525)', async ({ page }) => {
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.waitForFunction(() => document.readyState === 'complete');
await expect(bootFailure(page)).toBeHidden();
});
for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) {
test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => {
const pageErrors = [];
page.on('pageerror', error => pageErrors.push(error.message));
await page.route(pattern, route => route.abort());
await page.goto(`${workspace}#/library`);
const failure = bootFailure(page);
await expect(failure).toBeVisible();
await expect(failure).toHaveAttribute('role', 'alert');
const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true });
await expect(dashboard).toBeVisible();
await expect(failure.getByRole('link')).toHaveCount(4);
await dashboard.click();
await expect(page).toHaveURL(/\/prompt_manager\/admin$/);
expect(pageErrors).toEqual([]);
});
}
test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => {
await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" }));
await page.goto(`${workspace}#/library`);
await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible();
// The broken module's own error is this test's premise, not a failure of the page.
page.workspaceErrors.length = 0;
});
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
@@ -428,6 +514,8 @@ const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Int
'Backup & export', 'Diagnostics & logs'];
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
test.describe(() => {
test.use({ olderImagesNotice: true });
test('System is native: the concept shell, section navigation, sections and the Save row', async ({ page }) => {
const mutations = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
@@ -438,7 +526,7 @@ test('System is native: the concept shell, section navigation, sections and the
// D92: no "Sample configuration" chip.
await expect(main.locator('.heading .chip')).toHaveCount(0);
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']);
const nav = main.getByRole('group', { name: 'System sections', exact: true });
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
@@ -506,6 +594,7 @@ test('System is native: the concept shell, section navigation, sections and the
await expect(main.locator('.settings-layout')).toHaveCount(1);
expect(mutations).toEqual([]);
});
});
test('Overview opens native System from its System card (Q12)', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
+3 -3
View File
@@ -210,14 +210,14 @@ test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page },
}
});
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
test(`${label} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => {
await addEditor(page, type);
const popupPromise = page.waitForEvent('popup');
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
+63 -3
View File
@@ -84,7 +84,7 @@ test('all node schemas and extension entrypoints load from the real host', async
});
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
test(`${type} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => {
await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
@@ -95,14 +95,74 @@ for (const type of ['PromptManager', 'PromptManagerText']) {
await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
}
test('top-bar PM button opens the workspace in either renderer', async ({ page }) => {
const button = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first();
await expect(button).toBeVisible();
const popupPromise = page.waitForEvent('popup');
await button.click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await popup.close();
});
for (const [type, buttonName] of [['PromptManager', '🌐 Web UI'], ['PromptManagerFocused', 'Web UI ↗'], ['PromptManagerPair', 'Web UI ↗']]) {
test(`${type} and the PM button open the workspace after switching renderers both ways, a workflow reload and node removal`, async ({ page }, testInfo) => {
const id = await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
node.setSize([900, 800]);
app.canvas.centerOnNode(node);
return node.id;
}, type);
const webUi = page.getByRole('button', { name: buttonName, exact: true });
const pm = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first();
const opensWorkspace = async button => {
await expect(button).toHaveCount(1);
await expect(button).toBeVisible();
const popupPromise = page.waitForEvent('popup');
await button.click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await popup.close();
};
await opensWorkspace(webUi);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await opensWorkspace(webUi);
await opensWorkspace(pm);
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(app.graph.serialize());
});
await opensWorkspace(webUi);
await opensWorkspace(pm);
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(webUi).toHaveCount(0);
await opensWorkspace(pm);
});
}
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
+3 -3
View File
@@ -37,11 +37,11 @@ test('workspace serves real library data beside either ComfyUI renderer', async
await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
}
// D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
// D29: Images no longer links the legacy gallery; #525: nor does Overview. The page stays at its URL.
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
await workspace.evaluate(() => { location.hash = '/overview'; });
const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
const imagesCard = workspace.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true });
await expect(imagesCard).toHaveAttribute('href', '#/images');
expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
await workspace.evaluate(() => { location.hash = '/images'; });
await workspace.reload();
+34
View File
@@ -0,0 +1,34 @@
// #525: where the canvas buttons open PromptManager (web/js/workspace-entry.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const source = import('../../web/js/workspace-entry.js');
const state = import('../../web/js/workspace/state.mjs');
test('the canvas entry route is the workspace Library, a native route', async () => {
const { WORKSPACE_ENTRY_ROUTE } = await source;
const { resolveRoute } = await state;
assert.equal(WORKSPACE_ENTRY_ROUTE, '/prompt_manager/workspace#/library');
const [pathname, hash] = WORKSPACE_ENTRY_ROUTE.split('#');
assert.equal(pathname, '/prompt_manager/workspace');
assert.equal(resolveRoute(`#${hash}`).id, 'library');
});
test('/prompt_manager/web redirects to the same route, under any mount prefix', async () => {
const { WORKSPACE_ENTRY_ROUTE } = await source;
const html = fs.readFileSync(path.join(__dirname, '../../web/index.html'), 'utf8');
const [, pattern, flags, replacement] = html.match(/location\.replace\(\s*window\.location\.pathname\.replace\(\/(.+)\/([a-z]*), '([^']+)'\)/);
const redirect = pathname => pathname.replace(new RegExp(pattern, flags), replacement);
assert.equal(redirect('/prompt_manager/web'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/prompt_manager/web/'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/prompt_manager/index.html'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/sub/prompt_manager/web'), `/sub${WORKSPACE_ENTRY_ROUTE}`);
});
test('no canvas entry point still names a legacy page', () => {
for (const file of ['web/comfy/prompt_manager.js', 'web/js/focused-node.js', 'web/js/classic-node.js']) {
const code = fs.readFileSync(path.join(__dirname, '../..', file), 'utf8');
assert.doesNotMatch(code, /\/prompt_manager\/(?:admin|gallery|web)\b/, file);
}
});
+23 -5
View File
@@ -17,7 +17,7 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
const tags = resolveRoute('#/tags');
assert.equal(tags.label, 'Tags & categories');
assert.equal(routeTitle(tags), 'Tags');
assert.equal(tags.href, '/prompt_manager/admin#/tags', 'Overview keeps the existing tag manager card');
assert.equal(tags.href, '#/tags', 'Overview opens the native page (#525)');
for (const id of ['images', 'library', 'inspect', 'overview', 'system']) {
const route = resolveRoute(`#/${id}`);
assert.equal(routeTitle(route), route.label);
@@ -48,7 +48,6 @@ test('system resolves its sections; #/overview stays the production hub', async
// Q12: Overview's System card opens the native System Overview, with no legacy href or action.
const card = routes.find(route => route.id === 'system');
assert.equal(card.href, '#/system/overview');
assert.equal(card.native, true);
assert.equal(card.action, 'Open System');
});
@@ -77,9 +76,28 @@ test('inspect is the native Inspect a file page with the concept description', a
// The concept's heading description (app.js renderMetadata 743).
assert.equal(inspect.description, 'A standalone file uses the same image viewer and prompt actions.');
assert.equal(isHandoff(inspect), false);
// Overview keeps its card for the legacy metadata page, which stays working.
assert.equal(inspect.href, '/prompt_manager/gallery.html');
assert.equal(inspect.action, 'Open existing metadata inspector');
// Overview's card opens the native page (#525); the legacy metadata page stays at its URL.
assert.equal(inspect.href, '#/inspect');
assert.equal(inspect.action, 'Open Inspect a file');
});
test('every Overview card opens its own native page, never a legacy one (#525)', async () => {
const { resolveRoute, routes } = await source;
const cards = routes.filter(route => route.href);
assert.deepEqual(cards.map(card => card.id), ['images', 'library', 'tags', 'inspect', 'system']);
for (const card of cards) {
assert.match(card.href, /^#\//, card.id);
assert.equal(resolveRoute(card.href).id, card.id);
assert.match(card.action, /^Open /, card.id);
assert.doesNotMatch(card.action, /existing/, card.id);
}
});
test('the legacy pages System links are the four that stay at their URLs (#525 D165)', async () => {
const { LEGACY_PAGES } = await source;
assert.deepEqual(LEGACY_PAGES.map(page => page.href), ['/prompt_manager/admin', '/prompt_manager/admin#/tags',
'/prompt_manager/gallery', '/prompt_manager/gallery.html']);
for (const page of LEGACY_PAGES) assert.ok(page.label);
});
test('a job of the Activity page is its own deep link', async () => {
+16
View File
@@ -174,3 +174,19 @@ test('D158: "Save prompts from other workflows" drafts save_foreign_runs, disabl
assert.deepEqual(foreignRunsState({ value: true, monitoring: false, stopping: true }),
{ disabled: false, checked: true, note: FOREIGN_RUNS.off });
});
test('a failed notice write still dismisses for this page and tells watchers (#527)', async () => {
const { noticeDismissed, dismissNotice } = await sourcesModule;
const denied = () => { throw new Error('SecurityError'); };
assert.equal(noticeDismissed(denied), false);
assert.equal(dismissNotice(denied), false);
assert.equal(noticeDismissed(denied), true);
});
test('sourceCount reads the effective sources, null when the answer does not say (#527)', async () => {
const { sourceCount } = await import('../../web/js/workspace/older-images-notice.mjs');
assert.equal(sourceCount({ sources: { effective: [{}, {}] } }), 2);
assert.equal(sourceCount({ sources: { effective: [] } }), 0);
assert.equal(sourceCount({ sources: {} }), null);
assert.equal(sourceCount(null), null);
});
+2 -1
View File
@@ -25,7 +25,8 @@ test('execution script discovers only the new matrix and preserves all 78 origin
// + local Inspect host case (inspect-sender.spec.mjs, 2 projects), + the S10 renderer case (prompt-bridge.spec.mjs, 2 projects)
// + #343 linked text: five node types, PromptSearchList, the pre-#343 workflows, legacy Primitive, moved link,
// paste/clone/undo, wildcard Primitive, subgraph Primitive (text-link.spec.mjs, 2 projects)
assert.match(stock, /Total: 110 tests in 9 files/);
// + #525 the PM button and three nodes' Web UI after renderer switches and reload (prompt-manager.spec.mjs, 2 projects)
assert.match(stock, /Total: 118 tests in 9 files/);
assert.doesNotMatch(stock, /execution-history/);
assert.doesNotMatch(stock, /execution-outputs/);
assert.doesNotMatch(stock, /library-location/); // it starts its own fresh host (#249 S6b)
+12
View File
@@ -107,6 +107,12 @@
"functions": 98,
"statements": 98
},
"workspace-entry.js": {
"lines": 80,
"branches": 80,
"functions": 80,
"statements": 80
},
"save-to-library.js": {
"lines": 98,
"branches": 98,
@@ -401,6 +407,12 @@
"functions": 98,
"statements": 98
},
"workspace/older-images-notice.mjs": {
"lines": 80,
"branches": 70,
"functions": 80,
"statements": 80
},
"workspace/library-location.mjs": {
"lines": 94,
"branches": 91,
+10 -9
View File
@@ -7,6 +7,7 @@ const { installFocusedNode, installSplitNode, installPairNode } = await import(a
const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js'));
const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js'));
const { normalizeSaveWidget } = await import(api.fileURL('/prompt_manager/js/save-to-library.js'));
const { WORKSPACE_ENTRY_ROUTE } = await import(api.fileURL('/prompt_manager/js/workspace-entry.js'));
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
// ============================================================================
@@ -109,7 +110,7 @@ function addPMTopBarButton() {
// Create PM button
const pmButton = $el("button", {
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
title: "PromptManager - Open Admin Dashboard",
title: "PromptManager - Open workspace",
style: {
display: "flex",
alignItems: "center",
@@ -125,7 +126,7 @@ function addPMTopBarButton() {
transition: "background 0.2s",
},
onclick: () => {
window.open(api.fileURL("/prompt_manager/admin"), "_blank");
window.open(api.fileURL(WORKSPACE_ENTRY_ROUTE), "_blank");
},
onmouseenter: (e) => { e.target.style.background = "#0d9488"; },
onmouseleave: (e) => { e.target.style.background = "#14b8a6"; },
@@ -161,7 +162,7 @@ function addPMTopBarButtonLegacy() {
const btn = document.createElement("button");
btn.id = "pm-toolbar-btn";
btn.innerText = "PM";
btn.title = "Open PromptManager Admin Dashboard";
btn.title = "Open PromptManager workspace";
// Teal button styling
btn.style.cssText = `
@@ -181,7 +182,7 @@ function addPMTopBarButtonLegacy() {
btn.onmouseleave = () => btn.style.background = "#14b8a6";
btn.onclick = () => {
window.open(api.fileURL("/prompt_manager/admin"), "_blank");
window.open(api.fileURL(WORKSPACE_ENTRY_ROUTE), "_blank");
};
topBar.appendChild(btn);
@@ -1184,9 +1185,7 @@ app.registerExtension({
// Method to open web interface
nodeType.prototype.openWebInterface = function () {
try {
// Open the web interface served via our API endpoint
const currentOrigin = window.location.origin;
const webUrl = `${currentOrigin}/prompt_manager/web`;
const webUrl = api.fileURL(WORKSPACE_ENTRY_ROUTE);
if (this.properties.webuiDisplayMode === "newtab") {
// Open in new tab without window options
@@ -1198,7 +1197,8 @@ app.registerExtension({
"success",
);
} else {
window.location.href = webUrl;
// Never navigate the canvas away: it may hold unsaved work.
this.showNotification("Allow popups to open Web UI, then try again.", "error");
}
} else {
// Default popup mode with specific dimensions
@@ -1211,7 +1211,8 @@ app.registerExtension({
if (newWindow) {
this.showNotification("Web interface opened in popup", "success");
} else {
window.location.href = webUrl;
// Never navigate the canvas away: it may hold unsaved work.
this.showNotification("Allow popups to open Web UI, then try again.", "error");
}
}
} catch (error) {
+5 -5
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromptManager - Redirecting to Admin</title>
<title>PromptManager - Opening the workspace</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -49,13 +49,13 @@
<div class="container">
<div class="spinner"></div>
<h1>🔍 PromptManager</h1>
<p>Redirecting to Admin Dashboard...</p>
<p><small>Loading the full-featured prompt management interface</small></p>
<p>Opening the workspace...</p>
<noscript><p><a href="workspace#/library">Open the workspace</a></p></noscript>
</div>
<script>
// Redirect to admin interface immediately
window.location.href = window.location.pathname.replace(/\/(?:web|index\.html)\/?$/, '/admin');
// Replace, so Back doesn't return here and bounce. Same route as web/js/workspace-entry.js (#525).
window.location.replace(window.location.pathname.replace(/\/(?:web|index\.html)\/?$/, '/workspace#/library'));
</script>
</body>
</html>
+2 -1
View File
@@ -1,5 +1,6 @@
import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js';
import {createPromptLibrary} from './prompt-library.js';
import {WORKSPACE_ENTRY_ROUTE} from './workspace-entry.js';
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
import {exposeTextSockets, linkNoteText, preferTextInputs, textInputAtPoint} from './text-links.js';
import {SAVE_WIDGET, normalizeSaveWidget, placeSaveWidgetLast, saveWidget, savesToLibrary, setSaveToLibrary} from './save-to-library.js';
@@ -253,7 +254,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
}
});
scope.listen(web,'click',() => {
const url = api.fileURL('/prompt_manager/web');
const url = api.fileURL(WORKSPACE_ENTRY_ROUTE);
const opened = webDisplayMode === 'newtab' ? window.open(url,'_blank') : window.open(url,'_blank','width=1200,height=800,scrollbars=yes,resizable=yes');
footer.setAttribute('role',opened ? 'status' : 'alert');
footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.';
+2
View File
@@ -0,0 +1,2 @@
// Where the canvas's PM and Web UI buttons open PromptManager (#525). web/index.html redirects to the same route.
export const WORKSPACE_ENTRY_ROUTE = '/prompt_manager/workspace#/library';
+10 -5
View File
@@ -23,6 +23,7 @@ import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
import { createRestoreReload, onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
import { mountLibraryBanner } from './library-location.mjs';
import { mountOlderImagesNotice } from './older-images-notice.mjs';
import { detectJxlSupport } from './thumbnail-support.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -34,10 +35,9 @@ function element(tag, text, className) {
return node;
}
function legacyLink(route) {
function cardLink(route) {
const link = element('a', route.action, 'workspace-link');
// A native card (System, Q12) links its workspace hash; a legacy tool's page goes through the mount prefix.
link.href = route.native ? route.href : resolveUrl(route.href);
link.href = route.href;
return link;
}
@@ -86,6 +86,8 @@ function mountWorkspace() {
activity.load();
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
// D166: the one-time upgrade notice, above every page until dismissed; System > Sources shows its own.
const olderImages = mountOlderImagesNotice(document.getElementById('older-images-notice'), { api, activity, scope, main });
const sidebar = document.querySelector('aside.sidebar');
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`, a collection's `collection/<id>`.
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}`
@@ -260,6 +262,7 @@ function mountWorkspace() {
function renderNow(route) {
leaveGuard = null;
olderImages.showFor(route);
// A shared page dialog never stays open over the next route.
closeWorkspaceDialog();
if (route.id === 'system' && system && currentKey === 'system') {
@@ -485,11 +488,11 @@ function mountWorkspace() {
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.'));
tools.append(element('h2', 'Continue creating'), element('p', 'Pick up where you left off in your images, prompts and tools.'));
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));
card.append(element('h3', target.label), element('p', target.description, 'small'), cardLink(target));
cards.append(card);
}
tools.append(cards);
@@ -529,6 +532,8 @@ function mountWorkspace() {
}
mountWorkspace();
// #525: web/workspace.html shows its legacy-page fallback at `load` unless the shell got this far.
document.documentElement.dataset.workspaceBooted = 'true';
window.addEventListener('pageshow', event => {
if (event.persisted) mountWorkspace();
});
+129
View File
@@ -0,0 +1,129 @@
/**
* The one-time upgrade notice "Bring older images into your prompt history" (#527, D166).
*
* Its dismissal is the legacy admin page's own `pm_update_notice_dismissed`, so dismissing it anywhere dismisses it
* everywhere in this browser. The shell shows it above every page; System > Sources keeps the concept's copy in the
* page, so the shell hides on that section and the two never show together. Storage that cannot be written still
* dismisses it for this page.
*/
import { createButton } from '../shared/toolkit/core.js';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
export const NOTICE_KEY = 'pm_update_notice_dismissed';
export const NOTICE_VERSION = '3.2.0';
export const SOURCES_UNREADABLE = 'Could not read the image sources. Try again.';
const SETTINGS_URL = '/prompt_manager/settings';
const defaultStorage = () => window.localStorage;
const changes = new EventTarget();
// The storages this page dismissed the notice in without being able to write it.
const dismissedHere = new WeakSet();
/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */
export function noticeDismissed(getStorage = defaultStorage) {
if (dismissedHere.has(getStorage)) return true;
try {
return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION;
} catch {
return false;
}
}
/** Dismiss the notice everywhere on this page. Returns whether it was saved for the next visit. */
export function dismissNotice(getStorage = defaultStorage) {
let saved = true;
try {
getStorage().setItem(NOTICE_KEY, NOTICE_VERSION);
} catch {
dismissedHere.add(getStorage);
saved = false;
}
changes.dispatchEvent(new Event('dismissed'));
return saved;
}
/** Calls `onDismissed` when the notice is dismissed on this page or in another tab, until `scope` ends. */
export function watchNoticeDismissal({ scope, storage = defaultStorage, onDismissed }) {
changes.addEventListener('dismissed', onDismissed);
scope.cleanup(() => changes.removeEventListener('dismissed', onDismissed));
scope.listen(window, 'storage', event => {
if (event.key === NOTICE_KEY && noticeDismissed(storage)) onDismissed();
});
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The concept's `.notice` with Later and Review scan. */
export function noticeNode({ scope, onLater, onReview }) {
const notice = el('div', undefined, 'notice');
const text = el('div');
text.append(el('h2', 'Bring older images into your prompt history'),
el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.'));
const actions = el('div', undefined, 'row-actions');
const later = createButton('Later');
const review = createButton('Review scan', 'ca-primary');
scope.listen(later, 'click', onLater);
scope.listen(review, 'click', () => onReview(review));
actions.append(later, review);
notice.append(text, actions);
return notice;
}
/** How many output folders a scan reads, from GET /settings; null when the answer does not say. */
export function sourceCount(settings) {
const count = settings?.sources?.effective?.length;
return Number.isSafeInteger(count) ? count : null;
}
/**
* Mount the shell's notice in `container`, hidden until the first `showFor(route)`. The pressed button leaves with the
* notice, so focus moves to `main`. If the sources cannot be read, Review scan says so and the notice stays.
*/
export function mountOlderImagesNotice(container, { api, activity, scope, main, storage = defaultStorage, notify = showWorkspaceToast }) {
let onSources = true;
let reading = false;
const render = () => {
const show = !onSources && !noticeDismissed(storage);
container.hidden = !show;
if (!show) notice.remove();
else if (!notice.isConnected) container.append(notice);
};
const notice = noticeNode({ scope,
onLater: () => {
dismissNotice(storage);
main.focus({ preventScroll: true });
},
onReview: async button => {
if (reading) return;
reading = true;
button.setAttribute('aria-disabled', 'true');
let sources = null;
try {
sources = sourceCount(await api.get(SETTINGS_URL, { signal: scope.signal, cache: 'no-store' }));
} catch {
if (scope.signal.aborted) return;
}
reading = false;
button.removeAttribute('aria-disabled');
if (sources === null) {
notify(SOURCES_UNREADABLE);
return;
}
dismissNotice(storage);
main.focus({ preventScroll: true });
openMetadataScan({ scope, store: activity, sources, trigger: main });
} });
watchNoticeDismissal({ scope, storage, onDismissed: render });
return {
showFor(route) {
onSources = route.id === 'system' && route.section === 'sources';
render();
},
};
}
+22 -14
View File
@@ -1,14 +1,15 @@
export const routes = [
{ id: 'images', label: 'Images', href: '/prompt_manager/gallery',
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open existing image gallery' },
{ id: 'library', label: 'Prompt library', href: '/prompt_manager/admin',
description: 'Find, edit and organize the prompts already in your library.', action: 'Open existing prompt library' },
// Native screen; `title` is the concept's breadcrumb/title, the href stays for Overview's card.
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '/prompt_manager/admin#/tags',
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
// Native screen (#249 local Inspect); the href stays for Overview's card to the legacy metadata page.
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
description: 'A standalone file uses the same image viewer and prompt actions.', action: 'Open existing metadata inspector' },
// `href` and `action` are Overview's card (#525): every card opens its native page, never a legacy one.
{ id: 'images', label: 'Images', href: '#/images',
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open Images' },
{ id: 'library', label: 'Prompt library', href: '#/library',
description: 'Find, edit and organize the prompts already in your library.', action: 'Open Prompt library' },
// `title` is the concept's breadcrumb/title.
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '#/tags',
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open Tags & categories' },
// #249 local Inspect.
{ id: 'inspect', label: 'Inspect a file', href: '#/inspect',
description: 'A standalone file uses the same image viewer and prompt actions.', action: 'Open Inspect a file' },
{ 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' },
@@ -18,13 +19,20 @@ export const routes = [
{ id: 'review', label: 'Needs review', title: 'Review' },
// The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card.
{ id: 'activity', label: 'Activity' },
// Native System (#249 System S2): `#/system/<section>`. Q12: Overview's card opens its Overview section (`native`:
// a workspace hash, not a legacy page); every section is native (S12 retired D94's hand-off). The description is
// the concept's System heading (app.js renderSystem 700).
{ id: 'system', label: 'System', href: '#/system/overview', native: true,
// Native System (#249 System S2): `#/system/<section>`. Q12: Overview's card opens its Overview section; every
// section is native (S12 retired D94's hand-off). The description is the concept's System heading (app.js renderSystem 700).
{ id: 'system', label: 'System', href: '#/system/overview',
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
];
/** D165 (#525): the legacy pages, which stay at their URLs; System's Overview section links them discreetly. */
export const LEGACY_PAGES = Object.freeze([
{ label: 'Dashboard', href: '/prompt_manager/admin' },
{ label: 'Tags', href: '/prompt_manager/admin#/tags' },
{ label: 'Gallery', href: '/prompt_manager/gallery' },
{ label: 'Metadata viewer', href: '/prompt_manager/gallery.html' },
].map(page => Object.freeze(page)));
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
'collection']);
+7 -39
View File
@@ -2,8 +2,9 @@
* System › Sources & scans (#249 System S4 Task 11): the concept's `renderSystem` "sources" body (app.js 679–681).
*
* - The optional `.notice` "Bring older images into your prompt history" shows until this browser dismissed the 3.2.0
* update notice (`pm_update_notice_dismissed`, the legacy admin page's own key). Later dismisses it without scanning;
* Review scan dismisses it, as the legacy page's Scan does, and reviews every source.
* update notice (older-images-notice.mjs, the legacy admin page's own key). Later dismisses it without scanning;
* Review scan dismisses it, as the legacy page's Scan does, and reviews every source. D166: dismissing it in the shell
* or another tab removes it here too, and the shell does not show it on this section.
* - "Image sources" lists the server's sources (GET /settings, S4 Task 9) in `#source-list`, one `.operation` each:
* its label, "<path> · watching | watching until restart | manual scan | restart pending" and "Scan changes"
* (watched) or "Scan folder", which opens the shared "Review metadata scan" (D55) for that one source ("1 output
@@ -38,14 +39,15 @@ import { settingRow, operationRow } from './system-controls.mjs';
import { HOST_UNAVAILABLE } from './system-appearance.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { noun } from './plural.mjs';
import { dismissNotice, noticeDismissed, noticeNode, watchNoticeDismissal } from './older-images-notice.mjs';
export { NOTICE_KEY, NOTICE_VERSION, dismissNotice, noticeDismissed } from './older-images-notice.mjs';
const SETTINGS_URL = '/prompt_manager/settings';
const VALIDATE_URL = '/prompt_manager/settings/validate-source';
const RESCAN_URL = '/prompt_manager/gallery/rescan';
const ROOTS_KEY = 'gallery_root_paths';
export const NOTICE_KEY = 'pm_update_notice_dismissed';
export const NOTICE_VERSION = '3.2.0';
export const WATCH_COPY = Object.freeze({ watching: 'watching', stopping: 'watching until restart', manual: 'manual scan',
restart_pending: 'restart pending' });
// The states whose observer runs now: their scan only has to catch up on changes.
@@ -87,25 +89,6 @@ const addedLabels = new Map();
const defaultStorage = () => window.localStorage;
/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */
export function noticeDismissed(getStorage = defaultStorage) {
try {
return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION;
} catch {
return false;
}
}
/** Dismiss the notice for this browser; false when storage refused (it stays dismissed for this view). */
export function dismissNotice(getStorage = defaultStorage) {
try {
getStorage().setItem(NOTICE_KEY, NOTICE_VERSION);
return true;
} catch {
return false;
}
}
const sameRoots = (a, b) => Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((path, index) => path === b[index]);
/** The roots to draft: the saved list itself when they are equal, so the draft is clean again. */
@@ -168,22 +151,6 @@ function el(tag, text, className) {
return node;
}
/** The concept's `.notice` with Later and Review scan. */
function noticeNode({ scope, onLater, onReview }) {
const notice = el('div', undefined, 'notice');
const text = el('div');
text.append(el('h2', 'Bring older images into your prompt history'),
el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.'));
const actions = el('div', undefined, 'row-actions');
const later = createButton('Later');
const review = createButton('Review scan', 'ca-primary');
scope.listen(later, 'click', onLater);
scope.listen(review, 'click', onReview);
actions.append(later, review);
notice.append(text, actions);
return notice;
}
/**
* "Add image source" (the concept's `simpleInput`, D127): the server checks the folder; a refused one keeps the
* dialog open with the server's reason under the field. Enter in the field adds, as the Add button does.
@@ -263,6 +230,7 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
notice.remove();
scanAll(addButton);
} });
watchNoticeDismissal({ scope, storage, onDismissed: () => notice.remove() });
nodes.push(notice);
}
+20 -1
View File
@@ -12,7 +12,7 @@
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
*/
import { createButton, createScope } from '../shared/toolkit/core.js';
import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
import { LEGACY_PAGES, SYSTEM_SECTIONS, navigateTo } from './state.mjs';
import { createSystemDrafts, DRAFT_DOMAINS, INVALID_SETTING_CODE } from './system-state.mjs';
import { sectionNav } from './system-controls.mjs';
import { mountOverview } from './system-overview.mjs';
@@ -143,6 +143,22 @@ export const DELIVERED_SECTIONS = Object.freeze({
* under one of this tab's drafts flags that draft (D92's conflict status), including a change made while System was
* not mounted (the drafts outlive it, Q10).
*/
/** D165 (#525): one small line under System's Overview, the workspace's only visible way to the legacy pages. */
function legacyPagesLine() {
const line = el('p', 'small legacy-pages');
line.append('Legacy pages: ');
LEGACY_PAGES.forEach((page, index) => {
const link = document.createElement('a');
link.textContent = page.label;
link.href = window.ApiClient.resolveUrl(page.href);
link.target = '_blank';
link.rel = 'noopener';
if (index) line.append(' · ');
line.append(link);
});
return line;
}
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
const store = systemDrafts(api);
jobs = activity;
@@ -168,6 +184,7 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE
saveRow.append(status, save);
layout.append(saveRow);
main.append(nav, layout);
const legacyPages = legacyPagesLine();
const paintStatus = () => {
status.textContent = store.statusText();
@@ -193,6 +210,8 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE
// Every section is delivered (S12), and the router only names known sections (state.mjs).
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store });
layout.prepend(...nodes);
if (next === 'overview') main.append(legacyPages);
else legacyPages.remove();
paintStatus();
}
show(section);
+1
View File
@@ -509,6 +509,7 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
}
.more-menu-list[hidden] { display: none; }
.more-menu-list > button { justify-content: flex-start; text-align: left; width: 100%; white-space: nowrap; }
.legacy-pages { margin: 16px 0 0; color: var(--muted); /* D165 */ }
/* D164 (#524): the prompt page has four heading actions and titles of any length, which the concept never shows. From
the concept's sidebar breakpoint up the actions keep one row and the title takes what is left, clamped to two lines
+16
View File
@@ -13,6 +13,13 @@
<script src="js/comfy-metadata.js" defer></script>
<script src="js/gallery-settings.js" defer></script>
<script src="js/png-metadata.js" defer></script>
<script>
// #525: no module of the workspace can show this. A failed fetch or a throw while starting leaves the mark
// unset, and modules run before `load`.
window.addEventListener('load', () => {
if (!document.documentElement.dataset.workspaceBooted) document.getElementById('workspace-boot-failure').hidden = false;
});
</script>
<script type="module" src="js/workspace/index.mjs"></script>
</head>
<body class="ca-tools">
@@ -45,11 +52,20 @@
</header>
<!-- D129: the library-location notice, shown above every page while a node-folder library can move. -->
<div id="library-banner" class="library-banner" hidden></div>
<!-- D166: the one-time upgrade notice, shown above every page until dismissed. -->
<div id="older-images-notice" class="library-banner" hidden></div>
<main id="workspace-main" tabindex="-1"></main>
<footer class="app-footer"><span>ComfyAssets · Local tools. Your library.</span></footer>
</div>
</div>
<div id="workspace-toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="workspace-boot-failure" role="alert" hidden style="position: fixed; inset: 16px 16px auto; z-index: 1000; padding: 16px; border: 1px solid #888; border-radius: 8px; background: #1b1b1f; color: #eee; font: 14px/1.5 system-ui, sans-serif;">
<p style="margin: 0;">The workspace couldn't load. Reload the page, or use a legacy page:
<a style="color: inherit;" href="admin">Dashboard</a> ·
<a style="color: inherit;" href="admin#/tags">Tags</a> ·
<a style="color: inherit;" href="gallery">Gallery</a> ·
<a style="color: inherit;" href="gallery.html">Metadata viewer</a></p>
</div>
<noscript><p>This workspace needs JavaScript. <a href="admin">Open the existing dashboard</a>.</p></noscript>
</body>
</html>