trantor 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui.html ADDED
@@ -0,0 +1,410 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>agent-bus</title>
4
+ <style>
5
+ :root{--bg:#0a0e16;--panel:#111726;--card:#161d2e;--card2:#1b2335;--line:#1f2839;--tx:#e6edf6;--mut:#8a97a8;--dim:#5b6675;
6
+ --grn:#2dd4bf;--grn2:#14b8a6;--amb:#f59e0b;--blu:#4a90d9;--red:#ef6a6a;--pur:#9b6fd4}
7
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;height:100vh;display:flex;flex-direction:column}
8
+ header{display:flex;align-items:center;gap:13px;padding:11px 18px;border-bottom:1px solid var(--line);background:var(--panel)}
9
+ .logo{font-weight:800;letter-spacing:.4px;font-size:16px}.logo b{color:var(--grn)}
10
+ .dot{width:9px;height:9px;border-radius:50%;background:var(--grn);box-shadow:0 0 8px var(--grn);display:inline-block;flex:0 0 auto}
11
+ .dot.off{background:var(--dim);box-shadow:none}
12
+ .muted{color:var(--mut)}.dim{color:var(--dim)}.spacer{flex:1}
13
+ .pill{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:3px 11px;font-size:12px;color:var(--mut)}
14
+ main{flex:1;display:grid;grid-template-columns:1fr 330px;min-height:0}
15
+ .boards{overflow-y:auto;padding:16px 18px}
16
+ .proj{background:var(--panel);border:1px solid var(--line);border-radius:14px;margin-bottom:16px;overflow:hidden}
17
+ .proj-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);background:#0e1421}
18
+ .proj-h .pname{font-family:'Sora',ui-sans-serif,sans-serif;font-weight:700;font-size:15px}
19
+ .proj-h .pname b{color:var(--grn)}
20
+ .agents{display:flex;gap:6px;flex-wrap:wrap}
21
+ .agent{display:flex;align-items:center;gap:5px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2px 9px 2px 6px;font-size:11.5px;color:var(--mut)}
22
+ .agent .nm{color:var(--tx)}
23
+ .agent svg{flex:none}
24
+ .agent.offl{opacity:.42}
25
+ .agent .ast{color:var(--mut)}
26
+ .prog{font-size:11.5px;color:var(--dim);white-space:nowrap}
27
+ /* project brief + phase row */
28
+ .proj-brief{display:flex;align-items:center;gap:12px;padding:9px 16px 4px;flex-wrap:wrap}
29
+ .brief{font-size:12.5px;color:var(--mut);line-height:1.45;flex:1;min-width:200px}
30
+ .brief.dim{color:var(--dim);font-style:italic}
31
+ .phase{font-size:11px;font-weight:600;padding:3px 10px;border-radius:14px;white-space:nowrap;border:1px solid var(--line);background:var(--card)}
32
+ .phase.building{color:var(--blu);border-color:#26405f}
33
+ .phase.blocked{color:var(--red);border-color:#5a2c2c}
34
+ .phase.shipped{color:var(--grn);border-color:#1d4a44}
35
+ .phase.planned{color:var(--mut)}
36
+ .pbar{height:4px;border-radius:3px;background:#0d1320;margin:2px 16px 0;overflow:hidden}
37
+ .pbar i{display:block;height:100%;background:linear-gradient(90deg,var(--grn2),var(--grn));border-radius:3px;transition:width .4s}
38
+ .kanban{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;padding:12px 16px}
39
+ .col{background:#0d1320;border:1px solid var(--line);border-radius:10px;padding:9px;min-height:140px;max-height:clamp(280px,58vh,1100px);overflow-y:auto}
40
+ .col::-webkit-scrollbar{width:6px}.col::-webkit-scrollbar-thumb{background:#243049;border-radius:3px}
41
+ .col h4{position:sticky;top:-9px;background:#0d1320;padding:9px 0 6px;margin-top:-9px;z-index:1}
42
+ .col h4{margin:0 0 8px;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);display:flex;justify-content:space-between}
43
+ .col.todo h4 i{color:var(--mut)}.col.doing h4 i{color:var(--blu)}.col.testing h4 i{color:var(--amb)}.col.done h4 i{color:var(--grn)}.col.blocked h4 i{color:var(--red)}
44
+ .col h4 i{font-style:normal}
45
+ .tcard{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--mut);border-radius:7px;padding:7px 9px;margin-bottom:7px;cursor:pointer;font-size:13px}
46
+ .tcard:hover{background:var(--card2);border-color:#2c3a52}
47
+ .col.doing .tcard{border-left-color:var(--blu)}.col.testing .tcard{border-left-color:var(--amb)}.col.done .tcard{border-left-color:var(--grn)}.col.blocked .tcard{border-left-color:var(--red)}
48
+ .tcard.failed{border-left-color:var(--red);border-color:#5a2c2c;animation:failpulse 1.1s ease-in-out infinite}
49
+ .tcard.failed .who{color:#ef6a6a}
50
+ @keyframes failpulse{0%,100%{box-shadow:0 0 0 0 rgba(239,106,106,0)}50%{box-shadow:0 0 14px 2px rgba(239,106,106,.55);border-color:#ef6a6a}}
51
+ .tcard .who{color:var(--dim);font-size:11px;margin-top:4px;display:flex;align-items:center;gap:3px}
52
+ .diff{font-size:9px;font-weight:800;letter-spacing:.04em;padding:1px 5px;border-radius:8px;margin-left:5px;vertical-align:1px}
53
+ .diff.easy{background:#13302b;color:#2dd4bf}.diff.medium{background:#2c2a17;color:#f5d90b}.diff.hard{background:#33181f;color:#ff7a90}
54
+ .bounce{display:inline-block;font-size:9.5px;font-weight:700;color:#ef6a6a;background:#2a1517;border:1px solid #5a2c2c;border-radius:8px;padding:1px 6px;margin-left:5px}
55
+ .mdl{font-size:9px;font-family:ui-monospace,monospace;color:#8fa3be;background:#16202f;border:1px solid #243049;border-radius:7px;padding:1px 5px;margin-left:4px;vertical-align:1px}
56
+ .tcard.done{opacity:.65}.tcard.done span{text-decoration:line-through}
57
+ .empty{color:var(--dim);text-align:center;padding:30px}
58
+ .empty.big{padding:60px 20px;font-size:15px}
59
+ /* flow (DAG) view */
60
+ .vtog{display:flex;gap:2px;margin-left:10px}
61
+ .vbtn{font-size:10px;font-weight:700;letter-spacing:.05em;padding:2px 9px;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--dim);cursor:pointer}
62
+ .vbtn.on{color:var(--grn);border-color:#1d4a44;background:#0f1d1b}
63
+ .flowwrap{padding:14px 16px;overflow:auto}
64
+ .flowwrap svg{display:block}
65
+ .fnode{cursor:pointer}
66
+ .fnode rect{fill:var(--card);stroke:var(--line);stroke-width:1.4;rx:9}
67
+ .fnode.todo rect{stroke:#3a4458}
68
+ .fnode.doing rect{stroke:var(--blu);filter:drop-shadow(0 0 5px rgba(74,144,217,.45))}
69
+ .fnode.testing rect{stroke:var(--amb);filter:drop-shadow(0 0 5px rgba(245,158,11,.4))}
70
+ .fnode.done rect{stroke:var(--grn);fill:#0f1d1b}
71
+ .fnode.blocked rect{stroke:var(--red)}
72
+ .fnode.failed rect{stroke:var(--red);animation:fpulse 1.1s ease-in-out infinite}
73
+ @keyframes fpulse{0%,100%{filter:drop-shadow(0 0 0 rgba(239,106,106,0))}50%{filter:drop-shadow(0 0 9px rgba(239,106,106,.8))}}
74
+ .fnode text{fill:var(--tx);font-size:11.5px;font-family:ui-sans-serif,system-ui}
75
+ .fnode .fmeta{fill:var(--dim);font-size:9.5px}
76
+ .fnode.done .ftitle{fill:#7d9b95;text-decoration:line-through}
77
+ .fedge{fill:none;stroke:#2b3650;stroke-width:1.6}
78
+ .fedge.done{stroke:var(--grn2);opacity:.8}
79
+ .fedge.active{stroke:var(--blu);stroke-dasharray:6 5;animation:fdash 1s linear infinite}
80
+ @keyframes fdash{to{stroke-dashoffset:-11}}
81
+ .floop{fill:none;stroke:var(--red);stroke-width:1.4;opacity:.85}
82
+ .floopn{fill:var(--red);font-size:9px;font-weight:700}
83
+ .flowwrap{position:relative;height:66vh;min-height:340px;overflow:hidden;background:radial-gradient(circle at 1px 1px, #18233a 1px, transparent 0);background-size:26px 26px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);cursor:grab}
84
+ .flowwrap.panning{cursor:grabbing}
85
+ .flowwrap svg{position:absolute;inset:0;width:100%;height:100%}
86
+ .fnode{cursor:move}
87
+ .fctl{position:absolute;top:8px;right:10px;display:flex;gap:4px;z-index:2}
88
+ .fctl button{font-size:10px;font-weight:700;padding:3px 9px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--mut);cursor:pointer}
89
+ .fctl button:hover{color:var(--grn);border-color:#1d4a44}
90
+ .fhint{position:absolute;bottom:7px;right:10px;font-size:9.5px;color:var(--dim);z-index:2;pointer-events:none}
91
+ .fedge-hit{fill:none;stroke:transparent;stroke-width:12;cursor:pointer}
92
+ .felabel{fill:#7e8ca3;font-size:9px;font-family:ui-monospace,monospace;opacity:0;pointer-events:none;transition:opacity .15s}
93
+ .fe:hover .felabel{opacity:1}
94
+ .fe:hover .fedge{stroke:#5fa8ff}
95
+ /* per-project inter-agent conversation lane */
96
+ .proj-chat{margin:4px 16px 14px;border:1px solid var(--line);border-radius:10px;background:#0c111c}
97
+ .proj-chat h5{margin:0;padding:7px 11px;font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:6px}
98
+ .proj-chat h5 .lc{width:6px;height:6px;border-radius:50%;background:var(--grn);box-shadow:0 0 6px var(--grn)}
99
+ .chatlog{max-height:clamp(130px,20vh,320px);overflow-y:auto;padding:7px 11px;display:flex;flex-direction:column;gap:5px}
100
+ .cmsg{font-size:12px;line-height:1.4;color:var(--tx)}
101
+ .cmsg .ct{color:var(--dim);font-family:ui-monospace,monospace;font-size:10px;margin-right:5px}
102
+ .cmsg .cf{font-weight:600}
103
+ .cmsg .arr{color:var(--dim);margin:0 4px}
104
+ .cmsg .cto{color:var(--blu)}.cmsg.bc .cto{color:var(--amb)}
105
+ .cmsg svg{vertical-align:-2px;margin-right:3px}
106
+ .chatempty{color:var(--dim);font-size:11.5px;padding:8px 11px;font-style:italic}
107
+ aside{border-left:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;min-height:0}
108
+ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);margin:0;padding:13px 14px 8px;font-weight:700}
109
+ .feed{flex:1;overflow-y:auto;padding:0 14px 10px}
110
+ .msg{padding:6px 0;border-bottom:1px solid #131a28;font-size:12.5px}
111
+ .msg .t{color:var(--dim);font-size:10.5px;font-family:ui-monospace,monospace}
112
+ .msg svg{vertical-align:-2px;margin:0 1px}
113
+ .msg .from{color:var(--grn);font-weight:600}.msg .to{color:var(--blu)}.msg.bc .to{color:var(--amb)}
114
+ .compose{border-top:1px solid var(--line);padding:10px 12px;display:flex;gap:6px;flex-wrap:wrap}
115
+ .compose select,.compose input{background:var(--card);border:1px solid var(--line);color:var(--tx);border-radius:7px;padding:7px 9px;font-size:12.5px}
116
+ .compose input{flex:1;min-width:120px}
117
+ .compose button{background:var(--grn2);border:none;color:#04201d;font-weight:700;border-radius:7px;padding:7px 14px;cursor:pointer}
118
+ @import url('https://fonts.googleapis.com/css2?family=Sora:wght@600;700&display=swap');
119
+ </style></head>
120
+ <body>
121
+ <header>
122
+ <span class="dot" id="livedot"></span>
123
+ <span class="logo">agent<b>·</b>bus</span>
124
+ <span class="pill" id="hub">—</span>
125
+ <span class="spacer"></span>
126
+ <span class="pill" id="econ" title="Scrooge ledger, last 24h" style="display:none"></span>
127
+ <span class="pill"><span id="nproj">0</span> projects · <span id="nsess">0</span> live · <span id="ntask">0</span> cards</span>
128
+ </header>
129
+ <main>
130
+ <div class="boards" id="boards"><div class="empty big">no projects yet — agents register a project on connect</div></div>
131
+ <aside>
132
+ <h2>Live feed</h2>
133
+ <div class="feed" id="feed"></div>
134
+ <div class="compose">
135
+ <select id="to"><option value="all">all (broadcast)</option></select>
136
+ <input id="text" placeholder="message the bus…" />
137
+ <button id="send">Send</button>
138
+ </div>
139
+ </aside>
140
+ </main>
141
+ <script>
142
+ const $=s=>document.querySelector(s);
143
+ const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
144
+ const COLS=[['todo','To Do'],['doing','In Progress'],['testing','Testing'],['done','Done'],['blocked','Blocked']];
145
+ let nmsg=0;
146
+
147
+ /* ---- LLM provider icons (lobehub SVGs — same set used across crebral.ai) ----
148
+ Keyed by the AI coding-CLI brand parsed from a session id (e.g. "claude:crebral"). */
149
+ const ICON={
150
+ anthropic:{c:'#D97757',p:['M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z']},
151
+ openai:{c:'#10A37F',p:['M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z']},
152
+ gemini:{c:'#4285F4',p:['M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z']},
153
+ mistral:{c:'#FA520F',p:['M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z']},
154
+ deepseek:{c:'#4D6BFE',p:['M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z']},
155
+ moonshot:{c:'#A78BFA',p:['M1.052 16.916l9.539 2.552c.02.68.04 1.36.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865 11.99 11.99 0 01-9.969-7.043zm-1.02-5.794l11.353 3.037c-.18.66-.337 1.332-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923a11.99 11.99 0 01-.625-4.801zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26a11.9 11.9 0 011.501-4.288zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213a12.084 12.084 0 013.96-3.641zM12.017 0a11.99 11.99 0 0110.42 6.054L7.222.994A11.95 11.95 0 0112.017 0z']},
156
+ qwen:{c:'#615CED',p:['M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031z']},
157
+ grok:{c:'#cfd3dc',p:['M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815']},
158
+ cursor:{c:'#cfd3dc',p:['M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z']}
159
+ };
160
+ const BRAND=[[/claude|anthropic/,'anthropic'],[/codex|openai|gpt|chatgpt/,'openai'],[/gemini|bard/,'gemini'],[/mistral|mixtral/,'mistral'],[/deepseek/,'deepseek'],[/kimi|moonshot|\bk2\b/,'moonshot'],[/qwen/,'qwen'],[/grok|xai/,'grok'],[/cursor/,'cursor']];
161
+ function brandOf(s){const x=String(s).toLowerCase();for(const[re,b]of BRAND)if(re.test(x))return b;return null;}
162
+ function iconFor(s,size){const b=brandOf(s);if(!b||!ICON[b])return `<span style="display:inline-flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:50%;background:#2a3346;color:#aeb9c9;font-size:${Math.round(size*.55)}px;font-weight:700">${esc(String(s).slice(0,1).toUpperCase())}</span>`;const i=ICON[b];return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="currentColor" style="color:${i.c}">${i.p.map(d=>`<path d="${d}"/>`).join('')}</svg>`;}
163
+ const phaseClass=ph=>/FAILED|blocked/.test(ph)?'blocked':/building|verifying|progress/.test(ph)?'building':/shipped|done/.test(ph)?'shipped':'planned';
164
+
165
+ let POOLS={};
166
+ async function econ(){
167
+ try{
168
+ const e=await (await fetch('/economics')).json();
169
+ POOLS={}; for(const[k,v]of Object.entries(e.profile||{}))POOLS[k]=v.tier;
170
+ if(e.scrooge&&e.scrooge.calls>0){
171
+ const s=e.scrooge;
172
+ const el=$('#econ'); el.style.display='';
173
+ el.innerHTML=`🪙 scrooge 24h: <b style="color:var(--grn)">$${s.cost_usd}</b> · saved ~$${Math.max(0,(s.opus_equiv_usd-s.cost_usd)).toFixed(2)} · ${s.calls} calls`;
174
+ }
175
+ }catch(_){}
176
+ }
177
+ econ();setInterval(econ,15000);
178
+ function poolOf(session){const b=brandOf(session);const k=b==='anthropic'?'claude':b==='openai'?'codex':b==='moonshot'?'kimi':b;return POOLS[k]||'';}
179
+ const VIEWS = JSON.parse(localStorage.getItem("abViews") || "{}");
180
+ function setView(proj, v){ VIEWS[proj] = v; localStorage.setItem("abViews", JSON.stringify(VIEWS)); render(); }
181
+ function flowLayout(cards, proj){
182
+ const byId = Object.fromEntries(cards.map(t => [t.id, t]));
183
+ let anyDeps = cards.some(t => (t.deps || []).length);
184
+ const deps = t => {
185
+ if (anyDeps) return (t.deps || []).filter(d => byId[d]);
186
+ if (/integrat|assemb|ship it|final/i.test(t.title)) return cards.filter(o => o.id !== t.id).map(o => o.id);
187
+ return [];
188
+ };
189
+ const L = {};
190
+ const layer = (t, seen = new Set()) => {
191
+ if (L[t.id] !== undefined) return L[t.id];
192
+ if (seen.has(t.id)) return (L[t.id] = 0);
193
+ seen.add(t.id);
194
+ const ds = deps(t);
195
+ return (L[t.id] = ds.length ? 1 + Math.max(...ds.map(d => layer(byId[d], seen))) : 0);
196
+ };
197
+ cards.forEach(t => layer(t));
198
+ const cols = {};
199
+ cards.forEach(t => (cols[L[t.id]] ||= []).push(t));
200
+ const NW = 190, NH = 64, GX = 90, GY = 26;
201
+ const nodes = {}, maxRows = Math.max(...Object.values(cols).map(c => c.length));
202
+ const totalH = maxRows * (NH + GY) - GY;
203
+ const manual = (JSON.parse(localStorage.getItem("abFlowPos") || "{}"))[proj] || {};
204
+ for (const [li, col] of Object.entries(cols)) {
205
+ const colH = col.length * (NH + GY) - GY, y0 = (totalH - colH) / 2;
206
+ col.forEach((t, ri) => {
207
+ const m = manual[t.id];
208
+ nodes[t.id] = { t, x: m ? m.x : li * (NW + GX), y: m ? m.y : y0 + ri * (NH + GY) };
209
+ });
210
+ }
211
+ const edges = [];
212
+ cards.forEach(t => deps(t).forEach(d => edges.push([d, t.id])));
213
+ return { nodes, edges, NW, NH };
214
+ }
215
+ function flowHTML(pt, proj){
216
+ if (!pt.length) return '<div class="empty">no cards yet</div>';
217
+ const { nodes, edges, NW, NH } = flowLayout(pt, proj);
218
+ const cam = (JSON.parse(localStorage.getItem("abFlowCam") || "{}"))[proj] || { s: 0.9, tx: 30, ty: 20 };
219
+ let inner = "";
220
+ for (const [a, b] of edges) {
221
+ const A = nodes[a], B = nodes[b]; if (!A || !B) continue;
222
+ const x1 = A.x + NW, y1 = A.y + NH / 2, x2 = B.x, y2 = B.y + NH / 2, mx = (x1 + x2) / 2;
223
+ const cls = A.t.status === "done" ? "done" : (B.t.status === "doing" || B.t.status === "testing") ? "active" : "";
224
+ const mk = cls === "done" ? "arrg" : cls === "active" ? "arrb" : "arr";
225
+ const d = `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`;
226
+ const label = `${String(A.t.title).slice(0, 18)} → ${String(B.t.title).slice(0, 18)}`;
227
+ inner += `<g class="fe"><path class="fedge-hit" d="${d}"><title>${esc(A.t.title)} → ${esc(B.t.title)}</title></path><path class="fedge ${cls}" marker-end="url(#${mk})" d="${d}"/><text class="felabel" x="${mx}" y="${(y1 + y2) / 2 - 6}" text-anchor="middle">${esc(label)}</text></g>`;
228
+ }
229
+ for (const { t, x, y } of Object.values(nodes)) {
230
+ const bounces = (t.history || []).filter(h => ({todo:0,doing:1,testing:2,done:3}[h.to] ?? 0) < ({todo:0,doing:1,testing:2,done:3}[h.from] ?? 0)).length;
231
+ inner += `<g class="fnode ${t.status}" data-id="${t.id}" transform="translate(${x},${y})">`;
232
+ inner += `<rect width="${NW}" height="${NH}" rx="9"/>`;
233
+ inner += `<text class="ftitle" x="10" y="20">${esc(t.title.slice(0, 26))}${t.title.length > 26 ? "…" : ""}</text>`;
234
+ inner += `<text class="fmeta" x="10" y="37">${esc(String(t.assignee || "").split(":")[0])}${t.difficulty ? " · " + t.difficulty : ""}${t.model ? " · " + esc(t.model.slice(0, 16)) : ""}</text>`;
235
+ inner += `<text class="fmeta" x="10" y="53">${t.status}</text>`;
236
+ if (bounces) inner += `<path class="floop" marker-end="url(#arr)" d="M${NW - 34},0 C${NW - 34},-16 ${NW - 6},-16 ${NW - 6},-1"/><text class="floopn" x="${NW - 30}" y="-7">↩${bounces}</text>`;
237
+ inner += `</g>`;
238
+ }
239
+ const defs = `<defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2b3650"/></marker><marker id="arrg" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#14b8a6"/></marker><marker id="arrb" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#4a90d9"/></marker></defs>`;
240
+ return `<div class="flowwrap" data-proj="${esc(proj)}"><div class="fctl"><button data-fa="out">−</button><button data-fa="in">+</button><button data-fa="fit">FIT</button><button data-fa="auto">AUTO</button></div><div class="fhint">⌘+scroll or pinch to zoom · drag canvas to pan</div><svg>${defs}<g class="fcam" transform="translate(${cam.tx},${cam.ty}) scale(${cam.s})">${inner}</g></svg></div>`;
241
+ }
242
+ let dragging = null; // suppresses re-render mid-gesture
243
+ function saveCam(proj, cam){ const c = JSON.parse(localStorage.getItem("abFlowCam") || "{}"); c[proj] = cam; localStorage.setItem("abFlowCam", JSON.stringify(c)); }
244
+ function wireFlow(el){
245
+ el.querySelectorAll(".flowwrap").forEach(w => {
246
+ const proj = w.dataset.proj, svg = w.querySelector("svg"), g = w.querySelector(".fcam");
247
+ let cam = (JSON.parse(localStorage.getItem("abFlowCam") || "{}"))[proj] || { s: 0.9, tx: 30, ty: 20 };
248
+ const apply = () => g.setAttribute("transform", `translate(${cam.tx},${cam.ty}) scale(${cam.s})`);
249
+ const zoomAt = (px, py, k) => {
250
+ const ns = Math.min(2.5, Math.max(0.2, cam.s * k));
251
+ cam.tx = px - (px - cam.tx) * (ns / cam.s); cam.ty = py - (py - cam.ty) * (ns / cam.s); cam.s = ns;
252
+ apply(); saveCam(proj, cam);
253
+ };
254
+ const fit = () => {
255
+ try {
256
+ const bb = g.getBBox(), r = w.getBoundingClientRect();
257
+ const sc = Math.min((r.width - 40) / bb.width, (r.height - 40) / bb.height, 1.6);
258
+ cam = { s: sc, tx: (r.width - bb.width * sc) / 2 - bb.x * sc, ty: (r.height - bb.height * sc) / 2 - bb.y * sc };
259
+ apply(); saveCam(proj, cam);
260
+ } catch {}
261
+ };
262
+ w.__fit = fit;
263
+ // wheel: ONLY zoom with cmd/ctrl held (trackpad pinch reports as ctrl+wheel) — plain wheel scrolls the PAGE
264
+ w.addEventListener("wheel", e => {
265
+ if (!(e.metaKey || e.ctrlKey)) return; // let it through — never trap page scroll
266
+ e.preventDefault();
267
+ const r = w.getBoundingClientRect();
268
+ zoomAt(e.clientX - r.left, e.clientY - r.top, e.deltaY < 0 ? 1.1 : 1 / 1.1);
269
+ }, { passive: false });
270
+ w.addEventListener("pointerdown", e => {
271
+ if (e.target.closest(".fctl")) return; // controls are not a pan gesture
272
+ const node = e.target.closest(".fnode");
273
+ if (node) { // drag a node
274
+ const id = node.dataset.id;
275
+ const tr = /translate\(([-\d.]+),([-\d.]+)\)/.exec(node.getAttribute("transform"));
276
+ dragging = { kind: "node", node, id, sx: e.clientX, sy: e.clientY, ox: +tr[1], oy: +tr[2], moved: false, proj };
277
+ } else { // pan the canvas
278
+ dragging = { kind: "pan", sx: e.clientX, sy: e.clientY, ox: cam.tx, oy: cam.ty, w, proj };
279
+ w.classList.add("panning");
280
+ }
281
+ w.setPointerCapture(e.pointerId);
282
+ });
283
+ w.addEventListener("pointermove", e => {
284
+ if (!dragging) return;
285
+ const dx = e.clientX - dragging.sx, dy = e.clientY - dragging.sy;
286
+ if (dragging.kind === "node") {
287
+ if (Math.abs(dx) + Math.abs(dy) > 3) dragging.moved = true;
288
+ dragging.nx = dragging.ox + dx / cam.s; dragging.ny = dragging.oy + dy / cam.s;
289
+ dragging.node.setAttribute("transform", `translate(${dragging.nx},${dragging.ny})`);
290
+ } else { cam.tx = dragging.ox + dx; cam.ty = dragging.oy + dy; apply(); }
291
+ });
292
+ w.addEventListener("pointerup", () => {
293
+ if (!dragging) return;
294
+ if (dragging.kind === "node" && dragging.moved) {
295
+ const pos = JSON.parse(localStorage.getItem("abFlowPos") || "{}");
296
+ (pos[proj] ||= {})[dragging.id] = { x: dragging.nx, y: dragging.ny };
297
+ localStorage.setItem("abFlowPos", JSON.stringify(pos));
298
+ dragging.suppressClick = dragging.node.dataset.id; // a drag is not a click
299
+ setTimeout(() => (suppressClickId = null), 50);
300
+ suppressClickId = dragging.id;
301
+ }
302
+ if (dragging.kind === "pan") { saveCam(proj, cam); w.classList.remove("panning"); }
303
+ dragging = null;
304
+ });
305
+ w.querySelectorAll(".fctl button").forEach(b => b.onclick = (e) => {
306
+ e.stopPropagation(); e.preventDefault();
307
+ const r = w.getBoundingClientRect();
308
+ if (b.dataset.fa === "in") zoomAt(r.width / 2, r.height / 2, 1.25);
309
+ else if (b.dataset.fa === "out") zoomAt(r.width / 2, r.height / 2, 0.8);
310
+ else if (b.dataset.fa === "fit") fit();
311
+ else if (b.dataset.fa === "auto") {
312
+ const pos = JSON.parse(localStorage.getItem("abFlowPos") || "{}");
313
+ delete pos[proj]; localStorage.setItem("abFlowPos", JSON.stringify(pos));
314
+ const cams = JSON.parse(localStorage.getItem("abFlowCam") || "{}");
315
+ delete cams[proj]; localStorage.setItem("abFlowCam", JSON.stringify(cams));
316
+ render();
317
+ }
318
+ });
319
+ // first load of this project's flow (no saved camera): fit the whole graph into the stage
320
+ if (!(JSON.parse(localStorage.getItem("abFlowCam") || "{}"))[proj]) fit();
321
+ });
322
+ }
323
+ let suppressClickId = null;
324
+ function projOf(m){return m.project||(String(m.from).includes(':')?String(m.from).split(':').pop():'');}
325
+ function chatLane(msgs){
326
+ if(!msgs.length)return `<div class="proj-chat"><h5><span class="lc"></span>conversation</h5><div class="chatempty">no messages yet — agents talk here as they coordinate</div></div>`;
327
+ const rows=msgs.slice(-8).map(m=>`<div class="cmsg ${m.to==='all'?'bc':''}"><span class="ct">${new Date(m.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>${iconFor(m.from,13)}<span class="cf">${esc(String(m.from).split(':')[0])}</span><span class="arr">→</span><span class="cto">${esc(m.to==='all'?'all':String(m.to).split(':')[0])}</span>: ${esc(m.text)}</div>`).join('');
328
+ return `<div class="proj-chat"><h5><span class="lc"></span>conversation · ${msgs.length}</h5><div class="chatlog">${rows}</div></div>`;
329
+ }
330
+ async function render(){
331
+ let projects=[],tasks=[],msgs=[];
332
+ try{projects=(await (await fetch('/projects')).json()).projects||[];}catch(e){}
333
+ try{tasks=(await (await fetch('/tasks')).json()).tasks||[];}catch(e){}
334
+ try{msgs=(await (await fetch('/recent?limit=200')).json()).messages||[];}catch(e){}
335
+ // hub was reset (server empty but feed shows history) -> clear the stale client-side feed
336
+ if(!msgs.length&&$('#feed').childElementCount>0){$('#feed').innerHTML='';nmsg=0;}
337
+ const liveSess=new Set();projects.forEach(p=>p.agents.forEach(a=>{if(a.online)liveSess.add(a.session)}));
338
+ $('#nproj').textContent=projects.filter(p=>p.project!=='(unassigned)').length;
339
+ $('#nsess').textContent=liveSess.size;
340
+ $('#ntask').textContent=tasks.length;
341
+ // session dropdown for composer
342
+ const allS=[...new Set(projects.flatMap(p=>p.agents.map(a=>a.session)))];
343
+ const sel=$('#to'),cur=sel.value;
344
+ sel.innerHTML='<option value="all">all (broadcast)</option>'+allS.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
345
+ sel.value=cur;
346
+ // sort: projects with online agents first, then by name; unassigned last
347
+ projects.sort((a,b)=>{const oa=a.agents.some(x=>x.online),ob=b.agents.some(x=>x.online);if(oa!==ob)return ob-oa;if(a.project==='(unassigned)')return 1;if(b.project==='(unassigned)')return -1;return a.project.localeCompare(b.project);});
348
+ const el=$('#boards');
349
+ if(dragging)return; // never rebuild mid-gesture
350
+ if(!projects.length){el.innerHTML='<div class="empty big">no projects yet — agents register a project on connect</div>';return;}
351
+ el.innerHTML=projects.map(p=>{
352
+ const pt=tasks.filter(t=>t.project===p.project);
353
+ const done=pt.filter(t=>t.status==='done').length;
354
+ const pct=pt.length?Math.round(done/pt.length*100):0;
355
+ const agents=p.agents.sort((a,b)=>b.online-a.online).map(a=>`<span class="agent ${a.online?'':'offl'}" title="${esc(a.session)}${a.online?' · online':' · offline'}">${iconFor(a.session,15)}<span class="nm">${esc(a.session)}</span>${a.status?` <span class="ast">· ${esc(a.status)}</span>`:''}${poolOf(a.session)?` <span class="ast" style="opacity:.7">[${esc(poolOf(a.session))}]</span>`:''}</span>`).join('');
356
+ const cols=COLS.map(([k,label])=>{
357
+ let cards=pt.filter(t=>k==='testing'?(t.status==='testing'||t.status==='failed'):t.status===k);
358
+ if(k==='done')cards=[...cards].sort((a,b)=>(b.updated||0)-(a.updated||0)); // newest finished on top
359
+ if(k==='testing')cards=[...cards].sort((a,b)=>(a.status==='failed'?-1:1)-(b.status==='failed'?-1:1)); // failures float up
360
+ return `<div class="col ${k}"><h4>${label} <i>${cards.length}</i></h4>${cards.map(t=>(()=>{
361
+ const hist=(t.history||[]);
362
+ const last=hist[hist.length-1];
363
+ const rank={todo:0,doing:1,testing:2,done:3};
364
+ const bounced=last&&last.from!==undefined&&rank[last.to]<rank[last.from]&&(Date.now()-last.ts)<10*60*1000;
365
+ const trail=hist.map(h=>`${h.from?h.from+'→':''}${h.to}${h.by?' by '+String(h.by).split(':')[0]:''} @${new Date(h.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`).join('\n');
366
+ return `<div class="tcard ${t.status}" data-id="${t.id}" title="${esc(trail)||'click to advance'}"><span>${esc(t.title)}</span>${t.difficulty?`<span class="diff ${t.difficulty}">${t.difficulty[0].toUpperCase()}</span>`:''}${t.model?`<span class="mdl">${esc(t.model)}</span>`:''}${bounced?`<span class="bounce">↩ bounced${last.by?' by '+esc(String(last.by).split(':')[0]):''}</span>`:''}${t.assignee?`<div class="who">${iconFor(t.assignee,12)}@${esc(t.assignee)}</div>`:''}</div>`;
367
+ })()).join('')}</div>`;
368
+ }).join('');
369
+ const ph=p.phase||'';
370
+ const brief=p.brief?`<span class="brief">${esc(p.brief)}</span>`:`<span class="brief dim">— no brief yet · an agent sets it with relay_project_brief</span>`;
371
+ const pmsgs=msgs.filter(m=>projOf(m)===p.project);
372
+ const view = VIEWS[p.project] || "board";
373
+ const vtog = `<div class="vtog"><button class="vbtn ${view==="board"?"on":""}" data-proj="${esc(p.project)}" data-view="board">BOARD</button><button class="vbtn ${view==="flow"?"on":""}" data-proj="${esc(p.project)}" data-view="flow">FLOW</button></div>`;
374
+ return `<div class="proj">`+
375
+ `<div class="proj-h"><span class="pname">📁 <b>${esc(p.project)}</b></span>${vtog}<div class="agents">${agents||'<span class="dim">no agents</span>'}</div><span class="spacer"></span><span class="prog">${done}/${pt.length} done · ${pct}%</span></div>`+
376
+ `<div class="proj-brief">${brief}${ph?`<span class="phase ${phaseClass(ph)}">${esc(ph)}</span>`:''}</div>`+
377
+ `<div class="pbar"><i style="width:${pct}%"></i></div>`+
378
+ (view === "flow" ? flowHTML(pt, p.project) : `<div class="kanban">${cols}</div>`)+
379
+ chatLane(pmsgs)+
380
+ `</div>`;
381
+ }).join('');
382
+ // keep each project's chat scrolled to the latest line
383
+ el.querySelectorAll('.chatlog').forEach(c=>{if(c.scrollHeight-c.clientHeight<60||c.dataset.stuck!=='0')c.scrollTop=c.scrollHeight;c.onscroll=()=>{c.dataset.stuck=(c.scrollHeight-c.scrollTop-c.clientHeight<40)?'1':'0';};});
384
+ // click a card -> advance status todo->doing->done
385
+ el.querySelectorAll('.vbtn').forEach(b=>b.onclick=()=>setView(b.dataset.proj,b.dataset.view));
386
+ wireFlow(el);
387
+ el.querySelectorAll('.tcard, .fnode').forEach(c=>c.onclick=async()=>{
388
+ const id=+c.dataset.id,t=tasks.find(x=>x.id===id);if(!t)return;
389
+ if(suppressClickId&&+suppressClickId===id)return; // drag-end, not a click
390
+ const next={todo:'doing',doing:'testing',testing:'done',failed:'doing',done:'todo',blocked:'doing'}[t.status];
391
+ await fetch('/task/update',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,status:next})});render();
392
+ });
393
+ }
394
+ function addMsg(m,count=true){
395
+ if(count){nmsg++;$('#ntask');}
396
+ const d=document.createElement('div');d.className='msg'+(m.to==='all'?' bc':'');
397
+ d.innerHTML=`<span class="t">${new Date(m.ts).toLocaleTimeString()}</span> ${iconFor(m.from,12)} <span class="from">${esc(m.from)}</span> → <span class="to">${esc(m.to)}</span>: ${esc(m.text)}`;
398
+ const f=$('#feed');const stick=f.scrollHeight-f.scrollTop-f.clientHeight<50;f.appendChild(d);if(stick)f.scrollTop=f.scrollHeight;
399
+ }
400
+ function stream(){const ev=new EventSource('/stream?session=all');
401
+ ev.onmessage=e=>{try{addMsg(JSON.parse(e.data));render();}catch(_){}};
402
+ ev.onerror=()=>{$('#livedot').classList.add('off');setTimeout(()=>{ev.close();stream();$('#livedot').classList.remove('off');},2000);};}
403
+ $('#send').onclick=async()=>{const t=$('#text').value.trim();if(!t)return;
404
+ await fetch('/send',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({from:'dashboard',to:$('#to').value,text:t})});$('#text').value='';};
405
+ $('#text').addEventListener('keydown',e=>{if(e.key==='Enter')$('#send').click();});
406
+ fetch('/recent?limit=40').then(r=>r.json()).then(d=>(d.messages||[]).forEach(m=>addMsg(m,false))).catch(()=>{});
407
+ $('#hub').textContent=location.host;
408
+ render();setInterval(render,2500);stream();
409
+ </script>
410
+ </body></html>