@cotal-ai/web 0.12.0 → 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 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 -> total message count
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[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
- // ── Harness (host connector) branding ─────────────────────────────────────────
45
- // A brand colour + logo (drawn in currentColor) per connector, keyed by the card's meta.connector.
46
- // Claude and OpenCode use their official marks (public-domain SVG data from Simple Icons, CC0);
47
- // Hermes/Nous Research has no clean official mark, so it gets a custom messenger glyph. Unknown
48
- // connectors degrade to a neutral badge with the raw name (the compact roster form omits the icon).
49
- // Icons are inline SVG no network, no extra files.
50
- const HARNESS = {
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.icon}</span>` : "";
77
- return `<span class="harness-badge" style="--hc:${color}" title="agent harness · ${esc(text)}">${h ? h.icon : ""}<span class="hl">${esc(text)}</span></span>`;
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
- function renderTiles(counts, oldest) {
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", oldest, "oldest unattended"],
87
+ ["oldest", stalest, "stalest live heartbeat"],
94
88
  ];
95
89
  $("tiles").innerHTML = tiles
96
- .map(
97
- ([k, n, lbl]) => `<div class="tile ${k}">
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" : ""}${ch.muted ? " muted" : ""}" data-ch="${esc(ch.key)}">
161
- <span class="l">${lead}<span class="name">${esc(ch.label)}</span></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((a, b) => a + b, 0);
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
- key: n,
172
- label: n,
173
- count: channels.get(n),
174
- mention: unread.get(n) || 0,
175
- muted: channels.get(n) === 0,
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 roleOf(name) {
185
- const r = roster.find((x) => x.card?.name === name);
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(name) {
189
- const r = roster.find((x) => x.card?.name === name);
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
- return /^[A-Z2-7]{32,}$/.test(x) ? x.slice(0, 6) + "…" : x; // unknown identity short id
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 a = nameOf(m.from),
220
- b = nameOf(m.to);
221
- if (a === "?" || b === "?" || a === b) continue;
222
- const key = [a, b].sort().join(SEP);
223
- if (!conv.has(key)) conv.set(key, { parts: [a, b].sort(), msgs: [] });
224
- conv.get(key).msgs.push({ ts: time(m.ts), who: a, status: rosterStatus(a), body: bodyText(m), _ts: m.ts });
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({ with: other, role: roleOf(other), status: rosterStatus(other), unread: 0, last, msgs: c.msgs });
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.name),
242
- status: rosterStatus(p.name),
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.name)}">
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.name;
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.name, c);
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
- <div class="body">${esc(e.body)}</div>
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
- <div class="body">${esc(m.body)}</div>
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 n = msg.from?.name;
390
- if (!n || seen.has(n)) continue;
391
- seen.set(n, { name: n, role: msg.from?.role, status: rosterStatus(n) });
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 = channels.get(name) ?? 51;
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?.name),
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 = channels.get(name) ?? items.length;
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
- <span class="chip mode on">✦ summarize</span>
423
- <span class="chip static">🔕 mute</span>
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">${esc(desc)}${plural(memberCount, "member")} · ${plural(msgCount, "message")}</div>
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 : peer;
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">${esc(m.body)}</div>
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.name === peer);
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 withName = conv ? conv.with : dmSel.with;
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(peer)} ↔ ${esc(withName)}</span><span class="dtag">direct</span></div>
480
- <div class="ctrls"><span class="chip static">🔕 mute</span></div>
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((a, b) => b.ts - a.ts)
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
- $("rail").className = "rail";
520
- $("rail").innerHTML =
521
- `<div class="rail-head"><span class="t">NEEDS YOU</span>${cards.length ? `<span class="n">${cards.length}</span>` : ""}</div>` +
522
- (cards.length
523
- ? cards.map(cardHTML).join("") +
524
- `<div class="rail-foot">Everything else stays quiet in the feed.</div>`
525
- : `<div class="empty">nothing waiting all clear ✓</div>`);
526
- for (const el of $("rail").querySelectorAll(".card[data-agent]"))
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
- const since = waiting ? `waiting ${esc(ago(p.ts))}` : `${esc(p.status)} · ${esc(ago(p.ts))}`;
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
- meta.model ? `<span class="d-badge model" title="model">${esc(meta.model)}</span>` : "",
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
- // Any other meta beyond the badges (connector harness, model), generically rendered (escaped, key-sorted).
574
- const extra = Object.entries(meta).filter(([k]) => k !== "connector" && k !== "model").sort(([a], [b]) => a.localeCompare(b));
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
- const waiting = roster.filter((p) => p.status === "waiting");
612
- const oldest = waiting.length ? agoShort(Math.min(...waiting.map((p) => p.ts))) : "—";
613
- renderTiles(counts, oldest);
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.name === peer);
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
- channels = new Map(list.map((c) => [c.channel, c.messages]));
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
- channels.set(msg.channel, (channels.get(msg.channel) ?? 0) + 1);
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();