Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27ff53649 | ||
|
|
20d798cd57 | ||
|
|
a8bcf94b8c | ||
|
|
af29832a05 | ||
|
|
c0b2f7d86a | ||
|
|
298081dbf3 |
No files matched your search
@@ -185,13 +185,14 @@
|
||||
}
|
||||
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
|
||||
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
|
||||
const activityCell = r => relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown';
|
||||
function cards(rows) {
|
||||
if (!rows.length) return '<p class="muted">Nothing here.</p>';
|
||||
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></div></li>`).join('')}</ul>`;
|
||||
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted" data-board-age="${esc(key(r))}">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></div></li>`).join('')}</ul>`;
|
||||
}
|
||||
function table(rows) {
|
||||
if (!rows.length) return `<div class="empty"><b>No sessions match these filters.</b><p>${data.sessions.length ? 'Clear Hide offline or Hide seen, or pick All projects, to see the rest.' : 'Sessions appear here when the board scan finds an agent session on this machine.'}</p></div>`;
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"${selected === key(r) ? ' class="is-selected"' : ''}><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"${selected === key(r) ? ' class="is-selected"' : ''}><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td data-board-activity="${esc(key(r))}">${activityCell(r)}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
function registered(r) {
|
||||
const reg = r.registered;
|
||||
@@ -432,10 +433,24 @@
|
||||
function accept(value) {
|
||||
if (out()) return; // a read that was in flight when the page signed out
|
||||
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
|
||||
const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object'
|
||||
? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
|
||||
const material = board => JSON.stringify(canonical({ counts: board.counts, sessions: board.sessions.map(({ageSeconds, scannedAt, ...row}) => row) }));
|
||||
const unchanged = data && material(data) === material(value);
|
||||
data = value; failed = false; $('error').hidden = true; $('error').className = 'banner err';
|
||||
$('board-refused').hidden = true; document.body.classList.remove('board-refused');
|
||||
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
|
||||
render();
|
||||
if (unchanged) {
|
||||
for (const r of data.sessions) {
|
||||
for (const el of document.querySelectorAll('[data-board-age]')) if (el.dataset.boardAge === key(r)) { const text = relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'); if (el.textContent !== text) el.textContent = text; }
|
||||
for (const el of document.querySelectorAll('[data-board-activity]')) if (el.dataset.boardActivity === key(r)) { const html = activityCell(r); if (el.innerHTML !== html) el.innerHTML = html; }
|
||||
}
|
||||
const selectedRow = row(selected);
|
||||
const field = [...$('inspection').querySelectorAll('dt')].find(el => /^(Historical )?Last activity$/.test(el.textContent))?.nextElementSibling;
|
||||
if (selectedRow && field) { const text = !selectedRow.lastActivity ? 'unknown' : activity(selectedRow) === selectedRow.lastActivity ? selectedRow.lastActivity : `${activity(selectedRow)} (${selectedRow.lastActivity})`; if (field.textContent !== text) field.textContent = text; }
|
||||
$('status').textContent = `Scanned ${window.MosaicLocalTime(data.generatedAt)} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
|
||||
fresh();
|
||||
} else render();
|
||||
}
|
||||
// A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
|
||||
// everything drawn from it: the cards, the table, the inspector, an open conversation and its
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
document.addEventListener('mosaic-processing-state', e => {
|
||||
halted = e.detail.halted;
|
||||
const cell = $('dashboard-kpis').lastElementChild;
|
||||
if (cell) { cell.querySelector('strong').textContent = halted === null ? '–' : halted ? 'Stopped' : 'On'; cell.querySelector('span').textContent = halted === null ? 'Not available yet' : 'Instance status'; }
|
||||
if (cell) { const strong=cell.querySelector('strong'), span=cell.querySelector('span'), value=halted === null ? '–' : halted ? 'Stopped' : 'On', caption=halted === null ? 'Not available yet' : 'Instance status'; if(strong.textContent!==value) strong.textContent=value; if(span.textContent!==caption) span.textContent=caption; }
|
||||
});
|
||||
setInterval(age, 1000);
|
||||
const button = $('avatar-button'), menu = $('avatar-menu');
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
if (l.error) return `<div class="chat-library"><p role="status">Library status unavailable (${txt(l.error)}).</p></div>`;
|
||||
const v = l.value, parts = [];
|
||||
parts.push(SHA.test(v.served ?? '') ? `Library at <code>${txt(v.served.slice(0, 7))}</code>` : 'Library not served yet');
|
||||
if (typeof v.lastFetch === 'string') parts.push(`fetched ${txt(ago(v.lastFetch))}`);
|
||||
if (typeof v.lastFetch === 'string') parts.push(`<span data-library-age="${esc(v.lastFetch)}">fetched ${txt(ago(v.lastFetch))}</span>`);
|
||||
if (SHA.test(v.diverged?.commit ?? '')) parts.push(`<b>library-diverged</b> at <code>${txt(v.diverged.commit.slice(0, 7))}</code>`);
|
||||
if (CODE.test(v.lastError?.code ?? '')) parts.push(`fetch error <code>${txt(v.lastError.code)}</code>`);
|
||||
const limits = v.replyLimits;
|
||||
@@ -205,7 +205,7 @@
|
||||
}
|
||||
if (g !== generation || out()) return;
|
||||
libraries.set(project, NO_LIBRARY.includes(entry.error) ? { hidden: true } : entry);
|
||||
paint();
|
||||
paint(false, true);
|
||||
}
|
||||
// design (writes): Revert is library.revert {commit}, her act. A refusal shows its code; before
|
||||
// C2-02's verb exists that is the bus's refusal of the verb.
|
||||
@@ -421,7 +421,7 @@
|
||||
if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; current.projects = []; }
|
||||
current.projectArchived = current.projects.some(p => p.id === id && p.archived);
|
||||
}
|
||||
if (g === generation && !out()) { paint(navigated); void loadLibrary(libraryOf(current), g, signal); }
|
||||
if (g === generation && !out()) { paint(navigated, true); void loadLibrary(libraryOf(current), g, signal); }
|
||||
}
|
||||
async function openRoom(projectId) {
|
||||
const c = current, id = projectId || c?.id;
|
||||
@@ -486,14 +486,115 @@
|
||||
const dialogFocus = dialog?.contains(active) ? { id:active.id, index:[...dialog.querySelectorAll('button,a,input,select,textarea,[tabindex]')].indexOf(active), value:'value' in active ? active.value : null, start:active.selectionStart, end:active.selectionEnd } : null;
|
||||
const toggles = [...current.container.querySelectorAll('.project-new-conversation,.project-create')].map(e=>e.open);
|
||||
const content = current.container.querySelector('[data-project-content]');
|
||||
const projectFocus = content?.contains(active) ? {id:active.id,index:[...content.querySelectorAll('a,button,select,input,textarea,summary,[tabindex]')].indexOf(active)} : null;
|
||||
const disclosures = content ? [...content.querySelectorAll('details')].map((e,i)=>e.open?i:-1).filter(i=>i>=0) : [];
|
||||
const { projectFocus, disclosures, closed } = window.MosaicProjects?.captureView ? window.MosaicProjects.captureView(content) : { projectFocus:null, disclosures:[], closed:[] };
|
||||
// A repaint can replace a log while its own pin is waiting for layout.
|
||||
// Carry that intent only while the offset still matches the pending pin.
|
||||
return { tabScroll: current.container.querySelector('.project-tabs')?.scrollLeft, field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
|
||||
return { tabScroll: current.container.querySelector('.project-tabs')?.scrollLeft, field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, closed, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
|
||||
}
|
||||
let transcriptObserver = null, transcriptResize = null;
|
||||
function paint(navigated = false) {
|
||||
let transcriptObserver = null, transcriptResize = null, lastPaint = null;
|
||||
const visualKey = html => html.replace(/(<textarea\b[^>]*>)[\s\S]*?(<\/textarea>)/g, '$1$2')
|
||||
.replace(/<span data-library-age="[^"]*">[^<]*<\/span>/g, '<span data-library-age></span>');
|
||||
function refreshLibraryAge(c) {
|
||||
const el = c.container.querySelector('[data-library-age]');
|
||||
const at = libraries.get(libraryOf(c, c.thread))?.value?.lastFetch;
|
||||
if (!el || typeof at !== 'string') return;
|
||||
const text = `fetched ${ago(at)}`;
|
||||
if (el.textContent !== text) el.textContent = text;
|
||||
if (el.dataset.libraryAge !== at) el.dataset.libraryAge = at;
|
||||
}
|
||||
// Refreshes often read exactly the same view. Remember the generated presentation,
|
||||
// not the mutable DOM (which also contains independently mounted request controls).
|
||||
function refreshProjectData(c, redesigned, restore) {
|
||||
if (c.user || c.tester || !window.MosaicProjects) return;
|
||||
const project = c.projects.find(p => p.id === c.id);
|
||||
const projectContent = c.container.querySelector('[data-project-content]');
|
||||
if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) {
|
||||
const surfaceProject = c.kind === 'thread' ? c.projects.find(p => p.id === c.thread?.project) : project;
|
||||
if (surfaceProject) void window.MosaicProjects.enhanceSurface(c.container, surfaceProject, c.threads, c.kind === 'thread' ? c.thread : null, c.canSend, form('room-open:' + surfaceProject.id).receipt);
|
||||
}
|
||||
if (projectContent && project) void window.MosaicProjects.mountContent({ container: projectContent, project, threads: c.threads, section: c.section, canSend: c.canSend, restore, roomReceipt: form('room-open:' + project.id).receipt });
|
||||
if (c.kind === 'list') window.MosaicProjects.enhanceList(c.container, c.projects, c.threads);
|
||||
}
|
||||
// Request panels, files and the request summary reread on every refresh, including one whose
|
||||
// visible view is unchanged: a request's phase moves without a new message. A quiet reread
|
||||
// keeps the present content until a changed result arrives.
|
||||
function refreshRequestData(c, quiet) {
|
||||
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
|
||||
const t = c.kind === 'thread' ? c.thread : null;
|
||||
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
|
||||
const cached = knownRequests.get(c.id);
|
||||
const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : [];
|
||||
const selected = t ? requestRows(state(c.key).rows) : new Set();
|
||||
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => selected.has(m.id)).map(m => m.request))] : [...remembered].slice(-REQUEST_ROWS);
|
||||
const files = c.container.querySelector('[data-chat-files]');
|
||||
if (files && typeof window.MosaicFiles?.mount === 'function') { if (!quiet) files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids, key: `thread:${c.id}`, quiet }); }
|
||||
const summary = c.container.querySelector('[data-chat-summary]');
|
||||
if (summary && repo && ids.length) {
|
||||
if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo, quiet });
|
||||
else summary.textContent = 'Request summary unavailable.';
|
||||
}
|
||||
}
|
||||
// When only the transcript changed, reconcile its keyed children in the
|
||||
// existing log. In particular, never detach an unchanged message or composer.
|
||||
function updateTranscript(c, html, next, keep) {
|
||||
if (!lastPaint || lastPaint.container !== c.container || lastPaint.path !== c.path) return false;
|
||||
const previous = document.createElement('template'); previous.innerHTML = lastPaint.html;
|
||||
const oldLog = previous.content.querySelector('.chat-log'), newLog = next.content.querySelector('.chat-log');
|
||||
const liveLog = c.container.querySelector('.chat-log');
|
||||
if (!oldLog || !newLog || !liveLog) return false;
|
||||
const oldItems = [...oldLog.children], newItems = [...newLog.children], liveItems = [...liveLog.children];
|
||||
oldLog.replaceChildren(); newLog.replaceChildren();
|
||||
const outside = template => visualKey(template.innerHTML);
|
||||
if (outside(previous) !== outside(next)) return false;
|
||||
const key = el => el.dataset.message ? 'message:' + el.dataset.message : el.querySelector('[data-origin-message]') ? 'request:' + el.querySelector('[data-origin-message]').dataset.originMessage : null;
|
||||
const oldByKey = new Map(oldItems.map(el => [key(el), el]).filter(([id]) => id));
|
||||
const liveByKey = new Map(liveItems.map(el => [key(el), el]).filter(([id]) => id));
|
||||
const desired = newItems.map(el => {
|
||||
const id = key(el), old = id && oldByKey.get(id), live = id && liveByKey.get(id);
|
||||
return old && live && old.outerHTML === el.outerHTML ? live : el;
|
||||
});
|
||||
for (let i = 0; i < desired.length; i++) if (liveLog.children[i] !== desired[i]) liveLog.insertBefore(desired[i], liveLog.children[i] || null);
|
||||
while (liveLog.children.length > desired.length) liveLog.lastElementChild.remove();
|
||||
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
|
||||
const s = state(c.key ?? c.id);
|
||||
if (s.tailRequested || s.follow && ((keep?.tail ?? true) || keep?.settlingPin)) pinTranscript(liveLog);
|
||||
else if (keep?.top !== undefined) { if (pageFlow(liveLog)) window.scrollTo(0, keep.top); else liveLog.scrollTop = keep.top; }
|
||||
s.tailRequested = false;
|
||||
return true;
|
||||
}
|
||||
function updateConversations(c, next) {
|
||||
if (c.kind !== 'project' || c.section !== 'conversations' || !lastPaint || lastPaint.container !== c.container || lastPaint.path !== c.path) return false;
|
||||
const previous = document.createElement('template'); previous.innerHTML = lastPaint.html;
|
||||
const oldList = previous.content.querySelector('[data-project-conversations]'), newList = next.content.querySelector('[data-project-conversations]');
|
||||
const liveList = c.container.querySelector('[data-project-conversations]');
|
||||
if (!oldList || !newList || !liveList) return false;
|
||||
const oldItems = [...oldList.children], newItems = [...newList.children], liveItems = [...liveList.children];
|
||||
oldList.replaceChildren(); newList.replaceChildren();
|
||||
const normalize = template => {
|
||||
const heading = template.content.querySelector('.project-heading p');
|
||||
for (const node of heading?.childNodes || []) if (node.nodeType === Node.TEXT_NODE) node.data = node.data.replace(/\d+ conversations?/g, '# conversations');
|
||||
for (const count of template.content.querySelectorAll('.project-tabs .project-count')) count.textContent = '#';
|
||||
return visualKey(template.innerHTML);
|
||||
};
|
||||
if (normalize(previous) !== normalize(next)) return false;
|
||||
const oldById = new Map(oldItems.filter(el => el.dataset.conversationId).map(el => [el.dataset.conversationId, el]));
|
||||
const liveById = new Map(liveItems.filter(el => el.dataset.conversationId).map(el => [el.dataset.conversationId, el]));
|
||||
const desired = newItems.map(el => {
|
||||
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; }
|
||||
const tab = c.container.querySelector('.project-tabs [href$="/conversations"] .project-count');
|
||||
if (tab && tab.textContent !== String(count)) tab.textContent = String(count);
|
||||
return true;
|
||||
}
|
||||
function paint(navigated = false, read = false) {
|
||||
if (!current || out()) return;
|
||||
const keep = capture(), c = current;
|
||||
const redesigned = (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester;
|
||||
@@ -527,8 +628,40 @@
|
||||
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
|
||||
}
|
||||
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||
html += '</div>';
|
||||
// Drafts already live in the focused textarea; their changing text is not a
|
||||
// reason for a background read to replace the control.
|
||||
const fingerprint = visualKey(html);
|
||||
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, keep);
|
||||
refreshRequestData(c, true);
|
||||
updateRoomCompletion();
|
||||
return;
|
||||
}
|
||||
const next = document.createElement('template');
|
||||
next.innerHTML = html;
|
||||
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, keep);
|
||||
refreshRequestData(c, true);
|
||||
updateRoomCompletion();
|
||||
return;
|
||||
}
|
||||
// updateTranscript empties the template log while comparing the rest.
|
||||
next.innerHTML = html;
|
||||
// Keep existing, unchanged message nodes when a later read adds more messages.
|
||||
if (!navigated && lastPaint?.container === c.container && lastPaint.path === c.path) {
|
||||
for (const message of next.content.querySelectorAll('[data-message]')) {
|
||||
const previous = [...c.container.querySelectorAll('[data-message]')].find(el => el.dataset.message === message.dataset.message);
|
||||
if (previous && previous.outerHTML === message.outerHTML) message.replaceWith(previous);
|
||||
}
|
||||
}
|
||||
lastPaint = { container: c.container, path: c.path, html, fingerprint };
|
||||
transcriptObserver?.disconnect(); transcriptResize?.disconnect();
|
||||
c.container.innerHTML = html + '</div>';
|
||||
c.container.replaceChildren(next.content);
|
||||
const observedLog = c.container.querySelector('.project-chat .chat-log');
|
||||
if (observedLog) {
|
||||
const observedState = state(c.key ?? c.id);
|
||||
@@ -542,20 +675,7 @@
|
||||
if (pageFlow(observedLog)) transcriptResize.observe(c.container);
|
||||
}
|
||||
}
|
||||
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
|
||||
const t = c.kind === 'thread' ? c.thread : null;
|
||||
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
|
||||
const cached = knownRequests.get(c.id);
|
||||
const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : [];
|
||||
const selected = t ? requestRows(state(c.key).rows) : new Set();
|
||||
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => selected.has(m.id)).map(m => m.request))] : [...remembered].slice(-REQUEST_ROWS);
|
||||
const files = c.container.querySelector('[data-chat-files]');
|
||||
if (files && typeof window.MosaicFiles?.mount === 'function') { files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids, key: `thread:${c.id}` }); }
|
||||
const summary = c.container.querySelector('[data-chat-summary]');
|
||||
if (summary && repo && ids.length) {
|
||||
if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo });
|
||||
else summary.textContent = 'Request summary unavailable.';
|
||||
}
|
||||
refreshRequestData(c, false);
|
||||
const remote = c.container.querySelector('[data-chat-remote]');
|
||||
const project = c.projects.find(p => p.id === c.id);
|
||||
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
|
||||
@@ -564,12 +684,7 @@
|
||||
const controls = c.container.querySelector('.project-archive-control');
|
||||
if (controls) c.container.querySelector('.project-tabs')?.insertAdjacentElement('afterend', controls);
|
||||
}
|
||||
if (!c.user && !c.tester && window.MosaicProjects) {
|
||||
const projectContent = c.container.querySelector('[data-project-content]');
|
||||
if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) { const surfaceProject = c.kind === 'thread' ? c.projects.find(p=>p.id===c.thread?.project) : project; if(surfaceProject) void window.MosaicProjects.enhanceSurface(c.container, surfaceProject, c.threads, c.kind === 'thread' ? c.thread : null, c.canSend, form('room-open:' + surfaceProject.id).receipt); }
|
||||
if (projectContent && project) void window.MosaicProjects.mountContent({ container: projectContent, project, threads: c.threads, section: c.section, canSend: c.canSend, restore: !navigated ? keep : null, roomReceipt: form('room-open:' + project.id).receipt });
|
||||
if (c.kind === 'list') window.MosaicProjects.enhanceList(c.container, c.projects, c.threads);
|
||||
}
|
||||
refreshProjectData(c, redesigned, !navigated ? keep : null);
|
||||
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
||||
if (!navigated && keep?.toggles) [...c.container.querySelectorAll('.project-new-conversation,.project-create')].forEach((e,i)=>{e.open=!!keep.toggles[i];});
|
||||
const details = c.container.querySelector('.chat-details');
|
||||
@@ -646,6 +761,7 @@
|
||||
// Keep the previous conversation in place, but immediately withdraw controls whose
|
||||
// server paths lack archive guards, and the request panel's writes.
|
||||
const withdraw = () => {
|
||||
lastPaint = null;
|
||||
for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true;
|
||||
window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived });
|
||||
if (!current.archiveUnknown) return;
|
||||
@@ -832,7 +948,7 @@
|
||||
}
|
||||
if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
|
||||
}
|
||||
paint(navigated);
|
||||
paint(navigated, true);
|
||||
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
|
||||
if (t) void loadLibrary(libraryOf(current, t), g, signal);
|
||||
}
|
||||
@@ -855,7 +971,7 @@
|
||||
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
|
||||
if (current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project))) current.archiveUnknown = false;
|
||||
else if (kind === 'thread' && !user && !tester) current.archiveUnknown = true;
|
||||
paint(navigated);
|
||||
paint(navigated, true);
|
||||
}
|
||||
}
|
||||
function refresh(next = false) {
|
||||
@@ -926,11 +1042,14 @@
|
||||
// input that scrolls nothing gives no later scroll event to decide it.
|
||||
settlingPins.set(log, pinned);
|
||||
const mark = ++settleMark;
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
// Resize delivery can lag the scroll event by several frames. Keep the
|
||||
// provenance long enough for the observer to restore a following tail.
|
||||
const settle = frames => frames ? requestAnimationFrame(() => settle(frames - 1)) : (() => {
|
||||
if (mark !== settleMark) return;
|
||||
if (settlingPins.get(log) === pinned) settlingPins.delete(log);
|
||||
if (log.isConnected && scroller.scrollTop === pinned && !transcriptAtTail(log)) s.follow = false;
|
||||
}));
|
||||
})();
|
||||
settle(6);
|
||||
return;
|
||||
}
|
||||
s.follow = transcriptAtTail(log);
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
else { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
|
||||
return a;
|
||||
}
|
||||
async function mount({ container, requestIds, key }) {
|
||||
async function mount({ container, requestIds, key, quiet = false }) {
|
||||
if (!(container instanceof Element)) return;
|
||||
const generation = (generations.get(container) || 0) + 1;
|
||||
generations.set(container, generation);
|
||||
@@ -41,17 +41,20 @@
|
||||
const cacheKey = typeof key === 'string' && key.length > 0 && key.length <= 320 ? `key:${key}` : `ids:${JSON.stringify(ids)}`;
|
||||
const order = ++started;
|
||||
const previous = !signedOut && !document.body.classList.contains('signed-out') && cache.get(cacheKey);
|
||||
container.replaceChildren(...(previous ? previous.map(node => node.cloneNode(true)) : [element('p', 'Reading files…')]));
|
||||
// A quiet reread keeps the present list (and any focus in it) until a changed result arrives.
|
||||
const keep = quiet && container.childNodes.length > 0;
|
||||
if (!keep) container.replaceChildren(...(previous ? previous.map(node => node.cloneNode(true)) : [element('p', 'Reading files…')]));
|
||||
const same = nodes => keep && container.innerHTML === nodes.map(node => node.outerHTML ?? node.textContent).join('');
|
||||
// A settled read paints and caches only if no later-started mount for this key has already
|
||||
// committed; otherwise this container shows that newer result. A pending later mount never
|
||||
// blocks an earlier completion. Callers keep their own fences before calling this.
|
||||
function commit(nodes) {
|
||||
if ((committed.get(cacheKey) || 0) > order) {
|
||||
const newer = cache.get(cacheKey);
|
||||
if (newer) container.replaceChildren(...newer.map(node => node.cloneNode(true)));
|
||||
if (newer && !same(newer)) container.replaceChildren(...newer.map(node => node.cloneNode(true)));
|
||||
return;
|
||||
}
|
||||
container.replaceChildren(...nodes);
|
||||
if (!same(nodes)) container.replaceChildren(...nodes);
|
||||
if (signedOut || document.body.classList.contains('signed-out')) return;
|
||||
committed.set(cacheKey, order);
|
||||
cache.set(cacheKey, nodes.map(node => node.cloneNode(true)));
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
? new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })
|
||||
: new Event('mosaic-signed-out'));
|
||||
};
|
||||
let snapshot = null, canWrite = false, busy = false, sequence = 0, timer;
|
||||
let snapshot = null, canWrite = false, busy = false, sequence = 0, timer, drawn;
|
||||
async function responseBody(response) {
|
||||
if (response.type === 'opaqueredirect') { signOut(false); return null; }
|
||||
const body = await response.json();
|
||||
@@ -20,6 +20,12 @@
|
||||
return body;
|
||||
}
|
||||
function draw() {
|
||||
const signature = JSON.stringify([snapshot?.version, snapshot?.generation, snapshot?.halted, snapshot?.stopping, canWrite, busy, out()]);
|
||||
if (signature === drawn) {
|
||||
document.dispatchEvent(new CustomEvent('mosaic-processing-state', { detail: { halted: snapshot?.halted ?? null } }));
|
||||
return;
|
||||
}
|
||||
drawn = signature;
|
||||
const halted = snapshot?.halted === true, valid = snapshot?.version === 1 && UUID.test(snapshot?.generation);
|
||||
host.dataset.state = halted ? 'halted' : snapshot?.halted === false ? 'running' : 'unknown';
|
||||
status.textContent = halted ? snapshot.stopping ? 'Processing halted. Stopping workloads.' : 'Processing halted.'
|
||||
|
||||
@@ -257,7 +257,9 @@ body:has(.project-chat) #bus-view, body:has(.project-chat) .chat-area { flex:1 1
|
||||
|
||||
.project-chat .chat-audience .room-chip { display:inline-flex; align-items:center; min-height:32px; margin-left:6px; background:var(--nav-active); border-color:var(--nav-active-border); }
|
||||
|
||||
.project-chat [data-conversation-request-status]:empty { display:none; }
|
||||
.chat-conversation.project-chat > .project-header { margin-bottom:0; }
|
||||
.project-chat [data-conversation-request-status] { min-height:16px; line-height:16px; margin:0; }
|
||||
.project-work-wait { min-height:1.5em; }
|
||||
/* Conversation fidelity and single-line tree labels. */
|
||||
.project-tree-row a { font-size:14px; font-weight:600; }
|
||||
.project-tree-children a { font-size:14px; font-weight:500; }
|
||||
@@ -269,7 +271,6 @@ body:has(.project-chat) #bus-view, body:has(.project-chat) .chat-area { flex:1 1
|
||||
.project-chat .chat-message-meta { opacity:1; font-size:12px; line-height:16px; }
|
||||
.project-chat .chat-message-meta strong { font-size:13px; font-weight:600; }
|
||||
.project-chat .chat-body { font-size:14px; }
|
||||
.project-chat [data-conversation-request-status]:not(:empty) { margin:16px 0 0; }
|
||||
@media(max-width:699px) { .project-heading p { font-size:14px; } }
|
||||
|
||||
.project-tree-children a { padding-left:40px; }
|
||||
|
||||
@@ -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'});
|
||||
@@ -45,12 +46,13 @@
|
||||
function drawTree(container, route) {
|
||||
if(!container?.isConnected || document.body.classList.contains('signed-out')) return;
|
||||
const current=currentProject(route), threadId=route.match(/\/threads\/([0-9a-f-]{36})/i)?.[1], room=/\/rooms\//.test(route);
|
||||
container.innerHTML=`<ul class="project-tree-list">${treeRows.filter(p=>!p.archived && UUID.test(p.id)).map(p=>{
|
||||
const html=`<ul class="project-tree-list">${treeRows.filter(p=>!p.archived && UUID.test(p.id)).map(p=>{
|
||||
const expanded=Object.hasOwn(treeState,p.id)?treeState[p.id]:p.id===current;
|
||||
const own=treeThreads.filter(t=>t.project===p.id && !t.shared && UUID.test(t.id)).slice(-5).reverse();
|
||||
const active=p.id===current && !room && !threadId;
|
||||
return `<li><div class="project-tree-row${active?' active':''}"><button type="button" data-project-disclosure="${p.id}" aria-expanded="${!!expanded}" aria-controls="project-children-${p.id}" aria-label="${expanded?'Collapse':'Expand'} ${clean(p.name)}">${expanded?'⌄':'›'}</button><a href="${path(p.id,'overview')}" title="${clean(p.name)}"${active?' aria-current="page"':''}>${folder}<span>${clean(p.name)}</span></a></div><ul id="project-children-${p.id}" class="project-tree-children"${expanded?'':' hidden'}><li><a href="${path(p.id,'room')}"${p.id===current && room?' aria-current="page"':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4h18v14H8l-5 3z"/></svg><span>Project room</span></a></li>${own.map(t=>`<li><a href="#/projects/threads/${t.id}"${t.id===threadId?' aria-current="page"':''} title="${clean(t.name)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4h18v14H8l-5 3z"/></svg><span>${clean(t.name)}</span><small>Private conversation</small></a></li>`).join('')}<li><a href="${path(p.id,'conversations')}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg><span>View conversations</span></a></li></ul></li>`;
|
||||
}).join('')}</ul>`;
|
||||
if (container.dataset.treePaint !== html) { container.innerHTML = html; container.dataset.treePaint = html; }
|
||||
const ancestor=document.querySelector('#sections > li > a[href="#/projects"]'); if(ancestor) { ancestor.classList.toggle('project-tree-ancestor',!!current); if(current) ancestor.removeAttribute('aria-current'); }
|
||||
}
|
||||
async function mountTree({container,path:route}) {
|
||||
@@ -62,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(); }
|
||||
} 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);
|
||||
@@ -158,7 +172,7 @@
|
||||
const requestCode = id => `<code title="${clean(id)}">${clean(UUID.test(id) ? id.slice(0,8) : id)}</code>`;
|
||||
function card(thread,request,failed) {
|
||||
const file=request && delivery(request);
|
||||
return `<article class="project-work"><div class="project-work-head"><div><span class="project-conversation-icon" aria-hidden="true">◌</span><a href="${conversationLink(thread)}"><strong>${clean(thread.name)}</strong></a><p>With ${clean(thread.instance)}${request?` · Request ${requestCode(request.id)}${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}</p></div>${request?chip(request.state):''}</div>${stepper(request?.state)}${request?.state?.wait?.code?`<p>Waiting: ${clean(request.state.wait.code)}</p>`:''}${file?`<p><code>${clean(file.name)}</code> · Reviewed delivery <a download href="/api/requests/${encodeURIComponent(request.id)}/artifact/${encodeURIComponent(file.id)}">Download</a> <a href="${path(request.thread.project,'files')}">Open files</a></p>`:''}<div class="project-work-bottom">${request?.state?.tasks?.length?`<span>Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')}</span>`:''}<a class="btn primary" href="${conversationLink(thread)}">Open conversation</a></div></article>`;
|
||||
return `<article class="project-work"><div class="project-work-head"><div><span class="project-conversation-icon" aria-hidden="true">◌</span><a href="${conversationLink(thread)}"><strong>${clean(thread.name)}</strong></a><p>With ${clean(thread.instance)}${request?` · Request ${requestCode(request.id)}${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}</p></div>${request?chip(request.state):''}</div>${stepper(request?.state)}<p class="project-work-wait">${request?.state?.wait?.code?`Waiting: ${clean(request.state.wait.code)}`:''}</p>${file?`<p><code>${clean(file.name)}</code> · Reviewed delivery <a download href="/api/requests/${encodeURIComponent(request.id)}/artifact/${encodeURIComponent(file.id)}">Download</a> <a href="${path(request.thread.project,'files')}">Open files</a></p>`:''}<div class="project-work-bottom">${request?.state?.tasks?.length?`<span>Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')}</span>`:''}<a class="btn primary" href="${conversationLink(thread)}">Open conversation</a></div></article>`;
|
||||
}
|
||||
const requestOrder=(a,b)=>(Number(needs(b.state))-Number(needs(a.state))) || (Number(!!b.state && !ended(b.state))-Number(!!a.state && !ended(a.state))) || (Date.parse(b.at)||0)-(Date.parse(a.at)||0);
|
||||
function docsList(data) { return Array.isArray(data.docs?.files)?data.docs.files.filter(f=>typeof f?.path==='string' && /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/.test(f.path)).map(f=>f.path):null; }
|
||||
@@ -187,7 +201,7 @@
|
||||
const candidate=d?.candidate, expanded=r && expandedPrds.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])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
|
||||
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==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>`:data.northRecordsMissing?'<p>This project has no records yet. Initialize its record repository to add documents.</p>':data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>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 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(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.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
|
||||
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==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>`:data.northRecordsMissing?'<p>This project has no records yet. Initialize its record repository to add documents.</p>':data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>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 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(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.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
|
||||
}
|
||||
function overview(data,project,canSend,receipt='') {
|
||||
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
|
||||
@@ -205,16 +219,59 @@
|
||||
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(), contentRuns = new WeakMap(), contentModels = new WeakMap();
|
||||
// Disclosure and focus state is saved by identity (request and requirement id, provenance), so
|
||||
// a row inserted or moved above it, or another request's requirement with the same local id,
|
||||
// cannot inherit its state. Keyed disclosures keep a closed state too, so a default-open panel
|
||||
// the user closed stays closed. Unkeyed details fall back to their position; a key that no
|
||||
// longer exists is dropped.
|
||||
const FOCUSABLE='a,button,select,input,textarea,summary,[tabindex]';
|
||||
const restoreKey=(el,i)=>el.dataset.restoreKey || 'index:'+i;
|
||||
function captureView(root) {
|
||||
if(!root) return {disclosures:[],closed:[],projectFocus:null};
|
||||
const active=document.activeElement, inside=root.contains(active);
|
||||
const owner=inside && active.matches('summary') ? active.parentElement?.closest('details[data-restore-key]') : null;
|
||||
return {
|
||||
disclosures:[...root.querySelectorAll('details')].map((el,i)=>el.open?restoreKey(el,i):null).filter(Boolean),
|
||||
closed:[...root.querySelectorAll('details[data-restore-key]')].filter(el=>!el.open).map(el=>el.dataset.restoreKey),
|
||||
projectFocus:inside ? {id:active.id,details:owner?.dataset.restoreKey || null,index:[...root.querySelectorAll(FOCUSABLE)].indexOf(active)} : null
|
||||
};
|
||||
}
|
||||
function restoreView(root,restore) {
|
||||
const wanted=new Set((restore.disclosures || []).map(String)), closed=new Set((restore.closed || []).map(String));
|
||||
[...root.querySelectorAll('details')].forEach((el,i)=>{const key=restoreKey(el,i);if(wanted.has(key)) el.open=true;else if(el.dataset.restoreKey && closed.has(key)) el.open=false;});
|
||||
const f=restore.projectFocus; if(!f) return;
|
||||
const focus=f.id ? document.getElementById(f.id) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
focus?.focus({preventScroll:true});
|
||||
}
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt}) {
|
||||
if(!container || !project) return;
|
||||
const generation=epoch; container.textContent='Reading…';
|
||||
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(!data){container.textContent='Project data unavailable. Refresh to try again.';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 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);
|
||||
// Retained controls (the request selector) draw from the newest accepted read, even when
|
||||
// this read changes nothing visible and the DOM is kept.
|
||||
contentModels.set(container,{data,project,section,canSend,roomReceipt});
|
||||
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 ? captureView(container) : null;
|
||||
const draw=selected=>{
|
||||
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
|
||||
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>');
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
|
||||
@@ -222,12 +279,10 @@
|
||||
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}); }
|
||||
restoreView(container,restore);
|
||||
}
|
||||
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'});
|
||||
@@ -235,7 +290,7 @@
|
||||
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;
|
||||
void gather(project,threads).then(data=>{if(!slot.isConnected)return;const current=[...data.requests].sort(requestOrder).find(r=>r.state);slot.innerHTML=`${current?chip(current.state):''}<span class="project-card-status-line">${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} ${ownThreads(project,threads).length === 1 ? 'conversation' : 'conversations'} · Updated just now</span>`;}).catch(()=>{if(slot.isConnected)slot.textContent='Status unavailable';});
|
||||
void gather(project,threads).then(data=>{if(!slot.isConnected)return;const current=[...data.requests].sort(requestOrder).find(r=>r.state);const html=`${current?chip(current.state):''}<span class="project-card-status-line">${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} ${ownThreads(project,threads).length === 1 ? 'conversation' : 'conversations'} · Updated just now</span>`;if(slot.innerHTML!==html)slot.innerHTML=html;}).catch(()=>{if(slot.isConnected && slot.textContent!=='Status unavailable')slot.textContent='Status unavailable';});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,21 +303,21 @@
|
||||
if(!data.failed) { setCount('tasks',taskRefs(data).length); if(f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries))) setCount('files',f.reviewed.length+f.drafts.length+f.docs.length); }
|
||||
revealTab(tabs);
|
||||
const requestStatus=container.querySelector('[data-conversation-request-status]');
|
||||
if(requestStatus && thread) requestStatus.textContent=data.failed?'Request status unavailable.':'';
|
||||
if(requestStatus && thread) { const text=data.failed?'Request status unavailable.':''; if(requestStatus.textContent!==text) requestStatus.textContent=text; }
|
||||
const selected=thread?workFor(data,thread):[...data.requests].sort(requestOrder).find(r=>r.state);
|
||||
const agent=container.querySelector('[data-project-agent]'); if(agent && thread) agent.textContent=thread.instance;
|
||||
const context=container.querySelector('[data-project-context]'); if(context && selected?.state) context.textContent=' · '+label(selected.state)+(selected.state.mode?' · '+modeLabel(selected.state.mode):'');
|
||||
for(const row of container.querySelectorAll('[data-conversation-id]')) { const t=data.threads.find(t=>t.id===row.dataset.conversationId), r=t && workFor(data,t), slot=row.querySelector('[data-conversation-status]'); if(slot) slot.innerHTML=r?chip(r.state):data.failed?'Status unavailable':'No active request'; }
|
||||
const agent=container.querySelector('[data-project-agent]'); if(agent && thread && agent.textContent!==thread.instance) agent.textContent=thread.instance;
|
||||
const context=container.querySelector('[data-project-context]'); if(context) { const text=selected?.state?' · '+label(selected.state)+(selected.state.mode?' · '+modeLabel(selected.state.mode):''):''; if(context.textContent!==text) context.textContent=text; }
|
||||
for(const row of container.querySelectorAll('[data-conversation-id]')) { const t=data.threads.find(t=>t.id===row.dataset.conversationId), r=t && workFor(data,t), slot=row.querySelector('[data-conversation-status]'); if(slot) { const html=r?chip(r.state):data.failed?'Status unavailable':'No active request'; if(slot.innerHTML!==html) slot.innerHTML=html; } }
|
||||
surfaceData.set(project.id,data);
|
||||
enrichRequest(container, project.id);
|
||||
}
|
||||
function enrichRequest(container, projectId) {
|
||||
const data=surfaceData.get(projectId); if(!data) return;
|
||||
for(const intro of container.querySelectorAll('.project-request-intro')) { const id=intro.querySelector('code')?.title, r=data.requests.find(r=>r.id===id && r.state?.revision===Number(intro.dataset.requestRevision)); if(!r) continue; const files=filesData(data), prd=intro.querySelector('[data-work-prd]'), counts=intro.querySelector('[data-work-files]'); if(prd) prd.innerHTML=`PRD ${clean(r.draft?.candidate?.prd_version || '–')} ${r.draft ? `<span class="project-chip ${r.draft.snapshot?.id?'success':''}">${r.draft.snapshot?.id?'Approved':'Draft'}</span>` : ''}`; if(counts) counts.textContent=`Files · ${!data.failed ? files.reviewed.length : '–'} reviewed, ${!data.failed && data.requests.every(x=>x.workspace?.available===true && Array.isArray(x.workspace.entries) && !x.workspace.truncated) ? files.drafts.length+' draft' : '– draft (Draft files unavailable)'}`; }
|
||||
for(const intro of container.querySelectorAll('.project-request-intro')) { const id=intro.querySelector('code')?.title, r=data.requests.find(r=>r.id===id && r.state?.revision===Number(intro.dataset.requestRevision)); if(!r) continue; const files=filesData(data), prd=intro.querySelector('[data-work-prd]'), counts=intro.querySelector('[data-work-files]'); if(prd) { const html=`PRD ${clean(r.draft?.candidate?.prd_version || '–')} ${r.draft ? `<span class="project-chip ${r.draft.snapshot?.id?'success':''}">${r.draft.snapshot?.id?'Approved':'Draft'}</span>` : ''}`; if(prd.innerHTML!==html) prd.innerHTML=html; } if(counts) { const text=`Files · ${!data.failed ? files.reviewed.length : '–'} reviewed, ${!data.failed && data.requests.every(x=>x.workspace?.available===true && Array.isArray(x.workspace.entries) && !x.workspace.truncated) ? files.drafts.length+' draft' : '– draft (Draft files unavailable)'}`; if(counts.textContent!==text) counts.textContent=text; } }
|
||||
}
|
||||
document.addEventListener('click',e=>{
|
||||
const open=e.target.closest?.('[data-project-dialog]');
|
||||
if(open){const dialog=document.getElementById(`project-${open.dataset.projectDialog}-dialog`);if(dialog && !dialog.open){dialog.showModal();dialog.querySelector('h2')?.focus();}return;}
|
||||
if(open){const dialog=document.getElementById(`project-${open.dataset.projectDialog}-dialog`);if(dialog && !dialog.open){open.focus({preventScroll:true});dialog.showModal();dialog.querySelector('h2')?.focus();}return;}
|
||||
const close=e.target.closest?.('[data-dialog-close]');if(close)close.closest('dialog')?.close();
|
||||
});
|
||||
window.MosaicProjectTree ??= Object.freeze({mount:mountTree});
|
||||
@@ -280,5 +335,5 @@
|
||||
box.innerHTML=`<a href="#/projects">Projects</a><span aria-hidden="true">/</span><a class="project-crumb-name" href="#/projects/${encodeURIComponent(id)}" title="${clean(name)}"><span>${clean(name)}</span></a>${conversation?`<span aria-hidden="true">/</span><a href="${path(id,'conversations')}">Conversations</a>`:''}<span class="project-crumb-current"><span aria-hidden="true">/</span> <span aria-current="page">${clean(leaf)}</span></span>`;
|
||||
return true;
|
||||
}
|
||||
window.MosaicProjects = Object.freeze({ header, revealTab, path, clean, mountContent, enhanceList, enhanceSurface, enrichRequest, breadcrumb, requestIntro, palette });
|
||||
window.MosaicProjects = Object.freeze({ header, revealTab, path, clean, mountContent, captureView, enhanceList, enhanceSurface, enrichRequest, breadcrumb, requestIntro, palette });
|
||||
})();
|
||||
@@ -283,7 +283,7 @@
|
||||
(focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, p.projectArchived, Boolean(p.reading)]);
|
||||
const signature = p => JSON.stringify(canonical([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, p.projectArchived, Boolean(p.reading)]));
|
||||
|
||||
// The reads that follow a state: the mode decision the selection waits on and the project picker.
|
||||
async function side(p) {
|
||||
@@ -527,7 +527,7 @@
|
||||
p.writes = cfg.writes;
|
||||
p.archiveUnknown = archiveUnknown;
|
||||
p.projectArchived = projectArchived;
|
||||
if (p.state || p.failed) draw(p);
|
||||
if ((p.state || p.failed) && (!el.firstElementChild || signature(p) !== p.sig)) draw(p);
|
||||
if (!p.reading && Date.now() - p.at > STALE) void read(p);
|
||||
}
|
||||
pulse();
|
||||
@@ -537,9 +537,10 @@
|
||||
}
|
||||
|
||||
// The project page supplies known IDs; each is reread through the existing authorized boundary.
|
||||
async function projectSummary({ container, requestIds, project } = {}) {
|
||||
// A quiet reread keeps the present summary until its read settles, then replaces it only if changed.
|
||||
async function projectSummary({ container, requestIds, project, quiet = false } = {}) {
|
||||
if (!container || out()) return;
|
||||
container.replaceChildren();
|
||||
if (!quiet) container.replaceChildren();
|
||||
const run = {}; summaryRuns.set(container, run); summaries.add(container);
|
||||
if (!text(project) || !ID.test(project) || !Array.isArray(requestIds)) return;
|
||||
const ids = [...new Set(requestIds.filter(id => text(id) && ID.test(id)))];
|
||||
@@ -551,7 +552,8 @@
|
||||
}));
|
||||
if (out() || !container.isConnected || summaryRuns.get(container) !== run) return;
|
||||
const valid = rows.filter(Boolean);
|
||||
container.innerHTML = valid.length ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
|
||||
const html = valid.length ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
|
||||
if (container.innerHTML !== html) container.innerHTML = html;
|
||||
}
|
||||
window.MosaicRequests = Object.freeze({ mount, forget, projectSummary });
|
||||
})();
|
||||
@@ -58,9 +58,9 @@ async function setup(t, suffix = '', initial = '', startRoute = '#/chat') {
|
||||
const route = async id => { await b.evaluate(`location.hash='#/chat/threads/${id}',true`); await wait(`document.querySelector('.chat-conversation')?.dataset.thread==='${id}'`); };
|
||||
await wait('document.querySelector(".chat-projects")');
|
||||
const refresh = async () => {
|
||||
await wait('!document.querySelector("#refresh").disabled');
|
||||
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
|
||||
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh');
|
||||
// A successful read can leave the existing DOM in place. Await the real
|
||||
// renderer instead of using replacement of the old element as its signal.
|
||||
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
|
||||
};
|
||||
return { b, wait, set, click, route, refresh };
|
||||
}
|
||||
|
||||
@@ -191,12 +191,20 @@ test('chat replies: unchanged file at a different library commit and incomplete
|
||||
const data=await res.json();data.rows[1].check=window.__checkOverride;
|
||||
return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}});
|
||||
};true`);
|
||||
await wait('!document.querySelector("#refresh").disabled');
|
||||
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
|
||||
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh && !document.querySelector("#refresh").disabled');
|
||||
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".chat-changed").length'), 0);
|
||||
});
|
||||
|
||||
test('library age changes without replacing the conversation', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
await b.evaluate(`window.__status.body.lastFetch=new Date(Date.now()-330000).toISOString();MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
|
||||
await wait(`window.__statusReads.length>=2`);
|
||||
await b.evaluate(`(()=>{const field=document.querySelector('#chat-body');field.focus();field.value='draft';field.dispatchEvent(new Event('input',{bubbles:true}));field.setSelectionRange(2,2);window.savedReplyField=field;window.savedReplyMessage=document.querySelector('[data-message]');const now=Date.now();Date.now=()=>now+120000;})()`);
|
||||
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
|
||||
await wait(`document.querySelector('.chat-library')?.textContent.includes('7 min ago')`);
|
||||
assert.equal(await b.evaluate(`savedReplyMessage.isConnected && savedReplyField.isConnected && document.activeElement===savedReplyField && savedReplyField.selectionStart===2`), true);
|
||||
});
|
||||
|
||||
// Invalid configuration is a diagnostic, unlike an ordinary project with no library mapping.
|
||||
test('chat status line: an invalid library mapping remains visible for diagnosis', async t => {
|
||||
const { b, wait, text } = await setup(t, { before: "window.__status={status:502,body:{error:'library-mapping-invalid'}};" });
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// Re-check findings on c0b2f7d86 (Sol, org-live-updates-recheck.md): a refresh that changes only an
|
||||
// unselected request must still feed the retained selector, and requirement disclosure and focus
|
||||
// follow the requirement identity when a row is inserted above it.
|
||||
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));});
|
||||
await run(()=>{const before=MosaicProjects;window.MosaicProjects={...before,mountContent:options=>{const pending=before.mountContent(options);window.lastContentProbe=pending;return pending;}}});
|
||||
return {f,b,run,wait,render};
|
||||
}
|
||||
|
||||
|
||||
test('refreshing an unselected request updates its next selection',async t=>{
|
||||
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
|
||||
await wait('document.querySelector("#project-request-select")?.value==="request-1" && document.querySelector("[data-prd]")?.textContent');
|
||||
await run(()=>{
|
||||
window.originalRequestSelect=document.querySelector('#project-request-select');
|
||||
const beforeSecondFetch=fetch;window.fetch=async(url,init)=>{const response=await beforeSecondFetch(url,init);if(String(url)==='/api/requests/request-2/draft')window.latestSecondPrd=(await response.clone().json()).prd;return response;};
|
||||
reviewDrafts['request-2'].prd='# UPDATED SECOND REQUEST PRD';
|
||||
reviewDrafts['request-2'].requirements[0].text='UPDATED SECOND REQUIREMENT';
|
||||
reviewStates['request-2'].revision++;
|
||||
reviewStates['request-2'].candidate.prd_version='v5';
|
||||
reviewStates['request-2'].snapshot='snapshot-request-2-r2';
|
||||
reviewDrafts['request-2'].snapshot={id:'snapshot-request-2-r2',revision:2};
|
||||
const now=Date.now();Date.now=()=>now+11000;
|
||||
});
|
||||
await render('/projects/'+P+'/requirements');
|
||||
await run(async()=>await window.lastContentProbe);
|
||||
const result=await run(()=>{
|
||||
const field=document.querySelector('#project-request-select');
|
||||
field.value='request-2';field.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return {observedPrd:latestSecondPrd,sameSelect:originalRequestSelect===field,selected:field.value,prd:document.querySelector('[data-prd]').textContent,updatedRequirement:document.querySelector('[data-project-content]').textContent.includes('UPDATED SECOND REQUIREMENT')};
|
||||
});
|
||||
assert.match(result.prd,/UPDATED SECOND REQUEST PRD/);
|
||||
assert.equal(result.updatedRequirement,true);
|
||||
});
|
||||
|
||||
test('prepending a requirement restores focus and disclosure to its surviving identity',async t=>{
|
||||
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
|
||||
await wait('document.querySelectorAll("[data-project-content] section details").length===6');
|
||||
await run(()=>{
|
||||
const detail=document.querySelectorAll('[data-project-content] section details')[2];detail.open=true;detail.querySelector('summary').focus();
|
||||
window.oldSummary=detail.querySelector('summary').textContent;
|
||||
const candidate=reviewStates['request-1'].candidate;
|
||||
const newRequirement={id:'R0',title:'New first requirement',path:'docs/r0-new.md',blob:'b-new'};
|
||||
candidate.requirements.unshift(newRequirement);
|
||||
candidate.prd_version='v4';
|
||||
reviewStates['request-1'].snapshot='snapshot-request-1-r2';
|
||||
reviewDrafts['request-1'].snapshot={id:'snapshot-request-1-r2',revision:2};
|
||||
reviewDrafts['request-1'].requirements.unshift({...newRequirement,text:'New text'});
|
||||
reviewStates['request-1'].revision++;
|
||||
const now=Date.now();Date.now=()=>now+11000;
|
||||
});
|
||||
await render('/projects/'+P+'/requirements');
|
||||
await wait('document.querySelectorAll("[data-project-content] section details").length===7');
|
||||
const result=await run(()=>({wanted:oldSummary,focused:document.activeElement.textContent,open:[...document.querySelectorAll('[data-project-content] section details')].filter(e=>e.open).map(e=>e.querySelector('summary').textContent)}));
|
||||
assert.equal(result.focused,result.wanted);
|
||||
assert.deepEqual(result.open,[result.wanted]);
|
||||
});
|
||||
|
||||
|
||||
// R3 check (Sol, org-live-updates-r3-check.md): requirement state is scoped to its request, and
|
||||
// a closed keyed disclosure stays closed through a repaint.
|
||||
|
||||
async function refresh(run,render) {
|
||||
await run(()=>{const now=Date.now();Date.now=()=>now+11000;});
|
||||
await render('/projects/'+P+'/requirements');
|
||||
await run(async()=>{await window.lastContentProbe;});
|
||||
}
|
||||
|
||||
test('removing the selected request drops its requirement state instead of transferring it to another request with the same local ids',async t=>{
|
||||
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
|
||||
await wait('document.querySelector("#project-request-select")?.value==="request-1" && document.querySelectorAll("[data-project-content] section details").length===6');
|
||||
await run(()=>{
|
||||
const detail=document.querySelectorAll('[data-project-content] section details')[2];detail.open=true;detail.querySelector('summary').focus();
|
||||
reviewDrafts['request-2'].prd='# DISTINCT SECOND REQUEST';
|
||||
reviewStates['request-2'].candidate.requirements[2].title='Second request distinct R3';
|
||||
reviewDrafts['request-2'].requirements[2].title='Second request distinct R3';
|
||||
reviewDrafts['request-2'].requirements[2].text='Only the second request owns this requirement';
|
||||
fixtureMessages.splice(0,fixtureMessages.length,...fixtureMessages.filter(m=>m.request!=='request-1'));
|
||||
});
|
||||
await refresh(run,render);
|
||||
const result=await run(()=>({
|
||||
prd:document.querySelector('[data-prd]').textContent,
|
||||
source:document.querySelector('.project-source').textContent,
|
||||
open:[...document.querySelectorAll('[data-project-content] section details')].filter(el=>el.open).map(el=>el.querySelector('summary').textContent),
|
||||
focused:document.activeElement.matches('[data-project-content] section details summary')?document.activeElement.textContent:null
|
||||
}));
|
||||
assert.match(result.prd,/DISTINCT SECOND REQUEST/);assert.match(result.source,/request-2/);
|
||||
assert.deepEqual(result.open,[]);assert.equal(result.focused,null);
|
||||
});
|
||||
|
||||
|
||||
test('closed desktop provenance remains closed through a requirement content change',async t=>{
|
||||
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements',1440);
|
||||
await wait('document.querySelectorAll("[data-project-content] section details").length===6');
|
||||
await run(()=>{const el=document.querySelector('[data-project-content] aside details');el.open=false;el.querySelector('summary').focus();reviewDrafts['request-1'].requirements[0].text='Isolated provenance repaint';});
|
||||
await refresh(run,render);
|
||||
const result=await run(()=>({open:document.querySelector('[data-project-content] aside details').open,focused:document.activeElement===document.querySelector('[data-project-content] aside summary'),requirementsOpen:[...document.querySelectorAll('[data-project-content] section details')].filter(el=>el.open).length}));
|
||||
assert.equal(result.focused,true);assert.equal(result.requirementsOpen,0);assert.equal(result.open,false);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setup, P, T } from './helpers/w15-conversations.mjs';
|
||||
import { fixture } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
|
||||
// A periodic paint must not replace controls or messages that a person is using.
|
||||
test('project and conversation stay still over refreshes and retain existing messages', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
await b.evaluate(`(() => {
|
||||
const original = window.fetch;
|
||||
window.extraMessages = []; window.extraThreads = [];
|
||||
window.fetch = async (url, options) => {
|
||||
const response = await original(url, options);
|
||||
if (String(url).startsWith('/api/chat/threads') && !String(url).includes('/messages') && !String(url).includes('/sessions') && !String(url).includes('/shares') && !String(url).includes('shared=true') && response.ok) {
|
||||
const page = await response.json();
|
||||
return new Response(JSON.stringify({...page, rows: [...page.rows, ...window.extraThreads]}), {headers: {'content-type':'application/json'}});
|
||||
}
|
||||
if (!String(url).includes('/threads/') || !String(url).includes('/messages') || !response.ok) return response;
|
||||
const page = await response.json();
|
||||
return new Response(JSON.stringify({...page, rows: [...page.rows, ...window.extraMessages]}), {headers: {'content-type':'application/json'}});
|
||||
};
|
||||
})()`);
|
||||
await b.evaluate(`location.hash='#/projects/${P}/conversations'`);
|
||||
await wait(`document.querySelector('[data-conversation-id]')`);
|
||||
await b.evaluate(`(() => {
|
||||
const row = document.querySelector('[data-conversation-id]');
|
||||
row.focus(); window.savedProject = {node:row, rect:row.getBoundingClientRect().toJSON(), scroll:window.scrollY};
|
||||
})()`);
|
||||
await new Promise(resolve => setTimeout(resolve, 21500));
|
||||
const project = await b.evaluate(`({connected:savedProject.node.isConnected, same:document.querySelector('[data-conversation-id]')===savedProject.node, rect:savedProject.node.getBoundingClientRect().toJSON(), before:savedProject.rect, focused:document.activeElement===savedProject.node, scroll:window.scrollY, beforeScroll:savedProject.scroll})`);
|
||||
assert.ok(project.connected && project.same, 'project row survives unchanged refreshes');
|
||||
assert.deepEqual(project.rect, project.before);
|
||||
assert.equal(project.focused, true);
|
||||
assert.equal(project.scroll, project.beforeScroll);
|
||||
await b.evaluate(`extraThreads.push({id:'20000000-0000-4000-8000-000000000004',project:'${P}',name:'New conversation',instance:'alice'}); document.querySelector('#refresh').click()`);
|
||||
await wait(`document.querySelector('[data-conversation-id="20000000-0000-4000-8000-000000000004"]')`);
|
||||
assert.equal(await b.evaluate('savedProject.node.isConnected && document.activeElement===savedProject.node'), true, 'new conversation keeps the existing row and focus');
|
||||
|
||||
await b.evaluate(`location.hash='#/projects/threads/${T}'`);
|
||||
await wait(`document.querySelector('[data-message]') && document.querySelector('#chat-body')`);
|
||||
await b.evaluate(`(() => {
|
||||
const message = document.querySelector('[data-message]'), field = document.querySelector('#chat-body');
|
||||
field.value='draft'; field.dispatchEvent(new Event('input',{bubbles:true})); field.focus(); field.setSelectionRange(2,2);
|
||||
window.savedChat = {node:message, rect:message.getBoundingClientRect().toJSON(), scroll:document.querySelector('.chat-log').scrollTop, field};
|
||||
})()`);
|
||||
await new Promise(resolve => setTimeout(resolve, 21500));
|
||||
const chat = await b.evaluate(`({connected:savedChat.node.isConnected, same:document.querySelector('[data-message]')===savedChat.node, rect:savedChat.node.getBoundingClientRect().toJSON(), before:savedChat.rect, focused:document.activeElement===savedChat.field, caret:savedChat.field.selectionStart, scroll:document.querySelector('.chat-log').scrollTop, beforeScroll:savedChat.scroll})`);
|
||||
assert.ok(chat.connected && chat.same, 'message survives unchanged refreshes');
|
||||
assert.deepEqual(chat.rect, chat.before);
|
||||
assert.equal(chat.focused, true);
|
||||
assert.equal(chat.caret, 2);
|
||||
assert.equal(chat.scroll, chat.beforeScroll);
|
||||
await b.evaluate(`extraMessages.push({id:'40000000-0000-4000-8000-000000000002',from_role:'human',body:'A new message',at:'2026-11-05T12:01:00Z'}); document.querySelector('#refresh').click()`);
|
||||
await wait(`document.querySelector('[data-message="40000000-0000-4000-8000-000000000002"]')`);
|
||||
assert.equal(await b.evaluate('savedChat.node.isConnected'), true, 'new message does not replace the old message');
|
||||
assert.equal(await b.evaluate('savedChat.field.isConnected && document.activeElement===savedChat.field && savedChat.field.selectionStart===2'), true, 'new message keeps the composer and caret');
|
||||
});
|
||||
|
||||
test('unchanged board reads keep the session row and focused reply control', async t => {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
await b.navigate(f.base);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30));}throw Error('timed out');})()`);
|
||||
await wait(`document.querySelector('[data-open="proj/agent1"]')`);
|
||||
await b.evaluate(`document.querySelector('[data-open="proj/agent1"]').click();document.querySelector('#reply').focus();window.boardRow=document.querySelector('tr[data-row="proj/agent1"]');window.boardAgeRow=document.querySelector('tr[data-row="proj/agent2"]');window.boardReply=document.querySelector('#reply')`);
|
||||
for (let i=0; i<2; i++) {
|
||||
await b.evaluate(`document.querySelector('#refresh').click()`);
|
||||
await wait(`!document.querySelector('#refresh').disabled`);
|
||||
}
|
||||
assert.equal(await b.evaluate(`boardRow.isConnected && boardReply.isConnected && document.activeElement===boardReply`), true);
|
||||
await b.evaluate(`(() => { const old=window.fetch; window.boardAge=120; window.fetch=async (url,options) => {
|
||||
const response=await old(url,options); if(String(url)!=='/api/board' || !response.ok) return response;
|
||||
const data=await response.json(); for(const row of data.sessions) row.ageSeconds=window.boardAge;
|
||||
return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}});
|
||||
}; document.querySelector('#refresh').click(); })()`);
|
||||
await wait(`!document.querySelector('#refresh').disabled`);
|
||||
assert.match(await b.evaluate(`boardAgeRow.textContent`), /2m ago/);
|
||||
assert.equal(await b.evaluate(`boardRow.isConnected && boardReply.isConnected && document.activeElement===boardReply`), true);
|
||||
});
|
||||
|
||||
test('conversation status has room for a temporary unavailable message', async t => {
|
||||
const { b, render, wait } = await setup(t);
|
||||
await render(`/projects/threads/${T}`);
|
||||
await wait(`document.querySelector('[data-conversation-request-status]')`);
|
||||
const position = await b.evaluate(`(() => {
|
||||
const log=document.querySelector('.chat-log'), line=document.querySelector('[data-conversation-request-status]');
|
||||
line.textContent='';
|
||||
const before=log.getBoundingClientRect().top, height=line.getBoundingClientRect().height;
|
||||
line.textContent='Request status unavailable.';
|
||||
return {before,after:log.getBoundingClientRect().top,height,filledHeight:line.getBoundingClientRect().height};
|
||||
})()`);
|
||||
assert.equal(position.after, position.before);
|
||||
assert.ok(position.height >= position.filledHeight, 'the empty status line reserves its height');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { w15Fixture } from './w15-fixture.mjs';
|
||||
|
||||
test('project tree returns after a failed refresh followed by the same data', async t => {
|
||||
const { f, b, wait } = await w15Fixture(t);
|
||||
await b.navigate(f.base + '/#/projects');
|
||||
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/threads' ? Promise.reject(Error('offline')) : originalFetch(url,options);
|
||||
await MosaicProjectTree.mount({container,path:'/projects'});
|
||||
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 {unavailable,recovered:!!container.querySelector('.project-tree-list a'),focused:document.activeElement.getAttribute('href')===href};
|
||||
})()`);
|
||||
assert.deepEqual(result, {unavailable:true,recovered:true,focused:true});
|
||||
});
|
||||
@@ -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('project work reserves a line for a wait reason', async t => {
|
||||
const f = await fixture(), b = await browser({machine:false});
|
||||
t.after(async () => { await b.close(); await f.close(); });
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', {source:stub});
|
||||
await b.viewport(1440, 1000);
|
||||
await b.navigate(f.base + '/#/projects/' + P);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.project-work'))return;await new Promise(r=>setTimeout(r,30))}throw Error('No project work')})()`);
|
||||
const positions = await b.evaluate(`(()=>{
|
||||
const line=document.querySelector('.project-work-wait'), bottom=document.querySelector('.project-work-bottom');
|
||||
if(!line)return null;
|
||||
const before=bottom.getBoundingClientRect().top, height=line.getBoundingClientRect().height;
|
||||
line.textContent='Waiting: decision';
|
||||
return {before,after:bottom.getBoundingClientRect().top,height,filled:line.getBoundingClientRect().height};
|
||||
})()`);
|
||||
assert.ok(positions, 'the wait line is present while empty');
|
||||
assert.equal(positions.after, positions.before);
|
||||
assert.ok(positions.height >= positions.filled);
|
||||
});
|
||||
@@ -39,6 +39,13 @@ async function setup(t, readonly = false) {
|
||||
await wait(`document.querySelector('#processing-state')?.textContent.includes(${JSON.stringify(readonly ? 'halted' : 'enabled')})`);
|
||||
return { b, wait };
|
||||
}
|
||||
test('unchanged processing reads retain the status text node', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
await b.evaluate(`window.processingText=document.querySelector('#processing-state').firstChild;window.readsBefore=window.processingRequests.length;window.dispatchEvent(new Event('focus'))`);
|
||||
await wait('window.processingRequests.length >= window.readsBefore + 2');
|
||||
await b.evaluate(`new Promise(resolve=>setTimeout(resolve,50))`);
|
||||
assert.equal(await b.evaluate(`processingText.isConnected && document.querySelector('#processing-state').firstChild===processingText`), true);
|
||||
});
|
||||
test('processing browser: keyboard Stop, confirmed Resume, unknown outcome and persistent banner', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
await b.evaluate(`document.querySelector('#processing-halt').focus(),true`); await b.key('Enter');
|
||||
|
||||
@@ -13,7 +13,8 @@ test('independent overview read error matrix',async t=>{
|
||||
async function check(name,s,expected,route=`/projects/${P}`){await t.test(name,async()=>{
|
||||
await b.evaluate(`window.__reviewScenario=${JSON.stringify(s)};document.dispatchEvent(new CustomEvent('mosaic-project-room-changed'));`);
|
||||
await render(route);
|
||||
await wait(`document.querySelector('[data-project-content]') && !document.querySelector('[data-project-content]').textContent.startsWith('Reading')`);
|
||||
// A reread keeps the earlier paint until its result arrives (live updates), so wait for the expected text, not for 'Reading' to go.
|
||||
await wait(`document.querySelector('[data-project-content]') && ${expected}.test(document.querySelector('[data-project-content]').textContent)`).catch(()=>{});
|
||||
const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`);
|
||||
assert.match(text,expected);
|
||||
});}
|
||||
|
||||
@@ -12,13 +12,26 @@ 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 replaced = `new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(document.querySelector('#bus-view .chat-area')!==window.__oldPaint && document.querySelector('#bus-view .chat-area'))resolve(true);else if(Date.now()>deadline)reject(Error('No repaint'));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('remote draft survives a real polling repaint and resets on project change', { timeout: 40000 }, async t => {
|
||||
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(`${input} window.__oldPaint=document.querySelector('#bus-view .chat-area')`);
|
||||
await b.evaluate(replaced);
|
||||
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 });
|
||||
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
|
||||
await b.evaluate(`${input} ${trackRefresh}`);
|
||||
await b.evaluate(refreshed);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url')?.value`), 'https://git.example/owner/unsaved');
|
||||
await b.evaluate(`location.hash='#/projects/${ids.empty}/settings'`); await wait(`document.querySelector('.project-remote')?.textContent.includes('no linked repository')`);
|
||||
await b.evaluate(`location.hash=${JSON.stringify(settings)}`); await wait(`!!document.querySelector('#project-remote-url')`);
|
||||
@@ -29,7 +42,7 @@ test('remote pending save and success survive shell refresh', { timeout: 30000 }
|
||||
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={})=>{if(init.method==='POST' && String(url).includes('/api/project-remotes/')) return new Promise(resolve=>window.__finish=()=>resolve(new Response(JSON.stringify({remote:'https://git.example/owner/unsaved'}),{status:200,headers:{'content-type':'application/json'}})));return __fetch(url,init)}; ${input} document.querySelector('.project-remote form').requestSubmit();`);
|
||||
await wait('!!window.__finish'); await b.evaluate(`window.__oldPaint=document.querySelector('#bus-view .chat-area'); ${refresh}`); await b.evaluate(replaced);
|
||||
await wait('!!window.__finish'); await b.evaluate(`${trackRefresh} ${refresh}`); await b.evaluate(refreshed);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url')?.disabled`), true);
|
||||
await b.evaluate('__finish()'); await wait(`document.querySelector('.project-remote')?.textContent.includes('Configured, not verified')`);
|
||||
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /https:\/\/git.example\/owner\/unsaved/);
|
||||
@@ -40,7 +53,7 @@ test('remote unknown outcome and reconciliation survive shell refresh', { timeou
|
||||
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
|
||||
await b.evaluate(`window.__fetch=fetch; window.fetch=async (url,init={})=>{if(init.method==='POST' && String(url).includes('/api/project-remotes/')) throw Error('lost');return __fetch(url,init)}; ${input} document.querySelector('.project-remote form').requestSubmit();`);
|
||||
await wait(`document.querySelector('.project-remote')?.textContent.includes('Save outcome is unknown')`);
|
||||
await b.evaluate(`window.__oldPaint=document.querySelector('#bus-view .chat-area'); ${refresh}`); await b.evaluate(replaced);
|
||||
await b.evaluate(`${trackRefresh} ${refresh}`); await b.evaluate(refreshed);
|
||||
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /Save outcome is unknown/);
|
||||
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
|
||||
await b.evaluate(`document.querySelector('.project-remote button[type=button]').click()`); await wait(`document.querySelector('.project-remote').textContent.includes('Review your entry')`);
|
||||
|
||||
Reference in new issue
Block a user