feat: add agent context handoffs

This commit is contained in:
Emil
2026-07-19 01:32:11 +03:00
parent 428a2b942c
commit 5caf926253
6 changed files with 517 additions and 39 deletions
+59 -6
View File
@@ -14,7 +14,14 @@
.chat{flex:1;min-height:0;overflow:auto;padding:15px 15px 20px;scrollbar-color:#35445d transparent;scrollbar-width:thin}.chat-empty{display:grid;place-items:center;height:100%;color:var(--subtle);text-align:center}.chat-empty .orb{display:grid;place-items:center;width:42px;height:42px;margin:0 auto 10px;border:1px solid #315078;border-radius:13px;background:#16243a;color:#8ab9f4}.message{margin:0 0 14px}.message-label{margin:0 0 5px;color:var(--muted);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.06em}.message-body{max-width:92%;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#151e2d;color:#dce5f3;line-height:1.48;white-space:pre-wrap;overflow-wrap:anywhere}.message.user{display:flex;flex-direction:column;align-items:flex-end}.message.user .message-label{text-align:right}.message.user .message-body{border-color:#315989;background:#173254;color:#eef6ff}.message.assistant .message-body{border-color:#29384e;background:#131b28}.message.error .message-body{border-color:#6a3540;background:#321b24;color:#ffc6cb}.system-message{margin:11px auto;padding:5px 9px;width:max-content;max-width:90%;border:1px solid var(--line);border-radius:999px;background:#121a27;color:var(--muted);font-size:10px;text-align:center}.thought{margin:0 0 12px;border-left:2px solid #556987;padding-left:10px;color:var(--muted)}.thought summary{cursor:pointer;font-size:11px;font-weight:700;user-select:none}.thought-text{margin-top:7px;max-height:180px;overflow:auto;color:#8fa0b8;font-size:11px;line-height:1.45;white-space:pre-wrap}.tool-call{margin:0 0 9px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:#101824}.tool-main{display:flex;align-items:center;gap:8px}.tool-icon{display:grid;place-items:center;width:22px;height:22px;border-radius:6px;background:#1d2a3e;color:#9bbbe8}.tool-title{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#c8d5e7;font:11px ui-monospace,SFMono-Regular,Consolas,monospace}.tool-status{color:var(--muted);font-size:10px}.tool-status.in_progress{color:var(--yellow)}.tool-status.completed{color:var(--green)}.tool-status.failed{color:var(--red)}.tool-call details{margin-top:7px}.tool-call summary{color:var(--subtle);cursor:pointer;font-size:10px}.tool-detail{max-height:180px;margin:7px 0 0;padding:8px;overflow:auto;border-radius:6px;background:#0a101a;color:#8fa4c2;font:10px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
.composer{padding:11px;border-top:1px solid var(--line);background:#111925}.composer-box{display:flex;align-items:flex-end;gap:8px;padding:8px 8px 8px 11px;border:1px solid var(--line2);border-radius:11px;background:#0c121d;transition:.15s}.composer-box:focus-within{border-color:#4d709e;box-shadow:0 0 0 3px #416caa18}.composer textarea{min-height:24px;max-height:110px;flex:1;resize:none;border:0;outline:0;background:transparent;color:var(--text);line-height:1.45}.composer textarea::placeholder{color:#65748a}.send-btn{display:grid;place-items:center;width:30px;height:30px;flex:none;border:0;border-radius:8px;background:#2c66a4;color:white;cursor:pointer}.send-btn:hover{background:#3979bd}.send-btn:disabled{cursor:not-allowed;opacity:.4}.composer-foot{display:flex;align-items:center;gap:6px;margin-top:7px;color:var(--subtle);font-size:9px}.scope-chip{padding:2px 5px;border-radius:4px;background:#192438;color:#8fa4c1;font:9px ui-monospace,SFMono-Regular,Consolas,monospace}.composer-foot .spacer{flex:1}
dialog{width:min(520px,calc(100vw - 30px));padding:0;border:1px solid var(--line2);border-radius:15px;background:#111826;color:var(--text);box-shadow:0 35px 100px #000b}dialog::backdrop{background:#050812b8;backdrop-filter:blur(5px)}.dialog-head{display:flex;align-items:center;padding:17px 18px;border-bottom:1px solid var(--line)}.dialog-head h2{margin:0;font-size:16px}.dialog-body{display:grid;gap:13px;padding:18px}.field label{display:block;margin-bottom:6px;color:var(--muted);font-size:11px;font-weight:700}.field input,.field textarea{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;outline:none;background:#0b111c;color:var(--text)}.field textarea{min-height:76px;resize:vertical}.field input:focus,.field textarea:focus{border-color:#5278a8}.field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.dialog-foot{display:flex;justify-content:flex-end;gap:8px;padding:13px 18px;border-top:1px solid var(--line)}.toast-stack{position:fixed;right:18px;bottom:18px;z-index:50;display:grid;gap:8px}.toast{max-width:380px;padding:10px 12px;border:1px solid var(--line2);border-radius:9px;background:#172131;box-shadow:0 12px 35px #0008;color:#dce5f2;font-size:12px}.toast.error{border-color:#7b3b48;color:#ffc6cc}
@media(max-width:800px){.topbar{flex-wrap:wrap;padding:12px 14px}.brand{min-width:auto}.fleet-stats{order:3;width:100%}.toolbar{gap:6px}.search{width:180px}.fleet{padding:13px}.agent-card{max-height:none;height:calc(100vh - 150px);min-height:560px}.field-row{grid-template-columns:1fr}}
.count-badge{display:inline-block;min-width:17px;margin-left:6px;padding:1px 6px;border-radius:999px;background:#28598f;color:#fff;font-size:10px;font-weight:800;text-align:center}
.handoff-entry{margin:0 0 14px;max-width:92%;padding:9px 12px;border:1px solid #3d3658;border-left:3px solid var(--violet);border-radius:10px;background:#151a2c}.handoff-head{display:flex;align-items:center;gap:7px;margin-bottom:4px;color:var(--violet);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}.handoff-source{color:var(--muted);font-weight:600;text-transform:none;letter-spacing:0}.handoff-title{margin:0 0 4px;color:var(--text);font-size:12.5px;font-weight:700}.handoff-text{color:#d5dcee;font-size:13px;line-height:1.48;white-space:pre-wrap;overflow-wrap:anywhere}
.segmented{display:flex;gap:4px;padding:3px;border:1px solid var(--line);border-radius:9px;background:#0b111c}.segmented button{flex:1;padding:6px 4px;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer;font-size:11px;font-weight:700;transition:.14s}.segmented button:hover{color:var(--text)}.segmented button.active{border-color:#456fa8;background:#1d3252;color:#eaf2ff}
.recipient-grid{display:flex;flex-wrap:wrap;gap:6px}.recipient-chip{display:flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;background:#0e1522;color:#c6d2e5;font-size:11.5px;cursor:pointer;user-select:none}.recipient-chip input{margin:0;accent-color:#6ea8fe}.recipient-chip:has(input:checked){border-color:#456fa8;background:#1a2c47;color:#eaf2ff}.recipient-chip:has(input:disabled){opacity:.75;cursor:default}
.hint{color:var(--subtle);font-size:11px;line-height:1.5}.label-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.label-row label{margin-bottom:0}.link-btn{padding:0;border:0;background:none;color:var(--blue);cursor:pointer;font-size:10.5px;font-weight:700}.link-btn:hover{text-decoration:underline}.field-error{min-height:15px;color:var(--red);font-size:11px}
dialog.drawer{width:min(600px,96vw);max-width:96vw;height:100%;max-height:100%;margin:0 0 0 auto;border:0;border-left:1px solid var(--line2);border-radius:0}dialog.drawer[open]{animation:drawer-in .18s ease}@keyframes drawer-in{from{transform:translateX(50px);opacity:0}}.drawer .dialog-head{position:sticky;top:0;z-index:2;background:#111826}
.drawer-list{display:grid;gap:9px;padding:16px 18px;overflow:auto}.handoff-record{padding:11px 12px;border:1px solid var(--line);border-radius:10px;background:#0e1522}.hr-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.hr-route{min-width:0;color:#dce5f3;font-size:12px;overflow:hidden;text-overflow:ellipsis}.hr-route strong{color:var(--text)}.hr-arrow{margin:0 5px;color:var(--violet)}.kind-chip{padding:2px 7px;border:1px solid var(--line2);border-radius:999px;color:#c6d2e5;font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.05em}.h-status{padding:2px 8px;border:1px solid;border-radius:999px;font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}.h-status.preparing,.h-status.delivering{color:var(--yellow);border-color:#6b5a2e;background:#2a2314}.h-status.delivered{color:var(--green);border-color:#2e5c46;background:#12291e}.h-status.partial{color:var(--violet);border-color:#4b3f6e;background:#1c1830}.h-status.failed{color:var(--red);border-color:#6a3540;background:#2a151a}.hr-time{margin-left:auto;color:var(--subtle);font-size:10px;white-space:nowrap}.hr-title{margin-top:5px;color:var(--text);font-size:12px;font-weight:700}.hr-details{margin-top:7px}.hr-details summary{cursor:pointer;color:var(--muted);font-size:11px;font-weight:700;user-select:none}.hr-content{margin:6px 0 0;max-height:220px;overflow:auto;padding:8px;border:1px solid var(--line);border-radius:8px;background:#0b111c;color:#c9d5e6;font:11px ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.hr-error{margin-top:6px;color:var(--red);font-size:11px}.hr-deliveries{display:grid;gap:3px;margin-top:6px;color:var(--muted);font-size:11px}.hr-deliveries .dl-ok{color:var(--green)}.hr-deliveries .dl-wait{color:var(--yellow)}.hr-deliveries .dl-fail{color:var(--red)}
@media(max-width:800px){.topbar{flex-wrap:wrap;padding:12px 14px}.brand{min-width:auto}.fleet-stats{order:3;width:100%}.toolbar{gap:6px}.search{width:180px}.fleet{padding:13px}.agent-card{max-height:none;height:calc(100vh - 150px);min-height:560px}.field-row{grid-template-columns:1fr}dialog.drawer{width:100vw;max-width:100vw}.segmented{flex-wrap:wrap}.segmented button{flex:1 1 31%}}
</style>
</head>
<body>
@@ -24,6 +31,7 @@
<div class="toolbar">
<label class="search-wrap"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><input id="search" class="search" placeholder="Search agents"></label>
<button id="densityBtn" class="icon-btn" title="Toggle compact grid">▦</button>
<button id="handoffsBtn" class="quiet-btn" title="Handoff history">⇄ Handoffs<span id="handoffCount" class="count-badge hidden">0</span></button>
<button id="newAgentBtn" class="primary-btn"><span>+</span> New agent</button>
</div>
</header>
@@ -40,11 +48,35 @@
<div class="dialog-foot"><button type="button" class="quiet-btn" data-close-dialog>Cancel</button><button type="submit" class="primary-btn">Start instance</button></div>
</form>
</dialog>
<dialog id="handoffDialog">
<form id="handoffForm" method="dialog">
<div class="dialog-head"><h2>Hand off · <span id="handoffFromName">agent</span></h2><button type="button" class="mini-btn" data-close-dialog style="margin-left:auto">✕</button></div>
<div class="dialog-body">
<div class="field"><div class="label-row"><label>Recipients</label><button type="button" id="handoffSelectAll" class="link-btn">Select all</button></div><div id="handoffRecipients" class="recipient-grid"></div></div>
<div class="field"><label>Kind</label><div id="handoffKinds" class="segmented"><button type="button" data-kind="message" class="active">Message</button><button type="button" data-kind="context">Context</button><button type="button" data-kind="delegate">Delegate</button><button type="button" data-kind="review">Review</button><button type="button" data-kind="broadcast">Broadcast</button></div><div id="handoffKindHint" class="hint" style="margin-top:6px"></div></div>
<div class="field"><label for="handoffTitle">Title · optional</label><input id="handoffTitle" maxlength="120" placeholder="Short summary, e.g. Auth refactor context"></div>
<div class="field" id="handoffMessageField"><label for="handoffMessage">Message</label><textarea id="handoffMessage" placeholder="Direct note delivered as-is to each recipient…"></textarea></div>
<div class="field hidden" id="handoffInstructionsField"><label for="handoffInstructions">Handoff instructions</label><textarea id="handoffInstructions" placeholder="What the handoff should contain, e.g. decisions made, files touched, what to do next…"></textarea></div>
<div id="handoffError" class="field-error"></div>
</div>
<div class="dialog-foot"><button type="button" class="quiet-btn" data-close-dialog>Cancel</button><button type="submit" id="handoffSend" class="primary-btn">Send handoff</button></div>
</form>
</dialog>
<dialog id="handoffHistory" class="drawer">
<div class="dialog-head"><h2>Handoffs</h2><button type="button" class="mini-btn" data-close-dialog style="margin-left:auto">✕</button></div>
<div id="handoffHistoryList" class="drawer-list"></div>
</dialog>
<div id="toasts" class="toast-stack"></div>
<script>
const grid=document.querySelector('#agentGrid'),stats=document.querySelector('#fleetStats'),search=document.querySelector('#search'),dialog=document.querySelector('#agentDialog'),form=document.querySelector('#agentForm'),toasts=document.querySelector('#toasts');
const state={agents:[],chats:new Map(),drafts:new Map(),focused:null,dense:localStorage.getItem('kimiFleetDensity')==='dense',refreshing:false,pendingAgents:new Set(),refreshTimer:null};
const handoffDialog=document.querySelector('#handoffDialog'),handoffForm=document.querySelector('#handoffForm'),historyDialog=document.querySelector('#handoffHistory'),historyList=document.querySelector('#handoffHistoryList'),handoffCount=document.querySelector('#handoffCount');
const handoffKindInfo={message:'Delivered as-is into each recipient’s context — nothing is generated.',context:'The source agent generates a focused context brief from your instructions, so recipients see what matters.',delegate:'Hands off a task: your instructions become an actionable assignment for recipients to execute.',review:'Asks recipients to review the source agent’s recent work — instructions focus what they should check.',broadcast:'Goes to every running agent at once — all other agents are selected automatically.'};
const hStatusClass=status=>['preparing','delivering','delivered','partial','failed'].includes(status)?status:'preparing';
const state={agents:[],chats:new Map(),drafts:new Map(),focused:null,dense:localStorage.getItem('kimiFleetDensity')==='dense',refreshing:false,pendingAgents:new Set(),refreshTimer:null,handoffs:[],handoffFrom:null,handoffKind:'message',handoffSending:false,handoffTouched:false};
const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const fmtTokens=value=>value>=1000?`${(value/1000).toFixed(value>=100000?0:1)}k`:String(value||0);
const icon=name=>({read:'◇',execute:'›_',edit:'✎',search:'⌕',delete:'−',fetch:'↓',think:'◌',move:'↝'}[name]||'·');
@@ -69,7 +101,7 @@
function card(agent){
const chat=state.chats.get(agent.name)||[],percent=Math.min(100,Number(agent.contextPercent)||0),contextClass=percent>=90?'danger':percent>=70?'warn':'',draft=state.drafts.get(agent.name)||'';
return `<article class="agent-card" data-agent="${esc(agent.name)}">
<header class="agent-head"><div class="head-row"><div class="agent-avatar">K</div><div class="agent-identity"><div class="agent-name">${esc(agent.name)}</div><div class="agent-meta">${esc(agent.role)} · ${esc(agent.workspace)}</div></div><span class="state-pill ${esc(agent.state)}"><i></i>${esc(agent.state)}</span><div class="agent-actions">${agent.state==='working'?`<button class="mini-btn" data-action="cancel" data-agent="${esc(agent.name)}" title="Cancel current task">■</button>`:''}<button class="mini-btn" data-action="compact" data-agent="${esc(agent.name)}" title="Compact context">↯</button><button class="mini-btn" data-action="reset" data-agent="${esc(agent.name)}" title="Fresh context">↺</button><button class="mini-btn" data-action="focus" data-agent="${esc(agent.name)}" title="${state.focused===agent.name?'Exit focus':'Focus'}">${state.focused===agent.name?'⊙':'⛶'}</button><button class="mini-btn" data-action="stop" data-agent="${esc(agent.name)}" title="Stop agent">×</button></div></div>
<header class="agent-head"><div class="head-row"><div class="agent-avatar">K</div><div class="agent-identity"><div class="agent-name">${esc(agent.name)}</div><div class="agent-meta">${esc(agent.role)} · ${esc(agent.workspace)}</div></div><span class="state-pill ${esc(agent.state)}"><i></i>${esc(agent.state)}</span><div class="agent-actions">${agent.state==='working'?`<button class="mini-btn" data-action="cancel" data-agent="${esc(agent.name)}" title="Cancel current task">■</button>`:''}<button class="mini-btn" data-action="compact" data-agent="${esc(agent.name)}" title="Compact context">↯</button><button class="mini-btn" data-action="reset" data-agent="${esc(agent.name)}" title="Fresh context">↺</button><button class="mini-btn" data-action="handoff" data-agent="${esc(agent.name)}" title="Hand off to other agents">⇄</button><button class="mini-btn" data-action="focus" data-agent="${esc(agent.name)}" title="${state.focused===agent.name?'Exit focus':'Focus'}">${state.focused===agent.name?'⊙':'⛶'}</button><button class="mini-btn" data-action="stop" data-agent="${esc(agent.name)}" title="Stop agent">×</button></div></div>
<div class="activity-line ${esc(agent.state)}"><span class="pulse"></span><span class="activity-text" title="${esc(agent.activity)}">${esc(agent.activity||'Waiting')}</span>${agent.queueDepth?`<span class="queue">${agent.queueDepth} queued</span>`:''}</div>
<div class="context-row" title="Estimated context usage"><div class="context-track"><div class="context-fill ${contextClass}" style="width:${percent}%"></div></div><span class="context-label">~${fmtTokens(agent.contextTokens)} / ${fmtTokens(agent.maxContextTokens)} · ${percent.toFixed(1)}%</span></div></header>
<section class="chat" data-chat="${esc(agent.name)}">${chat.length?chat.map(message).join(''):`<div class="chat-empty"><div><div class="orb">K</div>No messages in this context yet</div></div>`}</section>
@@ -81,6 +113,7 @@
if(item.type==='system')return `<div class="system-message">${esc(item.text)}</div>`;
if(item.type==='thought')return `<details class="thought" data-entry="${esc(item.id)}"><summary>Thinking</summary><div class="thought-text">${esc(item.text)}</div></details>`;
if(item.type==='tool')return `<div class="tool-call"><div class="tool-main"><span class="tool-icon">${icon(item.toolKind)}</span><span class="tool-title" title="${esc(item.title)}">${esc(item.title||item.toolKind||'Tool call')}</span><span class="tool-status ${esc(item.status)}">${esc((item.status||'pending').replace('_',' '))}</span></div>${item.text?`<details data-entry="${esc(item.id)}"><summary>Details</summary><pre class="tool-detail">${esc(item.text)}</pre></details>`:''}</div>`;
if(item.type==='handoff')return `<div class="handoff-entry"><div class="handoff-head"><span>⇄</span><span>${esc(item.kind||'handoff')}</span>${item.source?`<span class="handoff-source">from ${esc(item.source)}</span>`:''}</div>${item.title?`<div class="handoff-title">${esc(item.title)}</div>`:''}<div class="handoff-text">${esc(item.text)}</div></div>`;
const role=item.type==='user'?'You':item.type==='error'?'Error':'Kimi';return `<div class="message ${esc(item.type)}"><div class="message-label">${role}</div><div class="message-body">${esc(item.text)}</div></div>`;
}
@@ -100,13 +133,33 @@
grid.addEventListener('input',event=>{if(!event.target.matches('[data-composer]'))return;state.drafts.set(event.target.dataset.composer,event.target.value);event.target.style.height='auto';event.target.style.height=`${Math.min(110,event.target.scrollHeight)}px`;const button=event.target.closest('.composer').querySelector('[data-action="send"]');button.disabled=!event.target.value.trim()});
grid.addEventListener('keydown',event=>{if(event.target.matches('[data-composer]')&&event.key==='Enter'&&!event.shiftKey){event.preventDefault();send(event.target.dataset.composer)}});
grid.addEventListener('click',async event=>{const button=event.target.closest('[data-action]');if(!button)return;const {action,agent}=button.dataset;try{if(action==='send')await send(agent);else if(action==='focus'){state.focused=state.focused===agent?null:agent;render()}else if(action==='cancel'){await api(`/agents/${encodeURIComponent(agent)}/cancel`,{method:'POST',body:'{}'});toast(`${agent}: cancellation requested`)}else if(action==='compact'){await api(`/agents/${encodeURIComponent(agent)}/context/compact`,{method:'POST',body:JSON.stringify({instructions:'Preserve decisions, constraints, current task, changed files, and pending verification.'})});toast(`${agent}: context compaction queued`)}else if(action==='reset'){if(confirm(`Create a fresh context for ${agent}? Files are not changed.`)){await api(`/agents/${encodeURIComponent(agent)}/context/reset`,{method:'POST',body:'{}'});toast(`${agent}: fresh context created`);scheduleRefresh()}}else if(action==='stop'){if(confirm(`Stop ${agent} and release its scopes?`)){await api(`/agents/${encodeURIComponent(agent)}/stop`,{method:'POST',body:'{}'});if(state.focused===agent)state.focused=null;toast(`${agent} stopped`);scheduleRefresh()}}}catch(error){toast(error.message,true)}});
grid.addEventListener('click',async event=>{const button=event.target.closest('[data-action]');if(!button)return;const {action,agent}=button.dataset;try{if(action==='send')await send(agent);else if(action==='handoff')openHandoffDialog(agent);else if(action==='focus'){state.focused=state.focused===agent?null:agent;render()}else if(action==='cancel'){await api(`/agents/${encodeURIComponent(agent)}/cancel`,{method:'POST',body:'{}'});toast(`${agent}: cancellation requested`)}else if(action==='compact'){await api(`/agents/${encodeURIComponent(agent)}/context/compact`,{method:'POST',body:JSON.stringify({instructions:'Preserve decisions, constraints, current task, changed files, and pending verification.'})});toast(`${agent}: context compaction queued`)}else if(action==='reset'){if(confirm(`Create a fresh context for ${agent}? Files are not changed.`)){await api(`/agents/${encodeURIComponent(agent)}/context/reset`,{method:'POST',body:'{}'});toast(`${agent}: fresh context created`);scheduleRefresh()}}else if(action==='stop'){if(confirm(`Stop ${agent} and release its scopes?`)){await api(`/agents/${encodeURIComponent(agent)}/stop`,{method:'POST',body:'{}'});if(state.focused===agent)state.focused=null;toast(`${agent} stopped`);scheduleRefresh()}}}catch(error){toast(error.message,true)}});
async function send(agent){const prompt=(state.drafts.get(agent)||'').trim();if(!prompt)return;state.drafts.set(agent,'');render();try{await api(`/agents/${encodeURIComponent(agent)}/prompt`,{method:'POST',body:JSON.stringify({prompt})});scheduleRefresh()}catch(error){state.drafts.set(agent,prompt);render();toast(error.message,true)}}
document.querySelector('#newAgentBtn').addEventListener('click',()=>{document.querySelector('#agentWorkspace').value=localStorage.getItem('kimiFleetWorkspace')||state.agents[0]?.workspace||'';dialog.showModal()});document.querySelectorAll('[data-close-dialog]').forEach(button=>button.addEventListener('click',()=>dialog.close()));
document.querySelector('#newAgentBtn').addEventListener('click',()=>{document.querySelector('#agentWorkspace').value=localStorage.getItem('kimiFleetWorkspace')||state.agents[0]?.workspace||'';dialog.showModal()});document.querySelectorAll('[data-close-dialog]').forEach(button=>button.addEventListener('click',()=>button.closest('dialog')?.close()));
form.addEventListener('submit',async event=>{event.preventDefault();const data=new FormData(form),payload={name:data.get('name').trim(),workspace:data.get('workspace').trim(),role:data.get('role').trim()||'worker',scopes:data.get('scopes').split(/[,\n]/).map(value=>value.trim()).filter(Boolean)};try{await api('/agents',{method:'POST',body:JSON.stringify(payload)});localStorage.setItem('kimiFleetWorkspace',payload.workspace);dialog.close();form.reset();toast(`${payload.name} started`);scheduleRefresh()}catch(error){toast(error.message,true)}});
document.querySelector('#densityBtn').addEventListener('click',()=>{state.dense=!state.dense;localStorage.setItem('kimiFleetDensity',state.dense?'dense':'comfortable');render()});search.addEventListener('input',render);
const source=new EventSource('/events');source.onmessage=event=>{try{scheduleRefresh(JSON.parse(event.data).agent)}catch{scheduleRefresh()}};source.onerror=()=>setTimeout(()=>scheduleRefresh(),1000);refresh();setInterval(()=>refresh([]),4000);
function fmtTime(value){const date=new Date(value);return Number.isNaN(date.getTime())?'':date.toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
function handoffRecipients(){return[...document.querySelectorAll('#handoffRecipients input:checked')].map(box=>box.value)}
function validateHandoff(){const isMessage=state.handoffKind==='message';let error='';if(!handoffRecipients().length)error='Select at least one recipient.';else if(isMessage&&!document.querySelector('#handoffMessage').value.trim())error='Write a message to send.';else if(!isMessage&&!document.querySelector('#handoffInstructions').value.trim())error='Describe what the handoff should contain.';document.querySelector('#handoffError').textContent=state.handoffTouched?error:'';document.querySelector('#handoffSend').disabled=Boolean(error)||state.handoffSending;return!error}
function setHandoffSending(sending){state.handoffSending=sending;document.querySelector('#handoffSend').textContent=sending?'Sending…':'Send handoff';validateHandoff()}
function syncBroadcast(){const broadcast=state.handoffKind==='broadcast';document.querySelectorAll('#handoffRecipients input').forEach(box=>{if(broadcast)box.checked=true;box.disabled=broadcast});document.querySelector('#handoffSelectAll').classList.toggle('hidden',broadcast)}
function syncKindUI(){document.querySelectorAll('#handoffKinds button').forEach(button=>button.classList.toggle('active',button.dataset.kind===state.handoffKind));document.querySelector('#handoffKindHint').textContent=handoffKindInfo[state.handoffKind];const isMessage=state.handoffKind==='message';document.querySelector('#handoffMessageField').classList.toggle('hidden',!isMessage);document.querySelector('#handoffInstructionsField').classList.toggle('hidden',isMessage);syncBroadcast();validateHandoff()}
function openHandoffDialog(from){state.handoffFrom=from;state.handoffKind='message';state.handoffSending=false;state.handoffTouched=false;handoffForm.reset();document.querySelector('#handoffFromName').textContent=from;document.querySelector('#handoffError').textContent='';document.querySelector('#handoffSend').textContent='Send handoff';const others=state.agents.filter(agent=>agent.name!==from&&!['starting','stopped'].includes(agent.state));document.querySelector('#handoffRecipients').innerHTML=others.length?others.map(agent=>`<label class="recipient-chip"><input type="checkbox" value="${esc(agent.name)}">${esc(agent.name)}</label>`).join(''):'<span class="hint">No other agents running yet.</span>';syncKindUI();handoffDialog.showModal()}
async function fetchHandoffs(){try{const records=await api('/handoffs');if(Array.isArray(records)){state.handoffs=records;renderHandoffBadge();if(historyDialog.open)renderHandoffHistory()}}catch{}}
function scheduleHandoffRefresh(){clearTimeout(scheduleHandoffRefresh.timer);scheduleHandoffRefresh.timer=setTimeout(fetchHandoffs,450)}
function renderHandoffBadge(){const count=state.handoffs.length;handoffCount.textContent=count>99?'99+':String(count);handoffCount.classList.toggle('hidden',count===0)}
function deliveryLines(deliveries){if(!deliveries)return[];const entries=Array.isArray(deliveries)?deliveries.map(d=>[d?.recipient??d?.name??d?.agent??'?',d]):Object.entries(deliveries);return entries.map(([name,d])=>{const error=typeof d==='string'?null:d?.error||null,status=typeof d==='string'?d:d?.status||d?.state||'pending';return{name,error,status,failed:Boolean(error||status==='failed')}})}
function renderHandoffHistory(){const open=new Set([...historyList.querySelectorAll('details[open][data-h]')].map(node=>node.dataset.h));historyList.innerHTML=state.handoffs.length?state.handoffs.map(h=>{const status=h.status||'preparing',deliveries=deliveryLines(h.deliveries),issues=h.error||deliveries.some(d=>d.failed);return `<div class="handoff-record"><div class="hr-top"><span class="hr-route"><strong>${esc(h.from)}</strong><span class="hr-arrow">→</span>${esc((h.recipients||[]).join(', '))}</span><span class="kind-chip">${esc(h.kind||'message')}</span><span class="h-status ${hStatusClass(status)}">${esc(status)}</span><span class="hr-time">${esc(fmtTime(h.createdAt))}</span></div>${h.title?`<div class="hr-title">${esc(h.title)}</div>`:''}<details class="hr-details" data-h="${esc(h.id)}"${open.has(String(h.id))?' open':''}><summary>Content${issues?' · issues':''}</summary><pre class="hr-content">${esc(h.content||'(no content yet)')}</pre>${h.error?`<div class="hr-error">⚠ ${esc(h.error)}</div>`:''}${deliveries.length?`<div class="hr-deliveries">${deliveries.map(d=>{const done=d.status==='delivered';return`<div class="${d.failed?'dl-fail':done?'dl-ok':'dl-wait'}">${d.failed?'✗':done?'✓':'·'} ${esc(d.name)} · ${esc(d.status)}${d.error?` — ${esc(d.error)}`:''}</div>`}).join('')}</div>`:''}</details></div>`}).join(''):'<div class="hint">No handoffs yet — use the ⇄ action on any agent card.</div>'}
document.querySelector('#handoffsBtn').addEventListener('click',()=>{renderHandoffHistory();historyDialog.showModal();fetchHandoffs()});
document.querySelector('#handoffKinds').addEventListener('click',event=>{const button=event.target.closest('button[data-kind]');if(!button)return;state.handoffTouched=true;state.handoffKind=button.dataset.kind;syncKindUI()});
document.querySelector('#handoffRecipients').addEventListener('change',()=>{state.handoffTouched=true;validateHandoff()});
document.querySelector('#handoffSelectAll').addEventListener('click',()=>{state.handoffTouched=true;document.querySelectorAll('#handoffRecipients input').forEach(box=>box.checked=true);validateHandoff()});
document.querySelector('#handoffMessage').addEventListener('input',()=>{state.handoffTouched=true;validateHandoff()});
document.querySelector('#handoffInstructions').addEventListener('input',()=>{state.handoffTouched=true;validateHandoff()});
handoffForm.addEventListener('submit',async event=>{event.preventDefault();state.handoffTouched=true;if(!validateHandoff()||state.handoffSending)return;const recipients=handoffRecipients(),title=document.querySelector('#handoffTitle').value.trim(),payload={recipients,kind:state.handoffKind};if(title)payload.title=title;if(state.handoffKind==='message')payload.message=document.querySelector('#handoffMessage').value.trim();else payload.instructions=document.querySelector('#handoffInstructions').value.trim();setHandoffSending(true);try{await api(`/agents/${encodeURIComponent(state.handoffFrom)}/handoff`,{method:'POST',body:JSON.stringify(payload)});handoffDialog.close();toast(`Handoff sent to ${recipients.join(', ')}`);scheduleRefresh();fetchHandoffs()}catch(error){toast(error.message,true);document.querySelector('#handoffError').textContent=error.message;setHandoffSending(false)}});
const source=new EventSource('/events');source.onmessage=event=>{try{const update=JSON.parse(event.data);scheduleRefresh(update.agent);if(update.kind==='handoff')scheduleHandoffRefresh()}catch{scheduleRefresh()}};source.onerror=()=>setTimeout(()=>scheduleRefresh(),1000);refresh();fetchHandoffs();setInterval(()=>refresh([]),4000);
</script>
</body>
</html>