feat(webui): update request presentation (#797)

This commit is contained in:
jason.woltje committed 2026-11-05 21:23:14 -06:00
1 parent 05fdfef9ea
commit ea0b676541
5 files changed
+251 -20

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
/* Request presentation. Shell loads this after the shared styles. */
.request-panel { border-radius: 6px; padding: 1rem; overflow-wrap: anywhere; }
.request-panel .request-head { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; }
.request-panel .request-head b { font-size: 1.1rem; }
.request-panel .request-phase, .request-panel .request-mode, .request-observed { font-size: .875rem; }
.request-panel .request-question, .request-panel .request-delivery, .request-panel .request-files { border: 1px solid var(--line); border-radius: 4px; padding: .75rem; margin-block: .75rem; }
.request-panel .request-question-text { font-family: inherit; font-weight: 600; }
.request-panel .request-choices { display: grid; gap: .5rem; }
.request-panel .request-choices .btn { text-align: start; }
.request-panel .btn, .request-panel summary, .request-return { min-height: 44px; }
.request-panel summary, .request-return { display: flex; align-items: center; cursor: pointer; }
.request-panel pre { max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.request-panel details { margin-block: .5rem; }
.request-panel .request-foot { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
.request-panel h3 { margin: 0 0 .5rem; font-size: 1rem; }
+59 -20
View File
@@ -46,7 +46,7 @@
};
const prdDecision = s => s.phase === 'prd-review' && s.wait?.kind === 'decision' && s.wait.code === 'prd-approval' && text(s.wait.subject);
const panels = new Map();
const panels = new Map(), summaryRuns = new WeakMap(), summaries = new Set();
let signedOut = false, timer = null, config = null, loading = null;
const out = () => signedOut || document.body.classList.contains('signed-out');
const opId = prefix => prefix + '-' + [...crypto.getRandomValues(new Uint8Array(12))].map(b => b.toString(16).padStart(2, '0')).join('');
@@ -73,6 +73,8 @@
function purge() {
forget(); dropAll(); config = null; loading = null;
for (const el of document.querySelectorAll('.request-mount')) el.replaceChildren();
for (const el of summaries) el.replaceChildren();
summaries.clear();
}
const signOut = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
document.addEventListener('mosaic-signed-out', () => { signedOut = true; purge(); });
@@ -97,7 +99,7 @@
let p = panels.get(id);
if (!p) {
const stored = load(id);
p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, reading: null, stored, sig: '',
p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, observedAt: null, reading: null, stored, sig: '',
ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } };
panels.set(id, p);
}
@@ -120,15 +122,22 @@
if (p.ui.draft === rec.text) p.ui.draft = '';
if (!p.ui.receipt) p.ui.receipt = q && q.id === rec.question && q.answer !== null ? 'The question was answered. Your earlier answer is not sent again.' : 'That question is closed. Your unsent answer was discarded.';
}
function adopt(p, s) {
function adopt(p, s, observedAt = null) {
if (p.state && s.revision < p.state.revision) return false;
if (p.state && (s.revision !== p.state.revision || !same(s.candidate, p.state.candidate))) p.ui.confirm = null;
if (p.state && s.snapshot !== p.state.snapshot) p.ui.revokeAsk = false;
if (p.state && (s.snapshot !== p.state.snapshot || !same(s.candidate, p.state.candidate))) {
p.material = null; p.materialError = '';
}
if (!observedAt && p.state && !same(s, p.state)) p.observedAt = null;
if (!p.state || s.revision >= p.state.revision) p.state = s;
p.missing = false; p.failed = false; p.stale = false;
reconcile(p);
if (observedAt && p.state === s) {
p.observedAt = observedAt;
document.dispatchEvent(new CustomEvent('mosaic-request-state', { detail: { state: structuredClone(s), observedAt } }));
}
return true;
}
const WAITS = {
@@ -153,9 +162,9 @@
if (!m.candidate) return '<p class="request-prd-empty">No current PRD candidate or approved snapshot. Waiting for a PRD submission.</p>';
const c = m.candidate;
let h = `<section class="request-prd" aria-label="Exact product PRD"><p><b>Product PRD ${txt(c.prd_version)}</b></p>
<p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p>
<details><summary>PRD identity and provenance</summary><p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p></details>
<pre class="request-prd-text">${txt(m.prd)}</pre><div class="request-requirements" aria-label="Requirements">${m.requirements.map((r, i) =>
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><details><summary>Requirement identity</summary><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p></details><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
if (m.snapshot.id) {
h += `<p class="request-snapshot">Approved snapshot: <code>${txt(m.snapshot.id)}</code></p>`;
if (can) h += `<div class="request-revoke">${p.ui.revokeAsk
@@ -173,26 +182,35 @@
if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : '';
const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale;
const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null;
let body = `<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
const returnHash = /^#\/chat\/threads\//.test(location.hash) || /^#\/projects\/threads\//.test(location.hash) ? location.hash : '';
let body = `${returnHash ? `<a class="request-return" href="${esc(returnHash)}">Return to conversation</a>` : ''}<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
if (s.project) body += `<p class="request-project">Project: <code>${txt(s.project)}</code>${s.phase === 'cancelled' ? ' (kept)' : ''}</p>`;
if (p.observedAt) body += `<p class="request-observed small">Observed: <time datetime="${esc(p.observedAt)}">${txt(p.observedAt)}</time></p>`;
body += waitHtml(s);
if (Array.isArray(s.tasks) && s.tasks.length) body += `<p class="request-tasks">Tasks: ${s.tasks.filter(text).map(t => `<a href="#/tasks/${enc(t)}">${txt(t)}</a>`).join(', ')}</p>`;
if (s.phase === 'complete' && !s.delivery) body += '<p class="request-legacy-complete">Completed before delivery evidence.</p>';
const d = s.delivery_evidence;
if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.artifact && d.verification) {
if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.project === s.project && d.snapshot === s.snapshot && d.artifact && d.verification) {
const pr = d.pull_request;
let prUrl = null;
try {
const u = new URL(pr?.url);
if (pr.request === s.request && pr.project === s.project && u.protocol === 'https:' && !u.username && !u.password) prUrl = u.href;
} catch { /* No valid bound PR URL. */ }
body += `<section class="request-delivery" aria-label="Reviewed delivery"><p><b>Reviewed delivery</b> — not landed or deployed.</p>
<p><a href="/api/requests/${enc(s.request)}/artifact/${enc(d.id)}" download>Download reviewed HTML</a></p>
<p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
<p>${prUrl ? `<a href="${esc(prUrl)}" target="_blank" rel="noopener noreferrer">Open pull request</a>` : 'PR link unavailable.'}</p><details><summary>Delivery evidence</summary><p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
Code commit: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.commit)}</code></a><br>Review request: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.review_request)}</code></a><br>Verdict: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.verdict)}</code></a><br>
Verification record: <a href="#/docs/${enc(d.project)}/${enc(d.verification.record_path)}">${txt(d.verification.record_path)}</a> at <code>${txt(d.verification.record_commit)}</code><br>
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></section>`;
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></details></section>`;
}
body += prdHtml(p, busy, can);
if (q) {
body += q.answer === null
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
: `<p class="request-question answered">Answered: ${txt(q.text)}</p>`;
: `<p class="request-question answered">Answer received: ${txt(q.text)}. Application is not established by this receipt.</p>`;
}
if (!can) body += '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
if (!can) body += p.stale ? '<p class="request-readonly small muted">Writes are unavailable until the request is read again.</p>' : '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
if (can && ui.confirm) {
const c = ui.confirm;
body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p>
@@ -202,7 +220,7 @@
<button type="button" class="btn" data-request-unconfirm${busy}>Back</button></div>`;
} else if (can && (s.phase === 'selection' || prdDecision(s) && p.material?.candidate) && p.inbox) {
body += `<div class="request-choices" role="group" aria-label="${prdDecision(s) ? 'Review this exact product PRD version' : 'Choose how to run this request'}"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o =>
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>`;
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${s.phase === 'selection' && ['guided', 'autonomous', 'existing'].includes(o.key) ? `<b>${o.key === 'existing' ? 'Existing project' : o.key === 'guided' ? 'Guided' : 'Autonomous'}</b>: ` : ''}${txt(o.text)}</button>`).join(' ')}</div>`;
}
if (can && pickerOpen(s)) {
const picked = s.wait.code === 'request-attachment-selected' ? s.wait.subject : null;
@@ -211,6 +229,7 @@
`<button type="button" class="btn" data-request-project="${esc(x.project)}" aria-pressed="${x.project === picked}"${busy}>${txt(x.name)} <small>(${txt(x.project)})</small></button>`).join(' ')}` : '<p>No project is available to you for this request.</p>'}</div>`
: '<p class="request-picker-note">The projects you may use could not be read. Use Read again.</p>';
}
if (p.stale && openQuestion(s) && ui.draft) body += `<p>Unsent answer retained:</p><pre class="request-retained-draft">${txt(ui.draft)}</pre>`;
if (can && openQuestion(s)) {
const lost = p.stored && p.stored.question === s.question.id;
body += `<form class="request-answer" novalidate><label for="chat-request-answer-${esc(p.id)}">Your answer</label>
@@ -231,8 +250,9 @@
<button type="button" class="btn" data-request-cancel-confirm${busy}>Yes, cancel the request</button> <button type="button" class="btn" data-request-cancel-keep${busy}>Keep it going</button>`
: `<button type="button" class="btn" data-request-cancel${busy}>Cancel this request</button>`}</div>`;
}
body += `<section class="request-files" aria-label="Files"><h3>Files</h3><div data-request-files><p>Files unavailable.</p></div></section>`;
body += `<p class="request-receipt small" role="status" tabindex="-1">${txt(ui.receipt)}</p>`;
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}<button type="button" class="btn" data-request-reload${busy}>Read again</button></p>`;
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}${p.reading ? '<span role="status">Refreshing request…</span>' : `<button type="button" class="btn" data-request-reload${busy}>Read again</button>`}</p>`;
return `<section class="request-panel" data-request="${esc(p.id)}" aria-label="Request">${body}</section>`;
}
@@ -246,6 +266,8 @@
const area = inside && a.matches('textarea') ? { id: a.id, start: a.selectionStart, end: a.selectionEnd } : null;
const mark = inside && !area ? [...a.attributes].find(x => x.name.startsWith('data-request-')) : null;
el.innerHTML = html(p);
const files = el.querySelector('[data-request-files]');
if (files && typeof window.MosaicFiles?.mount === 'function') window.MosaicFiles.mount({ container: files, requestIds: [p.id] });
if (!inside) continue;
let focus = null;
if (area) {
@@ -294,9 +316,9 @@
try {
const { res, json } = await call(`/api/requests/${enc(p.id)}`);
if (out() || panels.get(p.id) !== p) return;
if (res?.ok && isState(json, p.id)) { adopt(p, json); await side(p); }
else if (res?.status === 404) { p.state = null; p.missing = true; p.failed = false; }
else if (p.state) { p.stale = true; p.material = null; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; }
if (res?.ok && isState(json, p.id) && adopt(p, json, new Date().toISOString())) { await side(p); }
else if (res?.status === 404 && !p.state) { p.state = null; p.missing = true; p.failed = false; }
else if (p.state) { p.stale = true; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; }
else p.failed = true;
p.at = Date.now();
if (out() || panels.get(p.id) !== p) return;
@@ -426,7 +448,7 @@
// One use, whatever came back.
ui.pending = null; ui.confirm = null;
ui.receipt = res?.ok && json?.id === c.id && json.op === 'resolved' && json.choice === c.choice
? `Recorded: you chose “${c.text}”. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown'));
? `Recorded: you chose “${c.text}” for decision ${c.id}${c.candidate ? `, product PRD ${c.candidate.prd_version}` : ''}. Execution owner unavailable. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown'));
draw(p);
void read(p);
}
@@ -436,7 +458,7 @@
const el = button?.closest('.request-panel');
if (!button || !el || out()) return;
const p = panels.get(el.dataset.request);
if (!p || button.disabled || p.ui.pending || p.reading) return;
if (!p || button.disabled || p.ui.pending || p.reading || (p.stale && !('requestReload' in button.dataset))) return;
const d = button.dataset;
if ('requestReload' in d) { p.ui.receipt = ''; void read(p); draw(p); }
else if ('requestChoice' in d) void choose(p, d.requestChoice);
@@ -459,7 +481,7 @@
if (!form) return;
event.preventDefault();
const p = panels.get(form.closest('.request-panel')?.dataset.request);
if (p && !out()) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); }
if (p && !out() && !p.stale && !p.reading) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); }
});
document.addEventListener('input', event => {
const area = event.target.closest?.('.request-answer textarea');
@@ -506,5 +528,22 @@
else void settings().then(run);
}
window.MosaicRequests = Object.freeze({ mount, forget });
// The project page supplies known IDs; each is reread through the existing authorized boundary.
async function projectSummary({ container, requestIds, project } = {}) {
if (!container || out()) return;
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)))];
const cfg = config || await settings();
if (out() || !cfg?.on) return;
const rows = await Promise.all(ids.map(async id => {
const { res, json } = await call(`/api/requests/${enc(id)}`);
return res?.ok && isState(json, id) && json.project === project ? json : null;
}));
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>';
}
window.MosaicRequests = Object.freeze({ mount, forget, projectSummary });
})();
@@ -0,0 +1,76 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { fixture } from '../fixture.mjs';
import { browser } from '../browser.mjs';
export const THREAD = '20000000-0000-4000-8000-000000000002';
export const MESSAGE = '40000000-0000-4000-8000-000000000004';
export const REQUEST = '50000000-0000-4000-8000-000000000005';
export const DECISION = '60000000-0000-4000-8000-000000000006';
export const state = extra => ({ version: 1, request: REQUEST, revision: 7, phase: 'discovery', project: 'factory', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null, ...extra });
export const candidate = { request: REQUEST, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd.md', prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, requirements: [{ id: 'REQ-1', title: 'Timer', path: 'req.json', blob: 'd'.repeat(40) }] };
export const draft = { candidate, prd: 'A timer that counts down.', requirements: [{ id: 'REQ-1', title: 'Timer', text: 'Count down.' }], snapshot: { id: null, revision: 2 } };
export const evidence = { id: 'delivery-1', request: REQUEST, project: 'factory', snapshot: 'snapshot-1', task_ref: 'vikunja:2/41', commit: 'a'.repeat(40), review_request: 'review-1', verdict: 'verdict-1', artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } };
export const wait = (kind, code, subject) => ({ kind, code, subject });
export const question = { id: 'q-1', text: 'Which platform?', answer: null, options: [] };
export async function setup(t, boot) {
const f = await fixture(); t.after(() => f.close());
const b = await browser({ machine: false }); t.after(() => b.close());
const css = await readFile(new URL('../../src/public/requests.css', import.meta.url), 'utf8');
const source = `(() => {
const RealDate = Date; window.Date = class extends RealDate { constructor(...args) { super(...(args.length ? args : ['2026-11-05T12:00:00Z'])); } };
const boot = ${JSON.stringify(boot)}; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = [];
document.addEventListener('mosaic-request-state', e => window.__events.push(e.detail));
window.MosaicFiles = { mount({container, requestIds}) { window.__fileCalls.push(requestIds); container.textContent = 'Fixture Files mount'; } };
document.addEventListener('DOMContentLoaded', () => { const s=document.createElement('style'); s.textContent=${JSON.stringify(css)}; document.head.append(s); });
window.fetch = async (url, init = {}) => {
const path = String(url), method = init.method || 'GET';
const json = (v, status=200) => new Response(JSON.stringify(v), {status, headers:{'content-type':'application/json'}});
if (method !== 'GET') { window.__writes.push({path, method}); if (path === '/api/decisions/${DECISION}/confirmation' && boot.allowConfirmation) return json({id:'${DECISION}',action:'answer',kind:'decision',choice:'yes',text:'Approve exact PRD',question:'Approve the exact PRD?',confirmation:'fixture-token'}); return json({error:'fixture-refusal'}, 403); }
if (path === '/api/config') return json({send:true, requests:true, requestWrites:true});
if (path === '/api/bus/inbox') return json({rows:boot.inbox || [], unread:0, at:'2026-11-05T12:00:00Z'});
if (path.startsWith('/api/bus/')) return json({rows:[], unread:0, at:'2026-11-05T12:00:00Z'});
if (path === '/api/business') return json({projects:[]});
if (path === '/api/chat/instances') return json({rows:[{instance:'assistant',name:'assistant'}]});
if (path === '/api/chat/projects') return json({rows:[]});
if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${THREAD}',name:'default',default:true}]});
if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return json({rows:[{id:'${THREAD}',project:'${THREAD}',name:'Project',instance:'assistant'}]});
if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${REQUEST}'}],after:'${MESSAGE}',hasMore:false});
const m=/^\\/api\\/requests\\/([^/]+)(?:\\/(projects|draft))?$/.exec(path);
if (m) { if (m[2] === 'projects') return json([]); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); }
window.__unexpected.push(path); return json({error:'unexpected fixture API'}, 403);
};
})();`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
await b.call('Fetch.enable', { patterns: [{urlPattern:'*'}] });
const network = [], consoleErrors = [];
b.on('Runtime.exceptionThrown', e => consoleErrors.push(e.exceptionDetails?.text || 'Runtime exception'));
b.on('Runtime.consoleAPICalled', e => { if (e.type === 'error') consoleErrors.push(e.args.map(a => a.value || a.description || '').join(' ')); });
b.on('Fetch.requestPaused', e => {
const url = e.request.url;
if (url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:')) void b.call('Fetch.continueRequest', {requestId:e.requestId}).catch(() => {});
else { network.push(url); void b.call('Fetch.failRequest', {requestId:e.requestId,errorReason:'BlockedByClient'}).catch(() => {}); }
});
const route = `#/chat/threads/${THREAD}`;
const navigate = async width => {
await b.viewport(width, 1000); await b.evaluate('window.__beforeNavigate=true;true'); await b.navigate(f.base + '/?width=' + width + route);
await b.evaluate(`(async()=>{const end=Date.now()+10000;while(window.__beforeNavigate || !document.querySelector('.request-panel')){if(Date.now()>end)throw Error('request panel missing');await new Promise(r=>setTimeout(r,30))}return true})()`);
};
const shot = async (name, width) => {
if (!process.env.MOSAIC_UX_SHOTS) return [];
await mkdir(process.env.MOSAIC_UX_SHOTS, {recursive:true});
const viewport = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}.png`);
const full = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-full.png`);
await b.evaluate("document.querySelector('.request-panel').scrollIntoView({block:'start'});window.scrollBy(0,-90);true");
await b.screenshot(viewport);
const {cssContentSize} = await b.call('Page.getLayoutMetrics');
const {data} = await b.call('Page.captureScreenshot', {format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(cssContentSize.width),height:Math.ceil(cssContentSize.height),scale:1}});
await writeFile(full, Buffer.from(data,'base64'));
const continuation = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-continuation.png`);
await b.evaluate("(()=>{let e=document.querySelector('.request-panel').parentElement;while(e && !(e.scrollHeight > e.clientHeight && /(auto|scroll)/.test(getComputedStyle(e).overflowY))) e=e.parentElement;if(e)e.scrollTop=e.scrollHeight;return true})()");
await b.screenshot(continuation);
return [viewport,full,continuation];
};
return {f,b,route,navigate,shot,network,consoleErrors};
}
@@ -578,3 +578,17 @@ test('reviewed delivery shows a download and bound evidence separately from depl
for (const value of [evidence.snapshot, evidence.task_ref, evidence.commit, evidence.review_request, evidence.verdict, evidence.verification.record_commit, evidence.artifact.sha256]) assert.ok(panel.includes(value));
assert.equal(await b.evaluate(`document.querySelector('${P} .request-delivery a').getAttribute('href')`), `/api/requests/${R}/artifact/delivery-1`);
});
test('reviewed delivery refuses evidence bound to another project or snapshot', async t => {
const evidence = { id: 'delivery-1', request: R, project: 'other', snapshot: 'prd-approved', task_ref: 'vikunja:2/41',
artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } };
const complete = state({ ...APPROVED, phase: 'complete', delivery: evidence.id, delivery_evidence: evidence });
const e = await setup(t, { boot: { states: { [R]: complete }, drafts: { [R]: ACTIVE_DRAFT } } });
await e.w(`document.querySelector('${P} .request-snapshot')`);
assert.equal(await e.count(`${P} .request-delivery`), 0);
await e.b.evaluate(`window.__states['${R}']=${JSON.stringify({ ...complete, revision: 15, delivery_evidence: { ...evidence, project: 'factory', snapshot: 'other' } })};true`);
await e.click(`${P} [data-request-reload]`);
await e.w(`window.__rreads.filter(p => p === '/api/requests/${R}').length >= 2 && document.querySelector('${P} [data-request-reload]')`);
assert.equal(await e.count(`${P} .request-delivery`), 0);
assert.deepEqual(await e.writes(), []);
});
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { setup, state, candidate, draft, evidence, wait, question, REQUEST, DECISION } from './helpers/w15-requests.mjs';
const choices = [{key:'guided',text:'New project guided'}, {key:'autonomous',text:'New project autonomous'}, {key:'existing',text:'Add to existing project'}];
const activeDraft = {...draft, snapshot:{id:'snapshot-1',revision:2}};
const inbox = [{id:DECISION,question:'Choose a mode',console:{kind:'decision',options:choices}}];
const scenarios = [
['mode-choice', state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)}), {inbox}],
['project-picker-empty', state({project:null,phase:'selection',route:'existing',wait:wait('setup','request-existing-project',REQUEST)}), {}],
['pm-question', state({question,wait:wait('question','question','q-1')}), {}],
['prd-confirmation', state({phase:'prd-review',candidate,wait:wait('decision','prd-approval',DECISION)}), {draft,allowConfirmation:true,inbox:[{id:DECISION,question:'Approve exact PRD?',console:{kind:'decision',options:[{key:'yes',text:'Approve exact PRD'},{key:'no',text:'Request changes'}]}}]}],
['request-progress', state({phase:'implementation',snapshot:'snapshot-1',candidate,question:{...question,answer:'answer-1'},tasks:['vikunja:2/41']}), {draft:activeDraft}],
['request-refresh-failed', state({question,wait:wait('question','question','q-1')}), {}],
['reviewed-result', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:evidence}), {draft:activeDraft}],
['result-evidence', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:evidence}), {draft:activeDraft}],
['legacy-complete', state({phase:'complete'}), {}],
['pr-link-present-and-absent', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:{...evidence,pull_request:{request:REQUEST,project:'factory',url:'https://example.org/repo/pull/1'}}}), {draft:activeDraft}],
];
test('W15 requests fixture scenarios and screenshots', async t => {
const manifest = [];
for (const [name, current, extra] of scenarios) {
await t.test(name, async t => {
const {b, route, navigate, shot, network, consoleErrors} = await setup(t, {states:{[REQUEST]:current}, ...extra});
for (const width of [1440,390]) {
await navigate(width);
const waitFor = async expression => b.evaluate(`(async()=>{const end=Date.now()+10000;while(!(${expression})){if(Date.now()>end)throw Error('state missing');await new Promise(r=>setTimeout(r,30))}return true})()`);
if (name === 'mode-choice') { await waitFor("document.querySelector('[data-request-choice]')"); assert.match(await b.evaluate("document.querySelector('[data-request-choice=guided]').textContent"), /^Guided/); }
if (name === 'project-picker-empty') { await waitFor("document.querySelector('.request-picker')"); assert.match(await b.evaluate("document.querySelector('.request-picker').textContent"), /No project/); }
if (name === 'prd-confirmation') { await waitFor("document.querySelector('[data-request-choice=yes]')"); await b.evaluate("document.querySelector('[data-request-choice=yes]').click();true"); await waitFor("document.querySelector('[data-request-confirm]')"); }
if (name === 'request-progress') assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Answer received.*not established/s);
if (name === 'legacy-complete') assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Completed before delivery evidence/);
if (name === 'reviewed-result') assert.match(await b.evaluate("document.querySelector('.request-delivery').textContent"), /not landed or deployed.*PR link unavailable/s);
if (name === 'result-evidence') await b.evaluate("document.querySelector('.request-delivery details').open=true;true");
if (name === 'pr-link-present-and-absent') assert.equal(await b.evaluate("document.querySelector('.request-delivery a[target=_blank]').href"), 'https://example.org/repo/pull/1');
if (name === 'request-refresh-failed') {
const observed = await b.evaluate("document.querySelector('.request-observed time').dateTime");
await b.evaluate("(()=>{window.__failure=true;const a=document.querySelector('.request-answer textarea');a.value='Draft kept';a.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('[data-request-reload]').click();return true})()");
await waitFor("document.querySelector('.request-retained-draft')");
assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Could not refresh.*last state/s);
assert.equal(await b.evaluate("document.querySelector('.request-retained-draft').textContent"), 'Draft kept');
assert.equal(await b.evaluate("document.querySelector('.request-observed time').dateTime"), observed);
assert.equal(await b.evaluate('window.__events.length'), 1);
assert.equal(await b.evaluate("document.querySelectorAll('.request-panel form').length"), 0);
}
assert.equal(await b.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"), true, 'no document overflow');
assert.equal(await b.evaluate("document.querySelector('.request-return').getAttribute('href')"), route);
assert.deepEqual(await b.evaluate('window.__fileCalls.at(-1)'), [REQUEST]);
const files = await shot(name, width);
manifest.push({scenario:name, route, width, height:1000, observation:'Fixture read at 2026-11-05T12:00:00.000Z', files, networkErrors:[...network], consoleErrors:[...consoleErrors], refusedFixtureRequests:await b.evaluate('window.__unexpected')});
}
});
}
if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'), JSON.stringify({candidate:'set after commit', command:'node --test --test-concurrency=1 packages/webui/tests/w15-requests-browser.test.mjs', result:'see w15-tests.txt', scenarios:manifest}, null, 2));
});
test('project summary rereads known IDs, refuses foreign project, and sign-out clears it', async t => {
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({})}}); await navigate(1440);
assert.deepEqual(await b.evaluate(`(async()=>{const el=document.createElement('div');document.body.append(el);window.__summary=el;await MosaicRequests.projectSummary({container:el,requestIds:['${REQUEST}'],project:'other'});return [el.textContent,window.__events.length]})()`), ['Request information unavailable.',1]);
assert.match(await b.evaluate(`(async()=>{await MosaicRequests.projectSummary({container:window.__summary,requestIds:['${REQUEST}'],project:'factory'});return window.__summary.textContent})()`), /factory|discovery/);
await b.evaluate("document.dispatchEvent(new Event('mosaic-signed-out'));true");
assert.equal(await b.evaluate('window.__summary.textContent'), '');
});
test('PR URLs require exact request and project binding and safe HTTPS', async t => {
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({phase:'complete',snapshot:'snapshot-1',delivery:evidence.id,delivery_evidence:{...evidence,pull_request:{request:REQUEST,project:'factory',url:'javascript:alert(1)'}}})}}); await navigate(1440);
assert.equal(await b.evaluate("document.querySelectorAll('.request-delivery a[target=_blank]').length"),0);
assert.match(await b.evaluate("document.querySelector('.request-delivery').textContent"), /PR link unavailable/);
});
test('optional Files module can be absent and keyboard refresh preserves focus', async t => {
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({question,wait:wait('question','question','q-1')})}}); await navigate(390);
await b.evaluate("delete window.MosaicFiles;document.querySelector('[data-request-reload]').focus();true");
await b.key('Enter');
await b.evaluate(`(async()=>{const end=Date.now()+10000;while(!document.querySelector('[data-request-reload]') || document.querySelector('[data-request-reload]').disabled){if(Date.now()>end)throw Error('refresh did not finish');await new Promise(r=>setTimeout(r,30))}return true})()`);
assert.match(await b.evaluate("document.querySelector('[data-request-files]').textContent"), /unavailable/i);
assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
await b.key('Tab'); await b.key('Tab','Tab',8); await b.key('Escape');
assert.equal(await b.evaluate("document.querySelector('.request-panel button[type=submit]').getBoundingClientRect().height >= 44"), true);
await b.evaluate("document.documentElement.dataset.mode='dark';true");
await b.call('Emulation.setPageScaleFactor',{pageScaleFactor:2});
assert.equal(await b.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"),true);
});