Fix S7 claim liveness and project layout fidelity
This commit is contained in:
1 parent
31de69f2f6
commit
b74c8c88c1
5 files changed
+60
-8
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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':''}`;};
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user