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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/hub.mjs +14 -0
- package/package.json +1 -1
- package/ui.html +146 -60
|
@@ -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
|
+
"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.
|
|
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.
|
|
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
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=>({'&':'&','<':'<','>':'>','"':'"'}[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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
337
|
-
|
|
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
|
-
//
|
|
466
|
-
//
|
|
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
|
|
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()=>{
|