From 2d602857780ccf48e0e6e4cc86ce297c0e82d233 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 09:51:43 -0700 Subject: [PATCH] fix(images): one discovery alert when sources and categories both fail With the server unreachable on load, the sources and the categories status paragraphs each fired role="alert" back to back. The page now keeps one alert, written once both first loads settled, naming whatever failed ("Sources, folders and categories could not be loaded. Rescan from Import to retry." when both did). Failing before: getByRole('alert') resolved to two elements. --- tests/browser/workspace-images.spec.mjs | 14 +++++++ web/js/workspace/images.mjs | 49 +++++++++++++++++-------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 82d8e54..517dea0 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1530,3 +1530,17 @@ test('Apply or Clear without a change keeps the loaded page', async ({ page, req await refine(page, { Media: 'image' }); await expect.poll(fetches).toBe(1); }); + +// #249 final review m6: discovery and categories failing together on mount give one alert, not two. +test('unreachable sources and categories announce one error', async ({ page }) => { + await page.route('**/prompt_manager/gallery/subfolders?*', route => route.abort('failed')); + await page.route('**/prompt_manager/library/options*', route => route.abort('failed')); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + await expect(page.getByRole('alert')).toHaveText('Sources, folders and categories could not be loaded. Rescan from Import to retry.'); + await expect(page.locator('.options-status')).toHaveCount(1); + await page.unroute('**/prompt_manager/gallery/subfolders?*'); + await page.unroute('**/prompt_manager/library/options*'); + await rescan(page); + await expect(page.getByRole('alert')).toHaveCount(0); +}); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 2ca3d2d..4d5203b 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -47,8 +47,14 @@ function selectOf(id, options, value) { const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'], ['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]); const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]); -const SOURCES_FAILED = 'Sources and folders could not be loaded. Rescan from Import to retry.'; -const CATEGORIES_FAILED = 'Categories could not be loaded. Rescan from Import to retry.'; +const RETRY_RESCAN = 'Rescan from Import to retry.'; +const RETRY_REFRESH = 'Refresh image collection to retry.'; +/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */ +function loadFailure(sourcesRetry, categoriesFailed) { + if (sourcesRetry && categoriesFailed) return `Sources, folders and categories could not be loaded. ${sourcesRetry}`; + if (sourcesRetry) return `Sources and folders could not be loaded. ${sourcesRetry}`; + return categoriesFailed ? `Categories could not be loaded. ${RETRY_RESCAN}` : ''; +} // Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer // counts every listed file across the output roots it walked. const RESCAN_STARTED = 'Rescanning output folders…'; @@ -136,9 +142,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : []; const optionsStatus = el('p', '', 'options-status muted'); optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert'); - const categoryStatus = el('p', '', 'options-status muted'); - categoryStatus.hidden = true; categoryStatus.setAttribute('role', 'alert'); - if (!embedded) main.append(optionsStatus, categoryStatus); + // The failed loads' retry hint (null once sources load) and whether the categories failed. + let sourcesRetry = null, categoriesFailed = false; + if (!embedded) main.append(optionsStatus); // The concept's filter row: layouts, thumbnail size, the scope chip and Group by positive prompt (D14, D15). const filterRow = el('div', undefined, 'filter-row'); const layouts = el('div', undefined, 'segmented'); @@ -596,8 +602,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage try { const result = await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal}); if (scope.signal.aborted) return; - loadCategories(); - if (await loadOptions() && !scope.signal.aborted) reset(); + const [found] = await Promise.all([loadOptions(), loadCategories()]); + if (scope.signal.aborted) return; + renderLoadStatus(); + if (found) reset(); if (!scope.signal.aborted) showWorkspaceToast(rescanFinished(result)); } catch { // A failed rescan leaves the collection and any discovery alert as they were. @@ -615,14 +623,24 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (toolbar) filters.q = toolbar.value().q.trim(); roots = [];folders = []; setControlsDisabled(true); - optionsStatus.hidden = true; + sourcesRetry = null; + renderLoadStatus(); reset(); const discovery = loadOptions(), request = optionsRequest; - if (!await discovery || scope.signal.aborted || request !== optionsRequest) return; + const found = await discovery; + if (scope.signal.aborted || request !== optionsRequest) return; + renderLoadStatus(); + if (!found) return; recovering = false; setControlsDisabled(false); reset(); } + function renderLoadStatus() { + const text = loadFailure(sourcesRetry, categoriesFailed); + setText(optionsStatus, text); + optionsStatus.hidden = !text; + } + /** Discover the sources and folders; the caller renders the outcome with renderLoadStatus. */ async function loadOptions() { optionsRequest?.abort(); const request = new AbortController(); optionsRequest = request; @@ -637,12 +655,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage .map(root => ({index:root.index, label:root.label, subfolders:paths(root.subfolders)})); folders = paths(data.subfolders); updateScope(); - optionsStatus.hidden = true; + sourcesRetry = null; return true; } catch { if (scope.signal.aborted || request !== optionsRequest) return false; - optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : SOURCES_FAILED; - optionsStatus.hidden = false; + sourcesRetry = recovering ? RETRY_REFRESH : RETRY_RESCAN; return false; } } @@ -656,16 +673,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (scope.signal.aborted || request !== categoryRequest) return; if (!Array.isArray(data?.categories)) throw new TypeError('Invalid categories'); toolbar.setCategories([...data.categories.filter(value => typeof value === 'string'), filters.category]); - categoryStatus.hidden = true; + categoriesFailed = false; } catch { if (scope.signal.aborted || request !== categoryRequest) return; - categoryStatus.textContent = CATEGORIES_FAILED; - categoryStatus.hidden = false; + categoriesFailed = true; } } scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); }); updateScope(); - if (!embedded) { loadOptions(); loadCategories(); } + // The page's one discovery alert is written once both first loads settled (#249 final review m6). + if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => { if (!scope.signal.aborted) renderLoadStatus(); }); if (visit) { renderState(stream.state); const selected = stream.state.items.find(image => imageKey(image) === selection);