fix(webui): settings review fixes (ui slice 2)
This commit is contained in:
1 parent
a06d792ff9
commit
65df511f38
9 files changed
+152
-73
No files matched your search
@@ -19,7 +19,7 @@
|
||||
const reasons = {};
|
||||
const usable = () => !!ctx && (ctx.canSend || ctx.isTester) && !ctx.out();
|
||||
const human = () => !!ctx && ctx.canSend && !ctx.isTester;
|
||||
const redraw = () => { if (ctx && !ctx.out()) ctx.render(); };
|
||||
const redraw = () => { if (ctx && !ctx.out()) return ctx.render(); };
|
||||
const reread = () => { if (ctx && !ctx.out()) ctx.refresh(); };
|
||||
|
||||
let editing = null;
|
||||
@@ -27,22 +27,23 @@
|
||||
ctx = c; view = v;
|
||||
const { txt, esc } = c, title = '<h2 id="agents-title" class="sr-only">Agents</h2>';
|
||||
if (!v) return `<section class="panel" id="agents-section">${title}<p class="small warn">The agents could not be read, so there is nothing to act on. Read again to retry.</p></section>`;
|
||||
const open=usable(), off=!open||busy||pending||c.instanceBusy?' disabled':'', defs=Object.keys(v.definitions||{}), accepted=defs.filter(d=>v.definitions[d]?.accepted);
|
||||
const open=usable(), off=!open||busy||pending?' disabled':'', addOff=!open||busy||pending||c.instanceBusyOps.includes('add')?' disabled':'', changeOff=!open||busy||pending||c.instanceBusyOps.includes('change')?' disabled':'', defs=Object.keys(v.definitions||{}), accepted=defs.filter(d=>v.definitions[d]?.accepted);
|
||||
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>`;};
|
||||
const option=(name,selected,first)=>`<option value="">${first}</option>${accepted.map(d=>`<option value="${esc(d)}"${d===selected?' selected':''}>${txt(d)}</option>`).join('')}`;
|
||||
const button=(action,name,label)=>`<button type="button"${['keep','unkeep'].includes(action)?' role="menuitem"':''} data-agent-action="${esc(action)}" data-agent-name="${esc(name)}"${off}>${txt(label)}</button>`;
|
||||
const rows=Object.entries(v.instances).map(([name,e])=>{
|
||||
const running=Array.isArray(e.running)?e.running:[], alive=Array.isArray(e.alive)?e.alive:[];
|
||||
const menu=open?`<button type="button" class="agent-more" aria-haspopup="menu" aria-expanded="false" aria-label="More actions for ${esc(name)}"${off}>⋯</button><div class="agent-menu" role="menu" hidden>${!c.isTester&&!e.protected?`<button type="button" role="menuitem" data-agent-edit="${esc(name)}">Edit</button>`:''}<button type="button" role="menuitem" data-agent-dialog="end" data-agent-name="${esc(name)}">Terminate…</button>${!c.isTester?`<button type="button" role="menuitem" data-agent-dialog="regenerate" data-agent-name="${esc(name)}">Regenerate…</button>${button(e.kept?'unkeep':'keep',name,e.kept?'Unkeep':'Keep')}${!e.endProtected?`<hr><button type="button" role="menuitem" class="agent-danger" data-agent-dialog="delete" data-agent-name="${esc(name)}">Delete…</button>`:''}`:''}</div>`:'';
|
||||
const d=c.instanceDrafts.change?.instance===name?c.instanceDrafts.change:{};
|
||||
const editor=editing===name?`<tr class="agent-editor"><td colspan="6"><form id="instance-change-form" class="instance-form" data-op="change"><input type="hidden" data-instance-field="instance" value="${esc(name)}"><label>Definition <select data-instance-field="definition"${off}>${option(name,d.definition,'Keep the definition')}</select></label>${v.launch?`<label>Launch <select data-instance-field="launch"${off}><option value="">Keep</option><option value="yes"${d.launch==='yes'?' selected':''}>Launch it</option><option value="no"${d.launch==='no'?' selected':''}>Don't launch it</option></select></label>`:''}<details><summary>Vars as JSON</summary><p>Harness, model and thinking are set in Routing.</p><textarea data-instance-field="vars" rows="3"${off}>${esc(d.vars??'')}</textarea></details><div class="settings-actions${c.instanceDrafts.change?' has-draft':''}"><button type="submit"${off}>${c.instanceBusy?'Saving…':'Save agent'}</button><button type="button" data-instance-discard${c.instanceDrafts.change?'':' hidden'}${off}>Discard draft</button></div></form></td></tr>`:'';
|
||||
return `<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launcher?'Launcher':e.launched?'Yes':'No'}</td><td>${e.kept?'Yes':'No'}</td><td>${running.length?running.map(r=>`<code>${txt(r)}</code>`).join(' '):'<span class="muted">None</span>'}${alive.map(r=>` <strong class="agent-alive">still running after fire: <code>${txt(r)}</code></strong>`).join('')}</td><td class="agent-actions">${open?button('launch',name,'Launch'):''}${menu}</td></tr>${editor}`;
|
||||
const editor=editing===name?`<tr class="agent-editor"><td colspan="6"><form id="instance-change-form" class="instance-form" data-op="change"><input type="hidden" data-instance-field="instance" value="${esc(name)}"><label>Definition <select data-instance-field="definition"${changeOff}>${option(name,d.definition,'Keep the definition')}</select></label>${v.launch?`<label>Launch <select data-instance-field="launch"${changeOff}><option value="">Keep</option><option value="yes"${d.launch==='yes'?' selected':''}>Launch it</option><option value="no"${d.launch==='no'?' selected':''}>Don't launch it</option></select></label>`:''}<details id="instance-change-vars"><summary>Vars as JSON</summary><p>Harness, model and thinking are set in Routing.</p><label><span class="sr-only">Vars as JSON</span><textarea data-instance-field="vars" rows="3"${changeOff}>${esc(d.vars??'')}</textarea></label></details><div class="settings-actions${c.instanceDrafts.change?' has-draft':''}">${instanceStatus('change')}<button type="submit"${changeOff}>${c.instanceBusyOps.includes('change')?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save agent'}</button><button type="button" data-instance-discard${c.instanceDrafts.change?'':' hidden'}${changeOff}>Discard draft</button></div></form></td></tr>`:'';
|
||||
return `<tr><th scope="row">${txt(name)}</th><td headers="agent-col-definition"><span class="agent-mobile-label" aria-hidden="true">Definition: </span>${txt(e.definition)}</td><td headers="agent-col-launch"><span class="agent-mobile-label" aria-hidden="true">Launch: </span>${e.launcher?'Launcher':e.launched?'Yes':'No'}</td><td headers="agent-col-kept"><span class="agent-mobile-label" aria-hidden="true">Kept: </span>${e.kept?'Yes':'No'}</td><td headers="agent-col-running"><span class="agent-mobile-label" aria-hidden="true">Running: </span>${running.length?running.map(r=>`<code>${txt(r)}</code>`).join(' '):'<span class="muted">None</span>'}${alive.map(r=>` <strong class="agent-alive">still running after fire: <code>${txt(r)}</code></strong>`).join('')}</td><td class="agent-actions">${open?button('launch',name,'Launch'):''}${menu}</td></tr>${editor}`;
|
||||
}).join('');
|
||||
const d=c.instanceDrafts.add||{}, bound=defs.filter(n=>Array.isArray(v.definitions[n].services)&&v.definitions[n].services.length).map(n=>`${txt(n)} (${v.definitions[n].services.map(txt).join(', ')})`);
|
||||
const add=!c.isTester&&open?`<section class="panel"><h3>Add an agent</h3><form id="instance-add-form" class="instance-form" data-op="add"><div class="filters"><label>Name <input data-instance-field="instance" value="${esc(d.instance??'')}" maxlength="64"${off}></label><label>Definition <select data-instance-field="definition"${off}>${option('',d.definition,'Choose a definition')}</select></label>${v.launch?`<label><input type="checkbox" data-instance-field="launch"${d.launch?' checked':''}${off}> Launch it</label>`:''}</div><details><summary>Vars as JSON</summary><p>Harness, model and thinking are set in Routing.</p><textarea data-instance-field="vars" rows="3"${off}>${esc(d.vars??'')}</textarea></details><div class="settings-actions${c.instanceDrafts.add?' has-draft':''}"><button type="submit"${off}>${c.instanceBusy?'Saving…':'Add agent'}</button><button type="button" data-instance-discard${c.instanceDrafts.add?'':' hidden'}${off}>Discard draft</button></div></form>${bound.length?`<p>Definitions that declare credentials are a hand edit: ${bound.join(', ')}.</p>`:''}</section>`:'';
|
||||
const add=!c.isTester&&open?`<section class="panel"><h3>Add an agent</h3><form id="instance-add-form" class="instance-form" data-op="add"><div class="filters"><label>Name <input data-instance-field="instance" value="${esc(d.instance??'')}" maxlength="64"${addOff}></label><label>Definition <select data-instance-field="definition"${addOff}>${option('',d.definition,'Choose a definition')}</select></label>${v.launch?`<label><input type="checkbox" data-instance-field="launch"${d.launch?' checked':''}${addOff}> Launch it</label>`:''}</div><details id="instance-add-vars"><summary>Vars as JSON</summary><p>Harness, model and thinking are set in Routing.</p><label><span class="sr-only">Vars as JSON</span><textarea data-instance-field="vars" rows="3"${addOff}>${esc(d.vars??'')}</textarea></label></details><div class="settings-actions${c.instanceDrafts.add?' has-draft':''}">${instanceStatus('add')}<button type="submit"${addOff}>${c.instanceBusyOps.includes('add')?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Add agent'}</button><button type="button" data-instance-discard${c.instanceDrafts.add?'':' hidden'}${addOff}>Discard draft</button></div></form>${bound.length?`<p>Definitions that declare credentials are a hand edit: ${bound.join(', ')}.</p>`:''}</section>`:'';
|
||||
const noHire=Array.isArray(v.noHire)?v.noHire:[], retired=Array.isArray(v.retired)?v.retired:[];
|
||||
const hiring=!c.isTester?`<section class="panel"><h3>Hiring</h3><p>Turn hiring off to stop new agents of a definition.</p><div class="hiring-grid">${defs.map(n=>`<div>${open?`<button type="button" role="switch" aria-label="Hiring allowed for ${esc(n)}" aria-checked="${!noHire.includes(n)}" data-agent-action="${noHire.includes(n)?'hire':'nohire'}" data-agent-name="${esc(n)}"${off}><span class="switch-track"></span></button>`:`<span>${noHire.includes(n)?'Off':'On'}</span>`}<span class="hiring-name">${txt(n)}</span></div>`).join('')}</div>${retired.length?`<p>Retired names, never reused: ${retired.map(txt).join(', ')}.</p>`:''}</section>`:'';
|
||||
const state=pending?`<p id="agents-pending">Request <code>${txt(pending.request)}</code> to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.</p>`:'';
|
||||
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th>Agent</th><th>Definition</th><th>Launch</th><th>Kept</th><th>Running</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p><p role="status" id="instances-receipt">${txt(c.instanceReceipt)}</p></section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
|
||||
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
|
||||
}
|
||||
|
||||
// One POST. `null` means the page signed out; otherwise the status and the parsed answer.
|
||||
@@ -132,9 +133,9 @@
|
||||
if(t.id==='agent-dialog-cancel'){document.querySelector('#agent-dialog').close();dialogReturn?.focus();return;}
|
||||
if(t.id==='agent-dialog-confirm'){const dialog=document.querySelector('#agent-dialog');if(dialogAction==='end')reasons[dialogName]=document.querySelector('#agent-dialog-reason').value;dialog.close();act(dialogAction,dialogName);return;}
|
||||
if(!t.closest?.('#agents-section'))return;
|
||||
if(t.dataset.agentEdit!==undefined){editing=t.dataset.agentEdit;redraw();return;}
|
||||
if(t.dataset.agentEdit!==undefined){editing=t.dataset.agentEdit;closeMenu(t.closest('.agent-menu')?.previousElementSibling);redraw()?.then(()=>document.querySelector('#instance-change-form select')?.focus());return;}
|
||||
if(t.dataset.agentDialog){dialogAction=t.dataset.agentDialog;dialogName=t.dataset.agentName;dialogReturn=t.closest('.agent-menu').previousElementSibling;closeMenu(dialogReturn);const dialog=document.querySelector('#agent-dialog'),label={end:'Terminate',regenerate:'Regenerate',delete:'Delete'}[dialogAction];document.querySelector('#agent-dialog-title').textContent=`${label} ${dialogName}?`;document.querySelector('#agent-dialog-copy').textContent=dialogAction==='regenerate'?`Its workspace is renamed under .retired and it starts fresh.`:dialogAction==='delete'?`Its evidence and its workspace under .retired stay.`:'';document.querySelector('#agent-reason-label').hidden=dialogAction!=='end';document.querySelector('#agent-dialog-reason').value='';document.querySelector('#agent-dialog-confirm').textContent=label;dialog.showModal();(dialogAction==='end'?document.querySelector('#agent-dialog-reason'):document.querySelector('#agent-dialog-cancel')).focus();return;}
|
||||
const action=t.dataset.agentAction,name=t.dataset.agentName;if(action&&name!==undefined){const menu=t.closest('.agent-menu');if(menu)closeMenu(menu.previousElementSibling);act(action,name);}
|
||||
const actionButton=t.closest?.('[data-agent-action]');if(actionButton?.disabled)return;const action=actionButton?.dataset.agentAction,name=actionButton?.dataset.agentName;if(action&&name!==undefined){const menu=t.closest('.agent-menu');if(menu)closeMenu(menu.previousElementSibling);act(action,name);}
|
||||
});
|
||||
document.addEventListener('keydown',e=>{const menu=e.target.closest?.('.agent-menu'),more=e.target.closest?.('.agent-more');if(more && ['ArrowDown','Enter',' '].includes(e.key)){e.preventDefault();openMenu(more);return;}if(menu){const trigger=menu.previousElementSibling,items=[...menu.querySelectorAll('button')];if(e.key==='Escape'){e.preventDefault();closeMenu(trigger,true);}else if(e.key==='Tab')closeMenu(trigger);else if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();items[(items.indexOf(e.target)+(e.key==='ArrowDown'?1:-1)+items.length)%items.length]?.focus();}}});
|
||||
document.addEventListener('cancel',e=>{if(e.target.id==='agent-dialog'){e.preventDefault();e.target.close();dialogReturn?.focus();}},true);
|
||||
|
||||
@@ -704,12 +704,12 @@
|
||||
<section class="panel"><h3>Palette</h3><p>Colors for every page. Harbor is the default.</p><div class="palette-grid" role="radiogroup" aria-label="Palette">${Brand.palettes.map(p=>`<label class="palette-tile"><input type="radio" name="palette" value="${esc(p.id)}"${p.id===palette?' checked':''}><span class="palette-content"><span class="palette-swatches" data-swatch="${esc(p.id)}"><i></i><i></i><i></i></span><strong>${txt(p.name)}</strong><span class="palette-check" aria-hidden="true">✓</span></span></label>`).join('')}</div><p class="settings-browser-status" data-status="palette" role="status"></p></section>
|
||||
<section class="panel"><h3>Sidebar density</h3><p>How much space each sidebar row takes.</p>${segment('Density',[['comfortable','Comfortable'],['compact','Compact']],window.MosaicDisplay.get().density)}<p class="settings-browser-status" data-status="density" role="status"></p></section>`;
|
||||
}
|
||||
let browserStatusTimer;
|
||||
const browserStatusTimers = Object.create(null);
|
||||
function browserStatus(name, ok) {
|
||||
const el = document.querySelector(`[data-status="${name}"]`); if (!el) return;
|
||||
el.textContent = ok ? '✓ Saved in this browser.' : 'Could not save in this browser. The choice applies until you reload.';
|
||||
el.classList.toggle('warning', !ok); clearTimeout(browserStatusTimer);
|
||||
if (ok) browserStatusTimer = setTimeout(()=>{if(el.isConnected)el.textContent='';},4000);
|
||||
el.classList.toggle('warning', !ok); clearTimeout(browserStatusTimers[name]);
|
||||
if (ok) browserStatusTimers[name] = setTimeout(()=>{if(el.isConnected)el.textContent='';},4000);
|
||||
}
|
||||
document.addEventListener('change', e => {
|
||||
if (!e.target.closest?.('.settings-appearance')) return;
|
||||
@@ -803,17 +803,19 @@
|
||||
// Row 155 (#1649, Q169): each assistant instance's settings, its seat name and a stop. A save
|
||||
// sends only the fields that changed, with the read's revision. Drafts survive polling and
|
||||
// refusals as in the limits form, and nothing retries.
|
||||
let assistantDrafts=Object.create(null), nameDrafts=Object.create(null), assistantBusy=false, assistantReceipts=Object.create(null);
|
||||
let assistantDrafts=Object.create(null), nameDrafts=Object.create(null), assistantBusy=false, assistantReceipts=Object.create(null), assistantReceiptKinds=Object.create(null), assistantCardReceipts=Object.create(null);
|
||||
const assistantFormsBusy=new Set(), assistantKey=(instance,kind)=>`${instance}:${kind}`;
|
||||
const asShown=(entry,field)=>field==='definition'?entry.definition??'':entry[field]==null?'':JSON.stringify(entry[field],null,2);
|
||||
function assistantSection(value,names){
|
||||
if(!value||value.refused)return '';
|
||||
const v=takeSnapshot(value);if(!v.assistants||!Object.keys(v.assistants).length)return '<p class="empty">This business has no assistant instance.</p>';
|
||||
const disabled=!canSend||out()||assistantBusy?' disabled':'';
|
||||
const named=names?Object.fromEntries(names.map(r=>[r.instance,r.name])):null;
|
||||
const forms=Object.entries(v.assistants).map(([instance,entry])=>{
|
||||
const d=assistantDrafts[instance]||{}, val=f=>Object.hasOwn(d,f)?d[f]:asShown(entry,f), k=esc(instance);
|
||||
const name=Object.hasOwn(nameDrafts,instance)?nameDrafts[instance]:named?.[instance]??'';
|
||||
return `<form class="assistant-form" data-instance="${k}"><section class="panel"><h3>Seat name</h3><p>The name people see for this seat.</p><div class="filters"><label>Seat name <input data-assistant-field="name" value="${esc(name)}" maxlength="64" placeholder="No name set"${disabled}></label><button type="button" data-assistant-name="${k}"${disabled}>Save name</button>${named?'':' <span class="small muted">The current name could not be read.</span>'}</div></section><section class="panel"><h3>Settings</h3><label>Definition <input data-assistant-field="definition" value="${esc(val('definition'))}" maxlength="64"${disabled}></label><label>Vars as JSON <textarea data-assistant-field="vars" rows="4"${disabled}>${esc(val('vars'))}</textarea></label><p>Harness, model and thinking are set in Routing.</p><label>Tracker as JSON <textarea data-assistant-field="tracker" rows="4"${disabled}>${esc(val('tracker'))}</textarea></label><p>Leave empty to remove the tracker.</p><div class="settings-actions${assistantDrafts[instance]||Object.hasOwn(nameDrafts,instance)?' has-draft':''}"><button type="submit"${disabled}>${assistantBusy?'Saving…':'Save assistant settings'}</button><button type="button" data-assistant-discard="${k}"${assistantDrafts[instance]||Object.hasOwn(nameDrafts,instance)?'':' hidden'}${assistantBusy?' disabled':''}>Discard draft</button><p role="status" data-assistant-receipt="${k}">${txt(assistantReceipts[instance]||'')}</p></div></section><section class="panel"><h3>Runs</h3><p>Stop ends the runs of your threads. The assistant starts again when you next write to it.</p><button type="button" class="agent-danger" data-assistant-stop="${k}"${disabled}>Stop its runs</button></section></form>`;
|
||||
const busy=kind=>assistantFormsBusy.has(assistantKey(instance,kind)), disabledFor=kind=>!canSend||out()||busy(kind)?' disabled':'', disabled=disabledFor('settings'), nameDisabled=disabledFor('name'), stopDisabled=disabledFor('stop');
|
||||
const status=kind=>{const text=assistantCardReceipts[assistantKey(instance,kind)]||'', current=(assistantReceiptKinds[instance]||'settings')===kind;return `<p role="status" data-assistant-kind="${kind}"${current?` data-assistant-receipt="${k}"`:''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.includes('saved')||text.startsWith('Seat name set')||text.startsWith('Stopped')?'settings-success':''}">${txt(text)}</p>`;};
|
||||
return `<form class="assistant-form" data-instance="${k}"><section class="panel"><h3>Seat name</h3><p>The name people see for this seat.</p><div class="filters"><label>Seat name <input data-assistant-field="name" value="${esc(name)}" maxlength="64" placeholder="No name set"${nameDisabled}></label><button type="button" data-assistant-name="${k}"${nameDisabled}>${busy('name')?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save name'}</button>${named?'':' <span class="small muted">The current name could not be read.</span>'}</div>${status('name')}</section><section class="panel"><h3>Settings</h3><label>Definition <input data-assistant-field="definition" value="${esc(val('definition'))}" maxlength="64"${disabled}></label><label>Vars as JSON <textarea data-assistant-field="vars" rows="4"${disabled}>${esc(val('vars'))}</textarea></label><p>Harness, model and thinking are set in Routing.</p><label>Tracker as JSON <textarea data-assistant-field="tracker" rows="4"${disabled}>${esc(val('tracker'))}</textarea></label><p>Leave empty to remove the tracker.</p><div class="settings-actions${assistantDrafts[instance]||Object.hasOwn(nameDrafts,instance)?' has-draft':''}"><button type="submit"${disabled}>${busy('settings')?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save assistant settings'}</button><button type="button" data-assistant-discard="${k}"${assistantDrafts[instance]||Object.hasOwn(nameDrafts,instance)?'':' hidden'}${busy('settings')?' disabled':''}>Discard draft</button>${status('settings')}</div></section><section class="panel"><h3>Runs</h3><p>Stop ends the runs of your threads. The assistant starts again when you next write to it.</p><button type="button" class="agent-danger" data-assistant-stop="${k}"${stopDisabled}>${busy('stop')?'<span class="settings-spinner" aria-hidden="true"></span> Stopping…':'Stop its runs'}</button>${status('stop')}</section></form>`;
|
||||
}).join('');
|
||||
return `<div aria-labelledby="settings-page-title">${forms}</div>`;
|
||||
}
|
||||
@@ -832,37 +834,38 @@
|
||||
return {revision:settingsSnapshot.revision,assistants:{[instance]:patch}};
|
||||
}
|
||||
const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.';
|
||||
async function assistantPost(url,payload,instance,ok){
|
||||
assistantBusy=true;assistantReceipts[instance]='';render(false);
|
||||
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{assistantReceipts[instance]=unknownAssistant;}finally{assistantBusy=false;if(!out())render(false);}
|
||||
async function assistantPost(url,payload,instance,ok,kind='tester'){
|
||||
if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);
|
||||
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}
|
||||
}
|
||||
const assistantForm=el=>el?.closest?.('.assistant-form');
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};if(field){markSettingsDraft('assistant',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-assistant-discard]');if(discard)discard.hidden=false;}});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Enter' && e.target.matches?.('[data-assistant-field=name]')){e.preventDefault();e.target.closest('.assistant-form')?.querySelector('[data-assistant-name]')?.click();}});
|
||||
document.addEventListener('submit',async e=>{
|
||||
const form=assistantForm(e.target);if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out()||assistantBusy)return;const instance=form.dataset.instance;let payload;
|
||||
try{payload=assistantPayload(instance,form);}catch(err){assistantReceipts[instance]=err.message;render(false);return;}
|
||||
await assistantPost('/api/settings',payload,instance,result=>{if(!/^[a-f0-9]{64}$/.test(result?.revision??''))return unknownAssistant;delete assistantDrafts[instance];settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});return 'Assistant settings saved.';});
|
||||
const form=assistantForm(e.target);if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out())return;const instance=form.dataset.instance;if(assistantFormsBusy.has(assistantKey(instance,'settings')))return;let payload;
|
||||
try{payload=assistantPayload(instance,form);}catch(err){assistantReceiptKinds[instance]='settings';assistantReceipts[instance]=err.message;assistantCardReceipts[assistantKey(instance,'settings')]=err.message;render(false);return;}
|
||||
await assistantPost('/api/settings',payload,instance,result=>{if(!/^[a-f0-9]{64}$/.test(result?.revision??''))return unknownAssistant;delete assistantDrafts[instance];settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});return 'Assistant settings saved.';},'settings');
|
||||
});
|
||||
document.addEventListener('click',async e=>{
|
||||
const form=assistantForm(e.target);if(!form||!canSend||out()||assistantBusy)return;const instance=form.dataset.instance;
|
||||
if(e.target.dataset.assistantDiscard!==undefined){delete assistantDrafts[instance];delete nameDrafts[instance];delete assistantReceipts[instance];settingsSnapshot=null;last.delete('api:settings');render(true);return;}
|
||||
if(e.target.dataset.assistantName!==undefined){const name=form.querySelector('[data-assistant-field="name"]').value.trim();
|
||||
await assistantPost('/api/name',{instance,name},instance,result=>{if(typeof result?.name!=='string')return unknownAssistant;delete nameDrafts[instance];const prior=last.get('names');if(prior)last.set('names',{...prior,rows:[...prior.rows.filter(r=>r.instance!==result.instance),{instance:result.instance,name:result.name,at:result.at}]});return `Seat name set to ${result.name}.`;});}
|
||||
else if(e.target.dataset.assistantStop!==undefined)await assistantPost('/api/assistant-stop',{instance},instance,result=>Array.isArray(result?.stopped)?`Stopped ${plural(result.stopped.length,'run')}.`:unknownAssistant);
|
||||
const form=assistantForm(e.target);if(!form||!canSend||out())return;const instance=form.dataset.instance;
|
||||
if(e.target.dataset.assistantDiscard!==undefined){delete assistantDrafts[instance];delete nameDrafts[instance];delete assistantReceipts[instance];delete assistantCardReceipts[assistantKey(instance,'name')];delete assistantCardReceipts[assistantKey(instance,'settings')];settingsSnapshot=null;last.delete('api:settings');render(true);return;}
|
||||
if(e.target.dataset.assistantName!==undefined){if(assistantFormsBusy.has(assistantKey(instance,'name')))return;const name=form.querySelector('[data-assistant-field="name"]').value.trim();
|
||||
await assistantPost('/api/name',{instance,name},instance,result=>{if(typeof result?.name!=='string')return unknownAssistant;delete nameDrafts[instance];const prior=last.get('names');if(prior)last.set('names',{...prior,rows:[...prior.rows.filter(r=>r.instance!==result.instance),{instance:result.instance,name:result.name,at:result.at}]});return `Seat name set to ${result.name}.`;},'name');}
|
||||
else if(e.target.dataset.assistantStop!==undefined&&!assistantFormsBusy.has(assistantKey(instance,'stop')))await assistantPost('/api/assistant-stop',{instance},instance,result=>Array.isArray(result?.stopped)?`Stopped ${plural(result.stopped.length,'run')}.`:unknownAssistant,'stop');
|
||||
});
|
||||
// Row 165 (#1662, Q171): role instances. Add one, or change one's definition, vars and launch
|
||||
// membership, with the read's revision. A definition that declares credentials is named but not
|
||||
// offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt
|
||||
// says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries.
|
||||
let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusy=false, instanceReceipt='';
|
||||
let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusyOps=new Set(), instanceReceipt='', instanceReceiptOp=null, instanceReceipts=Object.create(null);
|
||||
const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
|
||||
const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.';
|
||||
function instancesSection(view){
|
||||
const title='<h2 id="instances-title">Role instances</h2>';
|
||||
if(!view)return `<section class="panel" aria-labelledby="instances-title">${title}<p class="small warn">The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.</p></section>`;
|
||||
if(!instanceDrafts.add&&!instanceDrafts.change)instanceView=view;
|
||||
const v=instanceView, disabled=!(canSend || isTester)||out()||instanceBusy?' disabled':'';
|
||||
const v=instanceView, disabled=!(canSend || isTester)||out()||instanceBusyOps.size?' disabled':'';
|
||||
const defs=Object.entries(v.definitions), accepted=defs.filter(([,d])=>d.accepted).map(([n])=>n);
|
||||
const bound=defs.filter(([,d])=>Array.isArray(d.services)&&d.services.length).map(([n,d])=>`${txt(n)} (${d.services.map(txt).join(', ')})`);
|
||||
const changeable=Object.entries(v.instances).filter(([,e])=>!e.protected).map(([n])=>n);
|
||||
@@ -891,16 +894,16 @@
|
||||
// The answer replaces the view the next render draws, so it must have the read's whole shape.
|
||||
const instanceAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&typeof r.restart==='boolean'&&[r.instances,r.definitions].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('.instance-form');if(form){instanceDrafts[form.dataset.op]=instanceFields(form);markSettingsDraft('agents',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-instance-discard]');if(discard)discard.hidden=false;}});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='instances-reload' && e.target.dataset?.instanceDiscard===undefined)return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';last.delete('instances');render(true);});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='instances-reload' && e.target.dataset?.instanceDiscard===undefined)return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';instanceReceipts=Object.create(null);instanceReceiptOp=null;last.delete('instances');render(true);});
|
||||
document.addEventListener('submit',async e=>{
|
||||
const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||instanceBusy||!instanceView)return;
|
||||
const op=form.dataset.op;instanceDrafts[op]=instanceFields(form);let payload;
|
||||
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;render(false);return;}
|
||||
instanceBusy=true;instanceReceipt='';render(false);
|
||||
const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||!instanceView)return;
|
||||
const op=form.dataset.op;if(instanceBusyOps.has(op))return;instanceReceiptOp=op;instanceDrafts[op]=instanceFields(form);let payload;
|
||||
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
|
||||
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
|
||||
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
|
||||
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;}
|
||||
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{instanceReceipt=unknownInstance;}finally{instanceBusy=false;if(!out())render(false);}
|
||||
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}
|
||||
});
|
||||
// Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and
|
||||
// the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the
|
||||
@@ -919,7 +922,7 @@
|
||||
const v=routesView, disabled=!(canSend || isTester)||out()||routesBusy?' disabled':'';
|
||||
const cells=name=>routesDraft?.[name]??routeCells(v.instances[name]);
|
||||
const select=(name,key,list,current,why)=>{const offered=current===''||list.includes(current)?list:[...list,current];
|
||||
return `<select data-route="${esc(inputKey([name,key]))}" aria-label="${esc(`${name} ${ROUTE_CELLS.find(([k])=>k===key)[1].toLowerCase()}`)}"${disabled}>${key.startsWith('fallback.')||current===''?`<option value=""${current===''?' selected':''}>${key.startsWith('fallback.')?'none':'unset'}</option>`:''}${offered.map(x=>`<option value="${esc(x)}"${x===current?' selected':''}>${txt(x)}${list.includes(x)?'':` (${why})`}</option>`).join('')}</select>`;};
|
||||
return `<select data-route="${esc(inputKey([name,key]))}" aria-label="${esc(`${name} ${ROUTE_CELLS.find(([k])=>k===key)[1].toLowerCase()}`)}"${disabled}>${key.startsWith('fallback.')||current===''?`<option value=""${current===''?' selected':''}>${key.startsWith('fallback.')?'None':'unset'}</option>`:''}${offered.map(x=>`<option value="${esc(x)}"${x===current?' selected':''}>${txt(x)}${list.includes(x)?'':` (${why})`}</option>`).join('')}</select>`;};
|
||||
const levelsOf=h=>Array.isArray(v.levels[h])?v.levels[h]:[];
|
||||
const rows=Object.keys(v.instances).map(name=>{const c=cells(name);
|
||||
const harness=(key)=>select(name,key,v.harnesses,c[key],'not switched on');
|
||||
@@ -958,7 +961,7 @@
|
||||
});
|
||||
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
|
||||
// the same instances read. A refresh drops that read so the next render asks the bus again.
|
||||
const agentsSection=view=>{ if(view&&!instanceDrafts.add&&!instanceDrafts.change)instanceView=view; return window.MosaicAgents?window.MosaicAgents.section(view,{txt,esc,canSend,isTester,out,signOut,instanceDrafts,instanceReceipt,instanceBusy,render:()=>render(false),refresh:()=>{last.delete('instances');render(false);}}):''; };
|
||||
const agentsSection=view=>{ if(view&&!instanceDrafts.add&&!instanceDrafts.change)instanceView=view; return window.MosaicAgents?window.MosaicAgents.section(view,{txt,esc,canSend,isTester,out,signOut,instanceDrafts,instanceReceipt,instanceReceiptOp,instanceReceipts,instanceBusyOps:[...instanceBusyOps],render:()=>render(false),refresh:()=>{last.delete('instances');render(false);}}):''; };
|
||||
// Tester management is deliberately separate from the human's assistant/settings forms.
|
||||
function testerNames(names, instances) {
|
||||
if (!instances || !names) return '';
|
||||
@@ -982,12 +985,14 @@
|
||||
});
|
||||
});
|
||||
const settingsPages = [['appearance','Appearance'],['limits','Launch limits'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['about','About']];
|
||||
function markSettingsDraft(page,dirty){const link=document.querySelector(`#settings-nav a[href="#/settings/${page}"]`);if(link && dirty && !link.querySelector('.settings-dot'))link.insertAdjacentHTML('beforeend','<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>');}
|
||||
function markSettingsDraft(page,dirty){const link=document.querySelector(`#settings-nav a[href="#/settings/${page}"]`), dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';if(link && dirty && !link.querySelector('.settings-dot'))link.insertAdjacentHTML('beforeend',dot);const button=$('settings-disclosure');if(dirty && link?.getAttribute('aria-current')==='page' && button && !button.querySelector('.settings-dot'))button.querySelector('strong').insertAdjacentHTML('beforeend',dot);}
|
||||
function settingsShell(page, body, helper='') {
|
||||
const pages=isTester ? [['appearance','Appearance'],['routing','Routing'],['agents','Agents'],['names','Seat names']] : settingsPages;
|
||||
const label=pages.find(([id])=>id===page)?.[1] || 'Appearance';
|
||||
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="${id==='model-proxy'?'/settings/model-proxy':`#/settings/${id}`}"${id===page?' aria-current="page"':''}>${name}${(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change))?'<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>':''}</a>`).join('');
|
||||
return `${head('Settings','Your browser and this instance.')}<div class="settings-layout"><div><button type="button" id="settings-disclosure" aria-expanded="false" aria-controls="settings-nav"><small>Section</small><strong>${label}</strong><span aria-hidden="true">⌄</span></button><nav id="settings-nav" aria-label="Settings sections">${links}</nav></div><div class="settings-content settings-${page}${page!=='appearance'&&!canSend&&!isTester?' readonly':''}"><h2 id="settings-page-title" tabindex="-1">${label}</h2><p class="settings-helper">${helper}</p>${page!=='appearance'&&!canSend&&!isTester?'<p class="settings-readonly">Read only. Saving requires signed-in send mode.</p>':''}${body}</div></div>`;
|
||||
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change));
|
||||
const dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';
|
||||
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="${id==='model-proxy'?'/settings/model-proxy':`#/settings/${id}`}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('');
|
||||
return `${head('Settings','Your browser and this instance.')}<div class="settings-layout"><div><button type="button" id="settings-disclosure" aria-expanded="false" aria-controls="settings-nav"><small>Section</small><strong>${label}${isDirty(page)?dot:''}</strong><span aria-hidden="true">⌄</span></button><nav id="settings-nav" aria-label="Settings sections">${links}</nav></div><div class="settings-content settings-${page}${page!=='appearance'&&!canSend&&!isTester?' readonly':''}"><h2 id="settings-page-title" tabindex="-1">${label}</h2><p class="settings-helper">${helper}</p>${page!=='appearance'&&!canSend&&!isTester?'<p class="settings-readonly">Read only. Saving requires signed-in send mode.</p>':''}${body}</div></div>`;
|
||||
}
|
||||
async function settingsView(get, page='appearance') {
|
||||
if (page==='appearance') return settingsShell(page,appearance(),'These choices apply in this browser only.');
|
||||
@@ -1253,19 +1258,24 @@
|
||||
return {
|
||||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
|
||||
settingsChoice: view.contains(a) && a.type==='radio' ? [a.name,a.value] : null,
|
||||
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
|
||||
settingsField: view.contains(a) && a.closest('.settings-content') && (a.dataset?.instanceField || a.dataset?.assistantField || a.dataset?.route || a.dataset?.setting) ? {form:a.closest('form')?.id || null, instance:a.closest('[data-instance]')?.dataset.instance || null, key:a.dataset.instanceField?'instanceField':a.dataset.assistantField?'assistantField':a.dataset.route?'route':'setting', value:a.dataset.instanceField || a.dataset.assistantField || a.dataset.route || a.dataset.setting} : null,
|
||||
fallbacks:[...view.querySelectorAll('.route-row.fallback-open [data-route]')].map(e=>e.dataset.route),
|
||||
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
|
||||
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
|
||||
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
|
||||
};
|
||||
}
|
||||
function restore(k) {
|
||||
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
|
||||
for (const e of $('bus-view').querySelectorAll('.route-row [data-route]')) if(k.fallbacks?.includes(e.dataset.route)) e.closest('.route-row').classList.add('fallback-open');
|
||||
if ($('copy-status')) $('copy-status').textContent = k.status;
|
||||
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry'));
|
||||
// Row 252: the answer panel's buttons and its status line.
|
||||
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
|
||||
const choice=k.settingsChoice && [...$('bus-view').querySelectorAll('input[type=radio]')].find(x=>x.name===k.settingsChoice[0]&&x.value===k.settingsChoice[1]);
|
||||
if (choice) choice.focus({preventScroll:true});
|
||||
const setting=k.settingsField && [...$('bus-view').querySelectorAll('[data-instance-field],[data-assistant-field],[data-route],[data-setting]')].find(e=>e.dataset[k.settingsField.key]===k.settingsField.value && (!k.settingsField.form || e.closest('form')?.id===k.settingsField.form) && (!k.settingsField.instance || e.closest('[data-instance]')?.dataset.instance===k.settingsField.instance));
|
||||
if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
|
||||
else if (choice) choice.focus({preventScroll:true});
|
||||
else if (dec) dec.focus({ preventScroll: true });
|
||||
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
el.focus({ preventScroll: true });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
if (location.pathname === '/settings/model-proxy' && !location.hash) location.hash = '#/settings/model-proxy';
|
||||
if (document.documentElement.hasAttribute('data-settings-proxy-document') && !location.hash) location.hash = '#/settings/model-proxy';
|
||||
/* Browser-local sidebar density. No authority or project data is stored here. */
|
||||
(() => {
|
||||
const key='mosaic-sidebar-display-v1';
|
||||
@@ -15,11 +15,14 @@ if (location.pathname === '/settings/model-proxy' && !location.hash) location.ha
|
||||
// No browser storage, request/body logging, key reveal, or automatic write retry.
|
||||
(() => {
|
||||
'use strict';
|
||||
const markup = '<button id="proxy-refresh" type="button">Refresh</button><p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>\n<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>\n<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>\n<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Accounts"><table><thead><tr><th>Account</th><th>Provider</th><th>Status</th><th>Priority</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>\n<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>\n<form id="proxy-login-start"><fieldset data-write disabled><legend>Subscription account</legend><label>Provider <select name="provider"><option value="claude">Claude</option><option value="codex">Codex</option></select></label><button type="submit">Start sign-in</button></fieldset></form>\n<p id="proxy-login-message" role="status" aria-live="polite"></p>\n<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>\n<form id="proxy-login-submit"><fieldset data-write disabled><legend>Finish sign-in</legend><p>If the sign-in tab does not return, copy its full address and paste it here.</p><label>Callback address <input name="redirect_url" type="url" autocomplete="off" spellcheck="false" maxlength="8192" required></label><button type="submit">Submit address</button><button id="proxy-login-cancel" type="button">Cancel sign-in</button></fieldset></form></section>\n<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>\n<form id="proxy-apikey"><fieldset data-write disabled><legend>z.ai</legend><label>API key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096" required></label><p>The key is sent once and cleared from this field. It will never be shown again.</p><button type="submit">Add key</button></fieldset></form></section>\n<section class="panel" aria-labelledby="proxy-keys-title"><h2 id="proxy-keys-title">Client keys</h2><p>Only key IDs are shown. Hire and fire manage agent keys.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Client keys"><table><thead><tr><th>Key ID</th><th>Managed by</th><th>Agent</th><th>Issued</th><th>State</th></tr></thead><tbody id="proxy-keys"></tbody></table></div></section>\n';
|
||||
const markup = '<button id="proxy-refresh" type="button">Refresh</button><p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>\n<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>\n<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>\n<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Accounts"><table><thead><tr><th>Account</th><th>Provider</th><th>Status</th><th>Priority</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>\n<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>\n<form id="proxy-login-start"><fieldset data-write disabled><legend>Subscription account</legend><label>Provider <select name="provider"><option value="claude">Claude</option><option value="codex">Codex</option></select></label><button type="submit">Start sign-in</button></fieldset></form>\n<p id="proxy-login-message" role="status" aria-live="polite"></p>\n<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>\n<form id="proxy-login-submit"><fieldset data-write disabled><legend>Finish sign-in</legend><p>If the sign-in tab does not return, copy its full address and paste it here.</p><label>Callback address <input name="redirect_url" type="url" autocomplete="off" spellcheck="false" maxlength="8192" required></label><button type="submit">Submit address</button><button id="proxy-login-cancel" type="button">Cancel sign-in</button></fieldset></form></section>\n<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>\n<form id="proxy-apikey"><fieldset data-write disabled><legend>z.ai</legend><label>API key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096" required></label><p>The key is sent once and cleared from this field. It will never be shown again.</p><button type="submit">Add key</button></fieldset><p id="proxy-apikey-message" role="status"></p></form></section>\n<section class="panel" aria-labelledby="proxy-keys-title"><h2 id="proxy-keys-title">Client keys</h2><p>Only key IDs are shown. Hire and fire manage agent keys.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Client keys"><table><thead><tr><th>Key ID</th><th>Managed by</th><th>Agent</th><th>Issued</th><th>State</th></tr></thead><tbody id="proxy-keys"></tbody></table></div></section>\n';
|
||||
function mount() {
|
||||
const $ = id => document.getElementById(id);
|
||||
let canWrite = false, login = null, poll = null, busy = false;
|
||||
let canWrite = false, login = null, poll = null;
|
||||
const busy = new Set(), priorityReceipts = new Map();
|
||||
const message = (text, loginOnly = false) => { $(loginOnly ? 'proxy-login-message' : 'proxy-message').textContent = text; };
|
||||
const formBusy=(form,on)=>{for(const control of form.querySelectorAll('fieldset,input,select,button')){if(on){control.dataset.wasDisabled=String(control.disabled);control.disabled=true;}else if(control.dataset.wasDisabled!==undefined){control.disabled=control.dataset.wasDisabled==='true';delete control.dataset.wasDisabled;}}const button=form.querySelector('button[type=submit]');if(button){if(on){button.dataset.label=button.textContent;button.innerHTML='<span class="settings-spinner" aria-hidden="true"></span> Saving…';}else if(button.dataset.label){button.textContent=button.dataset.label;delete button.dataset.label;}}};
|
||||
const receipt=(id,text,kind)=>{const el=$(id);el.textContent=text;el.className=kind==='error'?'banner err':kind==='success'?'settings-success':'';};
|
||||
const cell = value => { const td = document.createElement('td'); td.textContent = value ?? '—'; return td; };
|
||||
async function call(path, args) {
|
||||
const response = await fetch('/api/proxy/' + path, { cache: 'no-store', credentials: 'same-origin',
|
||||
@@ -61,13 +64,13 @@ if (location.pathname === '/settings/model-proxy' && !location.hash) location.ha
|
||||
const td = document.createElement('td'), form = document.createElement('form');
|
||||
const input = document.createElement('input'); input.type = 'number'; input.min = '-1000'; input.max = '1000'; input.step = '1'; input.value = account.priority;
|
||||
input.setAttribute('aria-label', `Priority for ${account.displayName}`); input.disabled = !canWrite;
|
||||
const save = document.createElement('button'); save.type = 'submit'; save.textContent = 'Set priority'; save.disabled = !canWrite; save.hidden = !canWrite;
|
||||
form.append(input, save); form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (!canWrite || busy || !input.checkValidity()) return;
|
||||
busy = true; save.disabled = true;
|
||||
try { await call('priority/set', { accountId: account.accountId, priority: Number(input.value) }); message('Priority saved.'); await refresh(); }
|
||||
catch (error) { message(error.message); }
|
||||
finally { busy = false; save.disabled = !canWrite; }
|
||||
const save = document.createElement('button'); save.type = 'submit'; save.className='proxy-priority-save'; save.textContent = 'Set priority'; save.disabled = !canWrite; save.hidden = !canWrite;
|
||||
const receipt=document.createElement('p');receipt.setAttribute('role','status');if(priorityReceipts.has(account.accountId)){receipt.className='settings-success';receipt.textContent=priorityReceipts.get(account.accountId);}form.append(input, save, receipt); form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); const busyKey='priority:'+account.accountId;if (!canWrite || busy.has(busyKey) || !input.checkValidity()) return;
|
||||
busy.add(busyKey);priorityReceipts.delete(account.accountId); receipt.textContent='';receipt.className='';input.disabled = true; save.disabled = true; save.innerHTML = '<span class="settings-spinner" aria-hidden="true"></span> Saving…';
|
||||
try { await call('priority/set', { accountId: account.accountId, priority: Number(input.value) }); message('Priority saved.');priorityReceipts.set(account.accountId,'Priority saved.');receipt.className='settings-success';receipt.textContent='Priority saved.';await refresh(); }
|
||||
catch (error) { message(error.message);receipt.className='banner err';receipt.textContent=error.message; }
|
||||
finally { busy.delete(busyKey); input.disabled = !canWrite; save.disabled = !canWrite; save.textContent='Set priority'; }
|
||||
});
|
||||
td.append(form); row.append(td); return row;
|
||||
});
|
||||
@@ -79,39 +82,39 @@ if (location.pathname === '/settings/model-proxy' && !location.hash) location.ha
|
||||
}
|
||||
$('proxy-refresh').addEventListener('click', () => refresh().then(() => message('Accounts and client keys refreshed.')).catch(e => message(e.message)));
|
||||
$('proxy-login-start').addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (!canWrite || busy || login) return;
|
||||
busy = true;
|
||||
event.preventDefault(); const form=event.currentTarget;if (!canWrite || busy.has('login-start') || login) return;
|
||||
busy.add('login-start');formBusy(form,true);
|
||||
try {
|
||||
login = await call('login/start', { provider: event.currentTarget.elements.provider.value });
|
||||
$('proxy-authorize').href = login.url; $('proxy-authorize').hidden = false;
|
||||
window.open(login.url, '_blank', 'noopener,noreferrer');
|
||||
message('Complete account sign-in in the new tab, or use the sign-in link.', true);
|
||||
poll = setTimeout(status, 2000);
|
||||
} catch (error) { message(error.message, true); }
|
||||
finally { busy = false; }
|
||||
} catch (error) { message(error.message, true);$('proxy-login-message').className='banner err'; }
|
||||
finally { busy.delete('login-start');formBusy(form,false); }
|
||||
});
|
||||
$('proxy-login-submit').addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (!canWrite || busy || !login) return;
|
||||
event.preventDefault(); const form=event.currentTarget;if (!canWrite || busy.has('login-submit') || !login) return;
|
||||
const input = event.currentTarget.elements.redirect_url, redirect_url = input.value; input.value = '';
|
||||
busy = true;
|
||||
busy.add('login-submit');formBusy(form,true);
|
||||
try { await call('login/submit', { redirect_url }); message('Address submitted. Waiting for sign-in confirmation…', true); }
|
||||
catch (error) { message(error.message, true); }
|
||||
finally { busy = false; }
|
||||
catch (error) { message(error.message, true);$('proxy-login-message').className='banner err'; }
|
||||
finally { busy.delete('login-submit');formBusy(form,false); }
|
||||
});
|
||||
$('proxy-login-cancel').addEventListener('click', async () => {
|
||||
if (!canWrite || busy || !login) return;
|
||||
busy = true; clearTimeout(poll);
|
||||
$('proxy-login-cancel').addEventListener('click', async event => {
|
||||
const form=event.currentTarget.form;if (!canWrite || busy.has('login-cancel') || !login) return;
|
||||
busy.add('login-cancel');formBusy(form,true); clearTimeout(poll);
|
||||
try { await call('login/cancel', { state: login.state }); endLogin(); message('Sign-in cancelled.', true); }
|
||||
catch (error) { endLogin(); message(error.message, true); }
|
||||
finally { busy = false; }
|
||||
finally { busy.delete('login-cancel');formBusy(form,false); }
|
||||
});
|
||||
$('proxy-apikey').addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (!canWrite || busy) return;
|
||||
event.preventDefault(); const form=event.currentTarget;if (!canWrite || busy.has('apikey')) return;
|
||||
const input = event.currentTarget.elements.key, key = input.value; input.value = '';
|
||||
busy = true;
|
||||
try { await call('apikey/add', { provider: 'z.ai', key }); message('API key added.'); await refresh(); }
|
||||
catch (error) { message(error.message); }
|
||||
finally { busy = false; }
|
||||
busy.add('apikey');receipt('proxy-apikey-message','','');formBusy(form,true);
|
||||
try { await call('apikey/add', { provider: 'z.ai', key }); message('API key added.');receipt('proxy-apikey-message','API key added.','success');await refresh(); }
|
||||
catch (error) { message(error.message);receipt('proxy-apikey-message',error.message,'error'); }
|
||||
finally { busy.delete('apikey');formBusy(form,false); }
|
||||
});
|
||||
const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
|
||||
window.addEventListener('pagehide', cleanup, {once:true});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor">
|
||||
<html lang="en" data-settings-proxy-document data-design="console" data-palette="harbor">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Dashboard · Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css">
|
||||
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
|
||||
|
||||
@@ -293,3 +293,24 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
|
||||
|
||||
/* Agent menus must extend beyond the table without being clipped by its scroll region. */
|
||||
.settings-agents .table-wrap{overflow:visible}
|
||||
|
||||
/* Review fixes: preserve readable actions, labels and targets at every width. */
|
||||
.settings-content .instance-form .filters>label:not(:has(input[type=checkbox])){align-items:flex-start}
|
||||
.settings-content .instance-form .filters>label:has(input[type=checkbox]){grid-column:1/-1;justify-self:start}
|
||||
.settings-content .settings-actions .banner:not(:empty){order:-1;flex:1 0 100%;margin:0}
|
||||
.agent-mobile-label{display:none}
|
||||
#bus-view .page-head:has(+ .settings-layout) p{font-size:16px}
|
||||
.settings-segments:has(input:focus-visible){outline:2px solid var(--focus);outline-offset:2px}
|
||||
.settings-segments input:focus-visible+span{outline:none}
|
||||
#proxy-authorize:not([hidden]){display:inline-flex;align-items:center;min-height:44px}
|
||||
.settings-content #proxy-accounts .proxy-priority-save{background:var(--surface);color:var(--text);border-color:var(--border)}
|
||||
@media(min-width:760px){#routes-table select{min-width:132px}}
|
||||
@media(max-width:759px){
|
||||
#agents-table :is(th,td,code){min-width:0;max-width:100%;white-space:normal;overflow-wrap:anywhere;word-break:break-word}
|
||||
.agent-mobile-label{display:inline;color:var(--muted)}
|
||||
.settings-content .settings-actions.has-draft{max-height:45vh;overflow-y:auto;align-items:center}
|
||||
.settings-content .settings-actions.has-draft p:not(:empty){display:block;order:-1;flex:1 0 100%;margin:0}
|
||||
.settings-content:has(.settings-actions.has-draft){padding-bottom:50vh}
|
||||
}
|
||||
|
||||
.settings-success:not(:empty)::before{content:"✓ ";}
|
||||
@@ -29,12 +29,12 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
|
||||
// Read-only: the table, the unavailable line, every action disabled, nothing injected.
|
||||
await b.navigate(`http://127.0.0.1:${port}/#/settings/agents`);
|
||||
await wait('!!document.querySelector("#agents-table")');
|
||||
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,5).map(c=>c.textContent))"),[
|
||||
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,5).map(c=>{const copy=c.cloneNode(true);copy.querySelector('.agent-mobile-label')?.remove();return copy.textContent}))"),[
|
||||
['coder','coder','Yes','No','run-1 still running after fire: run-0'],[hostile,'coder','No','Yes',`None still running after fire: ${hostile}`],['assistant','assistant','No','No','None'],['bean','companion','No','No','None'],
|
||||
]);
|
||||
assert.equal(await b.evaluate('window.injected===true'),false);
|
||||
// D7 (S6): a run a fire's stop left alive is flagged beside the running runs, as text.
|
||||
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table .agent-alive code')].map(c=>c.textContent)"),['run-0',hostile]);
|
||||
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table .agent-alive code')].map(c=>{const copy=c.cloneNode(true);copy.querySelector('.agent-mobile-label')?.remove();return copy.textContent})"),['run-0',hostile]);
|
||||
assert.match(await b.evaluate('document.querySelector(".settings-readonly").textContent'),/Read only/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action],#agents-section .agent-more").length'),0);
|
||||
assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Retired names, never reused: old-coder\./);
|
||||
|
||||
@@ -29,7 +29,7 @@ test('row 165 Settings: role instances read, add with a restart named, change wi
|
||||
// Read-only: the table, the accepted definitions only, the hand-edit line, every control disabled.
|
||||
await b.navigate(`http://127.0.0.1:${port}/#/settings/agents`);
|
||||
await wait('!!document.querySelector("#agents-table")');
|
||||
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,3).map(c=>c.textContent))"),[
|
||||
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,3).map(c=>{const copy=c.cloneNode(true);copy.querySelector('.agent-mobile-label')?.remove();return copy.textContent}))"),[
|
||||
['chief','chief-of-staff','Launcher'],['coder','coder','Yes'],['assistant','assistant','No'],
|
||||
]);
|
||||
assert.equal(await b.evaluate('window.injected===true'),false);
|
||||
|
||||
@@ -88,7 +88,7 @@ test('hiring switches lead names and launch settings inputs align in columns', {
|
||||
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/settings':{launch,notifier:{}},'/api/bus/instances':{rows:[instances]}}});
|
||||
await b.viewport(1440,1000);await b.navigate(f.base+'/#/settings/agents');await wait('document.querySelectorAll(".hiring-grid [role=switch]").length===2');
|
||||
const switchesLead=await b.evaluate('[...document.querySelectorAll(".hiring-grid > div")].every(row=>row.querySelector("[role=switch]").getBoundingClientRect().right<=row.querySelector(":scope > span").getBoundingClientRect().left)');
|
||||
const formLayout=await b.evaluate(`(()=>{const f=document.querySelector('#instance-add-form'),labels=[...f.querySelectorAll('.filters>label')],boxes=labels.map(e=>e.getBoundingClientRect()),details=f.querySelector('details').getBoundingClientRect(),button=f.querySelector('button[type=submit]').getBoundingClientRect();return {separate:boxes.every((r,i)=>!i||r.left>=boxes[i-1].right),gap:button.top-details.bottom,headingOutline:getComputedStyle(document.querySelector('#settings-page-title')).outlineStyle}})()`);
|
||||
const formLayout=await b.evaluate(`(()=>{const f=document.querySelector('#instance-add-form'),labels=[...f.querySelectorAll('.filters>label')],boxes=labels.map(e=>e.getBoundingClientRect()),details=f.querySelector('details').getBoundingClientRect(),button=f.querySelector('button[type=submit]').getBoundingClientRect();return {separate:boxes[1].left>=boxes[0].right&&boxes[2].top>=boxes[0].bottom,gap:button.top-details.bottom,headingOutline:getComputedStyle(document.querySelector('#settings-page-title')).outlineStyle}})()`);
|
||||
assert.equal(formLayout.separate,true,JSON.stringify(formLayout));assert.ok(formLayout.gap>=12,JSON.stringify(formLayout));assert.equal(formLayout.headingOutline,'none');
|
||||
await b.evaluate('location.hash="#/settings/limits"');await wait('document.querySelectorAll("#launch-settings-form input[type=number]").length>=5');
|
||||
const positions=await b.evaluate(`(()=>{const f=document.querySelector('#launch-settings-form'), numbers=[...f.querySelectorAll('input[type=number]')], table=[...f.querySelectorAll('[data-setting]')].filter(e=>JSON.parse(e.dataset.setting)[0]==='table');const x=e=>Math.round(e.getBoundingClientRect().left);return {limits:numbers.map(x),providers:table.filter(e=>JSON.parse(e.dataset.setting)[2]==='provider').map(x),models:table.filter(e=>JSON.parse(e.dataset.setting)[2]==='model').map(x)};})()`);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {resolve,extname} from 'node:path';
|
||||
import {browser} from './browser.mjs';
|
||||
import {w15Fixture} from './w15-fixture.mjs';
|
||||
|
||||
const rev='a'.repeat(64), long='a'.repeat(64);
|
||||
const instances={revision:rev,launch:true,instances:{pm:{definition:'coder',launched:true,kept:false,running:['run-1'],protected:false,endProtected:false},[long]:{definition:'coder',launched:true,kept:false,running:[long],protected:false,endProtected:false}},definitions:{coder:{accepted:true,services:[]}},noHire:[],retired:[]};
|
||||
const launch={revision:rev,models:{opus:{provider:'anthropic',model:'opus'}},limits:{},usage:{},assistants:{assistant:{definition:'assistant',vars:{},tracker:null}}};
|
||||
const routes={revision:rev,harnesses:['claude-code','codex'],models:['opus'],levels:{'claude-code':['high','xhigh'],codex:['high','xhigh']},instances:{pm:{harness:'claude-code',model:'opus',thinking:'high'}}};
|
||||
const scenario={'/api/config':{devOwner:true,send:true,decide:true,edit:true},'/api/settings':{launch,notifier:{binding:null}},'/api/bus/instances':{rows:[instances]},'/api/bus/routes':{rows:[routes]},'/api/bus/names':{rows:[{instance:'assistant',name:'Piper'}]},'/api/bus/limits':{rows:[]},'/api/bus/usage':{rows:[]},'/api/proxy/accounts':{accounts:[{accountId:'fixture',displayName:'Fixture',provider:'claude',status:'ready',priority:100}]},'/api/proxy/keys':{keys:[]}};
|
||||
async function page(t,section,width=1440,options={}) {
|
||||
const x=await w15Fixture(t,{scenario}); const {b,f,wait}=x;
|
||||
if(options.poll)await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{const original=setTimeout;window.setTimeout=(fn,ms,...args)=>{if(ms===10000)window.__pollRefresh=fn;return original(fn,ms,...args)}})()`});
|
||||
await b.viewport(width,width===390?900:1000);await b.navigate(f.base+'/#/settings/'+section);
|
||||
await wait(`document.querySelector('#settings-page-title')?.textContent===${JSON.stringify(({agents:'Agents',routing:'Routing',limits:'Launch limits',assistant:'Assistant',appearance:'Appearance','model-proxy':'Model proxy'})[section])}`);
|
||||
if(section==='agents')await wait('!!document.querySelector(".agent-more")');
|
||||
if(section==='routing')await wait('!!document.querySelector("[data-route]")');
|
||||
if(section==='model-proxy')await wait('!!document.querySelector("#proxy-accounts form")');
|
||||
return x;
|
||||
}
|
||||
async function pointer(b,sel,xpart=.5){const pt=await b.evaluate(`(()=>{let e=document.querySelector(${JSON.stringify(sel)});e.scrollIntoView({block:'center'});let r=e.getBoundingClientRect();return {x:r.left+r.width*${xpart},y:r.top+r.height/2}})()`);for(const type of ['mousePressed','mouseReleased'])await b.call('Input.dispatchMouseEvent',{type,...pt,button:'left',clickCount:1});}
|
||||
async function input(b,sel,value){await b.evaluate(`(()=>{let e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.focus()})()`);}
|
||||
const opts={timeout:90000};
|
||||
test('F1 More Edit opens and focuses editor through keyboard and pointer',opts,async t=>{const {b,wait}=await page(t,'agents');await b.evaluate('document.querySelector(".agent-more").focus()');await b.key('ArrowDown');await b.key('Enter');await wait('!!document.querySelector("#instance-change-form")');assert.equal(await b.evaluate('document.activeElement.closest("#instance-change-form")!==null'),true);await b.evaluate('location.hash="#/settings/appearance"');await wait('!!document.querySelector(".settings-appearance")');await b.evaluate('location.hash="#/settings/agents"');await wait('!!document.querySelector(".agent-more")');await pointer(b,'.agent-more');await pointer(b,'[data-agent-edit]');await wait('!!document.querySelector("#instance-change-form")');});
|
||||
test('F2 pointer on hiring track and thumb and keyboard all send',opts,async t=>{const {b,wait}=await page(t,'agents');await pointer(b,'.switch-track',.5);await wait('__writes.length===1');await wait('document.querySelector("#agents-receipt")?.textContent.length>0');assert.equal(await b.evaluate('__writes[0].path'),'/api/veto');await b.evaluate('window.__previousSwitch=document.querySelector(".hiring-grid [role=switch]")');await pointer(b,'.switch-track',.15);await wait('__writes.length===2');await wait('document.querySelector(".hiring-grid [role=switch]")!==window.__previousSwitch && document.querySelector("#agents-receipt")?.textContent.length>0');await b.evaluate('document.querySelector(".hiring-grid [role=switch]").focus()');await b.key(' ');await wait('__writes.length===3');});
|
||||
test('F3 phone shows invalid, refusal and unknown receipts while draft remains',opts,async t=>{const {b,wait}=await page(t,'limits',390);await input(b,'#launch-settings-form input[type=number]','-1');await b.key('Enter');await wait('!!document.querySelector("[aria-invalid]")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-receipt").getBoundingClientRect().height>0'),true);await input(b,'#launch-settings-form input[type=number]','2');await b.evaluate(`__scenario['POST /api/settings']={status:409,body:{message:'Fixture conflict',sent:false}}`);await b.key('Enter');await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Fixture conflict")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-receipt").getBoundingClientRect().height>0'),true);await b.evaluate(`__scenario['POST /api/settings']={status:500,body:{sent:'unknown'}}`);await wait('document.querySelector("#launch-settings-form button[type=submit]")?.disabled===false');await b.evaluate("document.querySelector('#launch-settings-form').requestSubmit()");await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Outcome unknown")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-receipt").getBoundingClientRect().height>0'),true);});
|
||||
test('F4 polling preserves Vars and fallback expansion, focus and caret',opts,async t=>{const {b,wait}=await page(t,'agents',1440,{poll:true});await b.evaluate('document.querySelector("#instance-add-form summary").click()');await input(b,'#instance-add-form textarea','{"note":"draft"}');await b.evaluate('document.activeElement.setSelectionRange(4,4);window.__old=document.activeElement;window.__pollRefresh()');await wait('document.querySelector("#instance-add-form textarea")!==window.__old');assert.deepEqual(await b.evaluate('({open:document.querySelector("#instance-add-form details").open,focus:document.activeElement===document.querySelector("#instance-add-form textarea"),caret:document.activeElement.selectionStart})'),{open:true,focus:true,caret:4});await b.evaluate('location.hash="#/settings/routing"');await wait('!!document.querySelector(".fallback-toggle")');await b.viewport(390,900);await pointer(b,'.fallback-toggle');await b.evaluate('document.querySelector(".fallback-cell select").focus();window.__old=document.activeElement;window.__pollRefresh()');await wait('document.querySelector(".fallback-cell select")!==window.__old');assert.equal(await b.evaluate('document.querySelector(".route-row").classList.contains("fallback-open")&&document.activeElement===document.querySelector(".fallback-cell select")'),true);});
|
||||
test('F5 Enter in seat name saves only name despite settings draft',opts,async t=>{const {b,wait}=await page(t,'assistant',390);await input(b,'[data-assistant-field=definition]','changed');await input(b,'[data-assistant-field=name]','Wren');await b.key('Enter');await wait('__writes.length>0');assert.equal(await b.evaluate('__writes[0].path'),'/api/name');assert.deepEqual(await b.evaluate('JSON.parse(__writes[0].body)'),{instance:'assistant',name:'Wren'});});
|
||||
test('F6 add agent labels left aligned with launch on own row',opts,async t=>{const {b}=await page(t,'agents');const geometry=await b.evaluate(`(()=>{let f=document.querySelector('#instance-add-form'),l=[...f.querySelectorAll('.filters>label')],c=l.map(e=>e.querySelector('input,select').getBoundingClientRect()),r=l.map(e=>e.getBoundingClientRect());return {left:l.slice(0,2).every(e=>getComputedStyle(e).alignItems==='flex-start'),separate:r[2].top>=Math.max(c[0].bottom,c[1].bottom),gap:f.querySelector('button[type=submit]').getBoundingClientRect().top-f.querySelector('details').getBoundingClientRect().bottom}})()`);assert.equal(geometry.left,true);assert.equal(geometry.separate,true);assert.ok(geometry.gap>=12);});
|
||||
test('F7 maximum length agent values wrap at 390',opts,async t=>{const {b}=await page(t,'agents',390);assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'),true);});
|
||||
test('F8 standalone proxy document mounts its settings section',opts,async t=>{const root=resolve('packages/webui/src/public'),server=http.createServer(async(req,res)=>{if(req.method!=='GET'){res.writeHead(403);return res.end();}const path=new URL(req.url,'http://fixture').pathname;if(path.startsWith('/api/')){res.setHeader('content-type','application/json');return res.end(JSON.stringify(path==='/api/config'?{send:true,devOwner:true}:path==='/api/proxy/accounts'?{accounts:[]}:path==='/api/proxy/keys'?{keys:[]}:{rows:[],sessions:[],projects:[],unread:0}));}const file=resolve(root,'.'+(path==='/'?'/index.html':path));if(!file.startsWith(root+'/')){res.writeHead(403);return res.end();}try{res.setHeader('content-type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml'})[extname(file)]||'application/octet-stream');res.end(await readFile(file));}catch{res.writeHead(404);res.end();}});await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r)));const b=await browser({machine:false});t.after(()=>b.close());await b.navigate(`http://127.0.0.1:${server.address().port}/proxy.html`);await b.evaluate('new Promise(r=>setTimeout(r,500))');assert.equal(await b.evaluate('!!document.querySelector("#proxy-accounts")'),true);});
|
||||
test('F9 add agent saving and refusal remain local and visible',opts,async t=>{const {b,wait}=await page(t,'agents');await pointer(b,'.agent-more');await pointer(b,'[data-agent-edit]');await wait('!!document.querySelector("#instance-change-form")');await input(b,'#instance-add-form [data-instance-field=instance]','new-coder');await b.evaluate(`document.querySelector('#instance-add-form select').value='coder';window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o);document.querySelector('#instance-add-form').requestSubmit()`);await wait('!!window.__answer && !!document.querySelector("#instance-add-form .settings-spinner")');assert.equal(await b.evaluate('!!document.querySelector("#instance-add-form .settings-spinner")'),true);assert.equal(await b.evaluate('document.querySelector("#instance-change-form select").disabled'),false);await b.evaluate('__answer(409,{message:"Fixture conflict",sent:false})');await wait('document.querySelector("#instances-receipt")?.textContent.includes("Fixture conflict")');assert.equal(await b.evaluate('!!document.querySelector("#instance-add-form .banner.err")'),true);assert.equal(await b.evaluate('document.querySelector("#instance-add-form .banner.err").getBoundingClientRect().bottom<=document.querySelector("#instance-add-form button[type=submit]").getBoundingClientRect().top'),true);});
|
||||
test('F10 routing widths, segmented focus, conditional proxy link and priority style',opts,async t=>{const {b,wait}=await page(t,'routing');assert.equal(await b.evaluate('[...document.querySelectorAll("#routes-table select")].every(e=>e.getBoundingClientRect().width>=132)'),true);await b.evaluate('location.hash="#/settings/appearance"');await wait('!!document.querySelector(".settings-segments")');await b.evaluate('document.querySelector("[name=theme]:checked").focus()');await b.key('ArrowLeft');assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".settings-segments")).outlineStyle!=="none"'),true);await b.evaluate('location.hash="#/settings/model-proxy"');await wait('!!document.querySelector("#proxy-accounts form")');await b.evaluate(`window.open=()=>null;__scenario['POST /api/proxy/login/start']={status:200,body:{url:'https://fixture.invalid/authorize',state:'fixture-state'}}`);await pointer(b,'#proxy-login-start button[type=submit]');await wait('document.querySelector("#proxy-authorize")?.hidden===false');assert.equal(await b.evaluate(`document.querySelector('#proxy-authorize').getBoundingClientRect().height>=44`),true);assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#proxy-accounts button")).backgroundColor===getComputedStyle(document.querySelector("#proxy-refresh")).backgroundColor'),true);});
|
||||
test('F11 Vars fields have labels and mobile agent values identify columns',opts,async t=>{const {b,wait}=await page(t,'agents',390);assert.equal(await b.evaluate('document.querySelector("#instance-add-form textarea").labels.length>0'),true);assert.match(await b.evaluate('document.querySelector("#agents-table tbody tr").innerText'),/Definition/);assert.equal(await b.evaluate('document.querySelector("#agents-table tbody td").getAttribute("headers")'), 'agent-col-definition');await b.viewport(1440,1000);await pointer(b,'.agent-more');await pointer(b,'[data-agent-edit]');await wait('!!document.querySelector("#instance-change-form")');assert.equal(await b.evaluate('document.querySelector("#instance-change-form textarea").labels.length>0'),true);});
|
||||
test('F12 collapsed section announces current draft and clears after discard',opts,async t=>{const {b,wait}=await page(t,'routing',390);await b.evaluate(`(()=>{let e=[...document.querySelectorAll('[data-route]')].find(e=>JSON.parse(e.dataset.route)[1]==='thinking');e.value='xhigh';e.dispatchEvent(new Event('change',{bubbles:true}))})()`);assert.equal(await b.evaluate('!!document.querySelector("#settings-disclosure .settings-dot")'),true);assert.match(await b.evaluate('document.querySelector("#settings-disclosure").textContent'),/unsaved changes/);await pointer(b,'#routes-reload');await wait('!document.querySelector("#settings-nav .settings-dot")');assert.equal(await b.evaluate('!!document.querySelector("#settings-disclosure .settings-dot")'),false);});
|
||||
test('F13 each browser save receipt expires independently',opts,async t=>{const {b}=await page(t,'appearance');await b.evaluate('document.querySelector("[name=theme][value=dim]").focus();document.querySelector("[name=theme][value=dim]").click();document.querySelector("[name=palette][value=grove]").focus();document.querySelector("[name=palette][value=grove]").click();document.querySelector("[name=density][value=compact]").focus();document.querySelector("[name=density][value=compact]").click()');await b.evaluate('new Promise(r=>setTimeout(r,4200))');assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".settings-browser-status")].map(e=>e.textContent)'),['','','']);});
|
||||
test('F14 settings subtitle is 16px and fallback option is None',opts,async t=>{const {b,wait}=await page(t,'appearance');assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#bus-view .page-head p")).fontSize'),'16px');await b.evaluate('location.hash="#/settings/routing"');await wait('!!document.querySelector(".fallback-cell select")');assert.equal(await b.evaluate('document.querySelector(".fallback-cell select option").textContent'),'None');});
|
||||
test('F9 assistant saving shows spinner and local refusal',opts,async t=>{const {b,wait}=await page(t,'assistant');await input(b,'[data-assistant-field=definition]','changed');await b.evaluate(`window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o);document.querySelector('.assistant-form').requestSubmit()`);await wait('!!window.__answer');assert.equal(await b.evaluate('!!document.querySelector(".assistant-form .settings-spinner")'),true);assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=name]").disabled'),false);await b.evaluate('__answer(409,{message:"Fixture conflict",sent:false})');await wait('document.querySelector("[data-assistant-receipt]")?.textContent.includes("Fixture conflict")');assert.equal(await b.evaluate('document.querySelector("[data-assistant-receipt]").classList.contains("err")'),true);});
|
||||
test('F9 proxy priority disables input and shows saving',opts,async t=>{const {b,wait}=await page(t,'model-proxy');await b.evaluate(`window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o);document.querySelector('#proxy-accounts form').requestSubmit()`);await wait('!!window.__answer');assert.equal(await b.evaluate('document.querySelector("#proxy-accounts input").disabled'),true);assert.equal(await b.evaluate('!!document.querySelector("#proxy-accounts .settings-spinner")'),true);assert.equal(await b.evaluate('document.querySelector("#proxy-apikey [name=key]").matches(":disabled")'),false);await b.evaluate('__answer(409,{error:"fixture"})');await wait('!!document.querySelector("#proxy-accounts form .banner.err")');await b.evaluate(`window.__answer=null;let f=document.querySelector('#proxy-apikey');f.elements.key.value='fixture-key';f.requestSubmit()`);await wait('!!window.__answer');assert.equal(await b.evaluate('document.querySelector("#proxy-apikey input").disabled'),true);assert.equal(await b.evaluate('!!document.querySelector("#proxy-apikey .settings-spinner")'),true);await b.evaluate('__answer(409,{error:"fixture"})');await wait('!!document.querySelector("#proxy-apikey-message.banner.err")');assert.equal(await b.evaluate('document.querySelector("#proxy-apikey input").value'), '');});
|
||||
test('F3 routing refusal remains visible and announced in the phone draft bar',opts,async t=>{const {b,wait}=await page(t,'routing',390);await b.evaluate(`__scenario['POST /api/routes']={status:409,body:{message:'Fixture conflict',sent:false}};[...document.querySelectorAll('[data-route]')].find(e=>JSON.parse(e.dataset.route)[1]==='thinking').focus()`);await b.key('ArrowDown');await wait('!!document.querySelector("#settings-disclosure .settings-dot")');await pointer(b,'#routes-form button[type=submit]');await wait('document.querySelector("#routes-receipt")?.textContent.includes("Fixture conflict")');assert.deepEqual(await b.evaluate(`(()=>{const e=document.querySelector('#routes-receipt'),r=e.getBoundingClientRect();return {visible:r.width>0&&r.height>0,role:e.getAttribute('role'),banner:e.classList.contains('err')}})()`),{visible:true,role:'status',banner:true});});
|
||||
test('F9 simultaneous assistant card refusals stay with their own drafts',opts,async t=>{const {b,wait}=await page(t,'assistant');await b.evaluate(`window.__answers={};window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answers[u]=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o)`);await input(b,'[data-assistant-field=definition]','changed');await b.evaluate('document.querySelector(".assistant-form").requestSubmit()');await wait('!!window.__answers["/api/settings"] && !!document.querySelector(".assistant-form .settings-spinner")');await input(b,'[data-assistant-field=name]','Wren');await b.key('Enter');await wait('!!window.__answers["/api/name"]');await b.evaluate(`__answers['/api/name'](409,{message:'Name refused',sent:false})`);await wait('document.querySelector("[data-assistant-kind=name]")?.textContent.includes("Name refused")');await b.evaluate(`__answers['/api/settings'](409,{message:'Settings refused',sent:false})`);await wait('document.querySelector("[data-assistant-kind=settings]")?.textContent.includes("Settings refused")');assert.match(await b.evaluate('document.querySelector("[data-assistant-kind=name]").textContent'),/Name refused/);assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=name]").value'),'Wren');assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=definition]").value'),'changed');});
|
||||
Reference in new issue
Block a user