trantor 0.17.0 → 0.17.1
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 +24 -5
- package/.claude-plugin/plugin.json +4 -2
- package/hooks/sessionstart.mjs +8 -0
- package/hub.mjs +20 -3
- package/package.json +1 -1
- package/ui.html +69 -8
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"owner": {
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Sasha Bogojevic",
|
|
5
|
+
"email": "hello@hivedigitalllc.com"
|
|
6
|
+
},
|
|
4
7
|
"metadata": {
|
|
5
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, …)",
|
|
6
|
-
"version": "0.17.
|
|
9
|
+
"version": "0.17.1"
|
|
7
10
|
},
|
|
8
11
|
"plugins": [
|
|
9
12
|
{
|
|
10
13
|
"name": "trantor",
|
|
11
14
|
"source": "./",
|
|
12
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.",
|
|
13
|
-
"version": "0.17.
|
|
14
|
-
"author": {
|
|
16
|
+
"version": "0.17.1",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Sasha Bogojevic"
|
|
19
|
+
},
|
|
15
20
|
"category": "development",
|
|
16
|
-
"keywords": [
|
|
21
|
+
"keywords": [
|
|
22
|
+
"multi-agent",
|
|
23
|
+
"agent-crew",
|
|
24
|
+
"orchestration",
|
|
25
|
+
"coordination",
|
|
26
|
+
"mcp",
|
|
27
|
+
"hooks",
|
|
28
|
+
"kanban",
|
|
29
|
+
"context-handoff",
|
|
30
|
+
"message-bus",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"codex",
|
|
33
|
+
"gemini",
|
|
34
|
+
"llm-routing"
|
|
35
|
+
]
|
|
17
36
|
}
|
|
18
37
|
]
|
|
19
38
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
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": {
|
|
7
7
|
"command": "node",
|
|
8
|
-
"args": [
|
|
8
|
+
"args": [
|
|
9
|
+
"${CLAUDE_PLUGIN_ROOT}/mcp.mjs"
|
|
10
|
+
]
|
|
9
11
|
}
|
|
10
12
|
},
|
|
11
13
|
"skills": "./skills/"
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -61,6 +61,14 @@ let additionalContext = "";
|
|
|
61
61
|
try {
|
|
62
62
|
await readStdin();
|
|
63
63
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
64
|
+
// Sessions started in the home directory itself aren't project work — registering
|
|
65
|
+
// them spawns a phantom "<username>" project board on the dashboard. Set
|
|
66
|
+
// RELAY_SESSION (or RELAY_PROJECT) to deliberately put a home-dir session on the bus.
|
|
67
|
+
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) {
|
|
68
|
+
process.stderr.write("[trantor] session in the home directory — not registering on the bus (set RELAY_SESSION to opt in)\n");
|
|
69
|
+
process.stdout.write("{}");
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
64
72
|
const session = process.env.RELAY_SESSION || `${hostname()}:${basename(projectDir)}`;
|
|
65
73
|
const url = relayUrl();
|
|
66
74
|
|
package/hub.mjs
CHANGED
|
@@ -38,6 +38,7 @@ try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch
|
|
|
38
38
|
// open SSE streams: [{ session, res }]
|
|
39
39
|
const streams = [];
|
|
40
40
|
const now = () => Date.now();
|
|
41
|
+
const fmtAge = ms => { const m = Math.floor(ms / 60000); return m > 48 * 60 ? `${Math.floor(m / 1440)}d ago` : m > 90 ? `${Math.floor(m / 60)}h ago` : `${m}m ago`; };
|
|
41
42
|
function body(req) { return new Promise(r => { let d = ""; req.on("data", c => (d += c)); req.on("end", () => { try { r(d ? JSON.parse(d) : {}); } catch { r({}); } }); }); }
|
|
42
43
|
function json(res, code, obj) { res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": "*" }); res.end(JSON.stringify(obj)); }
|
|
43
44
|
function touch(session, status, project) {
|
|
@@ -107,16 +108,30 @@ const server = http.createServer(async (req, res) => {
|
|
|
107
108
|
state.projectMeta[k] = m; dirty = true;
|
|
108
109
|
return json(res, 200, { ok: true, project: k, brief: m.brief || "" });
|
|
109
110
|
}
|
|
111
|
+
if (req.method === "POST" && P === "/project/delete") { // forget a project: its cards, peers, brief, and lane
|
|
112
|
+
const b = await body(req); const k = String(b.project || "").slice(0, 80);
|
|
113
|
+
if (!k) return json(res, 400, { error: "project required" });
|
|
114
|
+
const nt = state.tasks.length, np = Object.keys(state.peers).length, nm = state.messages.length;
|
|
115
|
+
state.tasks = state.tasks.filter(t => t.project !== k);
|
|
116
|
+
for (const [s, v] of Object.entries(state.peers)) if (v.project === k) delete state.peers[s];
|
|
117
|
+
delete state.projectMeta[k];
|
|
118
|
+
state.messages = state.messages.filter(m2 => (m2.project || "") !== k);
|
|
119
|
+
dirty = true; // the project reappears cleanly if an agent ever registers it again
|
|
120
|
+
return json(res, 200, { ok: true, project: k, removed: { tasks: nt - state.tasks.length, peers: np - Object.keys(state.peers).length, messages: nm - state.messages.length } });
|
|
121
|
+
}
|
|
110
122
|
if (req.method === "GET" && P === "/projects") { // project-grouped view
|
|
111
123
|
const cutoff = now() - ONLINE_MS; const byProj = {};
|
|
112
124
|
const proj = p => p || "(unassigned)";
|
|
113
|
-
const mk = k => (byProj[k] ||= { project: k, brief: (state.projectMeta[k]?.brief) || "", agents: [], tasks: { todo:0,doing:0,testing:0,failed:0,done:0,blocked:0 }, doingTitles: [] });
|
|
125
|
+
const mk = k => (byProj[k] ||= { project: k, brief: (state.projectMeta[k]?.brief) || "", agents: [], tasks: { todo:0,doing:0,testing:0,failed:0,done:0,blocked:0 }, doingTitles: [], lastActivity: 0 });
|
|
114
126
|
for (const [s, v] of Object.entries(state.peers)) {
|
|
115
|
-
const k = proj(v.project); mk(k).agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "" });
|
|
127
|
+
const k = proj(v.project); const e = mk(k); e.agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "" });
|
|
128
|
+
if ((v.lastSeen || 0) > e.lastActivity) e.lastActivity = v.lastSeen;
|
|
116
129
|
}
|
|
117
|
-
for (const t of state.tasks) { const e = mk(proj(t.project)); e.tasks[t.status] = (e.tasks[t.status]||0)+1; if (t.status === "doing") e.doingTitles.push(t.title); }
|
|
130
|
+
for (const t of state.tasks) { const e = mk(proj(t.project)); e.tasks[t.status] = (e.tasks[t.status]||0)+1; if (t.status === "doing") e.doingTitles.push(t.title); if ((t.updated || 0) > e.lastActivity) e.lastActivity = t.updated; }
|
|
118
131
|
// derive a one-line phase ("where it is in the process") from the board
|
|
119
132
|
for (const e of Object.values(byProj)) {
|
|
133
|
+
const mu = state.projectMeta[e.project]?.updated || 0; if (mu > e.lastActivity) e.lastActivity = mu;
|
|
134
|
+
e.idle = !e.agents.some(a => a.online);
|
|
120
135
|
const { todo, doing, testing=0, failed=0, done, blocked } = e.tasks; const total = todo+doing+testing+failed+done+blocked;
|
|
121
136
|
e.phase = total === 0 ? "no cards yet"
|
|
122
137
|
: failed > 0 ? `${failed} FAILED — fixing`
|
|
@@ -126,6 +141,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
126
141
|
: done === total ? "shipped — all cards done"
|
|
127
142
|
: todo > 0 ? `planned: ${todo} card${todo>1?"s":""} queued`
|
|
128
143
|
: "in progress";
|
|
144
|
+
// dead board: no live agents -> the phase above is stale, say so honestly
|
|
145
|
+
if (e.idle) e.phase = `idle · last activity ${e.lastActivity ? fmtAge(now() - e.lastActivity) : "unknown"}`;
|
|
129
146
|
}
|
|
130
147
|
return json(res, 200, { projects: Object.values(byProj) });
|
|
131
148
|
}
|
package/package.json
CHANGED
package/ui.html
CHANGED
|
@@ -56,6 +56,20 @@ main{flex:1;display:grid;grid-template-columns:1fr 330px;min-height:0}
|
|
|
56
56
|
.tcard.done{opacity:.65}.tcard.done span{text-decoration:line-through}
|
|
57
57
|
.empty{color:var(--dim);text-align:center;padding:30px}
|
|
58
58
|
.empty.big{padding:60px 20px;font-size:15px}
|
|
59
|
+
/* idle projects — collapsed thin rows below the live boards */
|
|
60
|
+
.idle-head{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);font-weight:700;margin:18px 2px 8px}
|
|
61
|
+
.idle-row{display:flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:7px 14px;margin-bottom:7px;font-size:12.5px;color:var(--mut);cursor:pointer}
|
|
62
|
+
.idle-row:hover{background:var(--card);border-color:#2c3a52}
|
|
63
|
+
.idle-row .nm{color:var(--tx);font-weight:600}
|
|
64
|
+
.mv{display:inline-flex;gap:3px;margin-left:6px}
|
|
65
|
+
.mv b{cursor:pointer;color:var(--dim);font-size:10px;line-height:1;padding:3px 5px;border:1px solid var(--line);border-radius:6px;background:var(--card);font-weight:400}
|
|
66
|
+
.mv b:hover{color:var(--tx);border-color:#2c3a52}
|
|
67
|
+
.pdel{cursor:pointer;color:var(--dim);font-size:10.5px;padding:2px 7px;border:1px solid var(--line);border-radius:6px;background:var(--card);margin-left:5px;white-space:nowrap}
|
|
68
|
+
.pdel:hover{color:var(--red);border-color:#5a2c2c}
|
|
69
|
+
.pdel.arm{color:#fff;background:var(--red);border-color:var(--red)}
|
|
70
|
+
.sortmode{font-size:10.5px;color:var(--dim);margin:0 0 10px;cursor:pointer;user-select:none}
|
|
71
|
+
.sortmode:hover{color:var(--mut)}
|
|
72
|
+
.proj.idle .proj-h{opacity:.55;cursor:pointer}
|
|
59
73
|
/* flow (DAG) view */
|
|
60
74
|
.vtog{display:flex;gap:2px;margin-left:10px}
|
|
61
75
|
.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}
|
|
@@ -120,7 +134,7 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
|
|
|
120
134
|
<body>
|
|
121
135
|
<header>
|
|
122
136
|
<span class="dot" id="livedot"></span>
|
|
123
|
-
<span class="logo">
|
|
137
|
+
<span class="logo">tran<b>t</b>or</span>
|
|
124
138
|
<span class="pill" id="hub">—</span>
|
|
125
139
|
<span class="spacer"></span>
|
|
126
140
|
<span class="pill" id="econ" title="Scrooge ledger, last 24h" style="display:none"></span>
|
|
@@ -160,7 +174,7 @@ const ICON={
|
|
|
160
174
|
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
175
|
function brandOf(s){const x=String(s).toLowerCase();for(const[re,b]of BRAND)if(re.test(x))return b;return null;}
|
|
162
176
|
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
|
|
177
|
+
const phaseClass=ph=>/^idle ·/.test(ph)?'planned':/FAILED|blocked/.test(ph)?'blocked':/building|verifying|progress/.test(ph)?'building':/shipped|done/.test(ph)?'shipped':'planned';
|
|
164
178
|
|
|
165
179
|
let POOLS={};
|
|
166
180
|
async function econ(){
|
|
@@ -178,6 +192,16 @@ econ();setInterval(econ,15000);
|
|
|
178
192
|
function poolOf(session){const b=brandOf(session);const k=b==='anthropic'?'claude':b==='openai'?'codex':b==='moonshot'?'kimi':b;return POOLS[k]||'';}
|
|
179
193
|
const VIEWS = JSON.parse(localStorage.getItem("abViews") || "{}");
|
|
180
194
|
function setView(proj, v){ VIEWS[proj] = v; localStorage.setItem("abViews", JSON.stringify(VIEWS)); render(); }
|
|
195
|
+
function toggleIdle(name){ if(!name)return; const s=new Set(JSON.parse(localStorage.getItem("abIdleOpen")||"[]")); s.has(name)?s.delete(name):s.add(name); localStorage.setItem("abIdleOpen",JSON.stringify([...s])); render(); }
|
|
196
|
+
let armedDel=null,armedTs=0; // pending ✕ confirmation (project name + when it was armed)
|
|
197
|
+
// ▲▼: snapshot the currently displayed order, swap, persist — manual order then wins until ↺ reset
|
|
198
|
+
function moveProj(name,dir){
|
|
199
|
+
const names=[...document.querySelectorAll('#boards [data-projname]')].map(d=>d.dataset.projname);
|
|
200
|
+
const i=names.indexOf(name),j=i+dir;
|
|
201
|
+
if(i<0||j<0||j>=names.length)return;
|
|
202
|
+
[names[i],names[j]]=[names[j],names[i]];
|
|
203
|
+
localStorage.setItem("abOrder",JSON.stringify(names)); render();
|
|
204
|
+
}
|
|
181
205
|
function flowLayout(cards, proj){
|
|
182
206
|
const byId = Object.fromEntries(cards.map(t => [t.id, t]));
|
|
183
207
|
let anyDeps = cards.some(t => (t.deps || []).length);
|
|
@@ -343,12 +367,21 @@ async function render(){
|
|
|
343
367
|
const sel=$('#to'),cur=sel.value;
|
|
344
368
|
sel.innerHTML='<option value="all">all (broadcast)</option>'+allS.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
|
|
345
369
|
sel.value=cur;
|
|
346
|
-
// sort:
|
|
347
|
-
|
|
370
|
+
// sort: manual order (abOrder, set via the ▲▼ controls) wins; otherwise online
|
|
371
|
+
// first, then most-recent activity — working boards float to the top on their own
|
|
372
|
+
const ORDER=JSON.parse(localStorage.getItem("abOrder")||"[]");
|
|
373
|
+
projects.sort((a,b)=>{
|
|
374
|
+
const ia=ORDER.indexOf(a.project),ib=ORDER.indexOf(b.project);
|
|
375
|
+
if(ia>=0||ib>=0)return (ia<0?1e9:ia)-(ib<0?1e9:ib);
|
|
376
|
+
const oa=a.agents.some(x=>x.online),ob=b.agents.some(x=>x.online);if(oa!==ob)return ob-oa;
|
|
377
|
+
if(a.project==='(unassigned)')return 1;if(b.project==='(unassigned)')return -1;
|
|
378
|
+
return (b.lastActivity||0)-(a.lastActivity||0)||a.project.localeCompare(b.project);
|
|
379
|
+
});
|
|
348
380
|
const el=$('#boards');
|
|
349
381
|
if(dragging)return; // never rebuild mid-gesture
|
|
350
382
|
if(!projects.length){el.innerHTML='<div class="empty big">no projects yet — agents register a project on connect</div>';return;}
|
|
351
|
-
|
|
383
|
+
const idleOpen=new Set(JSON.parse(localStorage.getItem("abIdleOpen")||"[]"));
|
|
384
|
+
const projBlock=p=>{
|
|
352
385
|
const pt=tasks.filter(t=>t.project===p.project);
|
|
353
386
|
const done=pt.filter(t=>t.status==='done').length;
|
|
354
387
|
const pct=pt.length?Math.round(done/pt.length*100):0;
|
|
@@ -371,18 +404,46 @@ async function render(){
|
|
|
371
404
|
const pmsgs=msgs.filter(m=>projOf(m)===p.project);
|
|
372
405
|
const view = VIEWS[p.project] || "board";
|
|
373
406
|
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
|
-
|
|
375
|
-
|
|
407
|
+
const ctl=`<span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span>${p.idle===true?`<span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span>`:''}`;
|
|
408
|
+
return `<div class="proj${p.idle===true?' idle':''}" data-projname="${esc(p.project)}"${p.idle===true?` data-idleproj="${esc(p.project)}"`:''}>`+
|
|
409
|
+
`<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>${ctl}</div>`+
|
|
376
410
|
`<div class="proj-brief">${brief}${ph?`<span class="phase ${phaseClass(ph)}">${esc(ph)}</span>`:''}</div>`+
|
|
377
411
|
`<div class="pbar"><i style="width:${pct}%"></i></div>`+
|
|
378
412
|
(view === "flow" ? flowHTML(pt, p.project) : `<div class="kanban">${cols}</div>`)+
|
|
379
413
|
chatLane(pmsgs)+
|
|
380
414
|
`</div>`;
|
|
381
|
-
}
|
|
415
|
+
};
|
|
416
|
+
// idle projects (zero online agents) collapse to thin rows below the live boards
|
|
417
|
+
const idleRow=p=>{
|
|
418
|
+
const pt=tasks.filter(t=>t.project===p.project);const done=pt.filter(t=>t.status==='done').length;
|
|
419
|
+
return `<div class="idle-row" data-projname="${esc(p.project)}" data-idleproj="${esc(p.project)}" title="click to expand">💤 <span class="nm">${esc(p.project)}</span><span class="dim">· ${esc(p.phase||'')} · ${done}/${pt.length} cards</span><span class="spacer"></span><span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span><span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span></div>`;
|
|
420
|
+
};
|
|
421
|
+
const live=projects.filter(p=>p.idle!==true),idlers=projects.filter(p=>p.idle===true);
|
|
422
|
+
el.innerHTML=(ORDER.length?`<div class="sortmode" id="sortreset" title="you've ordered projects manually — click to go back to automatic (recency) ordering">sort: manual · ↺ back to auto</div>`:'')
|
|
423
|
+
+live.map(projBlock).join('')
|
|
424
|
+
+(idlers.length?`<div class="idle-head">idle projects · ${idlers.length}</div>`+idlers.map(p=>idleOpen.has(p.project)?projBlock(p):idleRow(p)).join(''):'');
|
|
382
425
|
// keep each project's chat scrolled to the latest line
|
|
383
426
|
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
427
|
// click a card -> advance status todo->doing->done
|
|
385
428
|
el.querySelectorAll('.vbtn').forEach(b=>b.onclick=()=>setView(b.dataset.proj,b.dataset.view));
|
|
429
|
+
// collapsed idle row -> expand; expanded idle project's header -> collapse again
|
|
430
|
+
el.querySelectorAll('.idle-row').forEach(r=>r.onclick=e=>{if(e.target.closest('.mv,.pdel'))return;toggleIdle(r.dataset.idleproj);});
|
|
431
|
+
el.querySelectorAll('.proj.idle .proj-h').forEach(h=>h.onclick=e=>{if(e.target.closest('.vbtn,.agent,.mv,.pdel'))return;toggleIdle(h.closest('.proj').dataset.idleproj);});
|
|
432
|
+
// ▲▼ reorder (persists as a manual order); ✕ forget — armed two-step, no popup
|
|
433
|
+
el.querySelectorAll('.mvup').forEach(b=>b.onclick=e=>{e.stopPropagation();moveProj(b.dataset.proj,-1);});
|
|
434
|
+
el.querySelectorAll('.mvdn').forEach(b=>b.onclick=e=>{e.stopPropagation();moveProj(b.dataset.proj,1);});
|
|
435
|
+
el.querySelectorAll('.pdel').forEach(b=>b.onclick=async e=>{
|
|
436
|
+
e.stopPropagation();
|
|
437
|
+
const p=b.dataset.proj;
|
|
438
|
+
if(!(armedDel===p&&Date.now()-armedTs<4000)){armedDel=p;armedTs=Date.now();b.classList.add('arm');b.textContent='✕ sure?';return;}
|
|
439
|
+
armedDel=null;
|
|
440
|
+
await fetch('/project/delete',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({project:p})});
|
|
441
|
+
render();
|
|
442
|
+
});
|
|
443
|
+
// the 2.5s re-render rebuilds the DOM — re-apply a still-fresh armed state so the
|
|
444
|
+
// "✕ sure?" confirmation survives until it expires instead of silently resetting
|
|
445
|
+
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?';}}
|
|
446
|
+
const sr=$('#sortreset');if(sr)sr.onclick=()=>{localStorage.removeItem('abOrder');render();};
|
|
386
447
|
wireFlow(el);
|
|
387
448
|
el.querySelectorAll('.tcard, .fnode').forEach(c=>c.onclick=async()=>{
|
|
388
449
|
const id=+c.dataset.id,t=tasks.find(x=>x.id===id);if(!t)return;
|