diff --git a/packages/webui/src/public/panes.js b/packages/webui/src/public/panes.js index 70f954c69..da71ec7b4 100644 --- a/packages/webui/src/public/panes.js +++ b/packages/webui/src/public/panes.js @@ -269,7 +269,7 @@ restore(p, body, kept); } const content = body.querySelector('[data-project-content]'); - if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org) }); + if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org), idPrefix: `pane-${p.key}-` }); return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`; } // A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint diff --git a/packages/webui/src/public/projects.js b/packages/webui/src/public/projects.js index 4d6509e65..4ee0daf57 100644 --- a/packages/webui/src/public/projects.js +++ b/packages/webui/src/public/projects.js @@ -205,14 +205,15 @@ // state lives as long as the container. const paneViews = new WeakMap(); const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);}; - function requirementView(data,project,selected,expandedSet=expandedPrds) { + function requirementView(data,project,selected,expandedSet=expandedPrds,idPrefix='') { + const pid=esc(idPrefix); const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft; const north=typeof data.north?.content==='string'?data.north.content:null; - const select=options.length>1?``:''; + const select=options.length>1?``:''; const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id); const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null; const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`
`).join('') : 'Unavailable until a PRD is read.
'; - return `${r?`${clean(r.id)}`} · ${chip(r.state)} · from ${clean(r.thread.name)} Open conversation${window.MosaicRecords.markup(project.repo)}
`:data.northMissing?'No north star yet.
':project.repo?'North star unavailable. Docs could not read this project’s repository.
':`This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.
`}${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}
${clean(req.text)}
Requirement identity: ${clean(candidate.requirements[i]?.path)} · ${clean(candidate.requirements[i]?.blob)}
${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}
`}${d && data.requests.some(x=>x.materialFailed)?'Some request PRDs could not be read. Refresh to try again.
':''}${clean(r.id)}`} · ${chip(r.state)} · from ${clean(r.thread.name)} Open conversation${window.MosaicRecords.markup(project.repo)}
`:data.northMissing?'No north star yet.
':project.repo?'North star unavailable. Docs could not read this project’s repository.
':`This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.
`}${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}
${clean(req.text)}
Requirement identity: ${clean(candidate.requirements[i]?.path)} · ${clean(candidate.requirements[i]?.blob)}
${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}
`}${d && data.requests.some(x=>x.materialFailed)?'Some request PRDs could not be read. Refresh to try again.
':''}Some request data could not be read; this list may be incomplete.
'); const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id)); const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); } const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); } - container.querySelector('#project-request-select')?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector('#project-request-select')?.focus({preventScroll:true});}); + const requestSelect='#'+CSS.escape(idPrefix+'project-request-select'); + container.querySelector(requestSelect)?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector(requestSelect)?.focus({preventScroll:true});}); }; draw(selection); restore=liveRestore || restore; @@ -298,7 +302,7 @@ restoreView(container,restore); } const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section'); - if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'}); + if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#'+CSS.escape(idPrefix+'project-'+sectionTarget))?.scrollIntoView({block:'start'}); } // Cut at a word boundary, unless that would leave only a markdown marker (a long heading). function previewCut(content,max) { diff --git a/packages/webui/tests/side-by-side-browser.test.mjs b/packages/webui/tests/side-by-side-browser.test.mjs index 8f2a69a28..5174f257c 100644 --- a/packages/webui/tests/side-by-side-browser.test.mjs +++ b/packages/webui/tests/side-by-side-browser.test.mjs @@ -259,14 +259,16 @@ test('the same project in both panes keeps a request choice per pane', async t = await b.evaluate('window.MosaicPanes.refresh()'); await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`); - const values = `[...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value)`; - await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`); + const values = `[...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value)`; + await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`); assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']); - await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); + await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); await wait(`${values}[0] === 'request-2'`); + // The redrawn select gets focus back through the pane's own id. + assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-project-request-select'); await b.evaluate('window.MosaicPanes.refresh()'); await settled(b); - await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`); + await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`); assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']); }); @@ -279,10 +281,10 @@ test('a pane keeps its request choice and PRD disclosure when a new conversation await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); // The same organization and project in both panes. await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`); - await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`); - const state = `({ selected: [...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`; - await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); - await wait(`document.querySelector('#pane-a #project-request-select').value === 'request-2'`); + await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`); + const state = `({ selected: [...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`; + await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); + await wait(`document.querySelector('#pane-a-project-request-select').value === 'request-2'`); await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`); await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`); // A new conversation changes the header's count, so each pane replaces its project content. @@ -290,7 +292,7 @@ test('a pane keeps its request choice and PRD disclosure when a new conversation await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`); await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`); await settled(b); - await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`); + await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`); assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] }); }); @@ -338,3 +340,68 @@ test('each Agents pane keeps its own project choice for its organization', async assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']); assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true); }); + +// Polish 1 (#1873): every id a pane draws is its own. With the same page in both panes no id +// appears twice, and every label, ARIA reference and in-page link resolves inside its own pane. +const idAudit = `(() => { + const count = new Map(); + for (const el of document.querySelectorAll('[id]')) count.set(el.id, (count.get(el.id) || 0) + 1); + const duplicates = [...count].filter(([, n]) => n > 1).map(([id]) => id).sort(); + const stray = []; + for (const pane of document.querySelectorAll('.panes > .pane:not([hidden])')) { + for (const attr of ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls']) { + for (const el of pane.querySelectorAll('[' + attr + ']')) { + for (const id of el.getAttribute(attr).split(/\\s+/).filter(Boolean)) { + const target = document.getElementById(id); + if (!target || !pane.contains(target)) stray.push(pane.id + ' ' + attr + '=' + id); + } + } + } + for (const a of pane.querySelectorAll('a[href^="#"]:not([href^="#/"])')) { + const id = decodeURIComponent(a.getAttribute('href').slice(1)); + const target = id && document.getElementById(id); + if (id && (!target || !pane.contains(target))) stray.push(pane.id + ' href=#' + id); + } + } + return { duplicates, stray: stray.sort() }; +})()`; + +test('the same project, dashboard and Agents page in both panes draw no duplicate ids, and every reference stays in its pane', async t => { + const { b, wait } = await open(t); + await wait(`${title('b')}.includes("Forge")`); + const { stub, P } = await import('./helpers/projects-review-fixture.mjs'); + await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`)); + const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] }); + const set = agents => ({ state: 'ok', agents, excluded: [] }); + const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [ + { id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { [P]: [{ name: 'social-scheduler', form: 'define' }] }, + projects: { [P]: set([a('chief'), a('social-scheduler', 'project', 'coder')]) } }, + ] }; + await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`); + await b.evaluate('window.MosaicPanes.refresh()'); + await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); + const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`; + const pages = [ + ['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-system-types-heading]').length === 2`], + ['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`], + [`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], + [`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`], + [`/projects/${P}/tasks`, `${title('a')}.includes('Tasks') && ${title('b')}.includes('Tasks') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], + [`/projects/${P}/requirements`, `document.querySelectorAll('[data-pane-body] [data-prd]').length === 2 && document.querySelectorAll('[data-pane-body] select').length >= 2`], + [`/projects/${P}/files`, `${title('a')}.includes('Files') && ${title('b')}.includes('Files') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], + ]; + const found = {}, drawn = {}; + for (const [route, ready] of pages) { + await b.evaluate(both(route)); + await wait(ready); + await settled(b); + const page = route.replace(P, 'P'); + found[page] = await b.evaluate(idAudit); + drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`); + } + assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }]))); + // The audit is not vacuous: the dashboard draws its system types heading and the requirements page + // its request select, north star and PRD headings, each with the pane's prefix. + assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why', 'pane-a-ws-types-h']); + assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']); +});