Fix S7 claim liveness and project layout fidelity

This commit is contained in:
jason.woltje committed 2026-11-06 23:55:17 -06:00
1 parent 31de69f2f6
commit b74c8c88c1
5 files changed
+60 -8

No files matched your search

+6 -4
View File
@@ -594,7 +594,7 @@
}
// Row 278: a claims refusal belongs to this section. Only sign-out reaches
// the global handler; a 403 here must not forget the successful role read.
async function threadClaimsSection(get, holders, threads, runsKnown) {
async function threadClaimsSection(get, holders, threads, runsKnown, running) {
await configRead;
if (isTester) return '';
const title = '<h2 id="b-thread-claims">Thread claims</h2>';
@@ -606,7 +606,8 @@
const name = threads.find(t => t.id === c.thread)?.name;
const holder = holders.get(c.holder_run);
const thread = name ? `${txt(name)}<span class="source">${txt(short(c.thread))}</span>` : txt(c.thread);
const run = holder ? `${txt(holder)}<span class="source">run ${txt(short(c.holder_run))}</span>` : `run ${txt(c.holder_run)}${runsKnown ? ', not running' : ', running status unavailable'}`;
const status = !runsKnown ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running';
const run = holder ? `${txt(holder)}<span class="source">run ${txt(c.holder_run)}${status}</span>` : `run ${txt(c.holder_run)}${status}`;
return `<tr><th scope="row">${thread}</th><td>${run}</td><td>${txt(c.harness)}</td><td>${when(c.at)}</td></tr>`;
}).join('');
return `<section aria-labelledby="b-thread-claims">${title}${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Thread claims, scroll for all columns"><table class="s1-table s1-thread-claims"><thead><tr>${['Thread', 'Holder run', 'Harness', 'Claimed at'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No thread claim is held right now.', 'A thread appears while a run holds its claim on the bus.')}</section>`;
@@ -628,9 +629,10 @@
const holders = new Map();
if (instances && typeof instances === 'object') for (const [name, instance] of Object.entries(instances))
for (const run of [...(Array.isArray(instance.running) ? instance.running : []), ...(Array.isArray(instance.alive) ? instance.alive : [])]) holders.set(run, name);
const running = new Set(holders.keys());
for (const claim of claims) if (claim.holder_run && claim.role && !holders.has(claim.holder_run)) holders.set(claim.holder_run, claim.role);
const threadSection = await threadClaimsSection(get, holders, threads, !!instances);
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(short(c.holder_run))}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
return `${head('Agents', readNote(get))}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
+21 -2
View File
@@ -140,7 +140,7 @@
.project-stepper span { position:relative; flex-direction:column; gap:4px; min-width:0; text-align:center; border-radius:8px; }
.project-stepper span b { font-size:12px; font-weight:600; line-height:1.25; overflow-wrap:normal; }
.project-stepper span i { font-size:20px; }
@media(min-width:700px) { .project-stepper span:not(:last-child)::after { content:''; position:absolute; top:16px; left:calc(50% + 26px); width:calc(100% - 52px); border-top:1px solid var(--line); } }
.project-work-row { display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid var(--line); min-height:44px; }
.project-work-row > span { min-width:0; }
.agent-start-hint { display:inline-block; color:var(--muted); font-size:12px; }
@@ -273,7 +273,9 @@ body:has(.project-chat) #bus-view, body:has(.project-chat) .chat-area { flex:1 1
@media(max-width:699px) { .project-heading p { font-size:14px; } }
.project-tree-children a { padding-left:40px; }
.project-tree-children a span,.project-tree-children a small { max-width:126px; }
#sections:has(#project-tree) { grid-template-columns:minmax(0,1fr); }
#sections > li:has(#project-tree),.project-tree-list,.project-tree-list > li,.project-tree-children,.project-tree-children > li { min-width:0; }
.project-tree-children a span,.project-tree-children a small { max-width:100%; }
/* Align the conversation header and reserve the specified composer height. */
body:has(.project-chat) .breadcrumbs { margin-bottom:-6px; }
.project-header[data-project-conversation="true"] .project-heading { align-items:flex-start; }
@@ -310,3 +312,20 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
.project-chat .chat-audience .room-chip { min-height:28px; font-size:13px; font-weight:600; }
.project-chat #chat-send-form > textarea { min-height:32px; height:32px; }
@media(max-width:699px) { .project-chat #chat-send-form { min-height:128px; } }
/* Desktop stepper follows the horizontal request phase treatment. */
.project-step-separator { display:none; }
@media(min-width:700px) {
.project-stepper { display:flex; align-items:center; justify-content:space-between; gap:4px; }
.project-stepper span { flex-direction:row; gap:6px; white-space:nowrap; }
.project-stepper span b { font-size:14px; }
.project-stepper .current { border-radius:999px; }
.project-stepper .done b { color:var(--text); }
.project-stepper .current i { color:var(--action); }
.project-step-separator { display:block; width:16px; height:16px; flex:none; fill:none; stroke:var(--muted); stroke-width:1.5; }
.project-chat .chat-message-agent .chat-message-meta { gap:12px; }
.project-chat .chat-log > li.chat-message-agent > .chat-body { width:min(744px,calc(100% - 48px)); max-width:min(744px,calc(100% - 48px)); box-sizing:border-box; }
}
.project-chat .chat-message-meta .chat-avatar { font-size:14px; }
.project-chat #chat-send-form > textarea { padding:4px 0; border:0; border-top:1px solid var(--line); border-radius:0; background:transparent; }
.project-chat #chat-send-form > textarea::placeholder { color:var(--muted); opacity:1; }
+1 -1
View File
@@ -138,7 +138,7 @@
function stepper(s) {
if(!s) return '';
const idx=({selection:0,discovery:0,'prd-review':0,planning:0,implementation:1,review:2,delivery:3,complete:4,delivered:4,cancelled:-1})[s.phase] ?? -1;
return `<div class="project-stepper" aria-label="Request phase">${['Requirements','Build','Review','Deliver'].map((name,i)=>`<span class="${i<idx?'done':i===idx?'current':''}"><i aria-hidden="true">${i<idx?'✓':i===idx?'◉':'○'}</i><b>${name}</b></span>`).join('')}</div>`;
return `<div class="project-stepper" aria-label="Request phase">${['Requirements','Build','Review','Deliver'].map((name,i)=>`<span class="${i<idx?'done':i===idx?'current':''}"><i aria-hidden="true">${i<idx?'✓':i===idx?'◉':'○'}</i><b>${name}</b></span>`).join('<svg class="project-step-separator" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5"/></svg>')}</div>`;
}
const conversationLink=t=>`#/projects/threads/${encodeURIComponent(t.id)}`;
const taskLink=(ref,data)=>{const row=data?.tasks?.find(x=>x.task_ref===ref);const f=row?.fields||{};return `<a href="#/tasks/${encodeURIComponent(ref)}">${clean(ref)}${typeof f.title==='string'?' · '+clean(f.title):''}</a>${f.done===true?' · Done':''}`;};
+1 -1
View File
@@ -271,7 +271,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
// Agents: one row per held role, with its harness and address; the gaps are labelled.
await go('#/agents', 'Agents');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']);
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/);
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-run, running status unavailableclaude-codecto-thread/);
// Row 59 (#1548): the state mark and Input needed per role. Coder's open push decision waits on the human.
const agentRow = role => b.evaluate(`(() => { const r = [...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r => r.querySelector("th").textContent === ${JSON.stringify(role)}); return [...r.querySelectorAll("td")].map(td => td.textContent); })()`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents thead th")].map(e=>e.textContent)'), ['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since']);
@@ -60,3 +60,34 @@ test('sidebar child labels truncate and mobile business role names remain whole'
const role=await b.evaluate(`(()=>{const e=document.querySelector('.s1-roles tbody th'),s=getComputedStyle(e),r=e.getBoundingClientRect();return {wrap:s.overflowWrap,word:s.wordBreak,width:r.width,scroll:e.scrollWidth}})()`);
assert.equal(role.wrap,'normal'); assert.equal(role.word,'normal'); assert.ok(role.width>=role.scroll,JSON.stringify(role));
});
const staleFixture = `;{const old=window.fetch;window.instancesFail=false;window.fetch=(url,init)=>{const p=String(url),json=(v,status=200)=>Promise.resolve(new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}));if(p==='/api/bus/agents')return json({rows:[{role:'chief',holder_run:'r-old-chief-123456',harness:'pi',at:'2026-01-01T00:00:00Z',state:'idle'}]});if(p==='/api/bus/thread.claims')return json({rows:[{thread:'${T}',holder_run:'r-old-chief-123456',harness:'pi',at:'2026-01-01T00:00:00Z'}]});if(p==='/api/bus/instances')return window.instancesFail?json({error:'unavailable'},503):json({rows:[{instances:{chief:{running:['r-new-chief-654321']}}}]});return old(url,init)}}`;
for (const failed of [false,true]) test(`stale role and thread holders disclose ${failed?'unavailable':'stopped'} running status`, async t => {
const {b,wait}=await setup(t,'/agents',staleFixture + (failed?';window.instancesFail=true;':''));
await wait(`document.querySelector('.s1-thread-claims tbody tr')`);
const cells=await b.evaluate(`['[aria-labelledby="b-claims"] tbody td:nth-child(4)','.s1-thread-claims tbody td:nth-child(2)'].map(s=>document.querySelector(s).textContent)`);
for(const cell of cells) { assert.match(cell,/chief/); assert.match(cell,/r-old-ch/); assert.match(cell,failed?/running status unavailable/:/not running/); }
});
test('empty composer line fits at desktop and mobile widths',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T); await wait(`document.querySelector('#chat-body')`);
for(const width of [1440,390]) { await b.viewport(width,width===390?900:1000); const x=await b.evaluate(`(()=>{const e=document.querySelector('#chat-body'),s=getComputedStyle(e),r=e.getBoundingClientRect();return {client:e.clientHeight,scroll:e.scrollHeight,line:parseFloat(s.lineHeight),top:parseFloat(s.paddingTop),bottom:parseFloat(s.paddingBottom),height:r.height,borderTop:parseFloat(s.borderTopWidth),borderBottom:parseFloat(s.borderBottomWidth),placeholder:e.placeholder}})()`); assert.ok(x.placeholder,JSON.stringify(x)); assert.ok(x.scroll<=x.client,JSON.stringify(x)); assert.ok(x.top+x.line+x.bottom<=x.height-x.borderTop-x.borderBottom+0.1,JSON.stringify(x)); }
});
test('stepper uses inline desktop labels and mobile columns on conversation and overview',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);
for(const route of ['/projects/threads/'+T,'/projects/'+P]) { await b.evaluate(`location.hash='#${route}'`); await wait(route.includes('/threads/') ? `document.querySelector('.project-chat .project-stepper .current')` : `document.querySelector('.project-work .project-stepper .current')`);
for(const width of [1440,390]) {await b.viewport(width,width===390?900:1000); const x=await b.evaluate(`(()=>{const root=document.querySelector('.project-stepper'),steps=[...root.querySelectorAll(':scope > span')],s=steps[0],i=s.querySelector('i').getBoundingClientRect(),b=s.querySelector('b'),r=b.getBoundingClientRect(),cs=getComputedStyle(b),cur=getComputedStyle(root.querySelector('.current'));return {font:cs.fontSize,weight:cs.fontWeight,dir:getComputedStyle(s).flexDirection,delta:Math.abs(i.top+i.height/2-r.top-r.height/2),separators:root.querySelectorAll('.project-step-separator').length,radius:cur.borderRadius,columns:steps.map(e=>e.getBoundingClientRect().width)}})()`); if(width===1440){assert.equal(x.font,'14px');assert.equal(x.weight,'600');assert.equal(x.dir,'row');assert.ok(x.delta<1,JSON.stringify(x));assert.equal(x.separators,3);assert.ok(parseFloat(x.radius)>=16,JSON.stringify(x));}else{assert.equal(x.dir,'column');assert.ok(Math.max(...x.columns)-Math.min(...x.columns)<1,JSON.stringify(x));} }
}
});
test('expanded tree stays within sidebar on conversation and room',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);
for(const route of ['/projects/threads/'+T,'/projects/rooms/'+P]) {await b.evaluate(`location.hash='#${route}'`); await wait(route.includes('/rooms/') ? `document.querySelector('#chat-room-send-form')` : `document.querySelector('#chat-send-form')`);await wait(`document.querySelector('.project-tree-children:not([hidden]) a[href$="conversations"] span')`);const x=await b.evaluate(`(()=>{const tree=document.querySelector('nav.tree'),e=document.querySelector('.project-tree-children a[href$="conversations"] span');return {client:tree.clientWidth,scroll:tree.scrollWidth,right:e.getBoundingClientRect().right,ellipsis:getComputedStyle(e).textOverflow}})()`);assert.ok(x.scroll<=x.client,JSON.stringify(x));assert.ok(x.right<=206,JSON.stringify(x));assert.equal(x.ellipsis,'ellipsis');}
});
test('desktop agent message matches avatar and card geometry',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);await wait(`document.querySelector('.chat-message-agent .chat-body') && document.querySelector('.project-request-item .request-panel')`);
const x=await b.evaluate(`(()=>{const m=document.querySelector('.chat-message-agent'),a=m.querySelector('.chat-avatar'),name=m.querySelector('strong'),bubble=m.querySelector('.chat-body').getBoundingClientRect(),card=document.querySelector('.project-request-item .request-mount').getBoundingClientRect(),s=getComputedStyle(a);return {font:s.fontSize,weight:s.fontWeight,name:name.getBoundingClientRect().left,left:bubble.left,right:bubble.right,width:bubble.width,cardRight:card.right}})()`);
assert.equal(x.font,'14px');assert.equal(x.weight,'700');assert.ok(Math.abs(x.name-296)<1,JSON.stringify(x));assert.ok(Math.abs(x.left-296)<1&&Math.abs(x.width-744)<1&&Math.abs(x.right-x.cardRight)<1,JSON.stringify(x));
});