From e12713f68e09418e5a4c31eaaa6a512456f7775f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 9 Nov 2026 12:44:53 -0600 Subject: [PATCH 1/2] webui: render the project north star as markdown on the overview (F1, #1873) The overview's Project direction panel printed north-star.md source as one paragraph. It now mounts the same DOM-building markdown renderer the requirements view uses, trims the preview to 500 characters at a word boundary, and links to the full document in Docs. No raw HTML from the document reaches the page; the fallback is a text node. Quick Setup leaving no records was not reproduced on 00ef0bd30: all six steps report done and the records step writes where the project view reads. org-quick-live gains a records case that checks the six setup POSTs, the records repository on disk and the live north-star read with no button press. The manual records button stays for older projects. Tests: projects-north-star-browser 1/0 (red before the change), org-quick-live 4/0 twice on a fresh scratch instance. --- packages/webui/src/public/projects.js | 6 +++--- packages/webui/tests/org-quick-live.test.mjs | 20 +++++++++++++++--- .../projects-north-star-browser.test.mjs | 21 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 packages/webui/tests/projects-north-star-browser.test.mjs diff --git a/packages/webui/src/public/projects.js b/packages/webui/src/public/projects.js index 110f55db7..e9618527f 100644 --- a/packages/webui/src/public/projects.js +++ b/packages/webui/src/public/projects.js @@ -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?`
${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. Review decision
`:''}
${metrics.map(([title,value,caption,href])=>`${title}${value}${clean(caption)}`).join('')}

Updated just now · Showing requests from recent messages.

Current work

${canSend&&!project.archived?`${plusIcon}New conversation`:''}
${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`${clean(t.name)}With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}${workFor(data,t)?chip(workFor(data,t).state):''}`).join(''):'

No conversations yet. Start one to begin work.

'}

Tasks ${taskRefs(data).length}

${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`
${taskLink(ref,data)}
`).join(''):data.failed?'

Request tasks unavailable. Refresh to try again.

':'

No request tasks available.

'}View all project tasks
`; + return `${waiting.length?`
${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. Review decision
`:''}
${metrics.map(([title,value,caption,href])=>`${title}${value}${clean(caption)}`).join('')}

Updated just now · Showing requests from recent messages.

Current work

${canSend&&!project.archived?`${plusIcon}New conversation`:''}
${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`${clean(t.name)}With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}${workFor(data,t)?chip(workFor(data,t).state):''}`).join(''):'

No conversations yet. Start one to begin work.

'}

Tasks ${taskRefs(data).length}

${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`
${taskLink(ref,data)}
`).join(''):data.failed?'

Request tasks unavailable. Refresh to try again.

':'

No request tasks available.

'}View all project tasks
`; } 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',` ${taskRefs(data).length}`);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',` ${f.reviewed.length+f.drafts.length+f.docs.length}`);} revealTab(header); @@ -276,7 +276,7 @@ if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','

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 && 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 ? data.north.content.slice(0,500).replace(/\s+\S*$/, '')+'…' : 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); diff --git a/packages/webui/tests/org-quick-live.test.mjs b/packages/webui/tests/org-quick-live.test.mjs index 072435bd0..6ee2c0c97 100644 --- a/packages/webui/tests/org-quick-live.test.mjs +++ b/packages/webui/tests/org-quick-live.test.mjs @@ -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")'); diff --git a/packages/webui/tests/projects-north-star-browser.test.mjs b/packages/webui/tests/projects-north-star-browser.test.mjs new file mode 100644 index 000000000..6156deb79 --- /dev/null +++ b/packages/webui/tests/projects-north-star-browser.test.mjs @@ -0,0 +1,21 @@ +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\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')})()`); +}); -- 2.54.0 From c9c409ac4edcd154e336009e55d468c8dcbb2f58 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 9 Nov 2026 13:02:17 -0600 Subject: [PATCH 2/2] webui: keep a long north-star heading in the overview preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The word-boundary cut removed everything after '# ' when the first 500 characters were one long heading, leaving '#…'. Keep the hard cut when the soft cut would leave only a markdown marker. Review P3 on F1 (#1873). --- packages/webui/src/public/projects.js | 7 ++++++- packages/webui/tests/projects-north-star-browser.test.mjs | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/public/projects.js b/packages/webui/src/public/projects.js index e9618527f..c1b352d48 100644 --- a/packages/webui/src/public/projects.js +++ b/packages/webui/src/public/projects.js @@ -276,7 +276,7 @@ if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','

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 ? data.north.content.slice(0,500).replace(/\s+\S*$/, '')+'…' : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); } + 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; diff --git a/packages/webui/tests/projects-north-star-browser.test.mjs b/packages/webui/tests/projects-north-star-browser.test.mjs index 6156deb79..db4bc49f5 100644 --- a/packages/webui/tests/projects-north-star-browser.test.mjs +++ b/packages/webui/tests/projects-north-star-browser.test.mjs @@ -18,4 +18,7 @@ test('overview renders a safe, bounded north star markdown preview and links to 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)})()`); }); -- 2.54.0