@cotal-ai/cli 0.1.2 → 0.2.0-next-20260612020133
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/feedback.d.ts +3 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +353 -0
- package/dist/commands/feedback.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/web/app.js +696 -0
- package/dist/web/index.html +298 -0
- package/package.json +2 -2
package/dist/web/app.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// Cotal observability client: a read-only god-view of one space. Presence + channel
|
|
2
|
+
// list + DM history come over HTTP; the live stream (roster + every chat/unicast/anycast
|
|
3
|
+
// message) arrives via SSE (/feed). This page never publishes to the mesh.
|
|
4
|
+
//
|
|
5
|
+
// Three centre views, one consistent skeleton (left = navigation, centre = content,
|
|
6
|
+
// right = NEEDS YOU, always): the Monitor (all-activity feed), a Channel view (message
|
|
7
|
+
// list; members fold into the header), and a Direct-messages lens (per-peer roll-up in
|
|
8
|
+
// the sidebar → a thread in the centre). `?demo` renders the fixed reference scene.
|
|
9
|
+
|
|
10
|
+
const $ = (id) => document.getElementById(id);
|
|
11
|
+
const STATUS = ["working", "waiting", "idle", "offline"];
|
|
12
|
+
// Status as shape *and* colour (never colour alone) — see research/multi-agent-ux.md.
|
|
13
|
+
const GLYPH = { working: "●", waiting: "◐", idle: "○", offline: "⊘" };
|
|
14
|
+
const MODES = ["chat", "unicast", "anycast"];
|
|
15
|
+
const isDemo = new URLSearchParams(location.search).has("demo");
|
|
16
|
+
|
|
17
|
+
let roster = [];
|
|
18
|
+
let channels = new Map(); // name -> total message count
|
|
19
|
+
let unread = new Map(); // name -> messages seen since last viewed
|
|
20
|
+
let dms = []; // raw DM messages (god-view), grouped client-side
|
|
21
|
+
let selected = "*"; // "*" = all activity, else a channel name (null when a DM is open)
|
|
22
|
+
let dmSel = null; // { peer, with } when a Direct-messages thread is open
|
|
23
|
+
let agentSel = null; // peer id when an Agent Detail drill-down is open (else selected/dmSel drive the view)
|
|
24
|
+
let activity = []; // {mode, msg} ring buffer for the all-activity view
|
|
25
|
+
let channelMsgs = []; // messages for the selected channel
|
|
26
|
+
let modes = new Set(MODES); // delivery modes currently shown
|
|
27
|
+
let paused = false; // freeze auto-scroll so a value can be read
|
|
28
|
+
|
|
29
|
+
const esc = (s) =>
|
|
30
|
+
String(s).replace(/[&<>]/g, (ch) => ({ "&": "&", "<": "<", ">": ">" })[ch]);
|
|
31
|
+
const time = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
32
|
+
const bodyText = (msg) =>
|
|
33
|
+
(msg.parts || []).map((p) => (p.kind === "text" ? p.text : JSON.stringify(p.data))).join(" ");
|
|
34
|
+
function ago(ts) {
|
|
35
|
+
const s = Math.max(0, (Date.now() - ts) / 1000);
|
|
36
|
+
if (s < 45) return "just now";
|
|
37
|
+
if (s < 3600) return `${Math.round(s / 60)}m`;
|
|
38
|
+
if (s < 86400) return `${Math.round(s / 3600)}h`;
|
|
39
|
+
return `${Math.round(s / 86400)}d`;
|
|
40
|
+
}
|
|
41
|
+
const agoShort = (ts) => (ago(ts) === "just now" ? "now" : ago(ts));
|
|
42
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
43
|
+
|
|
44
|
+
function setConn(live) {
|
|
45
|
+
const el = $("conn");
|
|
46
|
+
el.className = "pill" + (live ? "" : " down");
|
|
47
|
+
el.querySelector(".t").textContent = live ? "live" : "disconnected";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Header: golden-signal tiles ───────────────────────────────────────────────
|
|
51
|
+
function renderTiles(counts, oldest) {
|
|
52
|
+
const tiles = [
|
|
53
|
+
["working", counts.working],
|
|
54
|
+
["waiting", counts.waiting],
|
|
55
|
+
["idle", counts.idle],
|
|
56
|
+
["offline", counts.offline],
|
|
57
|
+
["oldest", oldest, "oldest unattended"],
|
|
58
|
+
];
|
|
59
|
+
$("tiles").innerHTML = tiles
|
|
60
|
+
.map(
|
|
61
|
+
([k, n, lbl]) => `<div class="tile ${k}">
|
|
62
|
+
<span class="bar"></span>
|
|
63
|
+
<div class="c"><span class="n">${n}</span><span class="lbl">${lbl ?? k}</span></div>
|
|
64
|
+
</div>`,
|
|
65
|
+
)
|
|
66
|
+
.join("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Sidebar: roster ───────────────────────────────────────────────────────────
|
|
70
|
+
function peerRow(p) {
|
|
71
|
+
return `<div class="peer ${p.status}">
|
|
72
|
+
<span class="dot ${p.status}">${GLYPH[p.status] ?? "○"}</span>
|
|
73
|
+
<div class="c">
|
|
74
|
+
<div class="l1">
|
|
75
|
+
<span class="name">${esc(p.name)}</span>
|
|
76
|
+
${p.role ? `<span class="role">${esc(p.role)}</span>` : ""}
|
|
77
|
+
${p.tag ? `<span class="tag">${esc(p.tag)}</span>` : ""}
|
|
78
|
+
</div>
|
|
79
|
+
${p.act ? `<div class="act" title="${esc(p.act)}">${esc(p.act)}</div>` : ""}
|
|
80
|
+
</div>
|
|
81
|
+
</div>`;
|
|
82
|
+
}
|
|
83
|
+
function renderRoster(list) {
|
|
84
|
+
$("roster").innerHTML = list.length
|
|
85
|
+
? list.map(peerRow).join("")
|
|
86
|
+
: `<div class="empty">no peers</div>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Sidebar: channels ─────────────────────────────────────────────────────────
|
|
90
|
+
function chanRow(ch) {
|
|
91
|
+
const sel = !dmSel && ch.key === selected;
|
|
92
|
+
const lead = ch.all ? `<span class="glyph">✸</span>` : `<span class="hash">#</span>`;
|
|
93
|
+
return `<div class="chan${sel ? " sel" : ""}${ch.muted ? " muted" : ""}" data-ch="${esc(ch.key)}">
|
|
94
|
+
<span class="l">${lead}<span class="name">${esc(ch.label)}</span></span>
|
|
95
|
+
${ch.mention ? `<span class="mention">${ch.mention}</span>` : ""}
|
|
96
|
+
<span class="count">${ch.count}</span>
|
|
97
|
+
</div>`;
|
|
98
|
+
}
|
|
99
|
+
function renderChannels() {
|
|
100
|
+
const names = [...channels.keys()]; // insertion order (curated in demo, server order live)
|
|
101
|
+
const total = [...channels.values()].reduce((a, b) => a + b, 0);
|
|
102
|
+
const rows = [{ key: "*", all: true, label: "all activity", count: total }].concat(
|
|
103
|
+
names.map((n) => ({
|
|
104
|
+
key: n,
|
|
105
|
+
label: n,
|
|
106
|
+
count: channels.get(n),
|
|
107
|
+
mention: unread.get(n) || 0,
|
|
108
|
+
muted: channels.get(n) === 0,
|
|
109
|
+
})),
|
|
110
|
+
);
|
|
111
|
+
$("channels").innerHTML = rows.map(chanRow).join("");
|
|
112
|
+
for (const el of $("channels").querySelectorAll(".chan")) el.onclick = () => select(el.dataset.ch);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Sidebar: direct messages (per-peer roll-up → drill) ───────────────────────
|
|
116
|
+
const SEP = "";
|
|
117
|
+
function roleOf(name) {
|
|
118
|
+
const r = roster.find((x) => x.card?.name === name);
|
|
119
|
+
return r?.card?.role;
|
|
120
|
+
}
|
|
121
|
+
function rosterStatus(name) {
|
|
122
|
+
const r = roster.find((x) => x.card?.name === name);
|
|
123
|
+
return r ? r.status : "offline";
|
|
124
|
+
}
|
|
125
|
+
// Group raw DMs into per-peer rows; each peer lists its counterparties (conversations).
|
|
126
|
+
// O(peers-with-DMs), never the n² pair cross-product — only pairs that actually talked.
|
|
127
|
+
function dmPeers() {
|
|
128
|
+
if (isDemo) return DEMO.dmPeers;
|
|
129
|
+
// `from` is a full card (has a name); `to` is the recipient's identity id. Build an
|
|
130
|
+
// id→name map from cards we've seen so recipients show a name, not a pubkey.
|
|
131
|
+
const idName = new Map();
|
|
132
|
+
for (const p of roster) if (p.card?.id) idName.set(p.card.id, p.card.name);
|
|
133
|
+
for (const m of dms) if (m.from?.id && m.from?.name) idName.set(m.from.id, m.from.name);
|
|
134
|
+
const nameOf = (x) => {
|
|
135
|
+
if (!x) return "?";
|
|
136
|
+
if (typeof x === "object") return x.name || nameOf(x.id);
|
|
137
|
+
if (idName.has(x)) return idName.get(x);
|
|
138
|
+
return /^[A-Z2-7]{32,}$/.test(x) ? x.slice(0, 6) + "…" : x; // unknown identity → short id
|
|
139
|
+
};
|
|
140
|
+
const conv = new Map();
|
|
141
|
+
for (const m of dms) {
|
|
142
|
+
const a = nameOf(m.from),
|
|
143
|
+
b = nameOf(m.to);
|
|
144
|
+
if (a === "?" || b === "?" || a === b) continue;
|
|
145
|
+
const key = [a, b].sort().join(SEP);
|
|
146
|
+
if (!conv.has(key)) conv.set(key, { parts: [a, b].sort(), msgs: [] });
|
|
147
|
+
conv.get(key).msgs.push({ ts: time(m.ts), who: a, status: rosterStatus(a), body: bodyText(m), _ts: m.ts });
|
|
148
|
+
}
|
|
149
|
+
const peers = new Map();
|
|
150
|
+
for (const c of conv.values()) {
|
|
151
|
+
c.msgs.sort((x, y) => x._ts - y._ts);
|
|
152
|
+
const last = c.msgs.length ? c.msgs[c.msgs.length - 1]._ts : 0;
|
|
153
|
+
for (const p of c.parts) {
|
|
154
|
+
const other = c.parts[0] === p ? c.parts[1] : c.parts[0];
|
|
155
|
+
if (!peers.has(p)) peers.set(p, { name: p, conversations: [], last: 0 });
|
|
156
|
+
const pe = peers.get(p);
|
|
157
|
+
pe.conversations.push({ with: other, role: roleOf(other), status: rosterStatus(other), unread: 0, last, msgs: c.msgs });
|
|
158
|
+
pe.last = Math.max(pe.last, last);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return [...peers.values()]
|
|
162
|
+
.map((p) => ({
|
|
163
|
+
name: p.name,
|
|
164
|
+
role: roleOf(p.name),
|
|
165
|
+
status: rosterStatus(p.name),
|
|
166
|
+
unread: 0,
|
|
167
|
+
threads: p.conversations.length,
|
|
168
|
+
conversations: p.conversations.sort((a, b) => b.last - a.last),
|
|
169
|
+
last: p.last,
|
|
170
|
+
}))
|
|
171
|
+
.sort((a, b) => b.last - a.last);
|
|
172
|
+
}
|
|
173
|
+
function dmPeerRow(p, expanded) {
|
|
174
|
+
return `<div class="dm${expanded ? " sel" : ""}" data-dm="${esc(p.name)}">
|
|
175
|
+
<span class="caret">${expanded ? "▾" : "▸"}</span>
|
|
176
|
+
<span class="l">
|
|
177
|
+
<span class="dot ${p.status}">${GLYPH[p.status] ?? "○"}</span>
|
|
178
|
+
<span class="nm">${esc(p.name)}</span>
|
|
179
|
+
${p.role ? `<span class="role">${esc(p.role)}</span>` : ""}
|
|
180
|
+
</span>
|
|
181
|
+
${p.unread ? `<span class="mention">${p.unread}</span>` : ""}
|
|
182
|
+
${expanded ? "" : `<span class="threads">${plural(p.threads, "thread")}</span>`}
|
|
183
|
+
</div>`;
|
|
184
|
+
}
|
|
185
|
+
function dmSubRow(peer, c) {
|
|
186
|
+
const sel = dmSel && dmSel.peer === peer && dmSel.with === c.with;
|
|
187
|
+
return `<div class="dm sub${sel ? " sel" : ""}" data-dm="${esc(peer)}${SEP}${esc(c.with)}">
|
|
188
|
+
<span class="ln">↳</span>
|
|
189
|
+
<span class="l">
|
|
190
|
+
<span class="dot ${c.status}">${GLYPH[c.status] ?? "○"}</span>
|
|
191
|
+
<span class="nm">${esc(c.with)}</span>
|
|
192
|
+
${c.role ? `<span class="role">${esc(c.role)}</span>` : ""}
|
|
193
|
+
</span>
|
|
194
|
+
${c.unread ? `<span class="mention">${c.unread}</span>` : ""}
|
|
195
|
+
</div>`;
|
|
196
|
+
}
|
|
197
|
+
function renderDMs() {
|
|
198
|
+
const peers = dmPeers();
|
|
199
|
+
if (!peers.length) {
|
|
200
|
+
$("dms").innerHTML = `<div class="empty">no direct messages</div>`;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
let html = "";
|
|
204
|
+
for (const p of peers) {
|
|
205
|
+
const expanded = !!dmSel && dmSel.peer === p.name;
|
|
206
|
+
html += dmPeerRow(p, expanded);
|
|
207
|
+
if (expanded) for (const c of p.conversations) html += dmSubRow(p.name, c);
|
|
208
|
+
}
|
|
209
|
+
$("dms").innerHTML = html;
|
|
210
|
+
for (const el of $("dms").querySelectorAll("[data-dm]")) {
|
|
211
|
+
el.onclick = () => {
|
|
212
|
+
const [peer, w] = el.dataset.dm.split(SEP);
|
|
213
|
+
selectDM(peer, w || null);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function renderSidebarNav() {
|
|
218
|
+
renderChannels();
|
|
219
|
+
renderDMs();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Feed rows (all-activity) ──────────────────────────────────────────────────
|
|
223
|
+
function rowHTML(e) {
|
|
224
|
+
if (e.type === "sys") return `<div class="sys">${esc(e.text)}</div>`;
|
|
225
|
+
if (e.type === "rollup")
|
|
226
|
+
return `<div class="rollup"><span class="ar">⌄</span><span class="t">${esc(e.text)}</span></div>`;
|
|
227
|
+
const intent = e.type === "intent";
|
|
228
|
+
const badgeClass = intent ? "intent" : e.mode;
|
|
229
|
+
const badgeText = intent ? "⟶ intent" : e.mode;
|
|
230
|
+
const tgt = intent ? e.note : e.target;
|
|
231
|
+
return `<div class="msg${intent ? " intent" : ""}">
|
|
232
|
+
<span class="ts">${esc(e.ts)}</span>
|
|
233
|
+
<span class="badge ${badgeClass}">${esc(badgeText)}</span>
|
|
234
|
+
<div class="c">
|
|
235
|
+
<div class="l1">
|
|
236
|
+
<span class="who">${esc(e.who)}</span>
|
|
237
|
+
${e.role ? `<span class="role">${esc(e.role)}</span>` : ""}
|
|
238
|
+
${tgt ? `<span class="tgt">${esc(tgt)}</span>` : ""}
|
|
239
|
+
${e.sub ? `<span class="subpill">${esc(e.sub)}</span>` : ""}
|
|
240
|
+
</div>
|
|
241
|
+
<div class="body">${esc(e.body)}</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>`;
|
|
244
|
+
}
|
|
245
|
+
function liveEntry(mode, msg) {
|
|
246
|
+
const target =
|
|
247
|
+
mode === "chat"
|
|
248
|
+
? `#${msg.channel ?? ""}`
|
|
249
|
+
: mode === "unicast"
|
|
250
|
+
? `→ ${msg.to ?? ""}`
|
|
251
|
+
: `→ @${msg.toService ?? ""}`;
|
|
252
|
+
return {
|
|
253
|
+
type: "msg",
|
|
254
|
+
mode,
|
|
255
|
+
ts: time(msg.ts),
|
|
256
|
+
who: msg.from?.name ?? "?",
|
|
257
|
+
role: msg.from?.role,
|
|
258
|
+
target,
|
|
259
|
+
body: bodyText(msg),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function renderAllActivity() {
|
|
264
|
+
const center = $("center");
|
|
265
|
+
const prev = center.querySelector(".feed");
|
|
266
|
+
const atBottom = prev ? prev.scrollHeight - prev.scrollTop - prev.clientHeight < 40 : true;
|
|
267
|
+
const rows = (isDemo ? DEMO.activity : activity.map((e) => liveEntry(e.mode, e.msg))).filter(
|
|
268
|
+
(e) => !e.mode || modes.has(e.mode),
|
|
269
|
+
);
|
|
270
|
+
const sub = isDemo ? "112 recent · live" : `${rows.length} recent · live`;
|
|
271
|
+
center.innerHTML = `
|
|
272
|
+
<div class="feed-head">
|
|
273
|
+
<span class="h">✸ All activity</span>
|
|
274
|
+
<span class="sub">${esc(sub)}</span>
|
|
275
|
+
<span class="ctrls">
|
|
276
|
+
${MODES.map((m) => `<span class="chip mode${modes.has(m) ? " on" : ""}" data-mode="${m}">${m}</span>`).join("")}
|
|
277
|
+
<span class="chip pause${paused ? " on" : ""}" id="pause">${paused ? "▶ resume" : "⏸ pause"}</span>
|
|
278
|
+
<span class="chip static">muted · 2</span>
|
|
279
|
+
</span>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="feed">${rows.length ? rows.map(rowHTML).join("") : `<div class="empty">waiting for messages…</div>`}</div>`;
|
|
282
|
+
for (const chip of center.querySelectorAll(".chip[data-mode]"))
|
|
283
|
+
chip.onclick = () => {
|
|
284
|
+
const m = chip.dataset.mode;
|
|
285
|
+
modes.has(m) ? modes.delete(m) : modes.add(m);
|
|
286
|
+
renderAllActivity();
|
|
287
|
+
};
|
|
288
|
+
const pause = center.querySelector("#pause");
|
|
289
|
+
if (pause) pause.onclick = () => ((paused = !paused), renderAllActivity());
|
|
290
|
+
const feed = center.querySelector(".feed");
|
|
291
|
+
if (atBottom && !paused) feed.scrollTop = feed.scrollHeight;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Channel view (centre; members fold into the header) ───────────────────────
|
|
295
|
+
function cmsgHTML(m) {
|
|
296
|
+
if (m.type === "unread")
|
|
297
|
+
return `<div class="unread-mark"><span class="line"></span><span class="t">${esc(m.text)}</span><span class="line"></span></div>`;
|
|
298
|
+
return `<div class="cmsg">
|
|
299
|
+
<span class="ts">${esc(m.ts)}</span>
|
|
300
|
+
<span class="dot ${m.status}">${GLYPH[m.status] ?? "●"}</span>
|
|
301
|
+
<div class="c">
|
|
302
|
+
<div class="l1"><span class="who">${esc(m.who)}</span>${m.role ? `<span class="role">${esc(m.role)}</span>` : ""}</div>
|
|
303
|
+
<div class="body">${esc(m.body)}</div>
|
|
304
|
+
${m.thread ? `<span class="thread">${esc(m.thread)}</span>` : ""}
|
|
305
|
+
</div>
|
|
306
|
+
</div>`;
|
|
307
|
+
}
|
|
308
|
+
function channelMembers(msgs) {
|
|
309
|
+
const seen = new Map();
|
|
310
|
+
for (const msg of msgs) {
|
|
311
|
+
const n = msg.from?.name;
|
|
312
|
+
if (!n || seen.has(n)) continue;
|
|
313
|
+
seen.set(n, { name: n, role: msg.from?.role, status: rosterStatus(n) });
|
|
314
|
+
}
|
|
315
|
+
return [...seen.values()];
|
|
316
|
+
}
|
|
317
|
+
function renderChannel() {
|
|
318
|
+
const name = selected;
|
|
319
|
+
let items, memberCount, msgCount, desc;
|
|
320
|
+
if (isDemo) {
|
|
321
|
+
items = DEMO.cv.messages;
|
|
322
|
+
memberCount = DEMO.cv.members.length;
|
|
323
|
+
msgCount = channels.get(name) ?? 51;
|
|
324
|
+
desc = name === "team.backend" ? "Backend coordination — channels, endpoint, NATS. · " : "";
|
|
325
|
+
} else {
|
|
326
|
+
items = channelMsgs.map((msg) => ({
|
|
327
|
+
ts: time(msg.ts),
|
|
328
|
+
status: rosterStatus(msg.from?.name),
|
|
329
|
+
who: msg.from?.name ?? "?",
|
|
330
|
+
role: msg.from?.role,
|
|
331
|
+
body: bodyText(msg),
|
|
332
|
+
}));
|
|
333
|
+
memberCount = channelMembers(channelMsgs).length;
|
|
334
|
+
msgCount = channels.get(name) ?? items.length;
|
|
335
|
+
desc = "";
|
|
336
|
+
}
|
|
337
|
+
const sub = name.includes(".") ? `subtree of ${name.split(".")[0]}.>` : "";
|
|
338
|
+
$("center").innerHTML = `
|
|
339
|
+
<div class="ch-head">
|
|
340
|
+
<div class="row">
|
|
341
|
+
<div class="title"><span class="h"># ${esc(name)}</span>${sub ? `<span class="sub">${esc(sub)}</span>` : ""}</div>
|
|
342
|
+
<div class="ctrls">
|
|
343
|
+
<span class="chip mode on">👥 ${plural(memberCount, "member")}</span>
|
|
344
|
+
<span class="chip mode on">✦ summarize</span>
|
|
345
|
+
<span class="chip static">🔕 mute</span>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
<div class="purpose">${esc(desc)}${plural(memberCount, "member")} · ${plural(msgCount, "message")}</div>
|
|
349
|
+
</div>
|
|
350
|
+
<div class="clist">${items.length ? items.map(cmsgHTML).join("") : `<div class="empty">no messages</div>`}</div>`;
|
|
351
|
+
const list = $("center").querySelector(".clist");
|
|
352
|
+
list.scrollTop = list.scrollHeight;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── Direct-messages thread (centre) ───────────────────────────────────────────
|
|
356
|
+
function dmMsgHTML(m, peer, withName) {
|
|
357
|
+
const to = m.who === peer ? withName : peer;
|
|
358
|
+
return `<div class="cmsg">
|
|
359
|
+
<span class="ts">${esc(m.ts)}</span>
|
|
360
|
+
<span class="dot ${m.status}">${GLYPH[m.status] ?? "●"}</span>
|
|
361
|
+
<div class="c">
|
|
362
|
+
<div class="l1"><span class="who">${esc(m.who)}</span><span class="dir">→ ${esc(to)}</span></div>
|
|
363
|
+
<div class="body">${esc(m.body)}</div>
|
|
364
|
+
</div>
|
|
365
|
+
</div>`;
|
|
366
|
+
}
|
|
367
|
+
function renderDMThread() {
|
|
368
|
+
const peer = dmSel.peer;
|
|
369
|
+
const pe = dmPeers().find((p) => p.name === peer);
|
|
370
|
+
const conv = pe && (pe.conversations.find((c) => c.with === dmSel.with) || pe.conversations[0]);
|
|
371
|
+
const withName = conv ? conv.with : dmSel.with;
|
|
372
|
+
const msgs = conv ? conv.msgs : []; // display-ready (ts, who, status, body) for demo + live
|
|
373
|
+
$("center").innerHTML = `
|
|
374
|
+
<div class="ch-head">
|
|
375
|
+
<div class="row">
|
|
376
|
+
<div class="title"><span class="h">${esc(peer)} ↔ ${esc(withName)}</span><span class="dtag">direct</span></div>
|
|
377
|
+
<div class="ctrls"><span class="chip static">🔕 mute</span></div>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="purpose">unicast · private to the two of them · ${plural(msgs.length, "message")}</div>
|
|
380
|
+
</div>
|
|
381
|
+
<div class="clist">${msgs.length ? msgs.map((m) => dmMsgHTML(m, peer, withName)).join("") : `<div class="empty">no messages</div>`}</div>`;
|
|
382
|
+
const list = $("center").querySelector(".clist");
|
|
383
|
+
list.scrollTop = list.scrollHeight;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── NEEDS YOU rail (always on the right) ──────────────────────────────────────
|
|
387
|
+
function cardHTML(c) {
|
|
388
|
+
const nav = c.id ? ` nav${c.id === agentSel ? " sel" : ""}` : "";
|
|
389
|
+
return `<div class="card tone-${c.tone}${nav}"${c.id ? ` data-agent="${esc(c.id)}"` : ""}>
|
|
390
|
+
<div class="top">
|
|
391
|
+
<div class="cat-l"><span class="cdot"></span><span class="cat">${esc(c.cat)}</span></div>
|
|
392
|
+
<span class="age">${esc(c.age)}</span>
|
|
393
|
+
</div>
|
|
394
|
+
<div class="title">${esc(c.title)}${c.role ? `<span class="crole">${esc(c.role)}</span>` : ""}</div>
|
|
395
|
+
<div class="desc">${esc(c.desc)}</div>
|
|
396
|
+
${c.primary ? `<div class="btns"><span class="btn primary">${esc(c.primary)}</span>${c.secondary ? `<span class="btn secondary">${esc(c.secondary)}</span>` : ""}</div>` : ""}
|
|
397
|
+
</div>`;
|
|
398
|
+
}
|
|
399
|
+
function waitingCards() {
|
|
400
|
+
return roster
|
|
401
|
+
.filter((p) => p.status === "waiting")
|
|
402
|
+
.sort((a, b) => b.ts - a.ts)
|
|
403
|
+
.map((p) => ({
|
|
404
|
+
tone: "amber",
|
|
405
|
+
cat: "WAITING",
|
|
406
|
+
age: ago(p.ts),
|
|
407
|
+
title: `${p.card.name} is waiting`,
|
|
408
|
+
role: p.card.role,
|
|
409
|
+
// p.activity is the Claude Code Notification text (the actual blocking prompt/permission).
|
|
410
|
+
desc: p.activity || "waiting for input",
|
|
411
|
+
id: p.card.id, // makes the card a clickable drill-down into the Agent Detail view
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
function renderRail() {
|
|
415
|
+
const cards = isDemo ? DEMO.cards : waitingCards();
|
|
416
|
+
$("rail").className = "rail";
|
|
417
|
+
$("rail").innerHTML =
|
|
418
|
+
`<div class="rail-head"><span class="t">NEEDS YOU</span>${cards.length ? `<span class="n">${cards.length}</span>` : ""}</div>` +
|
|
419
|
+
(cards.length
|
|
420
|
+
? cards.map(cardHTML).join("") +
|
|
421
|
+
`<div class="rail-foot">Everything else stays quiet in the feed.</div>`
|
|
422
|
+
: `<div class="empty">nothing waiting — all clear ✓</div>`);
|
|
423
|
+
for (const el of $("rail").querySelectorAll(".card[data-agent]"))
|
|
424
|
+
el.onclick = () => selectAgent(el.dataset.agent);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Agent Detail drill-down (centre) — the forward-looking per-agent frame (docs/web.md) ──
|
|
428
|
+
function selectAgent(id) {
|
|
429
|
+
agentSel = id;
|
|
430
|
+
dmSel = null;
|
|
431
|
+
selected = null;
|
|
432
|
+
renderSidebarNav();
|
|
433
|
+
renderCenter();
|
|
434
|
+
renderRail();
|
|
435
|
+
}
|
|
436
|
+
function renderAgentDetail() {
|
|
437
|
+
const p = roster.find((x) => x.card.id === agentSel);
|
|
438
|
+
if (!p) {
|
|
439
|
+
$("center").innerHTML = `<div class="detail"><div class="empty">agent no longer present — pick another from NEEDS YOU.</div></div>`;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const waiting = p.status === "waiting";
|
|
443
|
+
const who = p.card.role ? `${esc(p.card.name)}<span class="crole">${esc(p.card.role)}</span>` : esc(p.card.name);
|
|
444
|
+
const since = waiting ? `waiting ${esc(ago(p.ts))}` : `${esc(p.status)} · ${esc(ago(p.ts))}`;
|
|
445
|
+
const blocked = waiting
|
|
446
|
+
? `<div class="d-label">Blocked on</div><div class="d-block">${esc(p.activity || "waiting for input")}</div>`
|
|
447
|
+
: `<div class="d-block muted">${esc(p.activity || "no current activity")}</div>`;
|
|
448
|
+
$("center").innerHTML = `
|
|
449
|
+
<div class="detail${waiting ? " amber" : ""}">
|
|
450
|
+
<div class="d-head">
|
|
451
|
+
<span class="dot ${p.status}">${GLYPH[p.status] ?? "●"}</span>
|
|
452
|
+
<span class="d-status">${esc(waiting ? "WAITING" : p.status)}</span>
|
|
453
|
+
<span class="d-age">${since}</span>
|
|
454
|
+
</div>
|
|
455
|
+
<div class="d-who">${who}</div>
|
|
456
|
+
<div class="d-id">${esc(p.card.id.slice(0, 8))}…</div>
|
|
457
|
+
${blocked}
|
|
458
|
+
</div>`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── View dispatch ─────────────────────────────────────────────────────────────
|
|
462
|
+
function renderCenter() {
|
|
463
|
+
if (agentSel) return renderAgentDetail();
|
|
464
|
+
if (dmSel) return renderDMThread();
|
|
465
|
+
if (selected === "*") return renderAllActivity();
|
|
466
|
+
return renderChannel();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function refreshDerived() {
|
|
470
|
+
const counts = { working: 0, waiting: 0, idle: 0, offline: 0 };
|
|
471
|
+
for (const p of roster) counts[p.status] = (counts[p.status] ?? 0) + 1;
|
|
472
|
+
const waiting = roster.filter((p) => p.status === "waiting");
|
|
473
|
+
const oldest = waiting.length ? agoShort(Math.min(...waiting.map((p) => p.ts))) : "—";
|
|
474
|
+
renderTiles(counts, oldest);
|
|
475
|
+
$("online-c").textContent = roster.filter((p) => p.status !== "offline").length;
|
|
476
|
+
renderRoster(
|
|
477
|
+
[...roster]
|
|
478
|
+
.sort(
|
|
479
|
+
(a, b) =>
|
|
480
|
+
STATUS.indexOf(a.status) - STATUS.indexOf(b.status) ||
|
|
481
|
+
a.card.name.localeCompare(b.card.name),
|
|
482
|
+
)
|
|
483
|
+
.map((p) => ({
|
|
484
|
+
name: p.card.name,
|
|
485
|
+
role: p.card.role,
|
|
486
|
+
status: p.status,
|
|
487
|
+
act: p.activity,
|
|
488
|
+
tag: p.status === "waiting" ? "needs input" : null,
|
|
489
|
+
})),
|
|
490
|
+
);
|
|
491
|
+
renderDMs(); // peer statuses may have changed
|
|
492
|
+
renderRail();
|
|
493
|
+
if (agentSel) renderCenter(); // keep an open Agent Detail live as the peer's status/activity changes
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let loadSeq = 0;
|
|
497
|
+
async function select(key) {
|
|
498
|
+
agentSel = null;
|
|
499
|
+
dmSel = null;
|
|
500
|
+
selected = key;
|
|
501
|
+
if (key !== "*") unread.set(key, 0);
|
|
502
|
+
renderSidebarNav();
|
|
503
|
+
if (isDemo) return (renderCenter(), renderRail());
|
|
504
|
+
if (key !== "*") {
|
|
505
|
+
const seq = ++loadSeq;
|
|
506
|
+
channelMsgs = [];
|
|
507
|
+
renderCenter();
|
|
508
|
+
const msgs = await (await fetch(`/api/channels/${encodeURIComponent(key)}/history?limit=200`)).json();
|
|
509
|
+
if (seq !== loadSeq) return;
|
|
510
|
+
channelMsgs = msgs;
|
|
511
|
+
}
|
|
512
|
+
renderCenter();
|
|
513
|
+
renderRail();
|
|
514
|
+
}
|
|
515
|
+
function selectDM(peer, w) {
|
|
516
|
+
agentSel = null;
|
|
517
|
+
const pe = dmPeers().find((p) => p.name === peer);
|
|
518
|
+
dmSel = { peer, with: w || (pe && pe.conversations[0] ? pe.conversations[0].with : null) };
|
|
519
|
+
selected = null;
|
|
520
|
+
renderSidebarNav();
|
|
521
|
+
renderCenter();
|
|
522
|
+
renderRail();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function refresh() {
|
|
526
|
+
roster = await (await fetch("/api/roster")).json();
|
|
527
|
+
refreshDerived();
|
|
528
|
+
const list = await (await fetch("/api/channels")).json();
|
|
529
|
+
channels = new Map(list.map((c) => [c.channel, c.messages]));
|
|
530
|
+
dms = await (await fetch("/api/dms?limit=500")).json();
|
|
531
|
+
renderSidebarNav();
|
|
532
|
+
if (agentSel) {
|
|
533
|
+
renderCenter();
|
|
534
|
+
} else if (dmSel) {
|
|
535
|
+
renderCenter();
|
|
536
|
+
} else if (selected !== "*") {
|
|
537
|
+
select(selected);
|
|
538
|
+
} else {
|
|
539
|
+
activity = await (await fetch("/api/activity?limit=200")).json();
|
|
540
|
+
renderCenter();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function onMessage(entry) {
|
|
545
|
+
const { mode, msg } = entry;
|
|
546
|
+
if (!activity.some((e) => e.msg.id === msg.id)) {
|
|
547
|
+
activity.push(entry);
|
|
548
|
+
if (activity.length > 500) activity.shift();
|
|
549
|
+
}
|
|
550
|
+
if (mode === "unicast" && !dms.some((m) => m.id === msg.id)) {
|
|
551
|
+
dms.push(msg);
|
|
552
|
+
renderDMs();
|
|
553
|
+
}
|
|
554
|
+
if (msg.channel) {
|
|
555
|
+
channels.set(msg.channel, (channels.get(msg.channel) ?? 0) + 1);
|
|
556
|
+
if (!dmSel && selected === msg.channel) {
|
|
557
|
+
channelMsgs.push(msg);
|
|
558
|
+
if (channelMsgs.length > 500) channelMsgs.shift();
|
|
559
|
+
} else {
|
|
560
|
+
unread.set(msg.channel, (unread.get(msg.channel) ?? 0) + 1);
|
|
561
|
+
}
|
|
562
|
+
renderChannels();
|
|
563
|
+
}
|
|
564
|
+
if (dmSel ? mode === "unicast" : selected === "*" || selected === msg.channel) renderCenter();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function connect() {
|
|
568
|
+
const es = new EventSource("/feed");
|
|
569
|
+
es.addEventListener("open", () => {
|
|
570
|
+
setConn(true);
|
|
571
|
+
refresh();
|
|
572
|
+
});
|
|
573
|
+
es.addEventListener("roster", (e) => {
|
|
574
|
+
roster = JSON.parse(e.data);
|
|
575
|
+
refreshDerived();
|
|
576
|
+
});
|
|
577
|
+
es.addEventListener("message", (e) => onMessage(JSON.parse(e.data)));
|
|
578
|
+
es.addEventListener("error", () => setConn(false));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Demo scene (the Penpot reference frames) ──────────────────────────────────
|
|
582
|
+
const ab = [
|
|
583
|
+
{ ts: "10:47", who: "alice", status: "waiting", body: "can you take the API-key wiring while I'm blocked?" },
|
|
584
|
+
{ ts: "10:48", who: "bob", status: "working", body: "on it — grabbing the OPENAI_API_KEY wiring now" },
|
|
585
|
+
{ ts: "10:50", who: "alice", status: "waiting", body: "🙏 thanks — I'll keep drafting the auth outline" },
|
|
586
|
+
];
|
|
587
|
+
const ad = [
|
|
588
|
+
{ ts: "10:42", who: "dave", status: "working", body: "want me to stub the key so you can keep planning?" },
|
|
589
|
+
{ ts: "10:43", who: "alice", status: "waiting", body: "yes please — a no-op stub is perfect for now" },
|
|
590
|
+
];
|
|
591
|
+
const as = [{ ts: "10:40", who: "scout", status: "idle", body: "logged your block in #incidents" }];
|
|
592
|
+
const bd = [
|
|
593
|
+
{ ts: "10:09", who: "bob", status: "working", body: "merged your filter-subjects change" },
|
|
594
|
+
{ ts: "10:10", who: "dave", status: "working", body: "ty — running the suite now" },
|
|
595
|
+
];
|
|
596
|
+
const lm = [{ ts: "10:15", who: "maya", status: "idle", body: "sent the NATS v3 notes your way" }];
|
|
597
|
+
const DEMO = {
|
|
598
|
+
roster: [
|
|
599
|
+
{ name: "alice", role: "planner", status: "waiting", tag: "needs input", act: "blocked — needs OPENAI_API_KEY" },
|
|
600
|
+
{ name: "linus", role: "reviewer", status: "working", act: "reviewing PR #42 · auth guards" },
|
|
601
|
+
{ name: "bob", role: "builder", status: "working", act: "writing tests · channels.ts" },
|
|
602
|
+
{ name: "dave", role: "builder", status: "working", act: "refactoring endpoint.ts" },
|
|
603
|
+
{ name: "maya", role: "researcher", status: "idle", act: "—" },
|
|
604
|
+
{ name: "scout", role: "observer", status: "idle", act: "watching #team.>" },
|
|
605
|
+
],
|
|
606
|
+
activity: [
|
|
607
|
+
{ type: "sys", text: "— scout joined · observer —" },
|
|
608
|
+
{ type: "msg", mode: "chat", ts: "10:38", who: "dave", role: "builder", target: "#general", body: "anyone else hit the flaky CI test on channels.ts?" },
|
|
609
|
+
{ type: "rollup", text: "14 status updates · bob, dave, linus, maya" },
|
|
610
|
+
{ type: "msg", mode: "chat", ts: "10:41", who: "bob", role: "builder", target: "#team.backend", body: "pushed channels.ts tests — 12 green ✓" },
|
|
611
|
+
{ type: "intent", ts: "10:46", who: "linus", note: "about to act", body: "will merge PR #42 once the review check passes" },
|
|
612
|
+
{ type: "msg", mode: "unicast", ts: "10:47", who: "alice", role: "planner", target: "→ bob", body: "can you take the API-key wiring while I'm blocked?" },
|
|
613
|
+
{ type: "msg", mode: "anycast", ts: "10:49", who: "—", target: "→ @reviewer", sub: "unclaimed · 3m", body: "review needed on PR #51 (channels hierarchy)" },
|
|
614
|
+
{ type: "msg", mode: "chat", ts: "10:51", who: "linus", role: "reviewer", target: "#team.review", body: "left 2 comments on PR #42 — small nits" },
|
|
615
|
+
],
|
|
616
|
+
cards: [
|
|
617
|
+
{ tone: "amber", cat: "WAITING", age: "4m", title: "alice is blocked", desc: "Needs OPENAI_API_KEY to keep planning the auth module.", primary: "Provide key", secondary: "Open thread" },
|
|
618
|
+
{ tone: "red", cat: "FAILED", age: "1m", title: "bob's task failed", desc: "2 tests failing in channels.ts after the refactor.", primary: "Inspect", secondary: "Retry" },
|
|
619
|
+
{ tone: "orange", cat: "UNCLAIMED", age: "3m", title: "Anycast request unhandled", desc: "@reviewer · review PR #51 — no peer has claimed it.", primary: "Assign…", secondary: "Claim" },
|
|
620
|
+
{ tone: "blue", cat: "APPROVAL", age: "just now", title: "dave requests approval", desc: "Wants to force-push to main — irreversible.", primary: "Approve", secondary: "Deny" },
|
|
621
|
+
],
|
|
622
|
+
cv: {
|
|
623
|
+
messages: [
|
|
624
|
+
{ ts: "09:58", status: "working", who: "bob", role: "builder", body: "scaffolded the hierarchical channel matcher — wildcard subtree works" },
|
|
625
|
+
{ ts: "10:05", status: "working", who: "dave", role: "builder", body: "endpoint.ts: collapsed the filter subjects, tests pass", thread: "💬 3 replies · last 2m" },
|
|
626
|
+
{ ts: "10:12", status: "idle", who: "maya", role: "researcher", body: "NATS v3 split transports cleanly — notes in #planning" },
|
|
627
|
+
{ type: "unread", text: "new since you were away · 4 messages" },
|
|
628
|
+
{ ts: "10:39", status: "working", who: "dave", role: "builder", body: "anyone seen the flaky CI test on channels.ts?" },
|
|
629
|
+
{ ts: "10:41", status: "working", who: "bob", role: "builder", body: "pushed channels.ts tests — 12 green ✓" },
|
|
630
|
+
{ ts: "10:44", status: "waiting", who: "alice", role: "planner", body: "drafted the auth outline; blocked on the API key though" },
|
|
631
|
+
],
|
|
632
|
+
members: [
|
|
633
|
+
{ status: "working", name: "bob", role: "builder" },
|
|
634
|
+
{ status: "working", name: "dave", role: "builder" },
|
|
635
|
+
{ status: "waiting", name: "alice", role: "planner" },
|
|
636
|
+
{ status: "idle", name: "maya", role: "researcher" },
|
|
637
|
+
{ status: "idle", name: "scout", role: "observer" },
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
dmPeers: [
|
|
641
|
+
{ name: "alice", role: "planner", status: "waiting", unread: 2, threads: 3, conversations: [
|
|
642
|
+
{ with: "bob", role: "builder", status: "working", unread: 0, msgs: ab },
|
|
643
|
+
{ with: "dave", role: "builder", status: "working", unread: 1, msgs: ad },
|
|
644
|
+
{ with: "scout", role: "observer", status: "idle", unread: 1, msgs: as },
|
|
645
|
+
] },
|
|
646
|
+
{ name: "bob", role: "builder", status: "working", unread: 0, threads: 2, conversations: [
|
|
647
|
+
{ with: "alice", role: "planner", status: "waiting", unread: 0, msgs: ab },
|
|
648
|
+
{ with: "dave", role: "builder", status: "working", unread: 0, msgs: bd },
|
|
649
|
+
] },
|
|
650
|
+
{ name: "dave", role: "builder", status: "working", unread: 1, threads: 2, conversations: [
|
|
651
|
+
{ with: "alice", role: "planner", status: "waiting", unread: 1, msgs: ad },
|
|
652
|
+
{ with: "bob", role: "builder", status: "working", unread: 0, msgs: bd },
|
|
653
|
+
] },
|
|
654
|
+
{ name: "linus", role: "reviewer", status: "working", unread: 0, threads: 1, conversations: [
|
|
655
|
+
{ with: "maya", role: "researcher", status: "idle", unread: 0, msgs: lm },
|
|
656
|
+
] },
|
|
657
|
+
{ name: "maya", role: "researcher", status: "idle", unread: 0, threads: 1, conversations: [
|
|
658
|
+
{ with: "linus", role: "reviewer", status: "working", unread: 0, msgs: lm },
|
|
659
|
+
] },
|
|
660
|
+
],
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
function renderDemo() {
|
|
664
|
+
$("space").textContent = "· demo";
|
|
665
|
+
setConn(true);
|
|
666
|
+
renderTiles({ working: 4, waiting: 1, idle: 2, offline: 1 }, "6m");
|
|
667
|
+
$("online-c").textContent = "6";
|
|
668
|
+
renderRoster(DEMO.roster);
|
|
669
|
+
// Counts sum to 112 → the "all activity" total matches the reference.
|
|
670
|
+
channels = new Map([
|
|
671
|
+
["general", 24],
|
|
672
|
+
["planning", 12],
|
|
673
|
+
["team.backend", 51],
|
|
674
|
+
["team.frontend", 18],
|
|
675
|
+
["team.review", 7],
|
|
676
|
+
["incidents", 0],
|
|
677
|
+
]);
|
|
678
|
+
unread = new Map([["planning", 2], ["team.review", 1]]);
|
|
679
|
+
renderSidebarNav();
|
|
680
|
+
renderCenter();
|
|
681
|
+
renderRail();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (isDemo) {
|
|
685
|
+
document.title = "Cotal · demo";
|
|
686
|
+
renderDemo();
|
|
687
|
+
} else {
|
|
688
|
+
fetch("/api/meta")
|
|
689
|
+
.then((r) => r.json())
|
|
690
|
+
.then((m) => {
|
|
691
|
+
$("space").textContent = `· ${m.space}`;
|
|
692
|
+
document.title = `Cotal · ${m.space}`;
|
|
693
|
+
});
|
|
694
|
+
refresh();
|
|
695
|
+
connect();
|
|
696
|
+
}
|