webui: F2 review fixes, interview retry, fresh approval capability, primary start

Sol's F2 review: a refused interview start threw on the null
currentTarget after the await and left Start disabled (P2); the panel
cached /api/config for the life of the page, so Approve outlived the
capability (P3). Hold the button before the await; reread config on
each offer. Lead P3: on Quick Setup done, Start onboarding interview
gets the accent fill; Open organization stays secondary.
onboarding-retry-browser is red 0/2 on the old source, 2/0 now.
This commit is contained in:
jason.woltje committed 2026-11-09 13:17:10 -06:00
1 parent 205154e8d7
commit 5937a977f8
5 files changed
+77 -5

No files matched your search

+5 -4
View File
@@ -66,7 +66,6 @@
// project init wrote, and shows the assistant's draft with Approve when the draft is newer than the
// north star and says something else.
const runs = new WeakMap(), notes = new Map(), drafts = new Map();
let config = null;
async function offer(container, { project, section, north, docs, threads = [], canSend = false }) {
const run = (runs.get(container) || 0) + 1; runs.set(container, run);
const slot = () => container.parentElement?.querySelector(':scope > [data-onboarding]');
@@ -82,7 +81,8 @@
if (r.status === 200 && typeof r.body.content === 'string' && COMMIT.test(r.body.commit || '')) { draft = r.body; drafts.set(key, draft); }
else draft = drafts.get(key) ?? null;
}
if (!config) config = await call('/api/config').then(r => r.status === 200 ? r.body : null);
// Read on every offer: approval can become unavailable (an org bus going down) while the page stays open.
const config = await call('/api/config').then(r => r.status === 200 ? r.body : null);
if (runs.get(container) !== run || !container.isConnected) return;
const template = normal(north.content) === normal(plan.northStarTemplate);
// `committed` is each file's last commit time; `at` on a Console read is only when it was read.
@@ -113,9 +113,10 @@
if (view) view.replaceChildren(window.MosaicMarkdown?.render(draft.content) || document.createTextNode(draft.content));
const status = text => { const el = panel.querySelector('.onboarding-status'); if (el) el.textContent = text; };
panel.querySelector('[data-onboarding-start]')?.addEventListener('click', async e => {
e.currentTarget.disabled = true;
// Hold the button: the event's currentTarget is null once the await returns.
const button = e.currentTarget; button.disabled = true;
const done = await start({ chatProject: project.id, repo: project.repo, status });
if (!done) e.currentTarget.disabled = false;
if (!done) button.disabled = false;
});
panel.querySelector('[data-onboarding-approve]')?.addEventListener('click', async e => {
const button = e.currentTarget; button.disabled = true; status('Approving…');
+2
View File
@@ -11,6 +11,8 @@
.org-inline input,.org-inline select{width:100%}
.org-inline .org-help{grid-column:1/-1;grid-row:3}
.settings-content .org-admin button.org-secondary{background:var(--surface);color:var(--text);border-color:var(--border)}
/* F2: the onboarding interview is the main next step after Quick Setup; Open organization skips it. */
.settings-content .org-admin button.org-primary{background:var(--accent-fill);color:var(--on-accent);border-color:var(--accent-fill)}
.settings-content .org-admin button:disabled{opacity:.55;cursor:not-allowed}
.org-banner{margin:0 0 16px;padding:12px 16px;border:1px solid var(--line);border-radius:var(--r);background:var(--surface);color:var(--text);font-size:14px}
.org-banner.err{border-color:color-mix(in srgb,var(--danger) 50%,var(--line));background:color-mix(in srgb,var(--danger) 8%,var(--surface))}
+1 -1
View File
@@ -38,7 +38,7 @@
<label class="org-quick-risk"><input id="org-risk-accept" type="checkbox"> I understand local-only repositories have no copy elsewhere and can be lost.</label>
<div class="settings-actions"><button type="button" id="org-quick-run">Create and set up organization</button></div>
<ol id="org-quick-steps" class="org-steps" aria-live="polite"></ol>
<div class="settings-actions"><button type="button" id="org-quick-interview" hidden>Start onboarding interview</button><button type="button" class="org-secondary" id="org-quick-open-org" hidden>Open organization</button></div>
<div class="settings-actions"><button type="button" class="org-primary" id="org-quick-interview" hidden>Start onboarding interview</button><button type="button" class="org-secondary" id="org-quick-open-org" hidden>Open organization</button></div>
</section>
<section id="org-editor" class="org-editor" hidden aria-labelledby="org-editor-title">
<div class="org-editor-head"><h3 id="org-editor-title">New organization</h3><p id="org-editor-verb" class="org-verb"></p></div>
@@ -97,6 +97,9 @@ for (const scenario of ['start', 'skip', 'stopped']) test(`quick setup done stat
assert.equal(await shown(b, '#org-quick-open-org'), true, 'Open organization stays next to it');
// Both controls are in one row of actions, each a 44px target.
for (const id of ['#org-quick-interview', '#org-quick-open-org']) assert.ok(await b.evaluate(`document.querySelector('${id}').getBoundingClientRect().height >= 44`), id);
// The interview is the primary action: it gets the accent fill, Open organization does not.
const fill = id => b.evaluate(`getComputedStyle(document.querySelector('${id}')).backgroundColor`);
assert.notEqual(await fill('#org-quick-interview'), await fill('#org-quick-open-org'), 'the interview button is styled as the primary action');
if (scenario === 'start') await capture(b, '#org-quick-interview', 'quick-setup-done');
if (scenario === 'skip') {
await b.evaluate(`document.querySelector('#org-quick-open-org').click()`);
@@ -0,0 +1,66 @@
// F2 review (Sol, 11-09): the project-page onboarding panel after a refused start and a capability
// change. A throwaway loopback page loads the shipped onboarding.js with synthetic API answers.
// P2: a definite refusal to open the conversation must leave Start enabled for a retry.
// P3: a refresh must reread /api/config, so approval disappears once it is no longer available.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { browser } from './browser.mjs';
import { loadInterviewTemplate, interviewMessage, northStarTemplateText } from '../../project/src/onboarding.mjs';
const SCRIPT = fileURLToPath(new URL('../src/public/onboarding.js', import.meta.url));
const template = loadInterviewTemplate();
const spec = { path: 'spec/north-star.md', commit: 'a'.repeat(40), content: northStarTemplateText(), committed: '2026-11-09T10:00:00Z' };
const draft = { path: template.drafts.northStar, commit: 'b'.repeat(40), content: '# Draft\n', committed: '2026-11-09T11:00:00Z' };
const plan = { ...template, message: interviewMessage(template, { project: 'stack' }), northStarTemplate: northStarTemplateText(), assistant: { instance: 'assistant', name: 'Ada' } };
async function setup(t, { pending = false } = {}) {
const state = { approve: true, configReads: 0 };
const server = createServer((req, res) => {
if (req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><main><div id="content"></div></main><script src="/onboarding.js"></script>'); }
if (req.url === '/onboarding.js') { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(readFileSync(SCRIPT)); }
res.setHeader('content-type', 'application/json');
if (req.url.startsWith('/api/onboarding/interview?')) return res.end(JSON.stringify(plan));
if (req.url === '/api/config') { state.configReads++; return res.end(JSON.stringify({ northStarApprove: state.approve })); }
if (req.url.startsWith('/api/project-file?')) return res.end(JSON.stringify(draft));
if (req.url === '/api/chat/threads') { res.writeHead(503); return res.end(JSON.stringify({ error: 'capacity-full', sent: false })); }
res.writeHead(404); res.end(JSON.stringify({ error: 'missing' }));
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
t.after(() => new Promise(r => { server.closeAllConnections(); server.close(r); }));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/`);
const args = { project: { id: '20000000-0000-4000-8000-000000000002', repo: 'stack' }, section: 'overview', north: spec, docs: { files: pending ? [{ path: draft.path }] : [] }, canSend: true };
await b.evaluate(`window.errors=[];addEventListener('unhandledrejection',e=>{errors.push(String(e.reason));e.preventDefault()});window.args=${JSON.stringify(args)};window.offer=()=>MosaicOnboarding.offer(document.querySelector('#content'),args);window.offer()`);
return { b, state };
}
async function until(b, expression) {
for (let i = 0; i < 300; i++) {
if (await b.evaluate(expression)) return;
await new Promise(r => setTimeout(r, 20));
}
throw Error(`Timed out: ${expression}`);
}
test('a refused interview start leaves Start enabled for a retry, with no page error', { timeout: 60000 }, async t => {
const { b } = await setup(t);
await until(b, '!!document.querySelector("[data-onboarding-start]")');
await b.evaluate('document.querySelector("[data-onboarding-start]").click()');
await until(b, 'document.querySelector(".onboarding-status").textContent.includes("refused")');
await until(b, 'errors.length>0 || !document.querySelector("[data-onboarding-start]").disabled');
const observed = await b.evaluate('({disabled:document.querySelector("[data-onboarding-start]").disabled,errors})');
assert.deepEqual(observed, { disabled: false, errors: [] });
});
test('a refresh rereads config, so Approve disappears once approval is unavailable', { timeout: 60000 }, async t => {
const { b, state } = await setup(t, { pending: true });
await until(b, '!!document.querySelector("[data-onboarding-approve]")');
state.approve = false;
const reads = state.configReads;
await b.evaluate('offer()');
assert.ok(state.configReads > reads, 'the refresh read /api/config again');
assert.equal(await b.evaluate('!!document.querySelector("[data-onboarding-approve]")'), false);
assert.match(await b.evaluate('document.querySelector("[data-onboarding]").textContent'), /Approving is not available in this view\./);
});