webui: render the project north star as markdown on the overview (F1, #1873) #1877

Merged
jason.woltje merged 2 commits from org-f1-land into refactor 2026-11-09 19:02:49 +00:00
3 changed files with 49 additions and 6 deletions

No files matched your search

+8 -3
View File
@@ -209,7 +209,7 @@
const f=filesData(data), filesKnown=!data.failed && !!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–';
const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft;
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3><p>${n!==null?clean(n):data.northRecordsMissing?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."}</p><h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3>${n!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:`<p>${data.northRecordsMissing?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."}</p>`}<h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
}
function tasksView(data) {
const refs=taskRefs(data);
@@ -258,7 +258,7 @@
const view = section==='requirements'?requirementView(data,project,selection):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
// The generated presentation and separately mounted Markdown determine visible content.
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, section==='requirements' ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
revealTab(header);
@@ -276,7 +276,7 @@
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
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 && data.north?.content) north.replaceChildren(window.MosaicMarkdown?.render(data.north.content) || document.createTextNode(data.north.content));
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});});
};
draw(selection);
@@ -287,6 +287,11 @@
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#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) {
const hard=content.slice(0,max), soft=hard.replace(/\s+\S*$/, '');
return (/[^\s#>*+\-\d.`]/.test(soft.split('\n').pop()) ? soft : hard)+'…';
}
async function enhanceList(container,projects,threads) {
for(const project of projects.filter(p=>!p.archived)) {
const slot=container.querySelector(`[data-project-status="${project.id}"]`); if(!slot) continue;
+17 -3
View File
@@ -10,7 +10,7 @@ import { join } from 'node:path';
import { browser } from './browser.mjs';
const log = join(homedir(), 'orgdev-qconsole.log');
for (const scenario of ['fresh', 'recover', 'cli']) test(`Quick Setup real exchange and records: ${scenario}`, { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
for (const scenario of ['records', 'fresh', 'recover', 'cli']) test(`Quick Setup real exchange and records: ${scenario}`, { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
const url = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(readFileSync(log, 'utf8'));
assert.ok(url, 'scratch Console advertised a URL');
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url[2])), 'Console must use an allowed port');
@@ -33,12 +33,22 @@ for (const scenario of ['fresh', 'recover', 'cli']) test(`Quick Setup real excha
await click('#org-quick-open');
await wait('document.querySelector("#org-quick") && !document.querySelector("#org-quick").hidden');
for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
if (scenario !== 'fresh') await b.evaluate(`(() => { const real=window.fetch; let first=true; window.fetch=(url,opts={}) => { if(first && String(url).includes('/setup/records/') && opts.method==='POST') { first=false; return Promise.resolve(new Response(JSON.stringify({error:'test-stop',message:'Stopped for records recovery test'}),{status:409,headers:{'content-type':'application/json'}})); } return real(url,opts); }; })()`);
if (scenario === 'fresh' || scenario === 'records') await b.evaluate(`(() => {const real=window.fetch;window.__setupPosts=[];window.fetch=async(url,opts={})=>{const r=await real(url,opts);if(opts.method==='POST' && (String(url)==='/api/orgs' || String(url).includes('/setup/') || String(url).endsWith('/chat/projects')))window.__setupPosts.push({url:String(url),request:opts.body,status:r.status,body:await r.clone().json()});return r;};})()`);
if (!['fresh', 'records'].includes(scenario)) await b.evaluate(`(() => { const real=window.fetch; let first=true; window.fetch=(url,opts={}) => { if(first && String(url).includes('/setup/records/') && opts.method==='POST') { first=false; return Promise.resolve(new Response(JSON.stringify({error:'test-stop',message:'Stopped for records recovery test'}),{status:409,headers:{'content-type':'application/json'}})); } return real(url,opts); }; })()`);
await click('#org-risk-accept'); await click('#org-quick-run');
await wait(`document.querySelector('[data-quick-step="5"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
console.log('setup reached a terminal step');
const states = await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)');
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done', scenario === 'fresh' ? 'Done' : 'Stopped'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done', ['fresh', 'records'].includes(scenario) ? 'Done' : 'Stopped'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
if (scenario === 'fresh' || scenario === 'records') {
const calls = await b.evaluate('window.__setupPosts');
assert.deepEqual(calls.map(c => c.url), ['/api/orgs', `/api/orgs/${id}/setup/queue`, `/api/orgs/${id}/setup/project/work`, `/api/orgs/${id}/setup/bus`, `/api/orgs/${id}/chat/projects`, `/api/orgs/${id}/setup/records/work`]);
assert.equal(calls[5].request, '{}'); assert.equal(calls[5].status, 200);
assert.equal(calls[5].body.outcome, 'done'); assert.equal(calls[5].body.step, 'records');
const config = readdirSync(join(homedir(), '.config')).find(name => /^mosaic-[a-z][a-z0-9]{1,15}$/.test(name));
const { dataRoot } = JSON.parse(readFileSync(join(homedir(), '.config', config, 'config.json'), 'utf8'));
assert.ok(existsSync(join(dataRoot, 'org-runtime', id, 'projects', id, 'work', 'spec', 'north-star.md')), 'records repository exists before any recovery action');
}
assert.equal(await shown('#org-quick-open-org'), true, 'the finished organization can be opened');
await click('#org-quick-open-org'); console.log('opening organization');
await wait(`window.MosaicOrg?.id === ${JSON.stringify(id)} && document.querySelector('.project-card')?.textContent.includes('Repository: work')`, 30000);
@@ -63,6 +73,10 @@ for (const scenario of ['fresh', 'recover', 'cli']) test(`Quick Setup real excha
const projectFiles = await b.evaluate(`fetch('/api/orgs/${id}/project-files?project=work').then(async r => ({ status:r.status, body:await r.json() }))`);
assert.equal(projectFiles.status, 200, JSON.stringify(projectFiles));
assert.ok(projectFiles.body.files.some(f => f.path === 'spec/north-star.md'));
const north = await b.evaluate(`fetch('/api/orgs/${id}/project-file?project=work&path=spec%2Fnorth-star.md').then(async r => ({status:r.status,body:await r.json()}))`);
assert.equal(north.status, 200, JSON.stringify(north)); assert.match(north.body.content, /^# North star/);
if (scenario === 'fresh' || scenario === 'records') assert.equal(await b.evaluate(`!!document.querySelector('[data-record-init]')`), false, 'no manual initialization needed');
if (scenario === 'records') return;
const projectRoute = await b.evaluate('location.hash');
await b.evaluate(`location.hash=${JSON.stringify(projectRoute + '/conversations')}`);
await wait('!!document.querySelector("#chat-thread-form")');
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
test('overview renders a safe, bounded north star markdown preview and links to Docs', { timeout: 45000 }, async t => {
const f = await fixture(), b = await browser({ machine: false });
t.after(async () => { await b.close(); await f.close(); });
const markdown = '# A clear direction\n\n<script>window.northInjected=true</script>\n\n' + 'A long goal. '.repeat(200);
const source = stub.replace('A reliable Pomodoro timer that opens locally and works offline.', markdown.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n'));
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
await b.viewport(1440, 900); await b.navigate(f.base + '/#/projects/' + P);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-project-content] .project-metrics'))return;await new Promise(r=>setTimeout(r,30));}throw Error('overview did not load')})()`);
const result = await b.evaluate(`(()=>{const p=document.querySelector('[data-north-star]');return {heading:p?.querySelector('h1')?.textContent, html:p?.querySelector('script')!==null, injected:!!window.northInjected, length:p?.textContent.length, link:document.querySelector('[data-project-content] a[href="#/docs/timer/spec%2Fnorth-star.md"]')?.textContent}})()`);
assert.equal(result.heading, 'A clear direction');
assert.equal(result.html, false); assert.equal(result.injected, false);
assert.ok(result.length < 700, 'preview is bounded'); assert.equal(result.link, 'Open north star in Docs');
await b.evaluate(`{const old=window.fetch;window.fetch=(u,o)=>String(u).startsWith('/api/project-file?')?Promise.resolve(new Response(JSON.stringify({content:'# Updated direction'}),{headers:{'content-type':'application/json'}})):old(u,o);document.querySelector('#refresh').click();}`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-north-star] h1')?.textContent==='Updated direction')return;await new Promise(r=>setTimeout(r,30));}throw Error('north star did not refresh')})()`);
// A heading longer than the preview keeps its text instead of collapsing to the marker.
await b.evaluate(`{const old=window.fetch;window.fetch=(u,o)=>String(u).startsWith('/api/project-file?')?Promise.resolve(new Response(JSON.stringify({content:'# '+'A'.repeat(600)}),{headers:{'content-type':'application/json'}})):old(u,o);document.querySelector('#refresh').click();}`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if((document.querySelector('[data-north-star] h1')?.textContent||'').startsWith('AAAA'))return;await new Promise(r=>setTimeout(r,30));}throw Error('long heading collapsed: '+document.querySelector('[data-north-star]')?.textContent)})()`);
});