diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 45166e2..c22f6a0 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -539,6 +539,8 @@ test('counts refresh after a confirmed editor save', async ({ page, request }) = await expect(navCount(page, 'library')).toHaveText(String(after.library)); await expect(navCount(page, 'tags')).toHaveText(String(after.tags)); expect(reads.filter(path => path === '/prompt_manager/library').length).toBeGreaterThanOrEqual(1); + // A Library write cannot change the Images count, so the Images listing is not read again. + expect(reads.filter(path => path === '/prompt_manager/images/output')).toEqual([]); }); test('a superseded count read never lands and a failed refresh removes the stale count', async ({ page, request }) => { diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index 86d810e..ef6c222 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -131,3 +131,19 @@ test('nav counts issue three bounded requests and drop only the failed count', a assert.equal(urls[0].searchParams.get('mode'), 'workspace'); assert.ok(calls.every(([, used]) => used === signal)); }); + +test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => { + const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source; + assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']); + assert.ok(Object.isFrozen(LIBRARY_WRITE_COUNTS)); + const calls = []; + const api = {get: async url => { + calls.push(new URL(url, 'http://host').pathname); + return {success: true, total: url.startsWith('/prompt_manager/library') ? 4 : 9}; + }}; + const settled = []; + const counts = await loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]), LIBRARY_WRITE_COUNTS); + assert.deepEqual(calls, ['/prompt_manager/library', '/prompt_manager/vocabulary/tags']); + assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]); + assert.deepEqual([...counts], [['library', 4], ['tags', 9]]); +}); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 44208cb..a525b17 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -1,6 +1,7 @@ import '../api-client.js'; import { createButton, createScope } from '../shared/toolkit/core.js'; -import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts } from './state.mjs'; +import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts, + LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs'; import { mountWorkspaceTheme } from './theme.mjs'; import { mountImages } from './images.mjs'; import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs'; @@ -59,21 +60,26 @@ function mountWorkspace() { // D28: System, then the production Overview hub, in the concept's tools-and-system group. systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']])); const sidebar = document.querySelector('aside.sidebar'); - let countsRequest; - function refreshCounts() { - countsRequest?.abort(); - const request = countsRequest = new AbortController(); - // Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale. - loadNavCounts(api, request.signal, (id, count) => { - if (request.signal.aborted) return; - const link = primaryNav.querySelector(`[data-route="${id}"]`); - link?.querySelector('.nav-count')?.remove(); - if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count')); - }); + // One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight. + const countRequests = new Map(); + function refreshCounts(ids) { + for (const id of ids) { + countRequests.get(id)?.abort(); + const request = new AbortController(); + countRequests.set(id, request); + // Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale. + loadNavCounts(api, request.signal, (_, count) => { + if (request.signal.aborted) return; + const link = primaryNav.querySelector(`[data-route="${id}"]`); + link?.querySelector('.nav-count')?.remove(); + if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count')); + }, [id]); + } } - scope.cleanup(() => countsRequest?.abort()); - scope.cleanup(onLibraryWrite(refreshCounts)); - refreshCounts(); + scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); }); + // A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read. + scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS))); + refreshCounts(NAV_COUNT_ROUTES); let viewScope; let firstRender = true; scope.cleanup(() => viewScope?.dispose()); diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index 6cb6f81..831ad0b 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -61,13 +61,20 @@ export function summarizeNavCounts(pages = {}) { * Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read * settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts. */ -export async function loadNavCounts(api, signal, onCount = () => {}) { +/** The WORKSPACE routes that carry a count. */ +export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id)); + +/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */ +export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']); + +/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */ +export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) { const pages = {}; - await Promise.all(NAV_COUNT_SOURCES.map(async ([id, url]) => { + await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => { try { pages[id] = await api.get(url, { signal }); } catch { pages[id] = undefined; } onCount(id, summarizeNavCounts(pages).get(id)); })); - return summarizeNavCounts(pages); + return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id))); } export function mountRouter(win, onRoute) {