trantor 0.17.9 → 0.17.11

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
9
- "version": "0.17.9"
9
+ "version": "0.17.11"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "trantor",
14
14
  "source": "./",
15
15
  "description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, Gemini, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
16
- "version": "0.17.9",
16
+ "version": "0.17.11",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.9",
3
+ "version": "0.17.11",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/hub.mjs CHANGED
@@ -237,6 +237,20 @@ const server = http.createServer(async (req, res) => {
237
237
  const events = (q.project ? state.cardEvents.filter(e => e.project === q.project) : state.cardEvents).slice(-limit);
238
238
  return json(res, 200, { events });
239
239
  }
240
+ // A single card's FULL story for the detail panel: the card itself, its status events, and the
241
+ // bus messages that reference it (#<id>) — i.e. the agent's own reports of what it did, why, how.
242
+ if (req.method === "GET" && P === "/card") {
243
+ const id = Number(q.id);
244
+ if (!Number.isInteger(id)) return json(res, 400, { error: "numeric id required" });
245
+ const task = state.tasks.find(t => t.id === id) || null;
246
+ const events = state.cardEvents.filter(e => e.taskId === id);
247
+ const re = new RegExp("#" + id + "(?![0-9])"); // #5 but not #50
248
+ const messages = state.messages.filter(m => re.test(String(m.text || ""))).slice(-200);
249
+ // fall back to the last event for title/project/assignee when the card was deleted
250
+ const last = events[events.length - 1];
251
+ const meta = task || (last ? { id, title: last.title, project: last.project, status: "deleted", assignee: last.assignee, difficulty: last.difficulty } : null);
252
+ return json(res, 200, { task: meta, events, messages });
253
+ }
240
254
  if (req.method === "POST" && P === "/project") { // set a project's brief (what & why)
241
255
  const b = await body(req); const k = String(b.project || "").slice(0, 80);
242
256
  if (!k) return json(res, 400, { error: "project required" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.9",
3
+ "version": "0.17.11",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
package/ui.html CHANGED
@@ -140,6 +140,48 @@ main:not(.learn-open) .learn-body{display:none}
140
140
  .felabel{fill:#7e8ca3;font-size:9px;font-family:ui-monospace,monospace;opacity:0;pointer-events:none;transition:opacity .15s}
141
141
  .fe:hover .felabel{opacity:1}
142
142
  .fe:hover .fedge{stroke:#5fa8ff}
143
+ /* FLOW = development timeline: build-order columns, agent lanes, readable fixed cards, native scroll */
144
+ .tlwrap{position:relative;background:#0b1019;border-top:1px solid var(--line);overflow:hidden}
145
+ .tlscroll{position:absolute;inset:0;overflow-x:auto;overflow-y:auto}
146
+ .tlscroll svg{display:block}
147
+ .tlband{fill:#101827}
148
+ .tlsep{stroke:#161e2c;stroke-width:1}
149
+ .tlgrid{stroke:#161e2c;stroke-width:1}
150
+ .tlt{fill:var(--dim);font-size:9px;font-family:ui-sans-serif,system-ui;text-anchor:middle}
151
+ .tledge{fill:none;stroke:#33405c;stroke-width:1.6;opacity:.8}
152
+ .tledge.done{stroke:var(--grn2)}
153
+ .tlbar{fill:none;stroke:rgba(255,255,255,.14);stroke-width:1}
154
+ .tlnode{cursor:default}
155
+ .tlnode:hover .tlbar{stroke:rgba(255,255,255,.55)}
156
+ .tlnode.doing .tlbar,.tlnode.testing .tlbar{stroke:rgba(255,255,255,.3)}
157
+ .tlnode.failed .tlbar,.tlnode.blocked .tlbar{stroke:var(--red)}
158
+ .tltitle{fill:#eaf1fa;font-size:10.5px;font-family:ui-sans-serif,system-ui;pointer-events:none}
159
+ /* pinned lane-label gutter (stays put while the chart scrolls) */
160
+ .tlgut{position:absolute;left:0;top:0;bottom:0;background:#0b1019;border-right:1px solid var(--line);z-index:3;pointer-events:none}
161
+ .tlguth{display:flex;align-items:center;padding-left:8px;font-size:9px;color:var(--dim);border-bottom:1px solid #161e2c}
162
+ .tlgl{display:flex;align-items:center;gap:6px;padding-left:8px;font-size:11px;font-weight:600;color:var(--mut)}
163
+ .tlgl svg{flex:none}
164
+ .tlwrap .fhint{position:absolute;right:10px;bottom:6px;z-index:3}
165
+ .tlnode{cursor:pointer}
166
+ /* card detail modal — the full story of one card: status journey + the agent's own bus reports */
167
+ .cmodal{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(4,7,12,.62)}
168
+ .cmpanel{background:var(--panel);border:1px solid var(--line);border-radius:14px;width:min(760px,93vw);max-height:84vh;display:flex;flex-direction:column;box-shadow:0 20px 64px rgba(0,0,0,.55)}
169
+ .cmhead{display:flex;align-items:flex-start;gap:10px;padding:15px 18px;border-bottom:1px solid var(--line)}
170
+ .cmtitle{font-size:15px;font-weight:700;color:var(--tx);line-height:1.35}
171
+ .cmmeta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:6px;font-size:11.5px;color:var(--mut)}
172
+ .cmmeta svg{flex:none;vertical-align:middle}
173
+ .cmstat{font-size:10px;padding:1px 8px;border-radius:9px;border:1px solid var(--line)}
174
+ .cmclose{margin-left:auto;cursor:pointer;color:var(--mut);font-size:17px;line-height:1;padding:3px 7px;border-radius:6px;flex:none}
175
+ .cmclose:hover{background:var(--card);color:var(--tx)}
176
+ .cmbody{overflow-y:auto;padding:10px 18px 18px}
177
+ .cmrow{display:flex;gap:10px;padding:8px 0;border-top:1px solid #161e2c;font-size:12.5px;line-height:1.5}
178
+ .cmrow:first-child{border-top:none}
179
+ .cmrow .cmt{flex:none;color:var(--dim);font-size:10px;width:66px;padding-top:2px}
180
+ .cmrow.ev .cmtx{color:var(--mut)}
181
+ .cmrow .cmtx b{color:var(--tx);font-weight:600}
182
+ .cmrow.msg .cmfrom{color:var(--tx);font-weight:600}
183
+ .cmrow.msg svg{vertical-align:-2px}
184
+ .cmempty{color:var(--dim);font-style:italic;font-size:12px;padding:8px 0}
143
185
  /* timeline view */
144
186
  .timeline{padding:14px 18px;overflow-y:auto;max-height:66vh;display:flex;flex-direction:column;gap:5px;background:#0c111c;border-top:1px solid var(--line)}
145
187
  .tevent{font-size:12.5px;line-height:1.4;color:var(--mut);display:flex;align-items:center;gap:7px;padding:4px 0}
@@ -205,6 +247,7 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
205
247
  </div>
206
248
  </aside>
207
249
  </main>
250
+ <div id="cardmodal" class="cmodal" style="display:none"></div>
208
251
  <script>
209
252
  const $=s=>document.querySelector(s);
210
253
  const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
@@ -275,67 +318,104 @@ function moveProj(name,dir){
275
318
  [names[i],names[j]]=[names[j],names[i]];
276
319
  localStorage.setItem("abOrder",JSON.stringify(names)); render();
277
320
  }
278
- function flowLayout(cards, proj){
279
- const byId = Object.fromEntries(cards.map(t => [t.id, t]));
280
- let anyDeps = cards.some(t => (t.deps || []).length);
281
- const deps = t => {
282
- if (anyDeps) return (t.deps || []).filter(d => byId[d]);
283
- if (/integrat|assemb|ship it|final/i.test(t.title)) return cards.filter(o => o.id !== t.id).map(o => o.id);
284
- return [];
285
- };
286
- const L = {};
287
- const layer = (t, seen = new Set()) => {
288
- if (L[t.id] !== undefined) return L[t.id];
289
- if (seen.has(t.id)) return (L[t.id] = 0);
290
- seen.add(t.id);
291
- const ds = deps(t);
292
- return (L[t.id] = ds.length ? 1 + Math.max(...ds.map(d => layer(byId[d], seen))) : 0);
293
- };
294
- cards.forEach(t => layer(t));
295
- const cols = {};
296
- cards.forEach(t => (cols[L[t.id]] ||= []).push(t));
297
- const NW = 190, NH = 64, GX = 90, GY = 26;
298
- const nodes = {}, maxRows = Math.max(...Object.values(cols).map(c => c.length));
299
- const totalH = maxRows * (NH + GY) - GY;
300
- const manual = (JSON.parse(localStorage.getItem("abFlowPos") || "{}"))[proj] || {};
301
- for (const [li, col] of Object.entries(cols)) {
302
- const colH = col.length * (NH + GY) - GY, y0 = (totalH - colH) / 2;
303
- col.forEach((t, ri) => {
304
- const m = manual[t.id];
305
- nodes[t.id] = { t, x: m ? m.x : li * (NW + GX), y: m ? m.y : y0 + ri * (NH + GY) };
306
- });
307
- }
308
- const edges = [];
309
- cards.forEach(t => deps(t).forEach(d => edges.push([d, t.id])));
310
- return { nodes, edges, NW, NH };
311
- }
321
+ // FLOW = development timeline. Cards laid left→right in BUILD ORDER (even slots — bursts don't cram,
322
+ // idle gaps don't waste space), one lane per agent, each card a readable FIXED-width block segmented
323
+ // by the time it spent in each status. Natively scrollable left/right; lane labels pinned in a left
324
+ // gutter; dependency edges converge = merges. (No proportional-time slivers, no zoom cut-off.)
325
+ const TLSCROLL = {};
312
326
  function flowHTML(pt, proj){
313
327
  if (!pt.length) return '<div class="empty">no cards yet</div>';
314
- const { nodes, edges, NW, NH } = flowLayout(pt, proj);
315
- const cam = (JSON.parse(localStorage.getItem("abFlowCam") || "{}"))[proj] || { s: 0.9, tx: 30, ty: 20 };
316
- let inner = "";
317
- for (const [a, b] of edges) {
318
- const A = nodes[a], B = nodes[b]; if (!A || !B) continue;
319
- const x1 = A.x + NW, y1 = A.y + NH / 2, x2 = B.x, y2 = B.y + NH / 2, mx = (x1 + x2) / 2;
320
- const cls = A.t.status === "done" ? "done" : (B.t.status === "doing" || B.t.status === "testing") ? "active" : "";
321
- const mk = cls === "done" ? "arrg" : cls === "active" ? "arrb" : "arr";
322
- const d = `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`;
323
- const label = `${String(A.t.title).slice(0, 18)} → ${String(B.t.title).slice(0, 18)}`;
324
- 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>`;
328
+ const NOW = Date.now();
329
+ const SCOL = { todo:'#3a4458', doing:'#4a90d9', testing:'#f59e0b', failed:'#ef6a6a', blocked:'#ef6a6a', done:'#14b8a6' };
330
+ const GUT = 96, TH = 24, ROW = 42, BAR = 26, SLOT = 188, CW = 172, PAD = 10;
331
+ const cards = pt.map(t => {
332
+ const h = (t.history && t.history.length) ? t.history.slice() : [{ to: t.status || 'todo', ts: t.ts || NOW }];
333
+ return { t, h, tStart: h[0].ts || t.ts || NOW, tEnd: (t.status === 'done' ? (h[h.length-1].ts || NOW) : NOW) };
334
+ });
335
+ cards.sort((a,b)=> a.tStart - b.tStart || a.t.id - b.t.id);
336
+ cards.forEach((c,i)=> c.col = i);
337
+ const byId = {}; for (const c of cards) byId[c.t.id] = c;
338
+ const laneKey = t => (String(t.assignee || 'unassigned').split(':')[0] || 'unassigned');
339
+ const laneFirst = {}; for (const c of cards){ const k = laneKey(c.t); if (laneFirst[k] == null) laneFirst[k] = c.col; }
340
+ const lanes = [...new Set(cards.map(c => laneKey(c.t)))].sort((a,b)=> laneFirst[a]-laneFirst[b]);
341
+ const laneIdx = Object.fromEntries(lanes.map((k,i)=>[k,i]));
342
+ const X = col => GUT + col*SLOT + PAD;
343
+ const laneY = i => TH + i*ROW + (ROW-BAR)/2;
344
+ const totalW = GUT + cards.length*SLOT + 24, totalH = TH + lanes.length*ROW + 8;
345
+ let svg = '';
346
+ for (let i=0;i<lanes.length;i++){ const yb = TH + i*ROW;
347
+ if (i%2) svg += `<rect class="tlband" x="${GUT}" y="${yb}" width="${totalW-GUT}" height="${ROW}"/>`;
348
+ svg += `<line class="tlsep" x1="${GUT}" y1="${yb}" x2="${totalW}" y2="${yb}"/>`;
325
349
  }
326
- for (const { t, x, y } of Object.values(nodes)) {
327
- 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;
328
- inner += `<g class="fnode ${t.status}" data-id="${t.id}" transform="translate(${x},${y})">`;
329
- inner += `<rect width="${NW}" height="${NH}" rx="9"/>`;
330
- inner += `<text class="ftitle" x="10" y="20">${esc(t.title.slice(0, 26))}${t.title.length > 26 ? "…" : ""}</text>`;
331
- 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>`;
332
- inner += `<text class="fmeta" x="10" y="53">${t.status}</text>`;
333
- 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>`;
334
- inner += `</g>`;
350
+ // build-order time markers (every few cards, label that card's real timestamp) — scroll with the chart
351
+ const step = Math.max(1, Math.round(cards.length/12));
352
+ for (let i=0;i<cards.length;i+=step){ const xx = X(cards[i].col) + CW/2;
353
+ svg += `<line class="tlgrid" x1="${xx}" y1="${TH}" x2="${xx}" y2="${totalH-6}"/>`;
354
+ svg += `<text class="tlt" x="${xx}" y="15">${esc(new Date(cards[i].tStart).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}))}</text>`;
335
355
  }
336
- 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>`;
337
- 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>`;
356
+ for (const c of cards) for (const d of (c.t.deps||[])){ const A = byId[d]; if (!A) continue;
357
+ const x1 = X(A.col)+CW, y1 = laneY(laneIdx[laneKey(A.t)])+BAR/2, x2 = X(c.col), y2 = laneY(laneIdx[laneKey(c.t)])+BAR/2, mx = (x1+x2)/2;
358
+ svg += `<path class="tledge ${A.t.status==='done'?'done':''}" marker-end="url(#tarr)" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}"/>`;
359
+ }
360
+ for (const c of cards){ const xx = X(c.col), yy = laneY(laneIdx[laneKey(c.t)]), dur = Math.max(1, c.tEnd - c.tStart);
361
+ svg += `<g class="tlnode ${c.t.status}" data-id="${c.t.id}"><title>${esc(c.t.title)} — ${c.t.status} · @${esc(laneKey(c.t))}\n${esc(new Date(c.tStart).toLocaleString())}</title>`;
362
+ let sx = xx;
363
+ for (let i=0;i<c.h.length;i++){ const s0 = c.h[i].ts || c.tStart, s1 = (i+1<c.h.length) ? (c.h[i+1].ts||s0) : c.tEnd;
364
+ const w = Math.max(1.5, (Math.max(0, s1-s0)/dur)*CW);
365
+ svg += `<rect x="${sx.toFixed(1)}" y="${yy}" width="${w.toFixed(1)}" height="${BAR}" fill="${SCOL[c.h[i].to]||'#3a4458'}"/>`; sx += w;
366
+ }
367
+ svg += `<rect class="tlbar" x="${xx}" y="${yy}" width="${CW}" height="${BAR}" rx="5"/>`;
368
+ svg += `<text class="tltitle" x="${xx+7}" y="${yy+BAR/2+4}">${esc(c.t.title.slice(0,25))}${c.t.title.length>25?'…':''}</text>`;
369
+ svg += `</g>`;
370
+ }
371
+ const defs = `<defs><marker id="tarr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#14b8a6"/></marker></defs>`;
372
+ let gut = `<div class="tlgut" style="width:${GUT}px"><div class="tlguth" style="height:${TH}px">time →</div>`;
373
+ for (const k of lanes) gut += `<div class="tlgl" style="height:${ROW}px">${iconFor(k,13)}<span>${esc(k)}</span></div>`;
374
+ gut += `</div>`;
375
+ return `<div class="tlwrap" data-proj="${esc(proj)}" style="height:${Math.min(totalH+14, 540)}px">`
376
+ + `<div class="tlscroll" data-proj="${esc(proj)}"><svg width="${totalW}" height="${totalH}">${defs}${svg}</svg></div>`
377
+ + gut + `<div class="fhint">build order → · scroll left/right</div></div>`;
378
+ }
379
+ function wireTimeline(el){
380
+ el.querySelectorAll('.tlscroll').forEach(s => {
381
+ const proj = s.dataset.proj;
382
+ if (TLSCROLL[proj] != null) s.scrollLeft = TLSCROLL[proj];
383
+ s.onscroll = () => { TLSCROLL[proj] = s.scrollLeft; };
384
+ });
385
+ el.querySelectorAll('.tlnode').forEach(n => n.onclick = () => openCard(+n.dataset.id));
386
+ }
387
+ // Card detail modal: one card's FULL story — its status journey interleaved with the agent's own bus
388
+ // reports (what it did, why, how). Click any card in the FLOW timeline to open it.
389
+ let cardEsc = null;
390
+ async function openCard(id){
391
+ let d; try { d = await (await fetch('/card?id=' + id)).json(); } catch { return; }
392
+ const t = d.task; if (!t) return;
393
+ const SC = { todo:'var(--mut)', doing:'var(--blu)', testing:'var(--amb)', failed:'var(--red)', blocked:'var(--red)', done:'var(--grn)', deleted:'var(--dim)' };
394
+ const items = [];
395
+ for (const e of (d.events||[])) items.push({ ts:e.ts, ev:e });
396
+ for (const m of (d.messages||[])) items.push({ ts:m.ts, m });
397
+ items.sort((a,b)=> (a.ts||0)-(b.ts||0));
398
+ const tm = ts => new Date(ts).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
399
+ const rows = items.map(it => {
400
+ if (it.ev){ const e=it.ev; const tx = e.type==='created'?`created as <b>${esc(e.to||'todo')}</b>` : e.type==='moved'?`moved <b>${esc(e.from||'')}</b> → <b>${esc(e.to||'')}</b>` : e.type==='deleted'?`deleted` : `updated`;
401
+ return `<div class="cmrow ev"><span class="cmt">${esc(tm(e.ts))}</span><span class="cmtx">${tx}${e.by?` · ${esc(String(e.by).split(':')[0])}`:''}</span></div>`; }
402
+ const m=it.m; return `<div class="cmrow msg"><span class="cmt">${esc(tm(m.ts))}</span><span class="cmtx">${iconFor(m.from,13)} <span class="cmfrom">${esc(String(m.from).split(':')[0])}</span>${m.to&&m.to!=='all'?` → ${esc(String(m.to).split(':')[0])}`:''}: ${esc(m.text)}</span></div>`;
403
+ }).join('');
404
+ const el = $('#cardmodal');
405
+ el.innerHTML = `<div class="cmpanel">`
406
+ + `<div class="cmhead"><div><div class="cmtitle">${esc(t.title)}</div>`
407
+ + `<div class="cmmeta">${iconFor(t.assignee||'',14)} ${esc(String(t.assignee||'unassigned').split(':')[0])}`
408
+ + ` <span class="cmstat" style="color:${SC[t.status]||'var(--mut)'};border-color:${SC[t.status]||'var(--line)'}">${esc(t.status)}</span>`
409
+ + `${t.difficulty?` · ${esc(t.difficulty)}`:''}${t.model?` · ${esc(String(t.model).slice(0,26))}`:''} · #${id} · ${esc(t.project||'')}</div></div>`
410
+ + `<span class="cmclose" id="cmclose">✕</span></div>`
411
+ + `<div class="cmbody">${rows || '<div class="cmempty">No recorded activity for this card yet — no bus reports reference #'+id+'.</div>'}</div></div>`;
412
+ el.style.display = 'flex';
413
+ el.querySelector('.cmpanel').onclick = e => e.stopPropagation();
414
+ el.onclick = closeCard; $('#cmclose').onclick = closeCard;
415
+ cardEsc = e => { if (e.key === 'Escape') closeCard(); };
416
+ document.addEventListener('keydown', cardEsc);
338
417
  }
418
+ function closeCard(){ const el=$('#cardmodal'); el.style.display='none'; el.innerHTML=''; if(cardEsc){ document.removeEventListener('keydown', cardEsc); cardEsc=null; } }
339
419
  let dragging = null; // suppresses re-render mid-gesture
340
420
  function saveCam(proj, cam){ const c = JSON.parse(localStorage.getItem("abFlowCam") || "{}"); c[proj] = cam; localStorage.setItem("abFlowCam", JSON.stringify(c)); }
341
421
  function wireFlow(el){
@@ -462,15 +542,21 @@ async function render(){
462
542
  const sel=$('#to'),cur=sel.value;
463
543
  sel.innerHTML='<option value="all">all (broadcast)</option>'+allS.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
464
544
  sel.value=cur;
465
- // sort: manual order (abOrder, set via the ▲▼ controls) wins; otherwise online
466
- // first, then most-recent activity — working boards float to the top on their own
545
+ // STABLE display order: projects keep their first-seen position so the board NEVER reshuffles on
546
+ // activity — a working board updates in place instead of jumping to the top (which made boards
547
+ // pop in and out and impossible to read). Manual ▲▼ (abOrder) still wins; live boards group above
548
+ // idle. (Recency auto-sort removed — that was the thrash.)
549
+ let SEEN=JSON.parse(localStorage.getItem("abSeen")||"[]");
550
+ const curNames=projects.map(p=>p.project);
551
+ const SEEN2=[...SEEN.filter(n=>curNames.includes(n)),...curNames.filter(n=>!SEEN.includes(n))];
552
+ if(JSON.stringify(SEEN2)!==JSON.stringify(SEEN)){SEEN=SEEN2;localStorage.setItem("abSeen",JSON.stringify(SEEN));}
467
553
  const ORDER=JSON.parse(localStorage.getItem("abOrder")||"[]");
468
554
  projects.sort((a,b)=>{
469
555
  const ia=ORDER.indexOf(a.project),ib=ORDER.indexOf(b.project);
470
556
  if(ia>=0||ib>=0)return (ia<0?1e9:ia)-(ib<0?1e9:ib);
471
557
  const oa=a.agents.some(x=>x.online),ob=b.agents.some(x=>x.online);if(oa!==ob)return ob-oa;
472
558
  if(a.project==='(unassigned)')return 1;if(b.project==='(unassigned)')return -1;
473
- return (b.lastActivity||0)-(a.lastActivity||0)||a.project.localeCompare(b.project);
559
+ return SEEN.indexOf(a.project)-SEEN.indexOf(b.project); // stable first-seen order
474
560
  });
475
561
  const el=$('#boards');
476
562
  if(dragging)return; // never rebuild mid-gesture
@@ -540,7 +626,7 @@ async function render(){
540
626
  // "✕ sure?" confirmation survives until it expires instead of silently resetting
541
627
  if(armedDel&&Date.now()-armedTs<4000){const ab=el.querySelector(`.pdel[data-proj="${CSS.escape(armedDel)}"]`);if(ab){ab.classList.add('arm');ab.textContent='✕ sure?';}}
542
628
  const sr=$('#sortreset');if(sr)sr.onclick=()=>{localStorage.removeItem('abOrder');render();};
543
- wireFlow(el);
629
+ wireFlow(el);wireTimeline(el);
544
630
  // keep each timeline scrolled to the latest event
545
631
  el.querySelectorAll('.timeline').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';};});
546
632
  el.querySelectorAll('.tcard, .fnode').forEach(c=>c.onclick=async()=>{