trantor 0.17.8 → 0.17.10
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/hooks/hooks.json +3 -2
- package/hooks/todo-sync.mjs +52 -0
- package/hub.mjs +38 -0
- package/package.json +1 -1
- package/ui.html +92 -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.10"
|
|
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.10",
|
|
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.10",
|
|
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/hooks/hooks.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call
|
|
2
|
+
"description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
|
|
6
6
|
],
|
|
7
7
|
"PostToolUse": [
|
|
8
|
-
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] }
|
|
8
|
+
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] },
|
|
9
|
+
{ "matcher": "TodoWrite", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/todo-sync.mjs" } ] }
|
|
9
10
|
],
|
|
10
11
|
"PreCompact": [
|
|
11
12
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs" } ] }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor PostToolUse(TodoWrite) — mirror the session's todo list onto its project board as cards,
|
|
3
|
+
// so SOLO work (no crew fired up) shows up live and accrues timeline history. The hub reconciles by
|
|
4
|
+
// todo text (pending/in_progress/completed -> todo/doing/done). Fail-silent by contract: a bad hub,
|
|
5
|
+
// a home-dir session, or any error must never block or break the tool flow.
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join, basename } from "node:path";
|
|
8
|
+
import { homedir, hostname } from "node:os";
|
|
9
|
+
|
|
10
|
+
function relayUrl() {
|
|
11
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
12
|
+
try {
|
|
13
|
+
const cfg = join(homedir(), ".agent-bus", "config.json");
|
|
14
|
+
if (existsSync(cfg)) { const u = JSON.parse(readFileSync(cfg, "utf8")).url; if (u) return u; }
|
|
15
|
+
} catch {}
|
|
16
|
+
return "http://127.0.0.1:4477";
|
|
17
|
+
}
|
|
18
|
+
function readStdin() {
|
|
19
|
+
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
20
|
+
process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
|
|
21
|
+
setTimeout(() => res(d), 200); });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
26
|
+
// Mirror sessionstart/heartbeat: home-directory sessions aren't project work — don't card them
|
|
27
|
+
// (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
|
|
28
|
+
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
|
|
29
|
+
|
|
30
|
+
let input = {};
|
|
31
|
+
try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
|
|
32
|
+
if (input.tool_name && input.tool_name !== "TodoWrite") return; // the matcher should scope us, but be safe
|
|
33
|
+
const todos = input.tool_input?.todos;
|
|
34
|
+
if (!Array.isArray(todos) || !todos.length) return;
|
|
35
|
+
|
|
36
|
+
// Identity EXACTLY as mcp.mjs/heartbeat resolve it, so we card the same peer the relay registered.
|
|
37
|
+
const project = process.env.RELAY_PROJECT || basename(projectDir);
|
|
38
|
+
const session = process.env.RELAY_SESSION
|
|
39
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await fetch(`${relayUrl()}/todos`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
body: JSON.stringify({ session, project, by: session, todos: todos.map(t => ({ content: t.content, status: t.status })) }),
|
|
46
|
+
signal: AbortSignal.timeout(1500),
|
|
47
|
+
});
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Never block or break the tool flow: swallow everything, always exit clean.
|
|
52
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
package/hub.mjs
CHANGED
|
@@ -189,6 +189,44 @@ const server = http.createServer(async (req, res) => {
|
|
|
189
189
|
appendCardEvent(eventType, t, b.by, eventFrom, eventTo);
|
|
190
190
|
t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
|
|
191
191
|
}
|
|
192
|
+
// Mirror a session's TodoWrite list onto its board as cards, so SOLO work (no crew) shows up live
|
|
193
|
+
// and accrues timeline history. pending/in_progress/completed -> todo/doing/done. Reconciled by
|
|
194
|
+
// todo text per session: present todos create/update; a vanished todo's card is deleted UNLESS it
|
|
195
|
+
// was already done (accomplished work stays in the DONE column). Posted by hooks/todo-sync.mjs.
|
|
196
|
+
if (req.method === "POST" && P === "/todos") {
|
|
197
|
+
const b = await body(req);
|
|
198
|
+
const session = String(b.session || b.by || "").slice(0, 120);
|
|
199
|
+
const project = String(b.project || "").slice(0, 80);
|
|
200
|
+
if (!session || !project) return json(res, 400, { error: "session and project required" });
|
|
201
|
+
touch(session, undefined, project);
|
|
202
|
+
const ST = { pending: "todo", in_progress: "doing", completed: "done" };
|
|
203
|
+
const todos = Array.isArray(b.todos) ? b.todos : [];
|
|
204
|
+
const mine = state.tasks.filter(t => t.source === "todo" && t.assignee === session && t.project === project);
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
for (const todo of todos) {
|
|
207
|
+
const key = String(todo?.content || "").trim().slice(0, 200);
|
|
208
|
+
if (!key) continue;
|
|
209
|
+
seen.add(key);
|
|
210
|
+
const want = ST[todo.status] || "todo";
|
|
211
|
+
let t = mine.find(c => c.todoKey === key);
|
|
212
|
+
if (!t) {
|
|
213
|
+
t = { id: ++state.taskSeq, project, title: key, assignee: session, status: want, difficulty: "", model: "",
|
|
214
|
+
deps: [], by: session, ts: now(), updated: now(), source: "todo", todoKey: key,
|
|
215
|
+
history: [{ to: want, by: session, ts: now() }] };
|
|
216
|
+
state.tasks.push(t); appendCardEvent("created", t, session, null, want); dirty = true;
|
|
217
|
+
} else if (t.status !== want) {
|
|
218
|
+
(t.history ||= []).push({ from: t.status, to: want, by: session, ts: now() });
|
|
219
|
+
if (t.history.length > 40) t.history.splice(0, 10);
|
|
220
|
+
appendCardEvent("moved", t, session, t.status, want); t.status = want; t.updated = now(); dirty = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const t of mine) {
|
|
224
|
+
if (seen.has(t.todoKey) || t.status === "done") continue; // keep accomplished work on the board
|
|
225
|
+
state.tasks = state.tasks.filter(x => x.id !== t.id); appendCardEvent("deleted", t, session, null, null); dirty = true;
|
|
226
|
+
}
|
|
227
|
+
if (state.tasks.length > 2000) state.tasks.splice(0, state.tasks.length - 2000);
|
|
228
|
+
return json(res, 200, { ok: true, count: todos.length });
|
|
229
|
+
}
|
|
192
230
|
if (req.method === "GET" && P === "/tasks") {
|
|
193
231
|
const proj = q.project; const ts = proj ? state.tasks.filter(t => t.project === proj) : state.tasks;
|
|
194
232
|
return json(res, 200, { tasks: ts });
|
package/package.json
CHANGED
package/ui.html
CHANGED
|
@@ -140,6 +140,28 @@ 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}
|
|
143
165
|
/* timeline view */
|
|
144
166
|
.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
167
|
.tevent{font-size:12.5px;line-height:1.4;color:var(--mut);display:flex;align-items:center;gap:7px;padding:4px 0}
|
|
@@ -275,66 +297,70 @@ function moveProj(name,dir){
|
|
|
275
297
|
[names[i],names[j]]=[names[j],names[i]];
|
|
276
298
|
localStorage.setItem("abOrder",JSON.stringify(names)); render();
|
|
277
299
|
}
|
|
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
|
-
}
|
|
300
|
+
// FLOW = development timeline. Cards laid left→right in BUILD ORDER (even slots — bursts don't cram,
|
|
301
|
+
// idle gaps don't waste space), one lane per agent, each card a readable FIXED-width block segmented
|
|
302
|
+
// by the time it spent in each status. Natively scrollable left/right; lane labels pinned in a left
|
|
303
|
+
// gutter; dependency edges converge = merges. (No proportional-time slivers, no zoom cut-off.)
|
|
304
|
+
const TLSCROLL = {};
|
|
312
305
|
function flowHTML(pt, proj){
|
|
313
306
|
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
|
-
|
|
307
|
+
const NOW = Date.now();
|
|
308
|
+
const SCOL = { todo:'#3a4458', doing:'#4a90d9', testing:'#f59e0b', failed:'#ef6a6a', blocked:'#ef6a6a', done:'#14b8a6' };
|
|
309
|
+
const GUT = 96, TH = 24, ROW = 42, BAR = 26, SLOT = 188, CW = 172, PAD = 10;
|
|
310
|
+
const cards = pt.map(t => {
|
|
311
|
+
const h = (t.history && t.history.length) ? t.history.slice() : [{ to: t.status || 'todo', ts: t.ts || NOW }];
|
|
312
|
+
return { t, h, tStart: h[0].ts || t.ts || NOW, tEnd: (t.status === 'done' ? (h[h.length-1].ts || NOW) : NOW) };
|
|
313
|
+
});
|
|
314
|
+
cards.sort((a,b)=> a.tStart - b.tStart || a.t.id - b.t.id);
|
|
315
|
+
cards.forEach((c,i)=> c.col = i);
|
|
316
|
+
const byId = {}; for (const c of cards) byId[c.t.id] = c;
|
|
317
|
+
const laneKey = t => (String(t.assignee || 'unassigned').split(':')[0] || 'unassigned');
|
|
318
|
+
const laneFirst = {}; for (const c of cards){ const k = laneKey(c.t); if (laneFirst[k] == null) laneFirst[k] = c.col; }
|
|
319
|
+
const lanes = [...new Set(cards.map(c => laneKey(c.t)))].sort((a,b)=> laneFirst[a]-laneFirst[b]);
|
|
320
|
+
const laneIdx = Object.fromEntries(lanes.map((k,i)=>[k,i]));
|
|
321
|
+
const X = col => GUT + col*SLOT + PAD;
|
|
322
|
+
const laneY = i => TH + i*ROW + (ROW-BAR)/2;
|
|
323
|
+
const totalW = GUT + cards.length*SLOT + 24, totalH = TH + lanes.length*ROW + 8;
|
|
324
|
+
let svg = '';
|
|
325
|
+
for (let i=0;i<lanes.length;i++){ const yb = TH + i*ROW;
|
|
326
|
+
if (i%2) svg += `<rect class="tlband" x="${GUT}" y="${yb}" width="${totalW-GUT}" height="${ROW}"/>`;
|
|
327
|
+
svg += `<line class="tlsep" x1="${GUT}" y1="${yb}" x2="${totalW}" y2="${yb}"/>`;
|
|
328
|
+
}
|
|
329
|
+
// build-order time markers (every few cards, label that card's real timestamp) — scroll with the chart
|
|
330
|
+
const step = Math.max(1, Math.round(cards.length/12));
|
|
331
|
+
for (let i=0;i<cards.length;i+=step){ const xx = X(cards[i].col) + CW/2;
|
|
332
|
+
svg += `<line class="tlgrid" x1="${xx}" y1="${TH}" x2="${xx}" y2="${totalH-6}"/>`;
|
|
333
|
+
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>`;
|
|
334
|
+
}
|
|
335
|
+
for (const c of cards) for (const d of (c.t.deps||[])){ const A = byId[d]; if (!A) continue;
|
|
336
|
+
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;
|
|
337
|
+
svg += `<path class="tledge ${A.t.status==='done'?'done':''}" marker-end="url(#tarr)" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}"/>`;
|
|
325
338
|
}
|
|
326
|
-
for (const {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
for (const c of cards){ const xx = X(c.col), yy = laneY(laneIdx[laneKey(c.t)]), dur = Math.max(1, c.tEnd - c.tStart);
|
|
340
|
+
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>`;
|
|
341
|
+
let sx = xx;
|
|
342
|
+
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;
|
|
343
|
+
const w = Math.max(1.5, (Math.max(0, s1-s0)/dur)*CW);
|
|
344
|
+
svg += `<rect x="${sx.toFixed(1)}" y="${yy}" width="${w.toFixed(1)}" height="${BAR}" fill="${SCOL[c.h[i].to]||'#3a4458'}"/>`; sx += w;
|
|
345
|
+
}
|
|
346
|
+
svg += `<rect class="tlbar" x="${xx}" y="${yy}" width="${CW}" height="${BAR}" rx="5"/>`;
|
|
347
|
+
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>`;
|
|
348
|
+
svg += `</g>`;
|
|
335
349
|
}
|
|
336
|
-
const defs = `<defs><marker id="
|
|
337
|
-
|
|
350
|
+
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>`;
|
|
351
|
+
let gut = `<div class="tlgut" style="width:${GUT}px"><div class="tlguth" style="height:${TH}px">time →</div>`;
|
|
352
|
+
for (const k of lanes) gut += `<div class="tlgl" style="height:${ROW}px">${iconFor(k,13)}<span>${esc(k)}</span></div>`;
|
|
353
|
+
gut += `</div>`;
|
|
354
|
+
return `<div class="tlwrap" data-proj="${esc(proj)}" style="height:${Math.min(totalH+14, 540)}px">`
|
|
355
|
+
+ `<div class="tlscroll" data-proj="${esc(proj)}"><svg width="${totalW}" height="${totalH}">${defs}${svg}</svg></div>`
|
|
356
|
+
+ gut + `<div class="fhint">build order → · scroll left/right</div></div>`;
|
|
357
|
+
}
|
|
358
|
+
function wireTimeline(el){
|
|
359
|
+
el.querySelectorAll('.tlscroll').forEach(s => {
|
|
360
|
+
const proj = s.dataset.proj;
|
|
361
|
+
if (TLSCROLL[proj] != null) s.scrollLeft = TLSCROLL[proj];
|
|
362
|
+
s.onscroll = () => { TLSCROLL[proj] = s.scrollLeft; };
|
|
363
|
+
});
|
|
338
364
|
}
|
|
339
365
|
let dragging = null; // suppresses re-render mid-gesture
|
|
340
366
|
function saveCam(proj, cam){ const c = JSON.parse(localStorage.getItem("abFlowCam") || "{}"); c[proj] = cam; localStorage.setItem("abFlowCam", JSON.stringify(c)); }
|
|
@@ -462,15 +488,21 @@ async function render(){
|
|
|
462
488
|
const sel=$('#to'),cur=sel.value;
|
|
463
489
|
sel.innerHTML='<option value="all">all (broadcast)</option>'+allS.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
|
|
464
490
|
sel.value=cur;
|
|
465
|
-
//
|
|
466
|
-
//
|
|
491
|
+
// STABLE display order: projects keep their first-seen position so the board NEVER reshuffles on
|
|
492
|
+
// activity — a working board updates in place instead of jumping to the top (which made boards
|
|
493
|
+
// pop in and out and impossible to read). Manual ▲▼ (abOrder) still wins; live boards group above
|
|
494
|
+
// idle. (Recency auto-sort removed — that was the thrash.)
|
|
495
|
+
let SEEN=JSON.parse(localStorage.getItem("abSeen")||"[]");
|
|
496
|
+
const curNames=projects.map(p=>p.project);
|
|
497
|
+
const SEEN2=[...SEEN.filter(n=>curNames.includes(n)),...curNames.filter(n=>!SEEN.includes(n))];
|
|
498
|
+
if(JSON.stringify(SEEN2)!==JSON.stringify(SEEN)){SEEN=SEEN2;localStorage.setItem("abSeen",JSON.stringify(SEEN));}
|
|
467
499
|
const ORDER=JSON.parse(localStorage.getItem("abOrder")||"[]");
|
|
468
500
|
projects.sort((a,b)=>{
|
|
469
501
|
const ia=ORDER.indexOf(a.project),ib=ORDER.indexOf(b.project);
|
|
470
502
|
if(ia>=0||ib>=0)return (ia<0?1e9:ia)-(ib<0?1e9:ib);
|
|
471
503
|
const oa=a.agents.some(x=>x.online),ob=b.agents.some(x=>x.online);if(oa!==ob)return ob-oa;
|
|
472
504
|
if(a.project==='(unassigned)')return 1;if(b.project==='(unassigned)')return -1;
|
|
473
|
-
return
|
|
505
|
+
return SEEN.indexOf(a.project)-SEEN.indexOf(b.project); // stable first-seen order
|
|
474
506
|
});
|
|
475
507
|
const el=$('#boards');
|
|
476
508
|
if(dragging)return; // never rebuild mid-gesture
|
|
@@ -540,7 +572,7 @@ async function render(){
|
|
|
540
572
|
// "✕ sure?" confirmation survives until it expires instead of silently resetting
|
|
541
573
|
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
574
|
const sr=$('#sortreset');if(sr)sr.onclick=()=>{localStorage.removeItem('abOrder');render();};
|
|
543
|
-
wireFlow(el);
|
|
575
|
+
wireFlow(el);wireTimeline(el);
|
|
544
576
|
// keep each timeline scrolled to the latest event
|
|
545
577
|
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
578
|
el.querySelectorAll('.tcard, .fnode').forEach(c=>c.onclick=async()=>{
|