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".
This commit is contained in:
jason.woltje committed 2026-11-13 19:52:26 -06:00
1 parent 0c35eeed9d
commit 266f3a45ff
10 files changed
+127 -39

No files matched your search

+26 -22
View File
@@ -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', '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 // 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 // own code and a next step. hire.start.console runs prepare() (hire-decisions.mjs) before it records
// prepare() before it records the decision, then resolves and applies it in #hireConsole. Every // the decision, then #hireConsole checks it, consumes it, and applies it. apply() runs check() again
// check up to the consume transaction changes nothing, so those codes say "Nothing was applied." // and the writers, authorityHost and #fireApproved run after the consume, so most codes can come from
// (the recorded proposal, if one was recorded, stays open in the inbox). Codes the apply step can // either side of it; for a fire the agent's runs may already be stopped. The code alone does not say
// raise after the decision is consumed say what to check instead. organization-uncertain, a lost // which, so those say how to tell: a consumed decision shows as answered in the inbox. Only codes
// reply and the bus server's own request codes (request-timeout and invalid-envelope can fire while // that prepare() alone raises say "Nothing was applied.": the route checks (checkRoutes runs in
// the request is processing) stay outcome-unknown. // 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 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 CHECK = ' Check the Agents page and the inbox before trying again.';
const HIRE_START_REFUSALS = { 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], '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], '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-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-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-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.' + NONE], '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.' + NONE], '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.' + NONE], '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], '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], '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.' + NONE], '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.' + NONE], '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.' + NONE], '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.' + NONE], '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.' + NONE], '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.' + NONE], '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; if the inbox lists this proposal, answer it there.' + NONE], '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.' + NONE], '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.' + NONE], '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], '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], 'fire-in-progress': [409, 'A fire of this agent is already in progress.' + CHECK],
'instance-fired': [409, 'This agent was fired.' + 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], '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 // 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. // 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 // 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. // codes. Anything else that reads as a code is a definite refusal.
+5 -2
View File
@@ -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; 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. // 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) { function section(org) {
const s=states.get(org); const s=states.get(org);
if(!s || s.error)return `<section class="panel" data-autonomy><h3>Autonomy</h3><p>${esc(s?.error??'Autonomy could not be read.')}</p></section>`; if(!s || s.error)return `<section class="panel" data-autonomy><h3>Autonomy</h3><p>${esc(s?.error??'Autonomy could not be read.')}</p></section>`;
@@ -35,7 +36,7 @@
<div class="autonomy-fields"><label>Reverse window (hours)<input type="number" name="reverseWindowHours" min="1" max="168" value="${v.reverseWindowHours??24}"${s.writable?'':' disabled'}></label><label>Authority resolver<input name="authorityResolver" maxlength="64" value="${esc(v.authorityResolver??'')}" autocomplete="off"${s.writable?'':' disabled'}></label></div> <div class="autonomy-fields"><label>Reverse window (hours)<input type="number" name="reverseWindowHours" min="1" max="168" value="${v.reverseWindowHours??24}"${s.writable?'':' disabled'}></label><label>Authority resolver<input name="authorityResolver" maxlength="64" value="${esc(v.authorityResolver??'')}" autocomplete="off"${s.writable?'':' disabled'}></label></div>
<label class="autonomy-checkbox"><input name="notices" type="checkbox"${v.notices===true?' checked':''}${s.writable?'':' disabled'}> Console notices for decisions near the loop</label> <label class="autonomy-checkbox"><input name="notices" type="checkbox"${v.notices===true?' checked':''}${s.writable?'':' disabled'}> Console notices for decisions near the loop</label>
<p>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.</p> <p>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.</p>
${s.writable?'<button class="btn" type="submit">Save autonomy</button>':'<p>Only the owner can change autonomy.</p>'} ${s.writable?'<p><button class="btn" type="submit">Save autonomy</button> <button class="btn" type="button" data-autonomy-discard>Discard changes</button></p>':'<p>Only the owner can change autonomy.</p>'}
<p data-autonomy-status role="status">${esc(s.status)}</p></form> <p data-autonomy-status role="status">${esc(s.status)}</p></form>
${s.writable?`<details data-autonomy-review-list${s.reviewOpen?' open':''}><summary>Review reversals before moving out of the loop</summary><p>Read the decision trail and its reversals before recording your review.</p>${classes.map(k=>`<button class="btn" type="button" data-autonomy-review="${k}">Record review: ${labels[k]}</button>`).join('')}</details>`:''}</section>`; ${s.writable?`<details data-autonomy-review-list${s.reviewOpen?' open':''}><summary>Review reversals before moving out of the loop</summary><p>Read the decision trail and its reversals before recording your review.</p>${classes.map(k=>`<button class="btn" type="button" data-autonomy-review="${k}">Record review: ${labels[k]}</button>`).join('')}</details>`:''}</section>`;
} }
@@ -59,6 +60,8 @@
// An edited form is dirty until it saves: holdLive keeps a dirty form through every poll. // 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';}; 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('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('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=>{ document.addEventListener('click',async e=>{
const button=e.target.closest('[data-autonomy-confirm],[data-autonomy-reverse],[data-autonomy-review]');if(!button)return; const button=e.target.closest('[data-autonomy-confirm],[data-autonomy-reverse],[data-autonomy-review]');if(!button)return;
+5 -2
View File
@@ -87,12 +87,15 @@
.chat-area:has(.project-header),.chat-area:has(.project-list-head) { max-width:none; margin-inline:0; } .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; } .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 /* 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. */ this way". Where scroll timelines exist the fade runs only while there is more to scroll: a strip that
@property --tabs-fade { syntax:'<length>'; inherits:false; initial-value:48px; } 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:'<length>'; inherits:false; initial-value:0px; }
@keyframes project-tabs-cue { 0%, 92% { --tabs-fade:48px; } 100% { --tabs-fade:0px; } } @keyframes project-tabs-cue { 0%, 92% { --tabs-fade:48px; } 100% { --tabs-fade:0px; } }
@media (max-width:699px) { @media (max-width:699px) {
.project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - var(--tabs-fade)), transparent 100%); } .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 (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; } .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; } } @media(max-width:699px) { .project-stepper span { white-space:nowrap; font-size:10.5px; padding:4px 1px; }.project-work-head { align-items:flex-start; } }
+6 -1
View File
@@ -48,7 +48,12 @@
async function load(org) { async function load(org) {
const prev = states.get(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 ?? '' }; 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); 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]])]; const scopes = s => [['', 'Organization', s.data.workflows], ...Object.keys(s.data.projects).sort().map(p => [p, `Project ${p}`, s.data.projects[p]])];
+6 -1
View File
@@ -198,11 +198,16 @@
// or holds focus is kept: holdLive takes it before the repaint and graftLive puts that same node, with // 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 // 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. // 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) { function holdLive(root) {
const held = new Map(), a = document.activeElement; const held = new Map(), a = document.activeElement;
if (!root) return { held, active: null }; 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]')) { 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)) 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; ? { el: a, caret: typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null } : null;
+23 -3
View File
@@ -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/]]) { 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)); const {r,s}=await startWith(new BusReadError(code));
assert.equal(r.status,409,`${code}: ${r.text}`); 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}$`)); 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()=>{ 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)); 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']) { for(const code of ['organization-busy','fire-in-progress','credential-revoking']) {
const {r}=await startWith(new BusReadError(code)); 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')); 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/); 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()=>{ 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')); 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/); assert.equal(r.status,400);assert.equal(r.json().error,'invalid-request');assert.match(r.json().message,/Settings > Model proxy/);
@@ -1,7 +1,7 @@
// E2E6 P2-11, P2-12, P2-13 (#1873): layout pins measured in a real browser. // 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-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 // 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. // 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 test from 'node:test';
import assert from 'node:assert/strict'; 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 b.evaluate(`{const e=document.querySelector('.project-tabs');e.scrollLeft=e.scrollWidth}true`);
await frames(b); await frames(b);
assert.equal((await read()).fade, '0px', 'no fade once the row is scrolled to its end'); 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 = { const org = {
@@ -13,6 +13,8 @@ const REV = n => String(n).repeat(64);
async function fixture(t) { async function fixture(t) {
const counts = { workflows: 0, autonomy: 0, packs: 0 }; 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 = { const flows = {
revision: REV('a'), revision: REV('a'),
document: { document: {
@@ -23,9 +25,9 @@ async function fixture(t) {
templates: ['coder'], templates: ['coder'],
}; };
const read = async verb => { 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 === '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' }); throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
}; };
const write = async (verb, args) => { const write = async (verb, args) => {
@@ -38,7 +40,7 @@ async function fixture(t) {
list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }],
resolve: async () => ({ resolve: async () => ({
busHealthy: true, decisionWrite: async () => ({}), requestRead: read, requestWrite: read, packsWrite: write, 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: [] }) }, 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: [] }) }, bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
chat: { projects: async () => [], threads: async () => [] }, chat: { projects: async () => [], threads: async () => [] },
@@ -59,7 +61,7 @@ async function fixture(t) {
assert.ok(counts.workflows > n, 'the refresh read the workflows again'); assert.ok(counts.workflows > n, 'the refresh read the workflows again');
await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),200))`); 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 => { 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))`); 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'); 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');
});
@@ -26,13 +26,18 @@ async function accessibleName(b, selector) {
for (const width of [1440,390]) { for (const width of [1440,390]) {
test(`S8 project action icons, accessible names and positions at ${width}`, async t => { test(`S8 project action icons, accessible names and positions at ${width}`, async t => {
const {b,wait} = await setup(t,width); 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]; const y = width === 1440 ? [130,174] : [198,242];
for (const route of ['', '/conversations', '/tasks', '/requirements', '/files', '/settings', ...(width === 1440 ? ['/room'] : [])]) { for (const route of ['', '/conversations', '/tasks', '/requirements', '/files', '/settings', ...(width === 1440 ? ['/room'] : [])]) {
if (route) { if (route) {
await b.evaluate(`location.hash=${JSON.stringify(route === '/room' ? '#/projects/rooms/'+P : '#/projects/'+P+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))}`); 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()) { for (const [i,name] of ['North star','PRD','Settings'].entries()) {
const selector = `.project-header .project-primary-action:nth-of-type(${i+1})`; 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}})()`); 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}})()`);
@@ -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=>{ 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 {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_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)); 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])}`); 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 // 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. // action must align to its section when that puts the target right edge out of reach.