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
This commit is contained in:
Vito Sansevero
2026-10-30 04:42:40 -07:00
parent ec06120a00
commit 901b7be156
11 changed files with 186 additions and 9 deletions
+1 -1
View File
@@ -252,7 +252,7 @@ match Classic and Focused.
5. **Access the web interface**:
- **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 the workspace doesn't link to them:
- **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
+15 -5
View File
@@ -2642,7 +2642,7 @@ the grid) and D43 (sorted chips, checked against the concept with its chips sort
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; the legacy Library stays at
`/prompt_manager/admin` by direct URL, with no workspace link (#525)
`/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`.
@@ -3110,7 +3110,7 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
the legacy metadata page, which stays at its URL
(`/prompt_manager/gallery.html`) with no workspace link (#525). The page is the concept's `#metadata`
(`/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
@@ -7022,7 +7022,7 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays at
`/prompt_manager/admin#/tags` with no workspace link (#525); tag search and sort, the untagged
`/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
@@ -7151,6 +7151,16 @@ 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. Only the workspace's `<noscript>` links one, and admin's Metadata and Gallery buttons
still open their legacy pages (pinned by `legacy-system-links.spec.mjs`). The PM button has no
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`).
+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 }) => {
+66 -1
View File
@@ -130,6 +130,71 @@ test('/prompt_manager/web lands on the workspace Library and Back does not bounc
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 }) => {
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
@@ -459,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');
+49
View File
@@ -114,6 +114,55 @@ test('top-bar PM button opens the workspace in either renderer', async ({ page }
await popup.close();
});
for (const [type, buttonName] of [['PromptManager', '🌐 Web UI'], ['PromptManagerFocused', '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');
+7
View File
@@ -93,6 +93,13 @@ test('every Overview card opens its own native page, never a legacy one (#525)',
}
});
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 () => {
const { resolveRoute, routeTitle, isHandoff } = await source;
const id = '0123456789abcdef'.repeat(2);
+2
View File
@@ -528,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();
});
+8
View File
@@ -25,6 +25,14 @@ export const routes = [
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,3 +493,4 @@ 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 */ }
+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>