Preserve live project views through overlapping refreshes

This commit is contained in:
jason.woltje committed 2026-11-08 21:57:48 -06:00
1 parent 298081dbf3
commit c0b2f7d86a
5 files changed
+184 -26

No files matched your search

+6 -2
View File
@@ -523,8 +523,10 @@
const id = el.dataset.conversationId, old = oldById.get(id), live = liveById.get(id);
return old && live && old.outerHTML === el.outerHTML ? live : el;
});
const focused = liveList.contains(document.activeElement) ? document.activeElement : null;
for (let i = 0; i < desired.length; i++) if (liveList.children[i] !== desired[i]) liveList.insertBefore(desired[i], liveList.children[i] || null);
while (liveList.children.length > desired.length) liveList.lastElementChild.remove();
if (focused?.isConnected && document.activeElement !== focused) focused.focus({preventScroll:true});
const count = c.threads.filter(t => !t.shared && t.project === c.id).length;
const heading = c.container.querySelector('.project-heading p');
for (const node of heading?.childNodes || []) if (node.nodeType === Node.TEXT_NODE) { const value = node.data.replace(/\d+ conversations?/g, `${count} ${count === 1 ? 'conversation' : 'conversations'}`); if (node.data !== value) node.data = value; }
@@ -573,7 +575,8 @@
if (read && !navigated && lastPaint?.container === c.container && lastPaint.path === c.path && lastPaint.fingerprint === fingerprint && c.container.querySelector('.chat-area')) {
lastPaint.html = html;
refreshLibraryAge(c);
refreshProjectData(c, redesigned, null);
refreshProjectData(c, redesigned, keep);
updateRoomCompletion();
return;
}
const next = document.createElement('template');
@@ -581,7 +584,8 @@
if (read && !navigated && (updateTranscript(c, html, next, keep) || updateConversations(c, next))) {
lastPaint = { container: c.container, path: c.path, html, fingerprint };
refreshLibraryAge(c);
refreshProjectData(c, redesigned, null);
refreshProjectData(c, redesigned, keep);
updateRoomCompletion();
return;
}
// updateTranscript empties the template log while comparing the rest.
+36 -18
View File
@@ -29,6 +29,7 @@
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const storageKey=window.MosaicOrg ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree';
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
const treeFocus = new WeakMap();
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
async function read(url) {
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
@@ -63,10 +64,22 @@
treeRead=Promise.all([read('/api/chat/projects'),read('/api/chat/threads')]);
}
const [projects,threads]=await treeRead;
if(generation!==treeGeneration || !container.isConnected || !Array.isArray(projects.rows) || !Array.isArray(threads.rows)) return;
if(generation!==treeGeneration || !container.isConnected) return;
if(!Array.isArray(projects.rows) || !Array.isArray(threads.rows)) throw Error('invalid tree');
treeRows=projects.rows; treeThreads=threads.rows; drawTree(container,route);
const saved=treeFocus.get(container);
if(saved && (document.activeElement===document.body || container.contains(document.activeElement))) {
const target=[...container.querySelectorAll('a,button[data-project-disclosure]')].find(el=>saved.href ? el.getAttribute('href')===saved.href : el.dataset.projectDisclosure===saved.disclosure);
target?.focus({preventScroll:true});
}
treeFocus.delete(container);
container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey,JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();};
} catch { treeRead=null; if(generation===treeGeneration) { container.replaceChildren(); delete container.dataset.treePaint; } }
} catch { if(generation===treeGeneration && container.isConnected && !document.body.classList.contains('signed-out')) {
treeRead=null;
const active=document.activeElement;
if(container.contains(active)) treeFocus.set(container, {href:active.getAttribute('href'), disclosure:active.dataset.projectDisclosure});
container.innerHTML='<p role="status">Projects unavailable. Refresh to try again.</p>'; delete container.dataset.treePaint;
} }
}
document.addEventListener('mosaic-signed-out',()=>{treeGeneration++;treeRead=null;treeRows=[];treeThreads=[];document.querySelector('#project-tree')?.replaceChildren();});
const requestId = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(value);
@@ -206,27 +219,33 @@
return `<a class="project-file-row" href="#/tasks/${encodeURIComponent(ref)}"><div><strong>${clean(fields?.title || ref)}</strong>${request?`<small>From request ${clean(request.id)} · ${clean(request.thread.name)}</small>`:''}</div><span class="project-chip ${fields?.done===true?'success':''}">${fields?.done===true?'Done':fields?.bucket!==undefined && fields?.bucket!==null?'bucket '+clean(fields.bucket):'Status unavailable'}${assignees?' · '+assignees:''}</span></a>`;
}).join('')}</div>`:`<p>${data.failed?'Request tasks unavailable. Refresh to try again.':'No request tasks available.'}</p>`}`;
}
const contentPaints = new WeakMap();
const contentPaints = new WeakMap(), contentRuns = new WeakMap();
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt}) {
if(!container || !project) return;
const generation=epoch;
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
contentRuns.set(container,run);
if (!contentPaints.has(container)) container.textContent='Reading…';
const data=await gather(project,threads).catch(()=>null);
if(!container.isConnected || generation!==epoch || document.body.classList.contains('signed-out')) return;
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
const {at, ...content} = data;
// Workspace observation time changes on every read but is not shown here.
content.requests = content.requests.map(r => {
if (!r.workspace) return r;
const {observedAt, ...workspace} = r.workspace;
return {...r, workspace};
});
const signature = JSON.stringify(canonical({content, project, section, canSend, roomReceipt}));
if (contentPaints.get(container) === signature) return;
contentPaints.set(container, signature);
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
const selection=selectedRequests.get(project.id);
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 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);
if (contentPaints.get(container) === signature) return;
contentPaints.set(container, signature);
// Capture at commit time: the user may have interacted while the read was pending.
const active=document.activeElement;
const liveRestore=contentPaints.has(container) && container.children.length ? {
disclosures:[...container.querySelectorAll('details')].map((el,i)=>el.open?i:-1).filter(i=>i>=0),
projectFocus:container.contains(active) ? {id:active.id,index:[...container.querySelectorAll('a,button,select,input,textarea,summary,[tabindex]')].indexOf(active)} : null
} : null;
const draw=selected=>{
container.innerHTML=section==='requirements'?requirementView(data,project,selected):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
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>');
@@ -235,9 +254,8 @@
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));
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 selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
draw(selectedRequests.get(project.id));
draw(selection);
restore=liveRestore || restore;
if (restore) {
const details=container.querySelectorAll('details'); for(const i of restore.disclosures || []) if(details[i]) details[i].open=true;
if(restore.projectFocus) { const focus=restore.projectFocus.id ? document.getElementById(restore.projectFocus.id) : container.querySelectorAll('a,button,select,input,textarea,summary,[tabindex]')[restore.projectFocus.index]; focus?.focus({preventScroll:true}); }
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
async function setup(t, path='/projects/'+P, width=1440) {
const f=await fixture(), b=await browser({machine:false});
t.after(async()=>{await b.close();await f.close();});
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{}));
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub});
await b.viewport(width,900);
await b.navigate(f.base+'/#'+path);
const run=fn=>b.evaluate('('+fn.toString()+')()');
const wait=expr=>b.evaluate('(async()=>{for(let i=0;i<200;i++){if('+expr+')return;await new Promise(r=>setTimeout(r,25))}throw Error('+JSON.stringify(expr)+')})()');
const render=path=>{const canonical=path==='/projects'?'/chat':/^\/projects\/(threads|rooms)\//.test(path)?'/chat'+path.slice('/projects'.length):/^\/projects\/[^/]+$/.test(path)?'/chat/projects/'+path.slice('/projects/'.length):path;return b.evaluate('MosaicChat.render({path:'+JSON.stringify(canonical)+',container:document.querySelector("#bus-view"),canSend:true,navigated:false})');};
await wait('document.querySelector(".project-header")');
await run(async()=>{await new Promise(r=>setTimeout(r,150));});
return {f,b,run,wait,render};
}
test('probe: room recipient completion still works after an unchanged refresh',async t=>{
const {run,render,wait}=await setup(t,'/projects/rooms/'+P);
await wait('document.querySelector("#chat-room-body") && !document.querySelector("#chat-room-body").disabled');
await run(()=>{
window.roomField=document.querySelector('#chat-room-body');roomField.focus();roomField.value='@a';roomField.setSelectionRange(2,2);roomField.dispatchEvent(new Event('input',{bubbles:true}));
});
await wait('!document.querySelector("#chat-room-completion").hidden && !!document.querySelector("[data-room-complete=assistant]")');
await render('/projects/rooms/'+P);
const result=await run(()=>{
const menu=document.querySelector('#chat-room-completion');
const before={menuOpen:!menu.hidden,value:document.querySelector('#chat-room-body').value,same:roomField.isConnected};
document.querySelector('[data-room-complete=assistant]').click();
return {...before,after:document.querySelector('#chat-room-body').value};
});
console.log('ROOM_COMPLETION',JSON.stringify(result));
assert.equal(result.menuOpen,true);assert.equal(result.after,'@assistant ','the preserved completion menu must accept a recipient');
});
test('probe: invisible request revision does not collapse requirement details or drop focus',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content] .project-columns section details summary")');
await run(()=>{
window.requirement=document.querySelector('[data-project-content] .project-columns section details');
requirement.open=true;window.requirementSummary=requirement.querySelector('summary');requirementSummary.focus();
window.beforeText=requirement.textContent;
reviewStates['request-1']={...reviewStates['request-1'],revision:2};
const now=Date.now();Date.now=()=>now+11000;
});
await render('/projects/'+P+'/requirements');
await run(async()=>{await new Promise(r=>setTimeout(r,200));});
const result=await run(()=>{
const detail=document.querySelector('[data-project-content] .project-columns section details');
return {oldConnected:requirement.isConnected,open:detail.open,focused:document.activeElement===detail.querySelector('summary'),textSame:detail.textContent===beforeText};
});
console.log('REQUIREMENT_REFRESH',JSON.stringify(result));
assert.equal(result.textSame,true);
assert.equal(result.open&&result.focused,true,'an unchanged requirement must keep its disclosure and focus');
});
test('probe: focused row survives a keyed move',async t=>{
const {run,render}=await setup(t,'/projects/'+P+'/conversations');
await run(()=>{
window.movingRow=document.querySelectorAll('[data-conversation-id]')[2];movingRow.focus();
fixtureThreads.unshift(fixtureThreads.pop());
});
await render('/projects/'+P+'/conversations');
const result=await run(()=>({connected:movingRow.isConnected,focused:document.activeElement===movingRow,active:document.activeElement.tagName,first:document.querySelector('[data-conversation-id]')===movingRow}));
console.log('FOCUSED_MOVE',JSON.stringify(result));
assert.equal(result.connected&&result.first,true);
assert.equal(result.focused,true,'moving a keyed conversation must keep its keyboard focus');
});
test('probe: stale project-content read cannot roll back newer request state',async t=>{
const {run,render,wait}=await setup(t);
await wait('document.querySelector(".project-work .project-chip")?.textContent==="Building"');
await run(()=>{
const now=Date.now();window.clockOffset=11000;Date.now=()=>now+clockOffset;
const old=window.fetch;window.holdOld=true;
window.fetch=async(url,init)=>{
const res=await old(url,init);
if(String(url)==='/api/requests/request-1'&&holdOld){holdOld=false;return new Promise(resolve=>window.releaseOld=()=>resolve(res));}
return res;
};
});
await render('/projects/'+P);
await wait('!!window.releaseOld');
await run(()=>{reviewStates['request-1']={...reviewStates['request-1'],phase:'review',revision:2,mode:'supervised'};clockOffset+=11000;});
await render('/projects/'+P);
await wait('document.querySelector(".project-work .project-chip")?.textContent==="In review"');
await run(async()=>{window.newerText=document.querySelector('[data-project-content]').textContent;releaseOld();await new Promise(r=>setTimeout(r,200));});
const result=await run(()=>({newer:newerText,final:document.querySelector('.project-work .project-chip').textContent,content:document.querySelector('[data-project-content]').textContent,context:document.querySelector('[data-project-context]').textContent}));
console.log('CONTENT_RACE',JSON.stringify(result));
assert.equal(result.final,'In review','old project content must not overwrite the newer request phase');
assert.equal(result.content,result.newer,'no project content may roll back after the old read');
assert.match(result.content,/Supervised/);
});
for (const changed of [false, true]) for (const method of ['click', 'keyboard']) {
test(`room completion ${method} works after ${changed ? 'message-only' : 'unchanged'} refresh`, async t => {
const {b,run,render,wait}=await setup(t,'/projects/rooms/'+P);
await wait('document.querySelector("#chat-room-body") && !document.querySelector("#chat-room-body").disabled');
await run(()=>{const f=document.querySelector('#chat-room-body');f.focus();f.value='@a';f.setSelectionRange(2,2);f.dispatchEvent(new Event('input',{bubbles:true}));});
if(changed) await run(()=>fixtureRoom.push({id:'50000000-0000-4000-8000-000000000004',ids:['50000000-0000-4000-8000-000000000004'],from_role:'assistant',to:['human'],body:'New message',at:'2026-11-06T10:26:00Z'}));
await render('/projects/rooms/'+P);
if(changed) await wait('document.querySelectorAll("[data-message]").length===4');
if(method==='click') await run(()=>document.querySelector('[data-room-complete=assistant]').click());
else await b.key('Enter');
assert.equal(await run(()=>document.querySelector('#chat-room-body').value),'@assistant ');
});
}
test('changed requirement content retains the disclosure and focused summary',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content] .project-columns section details summary")');
await run(()=>{const d=document.querySelector('[data-project-content] .project-columns section details');d.open=true;d.querySelector('summary').focus();reviewDrafts['request-1'].requirements[0].text='Updated requirement text';const now=Date.now();Date.now=()=>now+11000;});
await render('/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content]")?.textContent.includes("Updated requirement text")');
const result=await run(()=>{const d=document.querySelector('[data-project-content] .project-columns section details');return {open:d.open,focused:document.activeElement===d.querySelector('summary')}});
assert.deepEqual(result,{open:true,focused:true});
});
@@ -8,15 +8,16 @@ test('project tree returns after a failed refresh followed by the same data', as
await wait(`document.querySelector('.project-tree-list a')`);
const result = await b.evaluate(`(async()=>{
const container=document.querySelector('.project-tree-list').parentElement;
const link=container.querySelector('.project-tree-list a'); link.focus(); const href=link.getAttribute('href');
const originalFetch=window.fetch, originalNow=Date.now;
Date.now=()=>originalNow()+20000;
window.fetch=(url,options)=>String(url)==='/api/chat/projects' ? Promise.reject(Error('offline')) : originalFetch(url,options);
window.fetch=(url,options)=>String(url)==='/api/chat/threads' ? Promise.reject(Error('offline')) : originalFetch(url,options);
await MosaicProjectTree.mount({container,path:'/projects'});
const cleared=!container.querySelector('.project-tree-list');
const unavailable=!!container.querySelector('[role=status],[role=alert]') && container.textContent.includes('Projects unavailable');
window.fetch=originalFetch;
await MosaicProjectTree.mount({container,path:'/projects'});
Date.now=originalNow;
return {cleared,recovered:!!container.querySelector('.project-tree-list a')};
return {unavailable,recovered:!!container.querySelector('.project-tree-list a'),focused:document.activeElement.getAttribute('href')===href};
})()`);
assert.deepEqual(result, {cleared:true,recovered:true});
assert.deepEqual(result, {unavailable:true,recovered:true,focused:true});
});
@@ -12,8 +12,20 @@ const remoteScenario = {
const roomScenario = { [`/api/chat/rooms/${ids.project}/messages?limit=100`]: { rows: [], after: null, hasMore: false } };
const input = `const el=document.querySelector('#project-remote-url'); el.value='https://git.example/owner/unsaved'; el.dispatchEvent(new Event('input',{bubbles:true}));`;
const refresh = `document.querySelector('#refresh').click()`;
const trackRefresh = `window.__pollReads=0;{const old=window.fetch;window.fetch=(url,init)=>{if(String(url)==='/api/chat/projects')window.__pollReads++;return old(url,init)}}`;
const refreshed = `new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(window.__pollReads && !document.querySelector('#refresh').disabled)resolve(true);else if(Date.now()>deadline)reject(Error('No completed refresh'));else setTimeout(tick,25)};tick()})`;
// Observe the actual chat render promise, including its completed read and paint.
const trackRefresh = `window.__pollRenders=0;{const original=window.MosaicChat;window.MosaicChat={...original,render:async (...args)=>{const result=await original.render(...args);window.__pollRenders++;return result}}}`;
const refreshed = `new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(window.__pollRenders)resolve(true);else if(Date.now()>deadline)reject(Error('No completed refresh'));else setTimeout(tick,25)};tick()})`;
test('refresh wait remains pending until the held project read and render finish', { timeout: 30000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`window.__fetch=fetch;window.fetch=async (url,init)=>{const response=await __fetch(url,init);if(String(url)==='/api/chat/projects'){window.__held=true;await new Promise(resolve=>window.__release=resolve)}return response}; ${trackRefresh} ${refresh}; window.__settled=false;void (${refreshed}).then(()=>window.__settled=true);`);
await wait('!!window.__held');
await b.evaluate(`new Promise(resolve=>setTimeout(resolve,200))`);
assert.equal(await b.evaluate('window.__settled'),false);
await b.evaluate('__release()');
await wait('window.__settled');
});
test('remote draft survives a real poll and resets on project change', { timeout: 40000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });