Files
KimiFleet/Dashboard.html
2026-07-19 01:32:11 +03:00

166 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KimiFleet</title>
<style>
:root{color-scheme:dark;--bg:#090d16;--surface:#101622;--surface2:#151d2c;--surface3:#1b2537;--line:#263247;--line2:#34435d;--text:#edf2fb;--muted:#8b9ab2;--subtle:#66748b;--blue:#6ea8fe;--cyan:#65d6d0;--green:#52d39a;--yellow:#f4bf64;--red:#ff737d;--violet:#a78bfa;--shadow:0 18px 55px #0005}
*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:radial-gradient(900px 500px at 50% -200px,#213353 0,#0d1320 50%,var(--bg) 100%);color:var(--text);font:14px Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow-x:hidden}button,input,textarea{font:inherit}button{color:inherit}.hidden{display:none!important}
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:18px;min-height:72px;padding:14px 26px;background:#0b101bd9;border-bottom:1px solid var(--line);backdrop-filter:blur(18px)}.brand{display:flex;align-items:center;gap:11px;min-width:210px}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid #41618f;border-radius:10px;background:linear-gradient(145deg,#223c63,#111a2a);color:#bcd6ff;font-weight:900;box-shadow:inset 0 1px #ffffff14,0 7px 22px #0005}.brand h1{margin:0;font-size:17px;letter-spacing:-.3px}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.fleet-stats{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}.stat{display:flex;align-items:center;gap:6px;padding:6px 9px;border:1px solid var(--line);border-radius:999px;background:#101725}.stat-dot{width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 9px var(--green)}.toolbar{display:flex;align-items:center;gap:8px;margin-left:auto}.search-wrap{position:relative}.search-wrap svg{position:absolute;left:11px;top:10px;color:var(--subtle)}.search{width:min(28vw,320px);height:36px;padding:0 12px 0 34px;border:1px solid var(--line);border-radius:9px;outline:none;background:#0e1522;color:var(--text)}.search:focus{border-color:#4c6f9f;box-shadow:0 0 0 3px #4e7fbd1d}.icon-btn,.primary-btn,.quiet-btn{height:36px;border:1px solid var(--line);border-radius:9px;background:#131b29;cursor:pointer;transition:.16s ease}.icon-btn{display:grid;place-items:center;width:36px;padding:0}.icon-btn:hover,.quiet-btn:hover{border-color:var(--line2);background:#1a2434}.primary-btn{display:flex;align-items:center;gap:7px;padding:0 13px;border-color:#456fa8;background:#28598f;color:white;font-weight:700}.primary-btn:hover{background:#326ba7}.quiet-btn{padding:0 11px;color:#c6d2e5}
.fleet{padding:20px 26px 32px}.agent-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(520px,100%),1fr));gap:16px;align-items:start}.agent-grid.dense{grid-template-columns:repeat(auto-fit,minmax(min(400px,100%),1fr))}.agent-grid.focused{display:block;max-width:1180px;margin:0 auto}.empty-fleet{display:grid;place-items:center;min-height:55vh;border:1px dashed var(--line2);border-radius:18px;color:var(--muted);text-align:center}.empty-fleet strong{display:block;margin-bottom:6px;color:var(--text);font-size:17px}
.agent-card{display:flex;flex-direction:column;min-height:650px;max-height:calc(100vh - 112px);overflow:hidden;border:1px solid var(--line);border-radius:15px;background:linear-gradient(180deg,#121a28,#0e1521);box-shadow:var(--shadow)}.dense .agent-card{min-height:520px}.focused .agent-card{height:calc(100vh - 112px);max-height:none}.agent-head{padding:13px 14px 11px;border-bottom:1px solid var(--line);background:#141c2a}.head-row{display:flex;align-items:center;gap:9px}.agent-avatar{display:grid;place-items:center;width:31px;height:31px;border:1px solid #36527a;border-radius:9px;background:#172943;color:#9ec8ff;font-weight:900}.agent-identity{min-width:0}.agent-name{font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-meta{margin-top:2px;color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-actions{display:flex;gap:5px;margin-left:auto}.mini-btn{display:grid;place-items:center;width:29px;height:29px;padding:0;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer}.mini-btn:hover{border-color:var(--line);background:var(--surface3);color:var(--text)}.state-pill{display:flex;align-items:center;gap:6px;margin-left:5px;padding:4px 8px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}.state-pill i{width:6px;height:6px;border-radius:50%;background:var(--muted)}.state-pill.working{color:var(--yellow)}.state-pill.working i{background:var(--yellow);box-shadow:0 0 8px var(--yellow)}.state-pill.ready{color:var(--green)}.state-pill.ready i{background:var(--green)}.state-pill.error{color:var(--red)}.state-pill.error i{background:var(--red)}
.activity-line{display:flex;align-items:center;gap:8px;margin-top:10px;color:#cbd8ea;font-size:12px}.activity-line .pulse{width:6px;height:6px;border-radius:50%;background:var(--yellow);box-shadow:0 0 9px var(--yellow)}.activity-line.ready .pulse{background:var(--green);box-shadow:0 0 9px var(--green)}.activity-line.error .pulse,.activity-line.stopped .pulse{background:var(--red);box-shadow:0 0 9px var(--red)}.activity-text{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue{margin-left:auto;color:var(--violet);font-size:10px}.context-row{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:10px}.context-track{height:3px;overflow:hidden;border-radius:4px;background:#253044}.context-fill{height:100%;border-radius:4px;background:var(--cyan);transition:width .3s}.context-fill.warn{background:var(--yellow)}.context-fill.danger{background:var(--red)}.context-label{color:var(--muted);font:10px ui-monospace,SFMono-Regular,Consolas,monospace}
.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}
.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>
<header class="topbar">
<div class="brand"><div class="brand-mark">K</div><div><h1>KimiFleet</h1><small>K3 context cockpit</small></div></div>
<div id="fleetStats" class="fleet-stats"></div>
<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>
<main class="fleet"><section id="agentGrid" class="agent-grid"></section></main>
<dialog id="agentDialog">
<form id="agentForm" method="dialog">
<div class="dialog-head"><h2>Start Kimi K3 instance</h2><button type="button" class="mini-btn" data-close-dialog style="margin-left:auto"></button></div>
<div class="dialog-body">
<div class="field-row"><div class="field"><label for="agentName">Name</label><input id="agentName" name="name" required pattern="[A-Za-z0-9_-]+" placeholder="renderer-2"></div><div class="field"><label for="agentRole">Role</label><input id="agentRole" name="role" placeholder="reviewer"></div></div>
<div class="field"><label for="agentWorkspace">Workspace</label><input id="agentWorkspace" name="workspace" required placeholder="/path/to/project"></div>
<div class="field"><label for="agentScopes">Exclusive scopes · one per line</label><textarea id="agentScopes" name="scopes" placeholder="src/Engine.AI&#10;tests/Engine.Tests"></textarea></div>
</div>
<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 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 recipients 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 agents 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]||'·');
function toast(message,error=false){const node=document.createElement('div');node.className=`toast${error?' error':''}`;node.textContent=message;toasts.append(node);setTimeout(()=>node.remove(),4200)}
async function api(path,options={}){const response=await fetch(path,{headers:{'Content-Type':'application/json',...(options.headers||{})},...options});let body=null;try{body=await response.json()}catch{}if(!response.ok)throw new Error(body?.error||`${response.status} ${response.statusText}`);return body}
function rememberEditor(){const active=document.activeElement;if(!active?.dataset?.composer)return null;return {name:active.dataset.composer,start:active.selectionStart,end:active.selectionEnd}}
function restoreEditor(saved){if(!saved)return;const editor=document.querySelector(`[data-composer="${CSS.escape(saved.name)}"]`);if(editor){editor.focus();editor.setSelectionRange(saved.start,saved.end)}}
function nearBottom(element){return element.scrollHeight-element.scrollTop-element.clientHeight<90}
function render(){
const editor=rememberEditor(),scrolls=new Map([...document.querySelectorAll('.chat')].map(node=>[node.dataset.chat,{top:node.scrollTop,bottom:nearBottom(node)}])),openEntries=new Set([...document.querySelectorAll('details[open][data-entry]')].map(node=>node.dataset.entry));
const query=search.value.trim().toLowerCase();let agents=state.agents.filter(agent=>!query||`${agent.name} ${agent.role} ${agent.workspace} ${(agent.scopes||[]).join(' ')}`.toLowerCase().includes(query));
agents.sort((a,b)=>Number(b.state==='working')-Number(a.state==='working')||a.name.localeCompare(b.name));if(state.focused)agents=agents.filter(agent=>agent.name===state.focused);
grid.className=`agent-grid${state.dense?' dense':''}${state.focused?' focused':''}`;
stats.innerHTML=`<span class="stat"><span class="stat-dot"></span>${state.agents.length} instance${state.agents.length===1?'':'s'}</span><span class="stat">${state.agents.filter(a=>a.state==='working').length} working</span>`;
grid.innerHTML=agents.length?agents.map(card).join(''):`<div class="empty-fleet"><div><strong>${state.agents.length?'No matching agents':'No Kimi instances yet'}</strong>${state.agents.length?'Try another search.':'Start a scoped K3 agent to begin.'}</div></div>`;
bindAutoResize();restoreEditor(editor);document.querySelectorAll('details[data-entry]').forEach(node=>node.open=openEntries.has(node.dataset.entry));
document.querySelectorAll('.chat').forEach(node=>{const saved=scrolls.get(node.dataset.chat);if(saved?.bottom||!saved)node.scrollTop=node.scrollHeight;else node.scrollTop=saved.top});
}
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="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>
<footer class="composer"><div class="composer-box"><textarea rows="1" data-composer="${esc(agent.name)}" placeholder="Message ${esc(agent.name)}…">${esc(draft)}</textarea><button class="send-btn" data-action="send" data-agent="${esc(agent.name)}" ${draft.trim()?'':'disabled'} title="Send task">↑</button></div><div class="composer-foot"><span>Enter to send · Shift+Enter newline</span><span class="spacer"></span>${(agent.scopes||[]).slice(0,2).map(scope=>`<span class="scope-chip">${esc(scope)}</span>`).join('')}</div></footer>
</article>`;
}
function message(item){
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>`;
}
async function refresh(requestedAgents=null){
if(state.refreshing){(requestedAgents||[]).forEach(name=>state.pendingAgents.add(name));return}
state.refreshing=true;
try{
const agents=await api('/agents'),activeNames=new Set(agents.map(agent=>agent.name)),knownNames=new Set(state.agents.map(agent=>agent.name));
const targets=requestedAgents===null?agents.map(agent=>agent.name):[...new Set([...(requestedAgents||[]),...agents.filter(agent=>!knownNames.has(agent.name)).map(agent=>agent.name)])];
const chats=new Map(state.chats);for(const name of [...chats.keys()])if(!activeNames.has(name))chats.delete(name);
await Promise.all(targets.filter(name=>activeNames.has(name)).map(async name=>chats.set(name,await api(`/agents/${encodeURIComponent(name)}/chat`))));
state.agents=agents;state.chats=chats;render();
}catch(error){toast(error.message,true)}finally{state.refreshing=false;if(state.pendingAgents.size)scheduleRefresh()}
}
function scheduleRefresh(agent){if(agent&&agent!=='fleet')state.pendingAgents.add(agent);if(state.refreshTimer)return;state.refreshTimer=setTimeout(()=>{const agents=[...state.pendingAgents];state.pendingAgents.clear();state.refreshTimer=null;refresh(agents)},450)}
function bindAutoResize(){document.querySelectorAll('.composer textarea').forEach(area=>{area.style.height='auto';area.style.height=`${Math.min(110,area.scrollHeight)}px`})}
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==='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',()=>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);
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>