@cotal-ai/web 0.11.6 → 0.13.0
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/web/app.js +388 -124
- package/dist/web/graph.html +13 -1
- package/dist/web/graph.js +181 -22
- package/dist/web/harness.js +33 -0
- package/dist/web/index.html +172 -21
- package/dist/web/md.js +31 -0
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +34 -9
- package/dist/web.js.map +1 -1
- package/package.json +7 -3
package/dist/web/app.js
CHANGED
|
@@ -15,7 +15,7 @@ const MODES = ["chat", "unicast", "anycast"];
|
|
|
15
15
|
const isDemo = new URLSearchParams(location.search).has("demo");
|
|
16
16
|
|
|
17
17
|
let roster = [];
|
|
18
|
-
let channels = new Map(); // name ->
|
|
18
|
+
let channels = new Map(); // name -> { messages, description?, replay, replayWindow?, deliveryClass }
|
|
19
19
|
let unread = new Map(); // name -> messages seen since last viewed
|
|
20
20
|
let dms = []; // raw DM messages (god-view), grouped client-side
|
|
21
21
|
let selected = "*"; // "*" = all activity, else a channel name (null when a DM is open)
|
|
@@ -25,6 +25,7 @@ let activity = []; // {mode, msg} ring buffer for the all-activity view
|
|
|
25
25
|
let channelMsgs = []; // messages for the selected channel
|
|
26
26
|
let modes = new Set(MODES); // delivery modes currently shown
|
|
27
27
|
let paused = false; // freeze auto-scroll so a value can be read
|
|
28
|
+
let expandAll = false; // channel-wide: expand every clamped message body (else per-message toggle)
|
|
28
29
|
|
|
29
30
|
const esc = (s) =>
|
|
30
31
|
String(s).replace(/[&<>]/g, (ch) => ({ "&": "&", "<": "<", ">": ">" })[ch]);
|
|
@@ -41,40 +42,31 @@ function ago(ts) {
|
|
|
41
42
|
const agoShort = (ts) => (ago(ts) === "just now" ? "now" : ago(ts));
|
|
42
43
|
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
43
44
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
claude: {
|
|
52
|
-
label: "Claude Code",
|
|
53
|
-
color: "#d97757", // official Claude clay
|
|
54
|
-
icon: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>`,
|
|
55
|
-
},
|
|
56
|
-
opencode: {
|
|
57
|
-
label: "OpenCode",
|
|
58
|
-
color: "#cdd6e0", // OpenCode is monochrome by brand; rendered light on the dark UI
|
|
59
|
-
icon: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/></svg>`,
|
|
60
|
-
},
|
|
61
|
-
hermes: {
|
|
62
|
-
label: "Hermes",
|
|
63
|
-
color: "#a78bfa", // no official mark — custom messenger glyph, violet
|
|
64
|
-
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 3 3 10.5l7 2.5 2.5 7L21 3Z"/><path d="M21 3 10 13"/></svg>`,
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
// Render the harness badge: a branded pill (icon + label) by default, or `{compact:true}` for a
|
|
68
|
-
// bare colour icon (the roster row). The inline style only ever takes a value from HARNESS, never
|
|
69
|
-
// raw card input, so meta.connector can't inject CSS.
|
|
45
|
+
// Markdown rendering lives in md.js (window.COTAL_MD) — marked + DOMPurify, one source for every
|
|
46
|
+
// message body. render() escapes+sanitizes untrusted agent text; never pass raw text to innerHTML.
|
|
47
|
+
const MD = window.COTAL_MD;
|
|
48
|
+
// Harness branding lives in harness.js (window.COTAL_HARNESS) — one source for monitor + graph.
|
|
49
|
+
const HARNESS = window.COTAL_HARNESS || {};
|
|
50
|
+
// Render the harness badge: a branded pill (svg + label) by default, or `{compact:true}` for a
|
|
51
|
+
// bare colour icon (the roster row). Style values only ever come from HARNESS, never raw card input.
|
|
70
52
|
function harnessBadge(connector, opts = {}) {
|
|
71
53
|
if (!connector) return "";
|
|
72
54
|
const h = HARNESS[connector];
|
|
73
55
|
const color = h ? h.color : "var(--dim)";
|
|
74
56
|
const text = h ? h.label : connector;
|
|
75
57
|
if (opts.compact)
|
|
76
|
-
return h ? `<span class="harness-ico" style="color:${color}" title="harness · ${esc(text)}">${h.
|
|
77
|
-
return `<span class="harness-badge" style="--hc:${color}" title="agent harness · ${esc(text)}">${h ? h.
|
|
58
|
+
return h ? `<span class="harness-ico" style="color:${color}" title="harness · ${esc(text)}">${h.svg}</span>` : "";
|
|
59
|
+
return `<span class="harness-badge" style="--hc:${color}" title="agent harness · ${esc(text)}">${h ? h.svg : ""}<span class="hl">${esc(text)}</span></span>`;
|
|
60
|
+
}
|
|
61
|
+
// Attention is advisory presence: open/absent are the same ("receives all") — only dnd/focus surface.
|
|
62
|
+
// Shape + label (never colour alone). Returns "" for open/absent.
|
|
63
|
+
function attentionChip(attention, opts = {}) {
|
|
64
|
+
if (attention !== "dnd" && attention !== "focus") return "";
|
|
65
|
+
const label = attention === "dnd" ? "dnd" : "focus";
|
|
66
|
+
const glyph = attention === "dnd" ? "◼" : "◉";
|
|
67
|
+
const title = attention === "dnd" ? "do not disturb — untagged channel chatter won't wake" : "focus — only DMs/anycast reach context";
|
|
68
|
+
if (opts.compact) return `<span class="att ${label}" title="${title}">${glyph}</span>`;
|
|
69
|
+
return `<span class="att-badge ${label}" title="${title}"><span class="att ${label}">${glyph}</span>${label}</span>`;
|
|
78
70
|
}
|
|
79
71
|
|
|
80
72
|
function setConn(live) {
|
|
@@ -84,21 +76,25 @@ function setConn(live) {
|
|
|
84
76
|
}
|
|
85
77
|
|
|
86
78
|
// ── Header: golden-signal tiles ───────────────────────────────────────────────
|
|
87
|
-
|
|
79
|
+
// Fifth tile is last-heartbeat freshness (Presence.ts), NOT blocked-duration — we cannot know
|
|
80
|
+
// how long someone has been waiting (no statusSince on the wire). Label it honestly.
|
|
81
|
+
function renderTiles(counts, stalest) {
|
|
88
82
|
const tiles = [
|
|
89
83
|
["working", counts.working],
|
|
90
84
|
["waiting", counts.waiting],
|
|
91
85
|
["idle", counts.idle],
|
|
92
86
|
["offline", counts.offline],
|
|
93
|
-
["oldest",
|
|
87
|
+
["oldest", stalest, "stalest live heartbeat"],
|
|
94
88
|
];
|
|
95
89
|
$("tiles").innerHTML = tiles
|
|
96
|
-
.map(
|
|
97
|
-
|
|
90
|
+
.map(([k, n, lbl]) => {
|
|
91
|
+
// Amber only when waiting > 0 — a zero-count alert is pure alarm fatigue.
|
|
92
|
+
const alert = k === "waiting" && Number(n) > 0 ? " alert" : "";
|
|
93
|
+
return `<div class="tile ${k}${alert}">
|
|
98
94
|
<span class="bar"></span>
|
|
99
95
|
<div class="c"><span class="n">${n}</span><span class="lbl">${lbl ?? k}</span></div>
|
|
100
|
-
</div
|
|
101
|
-
)
|
|
96
|
+
</div>`;
|
|
97
|
+
})
|
|
102
98
|
.join("");
|
|
103
99
|
}
|
|
104
100
|
|
|
@@ -114,6 +110,7 @@ function peerRow(p) {
|
|
|
114
110
|
<span class="name">${esc(p.name)}</span>
|
|
115
111
|
${p.harness ? harnessBadge(p.harness, { compact: true }) : ""}
|
|
116
112
|
${p.role ? `<span class="role">${esc(p.role)}</span>` : ""}
|
|
113
|
+
${attentionChip(p.attention, { compact: true })}
|
|
117
114
|
${p.tag ? `<span class="tag">${esc(p.tag)}</span>` : ""}
|
|
118
115
|
</div>
|
|
119
116
|
${p.act ? `<div class="act" title="${esc(p.act)}">${esc(p.act)}</div>` : ""}
|
|
@@ -149,44 +146,65 @@ function rosterRows() {
|
|
|
149
146
|
status: p.status,
|
|
150
147
|
act: p.activity,
|
|
151
148
|
harness: p.card.meta?.connector,
|
|
149
|
+
attention: p.attention, // open/absent both render as nothing
|
|
152
150
|
tag: p.status === "waiting" ? "needs input" : null,
|
|
153
151
|
}));
|
|
154
152
|
}
|
|
155
153
|
|
|
156
154
|
// ── Sidebar: channels ─────────────────────────────────────────────────────────
|
|
155
|
+
// deliveryClass as shape+letter (never colour alone): D = durable, L = live.
|
|
156
|
+
function deliveryGlyph(dc) {
|
|
157
|
+
if (dc === "live") return `<span class="dclass live" title="live delivery — at-most-once">L</span>`;
|
|
158
|
+
if (dc === "durable") return `<span class="dclass durable" title="durable delivery — at-least-once for members">D</span>`;
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
// Replay state at a glance (shape, not colour alone): ↻ = on, struck ↻ = off. Unknown (a live
|
|
162
|
+
// message that beat the registry) shows nothing rather than guessing a default.
|
|
163
|
+
function replayGlyph(replay) {
|
|
164
|
+
if (replay === true) return `<span class="replay on" title="replay on — a join backfills retained history">↻</span>`;
|
|
165
|
+
if (replay === false) return `<span class="replay off" title="replay off — a join does not backfill history">↻</span>`;
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
157
168
|
function chanRow(ch) {
|
|
158
169
|
const sel = !dmSel && ch.key === selected;
|
|
159
170
|
const lead = ch.all ? `<span class="glyph">✸</span>` : `<span class="hash">#</span>`;
|
|
160
|
-
return `<div class="chan${sel ? " sel" : ""}
|
|
161
|
-
<span class="l">${lead}<span class="name">${esc(ch.label)}</span
|
|
171
|
+
return `<div class="chan${sel ? " sel" : ""}" data-ch="${esc(ch.key)}">
|
|
172
|
+
<span class="l">${lead}<span class="name">${esc(ch.label)}</span>${ch.dclass ? deliveryGlyph(ch.dclass) : ""}${replayGlyph(ch.replay)}</span>
|
|
162
173
|
${ch.mention ? `<span class="mention">${ch.mention}</span>` : ""}
|
|
163
174
|
<span class="count">${ch.count}</span>
|
|
164
175
|
</div>`;
|
|
165
176
|
}
|
|
166
177
|
function renderChannels() {
|
|
167
178
|
const names = [...channels.keys()]; // insertion order (curated in demo, server order live)
|
|
168
|
-
const total = [...channels.values()].reduce((
|
|
179
|
+
const total = [...channels.values()].reduce((sum, ch) => sum + (ch.messages || 0), 0);
|
|
169
180
|
const rows = [{ key: "*", all: true, label: "all activity", count: total }].concat(
|
|
170
|
-
names.map((n) =>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
names.map((n) => {
|
|
182
|
+
const c = channels.get(n) || {};
|
|
183
|
+
return {
|
|
184
|
+
key: n,
|
|
185
|
+
label: n,
|
|
186
|
+
count: c.messages || 0,
|
|
187
|
+
mention: unread.get(n) || 0,
|
|
188
|
+
dclass: c.deliveryClass,
|
|
189
|
+
replay: c.replay,
|
|
190
|
+
};
|
|
191
|
+
}),
|
|
177
192
|
);
|
|
178
193
|
$("channels").innerHTML = rows.map(chanRow).join("");
|
|
179
194
|
for (const el of $("channels").querySelectorAll(".chan")) el.onclick = () => select(el.dataset.ch);
|
|
180
195
|
}
|
|
181
196
|
|
|
182
197
|
// ── Sidebar: direct messages (per-peer roll-up → drill) ───────────────────────
|
|
183
|
-
const SEP = "
|
|
184
|
-
function
|
|
185
|
-
|
|
198
|
+
const SEP = "\u0001";
|
|
199
|
+
function peerById(id) {
|
|
200
|
+
return roster.find((x) => x.card?.id === id);
|
|
201
|
+
}
|
|
202
|
+
function roleOf(id) {
|
|
203
|
+
const r = peerById(id);
|
|
186
204
|
return r?.card?.role;
|
|
187
205
|
}
|
|
188
|
-
function rosterStatus(
|
|
189
|
-
const r =
|
|
206
|
+
function rosterStatus(id) {
|
|
207
|
+
const r = peerById(id);
|
|
190
208
|
return r ? r.status : "offline";
|
|
191
209
|
}
|
|
192
210
|
// id→name resolution shared by the DM lens and the all-activity feed. `from` carries a full
|
|
@@ -205,7 +223,12 @@ function displayNameOf(x, idx) {
|
|
|
205
223
|
if (!x) return "?";
|
|
206
224
|
if (typeof x === "object") return x.name || displayNameOf(x.id, idx);
|
|
207
225
|
if (idx.has(x)) return idx.get(x);
|
|
208
|
-
|
|
226
|
+
// Principal ids are exactly two NATS-safe tokens, owner.actor. Keep the opaque owner for
|
|
227
|
+
// namespace disambiguation, but require a long actor so ordinary dotted display names survive.
|
|
228
|
+
const principal = /^([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)$/.exec(x);
|
|
229
|
+
if (principal && principal[2].length >= 32)
|
|
230
|
+
return `${principal[1]}.${principal[2].slice(0, 6)}…`;
|
|
231
|
+
return /^[A-Z2-7]{32,}$/.test(x) ? x.slice(0, 6) + "…" : x; // legacy bare identity
|
|
209
232
|
}
|
|
210
233
|
|
|
211
234
|
// Group raw DMs into per-peer rows; each peer lists its counterparties (conversations).
|
|
@@ -213,15 +236,22 @@ function displayNameOf(x, idx) {
|
|
|
213
236
|
function dmPeers() {
|
|
214
237
|
if (isDemo) return DEMO.dmPeers;
|
|
215
238
|
const idx = nameIndex();
|
|
216
|
-
const nameOf = (x) => displayNameOf(x, idx);
|
|
217
239
|
const conv = new Map();
|
|
218
240
|
for (const m of dms) {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
conv.
|
|
241
|
+
const from = m.from?.id;
|
|
242
|
+
const to = typeof m.to === "string" ? m.to : m.to?.id;
|
|
243
|
+
if (!from || !to || from === to) continue;
|
|
244
|
+
const parts = [from, to].sort();
|
|
245
|
+
const key = parts.join(SEP);
|
|
246
|
+
if (!conv.has(key)) conv.set(key, { parts, msgs: [] });
|
|
247
|
+
conv.get(key).msgs.push({
|
|
248
|
+
ts: time(m.ts),
|
|
249
|
+
who: from,
|
|
250
|
+
whoName: displayNameOf(from, idx),
|
|
251
|
+
status: rosterStatus(from),
|
|
252
|
+
body: bodyText(m),
|
|
253
|
+
_ts: m.ts,
|
|
254
|
+
});
|
|
225
255
|
}
|
|
226
256
|
const peers = new Map();
|
|
227
257
|
for (const c of conv.values()) {
|
|
@@ -229,17 +259,26 @@ function dmPeers() {
|
|
|
229
259
|
const last = c.msgs.length ? c.msgs[c.msgs.length - 1]._ts : 0;
|
|
230
260
|
for (const p of c.parts) {
|
|
231
261
|
const other = c.parts[0] === p ? c.parts[1] : c.parts[0];
|
|
232
|
-
if (!peers.has(p)) peers.set(p, { name: p, conversations: [], last: 0 });
|
|
262
|
+
if (!peers.has(p)) peers.set(p, { id: p, name: displayNameOf(p, idx), conversations: [], last: 0 });
|
|
233
263
|
const pe = peers.get(p);
|
|
234
|
-
pe.conversations.push({
|
|
264
|
+
pe.conversations.push({
|
|
265
|
+
with: other,
|
|
266
|
+
withName: displayNameOf(other, idx),
|
|
267
|
+
role: roleOf(other),
|
|
268
|
+
status: rosterStatus(other),
|
|
269
|
+
unread: 0,
|
|
270
|
+
last,
|
|
271
|
+
msgs: c.msgs,
|
|
272
|
+
});
|
|
235
273
|
pe.last = Math.max(pe.last, last);
|
|
236
274
|
}
|
|
237
275
|
}
|
|
238
276
|
return [...peers.values()]
|
|
239
277
|
.map((p) => ({
|
|
278
|
+
id: p.id,
|
|
240
279
|
name: p.name,
|
|
241
|
-
role: roleOf(p.
|
|
242
|
-
status: rosterStatus(p.
|
|
280
|
+
role: roleOf(p.id),
|
|
281
|
+
status: rosterStatus(p.id),
|
|
243
282
|
unread: 0,
|
|
244
283
|
threads: p.conversations.length,
|
|
245
284
|
conversations: p.conversations.sort((a, b) => b.last - a.last),
|
|
@@ -248,7 +287,7 @@ function dmPeers() {
|
|
|
248
287
|
.sort((a, b) => b.last - a.last);
|
|
249
288
|
}
|
|
250
289
|
function dmPeerRow(p, expanded) {
|
|
251
|
-
return `<div class="dm${expanded ? " sel" : ""}" data-dm="${esc(p.
|
|
290
|
+
return `<div class="dm${expanded ? " sel" : ""}" data-dm="${esc(p.id)}">
|
|
252
291
|
<span class="caret">${expanded ? "▾" : "▸"}</span>
|
|
253
292
|
<span class="l">
|
|
254
293
|
<span class="dot ${p.status}">${GLYPH[p.status] ?? "○"}</span>
|
|
@@ -265,7 +304,7 @@ function dmSubRow(peer, c) {
|
|
|
265
304
|
<span class="ln">↳</span>
|
|
266
305
|
<span class="l">
|
|
267
306
|
<span class="dot ${c.status}">${GLYPH[c.status] ?? "○"}</span>
|
|
268
|
-
<span class="nm">${esc(c.with)}</span>
|
|
307
|
+
<span class="nm">${esc(c.withName ?? c.with)}</span>
|
|
269
308
|
${c.role ? `<span class="role">${esc(c.role)}</span>` : ""}
|
|
270
309
|
</span>
|
|
271
310
|
${c.unread ? `<span class="mention">${c.unread}</span>` : ""}
|
|
@@ -279,9 +318,9 @@ function renderDMs() {
|
|
|
279
318
|
}
|
|
280
319
|
let html = "";
|
|
281
320
|
for (const p of peers) {
|
|
282
|
-
const expanded = !!dmSel && dmSel.peer === p.
|
|
321
|
+
const expanded = !!dmSel && dmSel.peer === p.id;
|
|
283
322
|
html += dmPeerRow(p, expanded);
|
|
284
|
-
if (expanded) for (const c of p.conversations) html += dmSubRow(p.
|
|
323
|
+
if (expanded) for (const c of p.conversations) html += dmSubRow(p.id, c);
|
|
285
324
|
}
|
|
286
325
|
$("dms").innerHTML = html;
|
|
287
326
|
for (const el of $("dms").querySelectorAll("[data-dm]")) {
|
|
@@ -297,6 +336,42 @@ function renderSidebarNav() {
|
|
|
297
336
|
}
|
|
298
337
|
|
|
299
338
|
// ── Feed rows (all-activity) ──────────────────────────────────────────────────
|
|
339
|
+
// Progressive disclosure: long agent essays collapse to a few lines; full text one click away.
|
|
340
|
+
// Never silently truncate — the expand control is the only path to the rest. Bodies render markdown
|
|
341
|
+
// (md.js), so the clamp caps rendered height, not raw characters. `expandAll` opens every body at
|
|
342
|
+
// once (the channel-wide toggle); an individual "show more" still flips one.
|
|
343
|
+
const BODY_CLAMP_CHARS = 280;
|
|
344
|
+
const isLong = (text) => !!text && text.length > BODY_CLAMP_CHARS;
|
|
345
|
+
function bodyBlock(text) {
|
|
346
|
+
const html = MD.render(text || "");
|
|
347
|
+
if (!isLong(text)) return `<div class="body md">${html}</div>`;
|
|
348
|
+
const open = expandAll ? " open" : "";
|
|
349
|
+
const label = expandAll ? "show less" : "show more";
|
|
350
|
+
return `<div class="body md clamp${open}">
|
|
351
|
+
<div class="body-text">${html}</div>
|
|
352
|
+
<button type="button" class="body-more">${label}</button>
|
|
353
|
+
</div>`;
|
|
354
|
+
}
|
|
355
|
+
// The channel-wide expand/collapse-all control — rendered only when a list actually has clamped
|
|
356
|
+
// bodies (nothing to toggle otherwise). Reuses the mode-chip look.
|
|
357
|
+
function toggleAllChip(items, bodyOf = (x) => x.body) {
|
|
358
|
+
if (!items.some((x) => isLong(bodyOf(x)))) return "";
|
|
359
|
+
return `<span class="chip toggle-all" id="toggle-all" title="${expandAll ? "collapse every message" : "expand every message"}">${expandAll ? "⊟ collapse all" : "⊞ expand all"}</span>`;
|
|
360
|
+
}
|
|
361
|
+
function bindToggleAll(root) {
|
|
362
|
+
const el = root.querySelector("#toggle-all");
|
|
363
|
+
if (el) el.onclick = () => ((expandAll = !expandAll), renderCenter());
|
|
364
|
+
}
|
|
365
|
+
function bindBodyToggles(root) {
|
|
366
|
+
for (const btn of root.querySelectorAll(".body-more")) {
|
|
367
|
+
btn.onclick = (ev) => {
|
|
368
|
+
ev.stopPropagation();
|
|
369
|
+
const block = btn.closest(".body");
|
|
370
|
+
const open = block.classList.toggle("open");
|
|
371
|
+
btn.textContent = open ? "show less" : "show more";
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
300
375
|
function rowHTML(e) {
|
|
301
376
|
if (e.type === "sys") return `<div class="sys">${esc(e.text)}</div>`;
|
|
302
377
|
if (e.type === "rollup")
|
|
@@ -315,7 +390,7 @@ function rowHTML(e) {
|
|
|
315
390
|
${tgt ? `<span class="tgt">${esc(tgt)}</span>` : ""}
|
|
316
391
|
${e.sub ? `<span class="subpill">${esc(e.sub)}</span>` : ""}
|
|
317
392
|
</div>
|
|
318
|
-
|
|
393
|
+
${bodyBlock(e.body)}
|
|
319
394
|
</div>
|
|
320
395
|
</div>`;
|
|
321
396
|
}
|
|
@@ -351,9 +426,9 @@ function renderAllActivity() {
|
|
|
351
426
|
<span class="h">✸ All activity</span>
|
|
352
427
|
<span class="sub">${esc(sub)}</span>
|
|
353
428
|
<span class="ctrls">
|
|
429
|
+
${toggleAllChip(rows)}
|
|
354
430
|
${MODES.map((m) => `<span class="chip mode${modes.has(m) ? " on" : ""}" data-mode="${m}">${m}</span>`).join("")}
|
|
355
431
|
<span class="chip pause${paused ? " on" : ""}" id="pause">${paused ? "▶ resume" : "⏸ pause"}</span>
|
|
356
|
-
<span class="chip static">muted · 2</span>
|
|
357
432
|
</span>
|
|
358
433
|
</div>
|
|
359
434
|
<div class="feed">${rows.length ? rows.map(rowHTML).join("") : `<div class="empty">waiting for messages…</div>`}</div>`;
|
|
@@ -365,6 +440,8 @@ function renderAllActivity() {
|
|
|
365
440
|
};
|
|
366
441
|
const pause = center.querySelector("#pause");
|
|
367
442
|
if (pause) pause.onclick = () => ((paused = !paused), renderAllActivity());
|
|
443
|
+
bindToggleAll(center);
|
|
444
|
+
bindBodyToggles(center);
|
|
368
445
|
const feed = center.querySelector(".feed");
|
|
369
446
|
if (atBottom && !paused) feed.scrollTop = feed.scrollHeight;
|
|
370
447
|
}
|
|
@@ -378,7 +455,7 @@ function cmsgHTML(m) {
|
|
|
378
455
|
<span class="dot ${m.status}">${GLYPH[m.status] ?? "●"}</span>
|
|
379
456
|
<div class="c">
|
|
380
457
|
<div class="l1"><span class="who">${esc(m.who)}</span>${m.role ? `<span class="role">${esc(m.role)}</span>` : ""}</div>
|
|
381
|
-
|
|
458
|
+
${bodyBlock(m.body)}
|
|
382
459
|
${m.thread ? `<span class="thread">${esc(m.thread)}</span>` : ""}
|
|
383
460
|
</div>
|
|
384
461
|
</div>`;
|
|
@@ -386,49 +463,63 @@ function cmsgHTML(m) {
|
|
|
386
463
|
function channelMembers(msgs) {
|
|
387
464
|
const seen = new Map();
|
|
388
465
|
for (const msg of msgs) {
|
|
389
|
-
const
|
|
390
|
-
if (!
|
|
391
|
-
seen.set(
|
|
466
|
+
const id = msg.from?.id;
|
|
467
|
+
if (!id || seen.has(id)) continue;
|
|
468
|
+
seen.set(id, { name: msg.from?.name ?? "?", role: msg.from?.role, status: rosterStatus(id) });
|
|
392
469
|
}
|
|
393
470
|
return [...seen.values()];
|
|
394
471
|
}
|
|
395
472
|
function renderChannel() {
|
|
396
473
|
const name = selected;
|
|
474
|
+
const meta = channels.get(name) || {};
|
|
397
475
|
let items, memberCount, msgCount, desc;
|
|
398
476
|
if (isDemo) {
|
|
399
477
|
items = DEMO.cv.messages;
|
|
400
478
|
memberCount = DEMO.cv.members.length;
|
|
401
|
-
msgCount =
|
|
402
|
-
desc = name === "team.backend" ? "Backend coordination — channels, endpoint, NATS.
|
|
479
|
+
msgCount = meta.messages ?? 51;
|
|
480
|
+
desc = name === "team.backend" ? "Backend coordination — channels, endpoint, NATS." : meta.description || "";
|
|
403
481
|
} else {
|
|
404
482
|
items = channelMsgs.map((msg) => ({
|
|
405
483
|
ts: time(msg.ts),
|
|
406
|
-
status: rosterStatus(msg.from?.
|
|
484
|
+
status: rosterStatus(msg.from?.id),
|
|
407
485
|
who: msg.from?.name ?? "?",
|
|
408
486
|
role: msg.from?.role,
|
|
409
487
|
body: bodyText(msg),
|
|
410
488
|
}));
|
|
411
489
|
memberCount = channelMembers(channelMsgs).length;
|
|
412
|
-
msgCount =
|
|
413
|
-
desc = "";
|
|
490
|
+
msgCount = meta.messages ?? items.length;
|
|
491
|
+
desc = meta.description || "";
|
|
414
492
|
}
|
|
493
|
+
// Effective policy chips from /api/channels (server-resolved; never re-derived in the browser).
|
|
494
|
+
const policy = [];
|
|
495
|
+
if (meta.deliveryClass) policy.push(`<span class="chip policy" title="effective delivery class">${esc(meta.deliveryClass)}</span>`);
|
|
496
|
+
if (meta.replay === false) policy.push(`<span class="chip policy" title="join does not backfill history">replay off</span>`);
|
|
497
|
+
else if (meta.replay === true)
|
|
498
|
+
policy.push(`<span class="chip policy" title="join backfills retained history">${meta.replayWindow ? `replay ${esc(meta.replayWindow)}` : "replay on"}</span>`);
|
|
415
499
|
const sub = name.includes(".") ? `subtree of ${name.split(".")[0]}.>` : "";
|
|
500
|
+
const purpose = [desc, `${plural(memberCount, "member")}`, `${plural(msgCount, "message")}`]
|
|
501
|
+
.filter(Boolean)
|
|
502
|
+
.map((b) => esc(String(b)))
|
|
503
|
+
.join(" · ");
|
|
416
504
|
$("center").innerHTML = `
|
|
417
505
|
<div class="ch-head">
|
|
418
506
|
<div class="row">
|
|
419
507
|
<div class="title"><span class="h"># ${esc(name)}</span>${sub ? `<span class="sub">${esc(sub)}</span>` : ""}</div>
|
|
420
508
|
<div class="ctrls">
|
|
509
|
+
${toggleAllChip(items)}
|
|
421
510
|
<span class="chip mode on">👥 ${plural(memberCount, "member")}</span>
|
|
422
|
-
|
|
423
|
-
|
|
511
|
+
${policy.join("")}
|
|
512
|
+
${isDemo ? `<span class="chip static" title="demo only">✦ summarize</span><span class="chip static" title="demo only">🔕 mute</span>` : ""}
|
|
424
513
|
${isDemo ? "" : `<span class="chip danger" id="ch-del" title="Delete this channel and all its messages">🗑 delete</span>`}
|
|
425
514
|
</div>
|
|
426
515
|
</div>
|
|
427
|
-
<div class="purpose">${
|
|
516
|
+
<div class="purpose">${purpose}</div>
|
|
428
517
|
</div>
|
|
429
518
|
<div class="clist">${items.length ? items.map(cmsgHTML).join("") : `<div class="empty">no messages</div>`}</div>`;
|
|
430
519
|
const list = $("center").querySelector(".clist");
|
|
431
520
|
list.scrollTop = list.scrollHeight;
|
|
521
|
+
bindToggleAll($("center"));
|
|
522
|
+
bindBodyToggles($("center"));
|
|
432
523
|
const del = $("center").querySelector("#ch-del");
|
|
433
524
|
if (del) del.onclick = () => deleteChannel(name);
|
|
434
525
|
}
|
|
@@ -456,32 +547,33 @@ async function deleteChannel(name) {
|
|
|
456
547
|
}
|
|
457
548
|
|
|
458
549
|
// ── Direct-messages thread (centre) ───────────────────────────────────────────
|
|
459
|
-
function dmMsgHTML(m, peer, withName) {
|
|
460
|
-
const to = m.who === peer ? withName :
|
|
550
|
+
function dmMsgHTML(m, peer, peerName, withName) {
|
|
551
|
+
const to = m.who === peer ? withName : peerName;
|
|
461
552
|
return `<div class="cmsg">
|
|
462
553
|
<span class="ts">${esc(m.ts)}</span>
|
|
463
554
|
<span class="dot ${m.status}">${GLYPH[m.status] ?? "●"}</span>
|
|
464
555
|
<div class="c">
|
|
465
|
-
<div class="l1"><span class="who">${esc(m.who)}</span><span class="dir">→ ${esc(to)}</span></div>
|
|
466
|
-
<div class="body">${
|
|
556
|
+
<div class="l1"><span class="who">${esc(m.whoName ?? m.who)}</span><span class="dir">→ ${esc(to)}</span></div>
|
|
557
|
+
<div class="body md">${MD.render(m.body)}</div>
|
|
467
558
|
</div>
|
|
468
559
|
</div>`;
|
|
469
560
|
}
|
|
470
561
|
function renderDMThread() {
|
|
471
562
|
const peer = dmSel.peer;
|
|
472
|
-
const pe = dmPeers().find((p) => p.
|
|
563
|
+
const pe = dmPeers().find((p) => p.id === peer);
|
|
473
564
|
const conv = pe && (pe.conversations.find((c) => c.with === dmSel.with) || pe.conversations[0]);
|
|
474
|
-
const
|
|
565
|
+
const peerName = pe?.name ?? displayNameOf(peer, nameIndex());
|
|
566
|
+
const withName = conv?.withName ?? displayNameOf(dmSel.with, nameIndex());
|
|
475
567
|
const msgs = conv ? conv.msgs : []; // display-ready (ts, who, status, body) for demo + live
|
|
476
568
|
$("center").innerHTML = `
|
|
477
569
|
<div class="ch-head">
|
|
478
570
|
<div class="row">
|
|
479
|
-
<div class="title"><span class="h">${esc(
|
|
480
|
-
<div class="ctrls"
|
|
571
|
+
<div class="title"><span class="h">${esc(peerName)} ↔ ${esc(withName)}</span><span class="dtag">direct</span></div>
|
|
572
|
+
<div class="ctrls"></div>
|
|
481
573
|
</div>
|
|
482
574
|
<div class="purpose">unicast · private to the two of them · ${plural(msgs.length, "message")}</div>
|
|
483
575
|
</div>
|
|
484
|
-
<div class="clist">${msgs.length ? msgs.map((m) => dmMsgHTML(m, peer, withName)).join("") : `<div class="empty">no messages</div>`}</div>`;
|
|
576
|
+
<div class="clist">${msgs.length ? msgs.map((m) => dmMsgHTML(m, peer, peerName, withName)).join("") : `<div class="empty">no messages</div>`}</div>`;
|
|
485
577
|
const list = $("center").querySelector(".clist");
|
|
486
578
|
list.scrollTop = list.scrollHeight;
|
|
487
579
|
}
|
|
@@ -500,13 +592,18 @@ function cardHTML(c) {
|
|
|
500
592
|
</div>`;
|
|
501
593
|
}
|
|
502
594
|
function waitingCards() {
|
|
595
|
+
// Sort by name (stable, honest) — p.ts is last heartbeat, not time-entered-waiting, so we
|
|
596
|
+
// must not present heartbeat order as an age-ordered priority queue.
|
|
503
597
|
return roster
|
|
504
598
|
.filter((p) => p.status === "waiting")
|
|
505
|
-
.sort(
|
|
599
|
+
.sort(
|
|
600
|
+
(a, b) =>
|
|
601
|
+
a.card.name.localeCompare(b.card.name) || a.card.id.localeCompare(b.card.id),
|
|
602
|
+
)
|
|
506
603
|
.map((p) => ({
|
|
507
604
|
tone: "amber",
|
|
508
605
|
cat: "WAITING",
|
|
509
|
-
age: ago(p.ts),
|
|
606
|
+
age: `seen ${ago(p.ts)}`, // Presence.ts = last heartbeat, not blocked-duration
|
|
510
607
|
title: `${p.card.name} is waiting`,
|
|
511
608
|
role: p.card.role,
|
|
512
609
|
// p.activity is the Claude Code Notification text (the actual blocking prompt/permission).
|
|
@@ -516,14 +613,22 @@ function waitingCards() {
|
|
|
516
613
|
}
|
|
517
614
|
function renderRail() {
|
|
518
615
|
const cards = isDemo ? DEMO.cards : waitingCards();
|
|
519
|
-
$("
|
|
520
|
-
$("rail")
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
616
|
+
const main = $("main");
|
|
617
|
+
const rail = $("rail");
|
|
618
|
+
// Always-present lane, not always full width: collapse to a strip when nothing needs a human.
|
|
619
|
+
if (!cards.length) {
|
|
620
|
+
main.classList.add("rail-thin");
|
|
621
|
+
rail.className = "rail collapsed";
|
|
622
|
+
rail.innerHTML = `<div class="rail-collapsed" title="nothing waiting">NEEDS YOU · all clear</div>`;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
main.classList.remove("rail-thin");
|
|
626
|
+
rail.className = "rail";
|
|
627
|
+
rail.innerHTML =
|
|
628
|
+
`<div class="rail-head"><span class="t">NEEDS YOU</span><span class="n">${cards.length}</span></div>` +
|
|
629
|
+
cards.map(cardHTML).join("") +
|
|
630
|
+
`<div class="rail-foot">Everything else stays quiet in the feed.</div>`;
|
|
631
|
+
for (const el of rail.querySelectorAll(".card[data-agent]"))
|
|
527
632
|
el.onclick = () => selectAgent(el.dataset.agent);
|
|
528
633
|
}
|
|
529
634
|
|
|
@@ -549,12 +654,20 @@ function renderAgentDetail() {
|
|
|
549
654
|
const meta = card.meta || {};
|
|
550
655
|
const waiting = p.status === "waiting";
|
|
551
656
|
const who = card.role ? `${esc(card.name)}<span class="crole">${esc(card.role)}</span>` : esc(card.name);
|
|
552
|
-
|
|
657
|
+
// p.ts is last heartbeat (Presence.ts), not status-entered-at — never say "waiting 4m".
|
|
658
|
+
const since = `${esc(p.status)} · seen ${esc(ago(p.ts))}`;
|
|
553
659
|
|
|
660
|
+
// Model when known; harness agent with no model → "not reported"; no harness and no model → nothing.
|
|
661
|
+
const modelBadge = meta.model
|
|
662
|
+
? `<span class="d-badge model" title="model">${esc(meta.model)}${meta.variant ? `<span class="var"> · ${esc(meta.variant)}</span>` : ""}</span>`
|
|
663
|
+
: meta.connector
|
|
664
|
+
? `<span class="d-badge model muted" title="host did not report a model">not reported</span>`
|
|
665
|
+
: "";
|
|
554
666
|
const badges = [
|
|
555
667
|
card.kind ? `<span class="d-badge">${esc(card.kind)}</span>` : "",
|
|
556
668
|
harnessBadge(meta.connector),
|
|
557
|
-
|
|
669
|
+
modelBadge,
|
|
670
|
+
attentionChip(p.attention), // open/absent → ""
|
|
558
671
|
].join("");
|
|
559
672
|
const desc = card.description ? `<div class="d-desc">${esc(card.description)}</div>` : "";
|
|
560
673
|
const blocked = waiting
|
|
@@ -570,8 +683,17 @@ function renderAgentDetail() {
|
|
|
570
683
|
.map((s) => `<div class="d-skill"><span class="nm">${esc(s.name || s.id)}</span>${s.description ? `<span class="dsc">${esc(s.description)}</span>` : ""}</div>`)
|
|
571
684
|
.join("")}</div>`
|
|
572
685
|
: "";
|
|
573
|
-
//
|
|
574
|
-
const
|
|
686
|
+
// Per-channel attention overrides (quiet/muted). Advisory receive-side presentation, not ACL.
|
|
687
|
+
const modeEntries = Object.entries(p.channelModes || {}).sort(([a], [b]) => a.localeCompare(b));
|
|
688
|
+
const channelModes = modeEntries.length
|
|
689
|
+
? `<div class="d-chips">${modeEntries
|
|
690
|
+
.map(([ch, m]) => `<span class="d-chip mode-${esc(m)}" title="per-channel attention">#${esc(ch)} · ${esc(m)}</span>`)
|
|
691
|
+
.join("")}</div>`
|
|
692
|
+
: "";
|
|
693
|
+
// Any other meta beyond the badges (connector/model/variant already first-class), generically rendered.
|
|
694
|
+
const extra = Object.entries(meta)
|
|
695
|
+
.filter(([k]) => k !== "connector" && k !== "model" && k !== "variant")
|
|
696
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
575
697
|
const metaKv = extra.length
|
|
576
698
|
? `<div class="d-kv">${extra
|
|
577
699
|
.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><span class="v">${esc(typeof v === "string" ? v : JSON.stringify(v))}</span></div>`)
|
|
@@ -590,6 +712,7 @@ function renderAgentDetail() {
|
|
|
590
712
|
${badges ? `<div class="d-badges">${badges}</div>` : ""}
|
|
591
713
|
${desc}
|
|
592
714
|
${blocked}
|
|
715
|
+
${sec("Channel attention", channelModes)}
|
|
593
716
|
${sec("Tags", tags)}
|
|
594
717
|
${sec("Skills", skills)}
|
|
595
718
|
${sec("Metadata", metaKv)}
|
|
@@ -608,9 +731,11 @@ function renderCenter() {
|
|
|
608
731
|
function refreshDerived() {
|
|
609
732
|
const counts = { working: 0, waiting: 0, idle: 0, offline: 0 };
|
|
610
733
|
for (const p of roster) counts[p.status] = (counts[p.status] ?? 0) + 1;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
734
|
+
// Stalest heartbeat among LIVE peers only — offline ages grow forever and are pure noise in a
|
|
735
|
+
// golden-signal slot. Among live peers this catches a lapsed heartbeat before the offline flip.
|
|
736
|
+
const live = roster.filter((p) => p.status !== "offline" && typeof p.ts === "number");
|
|
737
|
+
const stalest = live.length ? agoShort(Math.min(...live.map((p) => p.ts))) : "—";
|
|
738
|
+
renderTiles(counts, stalest);
|
|
614
739
|
$("online-c").textContent = roster.filter((p) => p.status !== "offline").length;
|
|
615
740
|
renderRoster(rosterRows()); // online list; offline peers drop out but still ride the header tiles
|
|
616
741
|
renderDMs(); // peer statuses may have changed
|
|
@@ -640,7 +765,7 @@ async function select(key) {
|
|
|
640
765
|
}
|
|
641
766
|
function selectDM(peer, w) {
|
|
642
767
|
agentSel = null;
|
|
643
|
-
const pe = dmPeers().find((p) => p.
|
|
768
|
+
const pe = dmPeers().find((p) => p.id === peer);
|
|
644
769
|
dmSel = { peer, with: w || (pe && pe.conversations[0] ? pe.conversations[0].with : null) };
|
|
645
770
|
selected = null;
|
|
646
771
|
renderSidebarNav();
|
|
@@ -653,7 +778,25 @@ async function refresh() {
|
|
|
653
778
|
roster = await (await fetch("/api/roster")).json();
|
|
654
779
|
refreshDerived();
|
|
655
780
|
const list = await (await fetch("/api/channels")).json();
|
|
656
|
-
|
|
781
|
+
// L2 shape is flat {channel,messages,description?,replay,replayWindow?,deliveryClass}.
|
|
782
|
+
// Tolerate a nested-config server briefly (pre-restart) without re-deriving defaults.
|
|
783
|
+
channels = new Map(
|
|
784
|
+
list.map((c) => {
|
|
785
|
+
if (c.replay !== undefined || c.deliveryClass !== undefined || (c.description && !c.config))
|
|
786
|
+
return [c.channel, c];
|
|
787
|
+
const cfg = c.config || {};
|
|
788
|
+
return [
|
|
789
|
+
c.channel,
|
|
790
|
+
{
|
|
791
|
+
messages: c.messages,
|
|
792
|
+
description: cfg.description,
|
|
793
|
+
replay: cfg.replay,
|
|
794
|
+
replayWindow: cfg.replayWindow,
|
|
795
|
+
deliveryClass: cfg.deliveryClass,
|
|
796
|
+
},
|
|
797
|
+
];
|
|
798
|
+
}),
|
|
799
|
+
);
|
|
657
800
|
dms = await (await fetch("/api/dms?limit=500")).json();
|
|
658
801
|
renderSidebarNav();
|
|
659
802
|
if (agentSel) {
|
|
@@ -679,7 +822,10 @@ function onMessage(entry) {
|
|
|
679
822
|
renderDMs();
|
|
680
823
|
}
|
|
681
824
|
if (msg.channel) {
|
|
682
|
-
|
|
825
|
+
// A live message can beat the next channel-registry refresh. Keep its count but leave the
|
|
826
|
+
// effective policy unknown until the server supplies the resolved L2 row.
|
|
827
|
+
const ch = channels.get(msg.channel);
|
|
828
|
+
channels.set(msg.channel, { ...(ch ?? {}), messages: (ch?.messages ?? 0) + 1 });
|
|
683
829
|
if (!dmSel && selected === msg.channel) {
|
|
684
830
|
channelMsgs.push(msg);
|
|
685
831
|
if (channelMsgs.length > 500) channelMsgs.shift();
|
|
@@ -741,10 +887,10 @@ const DEMO = {
|
|
|
741
887
|
{ type: "msg", mode: "chat", ts: "10:51", who: "linus", role: "reviewer", target: "#team.review", body: "left 2 comments on PR #42 — small nits" },
|
|
742
888
|
],
|
|
743
889
|
cards: [
|
|
744
|
-
{ 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" },
|
|
745
|
-
{ 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" },
|
|
746
|
-
{ tone: "orange", cat: "UNCLAIMED", age: "3m", title: "Anycast request unhandled", desc: "@reviewer · review PR #51 — no peer has claimed it.", primary: "Assign…", secondary: "Claim" },
|
|
747
|
-
{ tone: "blue", cat: "APPROVAL", age: "just now", title: "dave requests approval", desc: "Wants to force-push to main — irreversible.", primary: "Approve", secondary: "Deny" },
|
|
890
|
+
{ tone: "amber", cat: "WAITING", age: "seen 4m", title: "alice is blocked", desc: "Needs OPENAI_API_KEY to keep planning the auth module.", primary: "Provide key", secondary: "Open thread" },
|
|
891
|
+
{ tone: "red", cat: "FAILED", age: "seen 1m", title: "bob's task failed", desc: "2 tests failing in channels.ts after the refactor.", primary: "Inspect", secondary: "Retry" },
|
|
892
|
+
{ tone: "orange", cat: "UNCLAIMED", age: "seen 3m", title: "Anycast request unhandled", desc: "@reviewer · review PR #51 — no peer has claimed it.", primary: "Assign…", secondary: "Claim" },
|
|
893
|
+
{ tone: "blue", cat: "APPROVAL", age: "seen just now", title: "dave requests approval", desc: "Wants to force-push to main — irreversible.", primary: "Approve", secondary: "Deny" },
|
|
748
894
|
],
|
|
749
895
|
cv: {
|
|
750
896
|
messages: [
|
|
@@ -765,23 +911,23 @@ const DEMO = {
|
|
|
765
911
|
],
|
|
766
912
|
},
|
|
767
913
|
dmPeers: [
|
|
768
|
-
{ name: "alice", role: "planner", status: "waiting", unread: 2, threads: 3, conversations: [
|
|
914
|
+
{ id: "alice", name: "alice", role: "planner", status: "waiting", unread: 2, threads: 3, conversations: [
|
|
769
915
|
{ with: "bob", role: "builder", status: "working", unread: 0, msgs: ab },
|
|
770
916
|
{ with: "dave", role: "builder", status: "working", unread: 1, msgs: ad },
|
|
771
917
|
{ with: "scout", role: "observer", status: "idle", unread: 1, msgs: as },
|
|
772
918
|
] },
|
|
773
|
-
{ name: "bob", role: "builder", status: "working", unread: 0, threads: 2, conversations: [
|
|
919
|
+
{ id: "bob", name: "bob", role: "builder", status: "working", unread: 0, threads: 2, conversations: [
|
|
774
920
|
{ with: "alice", role: "planner", status: "waiting", unread: 0, msgs: ab },
|
|
775
921
|
{ with: "dave", role: "builder", status: "working", unread: 0, msgs: bd },
|
|
776
922
|
] },
|
|
777
|
-
{ name: "dave", role: "builder", status: "working", unread: 1, threads: 2, conversations: [
|
|
923
|
+
{ id: "dave", name: "dave", role: "builder", status: "working", unread: 1, threads: 2, conversations: [
|
|
778
924
|
{ with: "alice", role: "planner", status: "waiting", unread: 1, msgs: ad },
|
|
779
925
|
{ with: "bob", role: "builder", status: "working", unread: 0, msgs: bd },
|
|
780
926
|
] },
|
|
781
|
-
{ name: "linus", role: "reviewer", status: "working", unread: 0, threads: 1, conversations: [
|
|
927
|
+
{ id: "linus", name: "linus", role: "reviewer", status: "working", unread: 0, threads: 1, conversations: [
|
|
782
928
|
{ with: "maya", role: "researcher", status: "idle", unread: 0, msgs: lm },
|
|
783
929
|
] },
|
|
784
|
-
{ name: "maya", role: "researcher", status: "idle", unread: 0, threads: 1, conversations: [
|
|
930
|
+
{ id: "maya", name: "maya", role: "researcher", status: "idle", unread: 0, threads: 1, conversations: [
|
|
785
931
|
{ with: "linus", role: "reviewer", status: "working", unread: 0, msgs: lm },
|
|
786
932
|
] },
|
|
787
933
|
],
|
|
@@ -795,12 +941,12 @@ function renderDemo() {
|
|
|
795
941
|
renderRoster(DEMO.roster);
|
|
796
942
|
// Counts sum to 112 → the "all activity" total matches the reference.
|
|
797
943
|
channels = new Map([
|
|
798
|
-
["general", 24],
|
|
799
|
-
["planning", 12],
|
|
800
|
-
["team.backend", 51],
|
|
801
|
-
["team.frontend", 18],
|
|
802
|
-
["team.review", 7],
|
|
803
|
-
["incidents", 0],
|
|
944
|
+
["general", { messages: 24, replay: true, deliveryClass: "durable" }],
|
|
945
|
+
["planning", { messages: 12, replay: true, deliveryClass: "durable" }],
|
|
946
|
+
["team.backend", { messages: 51, replay: true, deliveryClass: "durable" }],
|
|
947
|
+
["team.frontend", { messages: 18, replay: true, deliveryClass: "durable" }],
|
|
948
|
+
["team.review", { messages: 7, replay: true, deliveryClass: "durable" }],
|
|
949
|
+
["incidents", { messages: 0, replay: true, deliveryClass: "durable" }],
|
|
804
950
|
]);
|
|
805
951
|
unread = new Map([["planning", 2], ["team.review", 1]]);
|
|
806
952
|
renderSidebarNav();
|
|
@@ -808,6 +954,124 @@ function renderDemo() {
|
|
|
808
954
|
renderRail();
|
|
809
955
|
}
|
|
810
956
|
|
|
957
|
+
// ── Resizable sidebars ─────────────────────────────────────────────────────────
|
|
958
|
+
// Left nav + right rail widths are drag-adjustable and persisted per browser. The centre column is
|
|
959
|
+
// minmax(0,1fr), so it just absorbs the remainder; we only clamp the sides so the centre can't be
|
|
960
|
+
// squeezed away. Double-click a handle to reset that side.
|
|
961
|
+
const SIDES = {
|
|
962
|
+
left: { cssVar: "--nav-w", key: "cotal.navW", def: 300, min: 200, max: 520, other: "right" },
|
|
963
|
+
right: { cssVar: "--rail-w", key: "cotal.railW", def: 340, min: 240, max: 560, other: "left" },
|
|
964
|
+
};
|
|
965
|
+
const sideW = (side) =>
|
|
966
|
+
parseInt(getComputedStyle(document.documentElement).getPropertyValue(SIDES[side].cssVar)) || SIDES[side].def;
|
|
967
|
+
function clampSide(side, px) {
|
|
968
|
+
const s = SIDES[side];
|
|
969
|
+
const hardMax = Math.min(s.max, window.innerWidth - sideW(s.other) - 360); // keep the centre ≥ 360px
|
|
970
|
+
return Math.round(Math.max(s.min, Math.min(Math.max(hardMax, s.min), px)));
|
|
971
|
+
}
|
|
972
|
+
const applySide = (side, px) => document.documentElement.style.setProperty(SIDES[side].cssVar, px + "px");
|
|
973
|
+
function setupResizers() {
|
|
974
|
+
for (const side of Object.keys(SIDES)) {
|
|
975
|
+
const saved = Number(localStorage.getItem(SIDES[side].key));
|
|
976
|
+
if (saved) applySide(side, clampSide(side, saved));
|
|
977
|
+
}
|
|
978
|
+
const bind = (id, side) => {
|
|
979
|
+
const el = $(id);
|
|
980
|
+
if (!el) return;
|
|
981
|
+
el.onpointerdown = (e) => {
|
|
982
|
+
e.preventDefault();
|
|
983
|
+
el.setPointerCapture(e.pointerId);
|
|
984
|
+
el.classList.add("drag");
|
|
985
|
+
const startX = e.clientX, startW = sideW(side);
|
|
986
|
+
const move = (ev) => applySide(side, clampSide(side, side === "left" ? startW + (ev.clientX - startX) : startW - (ev.clientX - startX)));
|
|
987
|
+
const up = () => {
|
|
988
|
+
el.classList.remove("drag");
|
|
989
|
+
el.removeEventListener("pointermove", move);
|
|
990
|
+
el.removeEventListener("pointerup", up);
|
|
991
|
+
localStorage.setItem(SIDES[side].key, String(sideW(side)));
|
|
992
|
+
};
|
|
993
|
+
el.addEventListener("pointermove", move);
|
|
994
|
+
el.addEventListener("pointerup", up);
|
|
995
|
+
};
|
|
996
|
+
el.ondblclick = () => (applySide(side, SIDES[side].def), localStorage.removeItem(SIDES[side].key));
|
|
997
|
+
};
|
|
998
|
+
bind("rz-left", "left");
|
|
999
|
+
bind("rz-right", "right");
|
|
1000
|
+
// A shrinking window can leave a side too wide for the new centre floor — re-clamp both.
|
|
1001
|
+
window.addEventListener("resize", () => { for (const side of Object.keys(SIDES)) applySide(side, clampSide(side, sideW(side))); });
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ── Resizable nav sections (ONLINE / CHANNELS / DIRECT MESSAGES) ─────────────────
|
|
1005
|
+
// Two vertical handles split the three stacked sections. Roster + channels carry explicit heights
|
|
1006
|
+
// (persisted per browser); dms takes the remainder. Handle 1 (roster|channels) trades height
|
|
1007
|
+
// between those two and leaves dms fixed; handle 2 (channels|dms) grows channels and shrinks the
|
|
1008
|
+
// dms remainder. Double-click either handle to reset the split.
|
|
1009
|
+
const NAV_MIN = { roster: 90, channels: 110, dms: 90 };
|
|
1010
|
+
const NAV_HANDLES = 14; // two 7px handles
|
|
1011
|
+
function setupNavResizers() {
|
|
1012
|
+
const nav = document.querySelector("aside.nav");
|
|
1013
|
+
const rosterSec = $("sec-roster"), channelsSec = $("sec-channels");
|
|
1014
|
+
if (!nav || !rosterSec || !channelsSec) return;
|
|
1015
|
+
const hOf = (el) => el.getBoundingClientRect().height;
|
|
1016
|
+
const setH = (cssVar, px) => document.documentElement.style.setProperty(cssVar, Math.round(px) + "px");
|
|
1017
|
+
|
|
1018
|
+
// Handle 1: keep roster+channels sum constant so dms stays put.
|
|
1019
|
+
const dragRoster = (dy, r0, c0) => {
|
|
1020
|
+
const sum = r0 + c0;
|
|
1021
|
+
const roster = Math.max(NAV_MIN.roster, Math.min(sum - NAV_MIN.channels, r0 + dy));
|
|
1022
|
+
setH("--roster-h", roster);
|
|
1023
|
+
setH("--channels-h", sum - roster);
|
|
1024
|
+
};
|
|
1025
|
+
// Handle 2: grow channels; the dms remainder shrinks (floored at its min).
|
|
1026
|
+
const dragChannels = (dy, c0) => {
|
|
1027
|
+
const room = nav.clientHeight - hOf(rosterSec) - NAV_HANDLES - NAV_MIN.dms;
|
|
1028
|
+
setH("--channels-h", Math.max(NAV_MIN.channels, Math.min(room, c0 + dy)));
|
|
1029
|
+
};
|
|
1030
|
+
const persist = () => {
|
|
1031
|
+
localStorage.setItem("cotal.rosterH", String(Math.round(hOf(rosterSec))));
|
|
1032
|
+
localStorage.setItem("cotal.channelsH", String(Math.round(hOf(channelsSec))));
|
|
1033
|
+
};
|
|
1034
|
+
const reset = () => {
|
|
1035
|
+
document.documentElement.style.removeProperty("--roster-h");
|
|
1036
|
+
document.documentElement.style.removeProperty("--channels-h");
|
|
1037
|
+
localStorage.removeItem("cotal.rosterH");
|
|
1038
|
+
localStorage.removeItem("cotal.channelsH");
|
|
1039
|
+
};
|
|
1040
|
+
const bind = (id, onDrag, startVals) => {
|
|
1041
|
+
const el = $(id);
|
|
1042
|
+
if (!el) return;
|
|
1043
|
+
el.onpointerdown = (e) => {
|
|
1044
|
+
e.preventDefault();
|
|
1045
|
+
el.setPointerCapture(e.pointerId);
|
|
1046
|
+
el.classList.add("drag");
|
|
1047
|
+
const startY = e.clientY, vals = startVals();
|
|
1048
|
+
const move = (ev) => onDrag(ev.clientY - startY, ...vals);
|
|
1049
|
+
const up = () => {
|
|
1050
|
+
el.classList.remove("drag");
|
|
1051
|
+
el.removeEventListener("pointermove", move);
|
|
1052
|
+
el.removeEventListener("pointerup", up);
|
|
1053
|
+
persist();
|
|
1054
|
+
};
|
|
1055
|
+
el.addEventListener("pointermove", move);
|
|
1056
|
+
el.addEventListener("pointerup", up);
|
|
1057
|
+
};
|
|
1058
|
+
el.ondblclick = reset;
|
|
1059
|
+
};
|
|
1060
|
+
bind("rz-roster", dragRoster, () => [hOf(rosterSec), hOf(channelsSec)]);
|
|
1061
|
+
bind("rz-channels", dragChannels, () => [hOf(channelsSec)]);
|
|
1062
|
+
|
|
1063
|
+
// Restore persisted heights (px), clamped to the current nav height.
|
|
1064
|
+
const savedR = Number(localStorage.getItem("cotal.rosterH")), savedC = Number(localStorage.getItem("cotal.channelsH"));
|
|
1065
|
+
if (savedR || savedC)
|
|
1066
|
+
requestAnimationFrame(() => {
|
|
1067
|
+
const navH = nav.clientHeight;
|
|
1068
|
+
if (savedR) setH("--roster-h", Math.max(NAV_MIN.roster, Math.min(savedR, navH - NAV_MIN.channels - NAV_MIN.dms - NAV_HANDLES)));
|
|
1069
|
+
if (savedC) setH("--channels-h", Math.max(NAV_MIN.channels, Math.min(savedC, navH - hOf(rosterSec) - NAV_MIN.dms - NAV_HANDLES)));
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
setupResizers();
|
|
1074
|
+
setupNavResizers();
|
|
811
1075
|
if (isDemo) {
|
|
812
1076
|
document.title = "Cotal · demo";
|
|
813
1077
|
renderDemo();
|