raidlock 0.1.0__py3-none-any.whl
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.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(function () {
|
|
3
|
+
const $ = id => document.getElementById(id);
|
|
4
|
+
const RMQ = matchMedia("(prefers-reduced-motion: reduce)");
|
|
5
|
+
let RM = RMQ.matches;
|
|
6
|
+
const POLL_MS = 3000, OVERVIEW_EVERY = 5, LANE_PAGE = 40, TODO_CAP = 5, NEEDS_CAP = 5, IDLE_CAP = 8, ROW_H = 64, LOG_CAP = 5000, CHAT_CAP = 40;
|
|
7
|
+
const DAY_MS = 86400000, FRESH_S = 600;
|
|
8
|
+
const LANES = ["todo", "doing", "review", "blocked", "done", "cancelled"];
|
|
9
|
+
const LANE = {todo: "To do", doing: "In progress", review: "Review", blocked: "Blocked", done: "Done", cancelled: "Cancelled"};
|
|
10
|
+
const RAID = {todo: "Quest board", doing: "On a quest", review: "Inspection", blocked: "Cursed", done: "Hall of legends", cancelled: "Abandoned quests",
|
|
11
|
+
needs: "The raid leader's call", decisions: "Council", agents: "Raid party",
|
|
12
|
+
cost: "Treasury", score: "Hall of renown", log: "Raid log", chat: "Tavern chatter", workspaces: "Guilds of the realm"};
|
|
13
|
+
const RAID_PRIMARY = {decision: "Hold council", blocked: "Lift the curse", review: "Inspect loot", question: "Answer", close: "Turn in quest",
|
|
14
|
+
lore: "Inscribe", apply: "Apply enchant", duel: "Judge the duel"};
|
|
15
|
+
function w(plain, raidText) { return raid() ? raidText : plain; }
|
|
16
|
+
const RAID_ICON = {needs: "\u2694\uFE0F", todo: "\u{1F4DC}", doing: "\u{1F525}", review: "\u{1F989}", blocked: "\u{1F480}", done: "\u{1F3C6}"};
|
|
17
|
+
const STATUS = {
|
|
18
|
+
blocked: {icon: "\u{1F480}", raid: "Cursed", label: "Blocked"},
|
|
19
|
+
stalled: {icon: "⌛", raid: "Lost in the woods", label: "Stalled"},
|
|
20
|
+
working: {icon: "\u{1F525}", raid: "On a quest", label: "Working"},
|
|
21
|
+
quiet: {icon: "\u{1F4A4}", raid: "Quiet on a quest", label: "Working (quiet)"},
|
|
22
|
+
review: {icon: "\u{1F989}", raid: "Awaiting inspection", label: "Waiting for review"},
|
|
23
|
+
idle: {icon: "\u{1F37A}", raid: "At the tavern", label: "Idle"}};
|
|
24
|
+
const ORDER = ["blocked", "stalled", "working", "quiet", "review", "idle"];
|
|
25
|
+
const CLASSES = [["\u{1F52E}", "Oracle"], ["\u{1F409}", "Wyrmrider"], ["\u{1F319}", "Moonwarden"], ["⚡", "Stormcaller"],
|
|
26
|
+
["\u{1F43A}", "Wolfrunner"], ["\u{1F31F}", "Starseer"], ["\u{1F528}", "Tinkerer"], ["\u{1F3AF}", "Pathfinder"], ["\u{1F33F}", "Grovekeeper"],
|
|
27
|
+
["\u{1F340}", "Luckbringer"]];
|
|
28
|
+
const ASK = /\?|^lead\b|\blead[:,]|\b(waiting|wait) (on|for) (the )?lead\b|\bneeds? (a |the )?(lead|decision|approval|sign-?off|go-ahead)\b|\b(please|decide|approve|confirm|choose|pick)\b/i;
|
|
29
|
+
const CHAT_KINDS = new Set(["msg", "note", "relay", "handback"]);
|
|
30
|
+
const PRIMARY = {decision: "Decide", blocked: "Inspect", review: "Review", question: "Reply", close: "Close card", lore: "Approve", apply: "Apply patch", duel: "Decide"};
|
|
31
|
+
const KIND = {msg: "message", relay: "relay", handback: "handback", note: "note", status: "status", cost: "cost",
|
|
32
|
+
artifact: "artifact", review: "review", verify: "verify"};
|
|
33
|
+
const BILL = {api: ["API", "Billed per token through an API key"], plan: ["Plan", "Included in a subscription plan; the cost shown is what the same tokens would cost through the API"],
|
|
34
|
+
mixed: ["Mixed", "More than one billing mode"], unknown: ["Billing not reported", "The runtime did not report how it is billed"]};
|
|
35
|
+
const GLYPH_OK = new Map();
|
|
36
|
+
function canShow(g) {
|
|
37
|
+
if (GLYPH_OK.has(g)) return GLYPH_OK.get(g);
|
|
38
|
+
let ok = true;
|
|
39
|
+
try {
|
|
40
|
+
const c = document.createElement("canvas");
|
|
41
|
+
c.width = c.height = 24;
|
|
42
|
+
const x = c.getContext("2d", {willReadFrequently: true});
|
|
43
|
+
x.textBaseline = "top";
|
|
44
|
+
x.font = "18px sans-serif";
|
|
45
|
+
const px = t => { x.clearRect(0, 0, 24, 24); x.fillText(t, 0, 0); return x.getImageData(0, 0, 24, 24).data.join(","); };
|
|
46
|
+
const a = px(g);
|
|
47
|
+
ok = a !== px("") && a !== px("");
|
|
48
|
+
} catch (_) {}
|
|
49
|
+
GLYPH_OK.set(g, ok);
|
|
50
|
+
return ok;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const state = {view: null, mode: "all", projects: [], overview: null, board: null, cards: {}, first: true, open: new Set(),
|
|
54
|
+
details: {}, laneLimit: {}, todoAll: false, needsAll: false, levels: {}, slain: {}, doneLimit: 5, etag: {}, log: [], cursor: 0, logWs: null, logSel: null,
|
|
55
|
+
logFilter: "*", statusPrev: {}, warned: new Set(), seenChat: null, seenLog: null, ticks: 0, unknown: "", theme: "plain",
|
|
56
|
+
sound: true, vol: 80, notify: false, chatFilter: null, treasuryBy: "by_model", busy: false, muted: new Set(), msgPrev: null,
|
|
57
|
+
repo: "", expired: new Set(), sub: "focus", subData: {}, achievements: false, loaded: false, error: "", anim: true, fxPrev: null,
|
|
58
|
+
fxPulse: new Map(), fxView: {}};
|
|
59
|
+
const VIEW_DEFS = (window.RaidlockViews || []).filter(v => v && v.id && v.endpoint && typeof v.render === "function");
|
|
60
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
61
|
+
const PANELS = ["needs", "todo", "doing", "donePanel", "agentsPanel", "logPanel", "chatter", "overviewCosts"];
|
|
62
|
+
let workTab = "todo", statusFilter = "", activityFilter = "all";
|
|
63
|
+
const chatRead = new Map();
|
|
64
|
+
|
|
65
|
+
function store(k, v) { try { localStorage.setItem(k, String(v)); } catch (_) {} }
|
|
66
|
+
function recall(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
|
|
67
|
+
function raid() { return state.theme === "raid"; }
|
|
68
|
+
function motion() { return raid() && !RM && state.anim; }
|
|
69
|
+
|
|
70
|
+
function h(tag, props, ...kids) {
|
|
71
|
+
const el = document.createElement(tag);
|
|
72
|
+
if (props) {
|
|
73
|
+
for (const [k, v] of Object.entries(props)) {
|
|
74
|
+
if (v === null || v === undefined || v === false) continue;
|
|
75
|
+
if (k === "class") el.className = v;
|
|
76
|
+
else if (k === "text") add(el, [v]);
|
|
77
|
+
else if (k === "vars") for (const [n, x] of Object.entries(v)) el.style.setProperty(n, x);
|
|
78
|
+
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
|
|
79
|
+
else el.setAttribute(k, v === true ? "" : String(v));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
add(el, kids);
|
|
83
|
+
return el;
|
|
84
|
+
}
|
|
85
|
+
function add(el, kids) {
|
|
86
|
+
for (const k of kids) {
|
|
87
|
+
if (k === null || k === undefined || k === false || k === "") continue;
|
|
88
|
+
if (Array.isArray(k)) add(el, k);
|
|
89
|
+
else if (k instanceof Node) el.appendChild(k);
|
|
90
|
+
else if (el.matches("code, pre, .cmd")) el.appendChild(document.createTextNode(String(k)));
|
|
91
|
+
else appendTimes(el, String(k));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function svg(tag, props, ...kids) {
|
|
95
|
+
const el = document.createElementNS(SVG_NS, tag);
|
|
96
|
+
for (const [k, v] of Object.entries(props || {})) {
|
|
97
|
+
if (v === null || v === undefined || v === false) continue;
|
|
98
|
+
if (k === "text") el.textContent = v;
|
|
99
|
+
else el.setAttribute(k, String(v));
|
|
100
|
+
}
|
|
101
|
+
for (const k of kids.flat()) if (k !== null && k !== undefined && k !== false) el.appendChild(k instanceof Node ? k : document.createTextNode(String(k)));
|
|
102
|
+
return el;
|
|
103
|
+
}
|
|
104
|
+
function ico(s) { return s && raid() && canShow(s) ? h("span", {class: "ico", "aria-hidden": "true"}, s + " ") : null; }
|
|
105
|
+
function raidSub(key) { return raid() && RAID[key] ? h("small", {class: "raid-sub", text: RAID[key]}) : null; }
|
|
106
|
+
const LEVEL_CAP = 60;
|
|
107
|
+
const RANKS = [[60, "Mythic"], [50, "Legend"], [40, "Hero"], [30, "Champion"], [20, "Knight"], [10, "Squire"], [1, "Recruit"]];
|
|
108
|
+
function levelOf(counts) {
|
|
109
|
+
const c = counts || {};
|
|
110
|
+
const closed = c.done || 0, open = ["todo", "doing", "review", "blocked"].reduce((n, l) => n + (c[l] || 0), 0);
|
|
111
|
+
const total = closed + open;
|
|
112
|
+
const level = total ? 1 + Math.floor((LEVEL_CAP - 1) * closed / total) : 1;
|
|
113
|
+
return {level, closed, open, total, rank: RANKS.find(([min]) => level >= min)[1]};
|
|
114
|
+
}
|
|
115
|
+
function levelUp(key, kind, counts, el) {
|
|
116
|
+
const x = levelOf(counts);
|
|
117
|
+
const prev = state.levels[key];
|
|
118
|
+
state.levels[key] = x.level;
|
|
119
|
+
if (!raid() || prev === undefined || x.level <= prev) return false;
|
|
120
|
+
toastAs("quest levelup", h("b", {text: `${kind} level ${x.level}!`}), ` ${x.rank}: ${x.closed} of ${x.total} quests completed.`);
|
|
121
|
+
play("levelup");
|
|
122
|
+
if (motion() && el) fxLayer(el, "fx-levelup", 2400, h("span", {class: "ding", text: `Level ${x.level}`}));
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
function victory(key, slain) {
|
|
126
|
+
const seen = state.slain[key];
|
|
127
|
+
state.slain[key] = new Set(slain.map(b => b.id));
|
|
128
|
+
if (!raid() || !seen) return;
|
|
129
|
+
const fresh = slain.filter(b => !seen.has(b.id));
|
|
130
|
+
if (!fresh.length) return;
|
|
131
|
+
const b = fresh[0];
|
|
132
|
+
play("levelup");
|
|
133
|
+
toastAs("quest levelup", h("b", {text: "Boss slain: "}), `${b.title} (${b.total} quests${b.final ? ", final blow by " + b.final : ""}).`);
|
|
134
|
+
if (!motion()) return;
|
|
135
|
+
document.getElementById("victory")?.remove();
|
|
136
|
+
const close = () => { box.remove(); removeEventListener("keydown", onKey, true); };
|
|
137
|
+
const onKey = e => { if (e.key === "Escape") close(); };
|
|
138
|
+
const box = h("div", {id: "victory", class: "victory", role: "status", "aria-live": "assertive"},
|
|
139
|
+
h("div", {class: "victory-card"}, h("div", {class: "v-kicker", text: "\u2694\ufe0f Boss slain"}), h("h2", {text: b.title}),
|
|
140
|
+
h("p", {text: `${b.total} ${b.total === 1 ? "quest" : "quests"} conquered${b.final ? " \u00b7 final blow by " + b.final : ""}`}),
|
|
141
|
+
h("button", {type: "button", class: "primary", text: "Onward", onclick: () => close()})));
|
|
142
|
+
document.body.append(box);
|
|
143
|
+
addEventListener("keydown", onKey, true);
|
|
144
|
+
setTimeout(close, 6000);
|
|
145
|
+
}
|
|
146
|
+
function streakChip(n) {
|
|
147
|
+
return raid() && n >= 2 ? h("span", {class: "streak", title: `${n} days in a row with a finished quest`}, `\ud83d\udd25 ${n}-day streak`) : null;
|
|
148
|
+
}
|
|
149
|
+
function sumCounts(projects) {
|
|
150
|
+
const out = {};
|
|
151
|
+
for (const p of projects) for (const [l, n] of Object.entries(p.counts || {})) out[l] = (out[l] || 0) + (n || 0);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
function levelBadge(kind, counts) {
|
|
155
|
+
if (!raid()) return null;
|
|
156
|
+
const x = levelOf(counts);
|
|
157
|
+
const label = `${kind} level ${x.level} of ${LEVEL_CAP}, ${x.rank}: ${x.closed} of ${x.total} quests completed, ${x.open} open`;
|
|
158
|
+
const fill = h("span", {class: "xp-fill"});
|
|
159
|
+
fill.style.width = `${Math.round(100 * x.closed / (x.total || 1))}%`;
|
|
160
|
+
return h("span", {class: "level", title: label},
|
|
161
|
+
h("b", {class: "lvl", text: `${kind} level ${x.level}`}), h("span", {class: "rank", text: x.rank}),
|
|
162
|
+
h("span", {class: "xp", role: "progressbar", "aria-valuemin": "0", "aria-valuemax": String(x.total || 1), "aria-valuenow": String(x.closed), "aria-label": label},
|
|
163
|
+
fill, h("span", {class: "xp-pct", text: `${Math.floor(100 * x.closed / (x.total || 1))}%`})),
|
|
164
|
+
h("small", {class: "xp-text", text: `${x.closed}/${x.total} quests`}));
|
|
165
|
+
}
|
|
166
|
+
function setKids(el, kids, sig) {
|
|
167
|
+
if (sig !== undefined) { if (el.dataset.sig === sig) return false; el.dataset.sig = sig; }
|
|
168
|
+
el.replaceChildren();
|
|
169
|
+
add(el, Array.isArray(kids) ? kids : [kids]);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function hash(s) { let x = 0; for (const c of s) x = (x * 31 + c.charCodeAt(0)) >>> 0; return x; }
|
|
174
|
+
function heroOf(name) {
|
|
175
|
+
if (!name) return null;
|
|
176
|
+
const lead = state.board ? state.board.lead : "";
|
|
177
|
+
if (name === lead) return ["\u{1F451}", "Raid leader"];
|
|
178
|
+
return CLASSES[hash(name) % CLASSES.length];
|
|
179
|
+
}
|
|
180
|
+
function avatar(name) {
|
|
181
|
+
const g = (heroOf(name) || ["?"])[0];
|
|
182
|
+
if (!raid() || !canShow(g)) return (name || "?").slice(0, 2).toUpperCase();
|
|
183
|
+
return g;
|
|
184
|
+
}
|
|
185
|
+
function localTime(t) {
|
|
186
|
+
const d = new Date(t);
|
|
187
|
+
return isFinite(d.getTime()) ? d.toLocaleString(undefined, {dateStyle: "medium", timeStyle: "short"}) : t;
|
|
188
|
+
}
|
|
189
|
+
function appendTimes(el, text) {
|
|
190
|
+
const re = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\b/g;
|
|
191
|
+
let start = 0;
|
|
192
|
+
for (const m of text.matchAll(re)) {
|
|
193
|
+
el.appendChild(document.createTextNode(text.slice(start, m.index)));
|
|
194
|
+
const time = document.createElement("time");
|
|
195
|
+
time.dateTime = m[0];
|
|
196
|
+
time.title = m[0];
|
|
197
|
+
time.textContent = localTime(m[0]);
|
|
198
|
+
el.appendChild(time);
|
|
199
|
+
start = m.index + m[0].length;
|
|
200
|
+
}
|
|
201
|
+
el.appendChild(document.createTextNode(text.slice(start)));
|
|
202
|
+
}
|
|
203
|
+
function waitingText(reason) {
|
|
204
|
+
const m = / overlaps .* held by (.+?) on card (.+?) in workspace (.+?) until (\S+)/.exec(reason);
|
|
205
|
+
if (!m) return `Waiting: ${reason}`;
|
|
206
|
+
const expiry = Date.parse(m[4]);
|
|
207
|
+
const end = Number.isFinite(expiry) ? expiry > Date.now() ? `lock frees ${untilText(m[4])}` : `lock expired ${untilText(m[4])}` : "lock held";
|
|
208
|
+
return `Waiting for ${m[2]} (${m[1]}) - ${end}`;
|
|
209
|
+
}
|
|
210
|
+
function secondsAgo(t) { const s = (Date.now() - Date.parse(t || "")) / 1000; return isFinite(s) ? s : null; }
|
|
211
|
+
function ago(t) {
|
|
212
|
+
const s = secondsAgo(t);
|
|
213
|
+
if (s === null) return "";
|
|
214
|
+
if (s < 60) return Math.max(1, s | 0) + "s";
|
|
215
|
+
if (s < 3600) return (s / 60 | 0) + " min";
|
|
216
|
+
if (s < 86400) return (s / 3600 | 0) + " h";
|
|
217
|
+
return (s / 86400 | 0) + " d";
|
|
218
|
+
}
|
|
219
|
+
function untilText(t) {
|
|
220
|
+
const ms = Date.parse(t || "") - Date.now();
|
|
221
|
+
if (!isFinite(ms)) return "";
|
|
222
|
+
const m = Math.round(Math.abs(ms) / 60000);
|
|
223
|
+
const span = m >= 120 ? `${Math.round(m / 60)} h` : `${Math.max(1, m)} min`;
|
|
224
|
+
return ms > 0 ? `in ${span}` : `${span} ago`;
|
|
225
|
+
}
|
|
226
|
+
function resetText(t) {
|
|
227
|
+
const d = new Date(t || "");
|
|
228
|
+
if (!isFinite(d.getTime())) return "";
|
|
229
|
+
const soon = d.getTime() - Date.now() < DAY_MS;
|
|
230
|
+
return soon ? d.toLocaleTimeString(undefined, {hour: "2-digit", minute: "2-digit"}) : d.toLocaleString(undefined, {weekday: "short", hour: "2-digit", minute: "2-digit"});
|
|
231
|
+
}
|
|
232
|
+
function usd(v) { v = +v || 0; return "$" + (v >= 100 ? v.toFixed(0) : v.toFixed(2)); }
|
|
233
|
+
function ktok(n) { n = +n || 0; return n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? Math.round(n / 1e3) + "k" : String(n); }
|
|
234
|
+
function mins(ms) { const m = Math.round((+ms || 0) / 60000); return m >= 120 ? (m / 60).toFixed(1) + " h" : m + " min"; }
|
|
235
|
+
function plural(n, one, many) { return `${n} ${n === 1 ? one : (many || one + "s")}`; }
|
|
236
|
+
function modelName(id) {
|
|
237
|
+
const names = (state.board && state.board.model_names) || (state.overview && state.overview.model_names) || {};
|
|
238
|
+
return id && id !== "unknown" ? (names[id] || id) : "";
|
|
239
|
+
}
|
|
240
|
+
function statusBadge(st) {
|
|
241
|
+
const s = STATUS[st] || STATUS.idle;
|
|
242
|
+
return h("span", {class: "badge", vars: {"--s": `var(--s-${st})`}, title: raid() ? s.raid : null}, ico(s.icon), h("b", {text: s.label}));
|
|
243
|
+
}
|
|
244
|
+
function billBadge(mode) {
|
|
245
|
+
if (!mode || mode === "unknown" || !BILL[mode]) return null;
|
|
246
|
+
return h("span", {class: "bill " + mode, title: BILL[mode][1]}, BILL[mode][0]);
|
|
247
|
+
}
|
|
248
|
+
function identity(a) {
|
|
249
|
+
a = a || {};
|
|
250
|
+
return [modelName(a.model), a.runtime].filter(Boolean).join(" · ");
|
|
251
|
+
}
|
|
252
|
+
function evidenceCount(x) {
|
|
253
|
+
x = x || {};
|
|
254
|
+
return (x.verify_passed || 0) + (x.verify_failed || 0) + (x.review_approve || 0) + (x.review_changes || 0) + (x.rework || 0) +
|
|
255
|
+
(x.lock_violations || 0) + (x.lock_denials || 0) + (x.stalls || 0);
|
|
256
|
+
}
|
|
257
|
+
function trustBadge(a) {
|
|
258
|
+
const x = (a && a.reputation_inputs) || {};
|
|
259
|
+
if (!Number.isFinite(a && a.reputation) || !evidenceCount(x)) return null;
|
|
260
|
+
const title = `Verification: ${x.verify_passed || 0} passed, ${x.verify_failed || 0} failed; reviews: ${x.review_approve || 0} approved, ${x.review_changes || 0} changes requested`;
|
|
261
|
+
return h("span", {class: "pill reputation", title, text: `Trust ${a.reputation}/100`});
|
|
262
|
+
}
|
|
263
|
+
function leadCmd(rest) {
|
|
264
|
+
const src = (state.board && state.board.lead_src) || '"$LEAD_SECRET_FILE"';
|
|
265
|
+
return `raid --lead-secret ${src} ${rest}`;
|
|
266
|
+
}
|
|
267
|
+
const CUE_LIST = [
|
|
268
|
+
["sent", "Message sent", "delivered right away"],
|
|
269
|
+
["queued", "Message queued", "held until the recipient is free"],
|
|
270
|
+
["delivered", "Message delivered", "a queued message is released"],
|
|
271
|
+
["read", "Message read", "the recipient opened it"],
|
|
272
|
+
["note", "Note", "an agent adds a note"],
|
|
273
|
+
["claim", "Claim", "a card moves to in progress"],
|
|
274
|
+
["review", "Review", "a card is handed in for review"],
|
|
275
|
+
["blocked", "Blocked", "a card is blocked"],
|
|
276
|
+
["done", "Done", "a card is finished"],
|
|
277
|
+
["stalled", "Stalled", "an agent goes quiet too long"],
|
|
278
|
+
["expiring", "Lease expiring", "under 5 minutes left"],
|
|
279
|
+
["expired", "Lease expired", "a held lease ran out"],
|
|
280
|
+
["posted", "New card", "a card is added or reopened"],
|
|
281
|
+
["coin", "Spend", "new tokens recorded on a card"],
|
|
282
|
+
["levelup", "Level up", "a workspace reaches a new progress level"]];
|
|
283
|
+
const CUE_NAMES = new Set(CUE_LIST.map(c => c[0]));
|
|
284
|
+
let actx = null, master = null, noiseBuf = null;
|
|
285
|
+
function soundReady() { return !!actx && actx.state === "running"; }
|
|
286
|
+
function updateSound() {
|
|
287
|
+
const b = $("sound");
|
|
288
|
+
b.setAttribute("aria-pressed", String(state.sound));
|
|
289
|
+
b.textContent = state.sound ? "Sound on" : "Sound muted";
|
|
290
|
+
$("vol").disabled = !state.sound;
|
|
291
|
+
$("soundSummary").textContent = state.sound ? "Sound" : "Sound (muted)";
|
|
292
|
+
$("soundBanner").hidden = !state.sound || soundReady();
|
|
293
|
+
}
|
|
294
|
+
function setVol(v) { state.vol = v; if (master) master.gain.value = Math.pow(v / 100, 1.5) * 1.6; }
|
|
295
|
+
function audio() {
|
|
296
|
+
if (actx) return true;
|
|
297
|
+
try {
|
|
298
|
+
actx = new (window.AudioContext || window.webkitAudioContext)();
|
|
299
|
+
const comp = actx.createDynamicsCompressor();
|
|
300
|
+
comp.threshold.value = -10; comp.knee.value = 6; comp.ratio.value = 8; comp.attack.value = .002; comp.release.value = .12;
|
|
301
|
+
master = actx.createGain();
|
|
302
|
+
master.connect(comp); comp.connect(actx.destination);
|
|
303
|
+
setVol(state.vol);
|
|
304
|
+
actx.onstatechange = updateSound;
|
|
305
|
+
return true;
|
|
306
|
+
} catch (_) { actx = null; return false; }
|
|
307
|
+
}
|
|
308
|
+
function unlock() {
|
|
309
|
+
if (!state.sound) return;
|
|
310
|
+
if (audio() && actx.state !== "running") actx.resume().then(updateSound, () => {});
|
|
311
|
+
updateSound();
|
|
312
|
+
}
|
|
313
|
+
function env(g, t, lvl, attack, dur) {
|
|
314
|
+
g.gain.setValueAtTime(.0001, t);
|
|
315
|
+
g.gain.exponentialRampToValueAtTime(lvl, t + attack);
|
|
316
|
+
g.gain.exponentialRampToValueAtTime(.0001, t + dur);
|
|
317
|
+
}
|
|
318
|
+
function tone(f, dur, o = {}) {
|
|
319
|
+
const a = actx, t = a.currentTime + (o.at || 0), osc = a.createOscillator(), g = a.createGain();
|
|
320
|
+
osc.type = o.type || "triangle";
|
|
321
|
+
osc.frequency.setValueAtTime(f, t);
|
|
322
|
+
if (o.to) osc.frequency.exponentialRampToValueAtTime(o.to, t + dur);
|
|
323
|
+
env(g, t, o.lvl || .7, o.attack || .008, dur);
|
|
324
|
+
let out = osc;
|
|
325
|
+
if (o.lp) { const fl = a.createBiquadFilter(); fl.type = "lowpass"; fl.frequency.value = o.lp; osc.connect(fl); out = fl; }
|
|
326
|
+
out.connect(g); g.connect(master);
|
|
327
|
+
osc.start(t); osc.stop(t + dur + .05);
|
|
328
|
+
}
|
|
329
|
+
function noise(dur, o = {}) {
|
|
330
|
+
const a = actx, t = a.currentTime + (o.at || 0);
|
|
331
|
+
if (!noiseBuf) {
|
|
332
|
+
noiseBuf = a.createBuffer(1, a.sampleRate | 0, a.sampleRate);
|
|
333
|
+
const d = noiseBuf.getChannelData(0);
|
|
334
|
+
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
|
335
|
+
}
|
|
336
|
+
const src = a.createBufferSource(), fl = a.createBiquadFilter(), g = a.createGain();
|
|
337
|
+
src.buffer = noiseBuf;
|
|
338
|
+
fl.type = "bandpass"; fl.frequency.setValueAtTime(o.f || 2000, t); fl.Q.value = o.q || 1.5;
|
|
339
|
+
if (o.to) fl.frequency.exponentialRampToValueAtTime(o.to, t + dur);
|
|
340
|
+
env(g, t, o.lvl || .8, o.attack || .004, dur);
|
|
341
|
+
src.connect(fl); fl.connect(g); g.connect(master);
|
|
342
|
+
src.start(t); src.stop(t + dur + .05);
|
|
343
|
+
}
|
|
344
|
+
const CUES = {
|
|
345
|
+
sent: () => { noise(.22, {f: 800, to: 4000, lvl: .5}); tone(520, .22, {type: "sine", to: 1320, lvl: .8}); },
|
|
346
|
+
queued: () => { tone(330, .16, {lvl: .8}); tone(330, .22, {at: .2, lvl: .7}); },
|
|
347
|
+
delivered: () => { tone(784, .3, {type: "sine", lvl: .8}); tone(1175, .45, {type: "sine", at: .1, lvl: .8}); },
|
|
348
|
+
read: () => { tone(1568, .09, {type: "sine", lvl: .7}); tone(2093, .14, {type: "sine", at: .07, lvl: .6}); },
|
|
349
|
+
note: () => { noise(.06, {f: 3200, q: 4, lvl: .9}); noise(.06, {f: 2400, q: 4, lvl: .8, at: .09}); },
|
|
350
|
+
claim: () => [392, 523, 784].forEach((f, i) => tone(f, .12, {type: "square", at: i * .07, lvl: .45, lp: 3000})),
|
|
351
|
+
review: () => { tone(659, .2, {lvl: .8}); tone(880, .34, {at: .18, lvl: .8}); },
|
|
352
|
+
blocked: () => { tone(220, .5, {type: "sawtooth", to: 110, lvl: .6, lp: 1200}); tone(233, .5, {type: "sawtooth", to: 116, lvl: .4, lp: 1200}); },
|
|
353
|
+
done: () => { [523, 659, 784].forEach((f, i) => tone(f, .16, {at: i * .1, lvl: .7})); tone(1047, .6, {at: .3, lvl: .8}); tone(1319, .6, {type: "sine", at: .3, lvl: .4}); },
|
|
354
|
+
stalled: () => { tone(392, .26, {lvl: .8}); tone(262, .55, {at: .22, lvl: .8, to: 247}); },
|
|
355
|
+
expiring: () => [0, .16, .32].forEach(at => tone(1000, .07, {type: "square", at, lvl: .45, lp: 4000})),
|
|
356
|
+
expired: () => { tone(880, .14, {type: "square", lvl: .45, lp: 3000}); tone(440, .4, {type: "square", at: .14, lvl: .45, lp: 2000}); },
|
|
357
|
+
posted: () => { tone(523, .3, {lvl: .7}); tone(1046, .25, {type: "sine", lvl: .35}); },
|
|
358
|
+
levelup: () => { [392, 523, 659, 784, 1047].forEach((f, i) => tone(f, .14, {at: i * .08, lvl: .6})); tone(1568, .9, {type: "sine", at: .42, lvl: .5}); tone(784, .9, {at: .42, lvl: .5}); },
|
|
359
|
+
coin: () => { tone(988, .08, {type: "square", lvl: .4, lp: 5000}); tone(1319, .35, {type: "square", at: .07, lvl: .4, lp: 5000}); }};
|
|
360
|
+
const pending = new Set();
|
|
361
|
+
let flushTimer = 0;
|
|
362
|
+
function cueOn(n) { return state.sound && !state.muted.has(n); }
|
|
363
|
+
function voice(n) {
|
|
364
|
+
if (!soundReady() || !master) return;
|
|
365
|
+
try { CUES[n](); } catch (_) {}
|
|
366
|
+
}
|
|
367
|
+
function emit(n) {
|
|
368
|
+
document.dispatchEvent(new CustomEvent("raidlock:cue", {detail: n}));
|
|
369
|
+
voice(n);
|
|
370
|
+
}
|
|
371
|
+
function preview(n) {
|
|
372
|
+
if (!state.sound) return;
|
|
373
|
+
unlock();
|
|
374
|
+
if (soundReady()) voice(n);
|
|
375
|
+
else if (actx) actx.resume().then(() => voice(n), () => {});
|
|
376
|
+
}
|
|
377
|
+
function play(n) {
|
|
378
|
+
if (!CUE_NAMES.has(n) || !cueOn(n)) return;
|
|
379
|
+
pending.add(n);
|
|
380
|
+
if (!flushTimer) flushTimer = setTimeout(flush, 0);
|
|
381
|
+
}
|
|
382
|
+
function flush() {
|
|
383
|
+
flushTimer = 0;
|
|
384
|
+
const names = [...pending];
|
|
385
|
+
pending.clear();
|
|
386
|
+
names.forEach((n, i) => setTimeout(() => { if (cueOn(n)) emit(n); }, i * 260));
|
|
387
|
+
}
|
|
388
|
+
function buildCues() {
|
|
389
|
+
const box = $("cueList");
|
|
390
|
+
const kids = [box.querySelector("legend")];
|
|
391
|
+
for (const [id, label, hint] of CUE_LIST) {
|
|
392
|
+
const cb = h("input", {type: "checkbox", id: "cue-" + id, "data-cue": id});
|
|
393
|
+
cb.checked = !state.muted.has(id);
|
|
394
|
+
kids.push(h("label", {class: "cue", for: "cue-" + id, title: hint}, cb, h("span", null, label, h("small", {text: hint}))),
|
|
395
|
+
h("button", {type: "button", class: "cue-try", "data-try": id, "aria-label": `Play the ${label.toLowerCase()} sound`, text: "Play"}));
|
|
396
|
+
}
|
|
397
|
+
box.replaceChildren(...kids);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function updateAchieve() {
|
|
401
|
+
const b = $("achieveBtn");
|
|
402
|
+
b.setAttribute("aria-pressed", String(state.achievements));
|
|
403
|
+
b.textContent = state.achievements ? "Achievements: on" : "Achievements: off";
|
|
404
|
+
}
|
|
405
|
+
function updateNotify() {
|
|
406
|
+
const b = $("notify");
|
|
407
|
+
const denied = "Notification" in window && Notification.permission === "denied";
|
|
408
|
+
b.setAttribute("aria-pressed", state.notify);
|
|
409
|
+
b.textContent = !("Notification" in window) ? "Desktop alerts: not available in this browser" : denied ? "Desktop alerts: blocked by the browser" : state.notify ? "Desktop alerts: on" : "Desktop alerts: off";
|
|
410
|
+
b.disabled = !("Notification" in window) || denied;
|
|
411
|
+
}
|
|
412
|
+
function notify(title, body) {
|
|
413
|
+
if (!state.notify || !("Notification" in window) || Notification.permission !== "granted" || !document.hidden) return;
|
|
414
|
+
try { new Notification(title, {body: body.slice(0, 200), tag: "raidlock"}); } catch (_) {}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function toast(...parts) { return toastAs("", ...parts); }
|
|
418
|
+
function toastAs(cls, ...parts) {
|
|
419
|
+
const d = h("div", {class: "toast" + (cls ? " " + cls : "")}, parts);
|
|
420
|
+
const t = $("toasts");
|
|
421
|
+
t.prepend(d);
|
|
422
|
+
if (t.showPopover) { try { if (t.matches(":popover-open")) t.hidePopover(); t.showPopover(); } catch (_) {} }
|
|
423
|
+
while ($("toasts").children.length > 4) $("toasts").lastChild.remove();
|
|
424
|
+
setTimeout(() => d.remove(), 6200);
|
|
425
|
+
notify("raidlock", d.textContent);
|
|
426
|
+
}
|
|
427
|
+
function burst(el, emoji) {
|
|
428
|
+
if (!motion() || !el) return;
|
|
429
|
+
const r = el.getBoundingClientRect();
|
|
430
|
+
for (let i = 0; i < 14; i++) {
|
|
431
|
+
const s = h("span", {class: "burst", "aria-hidden": "true", text: emoji[i % emoji.length]});
|
|
432
|
+
const a = Math.random() * Math.PI * 2, d = 60 + Math.random() * 90;
|
|
433
|
+
s.style.left = (r.left + r.width / 2) + "px"; s.style.top = (r.top + r.height / 2) + "px";
|
|
434
|
+
s.style.setProperty("--dx", Math.cos(a) * d + "px"); s.style.setProperty("--dy", Math.sin(a) * d + "px");
|
|
435
|
+
document.body.appendChild(s); setTimeout(() => s.remove(), 1400);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function centre(el) { const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; }
|
|
439
|
+
function onScreen(el) { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.bottom > 0 && r.top < innerHeight; }
|
|
440
|
+
function raven(from, to) {
|
|
441
|
+
if (!motion()) return;
|
|
442
|
+
const q = n => document.querySelector(`.agent[data-agent="${CSS.escape(n)}"] .av`) || document.querySelector(`.chip[data-agent="${CSS.escape(n)}"]`);
|
|
443
|
+
const a = q(from), b = q(to);
|
|
444
|
+
const [x1, y1] = onScreen(a) ? centre(a) : [24, innerHeight / 2];
|
|
445
|
+
const [x2, y2] = onScreen(b) ? centre(b) : [innerWidth - 60, innerHeight - 60];
|
|
446
|
+
const mx = (x1 + x2) / 2, my = Math.min(y1, y2) - 120;
|
|
447
|
+
for (const [glyph, dx, dy, size] of [["\u{1F426}", 0, 0, ""], ["\u{1F4DC}", 10, 14, "13px"]]) {
|
|
448
|
+
const s = h("span", {class: "raven", "aria-hidden": "true", text: glyph});
|
|
449
|
+
if (size) s.style.fontSize = size;
|
|
450
|
+
document.body.appendChild(s);
|
|
451
|
+
s.animate([{transform: `translate(${x1 + dx}px,${y1 + dy}px) scale(.6)`, opacity: 0},
|
|
452
|
+
{transform: `translate(${x1 + dx}px,${y1 + dy - 20}px) scale(1)`, opacity: 1, offset: .1},
|
|
453
|
+
{transform: `translate(${mx + dx}px,${my + dy}px) scale(1.3) rotate(-10deg)`, offset: .5},
|
|
454
|
+
{transform: `translate(${x2 + dx}px,${y2 + dy}px) scale(.8)`, opacity: 1, offset: .92},
|
|
455
|
+
{transform: `translate(${x2 + dx}px,${y2 + dy}px) scale(.3)`, opacity: 0}], {duration: 1600, easing: "ease-in-out"}).onfinish = () => s.remove();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const FX_VERIFY = /^verify (passed|failed)\b/;
|
|
460
|
+
const FX_MS = {done: 1100, verify: 1900, claim: 1300, refused: 1100, cost: 1700};
|
|
461
|
+
function fxLive(l) {
|
|
462
|
+
if (!l || l.live === false) return false;
|
|
463
|
+
return !l.expires || Date.parse(l.expires) > Date.now();
|
|
464
|
+
}
|
|
465
|
+
function fxMoney(k) { k = k || {}; return {usd: +k.usd_equiv || +k.usd || 0, tokens: +k.tokens || 0}; }
|
|
466
|
+
function fxOverlaps(cl) {
|
|
467
|
+
return ((cl && cl.reasons) || []).map(String).filter(r => / overlaps /.test(r)).map(r => r.replace(/ until .*$/, ""));
|
|
468
|
+
}
|
|
469
|
+
function fxSnap(data) {
|
|
470
|
+
const cards = {}, claim = {}, agents = {};
|
|
471
|
+
for (const c of (data && data.cards) || []) {
|
|
472
|
+
cards[c.id] = {lane: c.lane || "", owner: c.owner || "", live: fxLive(c.lease), notes_count: +c.notes_count || 0,
|
|
473
|
+
notes: (c.notes || []).map(n => String((n && n.text) || "")), money: fxMoney(c.cost)};
|
|
474
|
+
}
|
|
475
|
+
for (const [id, x] of Object.entries((data && data.claimable) || {})) claim[id] = fxOverlaps(x);
|
|
476
|
+
for (const [n, a] of Object.entries((data && data.agents) || {})) agents[n] = {status: (a && a.status) || "idle", card: (a && a.card) || ""};
|
|
477
|
+
return {cards, claim, agents};
|
|
478
|
+
}
|
|
479
|
+
function fxNewNotes(p, c) {
|
|
480
|
+
const n = c.notes_count - p.notes_count;
|
|
481
|
+
return n > 0 ? c.notes.slice(-n) : [];
|
|
482
|
+
}
|
|
483
|
+
function fxDiff(prev, cur) {
|
|
484
|
+
const out = [];
|
|
485
|
+
if (!prev || !cur) return out;
|
|
486
|
+
for (const [id, c] of Object.entries(cur.cards)) {
|
|
487
|
+
const p = prev.cards[id];
|
|
488
|
+
if (!p) continue;
|
|
489
|
+
if (c.lane === "done" && p.lane !== "done") out.push({kind: "done", card: id});
|
|
490
|
+
if (c.lane === "blocked" && p.lane !== "blocked") out.push({kind: "blocked", card: id});
|
|
491
|
+
const v = fxNewNotes(p, c).map(t => FX_VERIFY.exec(t)).filter(Boolean).pop();
|
|
492
|
+
if (v) out.push({kind: "verify", card: id, passed: v[1] === "passed"});
|
|
493
|
+
if (c.owner && c.live && (!p.live || p.owner !== c.owner)) out.push({kind: "claim", card: id, agent: c.owner});
|
|
494
|
+
const was = new Set(prev.claim[id] || []);
|
|
495
|
+
if ((cur.claim[id] || []).some(r => !was.has(r))) out.push({kind: "refused", card: id});
|
|
496
|
+
const usd = c.money.usd - p.money.usd, tokens = c.money.tokens - p.money.tokens;
|
|
497
|
+
if (usd >= 0.005 || (tokens > 0 && usd > -0.005)) out.push({kind: "cost", card: id, usd: Math.max(0, usd), tokens: Math.max(0, tokens)});
|
|
498
|
+
}
|
|
499
|
+
for (const [n, a] of Object.entries(cur.agents)) {
|
|
500
|
+
const was = prev.agents[n];
|
|
501
|
+
if (a.status === "stalled" && was && was.status !== "stalled") out.push({kind: "stalled", agent: n, card: a.card});
|
|
502
|
+
}
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
function fxPulses(pulse, events, cur) {
|
|
506
|
+
const next = new Map(pulse || []);
|
|
507
|
+
for (const e of events || []) {
|
|
508
|
+
if (e.kind === "blocked") next.set("card:" + e.card, "blocked");
|
|
509
|
+
if (e.kind === "stalled") next.set("agent:" + e.agent, "stalled");
|
|
510
|
+
}
|
|
511
|
+
for (const key of [...next.keys()]) {
|
|
512
|
+
const [type, id] = [key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1)];
|
|
513
|
+
const ok = type === "card" ? (cur.cards[id] || {}).lane === "blocked" : (cur.agents[id] || {}).status === "stalled";
|
|
514
|
+
if (!ok) next.delete(key);
|
|
515
|
+
}
|
|
516
|
+
return next;
|
|
517
|
+
}
|
|
518
|
+
function fxFrames(data) {
|
|
519
|
+
const out = {};
|
|
520
|
+
for (const f of (data && data.agents) || []) {
|
|
521
|
+
const ctx = (f && f.context) || {};
|
|
522
|
+
if (f && f.agent && Number.isFinite(ctx.pct)) out[f.agent] = {pct: ctx.pct, level: ctx.level || "ok"};
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
function fxContextDiff(prev, cur) {
|
|
527
|
+
const out = [];
|
|
528
|
+
if (!prev || !cur) return out;
|
|
529
|
+
for (const [agent, c] of Object.entries(cur)) {
|
|
530
|
+
const p = prev[agent];
|
|
531
|
+
if (p && (Math.abs(c.pct - p.pct) >= 0.5 || c.level !== p.level)) out.push({kind: "context", agent, from: p.pct, to: c.pct, level: c.level, was: p.level});
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
function fxEarned(data) {
|
|
536
|
+
return ((data && data.achievements) || []).filter(a => a && a.earned).map(a => a.id);
|
|
537
|
+
}
|
|
538
|
+
function fxNewEarned(prev, cur) {
|
|
539
|
+
if (!prev || !cur) return [];
|
|
540
|
+
const was = new Set(prev);
|
|
541
|
+
return cur.filter(id => !was.has(id));
|
|
542
|
+
}
|
|
543
|
+
window.RaidlockFx = {snap: fxSnap, diff: fxDiff, pulses: fxPulses, frames: fxFrames, contextDiff: fxContextDiff, earned: fxEarned, newEarned: fxNewEarned};
|
|
544
|
+
|
|
545
|
+
function fxEmit(kind, detail) { document.dispatchEvent(new CustomEvent("raidlock:fx", {detail: {...detail, kind}})); }
|
|
546
|
+
function fxTargets(sel) { return [...document.querySelectorAll(sel)].filter(onScreen); }
|
|
547
|
+
function fxRows(id) { return id ? fxTargets(`.row[data-id="${CSS.escape(id)}"]`) : []; }
|
|
548
|
+
function fxAgents(name) { return name ? fxTargets(`.agent[data-agent="${CSS.escape(name)}"]`) : []; }
|
|
549
|
+
function fxLayer(el, cls, ms, ...kids) {
|
|
550
|
+
const l = h("span", {class: "fx-layer " + cls, "aria-hidden": "true"}, kids);
|
|
551
|
+
el.appendChild(l);
|
|
552
|
+
setTimeout(() => l.remove(), ms + 100);
|
|
553
|
+
return l;
|
|
554
|
+
}
|
|
555
|
+
function fxRays() {
|
|
556
|
+
const rays = [];
|
|
557
|
+
for (let i = 0; i < 12; i++) rays.push(svg("line", {x1: 0, y1: -20, x2: 0, y2: i % 2 ? -34 : -44, transform: `rotate(${i * 30})`}));
|
|
558
|
+
return svg("svg", {class: "fx-rays", "aria-hidden": "true", viewBox: "-50 -50 100 100", width: 120, height: 120}, svg("circle", {r: 15}), rays);
|
|
559
|
+
}
|
|
560
|
+
function fxGlyph(cls) {
|
|
561
|
+
return svg("svg", {class: cls, "aria-hidden": "true", viewBox: "-20 -20 40 40", width: 40, height: 40},
|
|
562
|
+
svg("path", {class: "rim", d: "M0-17L15-8.5V8.5L0 17-15 8.5V-8.5Z"}), svg("path", {class: "mark", d: "M-7 0l5 5 9-10"}));
|
|
563
|
+
}
|
|
564
|
+
function fxCast(cls) { return h("span", {class: "fx-cast " + cls}, h("i")); }
|
|
565
|
+
function fxCostText(e) {
|
|
566
|
+
if (e.usd >= 0.005) return "+" + usd(e.usd);
|
|
567
|
+
return "+" + ktok(e.tokens) + " tok";
|
|
568
|
+
}
|
|
569
|
+
const FX = {
|
|
570
|
+
done: e => fxRows(e.card).forEach(el => fxLayer(el, "fx-done", FX_MS.done, fxRays())),
|
|
571
|
+
verify: e => fxRows(e.card).forEach(el => fxLayer(el, "fx-verify" + (e.passed ? "" : " fail"), FX_MS.verify, fxCast(e.passed ? "full" : "cut"), e.passed ? fxGlyph("fx-stamp") : null)),
|
|
572
|
+
claim: e => [...fxRows(e.card), ...fxAgents(e.agent)].forEach(el => fxLayer(el, "fx-claim", FX_MS.claim, h("span", {class: "fx-shield"}))),
|
|
573
|
+
refused: e => fxRows(e.card).forEach(el => fxLayer(el, "fx-refused", FX_MS.refused, fxCast("cut"), h("span", {class: "fx-ring"}), h("span", {class: "fx-ring late"}))),
|
|
574
|
+
cost: e => fxRows(e.card).forEach(el => fxLayer(el, "fx-cost", FX_MS.cost, h("span", {class: "fx-float", text: fxCostText(e)}))),
|
|
575
|
+
blocked: () => {},
|
|
576
|
+
stalled: () => {}};
|
|
577
|
+
function fxPlay(events) {
|
|
578
|
+
if (!motion()) return;
|
|
579
|
+
for (const e of events) {
|
|
580
|
+
fxEmit(e.kind, e);
|
|
581
|
+
try { FX[e.kind](e); } catch (_) {}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
let fxIO = null;
|
|
585
|
+
function fxDecorate() {
|
|
586
|
+
const on = motion();
|
|
587
|
+
const want = new Map();
|
|
588
|
+
if (on) {
|
|
589
|
+
for (const [key, kind] of state.fxPulse) {
|
|
590
|
+
const id = key.slice(key.indexOf(":") + 1);
|
|
591
|
+
if (key.startsWith("card:")) document.querySelectorAll(`.row[data-id="${CSS.escape(id)}"]`).forEach(el => want.set(el, kind));
|
|
592
|
+
else {
|
|
593
|
+
document.querySelectorAll(`.agent[data-agent="${CSS.escape(id)}"]`).forEach(el => want.set(el, kind));
|
|
594
|
+
const card = ((state.board && state.board.agents) || {})[id];
|
|
595
|
+
if (card && card.card) document.querySelectorAll(`.row[data-id="${CSS.escape(card.card)}"]`).forEach(el => { if (!want.has(el)) want.set(el, kind); });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
for (const el of document.querySelectorAll(".fx-pulse")) if (!want.has(el)) el.classList.remove("fx-pulse", "fx-stall", "fx-off");
|
|
600
|
+
for (const [el, kind] of want) { el.classList.add("fx-pulse"); el.classList.toggle("fx-stall", kind === "stalled"); }
|
|
601
|
+
if (!("IntersectionObserver" in window)) return;
|
|
602
|
+
if (!fxIO) fxIO = new IntersectionObserver(list => { for (const x of list) x.target.classList.toggle("fx-off", !x.isIntersecting); });
|
|
603
|
+
fxIO.disconnect();
|
|
604
|
+
for (const el of want.keys()) fxIO.observe(el);
|
|
605
|
+
}
|
|
606
|
+
function fxBoard(data) {
|
|
607
|
+
const cur = fxSnap(data);
|
|
608
|
+
const events = fxDiff(state.fxPrev, cur).filter(e => !state.first || e.kind === "stalled");
|
|
609
|
+
state.fxPrev = cur;
|
|
610
|
+
state.fxPulse = fxPulses(state.fxPulse, motion() ? events : [], cur);
|
|
611
|
+
fxPlay(events);
|
|
612
|
+
fxDecorate();
|
|
613
|
+
}
|
|
614
|
+
function fxBanner(list) {
|
|
615
|
+
if (!list.length) return;
|
|
616
|
+
const old = document.querySelector(".ach-banner");
|
|
617
|
+
if (old) old.remove();
|
|
618
|
+
const a = list[0];
|
|
619
|
+
const more = list.length > 1 ? ` and ${list.length - 1} more` : "";
|
|
620
|
+
const b = h("div", {class: "ach-banner", role: "status"}, fxGlyph("ach-seal"),
|
|
621
|
+
h("div", {class: "ach-text"}, h("small", {text: "Achievement unlocked"}), h("b", {text: a.name + more})));
|
|
622
|
+
document.body.appendChild(b);
|
|
623
|
+
setTimeout(() => b.remove(), 6000);
|
|
624
|
+
if (motion()) fxEmit("achievement", {id: a.id});
|
|
625
|
+
}
|
|
626
|
+
function fxView(id, data) {
|
|
627
|
+
if (id === "frames") {
|
|
628
|
+
const cur = fxFrames(data), changes = fxContextDiff(state.fxView.frames, cur);
|
|
629
|
+
state.fxView.frames = cur;
|
|
630
|
+
if (!motion()) return;
|
|
631
|
+
for (const x of changes) {
|
|
632
|
+
fxEmit("context", x);
|
|
633
|
+
for (const fr of document.querySelectorAll(`#viewPane .frame[data-agent="${CSS.escape(x.agent)}"]`)) {
|
|
634
|
+
const fill = fr.querySelector(".fbar.hp .track i");
|
|
635
|
+
if (fill && x.to > 0 && fill.animate) fill.animate([{transform: `scaleX(${Math.min(x.from / x.to, 8)})`}, {transform: "scaleX(1)"}], {duration: 700, easing: "cubic-bezier(.2,.8,.3,1)"});
|
|
636
|
+
if (x.level !== x.was) fxLayer(fr, "fx-level lvl-" + x.level, 900);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
} else if (id === "achievements") {
|
|
640
|
+
const cur = fxEarned(data), fresh = fxNewEarned(state.fxView.achievements, cur);
|
|
641
|
+
state.fxView.achievements = cur;
|
|
642
|
+
const names = Object.fromEntries(((data && data.achievements) || []).map(a => [a.id, a]));
|
|
643
|
+
fxBanner(fresh.map(x => names[x]).filter(Boolean));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function updateAnim() {
|
|
647
|
+
const b = $("animBtn");
|
|
648
|
+
b.setAttribute("aria-pressed", String(state.anim));
|
|
649
|
+
b.textContent = state.anim ? "Animations: on" : "Animations: off";
|
|
650
|
+
document.documentElement.setAttribute("data-anim", state.anim ? "on" : "off");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function getJSON(url, key) {
|
|
654
|
+
const headers = {};
|
|
655
|
+
if (key && state.etag[key]) headers["If-None-Match"] = state.etag[key];
|
|
656
|
+
const r = await fetch(url, {cache: "no-store", headers});
|
|
657
|
+
if (r.status === 304) return null;
|
|
658
|
+
if (!r.ok) { const e = new Error("http " + r.status); e.status = r.status; throw e; }
|
|
659
|
+
if (key) state.etag[key] = r.headers.get("ETag") || "";
|
|
660
|
+
return r.json();
|
|
661
|
+
}
|
|
662
|
+
function api(kind, ws, extra) { return `/api/${kind}/${encodeURIComponent(ws)}${extra || ""}`; }
|
|
663
|
+
function href(id) { return id === "*" ? "/" : "/" + encodeURIComponent(id); }
|
|
664
|
+
|
|
665
|
+
function copyCommand(button, text, label) {
|
|
666
|
+
const restore = () => { button.textContent = label; };
|
|
667
|
+
const done = ok => {
|
|
668
|
+
button.textContent = ok ? "Copied" : "Copy failed";
|
|
669
|
+
if (ok) toast("Copied. Paste it in the lead terminal: ", h("code", {text}));
|
|
670
|
+
else {
|
|
671
|
+
const row = button.closest("[data-id]");
|
|
672
|
+
if (row) openRow(row, true);
|
|
673
|
+
toast(`Copy failed: select the command in ${w("Details", "Examine")} and copy it by hand.`);
|
|
674
|
+
}
|
|
675
|
+
setTimeout(restore, 1500);
|
|
676
|
+
};
|
|
677
|
+
try { navigator.clipboard.writeText(text).then(() => done(true), () => done(false)); } catch (_) { done(false); }
|
|
678
|
+
}
|
|
679
|
+
function cmdLine(text) {
|
|
680
|
+
return h("div", {class: "cmd"}, h("code", {text}),
|
|
681
|
+
h("button", {type: "button", class: "copy", "aria-label": `Copy ${text}`, text: "Copy", "data-copy": text}));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function attention(o) { return o.missing || o.blocked || o.stalled ? 0 : o.review ? 1 : o.doing ? 2 : 3; }
|
|
685
|
+
function oldestStalled(agents, titles) {
|
|
686
|
+
let best = null;
|
|
687
|
+
for (const a of agents) {
|
|
688
|
+
if (a.status !== "stalled") continue;
|
|
689
|
+
const t = Date.parse(a.heartbeat || "");
|
|
690
|
+
const at = isFinite(t) ? t : 0;
|
|
691
|
+
if (!best || at < best.at) best = {at, name: a.name, title: a.title || (titles && titles[a.card]) || a.card || ""};
|
|
692
|
+
}
|
|
693
|
+
return best;
|
|
694
|
+
}
|
|
695
|
+
function statusLine(o) {
|
|
696
|
+
const seg = (n, label, cls) => n ? h("span", {class: `seg ${cls}`}, h("b", {text: String(n)}), " " + label) : null;
|
|
697
|
+
const r = raid();
|
|
698
|
+
const parts = [seg(o.needs, r ? "await the raid leader" : "need you", "s-review"), seg(o.doing, r ? "on a quest" : "in progress", "s-doing"),
|
|
699
|
+
seg(o.blocked, r ? "cursed" : "blocked", "s-blocked"), seg(o.todo, r ? "on the quest board" : "to do", "s-todo")];
|
|
700
|
+
if (o.total) parts.push(h("span", {class: "seg s-done"}, h("b", {text: String(o.done)}), r ? ` of ${o.total} quests completed` : ` of ${o.total} done`));
|
|
701
|
+
if (o.stalled) {
|
|
702
|
+
const mins = o.stalled.at ? Math.max(1, Math.round((Date.now() - o.stalled.at) / 60000)) : 0;
|
|
703
|
+
const since = !mins ? "" : mins >= 120 ? `${Math.round(mins / 60)} h` : `${mins} min`;
|
|
704
|
+
parts.push(h("span", {class: "seg s-stalled", title: `${o.stalled.title ? o.stalled.title + ": " : ""}${o.stalled.name} has been silent${since ? " for " + since : ""}`},
|
|
705
|
+
"Stalled: ", h("b", {text: o.stalled.title || o.stalled.name}), since ? ` (${since})` : ""));
|
|
706
|
+
}
|
|
707
|
+
if (o.today > 0) parts.push(h("span", {class: "seg s-spend", title: "Spend recorded today (UTC), at API prices"}, h("b", {text: usd(o.today)}), " today"));
|
|
708
|
+
const kids = parts.filter(Boolean);
|
|
709
|
+
return kids.length || o.quiet ? kids : [h("span", {class: "seg zero", text: "No cards yet"})];
|
|
710
|
+
}
|
|
711
|
+
function repoChip(r, interactive) {
|
|
712
|
+
const label = [r.name, h("span", {class: "rc", text: String(r.cards)})];
|
|
713
|
+
if (!interactive) return h("span", {class: "repo", title: `${plural(r.cards, "card")} in ${r.name}`}, label);
|
|
714
|
+
return h("button", {type: "button", class: "repo", "data-repo": r.name, "aria-pressed": String(state.repo === r.name), title: `Show only ${r.name}`}, label);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function needRule() { return +(((state.board.settings || {}).require_review || {}).count) || 0; }
|
|
718
|
+
function leaseText(c) {
|
|
719
|
+
const l = c.lease;
|
|
720
|
+
if (!l) return null;
|
|
721
|
+
if (!l.expires) return h("span", {class: "pill lock", title: (l.paths || []).join(", ") || "no locked paths"}, "Lease held");
|
|
722
|
+
const ms = Date.parse(l.expires) - Date.now();
|
|
723
|
+
const title = `Locked paths: ${(l.paths || []).join(", ") || "none"}. Lease ends ${l.expires}.`;
|
|
724
|
+
if (ms <= 0 || l.live === false) return h("span", {class: "pill lock low", title}, `Lease expired ${untilText(l.expires)}`);
|
|
725
|
+
return h("span", {class: "pill lock" + (ms < 600000 ? " low" : ""), title}, `${ms < 600000 ? "Lease expiring soon: " : "Lease ends "}${untilText(l.expires)}`);
|
|
726
|
+
}
|
|
727
|
+
function canTakeOver(c) {
|
|
728
|
+
const l = c.lease;
|
|
729
|
+
if (c.lane !== "doing" || !c.owner || !l || !l.expires) return false;
|
|
730
|
+
if (c.owner === state.board.lead) return false;
|
|
731
|
+
return Date.parse(l.expires) <= Date.now();
|
|
732
|
+
}
|
|
733
|
+
function takeoverPill(c) {
|
|
734
|
+
if (!canTakeOver(c)) return null;
|
|
735
|
+
return h("span", {class: "pill takeover", title: `${c.owner}'s lease expired. Another worker can run: raid claim ${c.id} --take-over`}, `Can be taken over from ${c.owner}`);
|
|
736
|
+
}
|
|
737
|
+
function claimPill(c) {
|
|
738
|
+
const cl = (state.board.claimable || {})[c.id];
|
|
739
|
+
if (!cl || c.lane !== "todo") return null;
|
|
740
|
+
if (cl.ok) return h("span", {class: "pill claim", title: "A member of the card's team can claim it now"}, "Ready");
|
|
741
|
+
const r = cl.reasons || [];
|
|
742
|
+
if (!r.length) return null;
|
|
743
|
+
return h("span", {class: "pill noclaim", title: r.join("; ")}, waitingText(r[0]), r.length > 1 ? ` +${r.length - 1}` : "");
|
|
744
|
+
}
|
|
745
|
+
function approvalPill(c) {
|
|
746
|
+
if (c.lane !== "review") return null;
|
|
747
|
+
const need = needRule();
|
|
748
|
+
const got = (c.approvals || []).length;
|
|
749
|
+
if (got >= Math.max(need, 1)) return h("span", {class: "pill appr ok", title: `Approved by ${c.approvals.join(", ")}`}, "Approved");
|
|
750
|
+
return h("span", {class: "pill appr", title: need ? `This board needs ${plural(need, "approving review")} before a card closes` : "No approving review yet"},
|
|
751
|
+
need ? `Approvals ${got}/${need}` : "Not reviewed yet");
|
|
752
|
+
}
|
|
753
|
+
function costPill(c) {
|
|
754
|
+
const k = c.cost || {};
|
|
755
|
+
const eq = +k.usd_equiv || 0, paid = +k.usd || 0, tokens = +k.tokens || 0;
|
|
756
|
+
if (!eq && !paid && !tokens) return null;
|
|
757
|
+
const money = paid && Math.abs(eq - paid) >= 0.005 ? `${usd(paid)} paid` : (paid || eq) ? usd(paid || eq) : "";
|
|
758
|
+
return h("span", {class: "pill coin", title: "Cost of this card's recorded runs; plan usage is shown at API prices"},
|
|
759
|
+
[money, tokens ? `${ktok(tokens)} tokens` : "", k.duration_ms ? mins(k.duration_ms) : ""].filter(Boolean).join(" · "));
|
|
760
|
+
}
|
|
761
|
+
const PR_CHECK = {success: "checks passed", failure: "checks failed", pending: "checks running", none: ""};
|
|
762
|
+
function prPill(c) {
|
|
763
|
+
const p = c.pr;
|
|
764
|
+
if (!p) return null;
|
|
765
|
+
const st = (p.state || "linked").toLowerCase();
|
|
766
|
+
const ck = (p.checks || {}).state || "none";
|
|
767
|
+
const text = `PR #${p.number} ${st}${PR_CHECK[ck] ? ", " + PR_CHECK[ck] : ""}`;
|
|
768
|
+
const title = p.held ? `Merged but not closed: ${p.held}` : null;
|
|
769
|
+
const safe = /^https:\/\/[^\s"<>]+\/pull\/\d+$/.test(p.url || "");
|
|
770
|
+
return safe ? h("a", {class: "pill pr", href: p.url, target: "_blank", rel: "noopener noreferrer", title}, text) : h("span", {class: "pill pr", title}, text);
|
|
771
|
+
}
|
|
772
|
+
function ownerPill(c) {
|
|
773
|
+
if (!c.owner) return null;
|
|
774
|
+
const a = (state.board.agents || {})[c.owner] || {};
|
|
775
|
+
return h("span", {class: "pill ow", title: identity(a) || null}, ico(avatar(c.owner)), c.owner === state.board.lead ? `${c.owner} (lead)` : c.owner);
|
|
776
|
+
}
|
|
777
|
+
const SYSTEM_NOTE = /^(claimed by \S+ \(fence \d+\)|lease (renewed|resumed|released|expired)\b|-> (doing|todo)\b)/;
|
|
778
|
+
function lastNoteObj(c) { return (c.notes || []).slice().reverse().find(x => !SYSTEM_NOTE.test(String(x.text || ""))) || null; }
|
|
779
|
+
function lastNote(c) { const n = lastNoteObj(c); return n ? String(n.text || "") : ""; }
|
|
780
|
+
function asksLead(c, item) {
|
|
781
|
+
const n = lastNoteObj(c);
|
|
782
|
+
if (n && n.by === state.board.lead) return false;
|
|
783
|
+
return ASK.test(n ? String(n.text || "").replace(/^-> blocked\s*/, "") : blockedReason(c, item));
|
|
784
|
+
}
|
|
785
|
+
function cardDetail(c, extra) {
|
|
786
|
+
const d = state.details[c.id];
|
|
787
|
+
const cl = (state.board.claimable || {})[c.id];
|
|
788
|
+
const rows = [];
|
|
789
|
+
if (extra) rows.push(extra);
|
|
790
|
+
if (cl && !cl.ok && (cl.reasons || []).length) rows.push(h("div", {class: "why"}, h("b", {text: "Not claimable yet: "}), cl.reasons.join("; ")));
|
|
791
|
+
if (c.team) rows.push(h("div", null, h("b", {text: w("Team: ", "Party: ")}), c.team));
|
|
792
|
+
if (c.ticket) rows.push(h("div", null, h("b", {text: "Ticket: "}), c.ticket));
|
|
793
|
+
if ((c.allow_protected || []).length) rows.push(h("div", null, h("b", {text: "Lead-approved protected paths: "}), c.allow_protected.join(", ")));
|
|
794
|
+
if ((c.lock_exclusions || []).length) rows.push(h("div", null, h("b", {text: "Excluded lock paths: "}), c.lock_exclusions.join(", ")));
|
|
795
|
+
if (c.cancel_reason) rows.push(h("div", null, h("b", {text: "Cancelled: "}), c.cancel_reason));
|
|
796
|
+
if (c.scope) rows.push(h("div", null, h("b", {text: "Scope: "}), c.scope));
|
|
797
|
+
if ((c.locks || []).length) rows.push(h("div", null, h("b", {text: w("Locked paths: ", "Warded paths: ")}), c.locks.join(", ")));
|
|
798
|
+
if ((c.depends || []).length) rows.push(h("div", null, h("b", {text: w("Depends on: ", "After quests: ")}), c.depends.join(", ")));
|
|
799
|
+
if (c.evidence) rows.push(h("div", null, h("b", {text: w("Closed with evidence: ", "Proof of deed: ")}), /^https:\/\//.test(c.evidence) ? h("a", {href: c.evidence, target: "_blank", rel: "noopener noreferrer", text: c.evidence}) : c.evidence));
|
|
800
|
+
if (c.verify) rows.push(h("div", null, h("b", {text: "Proof: "}), c.verify));
|
|
801
|
+
if ((c.approvals || []).length) rows.push(h("div", null, h("b", {text: w("Approved by: ", "Vouched for by: ")}), c.approvals.join(", ")));
|
|
802
|
+
if (c.pr && c.pr.held) rows.push(h("div", {class: "why"}, h("b", {text: "Merged, not closed: "}), c.pr.held));
|
|
803
|
+
for (const r of (d && d.reviews) || []) rows.push(h("div", null, h("b", {text: `Review by ${r.by}, ${r.verdict}: `}), r.note || ""));
|
|
804
|
+
const notes = ((d || c).notes || []).slice().reverse();
|
|
805
|
+
rows.push(h("h3", {class: "drawer-h", text: w("History", "Quest chronicle")}));
|
|
806
|
+
for (const n of notes) rows.push(h("div", null, h("b", {text: `${ago(n.ts)} ago, ${n.by || "?"}: `}), n.text));
|
|
807
|
+
if (!notes.length) rows.push(h("div", {class: "cls", text: "No notes yet."}));
|
|
808
|
+
if (!d && c.notes_count > (c.notes || []).length) rows.push(h("div", {class: "cls", text: `Loading ${c.notes_count} notes…`}));
|
|
809
|
+
rows.push(cmdLine(`raid show ${c.id}`));
|
|
810
|
+
return rows;
|
|
811
|
+
}
|
|
812
|
+
function moreMenu(id, commands, label) {
|
|
813
|
+
if (!(commands || []).length) return null;
|
|
814
|
+
return h("details", {class: "more-menu", "data-cmds": JSON.stringify(commands)}, h("summary", {class: "tog", "aria-label": `${label || "More actions"} for ${id}`, text: label || "More"}),
|
|
815
|
+
h("div", {class: "more-body"}));
|
|
816
|
+
}
|
|
817
|
+
function fillMore(m) {
|
|
818
|
+
const body = m.querySelector(".more-body");
|
|
819
|
+
if (!body || body.childElementCount) return;
|
|
820
|
+
let cmds = [];
|
|
821
|
+
try { cmds = JSON.parse(m.dataset.cmds || "[]"); } catch (_) {}
|
|
822
|
+
body.replaceChildren(h("p", {class: "cls", text: "Copy a command and run it in the lead terminal."}), ...cmds.map(cmdLine));
|
|
823
|
+
}
|
|
824
|
+
function row(c, opts) {
|
|
825
|
+
opts = opts || {};
|
|
826
|
+
const op = state.open.has(c.id);
|
|
827
|
+
const actions = [];
|
|
828
|
+
if (opts.primary) actions.push(h("button", {type: "button", class: "primary", "data-cmd": opts.primary.cmd, "data-label": opts.primary.label,
|
|
829
|
+
title: opts.primary.cmd, "aria-label": `${opts.primary.label} ${c.title}: copy the command`, text: opts.primary.label}));
|
|
830
|
+
if (opts.more) actions.push(opts.more);
|
|
831
|
+
if (!opts.oneLine) actions.push(h("button", {type: "button", class: "tog details", "aria-expanded": String(op), "aria-controls": "cardDrawer", text: w("Details", "Examine")}));
|
|
832
|
+
return h("li", {class: "row" + (op ? " open" : "") + (opts.oneLine ? " one" : ""), "data-id": c.id, "data-lane": c.lane, "data-kind": opts.kind || c.lane},
|
|
833
|
+
h("div", {class: "row-main"},
|
|
834
|
+
h("div", {class: "row-title"}, h("strong", {class: "t", text: c.title}), h("span", {class: "cid", text: c.id})),
|
|
835
|
+
opts.reason ? h("div", {class: "reason", text: opts.reason}) : null,
|
|
836
|
+
opts.meta && opts.meta.filter(Boolean).length ? h("div", {class: "meta"}, opts.meta) : null),
|
|
837
|
+
h("div", {class: "row-actions"}, actions));
|
|
838
|
+
}
|
|
839
|
+
function openRow(el, force) {
|
|
840
|
+
const id = el.dataset.id;
|
|
841
|
+
const open = force === undefined ? !state.open.has(id) : force;
|
|
842
|
+
state.open.clear();
|
|
843
|
+
if (open) state.open.add(id);
|
|
844
|
+
for (const row of document.querySelectorAll(".row")) {
|
|
845
|
+
const selected = open && row.dataset.id === id;
|
|
846
|
+
row.classList.toggle("open", selected);
|
|
847
|
+
row.querySelector("button.details")?.setAttribute("aria-expanded", String(selected));
|
|
848
|
+
}
|
|
849
|
+
if (!open) { closeDrawer(); return; }
|
|
850
|
+
refreshRow(el);
|
|
851
|
+
if (!$("cardDrawer").open) $("cardDrawer").showModal();
|
|
852
|
+
loadDetail(id);
|
|
853
|
+
}
|
|
854
|
+
function closeDrawer() {
|
|
855
|
+
const id = [...state.open][0];
|
|
856
|
+
state.open.clear();
|
|
857
|
+
for (const b of document.querySelectorAll("button.details")) { b.setAttribute("aria-expanded", "false"); b.closest(".row").classList.remove("open"); }
|
|
858
|
+
if ($("cardDrawer").open) $("cardDrawer").close();
|
|
859
|
+
if (id) document.querySelector(`.row[data-id="${CSS.escape(id)}"] button.details`)?.focus();
|
|
860
|
+
}
|
|
861
|
+
function refreshRow(el) {
|
|
862
|
+
const c = state.cards[el.dataset.id];
|
|
863
|
+
if (!c || !state.open.has(c.id)) return;
|
|
864
|
+
$("drawerTitle").textContent = c.title;
|
|
865
|
+
const item = itemFor(c.id);
|
|
866
|
+
setKids($("drawerBody"), cardDetail(c, item?.commands?.length ? h("div", null, h("b", {text: "Suggested command: "}), cmdLine(item.commands[0])) : null), JSON.stringify([c, state.details[c.id], item, state.theme]));
|
|
867
|
+
}
|
|
868
|
+
function itemFor(id) { return (((state.board || {}).approvals || {}).items || []).find(x => x.card === id) || null; }
|
|
869
|
+
async function loadDetail(id) {
|
|
870
|
+
const ws = state.view;
|
|
871
|
+
try {
|
|
872
|
+
const d = await getJSON(api("card", ws, "/" + encodeURIComponent(id)));
|
|
873
|
+
if (d && ws === state.view) { state.details[id] = d; document.querySelectorAll(`.row[data-id="${CSS.escape(id)}"]`).forEach(refreshRow); }
|
|
874
|
+
} catch (_) {}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function blockedReason(c, item) {
|
|
878
|
+
let r = lastNote(c) || (item && item.reason) || "";
|
|
879
|
+
r = r.replace(/^blocked:\s*/, "").replace(/^-> blocked\s*/, "").replace(/^\((.*)\)$/, "$1");
|
|
880
|
+
return r || "No reason given.";
|
|
881
|
+
}
|
|
882
|
+
function groups(cards) {
|
|
883
|
+
const items = ((state.board.approvals || {}).items || []);
|
|
884
|
+
const byCard = {};
|
|
885
|
+
for (const x of items) if (x.card) byCard[x.card] = byCard[x.card] || x;
|
|
886
|
+
const inView = new Set(cards.map(c => c.id));
|
|
887
|
+
const needs = items.filter(x => ["close", "apply", "duel"].includes(x.kind) && (!x.card || inView.has(x.card)));
|
|
888
|
+
const blocked = cards.filter(c => c.lane === "blocked");
|
|
889
|
+
const ask = blocked.filter(c => asksLead(c, byCard[c.id]));
|
|
890
|
+
const blockedOther = blocked.filter(c => !ask.includes(c));
|
|
891
|
+
const closeIds = new Set(needs.filter(x => x.kind === "close").map(x => x.card));
|
|
892
|
+
const review = cards.filter(c => c.lane === "review" && !closeIds.has(c.id));
|
|
893
|
+
const represented = new Set(needs.map(x => x.card));
|
|
894
|
+
for (const c of [...ask, ...review]) if (!represented.has(c.id)) {
|
|
895
|
+
represented.add(c.id);
|
|
896
|
+
const item = byCard[c.id];
|
|
897
|
+
needs.push({id: c.id, card: c.id, kind: ask.includes(c) ? "decision" : c.lane, reason: c.lane === "review" ? "Review awaiting lead" : blockedReason(c, item), commands: item?.commands?.length ? item.commands : [`raid show ${c.id}`]});
|
|
898
|
+
}
|
|
899
|
+
const laneOf = Object.fromEntries(cards.map(c => [c.id, c.lane]));
|
|
900
|
+
const chat = workspaceChat();
|
|
901
|
+
const answered = r => chat.some(m => m.from?.agent === state.board.lead && m.ts > r.ts && (m.task ? m.task === r.task : m.to === r.from?.agent));
|
|
902
|
+
for (const r of chat) if (r.from?.agent !== state.board.lead && ASK.test(r.text || "") && (r.to === state.board.lead || /\blead\b/i.test(r.text || "")) && (!r.task || inView.has(r.task))
|
|
903
|
+
&& !["done", "cancelled"].includes(laneOf[r.task]) && !answered(r)) {
|
|
904
|
+
needs.push({id: "ask-" + r.id, card: r.task, title: `Question from ${r.from?.agent || "agent"}`, kind: "question", reason: r.text, commands: [leadCmd(`msg "<reply>" --to ${quote(r.from?.agent || "agent")}${r.task ? " --task " + quote(r.task) : ""}`)]});
|
|
905
|
+
}
|
|
906
|
+
return {needs, ask, blocked: blockedOther, review, byCard};
|
|
907
|
+
}
|
|
908
|
+
function byUpdated(a, b) { return (b.updated || "").localeCompare(a.updated || ""); }
|
|
909
|
+
function needRow(x) {
|
|
910
|
+
const c = state.cards[x.card] || (state.board.cards || []).find(k => k.id === x.card);
|
|
911
|
+
const label = (raid() && RAID_PRIMARY[x.kind]) || PRIMARY[x.kind] || "Copy command";
|
|
912
|
+
const primary = x.commands && x.commands[0] ? {label, cmd: x.commands[0]} : null;
|
|
913
|
+
const extra = x.commands && x.commands.length > 1 ? moreMenu(x.card || x.id, x.commands.slice(1)) : null;
|
|
914
|
+
if (!c) {
|
|
915
|
+
return h("li", {class: "row one", "data-id": x.id, "data-kind": x.kind},
|
|
916
|
+
h("div", {class: "row-main"}, h("div", {class: "row-title"}, h("strong", {class: "t", text: x.title || x.id})), h("div", {class: "reason", text: x.reason})),
|
|
917
|
+
h("div", {class: "row-actions"}, primary ? h("button", {type: "button", class: "primary", "data-cmd": primary.cmd, "data-label": label, title: primary.cmd, text: label}) : null, extra));
|
|
918
|
+
}
|
|
919
|
+
const why = x.kind === "close" ? (x.reason || "").replace(/^passes the done gate;?\s*/, "").replace(/^approved by/, "Approved by") || "Ready to close" : x.reason;
|
|
920
|
+
return row(c, {kind: x.kind, primary, more: extra, reason: why, meta: [ownerPill(c), approvalPill(c), costPill(c), prPill(c)]});
|
|
921
|
+
}
|
|
922
|
+
function sectionHead(el, key, text, count) {
|
|
923
|
+
const main = raid() && RAID[key] ? RAID[key] : text;
|
|
924
|
+
const sub = raid() && RAID[key] ? h("small", {class: "raid-sub", text}) : null;
|
|
925
|
+
setKids(el, [h("span", null, ico(RAID_ICON[key]), main, " ", count === undefined ? null : h("span", {class: "count", text: String(count)})), sub], JSON.stringify([text, count, state.theme]));
|
|
926
|
+
}
|
|
927
|
+
function paged(list, key, make) {
|
|
928
|
+
const limit = state.laneLimit[key] || LANE_PAGE;
|
|
929
|
+
const kids = list.slice(0, limit).map(make);
|
|
930
|
+
if (list.length > limit) kids.push(h("li", {class: "more-row"}, h("button", {type: "button", class: "tog lane-more", "data-lane": key,
|
|
931
|
+
text: `Show ${Math.min(LANE_PAGE, list.length - limit)} more of ${list.length - limit}`})));
|
|
932
|
+
return kids;
|
|
933
|
+
}
|
|
934
|
+
function needsSummary(needs) {
|
|
935
|
+
const names = {close: ["to close", "to close"], decision: ["decision", "decisions"], blocked: ["blocked", "blocked"], review: ["review", "reviews"],
|
|
936
|
+
question: ["question", "questions"], apply: ["patch to apply", "patches to apply"], duel: ["duel to decide", "duels to decide"]};
|
|
937
|
+
const counts = {};
|
|
938
|
+
for (const x of needs) counts[x.kind] = (counts[x.kind] || 0) + 1;
|
|
939
|
+
return Object.entries(counts).map(([k, n]) => `${n} ${(names[k] || [k, k])[n === 1 ? 0 : 1]}`).join(" · ");
|
|
940
|
+
}
|
|
941
|
+
function todoOrder(cards) {
|
|
942
|
+
return cards.filter(c => c.lane === "todo").sort((a, b) => (a.created || a.updated || "").localeCompare(b.created || b.updated || "") || a.id.localeCompare(b.id));
|
|
943
|
+
}
|
|
944
|
+
function renderFocus(cards) {
|
|
945
|
+
const g = groups(cards);
|
|
946
|
+
const lore = ((state.board.approvals || {}).items || []).filter(x => x.kind === "lore");
|
|
947
|
+
$("loreInbox").hidden = !lore.length;
|
|
948
|
+
$("loreTitle").textContent = `Lore inbox (${lore.length})`;
|
|
949
|
+
setKids($("loreList"), paged(lore, "loreInbox", needRow));
|
|
950
|
+
sectionHead($("needsTitle"), "needs", "Needs you", g.needs.length);
|
|
951
|
+
$("needsSub").textContent = g.needs.length ? needsSummary(g.needs) : "";
|
|
952
|
+
const allNeeds = state.needsAll || statusFilter === "needs";
|
|
953
|
+
const needKids = g.needs.length ? (allNeeds ? g.needs : g.needs.slice(0, NEEDS_CAP)).map(needRow) : [h("li", {class: "empty"}, w("Nothing needs you", "All is quiet in the realm"))];
|
|
954
|
+
if (g.needs.length > NEEDS_CAP && statusFilter !== "needs") needKids.push(h("li", {class: "more-row"}, h("button", {type: "button", class: "tog", id: "needsMore", "aria-expanded": String(!!state.needsAll),
|
|
955
|
+
text: state.needsAll ? w(`Show the first ${NEEDS_CAP}`, `Roll up to ${NEEDS_CAP}`) : w(`Show all ${g.needs.length}`, `Unroll all ${g.needs.length}`)})));
|
|
956
|
+
setKids($("needsList"), needKids,
|
|
957
|
+
JSON.stringify([g.needs, allNeeds, state.theme, [...state.open], Object.keys(state.details).length]));
|
|
958
|
+
if (!$("waitingNote")) $("needs").querySelector("summary").append(h("span", {id: "waitingNote", class: "waiting-note"}));
|
|
959
|
+
const waiting = g.blocked.length;
|
|
960
|
+
setKids($("waitingNote"), waiting ? [w(`${waiting} blocked ${waiting === 1 ? "card waits" : "cards wait"} on others. `, `${waiting} cursed ${waiting === 1 ? "quest waits" : "quests wait"} on others. `),
|
|
961
|
+
h("a", {href: subHref("all"), "data-sub": "all", text: w("See them in All cards", "See them in the Quest log")})] : [], JSON.stringify([waiting, state.theme, state.view]));
|
|
962
|
+
$("waitingNote").hidden = !waiting;
|
|
963
|
+
$("needs").classList.toggle("calm", !g.needs.length);
|
|
964
|
+
$("needsList").hidden = !g.needs.length;
|
|
965
|
+
if (!g.needs.length) $("needsTitle").textContent = w("Nothing needs you", "All is quiet in the realm");
|
|
966
|
+
const doing = cards.filter(c => c.lane === "doing").sort(byUpdated);
|
|
967
|
+
sectionHead($("doingTitle"), "doing", "In progress", doing.length);
|
|
968
|
+
setKids($("doingList"), doing.length ? paged(doing, "doingFocus", c => row(c, {meta: [ownerPill(c), leaseText(c), takeoverPill(c), costPill(c), prPill(c)],
|
|
969
|
+
reason: [lastNote(c), (c.locks || []).length ? "Locks: " + c.locks.join(", ") : ""].filter(Boolean).join(" · ")})) : [h("li", {class: "empty"}, w("Nobody is working on a card. A worker starts one with ", "No hero is on a quest. A worker takes one with "), h("code", {text: "raid claim <card>"}), ".")],
|
|
970
|
+
JSON.stringify([doing, state.laneLimit.doingFocus, state.theme, [...state.open], Object.keys(state.details).length, Math.floor(Date.now() / 30000)]));
|
|
971
|
+
const claim = state.board.claimable || {};
|
|
972
|
+
const todo = todoOrder(cards);
|
|
973
|
+
sectionHead($("todoTitle"), "todo", "To do", todo.length);
|
|
974
|
+
const shown = state.todoAll ? todo : todo.slice(0, TODO_CAP);
|
|
975
|
+
const kids = shown.map(c => row(c, {meta: [claimPill(c), (c.depends || []).length ? h("span", {class: "pill", text: "after " + c.depends.join(", ")}) : null, c.ticket ? h("span", {class: "pill tk", text: c.ticket}) : null]}));
|
|
976
|
+
if (!todo.length) kids.push(h("li", {class: "empty"}, w("No cards waiting. Add one with ", "The quest board is empty. Post a quest with "), h("code", {text: leadCmd('new "Title" --team <team> --locks <repo>/<path>')}), "."));
|
|
977
|
+
if (todo.length > TODO_CAP) kids.push(h("li", {class: "more-row"}, h("button", {type: "button", class: "tog", id: "todoMore", "aria-expanded": String(state.todoAll),
|
|
978
|
+
text: state.todoAll ? w(`Show the first ${TODO_CAP}`, `Roll up to ${TODO_CAP}`) : w(`Show all ${todo.length}`, `Unroll all ${todo.length} quests`)})));
|
|
979
|
+
setKids($("todoList"), kids, JSON.stringify([todo.map(c => [c.id, c.updated, (claim[c.id] || {}).ok]), state.todoAll, state.theme, [...state.open], Object.keys(state.details).length]));
|
|
980
|
+
}
|
|
981
|
+
function renderDone(cards) {
|
|
982
|
+
const done = cards.filter(c => c.lane === "done").sort(byUpdated);
|
|
983
|
+
$("doneHint").textContent = "";
|
|
984
|
+
$("doneHint").className = "cls";
|
|
985
|
+
sectionHead($("doneTitle"), "done", "Done", done.length || undefined);
|
|
986
|
+
if (!$("donePanel").open) { $("doneList").dataset.sig = ""; return; }
|
|
987
|
+
const shown = done.slice(0, state.doneLimit);
|
|
988
|
+
const kids = shown.map(c => row(c, {meta: [ownerPill(c), h("span", {class: "pill", text: `closed ${ago(c.updated)} ago`}), costPill(c)]}));
|
|
989
|
+
if (!done.length) kids.push(h("li", {class: "empty", text: w("No card is done yet. The lead closes a card from Needs you.", "No legends yet. The raid leader turns in quests from the call above.")}));
|
|
990
|
+
if (done.length > shown.length) kids.push(h("li", {class: "more-row"}, h("button", {type: "button", class: "tog", id: "doneMore", text: `Show all ${done.length}`})));
|
|
991
|
+
setKids($("doneList"), kids, JSON.stringify([shown.map(c => [c.id, c.updated]), done.length, state.doneLimit, state.theme, [...state.open], Object.keys(state.details).length]));
|
|
992
|
+
}
|
|
993
|
+
function renderAllLanes(cards) {
|
|
994
|
+
const box = $("lanes");
|
|
995
|
+
const sig = JSON.stringify([cards.map(c => [c.id, c.lane, c.updated, c.owner]), state.laneLimit, state.theme, [...state.open], Object.keys(state.details).length]);
|
|
996
|
+
if (box.dataset.sig === sig) return;
|
|
997
|
+
box.dataset.sig = sig;
|
|
998
|
+
const empty = {todo: ["Nothing queued.", "The quest board is empty."], doing: ["Nobody is working on a card.", "No hero is on a quest."],
|
|
999
|
+
review: ["Nothing waits for review.", "Nothing awaits inspection."], blocked: ["Nothing is blocked.", "No quest is cursed."], done: ["No card is done yet.", "No legends yet."]};
|
|
1000
|
+
box.replaceChildren(...LANES.filter(l => l !== "cancelled" || cards.some(c => c.lane === l)).map(l => {
|
|
1001
|
+
const cs = l === "todo" ? todoOrder(cards) : cards.filter(c => c.lane === l).sort(byUpdated);
|
|
1002
|
+
const limit = state.laneLimit[l] || (l === "done" || l === "cancelled" ? 10 : LANE_PAGE);
|
|
1003
|
+
const kids = cs.slice(0, limit).map(c => row(c, {meta: [ownerPill(c), approvalPill(c), claimPill(c), leaseText(c), takeoverPill(c), costPill(c)]}));
|
|
1004
|
+
if (!cs.length) kids.push(h("li", {class: "empty", text: w(...(empty[l] || ["None.", "None."]))}));
|
|
1005
|
+
if (cs.length > limit) kids.push(h("li", {class: "more-row"}, h("button", {type: "button", class: "tog lane-more", "data-lane": l, text: `Show ${Math.min(LANE_PAGE, cs.length - limit)} more of ${cs.length - limit}`})));
|
|
1006
|
+
return h("section", {class: "lane" + (cs.length ? "" : " empty"), "data-lane": l, "aria-labelledby": "lane-" + l},
|
|
1007
|
+
h("h3", {class: "lane-h", id: "lane-" + l}, h("span", null, ico(RAID_ICON[l]), raid() && RAID[l] ? RAID[l] : LANE[l], raid() && RAID[l] ? h("small", {class: "raid-sub", text: LANE[l]}) : null), h("span", {class: "count", text: String(cs.length)})),
|
|
1008
|
+
h("ul", {class: "rows"}, kids));
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function lastSeen(a) {
|
|
1013
|
+
return Math.max(Date.parse(a.heartbeat || "") || 0, Date.parse(a.last_seen || "") || 0);
|
|
1014
|
+
}
|
|
1015
|
+
function isWorking(name, a) {
|
|
1016
|
+
return !!a.card || (state.board.cards || []).some(c =>
|
|
1017
|
+
(c.owner === name && !["todo", "done", "cancelled"].includes(c.lane)) ||
|
|
1018
|
+
(c.lease && c.lease.agent === name && fxLive(c.lease)));
|
|
1019
|
+
}
|
|
1020
|
+
function isActive(name, a) {
|
|
1021
|
+
return isWorking(name, a) || Date.now() - lastSeen(a) < 2 * 60 * 60 * 1000;
|
|
1022
|
+
}
|
|
1023
|
+
function agentCard(name, a, cards) {
|
|
1024
|
+
const st = a.status || "idle";
|
|
1025
|
+
const cur = cards.find(c => c.id === a.card);
|
|
1026
|
+
const lead = name === state.board.lead;
|
|
1027
|
+
const role = lead ? "Lead" : a.role === "reviewer" ? "Reviewer" : "Worker";
|
|
1028
|
+
const won = cards.filter(c => c.owner === name && c.lane === "done").length;
|
|
1029
|
+
const seen = lastSeen(a);
|
|
1030
|
+
const who = identity(a);
|
|
1031
|
+
const mail = (state.board.inbox || {})[name] || {};
|
|
1032
|
+
return h("div", {class: `agent st-${st}`, "data-agent": name, vars: {"--s": `var(--s-${st})`}, title: raid() && !lead ? (heroOf(name) || ["", ""])[1] : null},
|
|
1033
|
+
h("div", {class: "av", "aria-hidden": "true", text: avatar(name)}),
|
|
1034
|
+
h("div", {class: "body"},
|
|
1035
|
+
h("div", {class: "nm"}, h("b", {text: name}), " ", statusBadge(st)),
|
|
1036
|
+
h("div", {class: "line cls"}, [role, a.team ? `team ${a.team}` : "", won ? `${won} done` : ""].filter(Boolean).join(" · ")),
|
|
1037
|
+
who || billBadge(a.billing) ? h("div", {class: "line"}, who ? h("span", {class: "who", text: who}) : null, billBadge(a.billing), trustBadge(a)) : trustBadge(a) ? h("div", {class: "line"}, trustBadge(a)) : null,
|
|
1038
|
+
cur ? h("div", {class: "line"}, "On ", h("b", {text: cur.title})) : null,
|
|
1039
|
+
h("div", {class: "line cls"}, seen ? `seen ${ago(new Date(seen).toISOString())} ago` : "", +a.spend_usd ? ` · spent ${usd(a.spend_usd)}` : "",
|
|
1040
|
+
mail.unread ? ` · ${mail.unread} unread` : "", mail.queued ? ` · ${mail.queued} queued` : "")));
|
|
1041
|
+
}
|
|
1042
|
+
function renderAgents(cards) {
|
|
1043
|
+
const b = state.board;
|
|
1044
|
+
const all = Object.entries(b.agents || {}).map(([name, a]) => ({name, a}));
|
|
1045
|
+
const active = all.filter(x => isActive(x.name, x.a)).sort((x, y) => ORDER.indexOf(x.a.status || "idle") - ORDER.indexOf(y.a.status || "idle") || x.name.localeCompare(y.name));
|
|
1046
|
+
const idle = all.filter(x => !isActive(x.name, x.a)).sort((x, y) => lastSeen(y.a) - lastSeen(x.a) || x.name.localeCompare(y.name));
|
|
1047
|
+
$("agentsHint").textContent = all.length ? `${active.filter(x => isWorking(x.name, x.a)).length} working, ${active.filter(x => !isWorking(x.name, x.a)).length} recently active` : "nobody joined yet";
|
|
1048
|
+
if (!$("agentsPanel").open) { $("agents").dataset.sig = ""; return; }
|
|
1049
|
+
const sig = JSON.stringify([b.agents, b.inbox, cards.map(c => [c.id, c.owner, c.lane]), state.theme, Math.floor(Date.now() / 30000), !!state.idleAll]);
|
|
1050
|
+
const kids = [];
|
|
1051
|
+
if (!all.some(x => x.name !== b.lead)) kids.push(h("p", {class: "empty"}, "No workers yet. A worker joins from its own terminal with ", h("code", {text: "raid up --join"}), "."));
|
|
1052
|
+
if (active.length) kids.push(h("div", {class: "agents"}, active.map(x => agentCard(x.name, x.a, cards))));
|
|
1053
|
+
else if (all.length) kids.push(h("p", {class: "empty", text: "No agent was active in the last 2 hours."}));
|
|
1054
|
+
if (idle.length) {
|
|
1055
|
+
kids.push(h("section", {id: "idleFold"},
|
|
1056
|
+
h("h3", {text: `${idle.length} not seen for 2 hours`}),
|
|
1057
|
+
h("p", {class: "cls", text: "No card, no lease and not seen in the last 2 hours."}),
|
|
1058
|
+
h("div", {class: "chips"}, (state.idleAll ? idle : idle.slice(0, IDLE_CAP)).map(x => h("span", {class: "chip", "data-agent": x.name, title: lastSeen(x.a) ? `seen ${ago(new Date(lastSeen(x.a)).toISOString())} ago` : "never seen"}, x.name)),
|
|
1059
|
+
idle.length > IDLE_CAP ? h("button", {type: "button", class: "tog", id: "idleMore", "aria-expanded": String(!!state.idleAll),
|
|
1060
|
+
text: state.idleAll ? `Show the first ${IDLE_CAP}` : `Show all ${idle.length}`}) : null)));
|
|
1061
|
+
}
|
|
1062
|
+
setKids($("agents"), kids, sig);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function renderLimits(data) {
|
|
1066
|
+
const providers = (data && data.providers) || [];
|
|
1067
|
+
const real = providers.filter(p => (p.status === "ok" || p.status === "stale") && ((p.windows || []).length || p.context || (p.session && p.session.cost_usd != null)));
|
|
1068
|
+
const missing = providers.filter(p => !real.includes(p));
|
|
1069
|
+
const NAME = {claude: "Claude Code", codex: "Codex", gemini: "Gemini CLI"};
|
|
1070
|
+
const info = missing.map(p => `${NAME[p.provider] || p.provider}: ${p.status}${p.detail ? ". " + p.detail : ""}`);
|
|
1071
|
+
setKids($("providerInfo"), info.length ? [h("p", {class: "cls", text: "Not shown in the usage strip:"}), h("ul", null, info.map(t => h("li", {text: t})))] : [], JSON.stringify(info));
|
|
1072
|
+
$("limits").hidden = !real.length;
|
|
1073
|
+
$("limits").title = info.join("\n");
|
|
1074
|
+
const sig = JSON.stringify([real, Math.floor(Date.now() / 30000)]);
|
|
1075
|
+
setKids($("limits"), real.map(p => {
|
|
1076
|
+
const windows = (p.windows || []).filter(w => Number.isFinite(w.used_percent));
|
|
1077
|
+
const ctx = p.context;
|
|
1078
|
+
const peak = Math.max(0, ...windows.map(w => w.used_percent), ctx ? ctx.used_percent : 0);
|
|
1079
|
+
const level = peak >= 90 ? "critical" : peak >= 70 ? "warning" : "normal";
|
|
1080
|
+
const parts = [];
|
|
1081
|
+
if (ctx) parts.push(h("span", {class: "lv", title: ctx.assumed_window ? "Window size not reported; assumed " + ktok(ctx.window_tokens) + " tokens" : `Context window used: ${ktok(ctx.used_tokens || 0)} of ${ktok(ctx.window_tokens)} tokens`},
|
|
1082
|
+
`${w("Context", "Mana")} ${Math.round(ctx.used_percent)}%`, ctx.assumed_window ? "*" : ""));
|
|
1083
|
+
for (const w of windows) {
|
|
1084
|
+
const name = w.window === "weekly" ? "Weekly" : w.window || "Window";
|
|
1085
|
+
const reset = resetText(w.resets_at);
|
|
1086
|
+
parts.push(h("span", {class: "lv"}, `${name} ${Math.round(w.used_percent)}%`, reset ? h("small", {text: ` resets ${reset}`}) : null));
|
|
1087
|
+
}
|
|
1088
|
+
if (p.session && p.session.cost_usd != null) parts.push(h("span", {class: "lv", title: "Claude Code's own estimate for the current session, at list prices"}, `Session ${usd(p.session.cost_usd)}`));
|
|
1089
|
+
const seen = p.observed_at ? ago(p.observed_at) : "";
|
|
1090
|
+
const age = secondsAgo(p.observed_at);
|
|
1091
|
+
return h("div", {class: `limit ${level}` + (age !== null && age > 3600 ? " stale" : ""), "data-provider": p.provider || "", title: age !== null && age > 3600 ? `Last reported ${seen} ago; these numbers may be out of date` : null},
|
|
1092
|
+
h("strong", {text: (NAME[p.provider] || p.provider) + (p.plan ? ` (${p.plan})` : "")}), parts,
|
|
1093
|
+
seen ? h("span", {class: "age" + (age !== null && age > FRESH_S ? " old" : "")}, `updated ${seen} ago`) : null);
|
|
1094
|
+
}), sig);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function cardEvents(cards) {
|
|
1098
|
+
const prev = state.cards;
|
|
1099
|
+
for (const c of cards) {
|
|
1100
|
+
const p = prev[c.id];
|
|
1101
|
+
const el = () => document.querySelector(`.row[data-id="${CSS.escape(c.id)}"]`);
|
|
1102
|
+
const who = c.owner || "someone";
|
|
1103
|
+
if (!p) { toast("New card: ", h("b", {text: c.title})); play("posted"); continue; }
|
|
1104
|
+
if (p.lane !== c.lane) {
|
|
1105
|
+
if (c.lane === "done" && raid()) toastAs("quest", fxGlyph("quest-seal"), h("b", {text: "Quest complete: "}), c.title, ` (${who})`);
|
|
1106
|
+
else toast(h("b", {text: who}), " moved ", h("b", {text: c.title}), ` to ${LANE[c.lane]}`);
|
|
1107
|
+
const fx = {done: [null, "done"], review: [["\u{1F989}", "✨"], "review"], blocked: [["\u{1F480}"], "blocked"], doing: [["\u{1F525}", "✨"], "claim"]}[c.lane];
|
|
1108
|
+
if (fx) { if (fx[0]) setTimeout(() => burst(el(), fx[0]), 400); play(fx[1]); } else play("posted");
|
|
1109
|
+
} else if ((+(c.cost || {}).tokens || 0) > (+(p.cost || {}).tokens || 0)) {
|
|
1110
|
+
toast(`${ktok(c.cost.tokens - ((p.cost || {}).tokens || 0))} more tokens on `, h("b", {text: c.title}));
|
|
1111
|
+
play("coin");
|
|
1112
|
+
} else if (p.owner !== c.owner && c.owner) {
|
|
1113
|
+
toast(h("b", {text: c.owner}), " took ", h("b", {text: c.title})); play("claim");
|
|
1114
|
+
} else if ((c.notes_count || 0) > (p.notes_count || 0)) {
|
|
1115
|
+
const n = (c.notes || [])[c.notes.length - 1] || {};
|
|
1116
|
+
toast(h("b", {text: n.by || who}), ": ", String(n.text || "").slice(0, 140)); play("note");
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
function statusEvents() {
|
|
1121
|
+
const lapsed = new Set();
|
|
1122
|
+
for (const c of state.board.cards) {
|
|
1123
|
+
const l = c.lease;
|
|
1124
|
+
if (!l || c.lane !== "doing" || !l.expires) continue;
|
|
1125
|
+
const ms = Date.parse(l.expires) - Date.now(), k = c.id + "@" + l.expires;
|
|
1126
|
+
if ((ms <= 0 || l.live === false) && !state.expired.has(k)) {
|
|
1127
|
+
state.expired.add(k);
|
|
1128
|
+
state.warned.add(k);
|
|
1129
|
+
if (!state.first) { lapsed.add(c.owner); toast("Lease on ", h("b", {text: c.title}), " expired"); play("expired"); }
|
|
1130
|
+
} else if (ms > 0 && ms < 5 * 60000 && !state.warned.has(k)) {
|
|
1131
|
+
state.warned.add(k);
|
|
1132
|
+
if (!state.first) { toast("Lease on ", h("b", {text: c.title}), ` ends in ${Math.max(1, Math.round(ms / 60000))} min`); play("expiring"); }
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
for (const [n, s] of Object.entries(state.board.agents || {})) {
|
|
1136
|
+
const was = state.statusPrev[n];
|
|
1137
|
+
if (!state.first && was && was !== s.status && s.status === "stalled" && !lapsed.has(n)) { toast(h("b", {text: n}), " is stalled on ", s.card || "a card"); play("stalled"); }
|
|
1138
|
+
state.statusPrev[n] = s.status;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
function msgState(m, busy) {
|
|
1142
|
+
if (m.status === "read") return "read";
|
|
1143
|
+
if (m.status === "queued" && busy[m.to] && m.task !== busy[m.to]) return "queued";
|
|
1144
|
+
return "ready";
|
|
1145
|
+
}
|
|
1146
|
+
function messageEvents(data) {
|
|
1147
|
+
const busy = {};
|
|
1148
|
+
for (const c of data.cards || []) if (c.lane === "doing" && c.owner) busy[c.owner] = c.id;
|
|
1149
|
+
const now = {};
|
|
1150
|
+
for (const m of data.messages || []) if (m && m.id) now[m.id] = msgState(m, busy);
|
|
1151
|
+
const prev = state.msgPrev;
|
|
1152
|
+
state.msgPrev = now;
|
|
1153
|
+
if (!prev) return;
|
|
1154
|
+
for (const [id, st] of Object.entries(now)) {
|
|
1155
|
+
const was = prev[id];
|
|
1156
|
+
if (was === st) continue;
|
|
1157
|
+
if (!was) play(st === "queued" ? "queued" : st === "read" ? "read" : "sent");
|
|
1158
|
+
else if (st === "read") play("read");
|
|
1159
|
+
else if (was === "queued" && st === "ready") play("delivered");
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function ratio(a, b) { return `${a || 0}/${(a || 0) + (b || 0)}`; }
|
|
1164
|
+
function evidenceSummary(x) {
|
|
1165
|
+
return `verify ${ratio(x.verify_passed, x.verify_failed)}, review ${ratio(x.review_approve, x.review_changes)}, rework ${x.rework || 0}`;
|
|
1166
|
+
}
|
|
1167
|
+
function signed(v) { const n = Math.round((+v || 0) * 100) / 100; return n > 0 ? `+${n}` : String(n); }
|
|
1168
|
+
function evidenceList(x, c) {
|
|
1169
|
+
const items = [
|
|
1170
|
+
["Verify runs passed", ratio(x.verify_passed, x.verify_failed), c.verify],
|
|
1171
|
+
["Reviews approved", ratio(x.review_approve, x.review_changes), c.review],
|
|
1172
|
+
["Rework", String(x.rework || 0), c.rework],
|
|
1173
|
+
["Lock violations and denials", String((x.lock_violations || 0) + (x.lock_denials || 0)), c.lock],
|
|
1174
|
+
["Stalls", String(x.stalls || 0), c.stall],
|
|
1175
|
+
];
|
|
1176
|
+
return h("ul", {class: "evid"}, items.map(([label, value, delta]) =>
|
|
1177
|
+
h("li", null, h("span", {text: label}), h("b", {text: value}), h("span", {class: "delta", text: `${signed(delta)} pts`}))));
|
|
1178
|
+
}
|
|
1179
|
+
function renderScore(rows, reputations) {
|
|
1180
|
+
const sig = JSON.stringify([rows, reputations, state.theme]);
|
|
1181
|
+
const children = [];
|
|
1182
|
+
const known = rows.filter(g => g.provider !== "unknown" || g.model !== "unknown" || g.usd || g.usd_equiv || g.tokens);
|
|
1183
|
+
const head = ["Provider", "Model", "Cards", "Done", "Review pass", "Paid", "At API prices", "Tokens", "Time"];
|
|
1184
|
+
const cell = (i, text) => h("td", {class: i > 1 ? "num" : null, "data-label": head[i], text});
|
|
1185
|
+
const name = v => v && v !== "unknown" ? v : "not reported";
|
|
1186
|
+
if (known.length) children.push(h("div", {class: "tbl", tabindex: "0", role: "region", "aria-label": "Results by provider and model"}, h("table", {class: "stack"},
|
|
1187
|
+
h("thead", null, h("tr", null, head.map((x, i) => h("th", {class: i > 1 ? "num" : null, scope: "col", text: x})))),
|
|
1188
|
+
h("tbody", null, known.map(g => h("tr", null, cell(0, name(g.provider)), cell(1, name(g.model) === "not reported" ? "not reported" : g.label), cell(2, String(g.quests)), cell(3, String(g.done)),
|
|
1189
|
+
cell(4, g.pass_rate == null ? "-" : `${Math.round(g.pass_rate * 100)}% (${g.approve}/${g.approve + g.changes})`),
|
|
1190
|
+
cell(5, g.usd ? usd(g.usd) : "-"), cell(6, g.usd_equiv ? usd(g.usd_equiv) : "-"), cell(7, g.tokens ? ktok(g.tokens) : "-"), cell(8, g.duration_ms ? mins(g.duration_ms) : "-")))))));
|
|
1191
|
+
else children.push(h("p", {class: "empty", text: "No runs recorded yet. Runners record usage on their own; an agent can add one with raid cost."}));
|
|
1192
|
+
const scored = reputations.filter(r => evidenceCount(r.inputs));
|
|
1193
|
+
if (scored.length) {
|
|
1194
|
+
const trustRows = scored.map(r => {
|
|
1195
|
+
const evidence = h("section", {"aria-label": evidenceSummary(r.inputs || {})}, evidenceList(r.inputs || {}, r.contributions || {}));
|
|
1196
|
+
return h("tr", null, h("td", {"data-label": "Workspace", text: r.workspace}), h("td", {"data-label": "Model", text: r.label || r.key}),
|
|
1197
|
+
h("td", {class: "num score", "data-label": "Score / 100", text: String(r.score)}), h("td", {"data-label": "Evidence"}, evidence));
|
|
1198
|
+
});
|
|
1199
|
+
const trustHead = ["Workspace", "Model", "Score / 100", "Evidence"].map((text, i) => h("th", {scope: "col", class: i === 2 ? "num" : null, text}));
|
|
1200
|
+
children.push(h("section", {class: "model-trust"}, h("h3", {text: "Model trust scores and evidence"}),
|
|
1201
|
+
h("p", {class: "note-sm", text: "Scores start at 50. Recorded verification and review results raise or lower them; rework, lock violations and stalls lower them. Scores grant no permissions. Each workspace is scored separately."}),
|
|
1202
|
+
h("div", {class: "tbl", tabindex: "0", role: "region", "aria-label": "Model trust table"}, h("table", {class: "stack"}, h("thead", null, h("tr", null, trustHead)), h("tbody", null, trustRows)))));
|
|
1203
|
+
}
|
|
1204
|
+
setKids($("score"), children, sig);
|
|
1205
|
+
}
|
|
1206
|
+
function forecastBlock(f) {
|
|
1207
|
+
if (!f) return null;
|
|
1208
|
+
if (f.error) return h("p", {class: "forecast", text: "The forecast could not be computed from the recorded spend."});
|
|
1209
|
+
const days = f.by_day || [];
|
|
1210
|
+
if (!(f.burn || {}).per_day_trailing && !f.forecast && !f.month && !(f.alerts || []).length) return null;
|
|
1211
|
+
const spent = d => +d.usd || 0;
|
|
1212
|
+
const max = Math.max(...days.map(spent), 0.000001);
|
|
1213
|
+
const endAt = new Date((f.period || {}).end || "");
|
|
1214
|
+
if (!isNaN(endAt)) endAt.setUTCDate(endAt.getUTCDate() - 1);
|
|
1215
|
+
const end = isNaN(endAt) ? "" : endAt.toISOString().slice(0, 10);
|
|
1216
|
+
const caps = f.caps ? [f.caps.daily != null ? `daily cap ${usd(f.caps.daily)}` : "", f.caps.monthly != null ? `monthly cap ${usd(f.caps.monthly)}` : ""].filter(Boolean) : [];
|
|
1217
|
+
const spark = h("div", {class: "spark", role: "img", "aria-label": `Spend per day, last ${days.length} days, peak ${usd(max)}`},
|
|
1218
|
+
days.map(d => { const i = h("i", {title: `${d.day}: ${spent(d) ? usd(spent(d)) : "no spend"}`}); i.classList.add("height-" + Math.round(Math.max(2, Math.min(100, 100 * spent(d) / max)))); return i; }));
|
|
1219
|
+
const alerts = f.alerts || [];
|
|
1220
|
+
const many = !!f.workspaces;
|
|
1221
|
+
return h("div", {class: "forecast"},
|
|
1222
|
+
h("h3", {text: "Forecast"}),
|
|
1223
|
+
h("div", {class: "tsum"}, h("span", {class: "big", text: usd((f.burn || {}).per_day_trailing)}), h("span", {text: `a day over the last ${(f.burn || {}).trail_days || 7} days`}),
|
|
1224
|
+
h("span", {class: "big", text: usd(f.forecast)}), h("span", {text: `by month end (${end})`})),
|
|
1225
|
+
h("div", {class: "note-sm", text: `${usd(f.month)} this month, ${usd(f.today)} today${caps.length ? "; " + caps.join(", ") : ""}.`}),
|
|
1226
|
+
spark,
|
|
1227
|
+
days.length ? h("div", {class: "spark-axis", "aria-hidden": "true"}, h("span", {text: days[0].day}), h("span", {text: `peak ${usd(max)} a day`}), h("span", {text: days[days.length - 1].day})) : null,
|
|
1228
|
+
alerts.length ? h("ul", {class: "alerts", "aria-label": "Budget alerts"}, alerts.map(a => h("li", {class: "alert " + a.level},
|
|
1229
|
+
h("b", {text: a.level === "critical" ? "Over budget: " : "Budget warning: "}), many ? `${a.workspace}: ` : "", a.text)))
|
|
1230
|
+
: caps.length || many ? null : h("div", {class: "note-sm"}, "No budget set. Set one with ", h("code", {text: leadCmd("budget set budget.workspace.monthly_usd 50")}), "."));
|
|
1231
|
+
}
|
|
1232
|
+
function renderTreasury(t, f) {
|
|
1233
|
+
if (!t) return;
|
|
1234
|
+
const by = state.treasuryBy;
|
|
1235
|
+
const total = t.total || {};
|
|
1236
|
+
const runs = +total.runs || 0;
|
|
1237
|
+
$("costHint").textContent = runs && ((f || {}).alerts || []).length ? plural(f.alerts.length, "budget alert") : "";
|
|
1238
|
+
const sig = JSON.stringify([t, f, by, state.theme]);
|
|
1239
|
+
if (!runs) {
|
|
1240
|
+
setKids($("treasury"), [h("p", {class: "empty", text: "No usage recorded yet. Cost appears here once a runner or an agent records a run."})], sig);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
const rows = (t[by] || []).slice(0, 20);
|
|
1244
|
+
const spent = r => +r.usd_equiv || +r.usd || 0;
|
|
1245
|
+
const max = Math.max(...rows.map(spent), 0.000001);
|
|
1246
|
+
const opts = [["by_provider", "provider"], ["by_model", "model"], ["by_agent", "agent"], ["by_workspace", "workspace"], ["by_day", "day"]];
|
|
1247
|
+
const sel = h("select", {id: "treasuryBy", "aria-label": "Group cost by"}, opts.map(([v, l]) => h("option", {value: v, text: l})));
|
|
1248
|
+
sel.value = by;
|
|
1249
|
+
sel.addEventListener("change", e => { state.treasuryBy = e.target.value; store("rl.treasuryBy", e.target.value); renderTreasury(t, f); });
|
|
1250
|
+
const b = t.billing || {};
|
|
1251
|
+
const modes = [["api", "usd", "paid"], ["plan", "usd_equiv", "at API prices, no extra charge"], ["unknown", "usd_equiv", "at API prices"]]
|
|
1252
|
+
.filter(([m, k]) => +((b[m] || {})[k]) || +((b[m] || {}).runs));
|
|
1253
|
+
setKids($("treasury"), [
|
|
1254
|
+
h("div", {class: "tsum"}, total.usd ? [h("span", {class: "big", text: usd(total.usd)}), h("span", {text: "paid"})] : null,
|
|
1255
|
+
total.usd_equiv ? [h("span", {class: "big", text: usd(total.usd_equiv)}), h("span", {text: "at API prices"})] : null,
|
|
1256
|
+
h("span", {text: `${ktok(total.tokens)} tokens in ${plural(runs, "run")}`})),
|
|
1257
|
+
modes.length > 1 ? h("div", {class: "tsum"}, modes.map(([m, k, label]) => [h("span", {class: "bill " + m, title: BILL[m][1], text: BILL[m][0]}), h("span", {text: `${usd((b[m] || {})[k])} ${label}`})])) : null,
|
|
1258
|
+
total.unpriced_runs ? h("div", {class: "note-sm", text: `${plural(total.unpriced_runs, "run")} used a model with no price in pricing.json; their tokens count, their cost does not.`}) : null,
|
|
1259
|
+
forecastBlock(f),
|
|
1260
|
+
h("div", {class: "tside"}, h("div", {class: "panel-tools"}, h("label", {class: "vol"}, "Group by ", sel)),
|
|
1261
|
+
h("div", {class: "bars"}, rows.map(r => {
|
|
1262
|
+
const w = h("i");
|
|
1263
|
+
w.style.width = (100 * spent(r) / max) + "%";
|
|
1264
|
+
const key = r.key === "unknown" ? "not reported" : (r.label || r.key);
|
|
1265
|
+
return h("div", {class: "brow"}, h("span", {class: "nm", title: key, text: key}), h("span", {class: "tr"}, w),
|
|
1266
|
+
h("span", null, `${+r.usd ? usd(r.usd) + " paid" : usd(r.usd_equiv) + " at API prices"} · ${ktok(r.tokens)} `, by === "by_agent" ? billBadge(r.billing) : null));
|
|
1267
|
+
})))], sig);
|
|
1268
|
+
}
|
|
1269
|
+
function logRows() {
|
|
1270
|
+
const f = state.logFilter;
|
|
1271
|
+
const records = state.log.filter(r => !CHAT_KINDS.has(r.kind));
|
|
1272
|
+
const completed = (state.board?.cards || []).filter(c => c.lane === "done").map(c => ({id: "done-" + c.id, kind: "done", task: c.id, ts: c.updated, text: c.title, from: {agent: c.owner}}));
|
|
1273
|
+
const merged = (activityFilter === "done" ? completed : activityFilter === "log" ? records : [...records, ...completed]).sort((a, b) => (a.ts || "").localeCompare(b.ts || "") || a.id.localeCompare(b.id));
|
|
1274
|
+
const rows = f === "*" ? merged : merged.filter(r => r.task === f);
|
|
1275
|
+
return rows;
|
|
1276
|
+
}
|
|
1277
|
+
function recordText(r) {
|
|
1278
|
+
const d = r.data || {};
|
|
1279
|
+
if (r.kind === "cost" && r.data) return `${ktok(d.tokens)} tokens${modelName(d.model) ? " on " + modelName(d.model) : ""}${+d.usd ? ", " + usd(d.usd) + " paid" : ""}${+d.usd_equiv ? ", " + usd(d.usd_equiv) + " at API prices" : ""}`;
|
|
1280
|
+
return r.text || (r.artifacts || []).map(a => a.name).join(", ") || Object.entries(d).filter(([, v]) => typeof v !== "object").map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
1281
|
+
}
|
|
1282
|
+
function logRow(r, top, idx) {
|
|
1283
|
+
const fr = r.from || {};
|
|
1284
|
+
const who = fr.agent || "?";
|
|
1285
|
+
const meta = [modelName(fr.model), fr.runtime].filter(Boolean).join(" · ");
|
|
1286
|
+
const hd = h("div", {class: "hd", title: [who, ago(r.ts) + " ago", KIND[r.kind] || r.kind, r.task, r.to ? "→ " + r.to : "", meta].filter(Boolean).join(" · ")},
|
|
1287
|
+
h("b", {text: who}), h("span", {text: ago(r.ts) + " ago"}), h("span", {text: KIND[r.kind] || r.kind}),
|
|
1288
|
+
r.task ? h("span", null, r.task) : null, r.to ? h("span", {text: "→ " + r.to}) : null, meta ? h("span", {text: meta}) : null);
|
|
1289
|
+
const txt = recordText(r);
|
|
1290
|
+
const b = h("button", {type: "button", class: `lrow k-${r.kind}`, title: txt, "data-i": String(idx), "aria-pressed": String(state.logSel === r.id)},
|
|
1291
|
+
h("span", {class: "ava", "aria-hidden": "true", text: avatar(who)}), h("span", {class: "bub"}, hd, h("div", {class: "tx", text: txt})));
|
|
1292
|
+
b.style.top = top + "px";
|
|
1293
|
+
return b;
|
|
1294
|
+
}
|
|
1295
|
+
function renderLog(force) {
|
|
1296
|
+
const box = $("log");
|
|
1297
|
+
if (!$("logPanel").open || $("board").hidden) return;
|
|
1298
|
+
const rows = logRows();
|
|
1299
|
+
const n = rows.length;
|
|
1300
|
+
let sp = box.querySelector(".spacer");
|
|
1301
|
+
if (!sp) { sp = h("div", {class: "spacer"}); box.replaceChildren(sp); }
|
|
1302
|
+
sp.style.height = Math.max(n * ROW_H, 1) + "px";
|
|
1303
|
+
const first = Math.max(0, Math.floor(box.scrollTop / ROW_H) - 4);
|
|
1304
|
+
const last = Math.min(n, Math.ceil((box.scrollTop + box.clientHeight) / ROW_H) + 4);
|
|
1305
|
+
const sig = [n, first, last, state.logSel, state.theme, rows.length ? rows[0].id : ""].join("|");
|
|
1306
|
+
if (!force && box.dataset.sig === sig) return;
|
|
1307
|
+
box.dataset.sig = sig;
|
|
1308
|
+
const kids = [];
|
|
1309
|
+
for (let i = first; i < last; i++) { const r = rows[n - 1 - i]; kids.push(logRow(r, i * ROW_H, n - 1 - i)); }
|
|
1310
|
+
if (!n) kids.push(h("div", {class: "empty", text: "No activity yet. Completed work and agent records appear here."}));
|
|
1311
|
+
sp.replaceChildren(...kids);
|
|
1312
|
+
$("logHint").textContent = `${n} records${state.logFilter !== "*" ? " on " + state.logFilter : ""}`;
|
|
1313
|
+
}
|
|
1314
|
+
function showLogDetail(r) {
|
|
1315
|
+
const d = $("logDetail");
|
|
1316
|
+
if (!r) { d.hidden = true; return; }
|
|
1317
|
+
const fr = r.from || {};
|
|
1318
|
+
const who = [modelName(fr.model), fr.runtime, fr.provider].filter(Boolean).join(" · ");
|
|
1319
|
+
const kids = [h("div", null, h("b", {text: fr.agent || "?"}), who ? ` · ${who}` : ""),
|
|
1320
|
+
h("div", {class: "cls", text: `${KIND[r.kind] || r.kind}${r.to ? " to " + r.to : ""}${r.task ? " on " + r.task : ""} · ${r.ts}`}), h("div", {text: r.text || ""})];
|
|
1321
|
+
for (const a of r.artifacts || []) kids.push(h("div", null, h("b", {text: `${a.name}${a.kind ? " (" + a.kind + ")" : ""}: `}), a.text || ""));
|
|
1322
|
+
if (r.data) kids.push(h("div", {class: "cls", text: JSON.stringify(r.data)}));
|
|
1323
|
+
setKids(d, kids);
|
|
1324
|
+
d.hidden = false;
|
|
1325
|
+
}
|
|
1326
|
+
function renderLogFilter() {
|
|
1327
|
+
const sel = $("logFilter");
|
|
1328
|
+
const ids = Object.keys(state.cards).length ? Object.keys(state.cards) : (state.board.cards || []).map(c => c.id);
|
|
1329
|
+
const recent = [...new Set(state.log.slice(-400).reverse().map(r => r.task).filter(t => t && ids.includes(t)))].slice(0, 30);
|
|
1330
|
+
if (state.logFilter !== "*" && !recent.includes(state.logFilter) && ids.includes(state.logFilter)) recent.unshift(state.logFilter);
|
|
1331
|
+
const opts = [["*", "Every card"], ...recent.map(id => [id, id])];
|
|
1332
|
+
const sig = opts.map(o => o[0]).join("|");
|
|
1333
|
+
if (sel.dataset.sig !== sig) { setKids(sel, opts.map(([v, l]) => h("option", {value: v, text: l}))); sel.dataset.sig = sig; }
|
|
1334
|
+
if (!opts.some(o => o[0] === state.logFilter)) state.logFilter = "*";
|
|
1335
|
+
sel.value = state.logFilter;
|
|
1336
|
+
}
|
|
1337
|
+
function logEvents(fresh) {
|
|
1338
|
+
if (state.seenLog === null) { state.seenLog = true; return; }
|
|
1339
|
+
for (const r of fresh) {
|
|
1340
|
+
const fr = r.from || {};
|
|
1341
|
+
if (r.kind === "msg") { toast(ico(avatar(fr.agent)), h("b", {text: fr.agent || "?"}), " → ", h("b", {text: r.to || "?"}), r.task ? ` on ${r.task}` : "", ": ", (r.text || "").slice(0, 140)); raven(fr.agent || "", r.to || ""); }
|
|
1342
|
+
else if (r.kind === "handback") { toast(ico("\u{1F4E6}"), h("b", {text: fr.agent || "?"}), " handed back ", h("b", {text: r.task || ""})); }
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
async function pollLog(ws) {
|
|
1346
|
+
if (state.logWs !== ws) { state.logWs = ws; state.log = []; state.cursor = 0; state.seenLog = null; state.logSel = null; showLogDetail(null); $("log").scrollTop = 0; }
|
|
1347
|
+
const d = await getJSON(api("transcript", ws, `?since=${state.cursor}`), "t:" + ws + ":" + state.cursor);
|
|
1348
|
+
if (!d || ws !== state.view) return;
|
|
1349
|
+
const fresh = d.records || [];
|
|
1350
|
+
state.cursor = d.cursor;
|
|
1351
|
+
if (fresh.length) {
|
|
1352
|
+
state.log = state.log.concat(fresh);
|
|
1353
|
+
if (state.log.length > LOG_CAP) state.log = state.log.slice(-LOG_CAP);
|
|
1354
|
+
logEvents(fresh);
|
|
1355
|
+
renderLog(true);
|
|
1356
|
+
renderLogFilter();
|
|
1357
|
+
renderChat();
|
|
1358
|
+
if (state.board && state.sub === "focus") { renderFocus(shownCards(state.board.cards || [])); renderLayout(shownCards(state.board.cards || [])); }
|
|
1359
|
+
} else if (state.seenLog === null) state.seenLog = true;
|
|
1360
|
+
if (d.more) pollLog(ws);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function viewDef(id) { return VIEW_DEFS.find(v => v.id === id) || null; }
|
|
1364
|
+
function subOn(id) { if (id === "focus" || id === "all") return true; const d = viewDef(id); return !!d && (!d.optIn || state.achievements) && (!d.raidOnly || raid()); }
|
|
1365
|
+
function subRoute() {
|
|
1366
|
+
let v = "focus";
|
|
1367
|
+
try { v = new URLSearchParams(location.search).get("view") || "focus"; } catch (_) {}
|
|
1368
|
+
if (v === "board") v = "all";
|
|
1369
|
+
if (v === "chat") { statusFilter = "chatter"; workTab = "chatter"; return "focus"; }
|
|
1370
|
+
if (v === "treasury") { statusFilter = "treasury"; return "focus"; }
|
|
1371
|
+
return subOn(v) ? v : "focus";
|
|
1372
|
+
}
|
|
1373
|
+
function subHref(id) { return href(state.view) + (id === "focus" ? "" : "?view=" + encodeURIComponent(id)); }
|
|
1374
|
+
const FILTER_TABS = {chat: "chatter", treasury: "treasury"};
|
|
1375
|
+
function filterTab() { return state.sub === "focus" ? Object.keys(FILTER_TABS).find(k => FILTER_TABS[k] === statusFilter) || "" : ""; }
|
|
1376
|
+
function agentClass(name) {
|
|
1377
|
+
const names = Object.keys((state.board && state.board.agents) || {}).sort();
|
|
1378
|
+
const i = names.indexOf(name || "");
|
|
1379
|
+
return "ag-" + ((i >= 0 ? i : hash(name || "")) % 8);
|
|
1380
|
+
}
|
|
1381
|
+
function viewLabel(d) { return (d.label || {}).plain || d.id; }
|
|
1382
|
+
const TAB_ORDER = ["focus", "all", "chat", "graph", "map", "frames", "treasury", "replay", "bosses", "achievements"];
|
|
1383
|
+
function tabRank(id) { const i = TAB_ORDER.indexOf(id); return i < 0 ? TAB_ORDER.length : i; }
|
|
1384
|
+
function renderViewTabs() {
|
|
1385
|
+
const unread = unreadChat().length;
|
|
1386
|
+
const chat = unread ? ` \u00b7 ${unread}` : "";
|
|
1387
|
+
const active = filterTab() || state.sub;
|
|
1388
|
+
if (state.view === "*") {
|
|
1389
|
+
const realm = [["focus", "Workspaces", "Guilds"], ["chat", "Chatter" + chat, "Tavern chatter" + chat], ["treasury", "Cost", "Treasury"]];
|
|
1390
|
+
setKids($("realmTabs"), realm.map(([id, label, raidName]) => h("a", {href: subHref(id), "data-sub": id, "aria-current": id === active ? "page" : null, text: raid() ? raidName : label})),
|
|
1391
|
+
JSON.stringify([realm, active, state.theme]));
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const items = [["focus", "Focus", "War room"], ["all", "All cards", "Quest log"], ["chat", "Chatter" + chat, "Tavern chatter" + chat], ["treasury", "Cost", "Treasury"], ...VIEW_DEFS.filter(d => subOn(d.id)).map(d => [d.id, viewLabel(d), (d.label || {}).raid])]
|
|
1395
|
+
.sort((a, b) => tabRank(a[0]) - tabRank(b[0]));
|
|
1396
|
+
setKids($("viewTabs"), items.map(([id, label, raidName]) => h("a", {href: subHref(id), "data-sub": id, "aria-current": id === active ? "page" : null,
|
|
1397
|
+
title: raid() && raidName && raidName !== label ? label : null, text: raid() && raidName ? raidName : label})), JSON.stringify([items, active, state.view, state.theme]));
|
|
1398
|
+
}
|
|
1399
|
+
function viewCtx(d) {
|
|
1400
|
+
return {h, svg, ago, localTime, usd, ktok, modelName, avatar, agentClass, setKids, theme: state.theme, raid: raid(), RM: RM || !state.anim, ws: state.view, board: state.board,
|
|
1401
|
+
view: d.id, fresh: false, raidSub: key => raid() && key ? h("small", {class: "raid-sub", text: key}) : null,
|
|
1402
|
+
api: (endpoint, query) => getJSON(api(endpoint, state.view, query || "")), def: viewDef, go: id => goSub(id), reload: () => loadSub(true)};
|
|
1403
|
+
}
|
|
1404
|
+
function enter(el) { if (!el || !motion()) return; el.classList.remove("enter"); void el.offsetWidth; el.classList.add("enter"); }
|
|
1405
|
+
function applySub() {
|
|
1406
|
+
document.body.dataset.sub = state.sub;
|
|
1407
|
+
if (!subOn(state.sub)) state.sub = "focus";
|
|
1408
|
+
const custom = state.sub !== "focus" && state.sub !== "all";
|
|
1409
|
+
$("focus").hidden = state.sub !== "focus";
|
|
1410
|
+
$("lanes").hidden = state.sub !== "all";
|
|
1411
|
+
$("viewPane").hidden = !custom;
|
|
1412
|
+
for (const d of VIEW_DEFS) if (d.id !== state.sub && d.leave) { try { d.leave(); } catch (_) {} }
|
|
1413
|
+
if (!custom) { $("viewPane").replaceChildren(); $("viewPane").dataset.sig = ""; $("viewPane").dataset.view = ""; }
|
|
1414
|
+
else if (!state.subData[state.sub]) setKids($("viewPane"), h("p", {class: "empty", role: "status", text: "Loading this view…"}));
|
|
1415
|
+
const d = viewDef(state.sub);
|
|
1416
|
+
$("viewPane").setAttribute("aria-label", d ? viewLabel(d) : "");
|
|
1417
|
+
$("sidebar").hidden = custom;
|
|
1418
|
+
if (state.sub === "all") $("board").append($("sidebar")); else $("focus").append($("sidebar"));
|
|
1419
|
+
for (const id of ["donePanel", "agentsPanel", "logPanel"]) $(id).hidden = custom || state.view === "*";
|
|
1420
|
+
$("donePanel").hidden = state.sub !== "focus" || state.view === "*";
|
|
1421
|
+
$("costPanel").hidden = false;
|
|
1422
|
+
renderViewTabs();
|
|
1423
|
+
}
|
|
1424
|
+
function drawSub() {
|
|
1425
|
+
const d = viewDef(state.sub), data = state.subData[state.sub];
|
|
1426
|
+
if (!d || !data || state.view === "*") return;
|
|
1427
|
+
const pane = $("viewPane"), c = viewCtx(d);
|
|
1428
|
+
c.fresh = pane.dataset.view !== d.id;
|
|
1429
|
+
if (c.fresh) { pane.dataset.sig = ""; pane.replaceChildren(); }
|
|
1430
|
+
pane.dataset.view = d.id;
|
|
1431
|
+
try { d.render(c, data, pane); fxView(d.id, data); } catch (e) { setKids($("viewPane"), h("p", {class: "empty", text: "This view could not be drawn. Reload the page; if it keeps failing, run the matching command, for example raid map."})); }
|
|
1432
|
+
}
|
|
1433
|
+
async function loadSub(force) {
|
|
1434
|
+
const d = viewDef(state.sub);
|
|
1435
|
+
if (!d || state.view === "*") return;
|
|
1436
|
+
if (!force && d.poll === false && state.subData[d.id]) return;
|
|
1437
|
+
const ws = state.view;
|
|
1438
|
+
const query = d.query ? d.query() : "";
|
|
1439
|
+
try {
|
|
1440
|
+
const r = await getJSON(api(d.endpoint, ws, query), `v:${d.id}:${ws}:${query}`);
|
|
1441
|
+
if (ws !== state.view || d.id !== state.sub) return;
|
|
1442
|
+
if (r) state.subData[d.id] = r;
|
|
1443
|
+
if (r || force) drawSub();
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
if (ws === state.view && d.id === state.sub) setKids($("viewPane"), h("p", {class: "empty", role: "alert", text: e.status === 413 ? "This board is too large to draw here. Use the command line instead, for example raid graph --json." : "This view could not be loaded. It retries every few seconds; check that raid ui is still running."}));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
function goSub(id) {
|
|
1449
|
+
if (!subOn(id)) id = "focus";
|
|
1450
|
+
history.pushState(null, "", subHref(id));
|
|
1451
|
+
state.sub = id;
|
|
1452
|
+
applySub();
|
|
1453
|
+
if (id === "focus" || id === "all") rerender();
|
|
1454
|
+
else loadSub(true);
|
|
1455
|
+
enter(id === "focus" ? $("focus") : id === "all" ? $("lanes") : $("viewPane"));
|
|
1456
|
+
}
|
|
1457
|
+
function renderTabs() {
|
|
1458
|
+
const items = state.mode === "all" ? [{id: "*", label: w("All workspaces", "The realm")}] : [];
|
|
1459
|
+
for (const p of state.projects) items.push({id: p.name, label: p.name});
|
|
1460
|
+
$("tabs").hidden = items.length < 2;
|
|
1461
|
+
setKids($("tabs"), items.map(i => h("a", {href: href(i.id), "data-nav": i.id, "aria-current": i.id === state.view ? "page" : null}, i.label)),
|
|
1462
|
+
JSON.stringify([items, state.view, state.theme]));
|
|
1463
|
+
}
|
|
1464
|
+
function renderCrumbs() {
|
|
1465
|
+
const c = $("crumbs");
|
|
1466
|
+
if (state.error) { setKids(c, h("span", {class: "err", role: "alert", text: state.error})); return; }
|
|
1467
|
+
if (!state.loaded) { c.textContent = "Loading the board…"; return; }
|
|
1468
|
+
if (state.view === "*") { setKids(c, [h("span", {"aria-current": "page", text: w("All workspaces", "The realm")}), h("span", {class: "cls", text: ` · ${plural(state.projects.length, w("workspace", "guild"))}`})]); return; }
|
|
1469
|
+
setKids(c, [state.mode === "all" ? h("a", {href: "/", "data-nav": "*", text: w("All workspaces", "The realm")}) : null, state.mode === "all" ? " / " : null,
|
|
1470
|
+
h("span", {"aria-current": "page", text: state.view})]);
|
|
1471
|
+
}
|
|
1472
|
+
function renderWsBar(data, cards) {
|
|
1473
|
+
setKids($("wsName"), [state.view], JSON.stringify([state.view]));
|
|
1474
|
+
setKids($("hudLevel"), [streakChip(data.streak || 0), levelBadge("Guild", data.counts)], JSON.stringify(["ws", state.view, state.theme, data.counts, data.streak]));
|
|
1475
|
+
victory("ws:" + state.view, data.bosses_slain || []);
|
|
1476
|
+
levelUp("ws:" + state.view, "Guild", data.counts, $("hudLevel"));
|
|
1477
|
+
const repos = data.repos || [];
|
|
1478
|
+
if (state.repo && !repos.some(r => r.name === state.repo)) state.repo = "";
|
|
1479
|
+
const many = repos.length > 1;
|
|
1480
|
+
const chips = many ? [h("button", {type: "button", class: "repo all", "data-repo": "", "aria-pressed": String(!state.repo), text: w("All repos", "All zones")}), ...repos.map(r => repoChip(r, true))] : [];
|
|
1481
|
+
setKids($("repos"), chips, JSON.stringify([repos, state.repo, state.theme]));
|
|
1482
|
+
const ids = new Set(cards.map(c => c.id));
|
|
1483
|
+
const titles = Object.fromEntries(cards.map(c => [c.id, c.title]));
|
|
1484
|
+
const agents = Object.entries(data.agents || {}).map(([name, a]) => ({...a, name})).filter(a => !state.repo || ids.has(a.card));
|
|
1485
|
+
const count = l => cards.filter(c => c.lane === l).length;
|
|
1486
|
+
const g = groups(cards);
|
|
1487
|
+
const lore = ((state.board.approvals || {}).items || []).filter(x => x.kind === "lore");
|
|
1488
|
+
$("loreInbox").hidden = !lore.length;
|
|
1489
|
+
$("loreTitle").textContent = `Lore inbox (${lore.length})`;
|
|
1490
|
+
setKids($("loreList"), paged(lore, "loreInbox", needRow));
|
|
1491
|
+
const info = {needs: g.needs.length, doing: count("doing"), blocked: count("blocked"), todo: count("todo"), done: count("done"), total: cards.length,
|
|
1492
|
+
stalled: oldestStalled(agents, titles), today: (data.today || {}).usd_equiv || 0};
|
|
1493
|
+
setKids($("wsStatus"), statusLine({...info, needs: 0, doing: 0, todo: 0, total: 0, quiet: true}), JSON.stringify([info, state.theme, Math.floor(Date.now() / 30000)]));
|
|
1494
|
+
}
|
|
1495
|
+
function renderBoard(data) {
|
|
1496
|
+
state.board = data;
|
|
1497
|
+
state.loaded = true;
|
|
1498
|
+
blockStatus("");
|
|
1499
|
+
const cards = data.cards || [];
|
|
1500
|
+
applyStatuses(Object.values(data.agents || {}), Object.fromEntries(cards.map(c => [c.id, c])));
|
|
1501
|
+
$("board").hidden = false;
|
|
1502
|
+
const shown = shownCards(cards);
|
|
1503
|
+
if (!state.first) cardEvents(cards);
|
|
1504
|
+
state.cards = Object.fromEntries(cards.map(c => [c.id, c]));
|
|
1505
|
+
renderWsBar(data, shown);
|
|
1506
|
+
if (state.sub === "focus") renderFocus(shown);
|
|
1507
|
+
if (state.sub === "all") renderAllLanes(shown);
|
|
1508
|
+
renderDone(shown);
|
|
1509
|
+
statusEvents();
|
|
1510
|
+
messageEvents(data);
|
|
1511
|
+
renderAgents(cards);
|
|
1512
|
+
renderScore(data.scorecard || [], data.model_reputation || []);
|
|
1513
|
+
renderTreasury(data.treasury, data.forecast);
|
|
1514
|
+
renderLogFilter();
|
|
1515
|
+
renderChat();
|
|
1516
|
+
renderLayout(shown);
|
|
1517
|
+
for (const id of state.open) if (state.cards[id]) loadDetail(id);
|
|
1518
|
+
fxBoard(data);
|
|
1519
|
+
state.first = false;
|
|
1520
|
+
if (location.hash.startsWith("#card-") && $("board").dataset.linked !== state.view + location.hash) {
|
|
1521
|
+
$("board").dataset.linked = state.view + location.hash;
|
|
1522
|
+
try { revealCard(decodeURIComponent(location.hash.slice(6))); } catch (_) {}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const LIVE = new Set(["working", "quiet", "stalled"]);
|
|
1526
|
+
function derive(a, card) {
|
|
1527
|
+
if (!LIVE.has(a.status)) return a.status;
|
|
1528
|
+
const exp = card && card.lease && card.lease.expires;
|
|
1529
|
+
if (exp && Date.parse(exp) <= Date.now()) return "stalled";
|
|
1530
|
+
const age = (Date.now() - Date.parse(a.heartbeat || "")) / 1000;
|
|
1531
|
+
if (!isFinite(age) || age > 2700) return "stalled";
|
|
1532
|
+
return age > 900 ? "quiet" : "working";
|
|
1533
|
+
}
|
|
1534
|
+
function applyStatuses(agents, cardsById) {
|
|
1535
|
+
let changed = false;
|
|
1536
|
+
for (const a of agents) {
|
|
1537
|
+
const st = derive(a, cardsById && cardsById[a.card]);
|
|
1538
|
+
if (st !== a.status) { a.status = st; a.label = (STATUS[st] || STATUS.idle).label; changed = true; }
|
|
1539
|
+
}
|
|
1540
|
+
return changed;
|
|
1541
|
+
}
|
|
1542
|
+
function statusClock() {
|
|
1543
|
+
if (state.view === "*") {
|
|
1544
|
+
if (state.overview && applyStatuses(state.projects.flatMap(p => p.agents || []), null)) renderOverview();
|
|
1545
|
+
} else if (state.board) {
|
|
1546
|
+
const byId = Object.fromEntries((state.board.cards || []).map(c => [c.id, c]));
|
|
1547
|
+
applyStatuses(Object.values(state.board.agents || {}), byId);
|
|
1548
|
+
rerender();
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
function matches(c, terms) {
|
|
1552
|
+
const hay = [c.title, c.id, c.owner, c.ticket, ...(c.repos || [])].join(" ").toLowerCase();
|
|
1553
|
+
return terms.every(t => hay.includes(t));
|
|
1554
|
+
}
|
|
1555
|
+
function queryTerms() { return (state.q || "").toLowerCase().split(/\s+/).filter(Boolean); }
|
|
1556
|
+
function shownCards(cards) {
|
|
1557
|
+
const inRepo = state.repo ? cards.filter(c => (c.repos || []).includes(state.repo)) : cards;
|
|
1558
|
+
const terms = queryTerms();
|
|
1559
|
+
const found = terms.length ? inRepo.filter(c => matches(c, terms)) : inRepo;
|
|
1560
|
+
if ($("qCount")) $("qCount").textContent = terms.length ? w(`${found.length} of ${inRepo.length} match`, `${found.length} of ${inRepo.length} quests`) : "";
|
|
1561
|
+
return found;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function projInfo(p) {
|
|
1565
|
+
const c = p.counts || {};
|
|
1566
|
+
return {missing: !!p.missing, doing: c.doing || 0, review: c.review || 0, blocked: c.blocked || 0, todo: c.todo || 0, done: c.done || 0,
|
|
1567
|
+
needs: p.review || 0, total: LANES.reduce((n, l) => n + (c[l] || 0), 0), stalled: oldestStalled(p.agents || []), today: (p.today || {}).usd_equiv || 0};
|
|
1568
|
+
}
|
|
1569
|
+
function projCard(p, info) {
|
|
1570
|
+
if (p.missing) return h("div", {class: "proj att-0"}, h("h3", {text: p.name}), h("p", {class: "cls", text: p.error ? `This board cannot be read (${p.error}). Run raid doctor in its folder.` : "The board folder is missing. Remove it with raid workspaces, or restore the folder."}));
|
|
1571
|
+
const head = h("div", {class: "top"}, h("h3", null, p.name, raid() && info.total ? h("span", {class: "lvl-chip", title: `Guild level ${levelOf(p.counts).level}, ${levelOf(p.counts).rank}`, text: `Lv ${levelOf(p.counts).level}`}) : null), h("span", {class: "act", text: p.last ? `active ${ago(p.last)} ago` : "no activity yet"}));
|
|
1572
|
+
const repos = (p.repos || []).length ? h("div", {class: "repos"}, p.repos.map(r => repoChip(r, false))) : null;
|
|
1573
|
+
if (!info.total) return h("a", {class: "proj att-3", href: href(p.name), "data-nav": p.name}, head, repos, h("div", {class: "start"}, "No cards yet. Add one from the lead terminal:", h("code", {text: 'raid --lead-secret "$LEAD_SECRET_FILE" new "First card" --team core --locks repo/path'})));
|
|
1574
|
+
const lbl = LANES.filter(l => p.counts[l]).map(l => `${p.counts[l]} ${LANE[l].toLowerCase()}`).join(", ");
|
|
1575
|
+
const bar = h("div", {class: "bar", role: "img", "aria-label": `Cards: ${lbl}`}, LANES.filter(l => p.counts[l]).map(l => { const i = h("i", {title: `${LANE[l]}: ${p.counts[l]}`, vars: {"--lc": `var(--${l})`}}); i.style.width = (100 * p.counts[l] / info.total) + "%"; return i; }));
|
|
1576
|
+
return h("a", {class: `proj att-${attention(info)}`, href: href(p.name), "data-nav": p.name}, head, repos, h("p", {class: "status-line"}, statusLine(info)), bar);
|
|
1577
|
+
}
|
|
1578
|
+
function renderOverview() {
|
|
1579
|
+
blockStatus("");
|
|
1580
|
+
const o = $("projects");
|
|
1581
|
+
const ps = state.projects;
|
|
1582
|
+
applyStatuses(ps.flatMap(p => p.agents || []), null);
|
|
1583
|
+
setKids($("overviewTitle"), [w("Workspaces", "The realm")], JSON.stringify([state.theme]));
|
|
1584
|
+
const live = ps.filter(p => attention(projInfo(p)) < 3).length;
|
|
1585
|
+
$("realmStatus").textContent = `${plural(ps.length, w("workspace", "guild"))} · ${live} active`;
|
|
1586
|
+
setKids($("hudLevel"), [levelBadge("Realm", sumCounts(ps))], JSON.stringify(["realm", state.theme, ps.map(p => p.counts)]));
|
|
1587
|
+
levelUp("realm", "Realm", sumCounts(ps), $("hudLevel"));
|
|
1588
|
+
if (!ps.length) { setKids(o, h("div", {class: "empty"}, "No boards yet. In a project folder, run ", h("code", {text: "raid quickstart"}), ", then reload this page."), "none"); $("idle").hidden = true; return; }
|
|
1589
|
+
const rows = ps.map(p => ({p, info: projInfo(p)})).map(r => ({...r, rank: attention(r.info)}));
|
|
1590
|
+
rows.sort((a, b) => a.rank - b.rank || (b.p.last || "").localeCompare(a.p.last || "") || a.p.name.localeCompare(b.p.name));
|
|
1591
|
+
const busy = rows.filter(r => r.rank < 3), idle = rows.filter(r => r.rank === 3);
|
|
1592
|
+
const sig = JSON.stringify([ps.map(p => [p.name, p.counts, p.repos, p.today, p.agents, p.last, p.missing, p.review]), state.theme, Math.floor(Date.now() / 30000)]);
|
|
1593
|
+
setKids(o, busy.length ? busy.map(r => projCard(r.p, r.info)) : [h("p", {class: "empty", text: w("Every workspace is quiet: nothing in progress, in review or blocked.", "Every guild rests: no quests underway, inspected or cursed.")})], sig);
|
|
1594
|
+
$("idle").hidden = !idle.length;
|
|
1595
|
+
setKids($("idle"), [h("span", {class: "lbl", text: `Quiet (${idle.length}): `}), ...idle.flatMap((r, i) => [i ? ", " : null,
|
|
1596
|
+
h("a", {href: href(r.p.name), "data-nav": r.p.name, title: r.p.last ? `active ${ago(r.p.last)} ago` : "no activity yet", text: r.p.name})])], sig);
|
|
1597
|
+
renderScore(state.overview.scorecard || [], state.overview.model_reputation || []);
|
|
1598
|
+
renderTreasury(state.overview.treasury, state.overview.forecast);
|
|
1599
|
+
}
|
|
1600
|
+
function chatEvents() {
|
|
1601
|
+
const first = state.seenChat === null;
|
|
1602
|
+
const seen = state.seenChat || new Set();
|
|
1603
|
+
for (const p of state.projects) for (const r of p.chat || []) {
|
|
1604
|
+
const k = p.name + ":" + r.id;
|
|
1605
|
+
if (seen.has(k)) continue;
|
|
1606
|
+
seen.add(k);
|
|
1607
|
+
if (first || state.view !== "*" || r.kind !== "msg") continue;
|
|
1608
|
+
toast(h("b", {text: r.from.agent}), " → ", h("b", {text: r.to || "?"}), ` (${p.name}): `, (r.text || "").slice(0, 140));
|
|
1609
|
+
play("sent");
|
|
1610
|
+
}
|
|
1611
|
+
state.seenChat = seen;
|
|
1612
|
+
}
|
|
1613
|
+
function workspaceChat() {
|
|
1614
|
+
const projects = state.projects.filter(p => state.view === "*" ? state.chatFilter === "*" || !state.chatFilter || p.name === state.chatFilter : p.name === state.view);
|
|
1615
|
+
const items = projects.flatMap(p => (p.chat || []).filter(r => CHAT_KINDS.has(r.kind)).map(r => ({...r, workspace: p.name})));
|
|
1616
|
+
if (state.view !== "*") items.push(...state.log.filter(r => CHAT_KINDS.has(r.kind)).map(r => ({...r, workspace: state.view})));
|
|
1617
|
+
return [...new Map(items.map(r => [r.workspace + ":" + r.id, r])).values()].sort((a, b) => (a.ts || "").localeCompare(b.ts || "") || a.id.localeCompare(b.id));
|
|
1618
|
+
}
|
|
1619
|
+
function unreadChat() {
|
|
1620
|
+
const raw = chatRead.get(state.view) || recall("rl.chat.read." + state.view) || "";
|
|
1621
|
+
let seen = {ts: typeof raw === "string" ? raw : "", ids: null};
|
|
1622
|
+
try { seen = typeof raw === "object" ? raw : raw.startsWith("{") ? JSON.parse(raw) : seen; } catch (_) {}
|
|
1623
|
+
return workspaceChat().filter(r => (r.ts || "") > (seen.ts || "") ||
|
|
1624
|
+
((r.ts || "") === seen.ts && Array.isArray(seen.ids) && !seen.ids.includes(r.workspace + ":" + r.id)));
|
|
1625
|
+
}
|
|
1626
|
+
function markChatRead() {
|
|
1627
|
+
if (!$("chatter").open || !$("chat").getClientRects().length || document.visibilityState === "hidden") return;
|
|
1628
|
+
const bounds = $("chat").getBoundingClientRect();
|
|
1629
|
+
if (bounds.bottom <= 0 || bounds.top >= innerHeight) return;
|
|
1630
|
+
const items = workspaceChat();
|
|
1631
|
+
const ts = items.at(-1)?.ts || "";
|
|
1632
|
+
const seen = {ts, ids: items.filter(r => r.ts === ts).map(r => r.workspace + ":" + r.id)};
|
|
1633
|
+
chatRead.set(state.view, seen);
|
|
1634
|
+
store("rl.chat.read." + state.view, JSON.stringify(seen));
|
|
1635
|
+
$("chatUnread").textContent = "0 unread";
|
|
1636
|
+
$("chatUnread").hidden = true;
|
|
1637
|
+
$("chat").querySelectorAll(".new-messages").forEach(e => e.remove());
|
|
1638
|
+
renderViewTabs();
|
|
1639
|
+
const tab = $("workTabs").querySelector('[data-tab="chatter"]');
|
|
1640
|
+
if (tab) tab.textContent = "Chatter (0)";
|
|
1641
|
+
}
|
|
1642
|
+
function renderChat() {
|
|
1643
|
+
const sel = $("chatFilter");
|
|
1644
|
+
const opts = [["*", "All workspaces"], ...state.projects.map(p => [p.name, p.name])];
|
|
1645
|
+
setKids(sel, opts.map(([v, l]) => h("option", {value: v, text: l})), JSON.stringify(opts));
|
|
1646
|
+
if (state.chatFilter === null) state.chatFilter = "*";
|
|
1647
|
+
sel.value = state.chatFilter;
|
|
1648
|
+
sel.closest(".panel-tools").hidden = state.view !== "*";
|
|
1649
|
+
const box = $("chat");
|
|
1650
|
+
const scroll = box.scrollTop;
|
|
1651
|
+
const terms = queryTerms();
|
|
1652
|
+
const top = workspaceChat().filter(r => !terms.length || terms.every(t => [r.text, r.task, r.from?.agent].join(" ").toLowerCase().includes(t))).slice(-CHAT_CAP).reverse();
|
|
1653
|
+
const unread = unreadChat();
|
|
1654
|
+
const unreadIds = new Set(unread.map(r => r.workspace + ":" + r.id));
|
|
1655
|
+
const firstRead = unread.length ? top.find(r => !unreadIds.has(r.workspace + ":" + r.id)) : null;
|
|
1656
|
+
const firstUnread = firstRead && top[0] !== firstRead ? firstRead.workspace + ":" + firstRead.id : null;
|
|
1657
|
+
$("chatUnread").textContent = `${unread.length} unread`;
|
|
1658
|
+
$("chatUnread").hidden = !unread.length;
|
|
1659
|
+
const content = top.length ? top.flatMap(r => [r.workspace + ":" + r.id === firstUnread ? h("div", {class: "new-messages", text: "Earlier messages"}) : null,
|
|
1660
|
+
h("div", {class: `msg k-${r.kind}`, "data-message": r.workspace + ":" + r.id}, h("div", {class: "ava", "aria-hidden": "true", text: avatar(r.from?.agent)}),
|
|
1661
|
+
h("div", {class: "bub"}, h("div", {class: "hd"}, h("b", {text: r.from?.agent || "agent"}),
|
|
1662
|
+
r.task ? h("a", {href: href(r.workspace) + "#card-" + encodeURIComponent(r.task), "data-card": r.task, text: r.task}) : null,
|
|
1663
|
+
h("span", {text: ago(r.ts) + " ago"})), h("div", {class: "tx", text: r.text || ""})))])
|
|
1664
|
+
: [h("div", {class: "empty", text: "Quiet for now. Agent messages and notes appear here."})];
|
|
1665
|
+
const sig = JSON.stringify([state.view, state.chatFilter, top, state.theme]);
|
|
1666
|
+
if (box.dataset.sig !== sig) {
|
|
1667
|
+
box.dataset.sig = sig;
|
|
1668
|
+
const previous = new Map([...box.querySelectorAll("[data-message]")].map(el => [el.dataset.message, el]));
|
|
1669
|
+
const nodes = content.filter(Boolean).map(el => previous.get(el.dataset.message) || el);
|
|
1670
|
+
for (const child of [...box.children]) if (!nodes.includes(child)) child.remove();
|
|
1671
|
+
nodes.forEach((el, i) => { if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null); });
|
|
1672
|
+
}
|
|
1673
|
+
box.scrollTop = scroll;
|
|
1674
|
+
markChatRead();
|
|
1675
|
+
}
|
|
1676
|
+
function blockStatus(message) {
|
|
1677
|
+
for (const el of document.querySelectorAll(".block")) {
|
|
1678
|
+
let line = el.querySelector(":scope > .block-status");
|
|
1679
|
+
if (!line) { line = h("p", {class: "block-status sub", role: "status"}); el.firstElementChild.after(line); }
|
|
1680
|
+
line.hidden = !message;
|
|
1681
|
+
line.textContent = message;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
function foldDefault(id, actionable) {
|
|
1685
|
+
const el = $(id), preference = recall("rl.open." + id);
|
|
1686
|
+
const open = preference === null ? actionable : preference === "true";
|
|
1687
|
+
if (el.dataset.foldApplied === String(open)) return;
|
|
1688
|
+
el.dataset.foldApplied = String(open);
|
|
1689
|
+
if (el.open !== open) { el.dataset.autoFold = String(open); el.open = open; }
|
|
1690
|
+
}
|
|
1691
|
+
function renderLayout(cards) {
|
|
1692
|
+
const active = document.activeElement;
|
|
1693
|
+
const focusTab = active?.dataset.tab;
|
|
1694
|
+
const counts = {needs: groups(cards).needs.length, todo: cards.filter(c => c.lane === "todo").length,
|
|
1695
|
+
doing: cards.filter(c => c.lane === "doing").length, done: cards.filter(c => c.lane === "done").length, chatter: unreadChat().length};
|
|
1696
|
+
const medium = matchMedia("(min-width: 768px) and (max-width: 1279px)").matches;
|
|
1697
|
+
if (medium && workTab !== "chatter") workTab = "todo";
|
|
1698
|
+
const labels = raid() ? {needs: "Raid leader\u2019s call", todo: "Quest board", doing: "On a quest", done: "Hall of legends", chatter: "Tavern chatter"} : {needs: "Needs you", todo: "To do", doing: "In progress", done: "Done", chatter: "Chatter"};
|
|
1699
|
+
setKids($("workTabs"), (medium ? ["todo"] : ["todo", "doing", "done"]).map(id => h("button", {type: "button", role: "tab", id: "tab-" + id, class: "tog", "data-tab": id,
|
|
1700
|
+
"aria-selected": String(workTab === id), tabindex: workTab === id ? "0" : "-1", "aria-controls": medium && id === "todo" ? "columns" : id === "done" ? "donePanel" : id, text: medium && id === "todo" ? w("Board", "Quest board") : `${labels[id]} (${counts[id]})`,
|
|
1701
|
+
onclick: () => { workTab = id; statusFilter = ""; renderLayout(cards); if (id === "chatter") { $("chatter").open = true; renderChat(); } }})));
|
|
1702
|
+
if (focusTab) $("workTabs").querySelector(`[data-tab="${focusTab}"]`)?.focus();
|
|
1703
|
+
$("focus").dataset.tab = workTab;
|
|
1704
|
+
$("focus").dataset.filter = statusFilter;
|
|
1705
|
+
document.body.dataset.tab = statusFilter;
|
|
1706
|
+
if (statusFilter === "treasury") $("overviewCosts").open = true;
|
|
1707
|
+
for (const id of ["todo", "doing", "donePanel", "chatter", "columns"]) {
|
|
1708
|
+
const el = $(id), tab = id === "donePanel" ? "done" : id === "columns" ? "todo" : id;
|
|
1709
|
+
const panel = matchMedia("(max-width: 1279px)").matches && (medium ? id === "columns" || id === "chatter" : id !== "columns");
|
|
1710
|
+
if (panel) { el.setAttribute("role", "tabpanel"); el.setAttribute("aria-labelledby", "tab-" + tab); }
|
|
1711
|
+
else { el.removeAttribute("role"); el.removeAttribute("aria-labelledby"); }
|
|
1712
|
+
}
|
|
1713
|
+
foldDefault("needs", true);
|
|
1714
|
+
for (const id of ["todo", "doing"]) foldDefault(id, true);
|
|
1715
|
+
foldDefault("donePanel", false);
|
|
1716
|
+
foldDefault("agentsPanel", false);
|
|
1717
|
+
foldDefault("logPanel", false);
|
|
1718
|
+
foldDefault("chatter", true);
|
|
1719
|
+
setKids($("activityChips"), ["all", "done", "log"].map(id => h("button", {type: "button", class: "tog", "aria-pressed": String(activityFilter === id),
|
|
1720
|
+
text: raid() ? {all: "All", done: "Quests won", log: "Deeds"}[id] : {all: "All", done: "Done", log: "Events"}[id], onclick: () => { activityFilter = id; renderLog(true); renderLayout(cards); }})));
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function panelTitles() {
|
|
1724
|
+
const t = (id, text, key) => setKids($(id), raid() && RAID[key] ? [RAID[key], h("small", {class: "raid-sub", text})] : [text], state.theme);
|
|
1725
|
+
t("agentsTitle", "Team", "agents");
|
|
1726
|
+
t("costTitle", "Cost", "cost");
|
|
1727
|
+
if ($("ovCostTitle")) t("ovCostTitle", "Cost", "cost");
|
|
1728
|
+
t("logTitle", "Activity", "log");
|
|
1729
|
+
t("chatTitle", "Chatter", "chat");
|
|
1730
|
+
}
|
|
1731
|
+
function applyTheme() {
|
|
1732
|
+
document.documentElement.setAttribute("data-theme", state.theme);
|
|
1733
|
+
const svgIcon = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
|
|
1734
|
+
if (svgIcon) svgIcon.href = state.theme === "raid" ? "/static/icon-raid.svg" : "/static/icon.svg";
|
|
1735
|
+
const pngIcon = document.querySelector('link[rel="icon"][type="image/png"]');
|
|
1736
|
+
if (pngIcon) pngIcon.media = state.theme === "raid" ? "not all" : "all";
|
|
1737
|
+
panelTitles();
|
|
1738
|
+
for (const el of document.querySelectorAll("[data-sig]")) el.dataset.sig = "";
|
|
1739
|
+
stars();
|
|
1740
|
+
}
|
|
1741
|
+
function applyView() {
|
|
1742
|
+
const all = state.view === "*";
|
|
1743
|
+
document.body.dataset.page = all ? "realm" : "guild";
|
|
1744
|
+
$("overview").hidden = !all;
|
|
1745
|
+
$("content").prepend($("tabs"));
|
|
1746
|
+
$("board").hidden = all;
|
|
1747
|
+
$("chatter").hidden = false;
|
|
1748
|
+
if (all) $("panels").append($("chatter"), $("agentsPanel"), $("logPanel"));
|
|
1749
|
+
else { $("sidebar").append($("chatter"), $("agentsPanel")); $("panels").append($("overviewCosts"), $("logPanel")); }
|
|
1750
|
+
$("overviewCosts").hidden = false;
|
|
1751
|
+
if (!$("scoreHead")) $("score").before(h("h3", {id: "scoreHead", class: "merged-head", text: "Results by model"}));
|
|
1752
|
+
$("overviewCosts").append($("costPanel"), $("scoreHead"), $("score"));
|
|
1753
|
+
const costSub = $("overviewCosts").querySelector(".sub");
|
|
1754
|
+
if (costSub) costSub.textContent = all ? "Spend, forecast and results by model, across all workspaces." : "Spend, forecast and results by model.";
|
|
1755
|
+
$("export").hidden = all;
|
|
1756
|
+
if (!all) { $("export").href = api("snapshot", state.view); $("export").setAttribute("download", `raidlock-${state.view}.html`); }
|
|
1757
|
+
document.title = all ? "raidlock" : `${state.view} · raidlock`;
|
|
1758
|
+
document.body.dataset.tab = statusFilter;
|
|
1759
|
+
if (statusFilter === "chatter") $("chatter").open = true;
|
|
1760
|
+
if (statusFilter === "treasury") $("overviewCosts").open = true;
|
|
1761
|
+
applySub();
|
|
1762
|
+
$("notice").hidden = !state.unknown;
|
|
1763
|
+
$("notice").textContent = state.unknown ? `There is no workspace called "${state.unknown}". Showing all workspaces instead.` : "";
|
|
1764
|
+
renderTabs();
|
|
1765
|
+
renderCrumbs();
|
|
1766
|
+
}
|
|
1767
|
+
function route() {
|
|
1768
|
+
const seg = location.pathname.split("/").filter(Boolean)[0];
|
|
1769
|
+
try { return seg ? decodeURIComponent(seg) : "*"; } catch (_) { return seg; }
|
|
1770
|
+
}
|
|
1771
|
+
function select(id) {
|
|
1772
|
+
if (id === state.view) return;
|
|
1773
|
+
$("cardDrawer").close();
|
|
1774
|
+
state.log = [];
|
|
1775
|
+
state.view = id; state.board = null; state.cards = {}; state.first = true; state.statusPrev = {}; state.open.clear(); state.msgPrev = null; state.repo = ""; state.q = ""; if ($("q")) $("q").value = ""; statusFilter = ""; workTab = "todo";
|
|
1776
|
+
state.details = {}; state.laneLimit = {}; state.todoAll = false; state.needsAll = false; state.doneLimit = 5; state.unknown = ""; state.logFilter = "*"; state.subData = {}; state.sub = subRoute();
|
|
1777
|
+
state.fxPrev = null; state.fxPulse = new Map(); state.fxView = {};
|
|
1778
|
+
for (const k of Object.keys(state.etag)) if (!k.startsWith("o")) delete state.etag[k];
|
|
1779
|
+
for (const el of document.querySelectorAll("[data-sig]")) el.dataset.sig = "";
|
|
1780
|
+
applyView();
|
|
1781
|
+
blockStatus("Loading… Board data will appear shortly.");
|
|
1782
|
+
tick();
|
|
1783
|
+
}
|
|
1784
|
+
function onNav(e) {
|
|
1785
|
+
const a = e.target.closest && e.target.closest("a[data-nav]");
|
|
1786
|
+
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
1787
|
+
e.preventDefault();
|
|
1788
|
+
const id = a.dataset.nav;
|
|
1789
|
+
if (id === state.view) return;
|
|
1790
|
+
history.pushState(null, "", a.getAttribute("href"));
|
|
1791
|
+
select(id);
|
|
1792
|
+
scrollTo(0, 0);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
async function pollOverview(force) {
|
|
1796
|
+
const d = await getJSON("/api/overview", "o");
|
|
1797
|
+
if (!d && !force) return false;
|
|
1798
|
+
if (d) { state.overview = d; state.mode = d.mode; state.projects = d.projects || []; }
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
async function tick() {
|
|
1802
|
+
if (state.busy) return;
|
|
1803
|
+
state.busy = true;
|
|
1804
|
+
try {
|
|
1805
|
+
const want = state.view;
|
|
1806
|
+
const needOverview = want === "*" || state.ticks % OVERVIEW_EVERY === 0 || !state.overview;
|
|
1807
|
+
state.ticks++;
|
|
1808
|
+
let changed = false;
|
|
1809
|
+
if (needOverview) changed = await pollOverview(false);
|
|
1810
|
+
if (!state.overview) return;
|
|
1811
|
+
const wasError = !!state.error;
|
|
1812
|
+
state.error = "";
|
|
1813
|
+
if (state.view === "*") state.loaded = true;
|
|
1814
|
+
const names = state.projects.map(p => p.name);
|
|
1815
|
+
if (state.mode !== "all" && !names.includes(state.view) && names.length) { state.view = names[0]; applyView(); }
|
|
1816
|
+
else if (state.view !== "*" && !names.includes(state.view)) { state.unknown = state.view; state.view = "*"; applyView(); }
|
|
1817
|
+
if (changed || wasError) { renderTabs(); renderCrumbs(); chatEvents(); renderLimits(state.overview.limits); }
|
|
1818
|
+
if (state.view === "*") { if (changed || state.first || wasError) { renderOverview(); renderChat(); state.first = false; } return; }
|
|
1819
|
+
const ws = state.view;
|
|
1820
|
+
const b = await getJSON(api("board", ws), "b:" + ws);
|
|
1821
|
+
if (b && ws === state.view) { renderBoard(b); if (wasError || !$("crumbs").querySelector("[aria-current]")) renderCrumbs(); }
|
|
1822
|
+
await pollLog(ws);
|
|
1823
|
+
if (state.sub !== "focus" && state.sub !== "all") await loadSub(false);
|
|
1824
|
+
} catch (e) {
|
|
1825
|
+
state.error = e && e.status ? `This board could not be loaded (HTTP ${e.status}). It retries every few seconds; if it keeps failing, run raid doctor.`
|
|
1826
|
+
: "The board server is not answering. Start it again with: raid ui";
|
|
1827
|
+
renderCrumbs();
|
|
1828
|
+
blockStatus("Updates unavailable. Showing the last data; retrying automatically. Run raid ui if this persists.");
|
|
1829
|
+
} finally { state.busy = false; }
|
|
1830
|
+
}
|
|
1831
|
+
let starFrame = 0;
|
|
1832
|
+
function stars() {
|
|
1833
|
+
const c = $("stars");
|
|
1834
|
+
cancelAnimationFrame(starFrame);
|
|
1835
|
+
const x = c.getContext("2d");
|
|
1836
|
+
if (state.theme !== "raid") { x.clearRect(0, 0, c.width, c.height); return; }
|
|
1837
|
+
let W2, H2, S = [], M = [], shoot = null;
|
|
1838
|
+
const rs = () => {
|
|
1839
|
+
W2 = c.width = innerWidth; H2 = c.height = innerHeight;
|
|
1840
|
+
S = Array.from({length: Math.min(380, W2 * H2 / 4500 | 0)}, () => ({x: Math.random() * W2, y: Math.random() * H2, r: Math.random() * 1.4 + .2, p: Math.random() * 6.28, s: .6 + Math.random() * 1.6}));
|
|
1841
|
+
M = Array.from({length: Math.min(36, W2 / 40 | 0)}, () => ({x: Math.random() * W2, y: Math.random() * H2, r: 1 + Math.random() * 1.8, v: .5 + Math.random(), p: Math.random() * 6.28}));
|
|
1842
|
+
};
|
|
1843
|
+
rs();
|
|
1844
|
+
window.onresize = () => { rs(); if (!motion()) still(); };
|
|
1845
|
+
const still = () => { x.clearRect(0, 0, W2, H2); for (const s of S) { x.globalAlpha = .6; x.fillStyle = "#fff6dc"; x.beginPath(); x.arc(s.x, s.y, s.r, 0, 6.28); x.fill(); } x.globalAlpha = 1; };
|
|
1846
|
+
if (!motion()) { still(); return; }
|
|
1847
|
+
let lastDraw = 0;
|
|
1848
|
+
const f = t => {
|
|
1849
|
+
if (!motion()) return;
|
|
1850
|
+
starFrame = requestAnimationFrame(f);
|
|
1851
|
+
if (t - lastDraw < 40) return;
|
|
1852
|
+
lastDraw = t;
|
|
1853
|
+
x.clearRect(0, 0, W2, H2);
|
|
1854
|
+
for (const s of S) { x.globalAlpha = .35 + .65 * Math.abs(Math.sin(s.p + t / 1000 * s.s)); x.fillStyle = "#fff6dc"; x.beginPath(); x.arc(s.x, s.y, s.r, 0, 6.28); x.fill(); }
|
|
1855
|
+
const wind = .6 + .9 * Math.max(0, Math.sin(t / 5200)) + .3 * Math.sin(t / 1700);
|
|
1856
|
+
for (const m of M) {
|
|
1857
|
+
m.x += wind * m.v * 1.6; m.y += Math.sin(m.p + t / 900) * .45 - .15;
|
|
1858
|
+
if (m.x > W2 + 10) { m.x = -10; m.y = Math.random() * H2; }
|
|
1859
|
+
if (m.y < -10) m.y = H2 + 10;
|
|
1860
|
+
x.globalAlpha = .35 + .4 * Math.abs(Math.sin(m.p + t / 700)); x.fillStyle = "#ffcf7a";
|
|
1861
|
+
x.shadowColor = "#ffb347"; x.shadowBlur = 6; x.beginPath(); x.arc(m.x, m.y, m.r, 0, 6.28); x.fill(); x.shadowBlur = 0;
|
|
1862
|
+
}
|
|
1863
|
+
if (!shoot && Math.random() < .008) shoot = {x: Math.random() * W2 * .7, y: Math.random() * H2 * .3, l: 0};
|
|
1864
|
+
if (shoot) { x.globalAlpha = 1 - shoot.l / 40; x.strokeStyle = "#ffe9a8"; x.lineWidth = 2; x.beginPath(); x.moveTo(shoot.x, shoot.y); x.lineTo(shoot.x - 40, shoot.y - 14); x.stroke(); shoot.x += 14; shoot.y += 4.7; if (++shoot.l > 40) shoot = null; }
|
|
1865
|
+
x.globalAlpha = 1;
|
|
1866
|
+
};
|
|
1867
|
+
starFrame = requestAnimationFrame(f);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function revealCard(id) {
|
|
1871
|
+
if (state.sub !== "focus") goSub("focus");
|
|
1872
|
+
const c = state.cards[id];
|
|
1873
|
+
if (!c) return;
|
|
1874
|
+
workTab = c.lane === "done" ? "done" : ["todo", "doing"].includes(c.lane) ? c.lane : "todo";
|
|
1875
|
+
statusFilter = "";
|
|
1876
|
+
if (["todo", "doing", "done"].includes(c.lane)) {
|
|
1877
|
+
const panel = c.lane === "done" ? "donePanel" : c.lane;
|
|
1878
|
+
store("rl.open." + panel, true);
|
|
1879
|
+
$(panel).open = true;
|
|
1880
|
+
}
|
|
1881
|
+
state.todoAll = true;
|
|
1882
|
+
state.doneLimit = Number.MAX_SAFE_INTEGER;
|
|
1883
|
+
state.laneLimit.doingFocus = Number.MAX_SAFE_INTEGER;
|
|
1884
|
+
rerender();
|
|
1885
|
+
const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`);
|
|
1886
|
+
if (el) { el.closest("details")?.setAttribute("open", ""); el.scrollIntoView({block: "center"}); openRow(el, true); }
|
|
1887
|
+
}
|
|
1888
|
+
function onAction(e) {
|
|
1889
|
+
const t = e.target.closest ? e.target : null;
|
|
1890
|
+
if (!t) return;
|
|
1891
|
+
const link = t.closest("a[data-card]");
|
|
1892
|
+
if (link && state.cards[link.dataset.card]) {
|
|
1893
|
+
e.preventDefault();
|
|
1894
|
+
revealCard(link.dataset.card);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
const primary = t.closest("button[data-cmd]");
|
|
1898
|
+
if (primary) { copyCommand(primary, primary.dataset.cmd, primary.dataset.label); return; }
|
|
1899
|
+
const copy = t.closest("button[data-copy]");
|
|
1900
|
+
if (copy) { copyCommand(copy, copy.dataset.copy, "Copy"); return; }
|
|
1901
|
+
const det = t.closest("button.details");
|
|
1902
|
+
if (det) { openRow(det.closest(".row")); return; }
|
|
1903
|
+
if (t.closest("#todoMore")) { state.todoAll = !state.todoAll; rerender(); return; }
|
|
1904
|
+
if (t.closest("#needsMore")) { state.needsAll = !state.needsAll; rerender(); return; }
|
|
1905
|
+
if (t.closest("#idleMore")) { state.idleAll = !state.idleAll; $("agents").dataset.sig = ""; rerender(); return; }
|
|
1906
|
+
if (t.closest("#doneMore")) { state.doneLimit = Number.MAX_SAFE_INTEGER; rerender(); return; }
|
|
1907
|
+
const more = t.closest("button.lane-more");
|
|
1908
|
+
if (more) { const l = more.dataset.lane; state.laneLimit[l] = (state.laneLimit[l] || (l === "done" || l === "cancelled" ? 10 : LANE_PAGE)) + LANE_PAGE; rerender(); }
|
|
1909
|
+
}
|
|
1910
|
+
function quote(value) { return "'" + String(value).replaceAll("'", "'\"'\"'") + "'"; }
|
|
1911
|
+
function init() {
|
|
1912
|
+
$("content").append(h("dialog", {id: "cardDrawer", "aria-labelledby": "drawerTitle"},
|
|
1913
|
+
h("button", {type: "button", class: "tog", id: "drawerClose", text: "Close details", onclick: closeDrawer}),
|
|
1914
|
+
h("h2", {id: "drawerTitle"}), h("div", {id: "drawerBody"})));
|
|
1915
|
+
for (const ev of ["cancel", "close"]) $("cardDrawer").addEventListener(ev, closeDrawer);
|
|
1916
|
+
$("panels").append(h("details", {id: "overviewCosts", class: "block fold"}, h("summary", null, h("h2", {id: "ovCostTitle", text: "Cost"}), h("span", {class: "sub", text: "Usage across all workspaces."}))));
|
|
1917
|
+
$("columns").append($("donePanel"));
|
|
1918
|
+
$("focus").append(h("aside", {id: "sidebar", "aria-label": "Team communication"}));
|
|
1919
|
+
$("sidebar").append($("chatter"), $("agentsPanel"));
|
|
1920
|
+
$("content").prepend($("tabs"));
|
|
1921
|
+
for (const [old, current] of [["waiting", "review"], ["stuck", "blocked"], ["decisions", "needs"]]) {
|
|
1922
|
+
$("needs").querySelector("summary").prepend(h("span", {id: old}));
|
|
1923
|
+
if (current !== "needs") $("needs").querySelector("summary").prepend(h("span", {id: current}));
|
|
1924
|
+
}
|
|
1925
|
+
$("workTabs").setAttribute("role", "tablist");
|
|
1926
|
+
$("workTabs").addEventListener("keydown", e => {
|
|
1927
|
+
if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(e.key)) return;
|
|
1928
|
+
const tabs = [...$("workTabs").querySelectorAll("button")].filter(b => b.offsetWidth);
|
|
1929
|
+
const i = tabs.indexOf(e.target);
|
|
1930
|
+
const next = e.key === "Home" ? 0 : e.key === "End" ? tabs.length - 1 : (i + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
|
1931
|
+
e.preventDefault();
|
|
1932
|
+
const id = tabs[next].dataset.tab;
|
|
1933
|
+
tabs[next].click();
|
|
1934
|
+
$("workTabs").querySelector(`[data-tab="${id}"]`).focus();
|
|
1935
|
+
});
|
|
1936
|
+
addEventListener("resize", () => { if (state.board) renderLayout(shownCards(state.board.cards || [])); });
|
|
1937
|
+
addEventListener("scroll", () => {
|
|
1938
|
+
const box = $("chat");
|
|
1939
|
+
if (box.scrollHeight - box.scrollTop - box.clientHeight < 32) markChatRead();
|
|
1940
|
+
}, {passive: true});
|
|
1941
|
+
$("chat").addEventListener("scroll", () => {
|
|
1942
|
+
const box = $("chat");
|
|
1943
|
+
if (box.scrollHeight - box.scrollTop - box.clientHeight < 32) markChatRead();
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
state.theme = recall("rl.theme") === "plain" ? "plain" : "raid";
|
|
1947
|
+
state.sound = recall("rl.sound") !== "false";
|
|
1948
|
+
state.notify = recall("rl.notify") === "true" && "Notification" in window && Notification.permission === "granted";
|
|
1949
|
+
state.treasuryBy = recall("rl.treasuryBy") || "by_model";
|
|
1950
|
+
state.achievements = recall("rl.achievements") !== "false";
|
|
1951
|
+
state.anim = recall("rl.anim") !== "false";
|
|
1952
|
+
updateAchieve();
|
|
1953
|
+
updateAnim();
|
|
1954
|
+
const v = parseInt(recall("rl.vol"), 10);
|
|
1955
|
+
if (!isNaN(v)) state.vol = Math.max(0, Math.min(100, v));
|
|
1956
|
+
$("vol").value = state.vol;
|
|
1957
|
+
try { for (const n of JSON.parse(recall("rl.cuesOff") || "[]")) if (CUE_NAMES.has(n)) state.muted.add(n); } catch (_) {}
|
|
1958
|
+
buildCues();
|
|
1959
|
+
for (const id of PANELS) { const s = recall("rl.open." + id); if (s !== null) $(id).open = s === "true"; }
|
|
1960
|
+
foldDefault("chatter", true);
|
|
1961
|
+
$("sound").addEventListener("click", () => { state.sound = !state.sound; store("rl.sound", state.sound); updateSound(); preview("delivered"); });
|
|
1962
|
+
$("vol").addEventListener("input", e => { setVol(+e.target.value); store("rl.vol", e.target.value); });
|
|
1963
|
+
$("vol").addEventListener("change", () => preview("delivered"));
|
|
1964
|
+
$("cueList").addEventListener("change", e => {
|
|
1965
|
+
const n = e.target.dataset && e.target.dataset.cue;
|
|
1966
|
+
if (!n) return;
|
|
1967
|
+
e.target.checked ? state.muted.delete(n) : state.muted.add(n);
|
|
1968
|
+
store("rl.cuesOff", JSON.stringify([...state.muted]));
|
|
1969
|
+
if (e.target.checked) preview(n);
|
|
1970
|
+
});
|
|
1971
|
+
$("cueList").addEventListener("click", e => { const b = e.target.closest("[data-try]"); if (b) preview(b.dataset.try); });
|
|
1972
|
+
$("notify").addEventListener("click", async () => {
|
|
1973
|
+
if (!("Notification" in window)) return;
|
|
1974
|
+
if (!state.notify && Notification.permission !== "granted") { try { await Notification.requestPermission(); } catch (_) {} }
|
|
1975
|
+
state.notify = !state.notify && Notification.permission === "granted";
|
|
1976
|
+
store("rl.notify", state.notify);
|
|
1977
|
+
updateNotify();
|
|
1978
|
+
});
|
|
1979
|
+
$("chatFilter").addEventListener("change", e => { state.chatFilter = e.target.value; renderChat(); });
|
|
1980
|
+
$("logFilter").addEventListener("change", e => { state.logFilter = e.target.value; $("log").scrollTop = 0; renderLog(true); });
|
|
1981
|
+
for (const id of PANELS) $(id).addEventListener("toggle", e => {
|
|
1982
|
+
const automatic = e.target.dataset.autoFold === String(e.target.open);
|
|
1983
|
+
delete e.target.dataset.autoFold;
|
|
1984
|
+
if (!automatic) store("rl.open." + id, e.target.open);
|
|
1985
|
+
if (id === "chatter" && e.target.open) renderChat();
|
|
1986
|
+
if (id === "logPanel") renderLog(true);
|
|
1987
|
+
else if (e.target.open && state.board) rerender();
|
|
1988
|
+
});
|
|
1989
|
+
$("log").addEventListener("scroll", () => renderLog(false), {passive: true});
|
|
1990
|
+
$("log").addEventListener("click", e => {
|
|
1991
|
+
const b = e.target.closest(".lrow");
|
|
1992
|
+
if (!b) return;
|
|
1993
|
+
const r = logRows()[+b.dataset.i];
|
|
1994
|
+
state.logSel = r && state.logSel !== r.id ? r.id : null;
|
|
1995
|
+
showLogDetail(state.logSel ? r : null);
|
|
1996
|
+
renderLog(true);
|
|
1997
|
+
});
|
|
1998
|
+
let qTimer = 0;
|
|
1999
|
+
$("q").addEventListener("input", () => {
|
|
2000
|
+
clearTimeout(qTimer);
|
|
2001
|
+
qTimer = setTimeout(() => {
|
|
2002
|
+
state.q = $("q").value;
|
|
2003
|
+
state.laneLimit = {};
|
|
2004
|
+
state.needsAll = !!state.q.trim();
|
|
2005
|
+
if (state.q.trim() && !["focus", "all"].includes(state.sub)) goSub("all"); else rerender();
|
|
2006
|
+
if (statusFilter === "chatter") renderChat();
|
|
2007
|
+
}, 120);
|
|
2008
|
+
});
|
|
2009
|
+
$("q").addEventListener("keydown", e => {
|
|
2010
|
+
if (e.key === "Escape" && $("q").value) { e.stopPropagation(); $("q").value = ""; $("q").dispatchEvent(new Event("input")); }
|
|
2011
|
+
});
|
|
2012
|
+
document.addEventListener("keydown", e => {
|
|
2013
|
+
if (e.key !== "?" || e.metaKey || e.ctrlKey || e.altKey || e.target.closest("input, textarea, select, [contenteditable=true], dialog[open]")) return;
|
|
2014
|
+
e.preventDefault();
|
|
2015
|
+
if (!$("kbdHelp").open) $("kbdHelp").showModal();
|
|
2016
|
+
});
|
|
2017
|
+
document.addEventListener("keydown", e => {
|
|
2018
|
+
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || $("board").hidden) return;
|
|
2019
|
+
const t = e.target;
|
|
2020
|
+
if (t.closest("input, textarea, select, [contenteditable=true], dialog[open]")) return;
|
|
2021
|
+
e.preventDefault();
|
|
2022
|
+
$("q").focus();
|
|
2023
|
+
$("q").select();
|
|
2024
|
+
});
|
|
2025
|
+
$("repos").addEventListener("click", e => {
|
|
2026
|
+
const b = e.target.closest("button[data-repo]");
|
|
2027
|
+
if (!b) return;
|
|
2028
|
+
state.repo = b.dataset.repo;
|
|
2029
|
+
state.laneLimit = {};
|
|
2030
|
+
rerender();
|
|
2031
|
+
});
|
|
2032
|
+
$("content").addEventListener("click", onAction);
|
|
2033
|
+
RMQ.addEventListener("change", e => { RM = e.matches; stars(); fxDecorate(); });
|
|
2034
|
+
$("animBtn").addEventListener("click", () => { state.anim = !state.anim; store("rl.anim", state.anim); updateAnim(); stars(); fxDecorate(); });
|
|
2035
|
+
addEventListener("pointerdown", unlock, {capture: true});
|
|
2036
|
+
addEventListener("keydown", unlock, {capture: true});
|
|
2037
|
+
$("soundBanner").addEventListener("click", unlock);
|
|
2038
|
+
addEventListener("click", onNav);
|
|
2039
|
+
addEventListener("popstate", () => {
|
|
2040
|
+
const sub = subRoute();
|
|
2041
|
+
select(route());
|
|
2042
|
+
if (sub !== state.sub) { state.sub = sub; applySub(); if (sub === "focus" || sub === "all") rerender(); else loadSub(true); }
|
|
2043
|
+
});
|
|
2044
|
+
const onTab = e => {
|
|
2045
|
+
const a = e.target.closest("a[data-sub]");
|
|
2046
|
+
if (!a || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
2047
|
+
e.preventDefault();
|
|
2048
|
+
const want = FILTER_TABS[a.dataset.sub] || "";
|
|
2049
|
+
const leaving = Object.values(FILTER_TABS).includes(statusFilter);
|
|
2050
|
+
if (want || leaving) { statusFilter = want; workTab = want === "chatter" ? "chatter" : "todo"; }
|
|
2051
|
+
const target = want ? "focus" : a.dataset.sub;
|
|
2052
|
+
if (target !== state.sub) goSub(target); else rerender();
|
|
2053
|
+
if (want || (leaving && target === "focus")) history.replaceState(null, "", subHref(a.dataset.sub));
|
|
2054
|
+
renderViewTabs();
|
|
2055
|
+
if (want === "chatter") { $("chatter").open = true; renderChat(); }
|
|
2056
|
+
if (want === "treasury") $("overviewCosts").open = true;
|
|
2057
|
+
document.body.dataset.tab = statusFilter;
|
|
2058
|
+
};
|
|
2059
|
+
$("viewTabs").addEventListener("click", onTab);
|
|
2060
|
+
$("realmTabs").addEventListener("click", onTab);
|
|
2061
|
+
$("achieveBtn").addEventListener("click", () => {
|
|
2062
|
+
state.achievements = !state.achievements;
|
|
2063
|
+
store("rl.achievements", state.achievements);
|
|
2064
|
+
updateAchieve();
|
|
2065
|
+
if (!state.achievements && state.sub === "achievements") goSub("focus");
|
|
2066
|
+
else renderViewTabs();
|
|
2067
|
+
});
|
|
2068
|
+
const menus = () => [...document.querySelectorAll("details.menu, details.more-menu")];
|
|
2069
|
+
document.addEventListener("toggle", e => {
|
|
2070
|
+
const m = e.target;
|
|
2071
|
+
if (!m.matches || !m.matches("details.menu, details.more-menu") || !m.open) return;
|
|
2072
|
+
if (m.matches(".more-menu")) fillMore(m);
|
|
2073
|
+
for (const o of menus()) if (o !== m && o.open) o.open = false;
|
|
2074
|
+
}, true);
|
|
2075
|
+
document.addEventListener("click", e => { for (const m of menus()) if (m.open && !m.contains(e.target)) m.open = false; });
|
|
2076
|
+
document.addEventListener("keydown", e => {
|
|
2077
|
+
if (e.key !== "Escape") return;
|
|
2078
|
+
for (const m of menus()) if (m.open) { const inside = m.contains(document.activeElement); m.open = false; if (inside) m.querySelector("summary").focus(); }
|
|
2079
|
+
});
|
|
2080
|
+
audio();
|
|
2081
|
+
updateSound();
|
|
2082
|
+
updateNotify();
|
|
2083
|
+
applyTheme();
|
|
2084
|
+
state.view = route();
|
|
2085
|
+
state.sub = subRoute();
|
|
2086
|
+
applyView();
|
|
2087
|
+
blockStatus("Loading… Board data will appear shortly.");
|
|
2088
|
+
tick();
|
|
2089
|
+
setInterval(tick, POLL_MS);
|
|
2090
|
+
setInterval(statusClock, 30000);
|
|
2091
|
+
}
|
|
2092
|
+
function rerender() {
|
|
2093
|
+
if (state.view === "*") { if (state.overview) { renderOverview(); renderChat(); } return; }
|
|
2094
|
+
if (state.board) {
|
|
2095
|
+
const wasFirst = state.first; state.first = true; renderBoard(state.board); state.first = wasFirst;
|
|
2096
|
+
renderLog(true);
|
|
2097
|
+
renderViewTabs();
|
|
2098
|
+
drawSub();
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
init();
|
|
2102
|
+
})();
|