From 0c35eeed9d440bc4a26ce39e6b18a3e32329293e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 13 Nov 2026 19:24:48 -0600 Subject: [PATCH 1/3] fix(webui): E2E6 round, live edits survive refresh, org page layout, decision refusals (#1873) P1-1 an open workflow editor, a dirty autonomy form and a pack's picked project keep their node, draft and focus across the 10-second poll and an org-changed repaint. P1-2 decision-http names the refused field and keeps the bus code and detail. P1-3 long editors span the full width below the org page split. P2-1 workflow roles resolve as definitions, templates and human; P2-2 a pack apply re-reads workflows, autonomy and board and keeps its receipt; P2-3 pack preview fits its column; P2-4 unknown project, agent or harness say so instead of outcome-unknown; P2-6 a stopped bus reads "bus stopped"; P2-8 Queue says why it is unavailable; P2-9 pane settings values are not clipped; P2-10 spend wording; P2-11 project header stays in its column; P2-12 project tabs fade at the edge until scrolled to the end; P2-13 Restart bus and Stop bus line up in panes; P2-15 backticks in chat text render as code. Minor: Same-as commands wrap between words; harness checkboxes share a row. Tests: org-live-edit-browser, e2e6-layout-browser, chat-replies and decision-http additions. --- packages/webui/src/decision-http.mjs | 142 +++++++++++++++--- packages/webui/src/organizations.mjs | 8 +- packages/webui/src/public/autonomy.js | 20 ++- packages/webui/src/public/bus.js | 6 + packages/webui/src/public/chat.js | 7 +- packages/webui/src/public/org-create.css | 1 + packages/webui/src/public/org-create.js | 17 ++- packages/webui/src/public/packs.css | 4 + packages/webui/src/public/packs.js | 25 ++- packages/webui/src/public/panes.css | 10 ++ packages/webui/src/public/panes.js | 9 +- packages/webui/src/public/projects.css | 14 +- packages/webui/src/public/shell.css | 12 ++ packages/webui/src/public/workflows.js | 28 ++-- packages/webui/src/public/workspace-data.js | 7 +- packages/webui/src/public/workspace.js | 61 ++++++-- .../webui/tests/chat-replies-browser.test.mjs | 6 + packages/webui/tests/decision-http.test.mjs | 58 ++++++- .../webui/tests/e2e6-layout-browser.test.mjs | 75 +++++++++ .../tests/org-live-edit-browser.test.mjs | 119 +++++++++++++++ 20 files changed, 560 insertions(+), 69 deletions(-) create mode 100644 packages/webui/tests/e2e6-layout-browser.test.mjs create mode 100644 packages/webui/tests/org-live-edit-browser.test.mjs diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs index 8806a53fc..ef5b8fc9c 100644 --- a/packages/webui/src/decision-http.mjs +++ b/packages/webui/src/decision-http.mjs @@ -6,6 +6,7 @@ // rechecks everything in the resolve transaction. Nothing here retries. import { createHash, randomBytes } from 'node:crypto'; import { BusReadError, subjectOk } from './bus.mjs'; +import { ID_PATTERN } from '../../organization/src/vocabulary.mjs'; export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve|start|templates)$/; // The endpoint contract, design section 4. Each refusal is one of these, never a 200. @@ -42,21 +43,111 @@ const BUS_CODES = { 'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale', 'invalid-request': 'invalid-request', 'hire-owner-required':'hire-owner-required','right-required':'right-required','decision-expired':'decision-expired','hire-resolution-required':'hire-resolution-required', }; -// D4 (#1873, P1-2): a hire, fire or change start the bus definitely refused keeps its own code, -// but only for codes the broker raises before it records the decision: the proposal checks in -// hire-decisions.mjs prepare(), which #raiseHire runs before its INSERT. hire.start.console then -// resolves the decision and applies it in #hireConsole, and those steps can raise other codes (and -// some of the same generic ones) after the decision exists or is consumed, so every code not listed -// here stays outcome-unknown. Review P3: an allowlist, not a pass-through of any well-formed code. +// D4 (#1873, P1-2) and E2E6 P1-2: a hire, fire or change start the bus definitely refused keeps its +// own code and a next step. hire.start.console runs the proposal checks in hire-decisions.mjs +// prepare() before it records the decision, then resolves and applies it in #hireConsole. Every +// check up to the consume transaction changes nothing, so those codes say "Nothing was applied." +// (the recorded proposal, if one was recorded, stays open in the inbox). Codes the apply step can +// raise after the decision is consumed say what to check instead. organization-uncertain, a lost +// reply and the bus server's own request codes (request-timeout and invalid-envelope can fire while +// the request is processing) stay outcome-unknown. +const NONE = ' Nothing was applied.'; +const CHECK = ' Check the Agents page and the inbox before trying again.'; const HIRE_START_REFUSALS = { - 'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy. Nothing was applied.'], - 'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again. Nothing was applied.'], - 'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness. Nothing was applied.'], - 'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies. Nothing was applied.'], - 'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template. Nothing was applied.'], - 'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it. Nothing was applied.'], - 'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it. Nothing was applied.'], + 'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy.' + NONE], + 'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again.' + NONE], + 'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness.' + NONE], + 'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies.' + NONE], + 'unknown-model': [409, 'This model is not one of the organization\'s models. Pick a model listed under Settings > Model proxy.' + NONE], + 'harness-off': [409, 'This harness is not switched on for the organization. Pick a harness listed under Settings > Routing, or ask the owner to switch it on.' + NONE], + 'harness-refused': [409, 'The harness refused this route. Clear Thinking, or pick another model or harness under Settings > Routing.' + NONE], + 'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template.' + NONE], + 'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it.' + NONE], + 'definition-rev-conflict': [409, 'The role definition changed since it was read. Reload the page and propose again.' + NONE], + 'definition-missing': [409, 'The agent\'s role definition is missing from the organization file. Check the Agents page.' + NONE], + 'definition-secret-check-required': [503, 'The bus cannot check role text for secrets right now. Ask the owner to check the organization bus.' + NONE], + 'credential-leak': [409, 'The proposal carries a value the credential store holds. Remove it from the role text or the reason.' + NONE], + 'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it.' + NONE], + 'project-agents-invalid': [409, 'That project is not a code project of this organization. Pick a code project from Projects; a library project has no agents.' + NONE], + 'organization-unknown-instance': [409, 'No agent has that name at this level. Check the name on the Agents page.' + NONE], + 'organization-instance-exists': [409, 'An agent already has that name at this level. Pick another name, or propose a change to that agent.' + NONE], + 'organization-instance-retired': [409, 'That name belonged to a fired agent and cannot be reused. Pick another name.' + NONE], + 'organization-instance-referenced': [409, 'Another part of the organization still names this agent. Remove those references first.' + NONE], + 'instance-kept': [409, 'This agent is kept by the organization and cannot be fired or replaced this way.' + NONE], + 'root-fire-shared': [409, 'This root agent is shared with other organizations, so it cannot be fired from here.' + NONE], + 'decision-stale': [409, 'The role template or the organization changed since it was read. Reload the templates and propose again; if the inbox lists this proposal, answer it there.' + NONE], + 'organization-refused': [409, 'The organization file refused this change. Check the role and route against the organization checks.' + NONE], + 'not-configured': [503, 'This Console\'s bus has no hire support configured. Ask the owner to check the organization bus.' + NONE], + 'organization-busy': [409, 'The organization is busy with another write.' + CHECK], + 'fire-in-progress': [409, 'A fire of this agent is already in progress.' + CHECK], + 'instance-fired': [409, 'This agent was fired.' + CHECK], + 'copy-unavailable': [409, 'This copy is not available for credentials.' + CHECK], + 'copy-refused': [409, 'The copy refused this credential change.' + CHECK], + 'copy-stale': [409, 'The copy changed since it was read. Reload the Agents page.' + CHECK], + 'root-policy-unreadable': [503, 'The root policy could not be read.' + CHECK], + 'credential-definition-unpinned': [409, 'The copy\'s role definition is not pinned, so credentials cannot be bound to it.' + CHECK], + 'credential-binding-changed': [409, 'The copy\'s credential binding changed since it was read.' + CHECK], + 'credential-role-changed': [409, 'The copy\'s role changed since it was read.' + CHECK], + 'credential-unbound': [409, 'The copy has no credential binding to remove.' + CHECK], + 'credential-revoking': [409, 'The copy\'s credential is being revoked.' + CHECK], + 'credential-scope-exceeded': [409, 'The requested credential scope is wider than the role allows.' + CHECK], + // The fields passed the shape check below, so a malformed answer here is a value the organization + // checks refused: most often a model that is not a launch.models key. + 'invalid-request': [400, 'The bus refused a value in this proposal. Pick a model listed under Settings > Model proxy and a role the organization checks accept.' + NONE], }; +// A reply the endpoint cannot read as a definite answer: no answer, or the bus server's request +// codes. Anything else that reads as a code is a definite refusal. +const NO_ANSWER = new Set(['outcome-unknown', 'invalid-response', 'response-too-large', 'organization-uncertain', 'request-timeout', 'invalid-envelope', 'incomplete-request', 'revision-conflict-revoked']); +const CODE = /^[a-z][a-z-]{0,63}$/; +const definiteStart = err => err instanceof BusReadError && typeof err.code === 'string' && CODE.test(err.code) && + !NO_ANSWER.has(err.code) && !err.code.startsWith('outcome-unknown') ? err.code : null; +// E2E6 P2-4: the start's own shape check, the structural rules prepare() applies, so a malformed +// proposal names its field and rule before anything leaves for the bus. A binding request keeps the +// bus's own check (its shape lives in copy-bindings.mjs). +const HIRE_KEYS = ['op', 'level', 'project', 'name', 'role', 'credentials', 'personality', 'route', 'reason', 'binding']; +const plain = x => x !== null && typeof x === 'object' && !Array.isArray(x); +const idOk = x => typeof x === 'string' && ID_PATTERN.test(x) && !['human', 'tester', 'user'].includes(x); +const ID_RULE = 'lowercase letters, digits, dot, dash or underscore, starting with a letter or digit, at most 64 characters, and not human, tester or user'; +export function hireProblem(hire) { + if (!plain(hire)) return { field: 'hire', rule: 'must be an object' }; + const extra = Object.keys(hire).find(k => !HIRE_KEYS.includes(k)); + if (extra) return { field: extra, rule: 'is not a hire field' }; + if ('binding' in hire) return null; + for (const k of ['op', 'level', 'name', 'reason']) if (!Object.hasOwn(hire, k)) return { field: k, rule: 'is required' }; + if (!['hire', 'fire', 'change'].includes(hire.op)) return { field: 'op', rule: 'must be hire, fire or change' }; + if (!['org', 'project', 'root'].includes(hire.level)) return { field: 'level', rule: 'must be org, project or root' }; + if (!idOk(hire.name)) return { field: 'name', rule: `must be ${ID_RULE}` }; + if (typeof hire.reason !== 'string' || !hire.reason.trim()) return { field: 'reason', rule: 'must not be blank' }; + if (hire.reason.length > 500) return { field: 'reason', rule: 'must be at most 500 characters' }; + if (hire.level === 'project' && !idOk(hire.project)) return { field: 'project', rule: 'must name a code project at project level' }; + if (hire.level !== 'project' && 'project' in hire) return { field: 'project', rule: 'is only for project level' }; + if (hire.op === 'fire') { + const k = ['role', 'route', 'credentials', 'personality'].find(x => x in hire); + return k ? { field: k, rule: 'is not used by fire' } : null; + } + const route = hire.route; + if (!plain(route)) return { field: 'route', rule: 'must name a harness and a model' }; + const rk = Object.keys(route).find(k => !['harness', 'model', 'thinking'].includes(k)); + if (rk) return { field: `route.${rk}`, rule: 'is not a route field' }; + for (const k of ['harness', 'model']) if (!Object.hasOwn(route, k)) return { field: `route.${k}`, rule: 'is required' }; + for (const [k, v] of Object.entries(route)) if (typeof v !== 'string' || !v || v.length > 200) return { field: `route.${k}`, rule: 'must be text of 1 to 200 characters' }; + const role = hire.role; + if (!plain(role)) return { field: 'role', rule: 'must name a role template' }; + const kinds = ['template', 'definition', 'inline'].filter(k => k in role); + if (kinds.length !== 1) return { field: 'role', rule: 'must name exactly one of a template, a definition or an inline role' }; + const ok = { template: ['template', 'sha256'], definition: ['definition', 'rev'], inline: ['inline'] }[kinds[0]]; + const rx = Object.keys(role).find(k => !ok.includes(k)); + if (rx) return { field: `role.${rx}`, rule: `is not used with role.${kinds[0]}` }; + if (kinds[0] === 'template') { + if (!idOk(role.template)) return { field: 'role.template', rule: `must be ${ID_RULE}` }; + if (!DIGEST.test(role.sha256 ?? '')) return { field: 'role.sha256', rule: 'must be the template\'s 64-character digest; read the templates again' }; + } + if (kinds[0] === 'definition') { + if (!idOk(role.definition)) return { field: 'role.definition', rule: `must be ${ID_RULE}` }; + if (!Number.isSafeInteger(role.rev)) return { field: 'role.rev', rule: 'must be a whole number' }; + } + return null; +} // #1873 CB5: the owner's next step once a copy credential binding decision is approved, fixed text // (design section 6 and its three steps). Nothing in it comes from the decision, the bus or a token. export const BINDING_NEXT = Object.freeze({ @@ -156,6 +247,14 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf if(step==='start' || step==='templates') { const allowed=step==='start'?['business','hire']:['business']; if(id!=='hire' || Object.keys(value).length!==allowed.length || Object.keys(value).some(k=>!allowed.includes(k)) || !subjectOk(value.business))return refuse('invalid-request'); + if(step==='start') { + const problem=hireProblem(value.hire); + if(problem) { + const label=problem.field[0].toUpperCase()+problem.field.slice(1); + line(`error=invalid-request field=${problem.field}`); + return json(res,400,{error:'invalid-request',field:problem.field,rule:problem.rule,message:`${label} ${problem.rule}. Nothing was sent to the bus.`}); + } + } if(live()!==session)return refuse('sign-in-required'); try { const result=await decide.call(step==='start'?'hire.start.console':'hire.templates.console',step==='start'?value.hire:{},{subject,business:value.business}); @@ -167,11 +266,18 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf ? (value.hire.binding.services===null?'unbind':'bind') : null; return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice,...(change?{next:BINDING_NEXT[change]}:{})}:result); } catch(err) { - if(step!=='start' || !(err instanceof BusReadError) || !Object.hasOwn(HIRE_START_REFUSALS,err.code)) - return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable')); - const [status,message]=HIRE_START_REFUSALS[err.code]; - line(`error=${err.code}`); - return json(res,status,{error:err.code,message}); + if(step!=='start')return refuse(busCode(err,'confirmation-unavailable')); + const code=definiteStart(err); + if(!code)return refuse('outcome-unknown'); + if(Object.hasOwn(HIRE_START_REFUSALS,code)) { + const [status,message]=HIRE_START_REFUSALS[code]; + line(`error=${code}`); + return json(res,status,{error:code,message}); + } + if(Object.hasOwn(BUS_CODES,code))return refuse(BUS_CODES[code]); + // A definite refusal this page has no wording for keeps its code, with a safe next step. + line(`error=${code}`); + return json(res,409,{error:code,message:'The bus refused this proposal.'+CHECK}); } } if (step === 'confirmation') { diff --git a/packages/webui/src/organizations.mjs b/packages/webui/src/organizations.mjs index 5a88e2d97..999d8a9c8 100644 --- a/packages/webui/src/organizations.mjs +++ b/packages/webui/src/organizations.mjs @@ -29,9 +29,11 @@ export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPat result.push({ id: entry.id, label: entry.id, invalid: true, reason: publicReason(ctx, error) }); continue; } - let busHealthy = false; - try { busHealthy = (await busStatus(ctx, entry.id)).healthy === true; } catch { /* offline metadata remains visible */ } - result.push({ id: entry.id, label: entry.id, theme: verified.shown.doc?.appearance?.theme ?? null, busHealthy }); + // E2E6 P2-8: a bus with no host recorded (stopped by its owner, or never started) is "stopped", + // not a failure. A host that is recorded but does not answer stays plain unavailable. + let busHealthy = false, busStopped = false; + try { const s = await busStatus(ctx, entry.id); busHealthy = s?.healthy === true; busStopped = !busHealthy && s?.pid == null; } catch { /* offline metadata remains visible */ } + result.push({ id: entry.id, label: entry.id, theme: verified.shown.doc?.appearance?.theme ?? null, busHealthy, ...(busStopped ? { busStopped: true } : {}) }); } return result; } diff --git a/packages/webui/src/public/autonomy.js b/packages/webui/src/public/autonomy.js index ce0d276de..4a4e59415 100644 --- a/packages/webui/src/public/autonomy.js +++ b/packages/webui/src/public/autonomy.js @@ -22,19 +22,22 @@ const r=await fetch(path(org,step),body===undefined?{cache:'no-store'}:{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value; } - async function load(org) {try{states.set(org,await call(org,''));}catch(error){states.set(org,{error:error.message});}} + // E2E6 P1-1: a poll reloads this state; the last save's receipt and the open review list stay. + async function load(org) {const prev=states.get(org),keep={status:prev?.status??'',reviewOpen:prev?.reviewOpen===true};try{states.set(org,{...await call(org,''),...keep});}catch(error){states.set(org,{error:error.message,...keep});}} function section(org) { const s=states.get(org); if(!s || s.error)return `

Autonomy

${esc(s?.error??'Autonomy could not be read.')}

`; const v=s.autonomy??{}; - return `

Autonomy

Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.

+ // The form carries the revision it was drawn from: a form a poll kept (workspace holdLive) saves + // against that revision, so it cannot overwrite a change it never showed. + return `

Autonomy

Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.

${classes.map(k=>``).join('')}

In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.

${s.writable?'':'

Only the owner can change autonomy.

'} -

- ${s.writable?`
Review reversals before moving out of the loop

Read the decision trail and its reversals before recording your review.

${classes.map(k=>``).join('')}
`:''}
`; +

${esc(s.status)}

+ ${s.writable?`
Review reversals before moving out of the loop

Read the decision trail and its reversals before recording your review.

${classes.map(k=>``).join('')}
`:''}
`; } function notice(d) { if(!d?.autonomy)return ''; @@ -49,9 +52,14 @@ const autonomy=Object.fromEntries(classes.map(k=>[k,fields.get(k)]));autonomy.notices=fields.has('notices');autonomy.reverseWindowHours=Number(fields.get('reverseWindowHours')); if(fields.get('authorityResolver'))autonomy.authorityResolver=fields.get('authorityResolver'); const button=form.querySelector('button[type="submit"]'),status=form.querySelector('[data-autonomy-status]');button.disabled=true; - try{const r=await call(org,'',{revision:s.revision,autonomy});s.revision=r.revision;s.autonomy=autonomy;s.levels=Object.fromEntries(classes.map(k=>[k,autonomy[k]]));status.textContent=`Saved. Receipt ${r.transaction}.`;} - catch(error){status.textContent=error.message;}finally{button.disabled=false;} + let base=s.revision;try{if(form.dataset.revision)base=JSON.parse(form.dataset.revision);}catch{/* drawn without one */} + try{const r=await call(org,'',{revision:base,autonomy});s.revision=r.revision;s.autonomy=autonomy;s.levels=Object.fromEntries(classes.map(k=>[k,autonomy[k]]));s.status=`Saved. Receipt ${r.transaction}.`;form.dataset.revision=JSON.stringify(r.revision);delete form.dataset.liveDirty;status.textContent=s.status;} + catch(error){s.status=error.message;status.textContent=s.status;}finally{button.disabled=false;} }); + // An edited form is dirty until it saves: holdLive keeps a dirty form through every poll. + const dirty=e=>{const form=e.target.closest?.('[data-autonomy-form]');if(form)form.dataset.liveDirty='1';}; + document.addEventListener('input',dirty);document.addEventListener('change',dirty); + document.addEventListener('toggle',e=>{if(!e.target.matches?.('[data-autonomy-review-list]'))return;const s=states.get(e.target.closest('[data-autonomy-org]')?.dataset.autonomyOrg);if(s)s.reviewOpen=e.target.open;},true); document.addEventListener('click',async e=>{ const button=e.target.closest('[data-autonomy-confirm],[data-autonomy-reverse],[data-autonomy-review]');if(!button)return; const section=button.closest('[data-autonomy-org]'),org=section.dataset.autonomyOrg,status=section.querySelector('[data-autonomy-status]'); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 3c13249da..edb2abb03 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -1743,8 +1743,11 @@ // A route edit after the markup was prepared must win over that pending redraw. if(path==='/settings/routing' && routeVersion!==routesEditVersion){render(false);return;} const k = !navigated && (shown === route || samePage) ? keep() : null; + // E2E6 P1-1: an org-page editor in use keeps its node, draft and focus across the repaint. + const heldLive = k ? window.MosaicWorkspace?.holdLive?.($('bus-view')) : null; unmountEditor(); $('bus-view').innerHTML = html; shown = route; + if (heldLive) window.MosaicWorkspace.graftLive($('bus-view'), heldLive); syncAppearance(true); if (path.startsWith('/docs/')) paintDoc(); if (path === '/organization') window.MosaicConsole?.attachBus(document.querySelector('[data-dashboard-bus]')); @@ -1929,6 +1932,9 @@ }; configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {}); // E1 (P1-5): after Restart bus the organization dashboard draws its bus line now, not at the next poll. + // E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a + // fresh read of the page; editors in progress are kept by holdLive. + document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && routeOf().split('?')[0] === '/organization') render(false); }); document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } }); // The cross-organization pages ask for a repaint after a filter, sort or selection change, // and report the inbox count across organizations for the sidebar. diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 475e0bc58..663e1db21 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -15,6 +15,9 @@ const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')); const txt = value => esc(inert(value)); + // E2E6 P2-15: a message's `inline code` spans show as code, not as raw backticks. The text is escaped + // first; a pair of single backticks on one line then wraps its already-escaped contents in . + const prose = value => txt(value).replace(/`([^`\n]+)`/g, '$1'); const states = new Map(), forms = new Map(), knownRequests = new Map(); const pinnedScrolls = new WeakMap(), settlingPins = new WeakMap(); const pageFlow = log => log.closest('.project-chat') && matchMedia('(max-width: 319px), (max-height: 540px)').matches; @@ -161,12 +164,12 @@ function messageBody(m) { if (m.from_role !== 'human' && typeof m.body === 'string' && m.body.startsWith('The turn failed: ')) return `
The assistant couldn't reply. Try again, or check Agents.
Details: ${txt(m.body.slice('The turn failed: '.length))}
`; const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) }); - if (r?.kind === 'reply') return `
${txt(r.text)}
${r.citations.length ? cites(r.citations) : ''}`; + if (r?.kind === 'reply') return `
${prose(r.text)}
${r.citations.length ? cites(r.citations) : ''}`; if (r?.kind === 'notice') { const v = reverts.get(r.commit) ?? {}; return `
${txt(r.text)} ${current.archiveUnknown || current.projectArchived || window.MosaicOrg ? '' : ``} ${txt(v.receipt ?? '')}
`; } - return `
${txt(m.body)}
`; + return `
${prose(m.body)}
`; } const ago = iso => { const minutes = Math.floor((Date.now() - Date.parse(iso)) / 60000); diff --git a/packages/webui/src/public/org-create.css b/packages/webui/src/public/org-create.css index 8d55f2eb9..661041850 100644 --- a/packages/webui/src/public/org-create.css +++ b/packages/webui/src/public/org-create.css @@ -6,6 +6,7 @@ .org-field-err{display:block;font-size:13px;color:var(--danger);margin:4px 0 0} .org-field-err code,.org-errors code{overflow-wrap:anywhere} .org-verb code{overflow-wrap:normal;word-break:normal} +.cmd-word{display:inline-block;max-width:100%;overflow-wrap:anywhere} .org-start-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px;align-items:start} .org-inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:center} .org-inline label{grid-column:1/-1;font-size:14px;font-weight:600;color:var(--strong)} diff --git a/packages/webui/src/public/org-create.js b/packages/webui/src/public/org-create.js index fdb00aa07..7fd0faedd 100644 --- a/packages/webui/src/public/org-create.js +++ b/packages/webui/src/public/org-create.js @@ -4,6 +4,9 @@ (() => { const $ = id => document.getElementById(id); const txt = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + // E2E6 minor: a command shown as "Same as" wraps between words, never inside a flag such as + // --if-revision. Each word is an inline-block; one longer than the line still breaks inside itself. + const cmd = v => `${String(v ?? '').split(' ').map(w => `${txt(w)}`).join(' ')}`; // #1873 P1-6: the remote check a repository receipt records, in one plain line. const CHECKED = { reachable: 'reachable', 'needs-credential': 'needs a credential', unreachable: 'unreachable' }; const checkLine = r => `Remote check: ${CHECKED[r.check.result] ?? 'unknown'}, ${r.check.message}. Origin: ${r.origin ?? 'none'}.`; @@ -253,7 +256,7 @@ $('org-archive').hidden = mode !== 'edit' || state !== 'active'; $('org-archived').hidden = mode !== 'edit' || state !== 'archived'; $('org-restore-verb').textContent = state === 'archived' ? `Same as mosaic org restore ${id} --if-revision ${revision}` : ''; $('org-editor-title').textContent = mode === 'create' ? `New organization: ${id}` : `Organization: ${id}`; - $('org-editor-verb').innerHTML = verb ? `Same as ${txt(verb)}` : ''; + $('org-editor-verb').innerHTML = verb ? `Same as ${cmd(verb)}` : ''; $('org-save').textContent = mode === 'create' ? 'Save organization' : 'Save changes'; $('org-errors').hidden = true; renderForm(); renderJSON(true); @@ -318,7 +321,7 @@ { title: 'Initialize project records', verb: `mosaic project init ${id} ${project}` }, ]; const list = $('org-quick-steps'); - list.innerHTML = steps.map((s, i) => `
  • ${txt(s.title)}

    Not started

    Same as ${txt(s.verb)}

  • `).join(''); + list.innerHTML = steps.map((s, i) => `
  • ${txt(s.title)}

    Not started

    Same as ${cmd(s.verb)}

  • `).join(''); const run = $('org-quick-run'); run.disabled = true; $('org-quick-open').disabled = true; $('org-advanced-open').disabled = true; $('org-preset').disabled = true; $('org-thinking').disabled = true; banner(''); let current = 0; try { @@ -404,7 +407,7 @@ } function showReceipt(title, body) { const box = $('org-receipt'); - box.innerHTML = `

    ${txt(title)}

    Same as ${txt(body.verb)}

    ${txt(JSON.stringify(body.receipt, null, 2))}
    `; + box.innerHTML = `

    ${txt(title)}

    Same as ${cmd(body.verb)}

    ${txt(JSON.stringify(body.receipt, null, 2))}
    `; box.hidden = false; status(`${title}. Receipt shown.`); } async function refreshList() { @@ -454,7 +457,7 @@ const c = st.choice.get(key) || {}, remote = c.mode === 'remote'; return `
  • ${i + 1} ${txt(title)}

    ${txt(stateLabel)}
    -

    Same as ${txt(s.verb || '')}

    +

    Same as ${cmd(s.verb || '')}

    ${s.message ? `

    ${txt(s.message)}

    ` : ''} ${key.startsWith('project:') && s.receipt?.check ? `

    ${txt(checkLine(s.receipt))}

    ` : ''} ${s.lastError ? `` : ''} @@ -570,7 +573,7 @@ : `The bus did not start: ${bus.message || 'no reason was given.'}`; const offer = (!known || bus.outcome !== 'done') && canWrite; const box = $('org-archive-success'); - box.innerHTML = `

    Organization restored. ${txt(line)}

    ${known && bus.verb ? `

    Same as ${txt(bus.verb)}

    ` : ''}${offer ? `

    ` : ''}`; + box.innerHTML = `

    Organization restored. ${txt(line)}

    ${known && bus.verb ? `

    Same as ${cmd(bus.verb)}

    ` : ''}${offer ? `

    ` : ''}`; box.hidden = false; box.focus(); } // Restart bus for the restored organization, the same route as the dashboard's (console-restart.js). @@ -620,13 +623,13 @@ if (r.status !== 200) { $('org-delete-error').textContent = `The outcome is unknown. ${r.body.message || `Deletion stopped (${r.body.error || r.status}).`} Inspect the receipt and resume with the CLI.`; const receipt = r.body.receipt; - $('org-delete-progress').innerHTML = `

    Same as ${txt(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}

    1. Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}
    2. Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped
    3. Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}
    `; + $('org-delete-progress').innerHTML = `

    Same as ${cmd(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}

    1. Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}
    2. Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped
    3. Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}
    `; status('Deletion stopped.'); return; } if (!verifiedReply(r, 'delete')) throw Error('unknown deletion outcome'); editorDirty = false; setupDirty.clear(); $('org-editor').hidden = true; $('org-setup').hidden = true; $('org-delete').hidden = true; $('org-archive').hidden = true; $('org-archived').hidden = true; const box = $('org-delete-success'); - box.innerHTML = `

    Organization deleted

    Same as ${txt(r.body.verb)}

    1. Bus stopped or was already stopped: Done
    2. Local files moved to the deleted folder: Done
    3. Organization removed from the list: Done
    Receipt
    ${txt(JSON.stringify(r.body.receipt, null, 2))}
    `; + box.innerHTML = `

    Organization deleted

    Same as ${cmd(r.body.verb)}

    1. Bus stopped or was already stopped: Done
    2. Local files moved to the deleted folder: Done
    3. Organization removed from the list: Done
    Receipt
    ${txt(JSON.stringify(r.body.receipt, null, 2))}
    `; const rows = await refreshOrganizations(); box.hidden = false; box.focus(); status('Organization deleted.'); switchIfCurrentRemoved(id, rows); } catch { $('org-delete-error').textContent = 'The outcome is unknown. Inspect the receipt and resume with the CLI.'; $('org-delete-progress').innerHTML = `

    Resume with ${txt(`mosaic org delete ${id} --confirm ${id}`)}

    `; } diff --git a/packages/webui/src/public/packs.css b/packages/webui/src/public/packs.css index 919458330..3c992b7a5 100644 --- a/packages/webui/src/public/packs.css +++ b/packages/webui/src/public/packs.css @@ -3,6 +3,10 @@ :is(.pack-row,.pack-hire,.wf-item){border-top:1px solid var(--border);padding:12px 0;overflow-wrap:anywhere} .pack-preview{border:1px solid var(--border);border-radius:var(--r);padding:12px;margin:12px 0;overflow-wrap:anywhere} .pack-preview .ws-tbl{overflow-x:auto} +/* E2E6 P2-3: the preview's overflow-wrap:anywhere let the table squeeze words ("Workfl ow"). Cells wrap + at word breaks; only a path, which has none, may break anywhere. */ +.pack-preview .ws-tbl :is(td,th){overflow-wrap:normal;word-break:normal} +.pack-preview .ws-tbl td code{overflow-wrap:anywhere} .pack-nohire,.wf-missing,.wf-note{color:var(--danger)} .wf-missing{font-size:.9em} .wf-steps{margin:6px 0;padding-left:22px}.wf-steps li{margin:4px 0} diff --git a/packages/webui/src/public/packs.js b/packages/webui/src/public/packs.js index 3a9777614..cd7f1db3f 100644 --- a/packages/webui/src/public/packs.js +++ b/packages/webui/src/public/packs.js @@ -39,8 +39,11 @@ if (!r.ok) throw Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`); return value; } + // E2E6 P1-1, P2-2: a poll reloads the list but keeps the open preview, the project picked for each + // pack and the last receipt. A kept preview applies by its digest, so a stale one is refused by the bus. async function load(org) { - const s = { packs: null, writable: false, error: null, projects: null, preview: null, status: '' }; + const prev = states.get(org); + const s = { packs: null, writable: false, error: null, projects: null, preview: prev?.preview ?? null, status: prev?.status ?? '', picks: prev?.picks ?? {} }; try { const value = await call(`${base(org)}/packs`); s.packs = Array.isArray(value?.packs) ? value.packs.filter(p => p && typeof p === 'object' && p.pack && typeof p.pack === 'object') : []; @@ -81,36 +84,42 @@ const hires = Array.isArray(v.hires) && v.hires.length ? `

    Hires

      ${v.hires.map(hireBlock).join('')}
    ` : '

    The pack proposes no hires.

    '; const skills = v.skills?.notEnabled?.length ? `

    Skills this machine has not enabled: ${v.skills.notEnabled.map(n => `${esc(n)}`).join(', ')}

    ` : ''; const chief = v.chief === false ? '

    The organization has no chief, so no line is compared against one.

    ' : ''; - const apply = s.writable ? `` : '

    This Console was started without send. Packs can be previewed, not applied.

    '; + const apply = s.writable ? `` : '

    This Console was started without send. Packs can be previewed, not applied.

    '; return `

    Preview: ${esc(v.pack?.label ?? p.pack)}${v.project ? ` for project ${esc(v.project)}` : ''}

    ${items}${hires}${skills}${chief}${apply}
    `; } function packRow(org, s, entry) { const pack = entry.pack; const project = pack.scope === 'project' ? s.projects === null ? '

    The project list could not be read.

    ' : s.projects.length - ? `` + ? `` : '

    No code project to apply this pack to.

    ' : ''; const canPreview = pack.scope !== 'project' || (s.projects && s.projects.length); return `
  • ${esc(pack.label ?? entry.id)} ${esc(entry.id)} ${esc(pack.version)} · ${esc(entry.origin)} · ${pack.scope === 'project' ? 'project pack' : 'organization pack'} -

    ${esc(pack.description)}

    ${project}${canPreview ? '' : ''}
  • `; +

    ${esc(pack.description)}

    ${project}${canPreview ? `` : ''}`; } function section(org) { const s = states.get(org); - if (!s || s.error) return `

    Starter packs

    ${esc(s?.error ?? 'Starter packs could not be read.')}

    `; + if (!s || s.error) return `

    Starter packs

    ${esc(s?.error ?? 'Starter packs could not be read.')}

    `; const list = s.packs.length ? `` : '

    No starter packs are installed.

    '; - return `

    Starter packs

    A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing, and a hire it proposes is only raised by the chief, as a decision.

    ${list}${previewBlock(s)}

    ${esc(s.status)}

    `; + return `

    Starter packs

    A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing, and a hire it proposes is only raised by the chief, as a decision.

    ${list}${previewBlock(s)}

    ${esc(s.status)}

    `; } function redraw(org) { const el = [...document.querySelectorAll('[data-packs-org]')].find(n => n.dataset.packsOrg === org); if (el) el.outerHTML = section(org); } + document.addEventListener('change', e => { + const select = e.target.closest?.('[data-pack-project]'); + const s = select && states.get(select.closest('[data-packs-org]')?.dataset.packsOrg); + if (s) s.picks[select.closest('[data-pack]').dataset.pack] = select.value; + }); document.addEventListener('click', async e => { const button = e.target.closest('[data-pack-preview-button],[data-pack-apply]'); if (!button) return; const root = button.closest('[data-packs-org]'), org = root?.dataset.packsOrg, s = states.get(org); if (!s) return; button.disabled = true; + let applied = false; try { if (button.hasAttribute('data-pack-preview-button')) { const row = button.closest('[data-pack]'), pack = row.dataset.pack, project = row.querySelector('[data-pack-project]')?.value; @@ -123,10 +132,14 @@ const added = Array.isArray(r.added) && r.added.length ? r.added.join(', ') : 'nothing new'; s.preview = null; s.status = `Applied. Added ${added}. Proposed hires are recorded for the chief to raise as decisions.`; + applied = true; } } catch (error) { s.status = error.message; } // A preview the page cannot draw says so, instead of leaving the button disabled with nothing shown. try { redraw(org); } catch { s.preview = null; s.status = 'The preview could not be shown.'; redraw(org); } + // An apply changes the workflows, the board and the roles too: the page and any pane showing this + // organization read every section again. The receipt above is kept in state, so it survives that read. + if (applied) document.dispatchEvent(new CustomEvent('mosaic-org-changed', { detail: { org } })); }); window.MosaicPacks = { load, section }; })(); diff --git a/packages/webui/src/public/panes.css b/packages/webui/src/public/panes.css index d6b863199..f137feca6 100644 --- a/packages/webui/src/public/panes.css +++ b/packages/webui/src/public/panes.css @@ -60,6 +60,16 @@ html[data-organizations] .pane-body .project-columns .project-section-title h2,h html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))} html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)} } +/* E2E6 P2-9: a dashboard in a pane sizes from the pane, not the window. Half a 1440 window left the + Settings column too narrow for its values (clipped to 1-2 characters): the split stacks below 900px + of pane, and a narrow Settings list puts each value on its own line, as at phone width. */ +@container (max-width:899px){ + html[data-organizations] .pane-body .ws-split{grid-template-columns:minmax(0,1fr)} +} +@container (max-width:559px){ + html[data-organizations] .pane-body .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto} + html[data-organizations] .pane-body .ws-cfg .v{grid-column:2/-1;grid-row:2} +} /* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */ body:has(#bus-view > .sbs) #breadcrumbs{display:none} html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px} diff --git a/packages/webui/src/public/panes.js b/packages/webui/src/public/panes.js index da71ec7b4..e0cb94a24 100644 --- a/packages/webui/src/public/panes.js +++ b/packages/webui/src/public/panes.js @@ -176,9 +176,11 @@ function paintBody(p, html) { const { body } = els(p); if (p.html === html && body.childElementCount) return; - const kept = capture(p, body); + const kept = capture(p, body), live = window.MosaicWorkspace?.holdLive?.(body); body.innerHTML = html; p.html = html; p.project = null; rewrite(p, body); + // E2E6 P1-1: an editor in use keeps its node, draft and focus; its ids were rewritten when first painted. + if (live && window.MosaicWorkspace.graftLive(body, live)) kept.focus = null; restore(p, body, kept); } @@ -470,6 +472,11 @@ void orgList(true).then(rows => { if (rows) listCache = rows; }); for (const p of mounted.panes) void drawPane(p, { fresh: true }); } + // E2E6 P2-2: a pane showing the organization a write changed reads it again. + document.addEventListener('mosaic-org-changed', e => { + if (!mounted) return; + for (const p of mounted.panes) if (p.org === e.detail?.org) void drawPane(p, { fresh: true }); + }); // The list the tabs draw from follows each fresh read of the organization list. document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; }); diff --git a/packages/webui/src/public/projects.css b/packages/webui/src/public/projects.css index e23587283..e1e8a5e37 100644 --- a/packages/webui/src/public/projects.css +++ b/packages/webui/src/public/projects.css @@ -86,7 +86,14 @@ .breadcrumbs:has(.project-crumb-current) a::after { content:''; position:absolute; top:0; bottom:0; left:50%; width:max(100%,44px); transform:translateX(-50%); } .chat-area:has(.project-header),.chat-area:has(.project-list-head) { max-width:none; margin-inline:0; } .project-tabs { scrollbar-width:none; } .project-tabs::-webkit-scrollbar { display:none; } -@media (max-width:699px) { .project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - 22px), transparent 100%); } } +/* E2E6 P2-12: a strip wider than the screen fades out over its last 48px, so a cut tab reads as "more + this way". Where scroll timelines exist the fade goes once the strip is scrolled to its end. */ +@property --tabs-fade { syntax:''; inherits:false; initial-value:48px; } +@keyframes project-tabs-cue { 0%, 92% { --tabs-fade:48px; } 100% { --tabs-fade:0px; } } +@media (max-width:699px) { + .project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - var(--tabs-fade)), transparent 100%); } + @supports (animation-timeline: scroll()) { .project-tabs { animation:project-tabs-cue linear both; animation-timeline:scroll(self inline); } } +} .project-chip { white-space:nowrap; flex:none; } @media(max-width:699px) { .project-stepper span { white-space:nowrap; font-size:10.5px; padding:4px 1px; }.project-work-head { align-items:flex-start; } } @@ -336,7 +343,8 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo /* Header actions use the specified compact widths while retaining 44px targets. */ .project-primary-action { box-sizing:border-box; flex:none; } @media (min-width:1200px) { - .project-header:not([data-project-conversation="true"]) { width:calc(100vw - 270px); } + /* E2E6 P2-11: 100vw counts the page scrollbar; max-width keeps the header inside its column. */ + .project-header:not([data-project-conversation="true"]) { width:calc(100vw - 270px); max-width:100%; box-sizing:border-box; } .project-header:not([data-project-conversation="true"]) .project-actions { align-self:flex-start; margin-top:4px; } .project-primary-action:nth-of-type(1) { width:130px; } .project-primary-action:nth-of-type(2) { width:88px; } @@ -504,3 +512,5 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo /* Keep the action in flow when a read error occupies the top of the page. */ @media (min-width:700px) { .chat-area:has(.chat-error) .project-create:not([open]) { position:static; margin:12px 0; } } +/* E2E6 P2-15: inline code in a chat bubble keeps the bubble's text colour on a light tint. */ +.project-chat .chat-body code { font-size:.92em; padding:0 .3em; border-radius:4px; background:color-mix(in srgb, currentColor 12%, transparent); color:inherit; } diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index ef1f90d54..8e042e4df 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -301,6 +301,11 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed} #agents-table tbody th,#routes-table tbody th{text-transform:none;font-size:14px;font-weight:600;color:var(--strong)} @media(max-width:759px){.settings-content .routing-panel{border:0;background:transparent;padding:0}.routing-panel .table-wrap{border:0;overflow:visible}.routing-panel #routes-table tr{display:grid;grid-template-columns:1fr 1fr;gap:0 10px;background:var(--surface)}.routing-panel #routes-table th,.routing-panel #routes-table td:first-of-type,.routing-panel #routes-table .fallback-cell:first-of-type{grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4){grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4)::before{content:'Fallback: None'}.routing-panel #routes-table .fallback-open td:nth-of-type(4)::before{content:'Fallback harness'}} .settings-content label:has(>input[type=checkbox]){display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0 8px} +/* E2E6 minor: the harness checkboxes sit side by side, each keeping its 44px target, instead of one per + 50px row. Save and the receipt keep their own full-width rows. */ +.settings-content fieldset.harness-control{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px} +.settings-content fieldset.harness-control>legend{flex-basis:100%} +.settings-content fieldset.harness-control>:is(button,p){flex-basis:100%} .settings-content :is(.instance-form,.level-form) textarea,.settings-content .assistant-form textarea{display:block;width:100%} .route-definition{display:none}.settings-spinner{display:inline-block;width:16px;height:16px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;vertical-align:middle;animation:settings-spin .8s linear infinite}@keyframes settings-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.settings-spinner{animation:none}} @media(max-width:759px){#routes-table .route-definition{display:inline;float:right;color:var(--muted);font-size:12px;font-weight:400}} @@ -444,6 +449,9 @@ html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [d html[data-organizations] .ws-btn svg.i{width:15px;height:15px} /* The live bus restart is console-restart.js's own button; it carries its icon through repaints, so the icon stays on the button however wide the receipt below it is. */ +/* E2E6 P2-13: the disabled pair (a pane, or a Console without the trail) sits in a flex row too. As two + inline-flex boxes on one line, Restart bus took its baseline from its glyph and sat 2.6px above Stop bus. */ +html[data-organizations] [data-dashboard-bus]:not(.ws-busctl){display:inline-flex;flex-wrap:wrap;align-items:center;gap:6px} html[data-organizations] .ws-busctl{display:inline-flex;flex-direction:column;align-items:flex-end} html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px} html[data-organizations] .ws-busctl .bus-buttons{display:inline-flex;flex-wrap:wrap;gap:6px;justify-content:flex-end} @@ -477,6 +485,10 @@ html[data-organizations] .ws-panel.tight{padding:12px 14px} html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start} html[data-organizations] .ws-split{margin-top:22px} html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0} +/* E2E6 P1-3: the long editors (Autonomy, Packs, Workflows) span the page below the split, so neither + column runs screens past the other at 1440. */ +html[data-organizations] .ws-wide{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;margin-top:22px} +html[data-organizations] .ws-wide>.panel{margin:0} /* Organization dashboard (sample 03). */ html[data-organizations] .ws-orghead{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:14px} diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js index 6fa82f5f4..7eb6bcdbe 100644 --- a/packages/webui/src/public/workflows.js +++ b/packages/webui/src/public/workflows.js @@ -43,8 +43,11 @@ s.data = value; s.known = new Set([...(value.roles.definitions || []), ...(value.roles.templates || []), 'human']); } + // E2E6 P1-1: a poll reloads the data but keeps the page's own state: an open editor and its draft, + // a pending delete confirmation and the last receipt. async function load(org) { - const s = { data: null, known: new Set(), error: null, editing: null, confirm: null, status: '' }; + const prev = states.get(org); + const s = { data: null, known: new Set(), error: null, editing: prev?.editing ?? null, confirm: prev?.confirm ?? null, status: prev?.status ?? '' }; try { await read(org, s); } catch (error) { s.error = error.message; } states.set(org, s); } @@ -60,8 +63,8 @@ const steps = Array.isArray(w?.steps) ? w.steps : []; const confirm = s.confirm && s.confirm.scope === scope && s.confirm.name === name; const actions = !s.data.writable ? '' : confirm - ? `

    Delete this workflow?

    ` - : `

    `; + ? `

    Delete this workflow?

    ` + : `

    `; return `
  • ${esc(w?.title || name)} ${esc(name)} ${w?.description ? `

    ${esc(w.description)}

    ` : ''}
      ${steps.map(st => stepItem(s, st)).join('')}
    ${actions}
  • `; } @@ -81,7 +84,7 @@ const scopeField = e.isNew ? `` : `

    ${e.scope ? `Project ${esc(e.scope)}` : 'Organization'}, ${esc(e.name)}

    `; - return `

    ${e.isNew ? 'New workflow' : 'Edit workflow'}

    ${scopeField} + return `

    ${e.isNew ? 'New workflow' : 'Edit workflow'}

    ${scopeField} ${e.draft.steps.map((st, i) => stepRow(s, st, i)).join('')} @@ -95,8 +98,8 @@ const names = Object.keys(flows || {}).sort(); return `

    ${esc(label)}

    ${names.length ? `
      ${names.map(n => workflowItem(org, s, scope, n, flows[n])).join('')}
    ` : '

    No workflows.

    '}
    `; }).join(''); - const add = s.data.writable ? (s.editing ? '' : '

    ') : '

    Only the owner, on a Console started with send, can change workflows.

    '; - return `

    Workflows

    A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.

    ${lists}${add}${editor(s)}

    ${esc(s.status)}

    `; + const add = s.data.writable ? (s.editing ? '' : `

    `) : '

    Only the owner, on a Console started with send, can change workflows.

    '; + return `

    Workflows

    A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.

    ${lists}${add}${editor(s)}

    ${esc(s.status)}

    `; } function redraw(org) { const el = [...document.querySelectorAll('[data-workflows-org]')].find(n => n.dataset.workflowsOrg === org); @@ -124,10 +127,10 @@ if (!s?.data) return; const item = button.closest('[data-wf]'), scope = item?.dataset.wfScope ?? '', name = item?.dataset.wf; if (button.hasAttribute('data-wf-new')) { - s.editing = { isNew: true, scope: '', name: '', draft: { title: '', description: '', steps: [{ title: '', state: 'todo', roles: '' }] } }; + s.editing = { isNew: true, revision: s.data.revision, scope: '', name: '', draft: { title: '', description: '', steps: [{ title: '', state: 'todo', roles: '' }] } }; } else if (button.hasAttribute('data-wf-edit')) { const w = flowsIn(s, scope)?.[name]; - s.editing = { isNew: false, scope, name, draft: { title: w?.title ?? '', description: w?.description ?? '', steps: (w?.steps ?? []).map(st => ({ title: st.title ?? '', state: st.state ?? 'todo', roles: (st.roles ?? []).join(', ') })) } }; + s.editing = { isNew: false, revision: s.data.revision, scope, name, draft: { title: w?.title ?? '', description: w?.description ?? '', steps: (w?.steps ?? []).map(st => ({ title: st.title ?? '', state: st.state ?? 'todo', roles: (st.roles ?? []).join(', ') })) } }; } else if (button.hasAttribute('data-wf-cancel')) { s.editing = null; } else if (button.hasAttribute('data-wf-step-add')) { @@ -148,7 +151,10 @@ } redraw(org); }); + // Every keystroke goes back into the draft, so any repaint draws what was typed. document.addEventListener('input', ev => { + const form = ev.target.closest('[data-wf-editor]'), root = form?.closest('[data-workflows-org]'); + if (form && root) { const st = states.get(root.dataset.workflowsOrg); if (st?.editing) capture(root, st); } const field = ev.target.closest('[data-wf-edit-step] [name="roles"]'); if (!field) return; const s = states.get(field.closest('[data-workflows-org]')?.dataset.workflowsOrg); @@ -168,6 +174,9 @@ if (!NAME.test(e.name)) { s.status = NAME_RULE; redraw(org); return; } // A save is a set: a new workflow under a name the scope already holds would replace it without a word. if (e.isNew && Object.hasOwn(flowsIn(s, e.scope) ?? {}, e.name)) { s.status = `A workflow named ${e.name} already exists here. Edit it instead.`; redraw(org); return; } + // E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically. + const unknown = e.draft.steps.flatMap((st, i) => st.roles.split(',').map(r => r.trim()).filter(r => r && !s.known.has(r)).map(r => [i + 1, r])); + if (unknown.length) { s.status = `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`; redraw(org); return; } const workflow = { ...(e.draft.title.trim() ? { title: e.draft.title.trim() } : {}), ...(e.draft.description.trim() ? { description: e.draft.description.trim() } : {}), @@ -176,7 +185,8 @@ const button = ev.target.querySelector('button[type="submit"]'); button.disabled = true; try { - await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: s.data.revision, workflow, ...target(s, e.scope) }); + // The revision the editor opened on: a change made since then is a revision-conflict, not overwritten. + await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: e.revision ?? s.data.revision, workflow, ...target(s, e.scope) }); s.editing = null; await reload(org, s, `Saved ${e.name}.`); } catch (error) { s.status = error.message; } diff --git a/packages/webui/src/public/workspace-data.js b/packages/webui/src/public/workspace-data.js index 418a18350..576351961 100644 --- a/packages/webui/src/public/workspace-data.js +++ b/packages/webui/src/public/workspace-data.js @@ -5,7 +5,12 @@ const read = async path => { const response = await fetch(path, { cache: 'no-store', redirect: 'manual' }); // The status travels with the failure, so a caller can tell a refusal (403) from a bus that is down. - if (!response.ok || response.type === 'opaqueredirect') { const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status; throw error; } + // E2E6 P2-6: the server's refusal code and its redacted message travel too, so a section can say why. + if (!response.ok || response.type === 'opaqueredirect') { + const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status; + if (response.type !== 'opaqueredirect') try { const body = await response.json(); if (typeof body?.error === 'string') error.code = body.error; if (typeof body?.message === 'string') error.detail = body.message; } catch { /* no JSON body */ } + throw error; + } return response.json(); }; const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`; diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index a53c4792a..5a53f3d15 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -52,7 +52,7 @@ } const av = (org, size = '') => ``; const label = org => org.label || org.id; - const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : ['s-offline', '○', 'bus unavailable']); + const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : org.busStopped ? ['s-offline', '○', 'bus stopped'] : ['s-offline', '○', 'bus unavailable']); // A status mark: a glyph plus its word, shown or read aloud only. const dot = (cls, glyph, word, shown = false) => `${shown ? esc(word) : `${esc(word)}`}`; const orgHref = id => `#/orgs/${enc(id)}`; @@ -183,7 +183,7 @@ ['business', 'Organizations', orgCount === null ? unread : plural(orgCount, 'organization'), 'system', 'organizations'], ['flag', 'About', typeof s?.release === 'string' ? `Release ${s.release}` : 'Address, release and notifier', 'system', 'about'], ['people', 'Members and invites', 'Not built yet', 'not built', null], - ['restart', 'Bus', `${org.busHealthy ? 'Running' : 'Unavailable'} · restart from this page's header`, 'system', null], + ['restart', 'Bus', `${org.busHealthy ? 'Running' : org.busStopped ? 'Stopped' : 'Unavailable'} · restart from this page's header`, 'system', null], ['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'], ]; return `
      ${rows.map(([ic, name, value, tag, page]) => { @@ -193,6 +193,35 @@ }).join('')}
    `; } + // E2E6 P1-1: a poll repaints the whole page, which would drop an editor's draft and its focus. A + // section marked data-live-key that is being edited (it holds [data-live-hold] or [data-live-dirty]) + // or holds focus is kept: holdLive takes it before the repaint and graftLive puts that same node, with + // its typed values, caret and focus, in place of its fresh counterpart. Each editor saves against the + // revision it started from, so a kept node cannot overwrite a change it never saw. + function holdLive(root) { + const held = new Map(), a = document.activeElement; + if (!root) return { held, active: null }; + for (const el of root.querySelectorAll('[data-live-key]')) { + if (el.matches('[data-live-hold]') || el.querySelector('[data-live-hold],[data-live-dirty]') || el.contains(a)) held.set(el.dataset.liveKey, el); + } + const active = a && [...held.values()].some(el => el.contains(a)) + ? { el: a, caret: typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null } : null; + return { held, active }; + } + function graftLive(root, kept) { + if (!root || !kept?.held?.size) return false; + for (const el of root.querySelectorAll('[data-live-key]')) { + const old = kept.held.get(el.dataset.liveKey); + if (old && old !== el) el.replaceWith(old); + } + const a = kept.active; + if (a && a.el.isConnected) { + a.el.focus({ preventScroll: true }); + if (a.caret && typeof a.el.setSelectionRange === 'function') try { a.el.setSelectionRange(a.caret[0], a.caret[1]); a.el.scrollTop = a.caret[2]; } catch { /* not a text field */ } + } + return Boolean(a && a.el.isConnected); + } + // A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus // restart (that stays in the organization's full view) and its ids carry the pane's prefix. async function dashboard(id = window.MosaicOrg.id, opts = {}) { @@ -202,7 +231,9 @@ const list = Array.isArray(listRead) ? listRead : null; const org = list?.find(o => o.id === id && !o.invalid); if (!org) return `

    Organization unavailable

    ${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}

    `; - const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)]); + // E2E6 P2-8: a stopped bus is not read for the bus-backed editors; each says the bus is stopped. + const stopped = !org.busHealthy && org.busStopped === true; + const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), ...(stopped ? [] : [window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)])]); const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null); const business = value(0), settings = value(1), instances = instancesOf(value(2)); const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5)); @@ -220,7 +251,7 @@ const bus = restart ? `${restart}` : ` `; const reload = ``; const [bcls, bglyph] = state(org); - const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

    `; + const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : org.busStopped ? `bus stopped, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

    `; const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid; const docChips = !docRead ? 'Docs unavailable' : docRead.failed ? 'Docs unavailable' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `${icon('doc')}${esc(docName(d.path))}${esc(projectName(d.project))}`).join('') : 'No docs yet'; @@ -242,9 +273,16 @@ : threads ? `Conversation${esc(first.thread)}${more}` : 'Conversation unavailable'; return `${esc(agent)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; }).join(''); - const sessions = live === null ? '

    Sessions unavailable. The bus did not answer the instance read.

    ' : live.length ? `
    ${sessionRows}
    AgentProject and taskModelStatus
    ` : '

    No active agent sessions

    '; + const sessions = live === null ? (stopped ? '

    Sessions not read: the bus is stopped.

    ' : '

    Sessions unavailable. The bus did not answer the instance read.

    ') : live.length ? `
    ${sessionRows}
    AgentProject and taskModelStatus
    ` : '

    No active agent sessions

    '; const open = queue ? queue.filter(r => r?.state !== 'done').length : null; const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows'; + // E2E6 P2-6: a failed queue read says why, in the words the Queue page uses (server-redacted). + const qerr = queue === null && reads[5].status === 'rejected' ? reads[5].reason : null; + const queueWhy = queue !== null ? '' : `

    ${qerr?.code === 'queue-refused' ? `The queue refused the read.${qerr.detail ? ` ${esc(qerr.detail.replace(//g, 'This checkout'))}` : ''}` + : qerr?.status === 0 ? 'You are signed out. Reload the page to sign in again.' + : qerr?.code === 'not-configured' ? 'This Console has no queue to read for this organization.' + : `The queue read did not answer${qerr?.code ? ` (${esc(qerr.code)})` : ''}.`}

    `; + const stoppedPanel = (attr, title) => `

    ${title}

    Not read: the bus is stopped.

    `; return `
    @@ -257,15 +295,18 @@ ${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', 'All agents')} ${sessions} ${secH('Queue', queueNote, 'Open queue')} + ${queueWhy}
    ${secH('Settings', 'every setting, with where its value comes from')} ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })} - ${window.MosaicAutonomy?.section(id)??''} - ${window.MosaicPacks?.section(id) ?? ''} - ${window.MosaicWorkflows?.section(id) ?? ''}
    +
    + ${stopped ? stoppedPanel('data-autonomy', 'Autonomy') : window.MosaicAutonomy?.section(id) ?? ''} + ${stopped ? stoppedPanel('data-packs', 'Starter packs') : window.MosaicPacks?.section(id) ?? ''} + ${stopped ? stoppedPanel('data-workflows', 'Workflows') : window.MosaicWorkflows?.section(id) ?? ''} +
    `; } @@ -514,7 +555,7 @@ ${tile('sessions', 'Sessions', sessN(t.sessions), `${num(t.sessions?.working)} working`, parts('sessions', x => sessN(x.sessions)), `${num(t.sessions?.waiting)} waiting on you · ${num(t.sessions?.idle)} idle${num(t.sessions?.live) ? ` · ${num(t.sessions.live)} live ${t.sessions.live === 1 ? 'thread' : 'threads'}` : ''}`)} ${tile('tasks', 'Open tasks', num(t.openTasks), '', parts('tasks', x => x.openTasks), `${num(t.doneThisWeek)} done this week`)} ${tile('projects', 'Projects', num(t.projects?.active) + num(t.projects?.archived), `${num(t.projects?.active)} active`, parts('projects', x => num(x.projects?.active) + num(x.projects?.archived)), `${num(t.projects?.archived)} archived`)} -
    Model spendNo spend figures yetNo read prices model use today.
    +
    Model spendNo spend figures yetNo model prices are read yet, so today's use has no cost figure.
    ` : '

    No organization answered, so there are no totals yet.

    '; const cards = o.rows.map(r => { @@ -783,6 +824,6 @@ // U4b: the org mark (colour and initials) for the task board (tasks-board.js). const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials }); - window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } }); + window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } }); refresh(); })(); diff --git a/packages/webui/tests/chat-replies-browser.test.mjs b/packages/webui/tests/chat-replies-browser.test.mjs index f45123278..e88bb7764 100644 --- a/packages/webui/tests/chat-replies-browser.test.mjs +++ b/packages/webui/tests/chat-replies-browser.test.mjs @@ -30,6 +30,8 @@ const ROWS = [ row(3, reply('Unchecked.', [cite('Notes/Eyes.md', [1, 1], HOSTILE)])), row(4, JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/Eyes.md', commit: C4 }), undefined, 'scribe'), row(5, JSON.stringify({ notice: 'library-write', op: 'delete', path: 'Fake/Claim.md', commit: C4 }), { citations: [] }, 'forger'), + // E2E6 P2-15: a human message with inline code, a hostile span and an unpaired backtick. + row(6, `Approve with \`mosaic project north-star work\`. Not \`${HOSTILE}\`, a lone \` stays.`, undefined, 'human'), ]; const STATUS = { project: 'books', version: 1, branch: 'main', served: C3, lastFetch: 'FIVE-MINUTES-AGO', lastError: { code: 'library-fetch-failed', at: '2026-10-23T18:00:00.000Z' }, diverged: { commit: C2, at: '2026-10-23T18:00:00.000Z' }, @@ -83,6 +85,10 @@ test('chat replies: free text, cites with badges, the agent label, changed since const { b, text, message } = await setup(t); assert.equal(await text(`${message(1)} .chat-body`), 'Plain text answer.'); assert.equal(await text(`${message(2)} .chat-body`), "Mara's eyes are grey."); + // E2E6 P2-15: backtick pairs render as code and their contents stay text. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(6) + ' .chat-body code')})].map(c => c.textContent)`), ['mosaic project north-star work', HOSTILE]); + assert.equal(await text(`${message(6)} .chat-body`), `Approve with mosaic project north-star work. Not ${HOSTILE}, a lone \` stays.`); + assert.equal(await b.evaluate(`document.querySelectorAll('.chat-log img').length`), 0); const cites = await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(2) + ' .chat-cites > li')})].map(li => ({ badge: li.querySelector('.chat-scope')?.textContent ?? null, ref: (li.querySelector('a') ?? li.querySelector('code'))?.textContent, href: li.querySelector('a')?.getAttribute('href') ?? null, rel: li.querySelector('a')?.getAttribute('rel') ?? null, diff --git a/packages/webui/tests/decision-http.test.mjs b/packages/webui/tests/decision-http.test.mjs index 7a2e49322..76bf3de8b 100644 --- a/packages/webui/tests/decision-http.test.mjs +++ b/packages/webui/tests/decision-http.test.mjs @@ -326,12 +326,62 @@ test('D4 hire start: the other proposal checks that run before the decision inse assert.doesNotMatch(r.json().message,/\bhire was\b/,'worded for hire, fire and change alike'); } }); -// Review P3: a code the broker can raise after the decision exists or is consumed, or one the bus -// server raises about the request itself, is not a definite refusal of the start. -test('D4 hire start: a code that can follow the decision insert, or a transport code, stays outcome-unknown',async()=>{ - for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','instance-kept','organization-instance-exists','organization-unknown-instance','root-fire-shared','fire-in-progress','some-future-code']) +// E2E6 P1-2: every definite refusal of a start keeps its own code and a next step. Only a reply that +// never came, could not be read, or the bus server's request codes stay outcome-unknown. +test('E2E6 hire start: an unknown project, an unknown agent on fire and an unknown harness keep their code',async()=>{ + for(const [code,re] of [['project-agents-invalid',/code project/],['organization-unknown-instance',/Agents page/],['harness-off',/Settings > Routing/]]) { + const {r,s}=await startWith(new BusReadError(code)); + assert.equal(r.status,409,`${code}: ${r.text}`); + assert.equal(r.json().error,code);assert.match(r.json().message,re);assert.match(r.json().message,/Nothing was applied/); + assert.match(s.lines.at(-1),new RegExp(`step=start error=${code}$`)); + } +}); +test('E2E6 hire start: the other proposal and apply codes keep their code; apply-phase ones do not claim nothing changed',async()=>{ + for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','unknown-model','harness-refused','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused']) { + const {r}=await startWith(new BusReadError(code)); + assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/Nothing was applied/); + } + for(const code of ['organization-busy','fire-in-progress','credential-revoking']) { + const {r}=await startWith(new BusReadError(code)); + assert.equal(r.status,409);assert.equal(r.json().error,code); + assert.doesNotMatch(r.json().message,/Nothing was applied/);assert.match(r.json().message,/Agents page and the inbox/); + } + const {r}=await startWith(new BusReadError('some-future-code')); + assert.equal(r.status,409);assert.equal(r.json().error,'some-future-code');assert.match(r.json().message,/Agents page and the inbox/); +}); +test('E2E6 hire start: invalid-request from the bus says the values, not the shape, were refused',async()=>{ + const {r}=await startWith(new BusReadError('invalid-request')); + assert.equal(r.status,400);assert.equal(r.json().error,'invalid-request');assert.match(r.json().message,/Settings > Model proxy/); +}); +test('E2E6 hire start: the bus server request codes and organization-uncertain stay outcome-unknown',async()=>{ + for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','organization-uncertain']) refused((await startWith(new BusReadError(code))).r,'outcome-unknown'); }); +// E2E6 P2-4: a malformed proposal names its field and the rule, and never reaches the bus. +test('E2E6 hire start: a malformed proposal names the field and the rule before the bus',async()=>{ + const cases=[ + [{...START_HIRE,name:'Bad Name'},'name',/lowercase/], + [(({route,...h})=>h)(START_HIRE),'route',/harness and a model/], + [{...START_HIRE,route:{harness:'pi'}},'route.model',/required/], + [{...START_HIRE,level:'project'},'project',/code project/], + [{...START_HIRE,project:'north'},'project',/only for project level/], + [{...START_HIRE,reason:' '},'reason',/blank/], + [{...START_HIRE,op:'promote'},'op',/hire, fire or change/], + [{op:'fire',level:'org',name:'scout',reason:'r',route:START_HIRE.route},'route',/not used by fire/], + [{...START_HIRE,role:{template:'coder',sha256:'x'}},'role.sha256',/digest/], + [{...START_HIRE,role:{template:'coder',sha256:DIGEST,definition:'x'}},'role',/exactly one/], + [{...START_HIRE,colour:'blue'},'colour',/not a hire field/], + ]; + for(const [hire,field,rule] of cases) { + const s=setup({resolve:()=>{throw new Error('must not be called');}}); + const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire}})); + assert.equal(r.status,400,`${field}: ${r.text}`); + const body=r.json(); + assert.equal(body.error,'invalid-request');assert.equal(body.field,field);assert.match(body.rule,rule); + assert.match(body.message,/Nothing was sent to the bus/);assert.ok(body.message.toLowerCase().startsWith(field.toLowerCase()),body.message); + assert.equal(s.calls.length,0,`${field}: no bus call`); + } +}); test('D4 hire start: a lost or unreadable reply is still outcome-unknown',async()=>{ for(const err of [new BusReadError('outcome-unknown'),new BusReadError('invalid-response'),new BusReadError('response-too-large'),new BusReadError('outcome-unknown-revoked'),new BusReadError('Not A Code'),new BusReadError(''),new Error('socket closed'),Object.assign(new Error('x'),{code:'model-provider-unavailable'})]) refused((await startWith(err)).r,'outcome-unknown'); diff --git a/packages/webui/tests/e2e6-layout-browser.test.mjs b/packages/webui/tests/e2e6-layout-browser.test.mjs new file mode 100644 index 000000000..87520d0a3 --- /dev/null +++ b/packages/webui/tests/e2e6-layout-browser.test.mjs @@ -0,0 +1,75 @@ +// E2E6 P2-11, P2-12, P2-13 (#1873): layout pins measured in a real browser. +// P2-11: a project's header stays inside its column at desktop widths, so its actions are not cut off. +// P2-12: at phone width the project tabs fade at the right edge while more tabs lie past it, and the +// fade is gone once the row is scrolled to its end. +// P2-13: Restart bus and Stop bus share one top and height, on the organization page and in a pane. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { fixture, close } from './fixture.mjs'; +import { browser } from './browser.mjs'; +import { startServer } from '../src/serve.mjs'; +import { createDevOwner } from '../src/dev-owner.mjs'; +import { P, stub } from './helpers/projects-review-fixture.mjs'; + +const until = (b, expression, what) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,25))}throw Error(${JSON.stringify(what)})})()`); +const frames = b => b.evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>r(true))))'); + +async function project(t, width) { + const f = await fixture(), b = await browser({ machine: false, args: ['--disable-extensions'] }); + t.after(async () => { await b.close(); await f.close(); }); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub }); + await b.viewport(width, 900); + await b.navigate(`${f.base}/#/projects/${P}`); + await until(b, `document.querySelector('.project-header .project-actions')`, 'project header'); + await b.evaluate('new Promise(r=>setTimeout(r,400))'); + return b; +} + +for (const width of [1440, 1280]) { + test(`E2E6 P2-11: the project header and its actions stay inside the column at ${width}`, { timeout: 60000 }, async t => { + const b = await project(t, width); + const m = JSON.parse(await b.evaluate(`JSON.stringify((()=>{const h=document.querySelector('.project-header'),p=h.parentElement.getBoundingClientRect(),r=h.getBoundingClientRect();return {right:r.right,parent:p.right,actions:[...h.querySelectorAll('.project-actions > *')].map(a=>a.getBoundingClientRect().right),page:[document.documentElement.scrollWidth,innerWidth]}})())`)); + assert.ok(m.right <= m.parent + 0.5, `header right ${m.right} inside its column ${m.parent}`); + for (const a of m.actions) assert.ok(a <= m.parent + 0.5, `action right ${a} inside ${m.parent}`); + assert.ok(m.page[0] <= m.page[1], 'no sideways page scroll'); + }); +} + +test('E2E6 P2-12: at 390 the project tabs fade at the edge until scrolled to the end', { timeout: 60000 }, async t => { + const b = await project(t, 390); + const read = () => b.evaluate(`JSON.stringify((()=>{const e=document.querySelector('.project-tabs'),s=getComputedStyle(e);return {mask:s.maskImage||s.webkitMaskImage,fade:s.getPropertyValue('--tabs-fade').trim(),sw:e.scrollWidth,cw:e.clientWidth}})())`).then(JSON.parse); + const start = await read(); + assert.ok(start.sw > start.cw, `the tabs overflow (${start.sw} > ${start.cw})`); + assert.match(start.mask, /linear-gradient/, 'a fade mask is set'); + assert.equal(start.fade, '48px', 'a 48px fade while more tabs lie past the edge'); + await b.evaluate(`{const e=document.querySelector('.project-tabs');e.scrollLeft=e.scrollWidth}true`); + await frames(b); + assert.equal((await read()).fade, '0px', 'no fade once the row is scrolled to its end'); +}); + +const org = { + busHealthy: true, decisionWrite: async () => ({}), send: async () => ({}), + requestRead: async () => { throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); }, + reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) }, + bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) }, + chat: { projects: async () => [], threads: async () => [] }, +}; +for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page'], [1440, 'orgs/acme/side-by-side?a=acme/organization&b=acme/agents', 'a pane'], [390, 'orgs/acme/side-by-side?a=acme/agents&b=acme/organization&show=b', 'a pane']]) { + test(`E2E6 P2-13: Restart bus and Stop bus line up on ${where} at ${width}`, { timeout: 60000 }, async t => { + const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => org } }); + t.after(() => close(server)); + const b = await browser({ machine: false, args: ['--disable-extensions'] }); + t.after(() => b.close()); + await b.viewport(width, 900); + await b.navigate(`http://127.0.0.1:${server.address().port}/#/${route}`); + const pair = `[...document.querySelectorAll('[data-bus-restart],[data-bus-restart-off],[data-bus-stop],[data-bus-stop-off]')].filter(e=>e.getClientRects().length)`; + await until(b, `${pair}.length===2`, 'the two bus buttons'); + await b.evaluate('new Promise(r=>setTimeout(r,300))'); + const [restart, stop] = JSON.parse(await b.evaluate(`JSON.stringify(${pair}.map(e=>{const r=e.getBoundingClientRect();return {text:e.textContent.trim(),top:r.top,height:r.height,left:r.left,right:r.right}}))`)); + assert.equal(restart.text, 'Restart bus'); + assert.equal(stop.text, 'Stop bus'); + assert.ok(Math.abs(restart.top - stop.top) < 0.5, `same top (${restart.top} vs ${stop.top})`); + assert.ok(Math.abs(restart.height - stop.height) < 0.5, `same height (${restart.height} vs ${stop.height})`); + assert.ok(stop.left - restart.right >= 4, 'a gap between them'); + }); +} diff --git a/packages/webui/tests/org-live-edit-browser.test.mjs b/packages/webui/tests/org-live-edit-browser.test.mjs new file mode 100644 index 000000000..40819f57b --- /dev/null +++ b/packages/webui/tests/org-live-edit-browser.test.mjs @@ -0,0 +1,119 @@ +// E2E6 P1-1, P2-2 (#1873): an editor in use on an organization's page survives the page's refresh. The +// workflow editor's node, typed draft and focus, the autonomy form's unsaved choice and a pack's picked +// project are kept when the page reads every section again. An apply refreshes the other sections. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../src/serve.mjs'; +import { createDevOwner } from '../src/dev-owner.mjs'; +import { browser } from './browser.mjs'; +import { close } from './fixture.mjs'; + +const waitFor = (b, expression, what) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,2000))})()`); +const REV = n => String(n).repeat(64); + +async function fixture(t) { + const counts = { workflows: 0, autonomy: 0, packs: 0 }; + const flows = { + revision: REV('a'), + document: { + workflows: { intake: { title: 'Intake', steps: [{ title: 'Sort', state: 'todo', roles: ['human'] }] } }, + projects: { site: { workflows: {} }, docs: { workflows: {} } }, + definitions: {}, + }, + templates: ['coder'], + }; + const read = async verb => { + if (verb === 'packs.console') { counts.packs++; return { packs: [{ id: 'web-project', origin: 'installed', pack: { label: 'Web project', description: 'A board.', version: '0.2.0', scope: 'project' } }] }; } + if (verb === 'pack.preview.console') return { pack: { id: 'web-project', version: '0.2.0', label: 'Web project', origin: 'installed', scope: 'project' }, project: 'docs', revision: REV('c'), items: [{ item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }], hires: [], skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d') }; + if (verb === 'autonomy.get.console') { counts.autonomy++; return { revision: REV('e'), autonomy: { merge: 'out of the loop' }, levels: { 'cross-role': 'out of the loop', merge: 'out of the loop', policy: 'out of the loop', authority: 'in the loop' } }; } + throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); + }; + const write = async (verb, args) => { + if (verb === 'pack.apply.console') return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '0.2.0' } }; + throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); + }; + const server = await startServer({ + port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, + organizations: { + list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], + resolve: async () => ({ + busHealthy: true, decisionWrite: async () => ({}), requestRead: read, requestWrite: read, packsWrite: write, + workflowsRead: async () => { counts.workflows++; return structuredClone(flows); }, send: async () => ({}), + reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) }, + bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) }, + chat: { projects: async () => [], threads: async () => [] }, + }), + }, + }); + t.after(() => close(server)); + const b = await browser({ machine: false, args: ['--disable-extensions'] }); + t.after(() => b.close()); + await b.viewport(1440, 900); + await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`); + // poll: wait for the page's own 10-second poll. Otherwise send the event a write on the page sends, + // which repaints /organization the same way. + const refresh = async ({ poll = false } = {}) => { + const n = counts.workflows; + if (!poll) await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org:'acme'}}));true`); + for (let i = 0; i < 500 && counts.workflows === n; i++) await new Promise(r => setTimeout(r, 30)); + assert.ok(counts.workflows > n, 'the refresh read the workflows again'); + await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),200))`); + }; + return { b, counts, refresh }; +} + +test('E2E6 P1-1: an open workflow editor keeps its node, draft and focus across a refresh', { timeout: 60000 }, async t => { + const { b, refresh } = await fixture(t); + await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')`, 'workflow list'); + await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`); + await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor'); + await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');window.__editor=f;const i=f.querySelector('input[name=title]');i.value='Intake, revised';i.focus();i.setSelectionRange(3,3);f.querySelector('[data-wf-edit-step="0"] [name=roles]').value='human, coder'}true`); + await refresh({ poll: true }); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true, 'the same editor node'); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf-editor]').length`), 1); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Intake, revised'); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] [data-wf-edit-step="0"] [name=roles]').value`), 'human, coder'); + assert.equal(await b.evaluate(`document.activeElement===document.querySelector('[data-wf-editor] input[name=title]')`), true, 'focus stays in the field'); + assert.equal(await b.evaluate(`document.activeElement.selectionStart`), 3, 'the caret stays put'); + // A second refresh with focus elsewhere still keeps the open editor: it holds data-live-hold. + await b.evaluate(`document.activeElement.blur();true`); + await refresh(); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Intake, revised'); + // Cancel closes it; the next refresh paints the list fresh. + await b.evaluate(`document.querySelector('[data-wf-cancel]').click();true`); + await waitFor(b, `!document.querySelector('[data-wf-editor]')`, 'cancel'); + await refresh(); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')`), null); +}); + +test('E2E6 P1-1, P2-14: an unsaved autonomy choice, a picked pack project and a focused link survive the poll', { timeout: 60000 }, async t => { + const { b, refresh } = await fixture(t); + await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-project]')`, 'autonomy and packs'); + await b.evaluate(`{const s=document.querySelector('[data-autonomy-form] select[name=merge]');s.value='in the loop';s.dispatchEvent(new Event('change',{bubbles:true}));window.__auto=document.querySelector('[data-autonomy-form]')}true`); + await b.evaluate(`{const s=document.querySelector('[data-pack-project]');s.value='docs';s.dispatchEvent(new Event('change',{bubbles:true}));s.blur()}true`); + // P2-14: a visible Open link outside any editor keeps focus through restore(). (The tester's "Open + // Inbox →" is the legacy dashboard card outside #bus-view, hidden on this page, so it never took focus.) + assert.equal(await b.evaluate(`document.querySelector('[data-focus-key="set:Identity"]').focus();document.activeElement.dataset.focusKey`), 'set:Identity'); + await refresh({ poll: true }); + assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'set:Identity', 'focus stays on the link'); + assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')===window.__auto`), true, 'the dirty form is kept'); + assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'in the loop'); + assert.equal(await b.evaluate(`document.querySelector('[data-pack-project]').value`), 'docs', 'the picked project is kept'); +}); + +test('E2E6 P2-2: a pack apply re-reads the other sections and keeps its receipt', { timeout: 60000 }, async t => { + const { b, counts } = await fixture(t); + await waitFor(b, `document.querySelector('[data-pack="web-project"] [data-pack-preview-button]')`, 'pack list'); + await b.evaluate(`{const s=document.querySelector('[data-pack-project]');s.value='docs';s.dispatchEvent(new Event('change',{bubbles:true}))}true`); + await b.evaluate(`document.querySelector('[data-pack-preview-button]').click();true`); + await waitFor(b, `document.querySelector('[data-pack-apply]')`, 'preview'); + const before = { ...counts }; + await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`); + await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply'); + for (let i = 0; i < 300 && counts.workflows === before.workflows; i++) await new Promise(r => setTimeout(r, 30)); + assert.ok(counts.workflows > before.workflows, 'workflows read again after the apply'); + assert.ok(counts.autonomy > before.autonomy, 'autonomy read again after the apply'); + await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),300))`); + assert.match(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), /^Applied\. Added board\./, 'the receipt survives the re-read'); +}); -- 2.54.0 From 266f3a45ffca2c025491c85190f89684f7155cf9 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 13 Nov 2026 19:52:26 -0600 Subject: [PATCH 2/3] fix(webui): E2E6 review round, apply-phase refusals, held sections, tab fade, header pins (#1873) Review finding 1: a hire start refusal the apply step can also raise after the decision is consumed (decision-stale, organization-instance-*, organization-refused, definition-missing, invalid-request and the rest) no longer says "Nothing was applied". It says that part may be applied when the inbox shows the proposal as answered, and to check Agents first. Only codes that prepare() or the route checks alone raise keep "Nothing was applied". Review P3: a failed workflows or autonomy read keeps the last good read, so an open editor or a dirty form stays through it. Focus holds a section only in a field being typed in; a focused button is rebuilt and gets focus back by data-focus-key. Autonomy has Discard changes. The project tab fade is 0px when every tab fits. s8 and s9 header pins at 1440 now measure from the content edge: 1418 was 100vw-based, ran under the page scrollbar and is the P2-11 clipping itself. Correction to the previous commit's message: P1-2 is the unknown project, agent or harness wording; P2-4 names the refused field; P2-6 is the Queue refusal reason; P2-8 is "bus stopped". --- packages/webui/src/decision-http.mjs | 48 ++++++++++--------- packages/webui/src/public/autonomy.js | 7 ++- packages/webui/src/public/projects.css | 7 ++- packages/webui/src/public/workflows.js | 7 ++- packages/webui/src/public/workspace.js | 7 ++- packages/webui/tests/decision-http.test.mjs | 26 ++++++++-- .../webui/tests/e2e6-layout-browser.test.mjs | 8 +++- .../tests/org-live-edit-browser.test.mjs | 42 ++++++++++++++-- .../webui/tests/s8-header-browser.test.mjs | 7 ++- .../webui/tests/s9-reflow-browser.test.mjs | 7 ++- 10 files changed, 127 insertions(+), 39 deletions(-) diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs index ef5b8fc9c..1e016e2c0 100644 --- a/packages/webui/src/decision-http.mjs +++ b/packages/webui/src/decision-http.mjs @@ -44,14 +44,18 @@ const BUS_CODES = { 'invalid-request': 'invalid-request', 'hire-owner-required':'hire-owner-required','right-required':'right-required','decision-expired':'decision-expired','hire-resolution-required':'hire-resolution-required', }; // D4 (#1873, P1-2) and E2E6 P1-2: a hire, fire or change start the bus definitely refused keeps its -// own code and a next step. hire.start.console runs the proposal checks in hire-decisions.mjs -// prepare() before it records the decision, then resolves and applies it in #hireConsole. Every -// check up to the consume transaction changes nothing, so those codes say "Nothing was applied." -// (the recorded proposal, if one was recorded, stays open in the inbox). Codes the apply step can -// raise after the decision is consumed say what to check instead. organization-uncertain, a lost -// reply and the bus server's own request codes (request-timeout and invalid-envelope can fire while -// the request is processing) stay outcome-unknown. +// own code and a next step. hire.start.console runs prepare() (hire-decisions.mjs) before it records +// the decision, then #hireConsole checks it, consumes it, and applies it. apply() runs check() again +// and the writers, authorityHost and #fireApproved run after the consume, so most codes can come from +// either side of it; for a fire the agent's runs may already be stopped. The code alone does not say +// which, so those say how to tell: a consumed decision shows as answered in the inbox. Only codes +// that prepare() alone raises say "Nothing was applied.": the route checks (checkRoutes runs in +// prepare(), and in adjusted() only for an adjust, which a start never carries), definition-shared, +// definition-not-hireable and no-chief-of-staff. organization-uncertain, a lost reply and the +// bus server's own request codes (request-timeout and invalid-envelope can fire while the request is +// processing) stay outcome-unknown. const NONE = ' Nothing was applied.'; +const EITHER = ' If the inbox shows this proposal as answered, part of it may already be applied: check the Agents page before trying again. Otherwise nothing was applied.'; const CHECK = ' Check the Agents page and the inbox before trying again.'; const HIRE_START_REFUSALS = { 'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy.' + NONE], @@ -63,21 +67,21 @@ const HIRE_START_REFUSALS = { 'harness-refused': [409, 'The harness refused this route. Clear Thinking, or pick another model or harness under Settings > Routing.' + NONE], 'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template.' + NONE], 'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it.' + NONE], - 'definition-rev-conflict': [409, 'The role definition changed since it was read. Reload the page and propose again.' + NONE], - 'definition-missing': [409, 'The agent\'s role definition is missing from the organization file. Check the Agents page.' + NONE], - 'definition-secret-check-required': [503, 'The bus cannot check role text for secrets right now. Ask the owner to check the organization bus.' + NONE], - 'credential-leak': [409, 'The proposal carries a value the credential store holds. Remove it from the role text or the reason.' + NONE], + 'definition-rev-conflict': [409, 'The role definition changed since it was read. Reload the page and propose again.' + EITHER], + 'definition-missing': [409, 'The agent\'s role definition is missing from the organization file. Check the Agents page.' + EITHER], + 'definition-secret-check-required': [503, 'The bus cannot check role text for secrets right now. Ask the owner to check the organization bus.' + EITHER], + 'credential-leak': [409, 'The proposal carries a value the credential store holds. Remove it from the role text or the reason.' + EITHER], 'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it.' + NONE], - 'project-agents-invalid': [409, 'That project is not a code project of this organization. Pick a code project from Projects; a library project has no agents.' + NONE], - 'organization-unknown-instance': [409, 'No agent has that name at this level. Check the name on the Agents page.' + NONE], - 'organization-instance-exists': [409, 'An agent already has that name at this level. Pick another name, or propose a change to that agent.' + NONE], - 'organization-instance-retired': [409, 'That name belonged to a fired agent and cannot be reused. Pick another name.' + NONE], - 'organization-instance-referenced': [409, 'Another part of the organization still names this agent. Remove those references first.' + NONE], - 'instance-kept': [409, 'This agent is kept by the organization and cannot be fired or replaced this way.' + NONE], - 'root-fire-shared': [409, 'This root agent is shared with other organizations, so it cannot be fired from here.' + NONE], - 'decision-stale': [409, 'The role template or the organization changed since it was read. Reload the templates and propose again; if the inbox lists this proposal, answer it there.' + NONE], - 'organization-refused': [409, 'The organization file refused this change. Check the role and route against the organization checks.' + NONE], - 'not-configured': [503, 'This Console\'s bus has no hire support configured. Ask the owner to check the organization bus.' + NONE], + 'project-agents-invalid': [409, 'That project is not a code project of this organization. Pick a code project from Projects; a library project has no agents.' + EITHER], + 'organization-unknown-instance': [409, 'No agent has that name at this level. Check the name on the Agents page.' + EITHER], + 'organization-instance-exists': [409, 'An agent already has that name at this level. Pick another name, or propose a change to that agent.' + EITHER], + 'organization-instance-retired': [409, 'That name belonged to a fired agent and cannot be reused. Pick another name.' + EITHER], + 'organization-instance-referenced': [409, 'Another part of the organization still names this agent. Remove those references first.' + EITHER], + 'instance-kept': [409, 'This agent is kept by the organization and cannot be fired or replaced this way.' + EITHER], + 'root-fire-shared': [409, 'This root agent is shared with other organizations, so it cannot be fired from here.' + EITHER], + 'decision-stale': [409, 'The role template or the organization changed since it was read. Reload the templates and propose again.' + EITHER], + 'organization-refused': [409, 'The organization file refused this change. Check the role and route against the organization checks.' + EITHER], + 'not-configured': [503, 'This Console\'s bus has no hire support configured. Ask the owner to check the organization bus.' + EITHER], 'organization-busy': [409, 'The organization is busy with another write.' + CHECK], 'fire-in-progress': [409, 'A fire of this agent is already in progress.' + CHECK], 'instance-fired': [409, 'This agent was fired.' + CHECK], @@ -93,7 +97,7 @@ const HIRE_START_REFUSALS = { 'credential-scope-exceeded': [409, 'The requested credential scope is wider than the role allows.' + CHECK], // The fields passed the shape check below, so a malformed answer here is a value the organization // checks refused: most often a model that is not a launch.models key. - 'invalid-request': [400, 'The bus refused a value in this proposal. Pick a model listed under Settings > Model proxy and a role the organization checks accept.' + NONE], + 'invalid-request': [400, 'The bus refused a value in this proposal. Pick a model listed under Settings > Model proxy and a role the organization checks accept.' + EITHER], }; // A reply the endpoint cannot read as a definite answer: no answer, or the bus server's request // codes. Anything else that reads as a code is a definite refusal. diff --git a/packages/webui/src/public/autonomy.js b/packages/webui/src/public/autonomy.js index 4a4e59415..2137b8566 100644 --- a/packages/webui/src/public/autonomy.js +++ b/packages/webui/src/public/autonomy.js @@ -23,7 +23,8 @@ const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value; } // E2E6 P1-1: a poll reloads this state; the last save's receipt and the open review list stay. - async function load(org) {const prev=states.get(org),keep={status:prev?.status??'',reviewOpen:prev?.reviewOpen===true};try{states.set(org,{...await call(org,''),...keep});}catch(error){states.set(org,{error:error.message,...keep});}} + // A failed poll keeps the last good read (review P3), so a dirty form the page holds is not dropped. + async function load(org) {const prev=states.get(org),keep={status:prev?.status??'',reviewOpen:prev?.reviewOpen===true};try{states.set(org,{...await call(org,''),...keep});}catch(error){states.set(org,prev&&!prev.error?{...prev,...keep}:{error:error.message,...keep});}} function section(org) { const s=states.get(org); if(!s || s.error)return `

    Autonomy

    ${esc(s?.error??'Autonomy could not be read.')}

    `; @@ -35,7 +36,7 @@

    In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.

    - ${s.writable?'':'

    Only the owner can change autonomy.

    '} + ${s.writable?'

    ':'

    Only the owner can change autonomy.

    '}

    ${esc(s.status)}

    ${s.writable?`
    Review reversals before moving out of the loop

    Read the decision trail and its reversals before recording your review.

    ${classes.map(k=>``).join('')}
    `:''}`; } @@ -59,6 +60,8 @@ // An edited form is dirty until it saves: holdLive keeps a dirty form through every poll. const dirty=e=>{const form=e.target.closest?.('[data-autonomy-form]');if(form)form.dataset.liveDirty='1';}; document.addEventListener('input',dirty);document.addEventListener('change',dirty); + // Review P3: Discard drops the held form and draws the last read in its place. + document.addEventListener('click',e=>{const b=e.target.closest?.('[data-autonomy-discard]');const el=b?.closest('[data-autonomy-org]');if(!el||!states.get(el.dataset.autonomyOrg))return;const org=el.dataset.autonomyOrg;el.outerHTML=section(org);[...document.querySelectorAll('[data-autonomy-org]')].find(n=>n.dataset.autonomyOrg===org)?.querySelector('[data-autonomy-discard]')?.focus();}); document.addEventListener('toggle',e=>{if(!e.target.matches?.('[data-autonomy-review-list]'))return;const s=states.get(e.target.closest('[data-autonomy-org]')?.dataset.autonomyOrg);if(s)s.reviewOpen=e.target.open;},true); document.addEventListener('click',async e=>{ const button=e.target.closest('[data-autonomy-confirm],[data-autonomy-reverse],[data-autonomy-review]');if(!button)return; diff --git a/packages/webui/src/public/projects.css b/packages/webui/src/public/projects.css index e1e8a5e37..9b71c1662 100644 --- a/packages/webui/src/public/projects.css +++ b/packages/webui/src/public/projects.css @@ -87,12 +87,15 @@ .chat-area:has(.project-header),.chat-area:has(.project-list-head) { max-width:none; margin-inline:0; } .project-tabs { scrollbar-width:none; } .project-tabs::-webkit-scrollbar { display:none; } /* E2E6 P2-12: a strip wider than the screen fades out over its last 48px, so a cut tab reads as "more - this way". Where scroll timelines exist the fade goes once the strip is scrolled to its end. */ -@property --tabs-fade { syntax:''; inherits:false; initial-value:48px; } + this way". Where scroll timelines exist the fade runs only while there is more to scroll: a strip that + fits has an inactive timeline and keeps the 0px initial value (review P3), and the fade goes once the + strip is scrolled to its end. Without scroll timelines the 48px fade is static. */ +@property --tabs-fade { syntax:''; inherits:false; initial-value:0px; } @keyframes project-tabs-cue { 0%, 92% { --tabs-fade:48px; } 100% { --tabs-fade:0px; } } @media (max-width:699px) { .project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - var(--tabs-fade)), transparent 100%); } @supports (animation-timeline: scroll()) { .project-tabs { animation:project-tabs-cue linear both; animation-timeline:scroll(self inline); } } + @supports not (animation-timeline: scroll()) { .project-tabs { --tabs-fade:48px; } } } .project-chip { white-space:nowrap; flex:none; } @media(max-width:699px) { .project-stepper span { white-space:nowrap; font-size:10.5px; padding:4px 1px; }.project-work-head { align-items:flex-start; } } diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js index 7eb6bcdbe..b99beba0c 100644 --- a/packages/webui/src/public/workflows.js +++ b/packages/webui/src/public/workflows.js @@ -48,7 +48,12 @@ async function load(org) { const prev = states.get(org); const s = { data: null, known: new Set(), error: null, editing: prev?.editing ?? null, confirm: prev?.confirm ?? null, status: prev?.status ?? '' }; - try { await read(org, s); } catch (error) { s.error = error.message; } + try { await read(org, s); } catch (error) { + // Review P3: a failed poll while an editor is open keeps the last good read, so the fresh section + // still carries its data-live-key and graftLive puts the held section, editor and focus back. + if (prev?.editing && prev.data) Object.assign(s, { data: prev.data, known: prev.known }); + else s.error = error.message; + } states.set(org, s); } const scopes = s => [['', 'Organization', s.data.workflows], ...Object.keys(s.data.projects).sort().map(p => [p, `Project ${p}`, s.data.projects[p]])]; diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 5a53f3d15..5a8024529 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -198,11 +198,16 @@ // or holds focus is kept: holdLive takes it before the repaint and graftLive puts that same node, with // its typed values, caret and focus, in place of its fresh counterpart. Each editor saves against the // revision it started from, so a kept node cannot overwrite a change it never saw. + const TYPING = 'input:not([type=button],[type=submit],[type=reset],[type=checkbox],[type=radio]),select,textarea'; function holdLive(root) { const held = new Map(), a = document.activeElement; if (!root) return { held, active: null }; + // Focus alone holds a section only while it sits in a field being typed in. A focused button or link + // is rebuilt with fresh data and gets its focus back through data-focus-key (review P3), so a section + // never stays stale just because its Apply button kept focus. + const typing = a?.matches?.(TYPING) ?? false; for (const el of root.querySelectorAll('[data-live-key]')) { - if (el.matches('[data-live-hold]') || el.querySelector('[data-live-hold],[data-live-dirty]') || el.contains(a)) held.set(el.dataset.liveKey, el); + if (el.matches('[data-live-hold]') || el.querySelector('[data-live-hold],[data-live-dirty]') || (typing && el.contains(a))) held.set(el.dataset.liveKey, el); } const active = a && [...held.values()].some(el => el.contains(a)) ? { el: a, caret: typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null } : null; diff --git a/packages/webui/tests/decision-http.test.mjs b/packages/webui/tests/decision-http.test.mjs index 76bf3de8b..bfd10db10 100644 --- a/packages/webui/tests/decision-http.test.mjs +++ b/packages/webui/tests/decision-http.test.mjs @@ -332,14 +332,25 @@ test('E2E6 hire start: an unknown project, an unknown agent on fire and an unkno for(const [code,re] of [['project-agents-invalid',/code project/],['organization-unknown-instance',/Agents page/],['harness-off',/Settings > Routing/]]) { const {r,s}=await startWith(new BusReadError(code)); assert.equal(r.status,409,`${code}: ${r.text}`); - assert.equal(r.json().error,code);assert.match(r.json().message,re);assert.match(r.json().message,/Nothing was applied/); + assert.equal(r.json().error,code);assert.match(r.json().message,re); + // harness-off comes only from prepare()'s route check; the other two can also come from apply(). + if(code==='harness-off')assert.match(r.json().message,/ Nothing was applied\.$/); + else assert.match(r.json().message,/inbox shows this proposal as answered.*Otherwise nothing was applied\.$/); assert.match(s.lines.at(-1),new RegExp(`step=start error=${code}$`)); } }); test('E2E6 hire start: the other proposal and apply codes keep their code; apply-phase ones do not claim nothing changed',async()=>{ - for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','unknown-model','harness-refused','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused']) { + for(const code of ['unknown-model','harness-refused']) { const {r}=await startWith(new BusReadError(code)); - assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/Nothing was applied/); + assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/ Nothing was applied\.$/); + } + // Review finding 1: apply() runs check() again and the writers run after the consume, so these + // never claim nothing changed outright; they say how to tell from the inbox. + for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused','definition-secret-check-required','not-configured','invalid-request']) { + const {r}=await startWith(new BusReadError(code)); + assert.equal(r.json().error,code,`${code}: ${r.text}`); + assert.doesNotMatch(r.json().message,/(^|\. )Nothing was applied/,`${code} does not claim nothing changed`); + assert.match(r.json().message,/If the inbox shows this proposal as answered, part of it may already be applied: check the Agents page/); } for(const code of ['organization-busy','fire-in-progress','credential-revoking']) { const {r}=await startWith(new BusReadError(code)); @@ -349,6 +360,15 @@ test('E2E6 hire start: the other proposal and apply codes keep their code; apply const {r}=await startWith(new BusReadError('some-future-code')); assert.equal(r.status,409);assert.equal(r.json().error,'some-future-code');assert.match(r.json().message,/Agents page and the inbox/); }); +test('E2E6 hire start: an apply-phase decision-stale on a fire does not say nothing was applied',async()=>{ + // #hireConsole consumes the decision and stops a project agent's runs before apply() checks again. + const s=setup({resolve:()=>{throw new BusReadError('decision-stale');}}); + const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{op:'fire',level:'project',project:'site',name:'coder',reason:'done'}}})); + assert.equal(s.calls.length,1);assert.equal(r.status,409,r.text);assert.equal(r.json().error,'decision-stale'); + assert.doesNotMatch(r.json().message,/(^|\. )Nothing was applied/); + assert.doesNotMatch(r.json().message,/answer it there/,'a consumed decision cannot be answered again'); + assert.match(r.json().message,/check the Agents page/); +}); test('E2E6 hire start: invalid-request from the bus says the values, not the shape, were refused',async()=>{ const {r}=await startWith(new BusReadError('invalid-request')); assert.equal(r.status,400);assert.equal(r.json().error,'invalid-request');assert.match(r.json().message,/Settings > Model proxy/); diff --git a/packages/webui/tests/e2e6-layout-browser.test.mjs b/packages/webui/tests/e2e6-layout-browser.test.mjs index 87520d0a3..b81392cfc 100644 --- a/packages/webui/tests/e2e6-layout-browser.test.mjs +++ b/packages/webui/tests/e2e6-layout-browser.test.mjs @@ -1,7 +1,7 @@ // E2E6 P2-11, P2-12, P2-13 (#1873): layout pins measured in a real browser. // P2-11: a project's header stays inside its column at desktop widths, so its actions are not cut off. // P2-12: at phone width the project tabs fade at the right edge while more tabs lie past it, and the -// fade is gone once the row is scrolled to its end. +// fade is gone once the row is scrolled to its end or when every tab fits. // P2-13: Restart bus and Stop bus share one top and height, on the organization page and in a pane. import test from 'node:test'; import assert from 'node:assert/strict'; @@ -45,6 +45,12 @@ test('E2E6 P2-12: at 390 the project tabs fade at the edge until scrolled to the await b.evaluate(`{const e=document.querySelector('.project-tabs');e.scrollLeft=e.scrollWidth}true`); await frames(b); assert.equal((await read()).fade, '0px', 'no fade once the row is scrolled to its end'); + // Review P3: a strip whose tabs all fit has nothing past the edge, so it has no fade. + await b.evaluate(`{const e=document.querySelector('.project-tabs');[...e.children].slice(1).forEach(c=>c.style.display='none');e.scrollLeft=0}true`); + await frames(b); + const fits = await read(); + assert.ok(fits.sw <= fits.cw, `the remaining tab fits (${fits.sw} <= ${fits.cw})`); + assert.equal(fits.fade, '0px', 'no fade when nothing is cut off'); }); const org = { diff --git a/packages/webui/tests/org-live-edit-browser.test.mjs b/packages/webui/tests/org-live-edit-browser.test.mjs index 40819f57b..884520c96 100644 --- a/packages/webui/tests/org-live-edit-browser.test.mjs +++ b/packages/webui/tests/org-live-edit-browser.test.mjs @@ -13,6 +13,8 @@ const REV = n => String(n).repeat(64); async function fixture(t) { const counts = { workflows: 0, autonomy: 0, packs: 0 }; + // ctl lets a test fail the next reads or change what the packs read returns. + const ctl = { workflowsFail: false, autonomyFail: false, packLabel: 'Web project' }; const flows = { revision: REV('a'), document: { @@ -23,9 +25,9 @@ async function fixture(t) { templates: ['coder'], }; const read = async verb => { - if (verb === 'packs.console') { counts.packs++; return { packs: [{ id: 'web-project', origin: 'installed', pack: { label: 'Web project', description: 'A board.', version: '0.2.0', scope: 'project' } }] }; } + if (verb === 'packs.console') { counts.packs++; return { packs: [{ id: 'web-project', origin: 'installed', pack: { label: ctl.packLabel, description: 'A board.', version: '0.2.0', scope: 'project' } }] }; } if (verb === 'pack.preview.console') return { pack: { id: 'web-project', version: '0.2.0', label: 'Web project', origin: 'installed', scope: 'project' }, project: 'docs', revision: REV('c'), items: [{ item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }], hires: [], skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d') }; - if (verb === 'autonomy.get.console') { counts.autonomy++; return { revision: REV('e'), autonomy: { merge: 'out of the loop' }, levels: { 'cross-role': 'out of the loop', merge: 'out of the loop', policy: 'out of the loop', authority: 'in the loop' } }; } + if (verb === 'autonomy.get.console') { counts.autonomy++; if (ctl.autonomyFail) throw Object.assign(new Error('bus-unreachable'), { code: 'bus-unreachable' }); return { revision: REV('e'), autonomy: { merge: 'out of the loop' }, levels: { 'cross-role': 'out of the loop', merge: 'out of the loop', policy: 'out of the loop', authority: 'in the loop' } }; } throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); }; const write = async (verb, args) => { @@ -38,7 +40,7 @@ async function fixture(t) { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => ({ busHealthy: true, decisionWrite: async () => ({}), requestRead: read, requestWrite: read, packsWrite: write, - workflowsRead: async () => { counts.workflows++; return structuredClone(flows); }, send: async () => ({}), + workflowsRead: async () => { counts.workflows++; if (ctl.workflowsFail) throw Object.assign(new Error('bus-unreachable'), { code: 'bus-unreachable' }); return structuredClone(flows); }, send: async () => ({}), reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) }, bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) }, chat: { projects: async () => [], threads: async () => [] }, @@ -59,7 +61,7 @@ async function fixture(t) { assert.ok(counts.workflows > n, 'the refresh read the workflows again'); await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),200))`); }; - return { b, counts, refresh }; + return { b, counts, ctl, refresh }; } test('E2E6 P1-1: an open workflow editor keeps its node, draft and focus across a refresh', { timeout: 60000 }, async t => { @@ -117,3 +119,35 @@ test('E2E6 P2-2: a pack apply re-reads the other sections and keeps its receipt' await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),300))`); assert.match(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), /^Applied\. Added board\./, 'the receipt survives the re-read'); }); + +test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held form, and a focused button does not hold its section', { timeout: 60000 }, async t => { + const { b, ctl, refresh } = await fixture(t); + await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')&&document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-preview-button]')`, 'sections'); + // A focused Preview button alone no longer holds the packs section: the next poll shows a changed + // pack and focus comes back to the rebuilt button through its data-focus-key. + await b.evaluate(`document.querySelector('[data-pack-preview-button]').focus();true`); + ctl.packLabel = 'Web project, renamed'; + await refresh({ poll: true }); + await waitFor(b, `/Web project, renamed/.test(document.querySelector('[data-packs-org]').textContent)`, 'the renamed pack'); + assert.match(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), /^pack-preview:acme:web-project$/, 'focus returns to the rebuilt button'); + // One failed workflows read while the editor is open keeps the same editor node, draft and focus. + await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`); + await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor'); + await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');window.__editor=f;const i=f.querySelector('input[name=title]');i.value='Typed during an outage';i.focus()}true`); + // A dirty autonomy form survives a failed autonomy read the same way. + await b.evaluate(`{const s=document.querySelector('[data-autonomy-form] select[name=merge]');s.value='in the loop';s.dispatchEvent(new Event('change',{bubbles:true}));window.__auto=document.querySelector('[data-autonomy-form]')}true`); + await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').focus();true`); + ctl.workflowsFail = true; ctl.autonomyFail = true; + await refresh({ poll: true }); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true, 'the same editor node after a failed read'); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Typed during an outage'); + assert.equal(await b.evaluate(`document.activeElement===document.querySelector('[data-wf-editor] input[name=title]')`), true, 'focus stays in the field'); + assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')===window.__auto`), true, 'the dirty autonomy form is kept after a failed read'); + // Discard draws the last read in place of the held form. + ctl.workflowsFail = false; ctl.autonomyFail = false; + await b.evaluate(`document.querySelector('[data-autonomy-discard]').click();true`); + assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')!==window.__auto`), true, 'a fresh form'); + assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'out of the loop', 'the stored value is back'); + assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]').hasAttribute('data-live-dirty')`), false); + assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-autonomy-discard')`), true, 'focus stays on Discard'); +}); diff --git a/packages/webui/tests/s8-header-browser.test.mjs b/packages/webui/tests/s8-header-browser.test.mjs index 1a439f04d..f0eccd40b 100644 --- a/packages/webui/tests/s8-header-browser.test.mjs +++ b/packages/webui/tests/s8-header-browser.test.mjs @@ -26,13 +26,18 @@ async function accessibleName(b, selector) { for (const width of [1440,390]) { test(`S8 project action icons, accessible names and positions at ${width}`, async t => { const {b,wait} = await setup(t,width); - const expected = width === 1440 ? [[1060,1190],[1202,1290],[1302,1418]] : [[12,132],[140,220],[228,340]]; + // E2E6 P2-11 (#1873): at 1440 the actions end 22px inside the content edge. A page tall enough to + // scroll has a scrollbar (sb wide), so the design's x values move left by its width on that route: + // 1418 was 100vw-based and ran under the scrollbar, which clipped Settings. + const design = width === 1440 ? [[1060,1190],[1202,1290],[1302,1418]] : [[12,132],[140,220],[228,340]]; const y = width === 1440 ? [130,174] : [198,242]; for (const route of ['', '/conversations', '/tasks', '/requirements', '/files', '/settings', ...(width === 1440 ? ['/room'] : [])]) { if (route) { await b.evaluate(`location.hash=${JSON.stringify(route === '/room' ? '#/projects/rooms/'+P : '#/projects/'+P+route)}`); await wait(`document.querySelector('.project-header')?.dataset.projectSection===${JSON.stringify(route.slice(1))}`); } + const sb = width === 1440 ? await b.evaluate('innerWidth-document.documentElement.clientWidth') : 0; + const expected = design.map(x => x.map(v => v - sb)); for (const [i,name] of ['North star','PRD','Settings'].entries()) { const selector = `.project-header .project-primary-action:nth-of-type(${i+1})`; const data = await b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect();return {icon:!!e.querySelector('svg[aria-hidden="true"]'),left:r.left,right:r.right,top:r.top,bottom:r.bottom}})()`); diff --git a/packages/webui/tests/s9-reflow-browser.test.mjs b/packages/webui/tests/s9-reflow-browser.test.mjs index 12a5da605..6510e7f28 100644 --- a/packages/webui/tests/s9-reflow-browser.test.mjs +++ b/packages/webui/tests/s9-reflow-browser.test.mjs @@ -65,10 +65,13 @@ for (const zoom of [true,false]) test(`S9 project routes reflow at ${zoom ? 'act }); for(const width of [1440,390]) test(`S9 Overview action and S8 header boxes at ${width}`,async t=>{ const {b}=await setup(t,width); - const data=await b.evaluate(`(()=>{const rect=e=>{const r=e.getBoundingClientRect();return [r.left,r.top,r.right,r.bottom]};return {button:rect(document.querySelector('[data-project-content] .project-section-title .btn')),section:rect(document.querySelector('.project-columns > section')),headers:[...document.querySelectorAll('.project-primary-action')].map(rect)}})()`); + const data=await b.evaluate(`(()=>{const rect=e=>{const r=e.getBoundingClientRect();return [r.left,r.top,r.right,r.bottom]};return {sb:innerWidth-document.documentElement.clientWidth,button:rect(document.querySelector('[data-project-content] .project-section-title .btn')),section:rect(document.querySelector('.project-columns > section')),headers:[...document.querySelectorAll('.project-primary-action')].map(rect)}})()`); if(process.env.S9_CAPTURE) await b.screenshot(`${process.env.S9_CAPTURE}/${process.env.S9_PHASE}-overview-${width}.png`); if(process.env.S9_MEASURE) console.log('S9 geometry',width,JSON.stringify(data)); - const expected=width===1440?[[1060,130,1190,174],[1202,130,1290,174],[1302,130,1418,174]]:[[12,198,132,242],[140,198,220,242],[228,198,340,242]]; + // E2E6 P2-11 (#1873): at 1440 the header actions end 22px inside the content edge, so the design's + // x values move left by the page scrollbar when the page scrolls (1418 was 100vw-based and clipped Settings). + const sb=width===1440?data.sb:0; + const expected=(width===1440?[[1060,130,1190,174],[1202,130,1290,174],[1302,130,1418,174]]:[[12,198,132,242],[140,198,220,242],[228,198,340,242]]).map(([x0,y0,x1,y1])=>[x0-sb,y0,x1-sb,y1]); for(let i=0;i<3;i++) for(let j=0;j<4;j++) assert.ok(Math.abs(data.headers[i][j]-expected[i][j])<=4,`header ${i}: ${JSON.stringify(data.headers[i])}`); // The accepted shell reserves a scrollbar and narrows the content; the // action must align to its section when that puts the target right edge out of reach. -- 2.54.0 From 23b5e24ebcb35461f5dcad3bc803280bb3969d88 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 13 Nov 2026 20:05:10 -0600 Subject: [PATCH 3/3] fix(webui): E2E6 re-check round, consumed-decision wording, autonomy focus and read errors (#1873) Re-check finding 1 (P2): the wording for refusals that can come after the decision is consumed told the user to look for the proposal "as answered" in the inbox and otherwise read "nothing was applied". The bus inbox lists no closed decision, so a consumed one drops out of it and the user would read the false claim. Those codes now say only that part of it may already be applied and to check the Agents page. decision-http tests assert no "nothing was applied" in any case and no inbox pointer for those codes. Re-check P3-b: the autonomy controls (Save, Discard, notices, review summary and buttons) carry data-focus-key, so a focused button in a clean form gets its focus back after the poll repaints the section. Re-check P3-c: a failed autonomy read keeps the last read only while the page holds the form (dirty, or focus inside it). A clean form shows the read error. org-live-edit-browser test 4 covers both. Re-check P3-a (harness-off after the consume through a corrupt prior settings file) is deferred: wording it exactly needs the bus to mark the phase on the refusal. --- packages/webui/src/decision-http.mjs | 6 ++++-- packages/webui/src/public/autonomy.js | 14 ++++++++------ packages/webui/tests/decision-http.test.mjs | 14 ++++++++------ .../webui/tests/org-live-edit-browser.test.mjs | 13 ++++++++++++- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs index 1e016e2c0..e64d16e1e 100644 --- a/packages/webui/src/decision-http.mjs +++ b/packages/webui/src/decision-http.mjs @@ -48,14 +48,16 @@ const BUS_CODES = { // the decision, then #hireConsole checks it, consumes it, and applies it. apply() runs check() again // and the writers, authorityHost and #fireApproved run after the consume, so most codes can come from // either side of it; for a fire the agent's runs may already be stopped. The code alone does not say -// which, so those say how to tell: a consumed decision shows as answered in the inbox. Only codes +// which, and the inbox cannot tell either: it lists no closed decision (broker inbox verb), so a +// consumed one simply drops out of it. Those codes therefore say part of the change may already be +// applied and send the user to the Agents page (re-check finding 1). Only codes // that prepare() alone raises say "Nothing was applied.": the route checks (checkRoutes runs in // prepare(), and in adjusted() only for an adjust, which a start never carries), definition-shared, // definition-not-hireable and no-chief-of-staff. organization-uncertain, a lost reply and the // bus server's own request codes (request-timeout and invalid-envelope can fire while the request is // processing) stay outcome-unknown. const NONE = ' Nothing was applied.'; -const EITHER = ' If the inbox shows this proposal as answered, part of it may already be applied: check the Agents page before trying again. Otherwise nothing was applied.'; +const EITHER = ' Part of it may already be applied: check the Agents page before trying again.'; const CHECK = ' Check the Agents page and the inbox before trying again.'; const HIRE_START_REFUSALS = { 'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy.' + NONE], diff --git a/packages/webui/src/public/autonomy.js b/packages/webui/src/public/autonomy.js index 2137b8566..13a65827f 100644 --- a/packages/webui/src/public/autonomy.js +++ b/packages/webui/src/public/autonomy.js @@ -23,22 +23,24 @@ const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value; } // E2E6 P1-1: a poll reloads this state; the last save's receipt and the open review list stay. - // A failed poll keeps the last good read (review P3), so a dirty form the page holds is not dropped. - async function load(org) {const prev=states.get(org),keep={status:prev?.status??'',reviewOpen:prev?.reviewOpen===true};try{states.set(org,{...await call(org,''),...keep});}catch(error){states.set(org,prev&&!prev.error?{...prev,...keep}:{error:error.message,...keep});}} + // A failed poll keeps the last good read only while the page holds the form (dirty, or a field has + // focus), so the held form is not dropped (review P3). A clean form shows the read error (re-check P3). + const held=org=>{const form=[...document.querySelectorAll('[data-autonomy-org]')].find(n=>n.dataset.autonomyOrg===org)?.querySelector('[data-autonomy-form]');return !!form&&(form.hasAttribute('data-live-dirty')||form.contains(document.activeElement));}; + async function load(org) {const prev=states.get(org),keep={status:prev?.status??'',reviewOpen:prev?.reviewOpen===true};try{states.set(org,{...await call(org,''),...keep});}catch(error){states.set(org,prev&&!prev.error&&held(org)?{...prev,...keep}:{error:error.message,...keep});}} function section(org) { const s=states.get(org); if(!s || s.error)return `

    Autonomy

    ${esc(s?.error??'Autonomy could not be read.')}

    `; - const v=s.autonomy??{}; + const v=s.autonomy??{},fk=k=>` data-focus-key="${esc(`autonomy-${k}:${org}`)}"`; // The form carries the revision it was drawn from: a form a poll kept (workspace holdLive) saves // against that revision, so it cannot overwrite a change it never showed. return `

    Autonomy

    Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.

    ${classes.map(k=>``).join('')}
    - +

    In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.

    - ${s.writable?'

    ':'

    Only the owner can change autonomy.

    '} + ${s.writable?`

    `:'

    Only the owner can change autonomy.

    '}

    ${esc(s.status)}

    - ${s.writable?`
    Review reversals before moving out of the loop

    Read the decision trail and its reversals before recording your review.

    ${classes.map(k=>``).join('')}
    `:''}
    `; + ${s.writable?`
    Review reversals before moving out of the loop

    Read the decision trail and its reversals before recording your review.

    ${classes.map(k=>``).join('')}
    `:''}`; } function notice(d) { if(!d?.autonomy)return ''; diff --git a/packages/webui/tests/decision-http.test.mjs b/packages/webui/tests/decision-http.test.mjs index bfd10db10..64aa4c6b4 100644 --- a/packages/webui/tests/decision-http.test.mjs +++ b/packages/webui/tests/decision-http.test.mjs @@ -335,7 +335,7 @@ test('E2E6 hire start: an unknown project, an unknown agent on fire and an unkno assert.equal(r.json().error,code);assert.match(r.json().message,re); // harness-off comes only from prepare()'s route check; the other two can also come from apply(). if(code==='harness-off')assert.match(r.json().message,/ Nothing was applied\.$/); - else assert.match(r.json().message,/inbox shows this proposal as answered.*Otherwise nothing was applied\.$/); + else {assert.match(r.json().message,/ Part of it may already be applied: check the Agents page before trying again\.$/);assert.doesNotMatch(r.json().message,/nothing was applied/i);} assert.match(s.lines.at(-1),new RegExp(`step=start error=${code}$`)); } }); @@ -345,12 +345,14 @@ test('E2E6 hire start: the other proposal and apply codes keep their code; apply assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/ Nothing was applied\.$/); } // Review finding 1: apply() runs check() again and the writers run after the consume, so these - // never claim nothing changed outright; they say how to tell from the inbox. + // never claim nothing changed, in any case or clause. The inbox lists no closed decision, so they + // do not send the user there to tell which side of the consume the refusal came from (re-check). for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused','definition-secret-check-required','not-configured','invalid-request']) { const {r}=await startWith(new BusReadError(code)); assert.equal(r.json().error,code,`${code}: ${r.text}`); - assert.doesNotMatch(r.json().message,/(^|\. )Nothing was applied/,`${code} does not claim nothing changed`); - assert.match(r.json().message,/If the inbox shows this proposal as answered, part of it may already be applied: check the Agents page/); + assert.doesNotMatch(r.json().message,/nothing was applied/i,`${code} does not claim nothing changed`); + assert.doesNotMatch(r.json().message,/inbox/,`${code} does not point at the inbox, which drops a consumed decision`); + assert.match(r.json().message,/Part of it may already be applied: check the Agents page/); } for(const code of ['organization-busy','fire-in-progress','credential-revoking']) { const {r}=await startWith(new BusReadError(code)); @@ -365,8 +367,8 @@ test('E2E6 hire start: an apply-phase decision-stale on a fire does not say noth const s=setup({resolve:()=>{throw new BusReadError('decision-stale');}}); const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{op:'fire',level:'project',project:'site',name:'coder',reason:'done'}}})); assert.equal(s.calls.length,1);assert.equal(r.status,409,r.text);assert.equal(r.json().error,'decision-stale'); - assert.doesNotMatch(r.json().message,/(^|\. )Nothing was applied/); - assert.doesNotMatch(r.json().message,/answer it there/,'a consumed decision cannot be answered again'); + assert.doesNotMatch(r.json().message,/nothing was applied/i); + assert.doesNotMatch(r.json().message,/inbox|answer it there/,'a consumed decision is gone from the inbox'); assert.match(r.json().message,/check the Agents page/); }); test('E2E6 hire start: invalid-request from the bus says the values, not the shape, were refused',async()=>{ diff --git a/packages/webui/tests/org-live-edit-browser.test.mjs b/packages/webui/tests/org-live-edit-browser.test.mjs index 884520c96..5287a60e7 100644 --- a/packages/webui/tests/org-live-edit-browser.test.mjs +++ b/packages/webui/tests/org-live-edit-browser.test.mjs @@ -120,7 +120,7 @@ test('E2E6 P2-2: a pack apply re-reads the other sections and keeps its receipt' assert.match(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), /^Applied\. Added board\./, 'the receipt survives the re-read'); }); -test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held form, and a focused button does not hold its section', { timeout: 60000 }, async t => { +test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held form, a focused button does not hold its section, and a clean form shows a failed read', { timeout: 60000 }, async t => { const { b, ctl, refresh } = await fixture(t); await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')&&document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-preview-button]')`, 'sections'); // A focused Preview button alone no longer holds the packs section: the next poll shows a changed @@ -150,4 +150,15 @@ test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held f assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'out of the loop', 'the stored value is back'); assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]').hasAttribute('data-live-dirty')`), false); assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-autonomy-discard')`), true, 'focus stays on Discard'); + // Re-check P3: a focused button in a clean form gets its focus back after the poll repaints it. + await refresh({ poll: true }); + assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'autonomy-discard:acme', 'focus returns to Discard after a poll'); + // Re-check P3: a clean form the page does not hold shows a failed read instead of hiding it. + await b.evaluate(`document.activeElement.blur();true`); + ctl.autonomyFail = true; + await refresh({ poll: true }); + await waitFor(b, `!document.querySelector('[data-autonomy-form]')&&/bus-unreachable|could not|refused/i.test(document.querySelector('[data-autonomy]')?.textContent??'')`, 'the autonomy read error'); + ctl.autonomyFail = false; + await refresh({ poll: true }); + await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')`, 'the form after a good read'); }); -- 2.54.0