Compare commits

...
Author SHA1 Message Date
Vito Sansevero 196abf0502 Merge remote-tracking branch 'origin/main' into feat/334-cutover-switch
# Conflicts:
#	web/js/workspace/workspace.css
2026-10-30 08:02:42 -07:00
Vito Sansevero e0fdde68b6 test(cutover): count the new real-host cases in the stock matrix pin
The PM button test and three renderer-and-reload tests, in 2 projects, take the
stock matrix from 110 to 118.

Part of #525
2026-10-30 05:18:16 -07:00
Vito Sansevero 10f987fcdc test(cutover): prove the Pair node's Web UI on the real host
Part of #525
2026-10-30 04:43:24 -07:00
Vito Sansevero 901b7be156 feat(cutover): link the legacy pages from System and when the workspace fails to load
System's Overview section ends with one small Legacy pages line (D165). If a
workspace module fails to load or throws while starting, the page shows a static
message with the same links instead of an empty shell.

Part of #525
2026-10-30 04:42:40 -07:00
Vito Sansevero ec06120a00 feat(cutover): every entry point opens the workspace; legacy pages stay reachable
The top-bar PM button, the nodes' Web UI buttons and /prompt_manager/web now
open the workspace Library. Overview's cards open their native pages. The legacy
pages stay at their URLs, with no route changed.

Classic Web UI no longer navigates the canvas away when a popup is blocked; it
shows a notification, as the focused node does.

Closes #525
2026-10-30 04:27:03 -07:00
22 changed files with 379 additions and 82 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**
+26 -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`.
@@ -3117,8 +3117,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
@@ -7029,8 +7029,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
@@ -7152,3 +7152,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');
+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})=>{
+3 -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 }) => {
+103 -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 }) => {
@@ -438,7 +524,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');
+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 () => {
+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)
+6
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,
+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';
+6 -5
View File
@@ -34,10 +34,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;
}
@@ -485,11 +484,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 +528,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();
});
+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']);
+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
@@ -493,6 +493,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
+14
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">
@@ -50,6 +57,13 @@
</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>