viberoom 0.5.8 → 0.6.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/ui/app.js CHANGED
@@ -30,6 +30,12 @@
30
30
  fvPath: $("#fv-path"),
31
31
  fvBody: $("#fv-body"),
32
32
  fvOpen: $("#fv-open"),
33
+ fvTools: $("#fv-tools"),
34
+ fvSearch: $("#fv-search"),
35
+ fvHits: $("#fv-hits"),
36
+ fvLine: $("#fv-line"),
37
+ fvWrap: $("#fv-wrap"),
38
+ fvCount: $("#fv-count"),
33
39
  rail: $("#rail"),
34
40
  railRooms: $("#rail-rooms"),
35
41
  railRoomsWrap: $("#rail-rooms-wrap"),
@@ -68,6 +74,7 @@
68
74
  sideRoomEmoji: $("#side-room-emoji"),
69
75
  composer: $("#composer"),
70
76
  shotsTray: $("#shots-tray"),
77
+ quotesTray: $("#quotes-tray"),
71
78
  lightbox: $("#lightbox"),
72
79
  input: $("#input"),
73
80
  pageView: $("#page-view"),
@@ -128,6 +135,32 @@
128
135
  eraseSubmit: $("#erase-submit"),
129
136
  };
130
137
 
138
+ const slowTasks = [];
139
+ function busyLabel() {
140
+ try {
141
+ const room = currentRoom();
142
+ const parts = [];
143
+ const streaming = room ? room.messages.filter((m) => m.streaming).length : 0;
144
+ if (streaming) parts.push(`${streaming} reply streaming`);
145
+ if (document.activeElement === els.input) parts.push("composer focused");
146
+ if (room) parts.push(`${room.messages.length} messages`);
147
+ return parts.join(" \u00b7 ");
148
+ } catch {
149
+ return "";
150
+ }
151
+ }
152
+ function noteSlow(name, ms) {
153
+ if (!(ms >= 8)) return;
154
+ slowTasks.push({ at: Date.now(), ms: Math.round(ms), name, busy: busyLabel() });
155
+ if (slowTasks.length > 60) slowTasks.splice(0, slowTasks.length - 40);
156
+ }
157
+ try {
158
+ new PerformanceObserver((list) => {
159
+ for (const e of list.getEntries()) noteSlow("browser task", e.duration);
160
+ }).observe({ entryTypes: ["longtask"] });
161
+ } catch {
162
+ }
163
+
131
164
  const FONTS = {
132
165
  text: {
133
166
  nunito: { label: "Nunito (default)", stack: '"Nunito", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
@@ -144,12 +177,16 @@
144
177
  },
145
178
  };
146
179
  const STATUS_LABEL = { unstaffed: "needs a coding agent", starting: "starting…", idle: "ready", queued: "waiting…", thinking: "thinking…", writing: "writing…", error: "error", offline: "offline", left: "left" };
147
- const FALLBACK_COLOR = "#9ca3af";
180
+ const STATUS_TONE = { idle: "ready", queued: "waiting", starting: "waiting", thinking: "thinking", writing: "writing", error: "error", offline: "asleep", left: "asleep", unstaffed: "attention" };
181
+ const TOOL_STATUSES = new Set(["pending", "in_progress", "completed", "failed"]);
182
+ const TOKENS = globalThis.VIBEROOM_TOKENS;
183
+ const FALLBACK_COLOR = TOKENS.current.elements.participant.fallback;
148
184
  const WORKING_SVG = '<svg class="working" viewBox="0 0 44 35" aria-hidden="true" title="working…">'
149
185
  + '<g class="body">'
150
186
  + '<circle cx="20" cy="6.5" r="5.6" fill="currentColor"/>'
151
187
  + '<path d="M6 35L13.7 16.5a4 4 0 0 1 8 0L14 35z" fill="currentColor"/>'
152
188
  + '</g>'
189
+ + '<g class="forearm far"><path d="M22 25.6h10.5" fill="none" stroke="currentColor" stroke-width="4.4" stroke-linecap="round"/></g>'
153
190
  + '<path d="M19 19.5L23 27" fill="none" stroke="currentColor" stroke-width="4.4" stroke-linecap="round"/>'
154
191
  + '<g class="forearm"><path d="M23 27h10.5" fill="none" stroke="currentColor" stroke-width="4.4" stroke-linecap="round"/></g>'
155
192
  + '<rect x="26" y="30.2" width="15.5" height="3" rx="1" fill="currentColor"/>'
@@ -162,38 +199,18 @@
162
199
  return room.messages.some((m) => m.streaming && m.from === p.id) ? "writing" : "thinking";
163
200
  }
164
201
  const CHAT_EMOJI = ["😀", "😄", "😂", "🙂", "😉", "😍", "🤔", "😎", "🥳", "😅", "😢", "😡", "👍", "👎", "👋", "🙏", "👏", "💪", "🔥", "✨", "🎉", "❤️", "💜", "✅", "❌", "⚠️", "💡", "🚀", "🐛", "🤖", "🤫", "☕"];
165
- const ROOM_EMOJI = ["🎭", "🚀", "🧪", "🛠️", "🎨", "📚", "🧠", "💬", "🔬", "🎯", "🐙", "☕", "🌈", "🏗️", "🎮", "🔥", "🧩", "📈", "🗺️", "🎧", "🌱", "🏠", "🛸", "🧭"];
202
+ const ROOM_EMOJI = [
203
+ "🎭", "🚀", "🧪", "🛠️", "🎨", "📚", "🧠", "💬", "🔬", "🎯", "🐙", "☕", "🌈", "🏗️", "🎮", "🔥", "🧩", "📈", "🗺️", "🎧", "🌱", "🏠", "🛸", "🧭",
204
+ "🧑‍💻", "⚒️", "🔨", "🔧", "🔩", "⚙️", "🧰", "🪛", "🧱", "🏭", "🔌", "🖥️", "💻", "⌨️", "🤖", "🐛", "🐞", "⚡", "🔋",
205
+ "📊", "📉", "🧮", "🔍", "🔭", "🧬", "⚗️", "🧲", "📡", "🛰️", "🗄️", "💾", "🗃️", "🌐", "🔗", "☁️",
206
+ "✍️", "📝", "📖", "📰", "📜", "📎", "📌", "🗂️", "🏷️", "✉️", "📣", "🗣️", "🤝", "👥",
207
+ "🖌️", "🖼️", "📷", "🎬", "🎥", "🎵", "🎹", "🎸", "🎤", "🎲", "♟️", "🧸", "🎪", "🎁",
208
+ "📅", "⏰", "⏳", "🗳️", "⚖️", "🧾", "💰", "📦", "🚚", "🛒", "🏦", "🏢", "🎓", "🏫", "🏁", "🏆", "💎",
209
+ "🔐", "🔑", "🛡️", "🚨", "🚦", "🧯", "🩺", "🧹", "♻️", "🧑‍🍳", "🧑‍🔬", "🧑‍🎨", "🧑‍🏫", "🧑‍🚀", "🕵️", "🧙",
210
+ "🦉", "🦊", "🐼", "🐝", "🐢", "🐬", "🦄", "🐲", "🌍", "🌙", "⭐", "🌊", "🏔️", "🏝️", "🌲", "🍀", "🌸", "🍕", "🍎", "✨", "💡", "🔮", "🪄", "❤️",
211
+ ];
166
212
  function emojiGrid(list, current, onPick) {
167
- const wrap = document.createElement("div");
168
- wrap.className = "avatar-picker";
169
- const render = (value) => {
170
- wrap.innerHTML = "";
171
- if (current !== null && current !== undefined) {
172
- const none = document.createElement("button");
173
- none.type = "button";
174
- none.className = "none" + (!value ? " selected" : "");
175
- none.textContent = "—";
176
- none.title = "No emoji";
177
- none.addEventListener("click", () => {
178
- onPick("");
179
- render("");
180
- });
181
- wrap.appendChild(none);
182
- }
183
- for (const e of list) {
184
- const b = document.createElement("button");
185
- b.type = "button";
186
- b.textContent = e;
187
- b.className = e === value ? "selected" : "";
188
- b.addEventListener("click", () => {
189
- onPick(e);
190
- render(e);
191
- });
192
- wrap.appendChild(b);
193
- }
194
- };
195
- render(current || "");
196
- return wrap;
213
+ return window.Avatars.searchableGrid(list, current, onPick, current !== null && current !== undefined ? { label: "", title: "No emoji" } : null);
197
214
  }
198
215
  const CLAMP_CHARS = 700;
199
216
 
@@ -207,7 +224,7 @@
207
224
  const timer = setTimeout(() => {
208
225
  if (Date.now() - stallNoticeAt > 30000) {
209
226
  stallNoticeAt = Date.now();
210
- toast("Still waiting for the hub… If several viberoom tabs are open, close the others: the browser allows only 6 connections to the hub and each tab keeps one.", "warn");
227
+ toast("Still waiting for the hub… It may be busy or restarting; this window reconnects on its own.", "warn");
211
228
  }
212
229
  }, 8000);
213
230
  return promise.finally(() => clearTimeout(timer));
@@ -305,7 +322,7 @@
305
322
  return `<div class="csv-block"><div class="mm-out">${csvTable(rows)}</div><pre class="mm-code" hidden>${code}</pre><div class="mm-bar"><button type="button" class="mm-src">source</button></div></div>`;
306
323
  }
307
324
  function mermaidBlock(code) {
308
- return `<div class="mermaid-block" data-src="${code}"><div class="mm-out"><pre>${code}</pre></div><pre class="mm-code" hidden>${code}</pre><div class="mm-bar"><button type="button" class="mm-src">source</button></div></div>`;
325
+ return `<div class="mermaid-block" data-src="${code}"><div class="mm-out"><pre>${code}</pre></div><pre class="mm-code" hidden>${code}</pre><div class="mm-bar"><button type="button" class="mm-src">source</button><button type="button" class="mm-expand" title="See it big (zoom and drag)">${ic("maximize")}</button></div></div>`;
309
326
  }
310
327
  function mentions(room, html) {
311
328
  return html.replace(/(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu, (m, name) => {
@@ -317,6 +334,21 @@
317
334
  const md = window.marked ? new window.marked.Marked({ gfm: true, breaks: true }) : null;
318
335
  if (md) {
319
336
  md.use({
337
+ extensions: [
338
+ {
339
+ name: "loneTilde",
340
+ level: "inline",
341
+ start(src) {
342
+ return src.indexOf("~");
343
+ },
344
+ tokenizer(src) {
345
+ return /^~(?!~)/.test(src) ? { type: "loneTilde", raw: "~", text: "~" } : undefined;
346
+ },
347
+ renderer() {
348
+ return "~";
349
+ },
350
+ },
351
+ ],
320
352
  renderer: {
321
353
  html(token) {
322
354
  return esc(token.text != null ? token.text : token.raw || "");
@@ -325,7 +357,9 @@
325
357
  const lang = token.lang || "";
326
358
  if (/^\s*(csv|tsv)\b/i.test(lang)) return csvBlock(String(token.text || ""));
327
359
  const code = esc(String(token.text || "")).trim();
328
- return /^\s*mermaid\b/i.test(lang) ? mermaidBlock(code) : `<pre>${code}</pre>`;
360
+ if (/^\s*mermaid\b/i.test(lang)) return mermaidBlock(code);
361
+ const id = prismLanguageOf(String(lang).trim().split(/\s+/)[0]);
362
+ return id ? `<pre><code data-lang="${esc(id)}">${code}</code></pre>` : `<pre>${code}</pre>`;
329
363
  },
330
364
  link(token) {
331
365
  const inner = this.parser.parseInline(token.tokens || []);
@@ -352,9 +386,27 @@
352
386
  }
353
387
  return out;
354
388
  }
355
- function renderText(room, text, images) {
356
- const html = md ? decorate(room, md.parse(String(text == null ? "" : text))) : renderTextLight(room, text);
357
- return images && images.length ? imageRefs(html, images) : html;
389
+ function renderText(room, text, images, quotes) {
390
+ let html = md ? decorate(room, md.parse(String(text == null ? "" : text))) : renderTextLight(room, text);
391
+ if (images && images.length) html = imageRefs(html, images);
392
+ if (quotes && quotes.length) html = quoteRefs(html, quotes);
393
+ return html;
394
+ }
395
+ function quoteRefs(html, quotes) {
396
+ const byN = new Map(quotes.map((q, i) => [q.n || i + 1, q]));
397
+ const placed = new Set();
398
+ const out = html.replace(/\[quote (\d+)\]/gi, (whole, n) => {
399
+ const q = byN.get(Number(n));
400
+ if (!q || placed.has(q)) return whole;
401
+ placed.add(q);
402
+ return quoteCard(q);
403
+ });
404
+ const orphans = quotes.filter((q) => !placed.has(q));
405
+ return orphans.length ? out + orphans.map(quoteCard).join("") : out;
406
+ }
407
+ function quoteCard(q) {
408
+ const head = `${ic("quote")}<b>${esc(q.fromName || "")}</b><span class="q-when">#${esc(String(q.seq))}${q.ts ? ` · ${esc(time(q.ts))}` : ""}</span>`;
409
+ return `<span class="quote" data-seq="${esc(String(q.seq))}" role="button" tabindex="0" title="Go to the message this comes from"><span class="q-head">${head}</span><span class="q-text">${esc(q.text || "")}</span></span>`;
358
410
  }
359
411
  function imageRefs(html, images) {
360
412
  const numbers = new Set(images.map((image, i) => image.n || i + 1));
@@ -378,8 +430,298 @@
378
430
  return html;
379
431
  }
380
432
 
433
+ let prismLoading = null;
434
+ function loadPrism() {
435
+ if (window.Prism && window.Prism.highlight) return Promise.resolve(window.Prism);
436
+ if (!prismLoading) {
437
+ window.Prism = window.Prism || {};
438
+ window.Prism.manual = true;
439
+ prismLoading = new Promise((resolve, reject) => {
440
+ const s = document.createElement("script");
441
+ s.src = "/vendor/prism.js";
442
+ s.onload = () => resolve(window.Prism);
443
+ s.onerror = () => reject(new Error("the code colouring script could not be loaded"));
444
+ document.head.appendChild(s);
445
+ });
446
+ }
447
+ return prismLoading;
448
+ }
449
+ const PRISM_NEEDS = { tsx: ["jsx", "typescript"], jsx: [], cpp: ["c"], php: ["markup-templating"], twig: ["markup-templating"], scss: [], docker: [] };
450
+ const prismLanguages = new Map();
451
+ function loadLanguage(id) {
452
+ if (!id) return Promise.resolve(null);
453
+ if (window.Prism && window.Prism.languages && window.Prism.languages[id]) return Promise.resolve(id);
454
+ if (!prismLanguages.has(id)) {
455
+ const load = loadPrism()
456
+ .then(() => Promise.all((PRISM_NEEDS[id] || []).map((need) => loadLanguage(need))))
457
+ .then(
458
+ () =>
459
+ new Promise((resolve) => {
460
+ if (window.Prism.languages[id]) return resolve(id);
461
+ const s = document.createElement("script");
462
+ s.src = `/vendor/prism-lang/${encodeURIComponent(id)}.js`;
463
+ s.onload = () => resolve(window.Prism.languages[id] ? id : null);
464
+ s.onerror = () => resolve(null);
465
+ document.head.appendChild(s);
466
+ }),
467
+ )
468
+ .catch(() => null);
469
+ prismLanguages.set(id, load);
470
+ }
471
+ return prismLanguages.get(id);
472
+ }
473
+ const LANGUAGE_ALIASES = {
474
+ ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript", node: "javascript",
475
+ py: "python", rb: "ruby", rs: "rust", golang: "go", sh: "bash", shell: "bash", zsh: "bash", console: "bash",
476
+ ps: "powershell", ps1: "powershell", "c++": "cpp", cxx: "cpp", "c#": "csharp", cs: "csharp", yml: "yaml",
477
+ html: "markup", xml: "markup", svg: "markup", vue: "markup", md: "markdown", dockerfile: "docker", make: "makefile",
478
+ };
479
+ function prismLanguageOf(word) {
480
+ const id = String(word || "").toLowerCase();
481
+ if (!id || id === "text" || id === "txt" || id === "plain") return null;
482
+ return LANGUAGE_ALIASES[id] || (/^[a-z0-9-]{1,32}$/.test(id) ? id : null);
483
+ }
484
+ const highlighted = new Map();
485
+ const HIGHLIGHT_CACHE = 200;
486
+ async function highlight(code, language) {
487
+ const key = `${language}::${code}`;
488
+ if (highlighted.has(key)) return highlighted.get(key);
489
+ const id = await loadLanguage(language);
490
+ let html;
491
+ try {
492
+ html = id ? window.Prism.highlight(code, window.Prism.languages[id], id) : esc(code);
493
+ } catch {
494
+ html = esc(code);
495
+ }
496
+ if (highlighted.size >= HIGHLIGHT_CACHE) highlighted.delete(highlighted.keys().next().value);
497
+ highlighted.set(key, html);
498
+ return html;
499
+ }
500
+ async function highlightBlocks(root) {
501
+ const blocks = [...root.querySelectorAll("pre code[data-lang]:not([data-coloured])")];
502
+ for (const block of blocks) {
503
+ block.dataset.coloured = "1";
504
+ const html = await highlight(block.textContent, block.dataset.lang);
505
+ block.innerHTML = html;
506
+ }
507
+ }
508
+ function codeWithGutter(code, language, firstLine) {
509
+ const lines = code.split("\n");
510
+ const start = firstLine || 1;
511
+ const gutter = lines.map((_, i) => `<span>${start + i}</span>`).join("");
512
+ return `<div class="code-view"><div class="gutter" aria-hidden="true">${gutter}</div><pre class="code-body"><code data-lang="${esc(language || "")}">${esc(code)}</code></pre></div>`;
513
+ }
514
+
381
515
 
382
516
  const VIEWABLE_RE = /\.(md|markdown|csv|tsv)$/i;
517
+ const NOT_VIEWABLE_RE = /\.(exe|dll|so|dylib|bin|zip|gz|tgz|rar|7z|pdf|mp[34]|mov|avi|wav|ogg|ttf|otf|woff2?|class|jar|pyc|node|msi|iso|db|sqlite3?)$/i;
518
+ const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp|avif|ico|svg)$/i;
519
+ const LINE_RE = /:(\d+)(?:-(\d+))?$/;
520
+ const previewCache = new Map();
521
+
522
+ function readableInRoom(target) {
523
+ if (!target || /^(https?:|mailto:)/i.test(target)) return false;
524
+ const spec = splitLine(target);
525
+ const file = spec ? spec.path : target;
526
+ if (NOT_VIEWABLE_RE.test(file) || IMAGE_RE.test(file)) return false;
527
+ return /[\\/]/.test(file);
528
+ }
529
+ function imageUrl(path) {
530
+ const room = currentRoom();
531
+ const roomPart = room && room.id ? `&room=${encodeURIComponent(room.id)}` : "";
532
+ return `/api/image?path=${encodeURIComponent(path)}${roomPart}`;
533
+ }
534
+ function splitLine(target) {
535
+ const m = LINE_RE.exec(target || "");
536
+ if (!m) return null;
537
+ const from = Number(m[1]);
538
+ const to = m[2] ? Number(m[2]) : 0;
539
+ return { path: target.slice(0, m.index), from, to: to && to >= from ? to : 0 };
540
+ }
541
+
542
+ const PREVIEW_CONTEXT = 3;
543
+ const PREVIEW_PLAIN_LINES = 40;
544
+ const fileName = (path) => String(path).split(/[\\/]/).pop();
545
+
546
+ function previewCard(key, spec, data, error) {
547
+ const range = data && data.from ? (data.to > data.from ? `lines ${data.from}–${data.to}` : `line ${data.from}`) : "";
548
+ const el = UI.el("file-card", { name: fileName(spec.path), lines: range, body: error ? undefined : codeWithGutter(data.text, data.language, data.from || 1), error: error || undefined, data: { key } });
549
+ const body = el.querySelector(".body");
550
+ if (!error && spec.from) {
551
+ const marked = body.querySelectorAll(".gutter span")[spec.from - (data.from || 1)];
552
+ if (marked) marked.classList.add("line-mark");
553
+ }
554
+ el.querySelector('[data-act="open-file"]').addEventListener("click", () => viewFile(spec.path, spec.from || 0).catch(showError));
555
+ if (!error) highlightBlocks(body);
556
+ return el;
557
+ }
558
+
559
+ async function fetchPreview(key, spec, from, to) {
560
+ if (previewCache.has(key)) return previewCache.get(key);
561
+ const promise = get(`/api/file?path=${encodeURIComponent(spec.path)}&from=${from}&to=${to}`)
562
+ .then((r) => ({ ok: true, data: r }))
563
+ .catch((e) => ({ ok: false, error: e && e.message ? e.message : String(e) }));
564
+ previewCache.set(key, promise);
565
+ return promise;
566
+ }
567
+
568
+ const INLINE_TAGS = new Set(["CODE", "EM", "STRONG", "B", "I", "U", "S", "SPAN", "SMALL", "MARK", "SUB", "SUP", "A", "DEL", "INS", "ABBR", "Q"]);
569
+ function placeFragmentCard(textEl, link, card) {
570
+ let anchor = link;
571
+ while (anchor.parentElement && anchor.parentElement !== textEl && INLINE_TAGS.has(anchor.parentElement.tagName)) anchor = anchor.parentElement;
572
+ const table = anchor.closest("table");
573
+ if (table && textEl.contains(table) && table !== textEl) anchor = table;
574
+ if (anchor.parentElement) anchor.after(card);
575
+ else textEl.appendChild(card);
576
+ }
577
+
578
+ function imageCard(key, path) {
579
+ const el = UI.el("file-card", { name: fileName(path), kind: "image", openTitle: "Open it big", body: String(UI.h("img", { alt: fileName(path), loading: "lazy", src: imageUrl(path) })), data: { key } });
580
+ const body = el.querySelector(".body");
581
+ const img = body.querySelector("img");
582
+ img.addEventListener("error", async () => {
583
+ let why = "";
584
+ try {
585
+ const res = await fetch(img.src);
586
+ const text = await res.text();
587
+ try {
588
+ why = JSON.parse(text).error || "";
589
+ } catch {
590
+ why = res.status === 404 ? "this hub does not serve pictures yet; restart it with the new build" : `the hub answered ${res.status}`;
591
+ }
592
+ } catch {
593
+ why = "the hub did not answer";
594
+ }
595
+ body.innerHTML = `<div class="note">${esc(fileName(path))} could not be shown here${why ? `: ${esc(why)}` : ""}.</div>`;
596
+ });
597
+ img.addEventListener("load", () => {
598
+ el.querySelector(".lines").textContent = `${img.naturalWidth}×${img.naturalHeight}`;
599
+ });
600
+ const big = () => openLightbox(img.src, fileName(path));
601
+ el.querySelector('[data-act="open-file"]').addEventListener("click", big);
602
+ img.addEventListener("click", big);
603
+ return el;
604
+ }
605
+
606
+ function renderPreviews(textEl, m) {
607
+ if (!textEl || m.streaming) return;
608
+ for (const link of textEl.querySelectorAll(".open-link[data-open]:not([data-previewed])")) {
609
+ const target = link.dataset.open;
610
+ if (/^(https?:|mailto:)/i.test(target) || NOT_VIEWABLE_RE.test(target)) continue;
611
+ const spec = splitLine(target);
612
+ link.dataset.previewed = "1";
613
+ if (IMAGE_RE.test(target) && /[\\/]/.test(target)) {
614
+ const key = `img|${target}`;
615
+ if (textEl.querySelector(`[data-ui="file-card"][data-key="${cssEscape(key)}"]`)) continue;
616
+ placeFragmentCard(textEl, link, imageCard(key, target));
617
+ } else if (spec && /[\\/]/.test(spec.path)) {
618
+ const from = Math.max(1, spec.from - PREVIEW_CONTEXT);
619
+ const to = (spec.to || spec.from) + PREVIEW_CONTEXT;
620
+ const key = `${spec.path}|${from}|${to}`;
621
+ if (textEl.querySelector(`[data-ui="file-card"][data-key="${cssEscape(key)}"]`)) continue;
622
+ const place = (result) => {
623
+ if (!textEl.isConnected || textEl.querySelector(`[data-ui="file-card"][data-key="${cssEscape(key)}"]`)) return;
624
+ placeFragmentCard(textEl, link, previewCard(key, spec, result.data, result.ok ? null : result.error));
625
+ };
626
+ fetchPreview(key, spec, from, to).then(place);
627
+ } else if (readableInRoom(target)) {
628
+ const room = currentRoom();
629
+ resolveInRoom(room ? room.id : "", target).then((found) => {
630
+ if (!found || found.kind !== "file" || !link.isConnected || link.nextElementSibling?.dataset.act === "preview") return;
631
+ const chip = UI.el("icon-button", { icon: "eye", title: `Preview: show the first ${PREVIEW_PLAIN_LINES} lines here`, kind: "inline", size: "xs", act: "preview" });
632
+ const key = `${target}|1|${PREVIEW_PLAIN_LINES}`;
633
+ const shown = () => textEl.querySelector(`[data-ui="file-card"][data-key="${cssEscape(key)}"]`);
634
+ const label = () => {
635
+ const open = !!shown();
636
+ chip.innerHTML = open ? ic("close") : ic("eye");
637
+ chip.title = open ? "Close the preview" : `Preview: show the first ${PREVIEW_PLAIN_LINES} lines here`;
638
+ chip.setAttribute("aria-label", open ? "Close the preview" : "Preview");
639
+ UI.setState(chip, open ? "on" : null);
640
+ };
641
+ chip.addEventListener("click", async () => {
642
+ const card = shown();
643
+ if (card) {
644
+ card.remove();
645
+ label();
646
+ return;
647
+ }
648
+ chip.disabled = true;
649
+ const result = await fetchPreview(key, { path: target, from: 0, to: 0 }, 1, PREVIEW_PLAIN_LINES);
650
+ chip.disabled = false;
651
+ if (!shown()) placeFragmentCard(textEl, link, previewCard(key, { path: target, from: 0, to: 0 }, result.data, result.ok ? null : result.error));
652
+ label();
653
+ });
654
+ link.after(chip);
655
+ });
656
+ }
657
+ }
658
+ }
659
+ const cssEscape = (value) => (window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/["\\]/g, "\\$&"));
660
+
661
+ const RELATIVE_RE = /(?<![\p{L}\p{N}./\\:~_-])((?:\.{1,2}[\\/])?[\p{L}\p{N}_.-]+(?:[\\/][\p{L}\p{N}_.-]+)+\.[\p{L}\p{N}]{1,10})(:\d+(?:-\d+)?)?(?![\p{L}\p{N}/\\_-])/gu;
662
+ const resolved = new Map();
663
+ function resolveInRoom(roomId, path) {
664
+ const key = `${roomId}|${path}`;
665
+ if (!resolved.has(key)) {
666
+ resolved.set(
667
+ key,
668
+ get(`/api/resolve?room=${encodeURIComponent(roomId)}&path=${encodeURIComponent(path)}`)
669
+ .then((r) => (r.path ? { path: r.path, kind: r.kind || "file" } : null))
670
+ .catch(() => null),
671
+ );
672
+ }
673
+ return resolved.get(key);
674
+ }
675
+ function textNodesOf(root) {
676
+ const out = [];
677
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
678
+ acceptNode: (node) =>
679
+ node.parentElement && node.parentElement.closest('a, pre, .quote, [data-ui="file-card"], [data-ui="tool-call"], .mermaid-block, .csv-block')
680
+ ? NodeFilter.FILTER_REJECT
681
+ : NodeFilter.FILTER_ACCEPT,
682
+ });
683
+ while (walker.nextNode()) out.push(walker.currentNode);
684
+ return out;
685
+ }
686
+ async function linkRelativePaths(textEl, m) {
687
+ const room = currentRoom();
688
+ if (!textEl || m.streaming || !room || !room.dir) return;
689
+ const nodes = textNodesOf(textEl);
690
+ const found = new Map();
691
+ for (const node of nodes) {
692
+ for (const match of String(node.nodeValue).matchAll(RELATIVE_RE)) {
693
+ if (NOT_VIEWABLE_RE.test(match[1])) continue;
694
+ const hits = found.get(match[1]) || [];
695
+ hits.push({ node, index: match.index, length: match[0].length, line: match[2] || "" });
696
+ found.set(match[1], hits);
697
+ }
698
+ }
699
+ if (!found.size) return;
700
+ const paths = await Promise.all([...found.keys()].map((token) => resolveInRoom(room.id, token)));
701
+ let linked = false;
702
+ [...found.keys()].forEach((token, i) => {
703
+ const full = paths[i] && paths[i].path;
704
+ if (!full) return;
705
+ const byNode = new Map();
706
+ for (const hit of found.get(token)) byNode.set(hit.node, [...(byNode.get(hit.node) || []), hit]);
707
+ for (const [node, hits] of byNode) {
708
+ if (!node.isConnected) continue;
709
+ for (const hit of hits.sort((a, b) => b.index - a.index)) {
710
+ const after = node.splitText(hit.index);
711
+ after.nodeValue = after.nodeValue.slice(hit.length);
712
+ const link = document.createElement("a");
713
+ link.className = "open-link";
714
+ link.href = "#";
715
+ link.dataset.open = `${full}${hit.line}`;
716
+ link.title = `${full} (relative to the room's folder)`;
717
+ link.textContent = `${token}${hit.line}`;
718
+ node.parentNode.insertBefore(link, after);
719
+ linked = true;
720
+ }
721
+ }
722
+ });
723
+ if (linked) renderPreviews(textEl, m);
724
+ }
383
725
  function csvTable(rows) {
384
726
  if (!rows.length) return '<p class="lead">Empty file.</p>';
385
727
  const cell = (tag, v) => `<${tag}>${esc(v)}</${tag}>`;
@@ -388,35 +730,98 @@
388
730
  const pad = (r) => r.concat(Array(Math.max(0, width - r.length)).fill(""));
389
731
  return `<div class="csv-wrap"><table class="csv"><thead><tr>${pad(head).map((v) => cell("th", v)).join("")}</tr></thead><tbody>${body.map((r) => `<tr>${pad(r).map((v) => cell("td", v)).join("")}</tr>`).join("")}</tbody></table></div><p class="csv-count">${body.length} row${body.length === 1 ? "" : "s"} · ${width} column${width === 1 ? "" : "s"}</p>`;
390
732
  }
391
- async function viewFile(path) {
392
- const r = await get(`/api/file?path=${encodeURIComponent(path)}`);
733
+ let fileView = null;
734
+ async function viewFile(path, line) {
735
+ const at = Number(line) || 0;
736
+ const window = at > 1 ? `&from=${Math.max(1, at - 40)}&to=${at + 200}` : "";
737
+ const r = await get(`/api/file?path=${encodeURIComponent(path)}${window}`);
738
+ fileView = { path: r.path, kind: r.kind, language: r.language || null, from: r.from || 1, lines: r.lines || 0, more: !!r.more };
393
739
  els.fvTitle.textContent = r.path.split(/[\\/]/).pop();
394
740
  els.fvPath.textContent = r.path;
395
741
  els.fvBody.className = `file-view ${r.kind}`;
396
- els.fvBody.innerHTML = r.kind === "csv" ? csvTable(r.rows) : renderText(currentRoom(), r.text);
742
+ els.fvTools.hidden = r.kind !== "text";
743
+ els.fvBody.innerHTML =
744
+ r.kind === "csv" ? csvTable(r.rows) : r.kind === "markdown" ? renderText(currentRoom(), r.text) : codeWithGutter(r.text, r.language, r.from || 1);
397
745
  els.fvOpen.dataset.path = r.path;
398
746
  els.fvBody.scrollTop = 0;
747
+ els.fvSearch.value = "";
748
+ els.fvHits.textContent = "";
749
+ els.fvLine.value = at > 1 ? String(at) : "";
750
+ if (r.kind === "text") {
751
+ els.fvCount.textContent = `${r.lines}${r.more ? "+" : ""} lines${r.language ? ` · ${r.language}` : ""}${r.more ? " · shown in windows" : ""}`;
752
+ els.fvBody.classList.toggle("wrap", els.fvWrap.checked);
753
+ }
399
754
  openDialog(els.fileDialog);
400
755
  if (r.kind === "markdown") renderDiagrams(els.fvBody);
756
+ if (r.kind === "text") {
757
+ await highlightBlocks(els.fvBody);
758
+ if (at > 1) markLine(at);
759
+ }
401
760
  }
761
+ function markLine(line) {
762
+ const first = fileView ? fileView.from : 1;
763
+ const index = line - first;
764
+ const rows = els.fvBody.querySelectorAll(".gutter span");
765
+ const row = rows[index];
766
+ if (!row) return;
767
+ els.fvBody.querySelectorAll(".line-mark").forEach((el) => el.classList.remove("line-mark"));
768
+ row.classList.add("line-mark");
769
+ const body = els.fvBody.querySelector(".code-body");
770
+ if (body) {
771
+ const lineHeight = row.getBoundingClientRect().height || 18;
772
+ els.fvBody.scrollTop = Math.max(0, index * lineHeight - els.fvBody.clientHeight / 2);
773
+ }
774
+ }
775
+ function findInFile(query) {
776
+ const code = els.fvBody.querySelector(".code-body code");
777
+ if (!code) return;
778
+ if (code.dataset.plain === undefined) code.dataset.plain = "1";
779
+ els.fvBody.querySelectorAll(".find-hit").forEach((el) => el.replaceWith(document.createTextNode(el.textContent)));
780
+ els.fvBody.querySelectorAll(".code-body code").forEach((c) => c.normalize());
781
+ if (!query) {
782
+ els.fvHits.textContent = "";
783
+ return;
784
+ }
785
+ const needle = query.toLowerCase();
786
+ let hits = 0;
787
+ const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
788
+ const texts = [];
789
+ while (walker.nextNode()) texts.push(walker.currentNode);
790
+ for (const node of texts) {
791
+ const value = node.nodeValue;
792
+ const lower = value.toLowerCase();
793
+ if (!lower.includes(needle)) continue;
794
+ const frag = document.createDocumentFragment();
795
+ let at = 0;
796
+ for (;;) {
797
+ const i = lower.indexOf(needle, at);
798
+ if (i < 0) break;
799
+ frag.appendChild(document.createTextNode(value.slice(at, i)));
800
+ const mark = document.createElement("mark");
801
+ mark.className = "find-hit";
802
+ mark.textContent = value.slice(i, i + query.length);
803
+ frag.appendChild(mark);
804
+ at = i + query.length;
805
+ hits++;
806
+ }
807
+ frag.appendChild(document.createTextNode(value.slice(at)));
808
+ node.replaceWith(frag);
809
+ }
810
+ els.fvHits.textContent = hits ? `${hits} hit${hits === 1 ? "" : "s"}` : "nothing found";
811
+ const first = els.fvBody.querySelector(".find-hit");
812
+ if (first) first.scrollIntoView({ block: "center" });
813
+ }
814
+ els.fvSearch.addEventListener("input", () => findInFile(els.fvSearch.value.trim()));
815
+ els.fvWrap.addEventListener("change", () => els.fvBody.classList.toggle("wrap", els.fvWrap.checked));
816
+ els.fvLine.addEventListener("change", async () => {
817
+ const line = Number(els.fvLine.value);
818
+ if (!fileView || !line) return;
819
+ if (line < fileView.from || !els.fvBody.querySelectorAll(".gutter span")[line - fileView.from]) await viewFile(fileView.path, line);
820
+ else markLine(line);
821
+ });
402
822
 
403
823
 
404
- const POP_PALETTE = [
405
- { fill: "#e4e6fb", stroke: "#5b5bf0" },
406
- { fill: "#d9f7e8", stroke: "#1d8f6a" },
407
- { fill: "#fff3cc", stroke: "#b8860b" },
408
- { fill: "#ffe4d6", stroke: "#d2691e" },
409
- { fill: "#ffe3ec", stroke: "#d6336c" },
410
- { fill: "#dcefff", stroke: "#2a6fbf" },
411
- { fill: "#f1e3fb", stroke: "#8a3fb8" },
412
- ];
413
- const DIAGRAM_PRESETS = {
414
- pop: { label: "Pop", palette: POP_PALETTE, primaryColor: "#e4e6fb", primaryBorderColor: "#5b5bf0", primaryTextColor: "#1c1b33", lineColor: "#8f8fb0", secondaryColor: "#d9f7e8", secondaryBorderColor: "#1d8f6a", tertiaryColor: "#fff3cc", tertiaryBorderColor: "#b8860b", textColor: "#1c1b33", clusterBkg: "#f8f8fd", clusterBorder: "#d9dbf5", edgeLabelBackground: "#ffffff", noteBkgColor: "#fff3cc", noteBorderColor: "#b8860b" },
415
- lavender: { label: "Lavender", primaryColor: "#ece9ff", primaryBorderColor: "#6d5dfc", primaryTextColor: "#24223d", lineColor: "#5a4be0", secondaryColor: "#e3f8f2", secondaryBorderColor: "#39c6a3", tertiaryColor: "#fff4d6", tertiaryBorderColor: "#f5a524", textColor: "#24223d", clusterBkg: "#f7f6fc", clusterBorder: "#d6d1f5", edgeLabelBackground: "#ffffff" },
416
- mint: { label: "Mint", primaryColor: "#e3f8f2", primaryBorderColor: "#39c6a3", primaryTextColor: "#0f3d33", lineColor: "#2a9d84", secondaryColor: "#ece9ff", secondaryBorderColor: "#6d5dfc", tertiaryColor: "#fff4d6", tertiaryBorderColor: "#f5a524", textColor: "#1b3a33", clusterBkg: "#f3fbf8", clusterBorder: "#b4ecdc", edgeLabelBackground: "#ffffff" },
417
- sunset: { label: "Sunset", primaryColor: "#ffe9d6", primaryBorderColor: "#f5a524", primaryTextColor: "#4a2b00", lineColor: "#d97706", secondaryColor: "#ffe9ec", secondaryBorderColor: "#ef5b6b", tertiaryColor: "#ece9ff", tertiaryBorderColor: "#6d5dfc", textColor: "#3b2a1a", clusterBkg: "#fff8f0", clusterBorder: "#fde1c2", edgeLabelBackground: "#ffffff" },
418
- slate: { label: "Slate", primaryColor: "#e9edf3", primaryBorderColor: "#64748b", primaryTextColor: "#1e293b", lineColor: "#475569", secondaryColor: "#f1f5f9", secondaryBorderColor: "#94a3b8", tertiaryColor: "#e2e8f0", tertiaryBorderColor: "#64748b", textColor: "#1e293b", clusterBkg: "#f8fafc", clusterBorder: "#cbd5e1", edgeLabelBackground: "#ffffff" },
419
- };
824
+ const DIAGRAM_PRESETS = TOKENS.diagrams.presets;
420
825
  function diagramSettings(d) {
421
826
  return d || (state.settings && state.settings.diagrams) || {};
422
827
  }
@@ -441,14 +846,14 @@
441
846
  }
442
847
  const MERMAID_CSS = [
443
848
  ".node rect, .node .label-container, .node .basic, .cluster rect, rect.actor { rx: 12px; ry: 12px; }",
444
- ".node .label-container, .node .basic, .node rect, .node circle, .node ellipse, rect.actor { stroke-width: 1.8px; filter: drop-shadow(0 2px 0 rgba(28, 27, 51, 0.10)); }",
849
+ `.node .label-container, .node .basic, .node rect, .node circle, .node ellipse, rect.actor { stroke-width: 1.8px; filter: drop-shadow(0 2px 0 ${TOKENS.current.elements.diagram.nodeShadow}); }`,
445
850
  ".edgePath .path, .flowchart-link, .messageLine0, .messageLine1, .transition, .relation { stroke-width: 2px; }",
446
851
  ".edgeLabel, .edgeLabel p { font-weight: 700; }",
447
852
  ".cluster rect { stroke-dasharray: 4 3; stroke-width: 1.5px; }",
448
853
  ".cluster-label, .cluster-label p { font-weight: 800; }",
449
854
  ].join(" ");
450
855
  function paintDiagram(root, palette) {
451
- const ink = "#1c1b33";
856
+ const ink = TOKENS.current.elements.diagram.nodeInk;
452
857
  const byKey = new Map();
453
858
  const pick = (key) => {
454
859
  if (!byKey.has(key)) byKey.set(key, palette[byKey.size % palette.length]);
@@ -513,11 +918,13 @@
513
918
  const out = block.querySelector(".mm-out");
514
919
  const src = block.dataset.src || "";
515
920
  try {
921
+ const t0 = performance.now();
516
922
  const { svg } = await mermaid.render(`mm-${++mermaidSeq}`, src);
517
923
  out.innerHTML = svg;
518
924
  if (palette) paintDiagram(out, palette);
519
925
  block.classList.add("ok");
520
926
  block.classList.remove("failed");
927
+ noteSlow("diagram drawn", performance.now() - t0);
521
928
  } catch (e) {
522
929
  block.classList.add("failed");
523
930
  out.innerHTML = `<pre>${esc(src)}</pre><div class="hint error">Mermaid: ${esc(String((e && e.message) || e).split("\n")[0])}</div>`;
@@ -586,7 +993,26 @@
586
993
  }
587
994
  function meAvatarData() {
588
995
  const s = state.settings || {};
589
- return { name: s.humanName || "You", color: "#1f1d3a", avatar: s.humanAvatar, kind: "human" };
996
+ return { name: s.humanName || "You", color: TOKENS.current.elements.participant.humanInk, avatar: s.humanAvatar, kind: "human" };
997
+ }
998
+ function copyableHtml(el) {
999
+ const clone = (el.querySelector(".text > .words") || el.querySelector(".text")).cloneNode(true);
1000
+ for (const node of clone.querySelectorAll('[data-ui="file-card"], [data-ui="icon-button"], .live-tail, .mermaid-block svg, .mm-bar')) node.remove();
1001
+ for (const link of clone.querySelectorAll("a.open-link, .img-ref")) link.replaceWith(document.createTextNode(link.textContent));
1002
+ clone.classList.remove("clamped");
1003
+ return clone.innerHTML.trim();
1004
+ }
1005
+ async function copyMessage(el, m) {
1006
+ const html = copyableHtml(el);
1007
+ const text = String(m.text || "");
1008
+ try {
1009
+ if (navigator.clipboard && window.ClipboardItem) {
1010
+ await navigator.clipboard.write([new ClipboardItem({ "text/html": new Blob([html], { type: "text/html" }), "text/plain": new Blob([text], { type: "text/plain" }) })]);
1011
+ } else await navigator.clipboard.writeText(text);
1012
+ toast("Copied: the words, formatted; the tool calls stayed here.");
1013
+ } catch (error) {
1014
+ showError(error);
1015
+ }
590
1016
  }
591
1017
  function toast(text, level) {
592
1018
  const el = document.createElement("div");
@@ -624,26 +1050,44 @@
624
1050
  function vendorLogo(r) {
625
1051
  return `<span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span>`;
626
1052
  }
1053
+ function slowTasksHtml() {
1054
+ if (!slowTasks.length) return '<p class="hint">Nothing over 8 ms so far in this window.</p>';
1055
+ const rows = [...slowTasks]
1056
+ .reverse()
1057
+ .slice(0, 25)
1058
+ .map((t) => `<tr><td>${esc(new Date(t.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }))}</td><td><b>${t.ms} ms</b></td><td>${esc(t.name)}</td><td class="hint">${esc(t.busy)}</td></tr>`);
1059
+ return `<table class="slow-table">${rows.join("")}</table>`;
1060
+ }
1061
+ function slowTasksText() {
1062
+ return [...slowTasks].reverse().map((t) => `${new Date(t.at).toLocaleTimeString()} - ${t.ms} ms - ${t.name}${t.busy ? ` - ${t.busy}` : ""}`).join("\n");
1063
+ }
627
1064
  function sectionTitle(iconName, text) {
628
1065
  return `<h4>${ic(iconName)}${text}</h4>`;
629
1066
  }
630
- function saveRow(id) {
631
- return `<div class="save-row"><button class="btn sm primary save" id="${id}" disabled>Save</button></div>`;
632
- }
633
1067
  function geek(id, bodyHtml, hint) {
634
1068
  return `<details class="geek" id="${id}"><summary>${ic("geek")}for geeks${hint ? `<span class="g-hint">${hint}</span>` : ""}<span class="chev">${ic("down")}</span></summary><div class="geek-body">${bodyHtml}</div></details>`;
635
1069
  }
1070
+ const SAVED_MARK_MS = 2000;
636
1071
  const recentlySaved = new Map();
637
- function bindSave(container, button, onSave) {
638
- if (!container || !button) return;
1072
+ function markSaved(fieldId, ms) {
1073
+ const el = fieldId && document.getElementById(fieldId);
1074
+ const host = el && el.closest(".field, .switch");
1075
+ const label = host && host.querySelector(":scope > .label");
1076
+ if (!label) return;
1077
+ const old = label.querySelector(".fsaved");
1078
+ if (old) old.remove();
1079
+ label.insertAdjacentHTML("beforeend", `<span class="fsaved">${ic("check")}Saved</span>`);
1080
+ const mark = label.lastElementChild;
1081
+ setTimeout(() => mark.remove(), ms == null ? SAVED_MARK_MS : ms);
1082
+ }
1083
+ function bindSave(container, onSave) {
1084
+ if (!container) return;
639
1085
  let dirty = false;
640
1086
  let saving = false;
641
1087
  let again = false;
1088
+ let lastField = null;
642
1089
  const arm = () => {
643
1090
  dirty = true;
644
- button.disabled = false;
645
- button.classList.remove("saved");
646
- button.textContent = "Save";
647
1091
  };
648
1092
  const save = async () => {
649
1093
  if (!dirty) return;
@@ -653,18 +1097,16 @@
653
1097
  }
654
1098
  saving = true;
655
1099
  dirty = false;
656
- button.disabled = true;
657
- button.classList.add("loading");
1100
+ const field = lastField;
1101
+ lastField = null;
658
1102
  try {
659
1103
  await onSave();
660
- button.classList.remove("loading");
661
- button.classList.add("saved");
662
- button.innerHTML = `${ic("check")} Saved`;
663
- if (button.id) recentlySaved.set(button.id, Date.now());
1104
+ if (field) {
1105
+ recentlySaved.set(field, Date.now());
1106
+ markSaved(field);
1107
+ }
664
1108
  } catch (e) {
665
1109
  dirty = true;
666
- button.classList.remove("loading");
667
- button.disabled = false;
668
1110
  showError(e);
669
1111
  }
670
1112
  saving = false;
@@ -673,14 +1115,14 @@
673
1115
  save();
674
1116
  }
675
1117
  };
676
- if (button.id && Date.now() - (recentlySaved.get(button.id) || 0) < 5000) {
677
- button.disabled = true;
678
- button.classList.add("saved");
679
- button.innerHTML = `${ic("check")} Saved`;
1118
+ for (const [id, at] of recentlySaved) {
1119
+ const left = SAVED_MARK_MS - (Date.now() - at);
1120
+ if (left > 0 && container.querySelector(`#${CSS.escape(id)}`)) markSaved(id, left);
680
1121
  }
681
1122
  container.addEventListener("input", arm);
682
- container.addEventListener("change", () => {
1123
+ container.addEventListener("change", (e) => {
683
1124
  arm();
1125
+ if (e.target.id) lastField = e.target.id;
684
1126
  save();
685
1127
  });
686
1128
  container.addEventListener("keydown", (e) => {
@@ -692,11 +1134,10 @@
692
1134
  }
693
1135
  });
694
1136
  container.addEventListener("focusout", (e) => {
695
- if (e.target.isContentEditable) save();
696
- });
697
- button.addEventListener("click", () => {
698
- dirty = true;
699
- save();
1137
+ if (e.target.isContentEditable) {
1138
+ if (e.target.id) lastField = e.target.id;
1139
+ save();
1140
+ }
700
1141
  });
701
1142
  }
702
1143
  function roomHue(room) {
@@ -707,9 +1148,9 @@
707
1148
  function roomMark(room, lg) {
708
1149
  const hue = roomHue(room);
709
1150
  const emoji = (room.settings && room.settings.emoji) || "";
710
- if (emoji) return `<span class="room-mark emoji${lg ? " lg" : ""}" style="background:hsl(${hue} 70% 93%)">${esc(emoji)}</span>`;
1151
+ if (emoji) return `<span class="room-mark emoji${lg ? " lg" : ""}" style="--room-hue:${hue}">${esc(emoji)}</span>`;
711
1152
  const letter = (room.name.trim()[0] || "?").toUpperCase();
712
- return `<span class="room-mark${lg ? " lg" : ""}" style="background:linear-gradient(135deg, hsl(${hue} 72% 66%), hsl(${(hue + 30) % 360} 68% 52%))">${esc(letter)}</span>`;
1153
+ return `<span class="room-mark${lg ? " lg" : ""}" style="--room-hue:${hue};--room-hue-2:${(hue + 30) % 360}">${esc(letter)}</span>`;
713
1154
  }
714
1155
  function roomTitle(room) {
715
1156
  const emoji = (room.settings && room.settings.emoji) || "";
@@ -725,11 +1166,120 @@
725
1166
  }, 2200);
726
1167
  }
727
1168
 
1169
+ function attachScrollHints(box) {
1170
+ if (!box || box.dataset.hinted) return;
1171
+ const frame = box.parentElement;
1172
+ if (!frame) return;
1173
+ box.dataset.hinted = "1";
1174
+ if (getComputedStyle(frame).position === "static") frame.style.position = "relative";
1175
+ const make = (dir) => {
1176
+ const b = document.createElement("button");
1177
+ b.type = "button";
1178
+ b.className = `scroll-hint ${dir}`;
1179
+ b.title = dir === "up" ? "There is more above" : "There is more below";
1180
+ b.innerHTML = ic(dir === "up" ? "chevrons-up" : "chevrons-down");
1181
+ b.hidden = true;
1182
+ b.addEventListener("click", () => box.scrollBy({ top: (dir === "up" ? -1 : 1) * Math.max(120, box.clientHeight * 0.85), behavior: "smooth" }));
1183
+ frame.appendChild(b);
1184
+ return b;
1185
+ };
1186
+ const up = make("up");
1187
+ const down = make("down");
1188
+ const update = () => {
1189
+ const hidden = box.scrollHeight - box.clientHeight;
1190
+ up.hidden = !(hidden > 8 && box.scrollTop > 8);
1191
+ down.hidden = !(hidden > 8 && box.scrollTop < hidden - 8);
1192
+ };
1193
+ box.addEventListener("scroll", update, { passive: true });
1194
+ new ResizeObserver(update).observe(box);
1195
+ new MutationObserver(update).observe(box, { childList: true, subtree: true });
1196
+ update();
1197
+ }
1198
+
728
1199
  function openDialog(dialog) {
729
1200
  if (dialog.open) return;
730
1201
  dialog.classList.remove("closing");
1202
+ restoreDialogSize(dialog);
731
1203
  dialog.showModal();
1204
+ if (dialog.classList.contains("light-dismiss") && !dialog.lightDismissWired) {
1205
+ dialog.lightDismissWired = true;
1206
+ dialog.addEventListener("click", (e) => {
1207
+ if (e.target !== dialog) return;
1208
+ const r = dialog.getBoundingClientRect();
1209
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) closeDialog(dialog);
1210
+ });
1211
+ }
1212
+ dialog.querySelectorAll(".scrolls").forEach(attachScrollHints);
1213
+ watchDialogSize(dialog);
732
1214
  }
1215
+
1216
+ const DIALOG_MIN_W = 320;
1217
+ const DIALOG_MIN_H = 220;
1218
+ const dialogSizeKey = (dialog) => `dialog.${dialog.id || "unnamed"}.size`;
1219
+ const dialogFits = (w, h) => ({
1220
+ w: Math.max(DIALOG_MIN_W, Math.min(w, Math.round(window.innerWidth * 0.94))),
1221
+ h: Math.max(DIALOG_MIN_H, Math.min(h, Math.round(window.innerHeight * 0.92))),
1222
+ });
1223
+ function restoreDialogSize(dialog) {
1224
+ const saved = recall(dialogSizeKey(dialog));
1225
+ if (!saved) return;
1226
+ const [w, h] = String(saved).split("x").map(Number);
1227
+ if (!w || !h) return;
1228
+ const size = dialogFits(w, h);
1229
+ dialog.style.width = `${size.w}px`;
1230
+ dialog.style.height = `${size.h}px`;
1231
+ dialog.classList.add("sized");
1232
+ }
1233
+ function resetDialogSize(dialog) {
1234
+ dialog.style.width = "";
1235
+ dialog.style.height = "";
1236
+ dialog.classList.remove("sized");
1237
+ remember(dialogSizeKey(dialog), "");
1238
+ try {
1239
+ localStorage.removeItem(`viberoom.${dialogSizeKey(dialog)}`);
1240
+ } catch {
1241
+ }
1242
+ }
1243
+ let dialogSizes = null;
1244
+ function watchDialogSize(dialog) {
1245
+ if (!window.ResizeObserver) return;
1246
+ if (!dialogSizes) {
1247
+ dialogSizes = new ResizeObserver((entries) => {
1248
+ for (const entry of entries) {
1249
+ const el = entry.target;
1250
+ if (!el.open || !el.style.width) continue;
1251
+ const box = entry.borderBoxSize && entry.borderBoxSize[0];
1252
+ const w = Math.round(box ? box.inlineSize : entry.contentRect.width);
1253
+ const h = Math.round(box ? box.blockSize : entry.contentRect.height);
1254
+ if (w >= DIALOG_MIN_W && h >= DIALOG_MIN_H) remember(dialogSizeKey(el), `${w}x${h}`);
1255
+ }
1256
+ });
1257
+ }
1258
+ if (!dialog.dataset.sized) {
1259
+ dialog.dataset.sized = "1";
1260
+ dialog.addEventListener("pointerdown", (e) => {
1261
+ const r = dialog.getBoundingClientRect();
1262
+ if (e.clientX > r.right - 22 && e.clientY > r.bottom - 22 && !dialog.style.width) {
1263
+ dialog.style.width = `${Math.round(r.width)}px`;
1264
+ dialog.style.height = `${Math.round(r.height)}px`;
1265
+ dialog.classList.add("sized");
1266
+ }
1267
+ });
1268
+ dialog.addEventListener("dblclick", (e) => {
1269
+ const r = dialog.getBoundingClientRect();
1270
+ if (e.clientX > r.right - 22 && e.clientY > r.bottom - 22) resetDialogSize(dialog);
1271
+ });
1272
+ dialogSizes.observe(dialog);
1273
+ }
1274
+ }
1275
+ window.addEventListener("resize", () => {
1276
+ for (const dialog of document.querySelectorAll("dialog[open]")) {
1277
+ if (!dialog.style.width) continue;
1278
+ const size = dialogFits(parseInt(dialog.style.width, 10), parseInt(dialog.style.height, 10));
1279
+ dialog.style.width = `${size.w}px`;
1280
+ dialog.style.height = `${size.h}px`;
1281
+ }
1282
+ });
733
1283
  function closeDialog(dialog) {
734
1284
  if (!dialog.open) return;
735
1285
  dialog.classList.add("closing");
@@ -837,8 +1387,8 @@
837
1387
  pop.id = "update-pop";
838
1388
  pop.className = "update-pop";
839
1389
  pop.dataset.version = u.latest;
840
- pop.innerHTML = `<div class="up-main"><div class="up-text"><b>viberoom ${esc(u.latest)}</b> is out. You have ${esc(u.current)}.</div><button type="button" class="btn sm primary up-go">Update now and restart</button></div>
841
- <div class="up-side"><button type="button" class="icon-btn sm up-x" title="Not now">${ic("close")}</button><button type="button" class="icon-btn sm up-settings" title="Update settings">${ic("settings")}</button></div>`;
1390
+ pop.innerHTML = `<div class="up-main"><div class="up-text"><b>viberoom ${esc(u.latest)}</b> is out. You have ${esc(u.current)}.</div>${UI.html("button", { label: "Update now and restart", kind: "primary", size: "sm", hook: "up-go" })}</div>
1391
+ <div class="up-side">${UI.html("icon-button", { icon: "close", title: "Not now", size: "sm", hook: "up-x" })}${UI.html("icon-button", { icon: "settings", title: "Update settings", size: "sm", hook: "up-settings" })}</div>`;
842
1392
  pop.querySelector(".up-x").addEventListener("click", () => {
843
1393
  remember("updateDismissed", u.latest);
844
1394
  pop.remove();
@@ -852,16 +1402,16 @@
852
1402
  const text = pop.querySelector(".up-text");
853
1403
  pop.dataset.busy = "1";
854
1404
  go.disabled = true;
855
- go.classList.add("loading");
1405
+ UI.setState(go, "loading");
856
1406
  text.innerHTML = `Installing <b>viberoom ${esc(version)}</b>… this takes a moment.`;
857
1407
  try {
858
1408
  await post("/api/update/install", {});
859
- go.classList.remove("loading");
1409
+ UI.setState(go, null);
860
1410
  text.innerHTML = `<b>viberoom ${esc(version)}</b> is installed. Restarting…`;
861
1411
  go.hidden = true;
862
1412
  } catch (e) {
863
1413
  delete pop.dataset.busy;
864
- go.classList.remove("loading");
1414
+ UI.setState(go, null);
865
1415
  go.disabled = false;
866
1416
  go.textContent = "Try again";
867
1417
  text.innerHTML = `<span class="error">${esc(e.message || String(e))}</span>`;
@@ -974,7 +1524,7 @@
974
1524
  const li = document.createElement("li");
975
1525
  li.className = state.skillsRoom === room.id ? "selected" : "";
976
1526
  const selected = state.skillsRoom === room.id;
977
- li.innerHTML = `${roomMark(room)}<div class="p-body"><div class="p-name"><span>${esc(room.name)}</span></div><div class="p-preview">${held.size ? `${held.size} skill${held.size === 1 ? "" : "s"} in use` : "no skills attached"}</div></div>${selected ? `<div class="p-actions"><button class="icon-btn sm goto-room" title="Go to the room">${ic("forward")}</button></div>` : ""}`;
1527
+ li.innerHTML = `${roomMark(room)}<div class="p-body"><div class="p-name"><span>${esc(room.name)}</span></div><div class="p-preview">${held.size ? `${held.size} skill${held.size === 1 ? "" : "s"} in use` : "no skills attached"}</div></div>${selected ? `<div class="p-actions">${UI.html("icon-button", { icon: "forward", title: "Go to the room", size: "sm", hook: "goto-room" })}</div>` : ""}`;
978
1528
  li.addEventListener("click", (e) => {
979
1529
  if (e.target.closest(".goto-room")) return selectRoom(room.id);
980
1530
  state.skillsRoom = room.id;
@@ -1038,7 +1588,7 @@
1038
1588
  <h1>${state.freshVibe ? "Welcome" : "Welcome back"}, ${esc(name)} 👋</h1>
1039
1589
  <p>One chat, many coding agents. Summon your vibemates into a room, talk to all of them at once, and let them talk to each other.</p>
1040
1590
  <div class="hero-actions">
1041
- <button class="btn cta hero-cta" id="home-open-room">${ic("rooms")}Open room</button>
1591
+ ${UI.html("button", { label: "Open room", icon: "rooms", size: "cta", id: "home-open-room", hook: "hero-cta" })}
1042
1592
  </div>
1043
1593
  </div>
1044
1594
  <div class="hero-art" aria-hidden="true">
@@ -1144,7 +1694,7 @@
1144
1694
  const rows = new Map([...els.participants.children].map((li) => [li.dataset.id, li]));
1145
1695
  for (const p of ordered) {
1146
1696
  let li = rows.get(p.id);
1147
- const selected = (state.selection.kind === "participant" && state.selection.id === p.id) || (p.kind === "human" && state.selection.kind === "me" && state.detailsOpen);
1697
+ const selected = state.detailsOpen && ((state.selection.kind === "participant" && state.selection.id === p.id) || (p.kind === "human" && state.selection.kind === "me"));
1148
1698
  const asleep = p.kind === "agent" && (p.status === "offline" || p.status === "left");
1149
1699
  const unstaffed = p.kind === "agent" && p.status === "unstaffed";
1150
1700
  const shown = shownStatus(room, p);
@@ -1152,25 +1702,24 @@
1152
1702
  const sub = p.kind === "human" ? "you, the human" : [p.tagline ? `"${p.tagline}"` : "", p.agentVendor || p.agentLabel, p.model].filter(Boolean).join(" · ");
1153
1703
  const warn = p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<div class="p-warn" title="${esc(p.statusDetail)}">${esc(p.statusDetail)}</div>` : "";
1154
1704
  const status = unstaffed
1155
- ? `<span class="badge status-unstaffed" title="Click to summon this vibemate: pick the coding agent that runs it">summon</span>`
1705
+ ? UI.html("badge", { label: "summon", tone: "attention", title: "Click to summon this vibemate: pick the coding agent that runs it" })
1156
1706
  : asleep
1157
1707
  ? `<span class="zzz" title="${esc(STATUS_LABEL[p.status] || p.status)}">zzz</span>`
1158
- : p.kind === "agent" && p.status !== "idle" ? `<span class="badge status-${shown}">${p.status === "thinking" ? '<span class="dot"></span>' : ""}${STATUS_LABEL[shown] || shown}</span>` : "";
1159
- const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true });
1708
+ : p.kind === "agent" && p.status !== "idle" ? UI.html("badge", { label: STATUS_LABEL[shown] || shown, tone: STATUS_TONE[shown] || "plain", dot: p.status === "thinking" }) : "";
1709
+ const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true, muted: p.muted });
1160
1710
  const statusClass = p.kind === "agent" ? `avatar-status status-${esc(shown || "idle")}` : "";
1161
1711
  const bodyHtml = `<div class="p-body">
1162
- <div class="p-name"><span>${esc(p.name)}</span>${p.muted ? '<span class="badge muted">muted</span>' : ""}${status}</div>
1712
+ <div class="p-name"><span>${esc(p.name)}</span>${p.muted ? UI.html("badge", { label: "muted", tone: "muted" }) : ""}${status}</div>
1163
1713
  <div class="p-sub">${esc(sub)}</div>
1164
1714
  ${warn}
1165
1715
  </div>
1166
1716
  <div class="p-actions">
1167
- ${p.kind === "agent" && p.status === "thinking" ? `<button class="icon-btn sm stop-btn" title="Stop this reply">${ic("stop")}</button>` : ""}
1168
- ${p.kind === "agent" && p.status === "offline" ? `<button class="icon-btn sm reconnect-btn" title="Reconnect">${ic("refresh")}</button>` : ""}
1717
+ ${p.kind === "agent" && p.status === "offline" ? UI.html("row-button", { icon: "refresh", title: `Wake ${p.name} up: reconnect it to the room`, act: "wake" }) : ""}
1169
1718
  </div>`;
1170
1719
  if (!li) {
1171
1720
  li = document.createElement("li");
1172
1721
  li.dataset.id = p.id;
1173
- li.innerHTML = avatarHtml + bodyHtml + (p.kind === "agent" ? `<button type="button" class="icon-btn sm p-gear" title="Open ${esc(p.name)}'s panel">${ic("settings")}</button>` : "");
1722
+ li.innerHTML = avatarHtml + bodyHtml + (p.kind === "agent" ? UI.html("row-button", { icon: "settings", title: `Open ${p.name}'s panel`, act: "panel" }) + UI.html("row-button", { icon: "last-reply", title: `Go to ${p.name}'s last reply`, act: "last-reply" }) : "");
1174
1723
  li.dataset.avatar = avatarHtml;
1175
1724
  if (statusClass) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
1176
1725
  li.dataset.body = bodyHtml;
@@ -1242,14 +1791,14 @@
1242
1791
  els.chatRoomSub.innerHTML =
1243
1792
  `<span>${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}${st.waiting ? ` · ${st.waiting} waiting` : ""}</span>` +
1244
1793
  (room.settings.topic ? `<span>· ${esc(room.settings.topic)}</span>` : "") +
1245
- `<span class="chip dir-chip" title="working directory of the vibemates: ${esc(room.dir)}">${ic("folder")}${esc(room.dir.split(/[\\/]/).filter(Boolean).slice(-1)[0] || room.dir)}</span>`;
1794
+ UI.html("chip", { label: room.dir.split(/[\\/]/).filter(Boolean).slice(-1)[0] || room.dir, icon: "folder", title: `working directory of the vibemates: ${room.dir}`, hook: "dir-chip" });
1246
1795
  }
1247
1796
 
1248
1797
 
1249
1798
  function messageMatches(m) {
1250
1799
  if (!state.search) return true;
1251
1800
  const q = state.search.toLowerCase();
1252
- return m.text.toLowerCase().includes(q) || (m.fromName || "").toLowerCase().includes(q);
1801
+ return m.text.toLowerCase().includes(q) || (m.fromName || "").toLowerCase().includes(q) || (m.quotes || []).some((x) => (x.text || "").toLowerCase().includes(q));
1253
1802
  }
1254
1803
 
1255
1804
  function renderHidden(el, m) {
@@ -1289,23 +1838,17 @@
1289
1838
  return el;
1290
1839
  }
1291
1840
  if (m.kind === "system") {
1292
- el.className = "msg system";
1293
- if (m.audience === "human") {
1294
- el.className = "msg system done";
1295
- const who = m.details && m.details.agentId ? findById(room, m.details.agentId) : null;
1296
- el.innerHTML = `<button type="button" class="done-row" data-ref="${esc((m.details && m.details.refId) || "")}" title="${esc(fullTime(m.ts))} · go to the reply">${who ? avatar(who, 20, {}) : ""}<span>${esc(m.text)}</span></button>`;
1297
- return el;
1298
- }
1299
1841
  if (m.audience === "agents") {
1300
1842
  el.className = "msg hidden";
1301
1843
  el.innerHTML = `<details class="hidden-turn"><summary>${ic("info")} hub → vibemates · ${esc(m.text.split(":")[0])}</summary><div class="hidden-body"><div class="hidden-label">What the vibemates were told</div><div class="hidden-text">${esc(m.text)}</div></div></details>`;
1302
1844
  return el;
1303
1845
  }
1304
- const hush = /^(Hush|Focus):/.test(m.text);
1305
- const warn = !hush && /could not answer|reported an error|Hop limit|was stopped/.test(m.text);
1306
- el.innerHTML = hush
1307
- ? `<div class="focus-pill" title="${esc(fullTime(m.ts))}"><span class="hush-face">🤫</span>${esc(m.text.replace(/^Focus:/, "Hush:"))}</div>`
1308
- : `<div class="sys${warn ? " warn" : ""}" title="${esc(fullTime(m.ts))}">${esc(m.text)}</div>`;
1846
+ const details = m.details || {};
1847
+ const who = details.agentId ? findById(room, details.agentId) : null;
1848
+ const tone = ["attention", "error", "hush"].includes(details.tone) ? details.tone : "news";
1849
+ const face = who ? avatar(who, 20, {}) : tone === "hush" ? '<span class="hush-face">🤫</span>' : "";
1850
+ el.className = "msg system";
1851
+ el.innerHTML = UI.html("hub-row", { text: m.text, tone, ref: details.refId || undefined, face: face ? UI.raw(face) : undefined, title: details.refId ? `${fullTime(m.ts)} · go to the reply` : fullTime(m.ts) });
1309
1852
  return el;
1310
1853
  }
1311
1854
  const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: m.from === "human" ? "human" : "agent" };
@@ -1313,7 +1856,7 @@
1313
1856
  el.className = "msg " + (mine ? "mine" : "agent");
1314
1857
  el.innerHTML = `
1315
1858
  <div class="bubble-col">
1316
- <div class="head"><span class="head-av">${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 32, { vendor: true })}</span><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? `<button type="button" class="edit-btn" title="Edit this message">${ic("pencil")}</button>` : ""}<button type="button" class="pin-btn" title="Pin this message">${ic("pin")}</button><span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
1859
+ <div class="head"><span class="head-av">${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 32, { vendor: true })}</span><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? UI.html("icon-button", { icon: "pencil", title: "Edit this message", kind: "ghost", size: "xs", act: "edit" }) : ""}${UI.html("icon-button", { icon: "quote", title: "Quote this message in your next one", kind: "ghost", size: "xs", act: "quote" })}${UI.html("icon-button", { icon: "pin", title: "Pin this message", kind: "ghost", size: "xs", act: "pin" })}${UI.html("icon-button", { icon: "copy", title: "Copy this message, formatted; the tool calls stay here", kind: "ghost", size: "xs", act: "copy" })}<span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
1317
1860
  <div class="bubble">
1318
1861
  ${m.skill ? `<div class="skill-invoke" title="skill invocation: the vibemates that have this skill got its instructions with this message">${ic("skills")} skill <b>${esc(m.skill.name)}</b></div>` : ""}
1319
1862
  <div class="edit-box" hidden></div>
@@ -1323,20 +1866,23 @@
1323
1866
  <div class="plan" hidden></div>
1324
1867
  <div class="text"></div>
1325
1868
  <div class="shots"></div>
1326
- <button class="more" hidden></button>
1869
+ ${UI.html("button", { label: "Show more", kind: "link", act: "more", hidden: true })}
1870
+ <div class="stop-note"></div>
1327
1871
  <div class="perms"></div>
1328
1872
  <div class="waiting" hidden></div>
1329
1873
  </div>
1330
1874
  <div class="meta"></div>
1331
1875
  </div>`;
1332
- el.querySelector(".more").addEventListener("click", () => {
1876
+ el.querySelector('[data-act="more"]').addEventListener("click", () => {
1333
1877
  if (state.expanded.has(m.id)) state.expanded.delete(m.id);
1334
1878
  else state.expanded.add(m.id);
1335
1879
  updateMessageElement(el, room, m);
1336
1880
  });
1337
- const editBtn = el.querySelector(".edit-btn");
1881
+ const editBtn = el.querySelector('[data-act="edit"]');
1338
1882
  if (editBtn) editBtn.addEventListener("click", () => openInlineEditor(el, room, m));
1339
- el.querySelector(".pin-btn").addEventListener("click", async () => {
1883
+ el.querySelector('[data-act="quote"]').addEventListener("click", () => addQuote(m, ""));
1884
+ el.querySelector('[data-act="copy"]').addEventListener("click", () => copyMessage(el, (currentRoom() || room).messages.find((x) => x.id === m.id) || m));
1885
+ el.querySelector('[data-act="pin"]').addEventListener("click", async () => {
1340
1886
  if (m.pending) return;
1341
1887
  try {
1342
1888
  await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(m.id)}/pin`, { pinned: !m.pinned });
@@ -1376,11 +1922,111 @@
1376
1922
  document.addEventListener("keydown", (e) => {
1377
1923
  if (e.key === "Escape" && !els.lightbox.hidden) closeLightbox();
1378
1924
  });
1925
+
1926
+ const dv = { el: $("#diagram-view"), stage: $("#dv-stage"), level: $("#dv-level"), scale: 1, x: 0, y: 0, fit: 1, dragging: false, moved: false };
1927
+ const DV_MIN = 0.1;
1928
+ const DV_MAX = 8;
1929
+ const DV_FIT_MAX = 3;
1930
+ const DV_BAR_SPACE = 120;
1931
+ function dvApply() {
1932
+ dv.stage.style.transform = `translate(${Math.round(dv.x)}px, ${Math.round(dv.y)}px) scale(${dv.scale})`;
1933
+ dv.level.textContent = `${Math.round((dv.scale / dv.fit) * 100)}%`;
1934
+ }
1935
+ function dvFit() {
1936
+ const svg = dv.stage.firstElementChild;
1937
+ if (!svg) return;
1938
+ const box = dv.el.getBoundingClientRect();
1939
+ const w = Number(svg.dataset.w) || svg.getBoundingClientRect().width || 1;
1940
+ const h = Number(svg.dataset.h) || svg.getBoundingClientRect().height || 1;
1941
+ dv.fit = Math.max(DV_MIN, Math.min((box.width - 80) / w, (box.height - DV_BAR_SPACE - 32) / h, DV_FIT_MAX));
1942
+ dv.scale = dv.fit;
1943
+ dv.x = (box.width - w * dv.scale) / 2;
1944
+ dv.y = Math.max(16, (box.height - DV_BAR_SPACE - h * dv.scale) / 2);
1945
+ dvApply();
1946
+ }
1947
+ function dvZoom(factor, cx, cy) {
1948
+ const next = Math.min(DV_MAX, Math.max(DV_MIN, dv.scale * factor));
1949
+ const box = dv.el.getBoundingClientRect();
1950
+ const px = (cx ?? box.left + box.width / 2) - box.left;
1951
+ const py = (cy ?? box.top + box.height / 2) - box.top;
1952
+ dv.x = px - ((px - dv.x) / dv.scale) * next;
1953
+ dv.y = py - ((py - dv.y) / dv.scale) * next;
1954
+ dv.scale = next;
1955
+ dvApply();
1956
+ }
1957
+ function openDiagram(block) {
1958
+ const svg = block && block.querySelector(".mm-out svg");
1959
+ if (!svg) return;
1960
+ const rect = svg.getBoundingClientRect();
1961
+ const clone = svg.cloneNode(true);
1962
+ clone.dataset.w = String(rect.width || 800);
1963
+ clone.dataset.h = String(rect.height || 600);
1964
+ clone.style.maxWidth = "none";
1965
+ clone.style.width = `${rect.width || 800}px`;
1966
+ clone.style.height = `${rect.height || 600}px`;
1967
+ dv.stage.replaceChildren(clone);
1968
+ dv.el.hidden = false;
1969
+ dvFit();
1970
+ }
1971
+ function closeDiagram() {
1972
+ dv.el.hidden = true;
1973
+ dv.stage.replaceChildren();
1974
+ }
1975
+ $("#dv-close").addEventListener("click", closeDiagram);
1976
+ $("#dv-in").addEventListener("click", () => dvZoom(1.25));
1977
+ $("#dv-out").addEventListener("click", () => dvZoom(0.8));
1978
+ dv.level.addEventListener("click", dvFit);
1979
+ dv.el.addEventListener("dblclick", (e) => {
1980
+ if (!e.target.closest("#dv-bar")) dvFit();
1981
+ });
1982
+ dv.el.addEventListener(
1983
+ "wheel",
1984
+ (e) => {
1985
+ if (e.target.closest("#dv-bar")) return;
1986
+ e.preventDefault();
1987
+ dvZoom(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX, e.clientY);
1988
+ },
1989
+ { passive: false },
1990
+ );
1991
+ dv.el.addEventListener("pointerdown", (e) => {
1992
+ if (e.target.closest("#dv-bar")) return;
1993
+ dv.dragging = true;
1994
+ dv.moved = false;
1995
+ dv.el.setPointerCapture(e.pointerId);
1996
+ dv.el.classList.add("dragging");
1997
+ });
1998
+ dv.el.addEventListener("pointermove", (e) => {
1999
+ if (!dv.dragging) return;
2000
+ if (e.movementX || e.movementY) dv.moved = true;
2001
+ dv.x += e.movementX;
2002
+ dv.y += e.movementY;
2003
+ dvApply();
2004
+ });
2005
+ for (const type of ["pointerup", "pointercancel"]) {
2006
+ dv.el.addEventListener(type, (e) => {
2007
+ if (!dv.dragging) return;
2008
+ dv.dragging = false;
2009
+ dv.el.classList.remove("dragging");
2010
+ if (type === "pointerup" && !dv.moved && !e.target.closest("#dv-bar") && e.target === dv.el) closeDiagram();
2011
+ });
2012
+ }
2013
+ window.addEventListener("resize", () => {
2014
+ if (!dv.el.hidden) dvFit();
2015
+ });
2016
+ document.addEventListener("keydown", (e) => {
2017
+ if (dv.el.hidden) return;
2018
+ if (e.key === "Escape") return void closeDiagram();
2019
+ if (e.key === "+" || e.key === "=") return void dvZoom(1.25);
2020
+ if (e.key === "-") return void dvZoom(0.8);
2021
+ if (e.key === "0") dvFit();
2022
+ });
1379
2023
  els.messages.addEventListener("click", (e) => {
1380
- const done = e.target.closest(".done-row");
1381
- if (done) return void jumpToMessage(els.messages.querySelector(`.msg[data-id="${done.dataset.ref}"]`));
2024
+ const hubRow = e.target.closest('[data-ui="hub-row"][data-ref]');
2025
+ if (hubRow) return void jumpToMessage(els.messages.querySelector(`.msg[data-id="${hubRow.dataset.ref}"]`));
1382
2026
  const shot = e.target.closest(".shot");
1383
2027
  if (shot) return void openLightbox(shot.dataset.src, shot.title);
2028
+ const quote = e.target.closest(".quote");
2029
+ if (quote) return void jumpToMessage(els.messages.querySelector(`.msg[data-seq="${quote.dataset.seq}"]`));
1384
2030
  const ref = e.target.closest(".img-ref");
1385
2031
  if (!ref) return;
1386
2032
  const msg = ref.closest(".msg");
@@ -1404,8 +2050,8 @@
1404
2050
  const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
1405
2051
  const verb = names.length === 1 ? "is" : "are";
1406
2052
  const stopLabel = names.length === 1 ? `Stop ${names[0]} and send now` : "Stop them and send now";
1407
- box.innerHTML = `<span class="waiting-text">${ic("clock")} ${esc(who)} ${verb} still working — this arrives when the current turn ends.</span><button type="button" class="waiting-stop">${esc(stopLabel)}</button>`;
1408
- box.querySelector(".waiting-stop").addEventListener("click", async (e) => {
2053
+ box.innerHTML = `<span class="waiting-text">${ic("clock")} ${esc(who)} ${verb} still working — this arrives when the current turn ends.</span>${UI.html("button", { label: stopLabel, kind: "inverse", size: "sm", act: "stop-and-send" })}`;
2054
+ box.querySelector('[data-act="stop-and-send"]').addEventListener("click", async (e) => {
1409
2055
  const btn = e.currentTarget;
1410
2056
  btn.disabled = true;
1411
2057
  btn.textContent = "Stopping…";
@@ -1420,8 +2066,33 @@
1420
2066
  }
1421
2067
 
1422
2068
  const openTools = new Set();
2069
+ const openToolGroups = new Set();
1423
2070
  els.messages.addEventListener("click", (e) => {
1424
- const chip = e.target.closest(".tool > .chip");
2071
+ const stop = e.target.closest('.live-tail [data-act="stop"]');
2072
+ if (stop) {
2073
+ const msgEl = stop.closest(".msg");
2074
+ const from = msgEl && msgEl.dataset.from;
2075
+ if (!from) return;
2076
+ stop.disabled = true;
2077
+ stop.textContent = "Stopping…";
2078
+ return void post(roomApi(`/participants/${encodeURIComponent(from)}/cancel`))
2079
+ .then((r) => {
2080
+ if (r && r.stopped === "nothing") resyncStream();
2081
+ })
2082
+ .catch((error) => {
2083
+ showError(error);
2084
+ stop.disabled = false;
2085
+ stop.textContent = "Stop";
2086
+ });
2087
+ }
2088
+ const choice = e.target.closest('[data-ui="ask-card"] [data-ui="button"][data-act]');
2089
+ if (choice) {
2090
+ const card = choice.closest('[data-ui="ask-card"]');
2091
+ if (choice.dataset.act === "permit") post(roomApi(`/permissions/${encodeURIComponent(card.dataset.key)}`), { optionId: choice.dataset.option || null }).catch(showError);
2092
+ else if (choice.dataset.act === "decide") post(roomApi(`/proposals/${encodeURIComponent(card.dataset.key)}`), { accept: choice.dataset.answer === "apply" }).catch(showError);
2093
+ return;
2094
+ }
2095
+ const chip = e.target.closest('[data-ui="tool-call"] > [data-ui="chip"]');
1425
2096
  if (!chip) return;
1426
2097
  const msgEl = chip.closest(".msg");
1427
2098
  const room = currentRoom();
@@ -1435,7 +2106,7 @@
1435
2106
  el.classList.toggle("hidden-by-search", !messageMatches(m));
1436
2107
  const wasPinned = el.classList.contains("pinned");
1437
2108
  el.classList.toggle("pinned", !!m.pinned);
1438
- const pinBtn = el.querySelector(".pin-btn");
2109
+ const pinBtn = el.querySelector('[data-act="pin"]');
1439
2110
  if (pinBtn) pinBtn.title = m.pinned ? "Unpin this message" : "Pin this message";
1440
2111
  if (wasPinned !== !!m.pinned) renderTimeline();
1441
2112
  const editedEl = el.querySelector(".edited");
@@ -1454,15 +2125,39 @@
1454
2125
  }
1455
2126
  if (m.kind === "system") return;
1456
2127
  const text = el.querySelector(".text");
1457
- const more = el.querySelector(".more");
2128
+ const more = el.querySelector('[data-act="more"]');
1458
2129
  const long = !m.streaming && m.text.length > CLAMP_CHARS;
1459
2130
  const expanded = state.expanded.has(m.id);
1460
- text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? WORKING_SVG : "");
1461
- if (!m.streaming) renderDiagrams(text);
1462
- if (m.streaming && !m.text) text.innerHTML = '<span class="pending" title="thinking…"><i></i><i></i><i></i></span>';
2131
+ let words = text.querySelector(":scope > .words");
2132
+ if (!words) {
2133
+ words = document.createElement("div");
2134
+ words.className = "words";
2135
+ text.replaceChildren(words);
2136
+ }
2137
+ words.innerHTML = renderText(room, m.text, m.images, m.quotes);
2138
+ if (!m.streaming) {
2139
+ renderDiagrams(words);
2140
+ highlightBlocks(words);
2141
+ renderPreviews(words, m);
2142
+ linkRelativePaths(words, m);
2143
+ }
2144
+ if (m.streaming && !m.text) words.innerHTML = '<span class="pending" title="thinking…"><i></i><i></i><i></i></span>';
2145
+ if (m.streaming) {
2146
+ let tail = el.liveTail;
2147
+ if (!tail) {
2148
+ tail = document.createElement("span");
2149
+ tail.className = "live-tail";
2150
+ tail.innerHTML = `${WORKING_SVG}${UI.html("button", { label: "Stop", kind: "ghost", size: "xs", act: "stop", title: "Stop this reply; what is written stays in the room" })}`;
2151
+ el.liveTail = tail;
2152
+ }
2153
+ tail.classList.toggle("no-figure", !m.text);
2154
+ if (tail.parentElement !== text) text.appendChild(tail);
2155
+ } else if (el.liveTail && el.liveTail.parentElement) el.liveTail.remove();
1463
2156
  text.classList.toggle("clamped", long && !expanded);
1464
2157
  more.hidden = !long;
1465
2158
  more.textContent = expanded ? "Show less" : "Show more";
2159
+ const stopNote = el.querySelector(".stop-note");
2160
+ if (stopNote) stopNote.innerHTML = !m.streaming && m.stopReason === "cancelled" ? UI.html("reply-note", { text: `Stopped by ${m.stoppedBy || (state.settings || {}).humanName || "you"}`, tone: "attention", icon: "stop" }) : "";
1466
2161
  renderShots(el.querySelector(".shots"), room, m);
1467
2162
  renderWaiting(el, room, m);
1468
2163
  const thought = el.querySelector(".thought");
@@ -1470,20 +2165,25 @@
1470
2165
  thought.hidden = false;
1471
2166
  thought.querySelector(".thought-text").textContent = m.thought;
1472
2167
  }
1473
- el.querySelector(".agent-notices").innerHTML = (m.notices || []).map((n) => `<div class="agent-notice">${ic("info")} ${renderText(room, n)}</div>`).join("");
2168
+ el.querySelector(".agent-notices").innerHTML = (m.notices || []).map((n) => UI.html("reply-note", { text: n, tone: "attention" })).join("");
1474
2169
  const tools = el.querySelector(".tools");
1475
2170
  tools.innerHTML = "";
1476
- for (const call of m.toolCalls || []) {
1477
- const open = openTools.has(call.toolCallId);
1478
- const box = document.createElement("div");
1479
- box.className = `tool${open ? " open" : ""}`;
2171
+ const calls = m.toolCalls || [];
2172
+ const folded = !m.streaming && calls.length > 0;
2173
+ let host = tools;
2174
+ if (folded) {
2175
+ const group = UI.el("tool-fold", { count: calls.length, failed: calls.filter((c) => c.status === "failed").length, open: openToolGroups.has(m.id) });
2176
+ group.addEventListener("toggle", () => {
2177
+ if (group.open) openToolGroups.add(m.id);
2178
+ else openToolGroups.delete(m.id);
2179
+ group.querySelector("summary").title = group.open ? "Fold the tool calls away" : "Show every tool call";
2180
+ });
2181
+ tools.appendChild(group);
2182
+ host = group.querySelector(".list");
2183
+ }
2184
+ for (const call of calls) {
1480
2185
  const input = call.rawInput === undefined ? "" : typeof call.rawInput === "string" ? call.rawInput : JSON.stringify(call.rawInput, null, 1);
1481
- box.innerHTML =
1482
- `<button type="button" class="chip chip-${esc(call.status || "pending")}" data-tool="${esc(call.toolCallId)}" title="${open ? "Collapse" : "Expand"}">${ic("tool")}${esc(`${call.title}${call.kind ? ` · ${call.kind}` : ""} · ${call.status || "pending"}`)}</button>` +
1483
- (open
1484
- ? `<div class="tool-body"><div class="tool-sec"><b>call</b><pre>${esc(call.title)}</pre></div>${input ? `<div class="tool-sec"><b>input</b><pre>${esc(input.slice(0, 4000))}</pre></div>` : ""}${call.output ? `<div class="tool-sec"><b>output</b><pre>${esc(call.output)}</pre></div>` : `<div class="tool-sec muted">no output recorded</div>`}</div>`
1485
- : "");
1486
- tools.appendChild(box);
2186
+ host.appendChild(UI.el("tool-call", { id: call.toolCallId, title: call.title, kind: call.kind || undefined, status: TOOL_STATUSES.has(call.status) ? call.status : "pending", open: openTools.has(call.toolCallId), input: input || undefined, output: call.output || undefined }));
1487
2187
  }
1488
2188
  const plan = el.querySelector(".plan");
1489
2189
  if (m.plan && m.plan.length) {
@@ -1493,13 +2193,34 @@
1493
2193
  const meta = el.querySelector(".meta");
1494
2194
  if (!m.streaming && m.from !== "human") {
1495
2195
  const parts = [];
1496
- if (m.stopReason && m.stopReason !== "end_turn") parts.push(`<span>${esc(m.stopReason)}</span>`);
2196
+ if (m.stopReason && m.stopReason !== "end_turn" && m.stopReason !== "cancelled") parts.push(`<span>${esc(m.stopReason)}</span>`);
1497
2197
  if (m.durationMs) parts.push(`<span title="how long the reply took">${ic("clock")} ${(m.durationMs / 1000).toFixed(1)} s</span>`);
1498
2198
  if (m.usage) parts.push(`<span title="tokens in">${ic("arrow-down")} ${fmtTokens(m.usage.inputTokens)}</span><span title="tokens out">${ic("arrow-up")} ${fmtTokens(m.usage.outputTokens)}</span>${m.usage.cachedWriteTokens ? `<span title="tokens written to the cache">${ic("database")} ${fmtTokens(m.usage.cachedWriteTokens)}</span>` : ""}`);
1499
- meta.innerHTML = parts.join("<span class=\"sep\">·</span>");
2199
+ meta.innerHTML = parts.length ? `<span class="stats">${parts.join('<span class="sep">·</span>')}</span>` : "";
1500
2200
  fillSeen(el, room, m);
1501
2201
  } else if (m.from === "human") fillSeen(el, room, m);
1502
- else meta.textContent = "";
2202
+ else meta.innerHTML = liveMetaHtml(m) ? `<span class="stats">${liveMetaHtml(m)}</span>` : "";
2203
+ }
2204
+
2205
+ function elapsedLabel(ms) {
2206
+ const total = Math.max(0, Math.round(ms / 1000));
2207
+ if (total < 60) return `${total} s`;
2208
+ const seconds = String(total % 60).padStart(2, "0");
2209
+ const minutes = Math.floor(total / 60) % 60;
2210
+ const hours = Math.floor(total / 3600);
2211
+ return hours ? `${hours}h ${String(minutes).padStart(2, "0")}m ${seconds}s` : `${minutes}m ${seconds}s`;
2212
+ }
2213
+ function liveMetaHtml(m) {
2214
+ if (!m.streaming || !m.ts) return "";
2215
+ const calls = (m.toolCalls || []).length;
2216
+ const tools = !m.text && calls ? `<span class="sep">·</span><span title="tool calls so far">${ic("tool")} ${calls}</span>` : "";
2217
+ return `<span class="live" data-since="${m.ts}" title="how long this reply has been coming">${ic("clock")} ${elapsedLabel(Date.now() - m.ts)}</span>${tools}`;
2218
+ }
2219
+ function tickLive() {
2220
+ for (const span of els.messages.querySelectorAll(".meta .live")) {
2221
+ const since = Number(span.dataset.since);
2222
+ if (since) span.innerHTML = `${ic("clock")} ${elapsedLabel(Date.now() - since)}`;
2223
+ }
1503
2224
  }
1504
2225
 
1505
2226
  function lastHumanMessage(room) {
@@ -1596,6 +2317,7 @@
1596
2317
  }
1597
2318
 
1598
2319
  function renderMessages() {
2320
+ const t0 = performance.now();
1599
2321
  const room = currentRoom();
1600
2322
  els.messages.innerHTML = "";
1601
2323
  els.messages.classList.toggle("searching", !!state.search);
@@ -1630,14 +2352,20 @@
1630
2352
  if (!placed.has(seq)) placeInList(dividerElement(agents));
1631
2353
  }
1632
2354
  for (const perm of room.permissions) renderPermission(room, perm);
2355
+ for (const prop of room.proposals || []) renderProposal(room, prop);
1633
2356
  refreshSeen(room);
1634
2357
  scrollToBottom();
1635
2358
  renderTimeline();
2359
+ noteSlow("full render of the list", performance.now() - t0);
1636
2360
  }
1637
2361
 
1638
2362
  function upsertMessage(roomId, m) {
1639
2363
  const room = state.rooms.get(roomId);
1640
2364
  if (!room) return;
2365
+ if (m.from === "human" && !m.pending && !room.messages.some((x) => x.id === m.id)) {
2366
+ const local = room.messages.find((x) => x.pending && x.from === "human" && x.text === m.text);
2367
+ if (local) adoptLocalMessage(roomId, local.id, m.id);
2368
+ }
1641
2369
  const idx = room.messages.findIndex((x) => x.id === m.id);
1642
2370
  const wasFinal = idx >= 0 && !room.messages[idx].streaming;
1643
2371
  if (idx >= 0) {
@@ -1682,6 +2410,10 @@
1682
2410
 
1683
2411
  const dirty = new Map();
1684
2412
  let flushScheduled = false;
2413
+ let lastTypedAt = 0;
2414
+ const TYPING_FLUSH_MS = 100;
2415
+ const TYPING_WINDOW_MS = 1500;
2416
+ const watchingTheBottom = () => stuck;
1685
2417
  function patchMessage(roomId, id, fn) {
1686
2418
  const room = state.rooms.get(roomId);
1687
2419
  if (!room) return;
@@ -1692,10 +2424,12 @@
1692
2424
  dirty.set(id, room);
1693
2425
  if (flushScheduled) return;
1694
2426
  flushScheduled = true;
1695
- requestAnimationFrame(flushPatches);
2427
+ if (Date.now() - lastTypedAt < TYPING_WINDOW_MS || !watchingTheBottom()) setTimeout(() => requestAnimationFrame(flushPatches), TYPING_FLUSH_MS);
2428
+ else requestAnimationFrame(flushPatches);
1696
2429
  }
1697
2430
  function flushPatches() {
1698
2431
  flushScheduled = false;
2432
+ const t0 = performance.now();
1699
2433
  const batch = [...dirty];
1700
2434
  dirty.clear();
1701
2435
  let touched = false;
@@ -1709,6 +2443,7 @@
1709
2443
  }
1710
2444
  if (touched && stuck) scrollToBottom();
1711
2445
  if (touched) updateWorkingNow();
2446
+ if (touched) noteSlow("streamed frame", performance.now() - t0);
1712
2447
  }
1713
2448
 
1714
2449
 
@@ -1716,7 +2451,7 @@
1716
2451
  const box = el.querySelector(".edit-box");
1717
2452
  const text = el.querySelector(".text");
1718
2453
  if (!box.hidden) return;
1719
- box.innerHTML = `<textarea class="edit-area" rows="3"></textarea><div class="row-btns"><button type="button" class="btn sm ghost edit-cancel">Cancel</button><button type="button" class="btn sm primary edit-save">Save</button></div>`;
2454
+ box.innerHTML = `<textarea class="edit-area" rows="3"></textarea><div class="row-btns">${UI.html("button", { label: "Cancel", kind: "ghost", size: "sm", hook: "edit-cancel" })}${UI.html("button", { label: "Save", kind: "primary", size: "sm", hook: "edit-save" })}</div>`;
1720
2455
  const area = box.querySelector(".edit-area");
1721
2456
  area.value = m.text;
1722
2457
  box.hidden = false;
@@ -1785,40 +2520,78 @@
1785
2520
  editEls.rewrite.addEventListener("click", () => editRequest && submitEdit(editRequest.room, editRequest.m, editRequest.next, "rewrite"));
1786
2521
 
1787
2522
 
2523
+ function placeCard(card, room, ts) {
2524
+ let anchor = null;
2525
+ for (const m of room.messages) {
2526
+ if (m.ts > ts) break;
2527
+ const el = els.messages.querySelector(`.msg[data-id="${CSS.escape(m.id)}"]`);
2528
+ if (el) anchor = el;
2529
+ }
2530
+ if (anchor) anchor.insertAdjacentElement("afterend", card);
2531
+ else els.messages.appendChild(card);
2532
+ }
2533
+ const OPTION_TONE = { allow_once: "ok", allow_always: "ok", reject_once: "no", reject_always: "no" };
1788
2534
  function renderPermission(room, perm) {
1789
2535
  const p = findById(room, perm.participantId);
1790
- const card = document.createElement("div");
1791
- card.className = "perm";
1792
- card.dataset.key = perm.key;
1793
2536
  const tc = perm.toolCall || {};
1794
- card.innerHTML = `
1795
- <div class="perm-title">${ic("lock")} ${esc(p ? p.name : perm.participantId)} asks for permission: <strong>${esc(tc.title || tc.toolCallId || "tool call")}</strong>${tc.kind ? ` <span class="kind">${esc(tc.kind)}</span>` : ""}</div>
1796
- ${tc.rawInput ? `<pre class="perm-input">${esc(JSON.stringify(tc.rawInput, null, 1).slice(0, 1200))}</pre>` : ""}
1797
- <div class="perm-actions"></div>`;
1798
- const actions = card.querySelector(".perm-actions");
1799
- for (const option of perm.options || []) {
1800
- const btn = document.createElement("button");
1801
- btn.className = `perm-btn kind-${option.kind}`;
1802
- btn.textContent = option.name;
1803
- btn.title = option.kind;
1804
- btn.addEventListener("click", () => post(roomApi(`/permissions/${encodeURIComponent(perm.key)}`), { optionId: option.optionId }).catch(showError));
1805
- actions.appendChild(btn);
1806
- }
1807
- const cancel = document.createElement("button");
1808
- cancel.className = "perm-btn";
1809
- cancel.textContent = "Dismiss (cancelled)";
1810
- cancel.addEventListener("click", () => post(roomApi(`/permissions/${encodeURIComponent(perm.key)}`), { optionId: null }).catch(showError));
1811
- actions.appendChild(cancel);
2537
+ const choices = (perm.options || []).map((option) => ({ label: option.name, tone: OPTION_TONE[option.kind] || "plain", act: "permit", title: option.kind, data: { option: option.optionId } }));
2538
+ choices.push({ label: "Dismiss (cancelled)", act: "permit" });
2539
+ const card = UI.el("ask-card", { kind: "permission", who: p ? p.name : perm.participantId, subject: tc.title || tc.toolCallId || "tool call", subjectKind: tc.kind || undefined, input: tc.rawInput ? JSON.stringify(tc.rawInput, null, 1) : undefined, choices, data: { key: perm.key } });
1812
2540
  const draft = [...room.messages].reverse().find((m) => m.streaming && m.from === perm.participantId);
1813
2541
  const host = draft ? els.messages.querySelector(`.msg[data-id="${draft.id}"] .perms`) : null;
1814
- (host || els.messages).appendChild(card);
1815
- scrollToBottom();
2542
+ if (host) host.appendChild(card);
2543
+ else placeCard(card, room, perm.ts || Date.now());
2544
+ if (stuck) scrollToBottom();
1816
2545
  }
1817
2546
  function resolvePermissionCard(key, optionId) {
1818
- const card = document.querySelector(`.perm[data-key="${key}"]`);
2547
+ const card = document.querySelector(`[data-ui="ask-card"][data-kind="permission"][data-key="${CSS.escape(key)}"]`);
1819
2548
  if (!card) return;
1820
- card.classList.add("resolved");
1821
- card.querySelector(".perm-actions").innerHTML = `<span class="perm-result">${optionId ? `chosen: ${esc(optionId)}` : "dismissed"}</span>`;
2549
+ UI.setState(card, "resolved");
2550
+ card.querySelector(".choices").replaceChildren(Object.assign(document.createElement("span"), { className: "outcome", textContent: optionId ? `chosen: ${optionId}` : "dismissed" }));
2551
+ }
2552
+
2553
+ function proposalValue(v) {
2554
+ if (v === null || v === undefined || v === "") return "(empty)";
2555
+ if (typeof v === "object") return v.mode === "fixed" ? v.language : v.mode || JSON.stringify(v);
2556
+ return String(v);
2557
+ }
2558
+ function proposalDiffHtml(p) {
2559
+ const rows = [];
2560
+ for (const c of p.settings || []) {
2561
+ if (c.key === "customRules") {
2562
+ const before = String(c.from || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
2563
+ const after = String(c.to || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
2564
+ const lines = [...before.filter((l) => !after.includes(l)).map((l) => `<div class="prop-line del">− ${esc(l)}</div>`), ...after.filter((l) => !before.includes(l)).map((l) => `<div class="prop-line add">+ ${esc(l)}</div>`)];
2565
+ rows.push(`<div class="prop-row"><b>Room rules</b>${lines.join("")}</div>`);
2566
+ } else rows.push(`<div class="prop-row"><b>${esc(c.key)}</b> <span class="prop-from">${esc(proposalValue(c.from))}</span> → <span class="prop-to">${esc(proposalValue(c.to))}</span></div>`);
2567
+ }
2568
+ for (const v of p.vibemates || []) {
2569
+ if (v.op === "update") rows.push(`<div class="prop-row"><b>${esc(v.name)}</b>${(v.fields || []).map((f) => `<div class="prop-line">${esc(f.field)}: <span class="prop-from">${esc(f.from || "(empty)")}</span> → <span class="prop-to">${esc(f.to || "(empty)")}</span></div>`).join("")}</div>`);
2570
+ else rows.push(`<div class="prop-row"><b>${v.op === "add" ? "New vibemate" : "Remove"}</b> ${esc(v.name)}${v.op === "add" ? ' <span class="hint">(you pick its coding agent)</span>' : ""}</div>`);
2571
+ }
2572
+ return rows.join("");
2573
+ }
2574
+ function renderProposal(room, p) {
2575
+ const existing = els.messages.querySelector(`[data-ui="ask-card"][data-kind="proposal"][data-key="${CSS.escape(p.key)}"]`);
2576
+ const body =
2577
+ (p.why ? `<div class="prop-why">${esc(p.why)}</div>` : "") +
2578
+ `<div class="prop-diff">${proposalDiffHtml(p)}</div>` +
2579
+ (p.warnings && p.warnings.length ? `<ul class="prop-warn">${p.warnings.map((w) => `<li>${esc(w)}</li>`).join("")}</ul>` : "") +
2580
+ (p.touchesOwn ? `<div class="prop-own">${ic("info")} This changes the rules or ${esc(p.participantName)}'s own persona: it decides how ${esc(p.participantName)} itself will behave.</div>` : "");
2581
+ const outcome = p.status === "pending" ? undefined : p.status === "applied" ? `applied${p.skipped && p.skipped.length ? ` · not applied: ${p.skipped.join("; ")}` : ""}` : "rejected";
2582
+ const card = UI.el("ask-card", { kind: "proposal", who: p.participantName, body, outcome, choices: [{ label: "Apply", tone: "ok", act: "decide", data: { answer: "apply" } }, { label: "Reject", tone: "no", act: "decide", data: { answer: "reject" } }], data: { key: p.key } });
2583
+ if (existing) existing.replaceWith(card);
2584
+ else {
2585
+ placeCard(card, room, p.ts || Date.now());
2586
+ if (stuck) scrollToBottom();
2587
+ }
2588
+ }
2589
+ function resolveProposalCard(room, key, status) {
2590
+ const p = (room.proposals || []).find((x) => x.key === key);
2591
+ if (p) {
2592
+ p.status = status;
2593
+ renderProposal(room, p);
2594
+ }
1822
2595
  }
1823
2596
 
1824
2597
 
@@ -1879,17 +2652,17 @@
1879
2652
  renderRoomPanel(room);
1880
2653
  }
1881
2654
  function profileHeader(p) {
1882
- return `${avatar(p, 76, { vendor: true, status: true })}
2655
+ return `${avatar(p, 76, { vendor: true, status: true, muted: p.muted })}
1883
2656
  <h3>${esc(p.name)}</h3>
1884
2657
  <div class="tagline">${esc(p.tagline || "no vibersona")}</div>
1885
- <div class="badges"><span class="badge">${esc(p.agentVendor || p.agentType || "vibemate")}</span><span class="badge status-${p.status}">${STATUS_LABEL[p.status] || p.status}</span>${p.muted ? '<span class="badge muted">muted</span>' : ""}</div>`;
2658
+ <div class="badges">${UI.html("badge", { label: p.agentVendor || p.agentType || "vibemate" })}${UI.html("badge", { label: STATUS_LABEL[p.status] || p.status, tone: STATUS_TONE[p.status] || "plain" })}${p.muted ? UI.html("badge", { label: "muted", tone: "muted" }) : ""}</div>`;
1886
2659
  }
1887
2660
  function refreshDetailsHeader(p) {
1888
2661
  const header = els.detailsInner.querySelector(".profile");
1889
2662
  if (header) header.innerHTML = profileHeader(p);
1890
2663
  }
1891
2664
  function panelTitle(title, sub) {
1892
- return `<div class="panel-title"><div><h3>${title}</h3>${sub ? `<div class="hint">${sub}</div>` : ""}</div><button class="icon-btn sm ghost" id="details-close" title="Close">${ic("close")}</button></div>`;
2665
+ return `<div class="panel-title"><div><h3>${title}</h3>${sub ? `<div class="hint">${sub}</div>` : ""}</div>${UI.html("icon-button", { icon: "close", title: "Close", kind: "ghost", size: "sm", id: "details-close" })}</div>`;
1893
2666
  }
1894
2667
  function wireDetailsClose() {
1895
2668
  const b = $("#details-close");
@@ -1906,8 +2679,8 @@
1906
2679
  </div>
1907
2680
  <div class="action-row">
1908
2681
  <button class="action" data-act="mention"><span class="ico">${ic("at")}</span>Mention</button>
1909
- <button class="action" data-act="${p.muted ? "unmute" : "mute"}"><span class="ico">${ic(p.muted ? "bell" : "bell-off")}</span>${p.muted ? "Unmute" : "Mute"}</button>
1910
- ${offline ? `<button class="action" data-act="reconnect"><span class="ico">${ic("refresh")}</span>Reconnect</button>` : `<button class="action" data-act="cancel" ${p.status !== "thinking" ? "disabled" : ""}><span class="ico">${ic("stop")}</span>Stop</button>`}
2682
+ <button class="action" data-act="${p.muted ? "unmute" : "mute"}"><span class="ico">${ic(p.muted ? "mute" : "unmute")}</span>${p.muted ? "Unmute" : "Mute"}</button>
2683
+ ${offline ? `<button class="action" data-act="reconnect"><span class="ico">${ic("refresh")}</span>Reconnect</button>` : `<button class="action" data-act="cancel" ${p.status !== "thinking" && p.status !== "queued" ? "disabled" : ""}><span class="ico">${ic("stop")}</span>Stop</button>`}
1911
2684
  <button class="action danger" data-act="remove"><span class="ico">${ic("trash")}</span>Remove</button>
1912
2685
  </div>
1913
2686
  <div class="section" id="pp-persona">
@@ -1916,30 +2689,29 @@
1916
2689
  ${field("Vibersona", `<input type="text" id="pp-tagline" maxlength="80" value="${esc(p.tagline || "")}" placeholder="a few words under the vibename">`, "Shown under the vibename.", "Everyone in the room sees it: you, and the other vibemates in their roster.")}
1917
2690
  ${field("Vibeface", `<div id="pp-avatar-picker"></div><input type="text" id="pp-avatar" maxlength="8" value="${esc(p.avatar || "")}" placeholder="custom emoji (optional)">`)}
1918
2691
  ${field("Vibio", `<textarea id="pp-role" rows="5" maxlength="4000" placeholder="who it is, how it speaks, what it cares about">${esc(p.role || "")}</textarea>`, "Only this vibemate reads it.", "Reaches the vibemate as refreshed instructions in its brief on its next turn; its memory is kept. The other participants never see it.")}
1919
- ${saveRow("pp-save")}
1920
2692
  </div>
2693
+ ${p.trouble ? `<div class="trouble"><b>${esc(p.trouble.what)}</b><span>${esc(p.trouble.advice)}</span></div>` : ""}
1921
2694
  ${p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<p class="hint" style="color:var(--danger);margin:0 4px 10px">${esc(p.statusDetail)}</p>` : ""}
2695
+ <div class="section" id="pp-engine">
2696
+ ${sectionTitle("spark", "Coding agent")}
2697
+ <p class="hint">What runs ${esc(p.name)}${rec ? `: ${esc(rec.vendor)}` : ""}. Its model, how hard it thinks, what it may do without asking.${geekTip("These options come from the coding agent itself: the hub lists the ones it offers and sets your pick on its running session, so a change takes effect from the next turn, without restarting it or losing what it remembers.")}</p>
2698
+ <div id="pp-config"></div>
2699
+ </div>
1922
2700
  ${geek(
1923
2701
  "pp-geek",
1924
2702
  `<div class="section" id="pp-skills-section">
1925
2703
  ${sectionTitle("skills", "Skills")}
1926
2704
  <div class="check-list" id="pp-skills"></div>
1927
2705
  <p class="hint" style="margin-top:8px">What this vibemate can load on request.${geekTip(`Listed in this vibemate's brief by name and description; the text arrives when you write /name or when the vibemate loads it. ${esc(skillChannelText(p))}`)}</p>
1928
- ${saveRow("pp-skills-save")}
1929
2706
  </div>
1930
2707
  <div class="section" id="pp-timing">
1931
2708
  ${sectionTitle("bolt", "Timing")}
1932
2709
  ${field("Reply delay override, seconds", `<input type="number" id="pp-delay" min="0" max="120" step="0.5" value="${p.replyDelay ?? ""}" placeholder="the room's: ${room.settings.replyDelay ?? 4} s">`, `Overrides the room's delay (${room.settings.replyDelay ?? 4} s, used only when two or more vibemates are in) for this vibemate only, even when it is alone. Empty: it follows the room.`, "Before each turn the vibemate waits a random 0–N seconds, so replies cross less often. Messages that arrive during the wait land in its backlog, so it can react to them or stay silent.")}
1933
- ${saveRow("pp-delay-save")}
1934
- </div>
1935
- <div class="section">
1936
- ${sectionTitle("link", "Session")}
1937
- <div id="pp-config"></div>
1938
2710
  </div>
1939
2711
  <div class="section danger">
1940
2712
  ${sectionTitle("bolt", "Respawn")}
1941
2713
  <p class="hint">${esc(p.name)} comes back with an empty head: it forgets this conversation entirely. The room's history stays and you still see everything.${geekTip("A session's context cannot be erased, so the vibemate's process and session are closed and it starts a new one with no replay. Its stored session is dropped too, or a later reconnect would bring the old context back. Same thing as typing /respawn @Name in the composer.")}</p>
1942
- <div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button><label class="lp-with"><button type="button" class="btn sm" data-act="respawn-mem">With the last</button><input type="number" id="pp-respawn-n" min="0" max="500" value="${room.settings.replayAfterRestart ?? 10}"> messages</label></div>
2714
+ <div class="row-btns start">${UI.html("button", { label: `Respawn ${p.name}`, icon: "bolt", kind: "danger", size: "sm", act: "respawn" })}<label class="lp-with">${UI.html("button", { label: "With the last", size: "sm", act: "respawn-mem" })}<input type="number" id="pp-respawn-n" min="0" max="500" value="${room.settings.replayAfterRestart ?? 10}"> messages</label></div>
1943
2715
  <p class="hint">With memory: a new session that gets only ${p.notes ? "its own notes and " : ""}the last N messages of this room; the rest is gone.</p>
1944
2716
  </div>
1945
2717
  <div class="section">
@@ -1956,7 +2728,7 @@
1956
2728
  <span>Adapter</span><span>${esc(rec ? rec.label : p.agentLabel || "")}${p.agentInfo && p.agentInfo.version ? ` ${esc(p.agentInfo.version)}` : ""}</span>
1957
2729
  </div>
1958
2730
  </div>`,
1959
- "skills, timing, session, stats",
2731
+ "skills, timing, stats",
1960
2732
  )}`;
1961
2733
  wireDetailsClose();
1962
2734
  const respawnBtn = els.detailsInner.querySelector('button[data-act="respawn"]');
@@ -1973,7 +2745,7 @@
1973
2745
  await post(roomApi(`/participants/${encodeURIComponent(p.id)}/remove`));
1974
2746
  closeDetails();
1975
2747
  }
1976
- } else if (act === "reconnect") openReconnectDialog(room);
2748
+ } else if (act === "reconnect") openReconnectDialog(room, p);
1977
2749
  else await post(roomApi(`/participants/${encodeURIComponent(p.id)}/${act}`));
1978
2750
  } catch (e) {
1979
2751
  showError(e);
@@ -1987,9 +2759,9 @@
1987
2759
  }),
1988
2760
  );
1989
2761
  renderSkillChecks($("#pp-skills"), p.skills || []);
1990
- bindSave($("#pp-skills-section"), $("#pp-skills-save"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { skills: checkedSkills($("#pp-skills")) }));
1991
- bindSave($("#pp-timing"), $("#pp-delay-save"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { replyDelay: $("#pp-delay").value === "" ? null : Number($("#pp-delay").value) }));
1992
- bindSave($("#pp-persona"), $("#pp-save"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { name: $("#pp-name").value, tagline: $("#pp-tagline").value, role: $("#pp-role").value, avatar: $("#pp-avatar").value }));
2762
+ bindSave($("#pp-skills-section"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { skills: checkedSkills($("#pp-skills")) }));
2763
+ bindSave($("#pp-timing"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { replyDelay: $("#pp-delay").value === "" ? null : Number($("#pp-delay").value) }));
2764
+ bindSave($("#pp-persona"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { name: $("#pp-name").value, tagline: $("#pp-tagline").value, role: $("#pp-role").value, avatar: $("#pp-avatar").value }));
1993
2765
  renderConfig($("#pp-config"), p, offline);
1994
2766
  }
1995
2767
 
@@ -2051,7 +2823,6 @@
2051
2823
  ${field("Vibename", `<input type="text" id="me-name" maxlength="24" value="${esc(s.humanName || "")}">`, "How you appear in every room.")}
2052
2824
  ${field("Vibeface", `<div id="me-avatar-picker"></div><input type="text" id="me-avatar" maxlength="8" value="${esc(s.humanAvatar || "")}" placeholder="custom emoji (optional)">`)}
2053
2825
  ${field("Your vibe line", `<textarea id="me-desc" rows="3" maxlength="200" placeholder="e.g. software engineer, curious about agent protocols; likes short answers">${esc(s.humanDescription || "")}</textarea>`, "A sentence or two about you.", "The vibemates get it in every room's brief, unless a room adds its own line or replaces it (below, when you are in a room).")}
2054
- ${saveRow("me-save")}
2055
2826
  </div>
2056
2827
  ${
2057
2828
  rs
@@ -2059,14 +2830,13 @@
2059
2830
  ${sectionTitle("chat", "In this room")}
2060
2831
  ${field("What vibemates get about you here", `<select id="hp-mode"><option value="inherit"${rs.humanDescriptionMode === "inherit" ? " selected" : ""}>Your vibe line</option><option value="append"${rs.humanDescriptionMode === "append" ? " selected" : ""}>Your vibe line + this room's</option><option value="override"${rs.humanDescriptionMode === "override" ? " selected" : ""}>Only this room's line</option><option value="none"${rs.humanDescriptionMode === "none" ? " selected" : ""}>Nothing about me in this room</option></select>`)}
2061
2832
  ${field("This room's line about you", `<textarea id="hp-desc" rows="3" maxlength="200" placeholder="e.g. host of this session, product owner">${esc(rs.humanDescription || "")}</textarea>`)}
2062
- ${saveRow("hp-save")}
2063
2833
  </div>`
2064
2834
  : ""
2065
2835
  }
2066
2836
  <div class="section danger">
2067
2837
  ${sectionTitle("alert", "Danger zone")}
2068
2838
  <p class="field-note">Erases everything in this viberoom: your vibe, all rooms and their history, vibemate sessions, your skills. Not undoable.</p>
2069
- <div class="row-btns start"><button class="btn danger sm" id="me-erase">${ic("bolt")}Erase my vibe</button></div>
2839
+ <div class="row-btns start">${UI.html("button", { label: "Erase my vibe", icon: "bolt", kind: "danger", size: "sm", id: "me-erase" })}</div>
2070
2840
  </div>`;
2071
2841
  wireDetailsClose();
2072
2842
  $("#me-avatar-picker").appendChild(
@@ -2075,8 +2845,8 @@
2075
2845
  $("#me-avatar").dispatchEvent(new Event("change", { bubbles: true }));
2076
2846
  }),
2077
2847
  );
2078
- bindSave($("#me-vibe"), $("#me-save"), () => post("/api/settings", { humanName: $("#me-name").value, humanAvatar: $("#me-avatar").value, humanDescription: $("#me-desc").value }));
2079
- bindSave($("#me-room"), $("#hp-save"), () => post(roomApi("/settings"), { humanDescriptionMode: $("#hp-mode").value, humanDescription: $("#hp-desc").value }));
2848
+ bindSave($("#me-vibe"), () => post("/api/settings", { humanName: $("#me-name").value, humanAvatar: $("#me-avatar").value, humanDescription: $("#me-desc").value }));
2849
+ bindSave($("#me-room"), () => post(roomApi("/settings"), { humanDescriptionMode: $("#hp-mode").value, humanDescription: $("#hp-desc").value }));
2080
2850
  $("#me-erase").addEventListener("click", openEraseDialog);
2081
2851
  }
2082
2852
 
@@ -2091,7 +2861,7 @@
2091
2861
  ${field("Name", `<input type="text" id="rp-name" maxlength="60" value="${esc(room.name)}">`)}
2092
2862
  ${field("Emoji", `<div id="rp-emoji-picker"></div><input type="text" id="rp-emoji" maxlength="8" value="${esc(rs.emoji || "")}" placeholder="custom emoji (optional)">`, "A face for the room, next to its name.")}
2093
2863
  ${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
2094
- ${field("Folder", `<span class="dir-row"><input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false"><button type="button" class="btn ghost browse-btn" id="rp-dir-browse" title="Choose a folder">${ic("folder")}Browse</button></span>`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
2864
+ ${field("Folder", `<span class="dir-row"><input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false">${UI.html("button", { label: "Browse", icon: "folder", kind: "ghost", id: "rp-dir-browse", title: "Choose a folder", hook: "browse-btn" })}</span>`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
2095
2865
  <div class="field mention-host"><span class="label">Room rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><div id="rp-rules" class="rules-editor" contenteditable="true" spellcheck="true" data-placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences."></div><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></div>
2096
2866
  ${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
2097
2867
  </div>
@@ -2105,7 +2875,7 @@
2105
2875
  <div class="section template">
2106
2876
  ${sectionTitle("rooms", "Turn this room into a template")}
2107
2877
  <p class="field-note">Its settings, rules, folder and vibemates (with the coding agent each runs on) become one of your templates, listed first under "Start from a template". You see everything it will contain, and can change any of it, before you create it.</p>
2108
- <div class="row-btns start stp-row"><button type="button" class="btn sm primary" id="rp-template">${ic("rooms")}Preview and create template</button></div>
2878
+ <div class="row-btns start stp-row">${UI.html("button", { label: "Preview and create template", icon: "rooms", kind: "primary", size: "sm", id: "rp-template" })}</div>
2109
2879
  </div>
2110
2880
  ${geek(
2111
2881
  "rp-geek",
@@ -2138,12 +2908,12 @@
2138
2908
  </div>`,
2139
2909
  "tools, hops, referee, briefs",
2140
2910
  )}
2141
- <div class="save-row" style="margin-top:10px"><span class="hint">The vibemates get the changes on their next turn.</span><button class="btn sm primary save" id="rp-save" disabled>Save</button></div>
2911
+ <div class="save-row" style="margin-top:10px"><span class="hint">The vibemates get the changes on their next turn.</span></div>
2142
2912
  </div>
2143
2913
  <div class="section danger" style="margin-top:12px">
2144
2914
  ${sectionTitle("alert", "Danger zone")}
2145
2915
  <p class="field-note">Closes every vibemate in this room and removes it from the list. Its history and files move to the trash folder of your viberoom data; a new room with the same name starts empty.</p>
2146
- <div class="row-btns start"><button class="btn danger sm" id="rp-delete">${ic("trash")}Close this room for good</button></div>
2916
+ <div class="row-btns start">${UI.html("button", { label: "Close this room for good", icon: "trash", kind: "danger", size: "sm", id: "rp-delete" })}</div>
2147
2917
  </div>`;
2148
2918
  wireDetailsClose();
2149
2919
  rulesToNodes($("#rp-rules"), room.customRulesText != null ? room.customRulesText : rs.customRules || "", room);
@@ -2151,7 +2921,7 @@
2151
2921
  $("#rp-emoji-picker").appendChild(
2152
2922
  emojiGrid(ROOM_EMOJI, rs.emoji || "", (emoji) => {
2153
2923
  $("#rp-emoji").value = emoji;
2154
- $("#rp-emoji").dispatchEvent(new Event("input", { bubbles: true }));
2924
+ $("#rp-emoji").dispatchEvent(new Event("change", { bubbles: true }));
2155
2925
  }),
2156
2926
  );
2157
2927
  $("#rp-dir-browse").addEventListener("click", () => openFolderPicker($("#rp-dir").value, (dir) => {
@@ -2159,7 +2929,7 @@
2159
2929
  $("#rp-dir").dispatchEvent(new Event("change", { bubbles: true }));
2160
2930
  }));
2161
2931
  $("#rp-template").addEventListener("click", () => openSaveTemplateDialog(room));
2162
- bindSave($("#rp-form"), $("#rp-save"), async () => {
2932
+ bindSave($("#rp-form"), async () => {
2163
2933
  const name = $("#rp-name").value;
2164
2934
  if (name.trim() !== room.name) await post(roomApi("/rename"), { name });
2165
2935
  const dir = $("#rp-dir").value.trim();
@@ -2206,7 +2976,7 @@
2206
2976
  .map((r) => {
2207
2977
  const v = (s.vendorPresets || {})[r.id] || {};
2208
2978
  return `<div class="vendor-card">
2209
- <div class="vc-head"><span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span><span class="vc-name">${esc(r.vendor)}</span><span class="badge status-idle"><span class="dot"></span>installed</span></div>
2979
+ <div class="vc-head"><span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span><span class="vc-name">${esc(r.vendor)}</span>${UI.html("badge", { label: "installed", tone: "ready", dot: true })}</div>
2210
2980
  <div class="vc-fields">
2211
2981
  ${field("Model", `<input type="text" data-vendor="${r.id}" data-key="model" value="${esc(v.model || "")}" placeholder="${esc(r.defaultModel || "vibemate default")}">`)}
2212
2982
  ${field("Effort", `<input type="text" data-vendor="${r.id}" data-key="effort" value="${esc(v.effort || "")}" placeholder="${esc(r.defaultEffort || "vibemate default")}">`)}
@@ -2217,10 +2987,16 @@
2217
2987
  .join("");
2218
2988
  const logo = (r) => `<span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span>`;
2219
2989
  const machine =
2220
- installed.map((r) => `<div class="vendor-row">${logo(r)}<span class="vc-name">${esc(r.vendor)}<span class="hint" title="${esc(r.installedAt || "")}">${esc(r.installedAt || "bundled")}</span></span><span class="badge status-idle"><span class="dot"></span>installed</span></div>`).join("") +
2221
- missing.map((r) => `<div class="vendor-row" style="opacity:.75">${logo(r)}<span class="vc-name">${esc(r.vendor)}<span class="hint">${esc(r.installHint || r.unavailableReason || "")}</span></span><span class="badge status-offline">not installed</span></div>`).join("");
2990
+ installed
2991
+ .map((r) => {
2992
+ const out = r.loginState === "missing";
2993
+ const where = out ? `not logged in — run <code>${esc(r.loginCommand)}</code>` : esc(r.installedAt || "bundled");
2994
+ return `<div class="vendor-row">${logo(r)}<span class="vc-name">${esc(r.vendor)}<span class="hint" title="${esc(r.installedAt || "")}">${where}</span></span>${UI.html("badge", { label: out ? "no login" : "installed", tone: out ? "thinking" : "ready", dot: true })}</div>`;
2995
+ })
2996
+ .join("") +
2997
+ missing.map((r) => `<div class="vendor-row" style="opacity:.75">${logo(r)}<span class="vc-name">${esc(r.vendor)}<span class="hint">${esc(r.installHint || r.unavailableReason || "")}</span></span>${UI.html("badge", { label: "not installed", tone: "asleep" })}</div>`).join("");
2222
2998
  els.pageInner.innerHTML = `
2223
- <div class="page-head"><div><h1>Settings</h1><div class="hint">${state.version ? `${esc(state.version.name)} ${esc(state.version.version)} · hub built ${esc(new Date(state.version.build).toLocaleString())}` : "hub build unknown (older hub process; run viberoom again to replace it)"}</div></div><div class="row-btns"><button class="btn primary save" id="sp-save" disabled>Save</button></div></div>
2999
+ <div class="page-head"><div><h1>Settings</h1><div class="hint">${state.version ? `${esc(state.version.name)} ${esc(state.version.version)} · hub built ${esc(new Date(state.version.build).toLocaleString())}` : "hub build unknown (older hub process; run viberoom again to replace it)"}</div></div></div>
2224
3000
  <div id="sp-form">
2225
3001
  <div class="page-cols">
2226
3002
  <div>
@@ -2235,18 +3011,18 @@
2235
3011
  </div>
2236
3012
  <div class="section" id="sp-appearance">
2237
3013
  ${sectionTitle("eye", "Appearance")}
2238
- ${field("Text size, px", `<input type="number" id="sp-chat-fs" min="12" max="24" step="0.5" value="${esc(String((s.appearance || {}).chatFontSize || 14.5))}">`, "The size of the chat text; 14.5 is the default. Everything else in the window scales with it.")}
3014
+ ${field("Text size, px", `<input type="number" id="sp-chat-fs" min="12" max="24" step="0.5" value="${esc(String((s.appearance || {}).chatFontSize || 14.5))}">`, "The size of the chat text; 14.5 is the default. Only the text changes size with it: the boxes, buttons and panels stay as they are.")}
2239
3015
  ${field("Font", `<select id="sp-font">${Object.entries(FONTS.text).map(([id, f]) => `<option value="${id}"${((s.appearance || {}).font || "nunito") === id ? " selected" : ""}>${esc(f.label)}</option>`).join("")}</select>`, "Nunito, Inter and Noto Sans come with viberoom and look the same on every OS; the system entries use what this machine has.")}
2240
3016
  ${field("Code font", `<select id="sp-mono">${Object.entries(FONTS.mono).map(([id, f]) => `<option value="${id}"${((s.appearance || {}).mono || "jetbrains-mono") === id ? " selected" : ""}>${esc(f.label)}</option>`).join("")}</select>`, "For code blocks, paths and tool output.")}
2241
- <div class="bubble" id="sp-chat-sample" style="display:inline-block;font-size:${((s.appearance || {}).chatFontSize || 14.5) / zoomFactor()}px">Messages will read like this, with <code>code</code> a step smaller.</div>
3017
+ <div class="bubble" id="sp-chat-sample" style="display:inline-block;font-size:${(s.appearance || {}).chatFontSize || 14.5}px">Messages will read like this, with <code>code</code> a step smaller.</div>
2242
3018
  </div>
2243
3019
  <div class="section" id="sp-editor">
2244
3020
  ${sectionTitle("pencil", "Open files at a line")}
2245
3021
  <label class="field"><span class="label">A click on a path like main.ts:375 opens the file in${geekTip("Only an editor can jump to a line; the OS default app just opens the file. Auto looks for VS Code, Cursor, Windsurf, Zed, Sublime Text, Notepad++ and the JetBrains IDEs, in that order, on PATH and in their usual folders. Custom: a command with {file}, {line} and {column} placeholders, e.g. code --goto {file}:{line}.")}</span>
2246
3022
  <div class="chips editor-modes">
2247
- <button type="button" class="chip-btn${(s.editor || {}).mode === "custom" ? "" : (s.editor || {}).mode === "default-app" ? "" : " on"}" data-mode="auto">Auto <span class="hint" id="sp-editor-auto">…</span></button>
2248
- <button type="button" class="chip-btn${(s.editor || {}).mode === "default-app" ? " on" : ""}" data-mode="default-app">The default app</button>
2249
- <button type="button" class="chip-btn${(s.editor || {}).mode === "custom" ? " on" : ""}" data-mode="custom">My own command</button>
3023
+ ${UI.html("choice", { label: "Auto", on: !["custom", "default-app"].includes((s.editor || {}).mode), data: { mode: "auto" }, trail: UI.raw('<span class="hint" id="sp-editor-auto">…</span>') })}
3024
+ ${UI.html("choice", { label: "The default app", on: (s.editor || {}).mode === "default-app", data: { mode: "default-app" } })}
3025
+ ${UI.html("choice", { label: "My own command", on: (s.editor || {}).mode === "custom", data: { mode: "custom" } })}
2250
3026
  </div>
2251
3027
  <input type="hidden" id="sp-editor-mode" value="${esc((s.editor || {}).mode || "auto")}">
2252
3028
  </label>
@@ -2255,11 +3031,11 @@
2255
3031
  <div class="section" id="sp-diagrams">
2256
3032
  ${sectionTitle("wand", "Diagrams")}
2257
3033
  <div class="field"><span class="label">Colours of the boxes${geekTip("Vibemates draw diagrams as Mermaid (a ```mermaid block in a message); the room renders them here, with these colours. Mermaid derives the shades of borders and text from the box colour.")}</span>
2258
- <div class="chips diagram-presets">${Object.entries(DIAGRAM_PRESETS).map(([id, p]) => `<button type="button" class="chip-btn${dg.preset === id ? " on" : ""}" data-preset="${id}"><span class="swatch" style="${p.palette ? `background:linear-gradient(90deg, ${p.palette.map((c) => c.fill).join(", ")});border-color:${p.palette[0].stroke}` : `background:${p.primaryColor};border-color:${p.primaryBorderColor}`}"></span>${p.label}</button>`).join("")}</div>
3034
+ <div class="chips diagram-presets">${Object.entries(DIAGRAM_PRESETS).map(([id, p]) => UI.html("choice", { label: p.label, on: dg.preset === id, data: { preset: id }, lead: UI.raw(`<span class="swatch" style="${p.palette ? `background:linear-gradient(90deg, ${p.palette.map((c) => c.fill).join(", ")});border-color:${p.palette[0].stroke}` : `background:${p.primaryColor};border-color:${p.primaryBorderColor}`}"></span>`) })).join("")}</div>
2259
3035
  <input type="hidden" id="sp-diagram-preset" value="${esc(dg.preset)}">
2260
3036
  </div>
2261
3037
  <label class="switch"><span class="label">My own colour for the boxes</span><input type="checkbox" id="sp-diagram-custom" ${dg.primary ? "checked" : ""}></label>
2262
- <div class="field row" id="sp-diagram-color-row" ${dg.primary ? "" : "hidden"}><span class="label">Box colour</span><input type="color" id="sp-diagram-color" value="${esc(dg.primary || "#ece9ff")}" style="width:46px;height:30px;padding:2px"></div>
3038
+ <div class="field row" id="sp-diagram-color-row" ${dg.primary ? "" : "hidden"}><span class="label">Box colour</span><input type="color" id="sp-diagram-color" value="${esc(dg.primary || TOKENS.diagrams.customBoxDefault)}" style="width:46px;height:30px;padding:2px"></div>
2263
3039
  ${mermaidBlock("graph LR\n A[You] --> B(Vibemate)\n B --> C{Agreed?}\n C -->|yes| D[Done]\n C -->|no| B").replace('class="mermaid-block"', 'class="mermaid-block preview"')}
2264
3040
  </div>
2265
3041
  </div>
@@ -2268,7 +3044,13 @@
2268
3044
  ${sectionTitle("refresh", "Updates")}
2269
3045
  <label class="switch"><span class="label">Check for updates once a day<span class="hint">At start, one request to the npm registry for the latest viberoom version; nothing else leaves this machine. A newer version shows as a bubble over your avatar.</span></span><input type="checkbox" id="sp-updates" ${s.checkForUpdates !== false ? "checked" : ""}></label>
2270
3046
  <p class="hint" id="sp-update-status">${updateStatusText()}</p>
2271
- <button type="button" class="btn sm" id="sp-update-check">Check now</button>
3047
+ ${UI.html("button", { label: "Check now", size: "sm", id: "sp-update-check" })}
3048
+ </div>
3049
+ <div class="section" id="sp-restart">
3050
+ ${sectionTitle("refresh", "Restart")}
3051
+ <p class="hint" style="margin-bottom:10px">Starts the hub again with what is on disk. The vibemates' sessions end and they come back through "Welcome back"; this window reconnects on its own. Closing the window does not do this: the hub keeps running in the background, which is why it can stay on an older build than the one you have.</p>
3052
+ ${state.version && state.version.staleSource ? `<p class="hint warn" style="margin-bottom:10px">This hub is older than the code on disk: <code>${esc(state.version.staleSource)}</code> changed after it was built. Restart takes what is built; in a source checkout run <code>node scripts/update.mjs</code>, which builds first.</p>` : ""}
3053
+ ${UI.html("button", { label: "Restart viberoom", size: "sm", id: "sp-restart-now" })}
2272
3054
  </div>
2273
3055
  <div class="section">
2274
3056
  ${sectionTitle("spark", "Vibemates on this machine")}
@@ -2295,6 +3077,12 @@
2295
3077
  </div>
2296
3078
  </div>
2297
3079
  <div>
3080
+ <div class="section" id="sp-slow">
3081
+ ${sectionTitle("clock", "Rendering in this window")}
3082
+ <p class="hint" style="margin-bottom:10px">Everything this window spent more than 8 ms on, newest first: the moment, the cost, what it was, and what the room was doing then. Anything over 50 ms the browser reports by itself, ours or not. When something feels slow, copy the list into the room: it says where to look, which a measurement from outside cannot.</p>
3083
+ ${slowTasksHtml()}
3084
+ ${UI.html("button", { label: "Copy the list", size: "sm", id: "sp-slow-copy" })}
3085
+ </div>
2298
3086
  <div class="section">
2299
3087
  ${sectionTitle("settings", "Presets per vibemate")}
2300
3088
  <p class="hint" style="margin-bottom:10px">Used when you summon one; leave a field empty for the built-in suggestion. The summon dialog always shows what the vibemate really offers. Bypass modes: Claude bypassPermissions, Codex agent-full-access, Gemini yolo, Cursor agent, OpenCode build, Copilot agent + allow_all.</p>
@@ -2302,19 +3090,19 @@
2302
3090
  </div>
2303
3091
  </div>
2304
3092
  </div>`,
2305
- "room defaults, presets per vibemate, vibemate skills",
3093
+ "room defaults, presets per vibemate, vibemate skills, rendering",
2306
3094
  )}
2307
3095
  </div>`;
2308
3096
  const editorSection = $("#sp-editor");
2309
3097
  const editorCmdRow = $("#sp-editor-cmd").closest(".field");
2310
3098
  const showEditorCmd = () => (editorCmdRow.hidden = $("#sp-editor-mode").value !== "custom");
2311
3099
  showEditorCmd();
2312
- editorSection.querySelectorAll(".editor-modes .chip-btn").forEach((b) =>
3100
+ editorSection.querySelectorAll('.editor-modes [data-ui="choice"]').forEach((b) =>
2313
3101
  b.addEventListener("click", () => {
2314
- editorSection.querySelectorAll(".editor-modes .chip-btn").forEach((x) => x.classList.toggle("on", x === b));
3102
+ editorSection.querySelectorAll('.editor-modes [data-ui="choice"]').forEach((x) => UI.setState(x, x === b ? "on" : null));
2315
3103
  const input = $("#sp-editor-mode");
2316
3104
  input.value = b.dataset.mode;
2317
- input.dispatchEvent(new Event("input", { bubbles: true }));
3105
+ input.dispatchEvent(new Event("change", { bubbles: true }));
2318
3106
  showEditorCmd();
2319
3107
  }),
2320
3108
  );
@@ -2332,12 +3120,12 @@
2332
3120
  block.querySelector(".mm-out").innerHTML = `<pre>${esc(block.dataset.src)}</pre>`;
2333
3121
  renderDiagrams(diagramSection, previewTheme());
2334
3122
  };
2335
- diagramSection.querySelectorAll(".diagram-presets .chip-btn").forEach((b) =>
3123
+ diagramSection.querySelectorAll('.diagram-presets [data-ui="choice"]').forEach((b) =>
2336
3124
  b.addEventListener("click", () => {
2337
- diagramSection.querySelectorAll(".diagram-presets .chip-btn").forEach((x) => x.classList.toggle("on", x === b));
3125
+ diagramSection.querySelectorAll('.diagram-presets [data-ui="choice"]').forEach((x) => UI.setState(x, x === b ? "on" : null));
2338
3126
  const input = $("#sp-diagram-preset");
2339
3127
  input.value = b.dataset.preset;
2340
- input.dispatchEvent(new Event("input", { bubbles: true }));
3128
+ input.dispatchEvent(new Event("change", { bubbles: true }));
2341
3129
  redrawPreview();
2342
3130
  }),
2343
3131
  );
@@ -2350,14 +3138,39 @@
2350
3138
  const sample = $("#sp-chat-sample");
2351
3139
  $("#sp-chat-fs").addEventListener("input", () => {
2352
3140
  const px = Number($("#sp-chat-fs").value);
2353
- if (px >= 12 && px <= 24) sample.style.fontSize = `${px / zoomFactor()}px`;
3141
+ if (px >= 12 && px <= 24) sample.style.fontSize = `${px}px`;
2354
3142
  });
2355
3143
  $("#sp-font").addEventListener("change", () => (sample.style.fontFamily = FONTS.text[$("#sp-font").value].stack));
2356
3144
  $("#sp-mono").addEventListener("change", () => sample.querySelectorAll("code").forEach((c) => (c.style.fontFamily = FONTS.mono[$("#sp-mono").value].stack)));
3145
+ $("#sp-slow-copy").addEventListener("click", async () => {
3146
+ const b = $("#sp-slow-copy");
3147
+ try {
3148
+ await navigator.clipboard.writeText(slowTasksText() || "nothing over 8 ms in this window");
3149
+ b.textContent = "Copied";
3150
+ setTimeout(() => (b.textContent = "Copy the list"), 1500);
3151
+ } catch (e) {
3152
+ showError(e);
3153
+ }
3154
+ });
3155
+ $("#sp-restart-now").addEventListener("click", async () => {
3156
+ const ok = await confirmDialog('Every vibemate loses its session and comes back through "Welcome back". This window reconnects on its own.', { title: "Restart viberoom?", okLabel: "Restart" });
3157
+ if (!ok) return;
3158
+ const button = $("#sp-restart-now");
3159
+ button.disabled = true;
3160
+ button.textContent = "Restarting…";
3161
+ try {
3162
+ await post("/api/restart");
3163
+ toast("viberoom is restarting; the window reconnects on its own.", "success");
3164
+ } catch (error) {
3165
+ showError(error);
3166
+ button.disabled = false;
3167
+ button.textContent = "Restart viberoom";
3168
+ }
3169
+ });
2357
3170
  $("#sp-update-check").addEventListener("click", async () => {
2358
3171
  const b = $("#sp-update-check");
2359
3172
  b.disabled = true;
2360
- b.classList.add("loading");
3173
+ UI.setState(b, "loading");
2361
3174
  try {
2362
3175
  state.update = await get("/api/update?check=1");
2363
3176
  $("#sp-update-status").textContent = updateStatusText();
@@ -2366,9 +3179,9 @@
2366
3179
  showError(e);
2367
3180
  }
2368
3181
  b.disabled = false;
2369
- b.classList.remove("loading");
3182
+ UI.setState(b, null);
2370
3183
  });
2371
- bindSave($("#sp-form"), $("#sp-save"), async () => {
3184
+ bindSave($("#sp-form"), async () => {
2372
3185
  const vendorPresets = {};
2373
3186
  els.pageInner.querySelectorAll("input[data-vendor]").forEach((inp) => {
2374
3187
  vendorPresets[inp.dataset.vendor] = vendorPresets[inp.dataset.vendor] || { model: null, effort: null, mode: null };
@@ -2407,13 +3220,13 @@
2407
3220
 
2408
3221
  function skillBadges(sk) {
2409
3222
  const out = [];
2410
- if (sk.userInvocable === false) out.push('<span class="badge">vibemate only</span>');
2411
- if (sk.agentInvocable === false) out.push('<span class="badge">human only</span>');
2412
- if (sk.author && sk.author !== "human") out.push(`<span class="badge outline">${esc(sk.author === "viberoom" ? "built-in" : `by ${sk.author.replace(/^agent:/, "").replace(/@.*$/, "")} (agent)`)}</span>`);
2413
- if (sk.draft) out.push('<span class="badge status-queued">draft: awaiting your approval</span>');
2414
- else if (sk.reviewed === false) out.push('<span class="badge status-thinking">unreviewed</span>');
2415
- if ((sk.problems || []).length) out.push(`<span class="badge status-error">${esc(sk.problems.join("; "))}</span>`);
2416
- if ((sk.warnings || []).length) out.push(`<span class="badge status-thinking" title="${esc(sk.warnings.join("; "))}">${sk.warnings.length} warning${sk.warnings.length > 1 ? "s" : ""}</span>`);
3223
+ if (sk.userInvocable === false) out.push(UI.html("badge", { label: "vibemate only" }));
3224
+ if (sk.agentInvocable === false) out.push(UI.html("badge", { label: "human only" }));
3225
+ if (sk.author && sk.author !== "human") out.push(UI.html("badge", { label: sk.author === "viberoom" ? "built-in" : `by ${sk.author.replace(/^agent:/, "").replace(/@.*$/, "")} (agent)`, tone: "outline" }));
3226
+ if (sk.draft) out.push(UI.html("badge", { label: "draft: awaiting your approval", tone: "waiting" }));
3227
+ else if (sk.reviewed === false) out.push(UI.html("badge", { label: "unreviewed", tone: "thinking" }));
3228
+ if ((sk.problems || []).length) out.push(UI.html("badge", { label: sk.problems.join("; "), tone: "error" }));
3229
+ if ((sk.warnings || []).length) out.push(UI.html("badge", { label: `${sk.warnings.length} warning${sk.warnings.length > 1 ? "s" : ""}`, tone: "thinking", title: sk.warnings.join("; ") }));
2417
3230
  return out.join(" ");
2418
3231
  }
2419
3232
 
@@ -2431,7 +3244,7 @@
2431
3244
  <div class="hint">${esc(sk.description)}</div>
2432
3245
  ${room ? `<div class="hint sk-holders">${ic("user")}${esc(holdersOf(sk.name).map((p) => p.name).join(", "))}</div>` : ""}
2433
3246
  </div>
2434
- <span class="skill-actions">${sk.draft ? `<button class="btn sm primary" data-approve-skill="${esc(sk.name)}">Approve</button>` : ""}<button class="icon-btn sm ghost" data-edit-skill="${esc(sk.name)}" title="Edit this skill">${ic("pencil")}</button></span>
3247
+ <span class="skill-actions">${sk.draft ? UI.html("button", { label: "Approve", kind: "primary", size: "sm", data: { approveSkill: sk.name } }) : ""}${UI.html("icon-button", { icon: "pencil", title: "Edit this skill", kind: "ghost", size: "sm", data: { editSkill: sk.name } })}</span>
2435
3248
  </li>`;
2436
3249
  const list = shown.length
2437
3250
  ? `<ul class="skill-list">${shown.map(item).join("")}</ul>`
@@ -2447,14 +3260,14 @@
2447
3260
  <label class="switch"><span class="label">Vibemates may load it themselves</span><input type="checkbox" id="sk-agent" ${ed.agentInvocable === false ? "" : "checked"}></label>
2448
3261
  <p class="error" id="sk-error" hidden></p>
2449
3262
  ${editing && editing.author === "viberoom" ? `<p class="field-note">${ic("lock")} Built-in skill: it comes with viberoom, the hub keeps it up to date, and it is read-only. Copy the text into a new skill to make your own version.</p>` : ""}
2450
- <div class="row-btns">${editing && editing.author !== "viberoom" ? '<button class="btn sm danger" id="sk-delete">Delete</button>' : ""}<span class="saved" id="sk-saved"></span><button class="btn sm ghost" id="sk-cancel">${editing && editing.author === "viberoom" ? "Close" : "Cancel"}</button>${editing && editing.author === "viberoom" ? "" : '<button class="btn sm primary" id="sk-save">Save skill</button>'}</div>
3263
+ <div class="row-btns">${editing && editing.author !== "viberoom" ? UI.html("button", { label: "Delete", kind: "danger", size: "sm", id: "sk-delete" }) : ""}<span class="saved" id="sk-saved"></span>${UI.html("button", { label: editing && editing.author === "viberoom" ? "Close" : "Cancel", kind: "ghost", size: "sm", id: "sk-cancel" })}${editing && editing.author === "viberoom" ? "" : UI.html("button", { label: "Save skill", kind: "primary", size: "sm", id: "sk-save" })}</div>
2451
3264
  </div>`
2452
3265
  : "";
2453
3266
  const about = ed
2454
3267
  ? ""
2455
3268
  : `<p class="hint sk-about">A skill is a folder <code>skills/&lt;name&gt;/SKILL.md</code> in the hub's data folder: a description (what triggers it) and the instructions. Attach skills to vibemates in their panels; invoke one with <code>/name</code> in the composer. Vibemates with the hub's tools can create skills too (they load <code>skill-writer</code> first).</p>`;
2456
3269
  els.pageInner.innerHTML = `
2457
- <div class="page-head"><div><h1>${room ? `Skills in ${esc(room.name)}` : "Skills"}</h1><div class="hint">${room ? `${shown.length} of ${skills.length} in the library are attached to a vibemate here` : `${skills.length} skill${skills.length === 1 ? "" : "s"} in the library`}</div></div><div class="row-btns">${room ? `<button class="btn ghost" id="sk-all">${ic("skills")}All skills</button>` : ""}<button class="btn ghost" id="sk-reload" title="Re-read the skills folder">${ic("refresh")}Reload</button><button class="btn primary cta" id="sk-new"><span class="cta-ico">${ic("plus")}</span><span class="label">New skill</span></button></div></div>
3270
+ <div class="page-head"><div><h1>${room ? `Skills in ${esc(room.name)}` : "Skills"}</h1><div class="hint">${room ? `${shown.length} of ${skills.length} in the library are attached to a vibemate here` : `${skills.length} skill${skills.length === 1 ? "" : "s"} in the library`}</div></div><div class="row-btns">${room ? UI.html("button", { label: "All skills", icon: "skills", kind: "ghost", id: "sk-all" }) : ""}${UI.html("button", { label: "Reload", icon: "refresh", kind: "ghost", id: "sk-reload", title: "Re-read the skills folder" })}${UI.html("button", { label: "New skill", icon: "plus", kind: "primary", size: "cta", id: "sk-new" })}</div></div>
2458
3271
  <div class="${ed ? "page-cols" : ""}"><div>${list}${about}</div>${editor}</div>`;
2459
3272
  const all = $("#sk-all");
2460
3273
  if (all)
@@ -2537,14 +3350,14 @@
2537
3350
  const row = document.createElement("label");
2538
3351
  row.className = "check-row";
2539
3352
  const broken = (s.problems && s.problems.length) || s.draft;
2540
- row.innerHTML = `<input type="checkbox" value="${esc(s.name)}" ${chosen.has(s.name.toLowerCase()) ? "checked" : ""} ${broken ? "disabled" : ""}><span><b>/${esc(s.name)}</b> <span class="hint">${esc(s.description)}</span>${s.draft ? '<span class="badge status-queued">draft</span>' : ""}${s.problems && s.problems.length ? `<span class="badge status-error">${esc(s.problems.join("; "))}</span>` : ""}</span>`;
3353
+ row.innerHTML = `<input type="checkbox" value="${esc(s.name)}" ${chosen.has(s.name.toLowerCase()) ? "checked" : ""} ${broken ? "disabled" : ""}><span><b>/${esc(s.name)}</b> <span class="hint">${esc(s.description)}</span>${s.draft ? UI.html("badge", { label: "draft", tone: "waiting" }) : ""}${s.problems && s.problems.length ? UI.html("badge", { label: s.problems.join("; "), tone: "error" }) : ""}</span>`;
2541
3354
  container.appendChild(row);
2542
3355
  }
2543
3356
  for (const name of selected || []) {
2544
3357
  if (known.has(name.toLowerCase())) continue;
2545
3358
  const row = document.createElement("label");
2546
3359
  row.className = "check-row";
2547
- row.innerHTML = `<input type="checkbox" value="${esc(name)}" checked><span><b>/${esc(name)}</b> <span class="badge status-error">missing from the library</span></span>`;
3360
+ row.innerHTML = `<input type="checkbox" value="${esc(name)}" checked><span><b>/${esc(name)}</b> ${UI.html("badge", { label: "missing from the library", tone: "error" })}</span>`;
2548
3361
  container.appendChild(row);
2549
3362
  }
2550
3363
  if (!container.children.length) container.innerHTML = '<span class="hint">No skills in the library yet (Skills in the menu).</span>';
@@ -2671,11 +3484,7 @@
2671
3484
  return;
2672
3485
  }
2673
3486
  for (const o of options) {
2674
- const b = document.createElement("button");
2675
- b.type = "button";
2676
- b.className = `chip-btn${o.selected ? " on" : ""}${o.value === "" ? " none" : ""}`;
2677
- b.textContent = o.textContent;
2678
- if (o.title) b.title = o.title;
3487
+ const b = UI.el("choice", { label: o.textContent, on: !!o.selected, quiet: o.value === "", title: o.title || undefined });
2679
3488
  b.addEventListener("click", () => {
2680
3489
  select.value = o.value;
2681
3490
  select.dispatchEvent(new Event("change", { bubbles: true }));
@@ -2742,8 +3551,13 @@
2742
3551
  const bypassOn = (state.settings || {}).bypassPermissionsByDefault !== false;
2743
3552
  els.invNote.textContent = (recipe.unavailableReason ? `${recipe.note} — ${recipe.unavailableReason}` : recipe.note) + (bypassOn && recipe.bypassMode ? ` Mode defaults to "${recipe.bypassMode}" (acts without asking; change it here or in Settings).` : "");
2744
3553
  els.invSubmit.disabled = !!recipe.unavailableReason;
2745
- els.invWhere.textContent = recipe.unavailableReason ? `Not installed on this machine. To install: ${recipe.installHint || ""}` : `Found on this machine: ${recipe.installedAt || "bundled"}`;
2746
- els.invWhere.className = recipe.unavailableReason ? "hint error" : "hint";
3554
+ const noLogin = !recipe.unavailableReason && recipe.loginState === "missing";
3555
+ els.invWhere.textContent = recipe.unavailableReason
3556
+ ? `Not installed on this machine. To install: ${recipe.installHint || ""}`
3557
+ : noLogin
3558
+ ? `Found at ${recipe.installedAt || "bundled"}, but no login of ${recipe.vendor} was found here. Run ${recipe.loginCommand} in a terminal first, or summon it and see.`
3559
+ : `Found on this machine: ${recipe.installedAt || "bundled"}`;
3560
+ els.invWhere.className = recipe.unavailableReason ? "hint error" : noLogin ? "hint warn" : "hint";
2747
3561
  fillSelect(els.invModel, recipe.modelPresets, preset.model, "vibemate default");
2748
3562
  fillSelect(els.invEffort, recipe.effortPresets, preset.effort, "vibemate default");
2749
3563
  fillSelect(els.invMode, recipe.modePresets, preset.mode, "vibemate default");
@@ -2766,7 +3580,7 @@
2766
3580
  els.invAgents.innerHTML = state.recipes
2767
3581
  .map(
2768
3582
  (r) =>
2769
- `<button type="button" class="agent-tile${r.unavailableReason ? " off" : ""}" data-agent="${esc(r.id)}" title="${esc(r.unavailableReason ? `Not installed on this machine. ${r.installHint || ""}` : `Found at ${r.installedAt || "bundled"}`)}"><span class="at-logo">${vendorLogo(r)}</span><span class="at-name">${esc(r.vendor)}</span><span class="at-sub">${r.unavailableReason ? "not installed" : "installed"}</span></button>`,
3583
+ `<button type="button" class="agent-tile${r.unavailableReason ? " off" : ""}" data-agent="${esc(r.id)}" title="${esc(r.unavailableReason ? `Not installed on this machine. ${r.installHint || ""}` : r.loginState === "missing" ? `Found at ${r.installedAt || "bundled"}, but not logged in: run ${r.loginCommand}` : `Found at ${r.installedAt || "bundled"}`)}"><span class="at-logo">${vendorLogo(r)}</span><span class="at-name">${esc(r.vendor)}</span><span class="at-sub">${r.unavailableReason ? "not installed" : r.loginState === "missing" ? "no login" : "installed"}</span></button>`,
2770
3584
  )
2771
3585
  .join("");
2772
3586
  els.invAgents.querySelectorAll(".agent-tile:not(.off)").forEach((b) =>
@@ -2829,7 +3643,7 @@
2829
3643
  return;
2830
3644
  }
2831
3645
  els.invSubmit.disabled = true;
2832
- els.invSubmit.classList.add("loading");
3646
+ UI.setState(els.invSubmit, "loading");
2833
3647
  els.invError.hidden = true;
2834
3648
  try {
2835
3649
  if (staffing.id) {
@@ -2865,20 +3679,45 @@
2865
3679
  els.invError.hidden = false;
2866
3680
  } finally {
2867
3681
  els.invSubmit.disabled = false;
2868
- els.invSubmit.classList.remove("loading");
3682
+ UI.setState(els.invSubmit, null);
2869
3683
  }
2870
3684
  }
2871
3685
 
2872
3686
 
2873
3687
  const reconnectPrompted = new Set();
2874
- function openReconnectDialog(room) {
2875
- const offline = offlineAgents(room);
3688
+ function renderReconnectDefault() {
3689
+ const mode = (state.settings || {}).reconnectMode === "load" ? "load" : "replay";
3690
+ for (const choice of els.rcForm.querySelectorAll(".choice")) {
3691
+ const value = choice.querySelector('input[name="rc-mode"]').value;
3692
+ choice.querySelector(".rc-is-default").hidden = value !== mode;
3693
+ choice.querySelector(".rc-default").hidden = value === mode;
3694
+ }
3695
+ return mode;
3696
+ }
3697
+ els.rcForm.addEventListener("click", async (e) => {
3698
+ const button = e.target.closest(".rc-default");
3699
+ if (!button) return;
3700
+ const mode = button.dataset.mode;
3701
+ try {
3702
+ await post("/api/settings", { reconnectMode: mode });
3703
+ if (state.settings) state.settings.reconnectMode = mode;
3704
+ els.rcForm.querySelector(`input[name="rc-mode"][value="${mode}"]`).checked = true;
3705
+ renderReconnectDefault();
3706
+ toast(mode === "load" ? "Welcome back will offer the full session first." : "Welcome back will offer the replay first.", "success");
3707
+ } catch (error) {
3708
+ showError(error);
3709
+ }
3710
+ });
3711
+ function openReconnectDialog(room, only) {
3712
+ const offline = offlineAgents(room).filter((p) => !only || p.id === only.id);
2876
3713
  if (!offline.length) return;
2877
- reconnectPrompted.add(room.id);
3714
+ if (!only) reconnectPrompted.add(room.id);
2878
3715
  els.rcError.hidden = true;
2879
3716
  els.rcReplay.value = room.settings.replayAfterRestart ?? 10;
2880
- els.rcForm.querySelector('input[name="rc-mode"][value="replay"]').checked = true;
2881
- els.rcIntro.textContent = `${offline.length} vibemate${offline.length > 1 ? "s are" : " is"} offline in "${room.name}" (their sessions ended with the previous hub run). Choose how they come back:`;
3717
+ els.rcForm.querySelector(`input[name="rc-mode"][value="${renderReconnectDefault()}"]`).checked = true;
3718
+ els.rcIntro.textContent = only
3719
+ ? `${only.name} is offline in "${room.name}" (its session ended with the previous hub run). Choose how it comes back:`
3720
+ : `${offline.length} vibemate${offline.length > 1 ? "s are" : " is"} offline in "${room.name}" (their sessions ended with the previous hub run). Choose how they come back:`;
2882
3721
  els.rcTable.innerHTML = offline
2883
3722
  .map(
2884
3723
  (p) => `<tr data-id="${esc(p.id)}">
@@ -2898,7 +3737,7 @@
2898
3737
  const replay = Number(els.rcReplay.value);
2899
3738
  const rows = [...els.rcTable.querySelectorAll("tr")].map((tr) => ({ id: tr.dataset.id, choice: tr.querySelector(".rc-per").value || globalMode }));
2900
3739
  els.rcSubmit.disabled = true;
2901
- els.rcSubmit.classList.add("loading");
3740
+ UI.setState(els.rcSubmit, "loading");
2902
3741
  els.rcError.hidden = true;
2903
3742
  const failures = [];
2904
3743
  for (const row of rows) {
@@ -2910,7 +3749,7 @@
2910
3749
  }
2911
3750
  }
2912
3751
  els.rcSubmit.disabled = false;
2913
- els.rcSubmit.classList.remove("loading");
3752
+ UI.setState(els.rcSubmit, null);
2914
3753
  if (failures.length) {
2915
3754
  els.rcError.textContent = failures.join(" · ");
2916
3755
  els.rcError.hidden = false;
@@ -2948,10 +3787,10 @@
2948
3787
  tplEls.list.innerHTML = tpl.items
2949
3788
  .map((t) => {
2950
3789
  const on = tpl.current && t.id === tpl.current.id;
2951
- const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: "#9ca3af" }, 20, {})).join("");
3790
+ const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: FALLBACK_COLOR }, 20, {})).join("");
2952
3791
  return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
2953
3792
  <span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
2954
- <span class="tpl-body"><b>${esc(t.name)}${t.builtin ? "" : '<span class="badge tpl-mine">your template</span>'}${t.recommended ? '<span class="badge tpl-rec">recommended</span>' : ""}</b><span class="tpl-meta">${t.vibemates.length} vibemate${t.vibemates.length === 1 ? "" : "s"}${t.builtin ? " · built in" : ""}</span><span class="avatar-stack">${faces}</span></span>
3793
+ <span class="tpl-body"><b>${esc(t.name)}${t.builtin ? "" : UI.html("badge", { label: "your template", size: "xs" })}${t.recommended ? UI.html("badge", { label: "recommended", tone: "attention", size: "xs" }) : ""}</b><span class="tpl-meta">${t.vibemates.length} vibemate${t.vibemates.length === 1 ? "" : "s"}${t.builtin ? " · built in" : ""}</span><span class="avatar-stack">${faces}</span></span>
2955
3794
  <span class="tpl-check">${ic("check")}</span>
2956
3795
  </button>`;
2957
3796
  })
@@ -3015,7 +3854,7 @@
3015
3854
  if (!v.agentType) return "";
3016
3855
  const rec = state.recipes.find((r) => r.id === v.agentType);
3017
3856
  const parts = [rec ? rec.vendor : v.agentType, v.model, v.effort, v.mode].filter(Boolean);
3018
- return `<div class="chips tpl-runs">${parts.map((x) => `<span class="chip">${esc(x)}</span>`).join("")}${rec && rec.unavailableReason ? '<span class="badge status-offline">not installed here</span>' : ""}</div>`;
3857
+ return `<div class="chips tpl-runs">${parts.map((x) => UI.html("chip", { label: x })).join("")}${rec && rec.unavailableReason ? UI.html("badge", { label: "not installed here", tone: "asleep" }) : ""}</div>`;
3019
3858
  }
3020
3859
 
3021
3860
  const stpEls = { dialog: $("#save-template-dialog"), form: $("#save-template-form"), name: $("#stp-name"), desc: $("#stp-desc"), preview: $("#stp-preview"), error: $("#stp-error"), create: $("#stp-create") };
@@ -3071,8 +3910,8 @@
3071
3910
  ${stpSwitch("Show vendor and model to other vibemates", "stp-vendor", !!st.showVendorInRoster)}
3072
3911
  </div>`;
3073
3912
  const agentOptions = [["", "none yet: cast when the room opens"], ...state.recipes.map((r) => [r.id, r.vendor + (r.unavailableReason ? " (not installed here)" : "")])];
3074
- const vms = (t.vibemates || []).map((v, i) => `<div class="stp-vm" data-i="${i}">${avatar({ name: v.name, avatar: v.avatar, color: "#9ca3af" }, 36, {})}<div>
3075
- <div class="stp-vm-top"><b>${esc(v.name)}</b><button type="button" class="icon-btn sm ghost" title="Leave this vibemate out of the template" data-stp-remove>${ic("close")}</button></div>
3913
+ const vms = (t.vibemates || []).map((v, i) => `<div class="stp-vm" data-i="${i}">${avatar({ name: v.name, avatar: v.avatar, color: FALLBACK_COLOR }, 36, {})}<div>
3914
+ <div class="stp-vm-top"><b>${esc(v.name)}</b>${UI.html("icon-button", { icon: "close", title: "Leave this vibemate out of the template", kind: "ghost", size: "sm", data: { stpRemove: true } })}</div>
3076
3915
  <div class="stp-vm-fields">
3077
3916
  ${stpField("Vibename", `<input type="text" data-k="name" maxlength="40" value="${esc(v.name)}" required>`)}
3078
3917
  ${stpField("Vibersona", `<input type="text" data-k="tagline" maxlength="80" value="${esc(v.tagline || "")}">`)}
@@ -3089,7 +3928,7 @@
3089
3928
  return `
3090
3929
  <div class="stp-section"><h5>Room</h5>${room}</div>
3091
3930
  <div class="stp-section"><h5>Room rules</h5><textarea id="stp-rules" rows="5" maxlength="4000" placeholder="one rule per line">${esc(st.customRules || "")}</textarea></div>
3092
- <div class="stp-section"><h5>Folder</h5><span class="dir-row"><input type="text" id="stp-dir" maxlength="1000" value="${esc(t.dir || "")}" spellcheck="false"><button type="button" class="btn ghost browse-btn" data-stp-browse>${ic("folder")}Browse</button></span></div>
3931
+ <div class="stp-section"><h5>Folder</h5><span class="dir-row"><input type="text" id="stp-dir" maxlength="1000" value="${esc(t.dir || "")}" spellcheck="false">${UI.html("button", { label: "Browse", icon: "folder", kind: "ghost", hook: "browse-btn", data: { stpBrowse: true } })}</span></div>
3093
3932
  <div class="stp-section"><h5>Vibemates · <span id="stp-vm-count">${(t.vibemates || []).length}</span></h5>${vms || '<span class="hint">none</span>'}</div>`;
3094
3933
  }
3095
3934
  function readTemplateForm() {
@@ -3209,7 +4048,7 @@
3209
4048
  els.eraseForm.addEventListener("submit", async (event) => {
3210
4049
  event.preventDefault();
3211
4050
  try {
3212
- els.eraseSubmit.classList.add("loading");
4051
+ UI.setState(els.eraseSubmit, "loading");
3213
4052
  await post("/api/profile/erase", { confirm: els.eraseWord.value.trim().toLowerCase() });
3214
4053
  try {
3215
4054
  localStorage.clear();
@@ -3217,7 +4056,7 @@
3217
4056
  }
3218
4057
  location.href = "/";
3219
4058
  } catch (error) {
3220
- els.eraseSubmit.classList.remove("loading");
4059
+ UI.setState(els.eraseSubmit, null);
3221
4060
  els.eraseError.textContent = error.message;
3222
4061
  els.eraseError.hidden = false;
3223
4062
  }
@@ -3245,13 +4084,20 @@
3245
4084
  function applyAppearance() {
3246
4085
  const a = (state.settings || {}).appearance || {};
3247
4086
  const root = document.documentElement;
3248
- root.style.zoom = String((a.chatFontSize || 14.5) / 14.5);
4087
+ root.style.setProperty("--fs-scale", String((a.chatFontSize || 14.5) / 14.5));
3249
4088
  root.style.setProperty("--font", (FONTS.text[a.font] || FONTS.text.nunito).stack);
3250
4089
  root.style.setProperty("--mono", (FONTS.mono[a.mono] || FONTS.mono["jetbrains-mono"]).stack);
3251
4090
  }
3252
- const zoomFactor = () => Number(document.documentElement.style.zoom) || 1;
3253
4091
 
4092
+ let hubIdentity = null;
3254
4093
  function loadSnapshot(snapshot) {
4094
+ const v = snapshot.version;
4095
+ const identity = v && v.build ? `${v.version} ${v.build} ${v.pid ?? ""}` : null;
4096
+ if (identity && hubIdentity && identity !== hubIdentity) {
4097
+ location.reload();
4098
+ return;
4099
+ }
4100
+ if (identity) hubIdentity = identity;
3255
4101
  state.settings = snapshot.settings;
3256
4102
  applyAppearance();
3257
4103
  state.update = snapshot.update || null;
@@ -3358,6 +4204,18 @@
3358
4204
  room.permissions = room.permissions.filter((p) => p.key !== event.key);
3359
4205
  if (showing) resolvePermissionCard(event.key, event.optionId);
3360
4206
  return;
4207
+ case "proposal":
4208
+ room.proposals = [...(room.proposals || []), event.proposal];
4209
+ if (showing) renderProposal(room, event.proposal);
4210
+ else toast(`${esc(event.proposal.participantName)} proposes changes to "${room.name}".`, "warn");
4211
+ return;
4212
+ case "proposal.resolved": {
4213
+ const p = (room.proposals || []).find((x) => x.key === event.key);
4214
+ if (p) p.skipped = event.skipped;
4215
+ if (showing) resolveProposalCard(room, event.key, event.status);
4216
+ else if (p) p.status = event.status;
4217
+ return;
4218
+ }
3361
4219
  case "room":
3362
4220
  room.hopLimit = event.hopLimit;
3363
4221
  room.hops = event.hops;
@@ -3384,34 +4242,23 @@
3384
4242
  }
3385
4243
  }
3386
4244
 
3387
- let stream = null;
3388
- let releaseTimer = null;
3389
- function connect() {
3390
- if (stream) return;
3391
- const es = new EventSource("/events");
3392
- stream = es;
3393
- es.onopen = () => els.conn.classList.add("ok");
3394
- es.onerror = () => els.conn.classList.remove("ok");
3395
- es.addEventListener("snapshot", (e) => loadSnapshot(JSON.parse(e.data).snapshot));
3396
- es.addEventListener("room.event", (e) => {
3397
- const { roomId, event } = JSON.parse(e.data);
3398
- onRoomEvent(roomId, event);
3399
- });
3400
- es.addEventListener("room.created", (e) => {
3401
- const { room } = JSON.parse(e.data);
3402
- state.rooms.set(room.id, room);
4245
+ const HUB_EVENTS = {
4246
+ snapshot: (m) => loadSnapshot(m.snapshot),
4247
+ "room.event": (m) => onRoomEvent(m.roomId, m.event),
4248
+ "room.created": (m) => {
4249
+ state.rooms.set(m.room.id, m.room);
3403
4250
  if ((state.view === "rooms" || state.view === "home")) {
3404
4251
  renderSideRooms();
3405
4252
  renderRoomsGrid();
3406
4253
  }
3407
4254
  renderRail();
3408
- });
3409
- es.addEventListener("rooms.opened", (e) => {
3410
- state.openRooms = JSON.parse(e.data).roomIds || [];
4255
+ },
4256
+ "rooms.opened": (m) => {
4257
+ state.openRooms = m.roomIds || [];
3411
4258
  renderRail();
3412
- });
3413
- es.addEventListener("room.removed", (e) => {
3414
- const { roomId } = JSON.parse(e.data);
4259
+ },
4260
+ "room.removed": (m) => {
4261
+ const { roomId } = m;
3415
4262
  state.rooms.delete(roomId);
3416
4263
  state.openRooms = state.openRooms.filter((id) => id !== roomId);
3417
4264
  if (state.currentRoomId === roomId) {
@@ -3424,35 +4271,68 @@
3424
4271
  renderRoomsGrid();
3425
4272
  }
3426
4273
  renderRail();
3427
- });
3428
- es.addEventListener("skills", (e) => {
3429
- state.skills = JSON.parse(e.data).skills || [];
4274
+ },
4275
+ skills: (m) => {
4276
+ state.skills = m.skills || [];
3430
4277
  if (state.view === "skills" && !editingInDetails()) renderSkillsPage();
3431
4278
  if (state.detailsOpen && !editingInDetails()) renderDetails();
3432
- });
3433
- es.addEventListener("settings", (e) => {
3434
- state.settings = JSON.parse(e.data).settings;
4279
+ },
4280
+ settings: (m) => {
4281
+ state.settings = m.settings;
3435
4282
  applyAppearance();
3436
4283
  rerenderDiagrams();
3437
4284
  renderRail();
3438
4285
  if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
3439
4286
  if (state.detailsOpen && state.selection.kind === "me" && !editingInDetails()) renderDetails();
3440
4287
  if (state.view === "room") renderSideRoom();
3441
- });
3442
- es.addEventListener("update", (e) => {
3443
- state.update = JSON.parse(e.data).update;
4288
+ },
4289
+ update: (m) => {
4290
+ state.update = m.update;
3444
4291
  renderUpdatePop();
3445
4292
  if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
3446
- });
3447
- es.addEventListener("reset", () => location.href = "/");
4293
+ },
4294
+ reset: () => (location.href = "/"),
4295
+ };
4296
+ let stream = null;
4297
+ let releaseTimer = null;
4298
+ let retryTimer = null;
4299
+ let retryDelay = 1000;
4300
+ function connect() {
4301
+ if (stream) return;
4302
+ clearTimeout(retryTimer);
4303
+ const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
4304
+ stream = ws;
4305
+ ws.onopen = () => {
4306
+ retryDelay = 1000;
4307
+ els.conn.classList.add("ok");
4308
+ };
4309
+ ws.onmessage = (e) => {
4310
+ const m = JSON.parse(e.data);
4311
+ const handle = HUB_EVENTS[m.type];
4312
+ if (handle) handle(m);
4313
+ };
4314
+ ws.onerror = () => els.conn.classList.remove("ok");
4315
+ ws.onclose = () => {
4316
+ els.conn.classList.remove("ok");
4317
+ if (stream !== ws) return;
4318
+ stream = null;
4319
+ retryTimer = setTimeout(connect, retryDelay);
4320
+ retryDelay = Math.min(retryDelay * 2, 10000);
4321
+ };
3448
4322
  }
3449
4323
  function releaseStream() {
3450
4324
  if (!stream) return;
3451
- stream.close();
4325
+ const ws = stream;
3452
4326
  stream = null;
4327
+ ws.close();
3453
4328
  els.conn.classList.remove("ok");
3454
4329
  els.conn.title = "Paused while this tab is in the background; resumes when you come back";
3455
4330
  }
4331
+ function resyncStream() {
4332
+ releaseStream();
4333
+ els.conn.title = "Connection to the hub";
4334
+ connect();
4335
+ }
3456
4336
  document.addEventListener("visibilitychange", () => {
3457
4337
  clearTimeout(releaseTimer);
3458
4338
  if (document.hidden) releaseTimer = setTimeout(releaseStream, 15000);
@@ -3497,14 +4377,14 @@
3497
4377
  let drag = null;
3498
4378
  grip.addEventListener("pointerdown", (e) => {
3499
4379
  if (e.button !== 0) return;
3500
- drag = { y: e.clientY / zoomFactor(), h: els.input.offsetHeight };
4380
+ drag = { y: e.clientY, h: els.input.offsetHeight };
3501
4381
  grip.setPointerCapture(e.pointerId);
3502
4382
  els.composer.classList.add("resizing");
3503
4383
  e.preventDefault();
3504
4384
  });
3505
4385
  grip.addEventListener("pointermove", (e) => {
3506
4386
  if (!drag) return;
3507
- composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY / zoomFactor())));
4387
+ composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY)));
3508
4388
  autosize();
3509
4389
  });
3510
4390
  const stop = () => {
@@ -3530,11 +4410,12 @@
3530
4410
 
3531
4411
  const composerClear = $("#composer-clear");
3532
4412
  function updateComposerClear() {
3533
- composerClear.hidden = !(els.input.value.trim() || pendingShots.length);
4413
+ composerClear.hidden = !(els.input.value.trim() || pendingShots.length || pendingQuotes.length);
3534
4414
  }
3535
4415
  composerClear.addEventListener("click", () => {
3536
4416
  els.input.value = "";
3537
4417
  clearShots();
4418
+ clearQuotes();
3538
4419
  autosize();
3539
4420
  updateComposerClear();
3540
4421
  els.input.focus();
@@ -3554,7 +4435,7 @@
3554
4435
  renderShotsTray();
3555
4436
  }
3556
4437
 
3557
- function insertShotMarker(n) {
4438
+ function insertMarker(text) {
3558
4439
  const el = els.input;
3559
4440
  const value = el.value;
3560
4441
  const start = el.selectionStart ?? value.length;
@@ -3563,16 +4444,15 @@
3563
4444
  const after = value.slice(end);
3564
4445
  const lead = before && !/\s$/.test(before) ? " " : "";
3565
4446
  const tail = after && !/^\s/.test(after) ? " " : "";
3566
- const marker = `${lead}${shotMarker(n)}${tail}`;
4447
+ const marker = `${lead}${text}${tail}`;
3567
4448
  el.value = before + marker + after;
3568
4449
  const caret = before.length + marker.length;
3569
4450
  el.setSelectionRange(caret, caret);
3570
4451
  autosize();
3571
4452
  }
3572
4453
 
3573
- function removeShotMarker(n) {
4454
+ function removeMarker(pattern) {
3574
4455
  const el = els.input;
3575
- const pattern = new RegExp(` ?\\[img ${n}\\]`, "gi");
3576
4456
  const caret = el.selectionStart ?? el.value.length;
3577
4457
  const removedBefore = (el.value.slice(0, caret).match(pattern) || []).join("").length;
3578
4458
  el.value = el.value.replace(pattern, "");
@@ -3597,7 +4477,7 @@
3597
4477
  const n = ++shotSeq;
3598
4478
  pendingShots.push({ n, name, mimeType: file.type, data: String(reader.result) });
3599
4479
  renderShotsTray();
3600
- insertShotMarker(n);
4480
+ insertMarker(shotMarker(n));
3601
4481
  };
3602
4482
  reader.onerror = () => showError(new Error(`could not read ${name || "the image"}`));
3603
4483
  reader.readAsDataURL(file);
@@ -3613,14 +4493,146 @@
3613
4493
  const drop = e.target.closest(".shot-drop");
3614
4494
  if (!drop) return;
3615
4495
  const [shot] = pendingShots.splice(Number(drop.dataset.i), 1);
3616
- if (shot) removeShotMarker(shot.n);
4496
+ if (shot) removeMarker(new RegExp(` ?\\[img ${shot.n}\\]`, "gi"));
3617
4497
  renderShotsTray();
3618
4498
  });
4499
+
4500
+ const QUOTES_MAX = 6;
4501
+ let pendingQuotes = [];
4502
+ let quoteSeq = 0;
4503
+ const quoteMarker = (n) => `[quote ${n}]`;
4504
+ const CHIP_CHARS = 70;
4505
+
4506
+ function renderQuotesTray() {
4507
+ updateComposerClear();
4508
+ els.quotesTray.hidden = !pendingQuotes.length;
4509
+ els.quotesTray.innerHTML = pendingQuotes
4510
+ .map(
4511
+ (q, i) =>
4512
+ `<span class="quote-chip" title="${esc(q.text)}"><span class="qc-n">${q.n}</span><b>${esc(q.fromName)}</b><span class="qc-text">${esc(q.text.replace(/\s+/g, " ").slice(0, CHIP_CHARS))}${q.text.length > CHIP_CHARS ? "…" : ""}</span><button type="button" class="qc-drop" data-i="${i}" title="Remove this quote">×</button></span>`,
4513
+ )
4514
+ .join("");
4515
+ }
4516
+
4517
+ function clearQuotes() {
4518
+ pendingQuotes = [];
4519
+ quoteSeq = 0;
4520
+ renderQuotesTray();
4521
+ }
4522
+
4523
+ function addQuote(m, text) {
4524
+ if (!m || m.kind !== "chat" || m.pending) return void showError(new Error("only a message the room has can be quoted"));
4525
+ if (pendingQuotes.length >= QUOTES_MAX) return void showError(new Error(`up to ${QUOTES_MAX} quotes per message`));
4526
+ const fragment = String(text || "").trim() || String(m.text || "").trim();
4527
+ if (!fragment) return void showError(new Error("there is no text to quote in that message"));
4528
+ const n = ++quoteSeq;
4529
+ pendingQuotes.push({ n, seq: m.seq, from: m.from, fromName: m.fromName, ts: m.ts, text: fragment });
4530
+ renderQuotesTray();
4531
+ insertMarker(quoteMarker(n));
4532
+ els.input.focus();
4533
+ }
4534
+
4535
+ els.quotesTray.addEventListener("click", (e) => {
4536
+ const drop = e.target.closest(".qc-drop");
4537
+ if (!drop) return;
4538
+ const [quote] = pendingQuotes.splice(Number(drop.dataset.i), 1);
4539
+ if (quote) removeMarker(new RegExp(` ?\\[quote ${quote.n}\\]`, "gi"));
4540
+ renderQuotesTray();
4541
+ });
4542
+
4543
+ function messageOfNode(node) {
4544
+ const el = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
4545
+ const msg = el && el.closest && el.closest(".msg[data-id]");
4546
+ const room = currentRoom();
4547
+ if (!msg || !room) return null;
4548
+ const m = room.messages.find((x) => x.id === msg.dataset.id);
4549
+ return m && m.kind === "chat" ? m : null;
4550
+ }
4551
+
4552
+ function bubbleSelection() {
4553
+ const sel = window.getSelection();
4554
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return null;
4555
+ const text = sel.toString().trim();
4556
+ if (!text) return null;
4557
+ const range = sel.getRangeAt(0);
4558
+ const m = messageOfNode(range.startContainer);
4559
+ if (!m || m !== messageOfNode(range.endContainer)) return null;
4560
+ if (!range.startContainer.parentElement || !range.startContainer.parentElement.closest(".bubble .text")) return null;
4561
+ return { m, text, rect: range.getBoundingClientRect() };
4562
+ }
4563
+
4564
+ const quotePop = document.createElement("button");
4565
+ quotePop.type = "button";
4566
+ quotePop.className = "quote-pop";
4567
+ quotePop.hidden = true;
4568
+ quotePop.innerHTML = `${ic("quote")}Quote`;
4569
+ document.body.appendChild(quotePop);
4570
+ let quotePopFor = null;
4571
+ function placeQuotePop() {
4572
+ const found = bubbleSelection();
4573
+ if (!found) {
4574
+ quotePop.hidden = true;
4575
+ quotePopFor = null;
4576
+ return;
4577
+ }
4578
+ quotePopFor = found;
4579
+ quotePop.hidden = false;
4580
+ const top = Math.max(8, found.rect.top - 36);
4581
+ quotePop.style.top = `${top}px`;
4582
+ quotePop.style.left = `${Math.min(window.innerWidth - 96, Math.max(8, found.rect.left + found.rect.width / 2 - 40))}px`;
4583
+ }
4584
+ els.messages.addEventListener("mouseup", () => setTimeout(placeQuotePop, 0));
4585
+ els.messages.addEventListener("keyup", (e) => {
4586
+ if (e.shiftKey || e.key === "Shift") setTimeout(placeQuotePop, 0);
4587
+ });
4588
+ document.addEventListener("selectionchange", () => {
4589
+ if (!quotePop.hidden && !bubbleSelection()) {
4590
+ quotePop.hidden = true;
4591
+ quotePopFor = null;
4592
+ }
4593
+ });
4594
+ quotePop.addEventListener("mousedown", (e) => e.preventDefault());
4595
+ quotePop.addEventListener("click", () => {
4596
+ if (quotePopFor) addQuote(quotePopFor.m, quotePopFor.text);
4597
+ quotePop.hidden = true;
4598
+ quotePopFor = null;
4599
+ window.getSelection().removeAllRanges();
4600
+ });
4601
+
4602
+ els.messages.addEventListener("copy", (e) => {
4603
+ const found = bubbleSelection();
4604
+ if (!found || !e.clipboardData) return;
4605
+ e.clipboardData.setData("text/plain", found.text);
4606
+ e.clipboardData.setData(
4607
+ "text/html",
4608
+ `<blockquote data-viberoom-seq="${found.m.seq}" data-viberoom-room="${esc(currentRoom().id)}" data-viberoom-from="${esc(found.m.fromName)}" cite="viberoom">${esc(found.text)}</blockquote>`,
4609
+ );
4610
+ e.preventDefault();
4611
+ });
4612
+
4613
+ function pastedQuotes(transfer) {
4614
+ const room = currentRoom();
4615
+ const html = transfer && room ? transfer.getData("text/html") : "";
4616
+ if (!html || !html.includes("data-viberoom-seq")) return [];
4617
+ const box = document.createElement("div");
4618
+ box.innerHTML = html;
4619
+ return [...box.querySelectorAll("[data-viberoom-seq]")]
4620
+ .filter((node) => node.dataset.viberoomRoom === room.id)
4621
+ .map((node) => ({ m: room.messages.find((x) => x.seq === Number(node.dataset.viberoomSeq)), text: node.textContent }))
4622
+ .filter((q) => q.m);
4623
+ }
4624
+
3619
4625
  els.input.addEventListener("paste", (e) => {
3620
4626
  const files = imageFilesFrom(e.clipboardData);
3621
- if (!files.length) return;
4627
+ if (files.length) {
4628
+ e.preventDefault();
4629
+ addShotFiles(files);
4630
+ return;
4631
+ }
4632
+ const quotes = pastedQuotes(e.clipboardData);
4633
+ if (!quotes.length) return;
3622
4634
  e.preventDefault();
3623
- addShotFiles(files);
4635
+ for (const q of quotes) addQuote(q.m, q.text);
3624
4636
  });
3625
4637
  for (const target of [els.composer, els.messages]) {
3626
4638
  target.addEventListener("dragover", (e) => {
@@ -3640,6 +4652,7 @@
3640
4652
  }
3641
4653
  let typingSentAt = 0;
3642
4654
  els.input.addEventListener("input", () => {
4655
+ lastTypedAt = Date.now();
3643
4656
  if (!currentRoom() || !els.input.value.trim()) return;
3644
4657
  const now = Date.now();
3645
4658
  if (now - typingSentAt < 2000) return;
@@ -3658,9 +4671,19 @@
3658
4671
  else closeLifePop();
3659
4672
  return;
3660
4673
  }
3661
- if (e.target.closest(".p-gear")) return openDetails({ kind: "participant", id: p.id });
3662
- if (e.target.closest(".stop-btn")) return void post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`)).catch(showError);
3663
- if (e.target.closest(".reconnect-btn")) return openReconnectDialog(room);
4674
+ const action = e.target.closest('[data-ui="row-button"]');
4675
+ if (action) {
4676
+ const act = action.dataset.act;
4677
+ if (act === "panel") return openDetails({ kind: "participant", id: p.id });
4678
+ if (act === "last-reply") {
4679
+ const last = [...els.messages.querySelectorAll(`.msg.agent[data-from="${cssEscape(p.id)}"]`)].pop();
4680
+ if (last) jumpToMessage(last);
4681
+ else toast(`${p.name} has not replied in this room yet`);
4682
+ return;
4683
+ }
4684
+ if (act === "wake") return openReconnectDialog(room, p);
4685
+ return;
4686
+ }
3664
4687
  if (e.target.closest("button")) return;
3665
4688
  if (p.kind === "human") openDetails({ kind: "me" });
3666
4689
  else if (p.status === "unstaffed") openStaffDialog(p);
@@ -3692,30 +4715,36 @@
3692
4715
  event.preventDefault();
3693
4716
  const text = els.input.value.trim();
3694
4717
  const room = currentRoom();
3695
- if ((!text && !pendingShots.length) || !room) return;
4718
+ if ((!text && !pendingShots.length && !pendingQuotes.length) || !room) return;
3696
4719
  const shots = pendingShots;
4720
+ const quotes = pendingQuotes;
3697
4721
  els.input.value = "";
3698
4722
  clearShots();
4723
+ clearQuotes();
3699
4724
  typingSentAt = 0;
3700
4725
  autosize();
3701
4726
  const local = { id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, seq: 0, from: "human", fromName: (state.settings || {}).humanName || "You", to: [], toNames: [], text, ts: Date.now(), kind: "chat", pending: true };
3702
4727
  if (shots.length) local.images = shots.map((shot) => ({ file: "", name: shot.name, mimeType: shot.mimeType, bytes: 0, n: shot.n, url: shot.data }));
4728
+ if (quotes.length) local.quotes = quotes.map((q) => ({ ...q }));
4729
+ const localId = local.id;
3703
4730
  upsertMessage(room.id, local);
3704
4731
  try {
3705
- const r = await post(roomApi("/send"), { text, images: shots });
3706
- if (r.command) removeMessage(room.id, local.id);
3707
- else adoptLocalMessage(room.id, local.id, r.id);
4732
+ const r = await post(roomApi("/send"), { text, images: shots, quotes: quotes.map((q) => ({ n: q.n, seq: q.seq, text: q.text })) });
4733
+ if (r.command) removeMessage(room.id, localId);
4734
+ else adoptLocalMessage(room.id, localId, r.id);
3708
4735
  } catch (error) {
3709
- removeMessage(room.id, local.id);
4736
+ removeMessage(room.id, localId);
3710
4737
  showError(error);
3711
4738
  els.input.value = text;
3712
4739
  pendingShots = shots;
4740
+ pendingQuotes = quotes;
3713
4741
  renderShotsTray();
4742
+ renderQuotesTray();
3714
4743
  }
3715
4744
  });
3716
4745
  function adoptLocalMessage(roomId, localId, realId) {
3717
4746
  const room = state.rooms.get(roomId);
3718
- if (!room || !realId) return;
4747
+ if (!room || !realId || localId === realId) return;
3719
4748
  if (room.messages.some((m) => m.id === realId)) return removeMessage(roomId, localId);
3720
4749
  const m = room.messages.find((x) => x.id === localId);
3721
4750
  if (!m) return;
@@ -3723,6 +4752,11 @@
3723
4752
  delete m.pending;
3724
4753
  const el = els.messages.querySelector(`.msg[data-id="${localId}"]`);
3725
4754
  if (el) el.dataset.id = realId;
4755
+ const note = doneNotes.find((n) => n.id === localId);
4756
+ if (note) {
4757
+ note.id = realId;
4758
+ renderDoneNotes();
4759
+ }
3726
4760
  }
3727
4761
 
3728
4762
 
@@ -4017,10 +5051,18 @@
4017
5051
  }
4018
5052
  els.sideToggle.addEventListener("click", () => setSideOpen(els.app.classList.contains("side-collapsed")));
4019
5053
  let lastScrollTop = 0;
5054
+ let lastUserScrollAt = 0;
5055
+ for (const type of ["wheel", "touchmove", "keydown", "mousedown"]) {
5056
+ els.messages.addEventListener(type, () => (lastUserScrollAt = Date.now()), { passive: true });
5057
+ }
4020
5058
  els.messages.addEventListener("scroll", () => {
4021
5059
  const top = els.messages.scrollTop;
4022
- if (nearBottom()) stuck = true;
4023
- else if (top < lastScrollTop - 1) stuck = false;
5060
+ const byHand = Date.now() - lastUserScrollAt < 700;
5061
+ if (byHand) {
5062
+ if (top < lastScrollTop) stuck = false;
5063
+ else if (els.messages.scrollHeight - top - els.messages.clientHeight < 12) stuck = true;
5064
+ } else if (nearBottom()) stuck = true;
5065
+ else if (top < lastScrollTop) stuck = false;
4024
5066
  lastScrollTop = top;
4025
5067
  els.jumpLatest.hidden = stuck;
4026
5068
  updateTimelineView();
@@ -4062,7 +5104,9 @@
4062
5104
  document.querySelectorAll("[data-close]").forEach((b) => b.addEventListener("click", () => closeDialog(b.closest("dialog"))));
4063
5105
  els.fvOpen.addEventListener("click", async () => {
4064
5106
  try {
4065
- const r = await post("/api/open", { target: `${els.fvOpen.dataset.path}:1` });
5107
+ const marked = els.fvBody.querySelector(".gutter span.line-mark");
5108
+ const line = marked ? Number(marked.textContent) : fileView ? fileView.from : 1;
5109
+ const r = await post("/api/open", { target: `${els.fvOpen.dataset.path}:${line || 1}` });
4066
5110
  toast(r.message, "info");
4067
5111
  } catch (err) {
4068
5112
  showError(err);
@@ -4122,10 +5166,20 @@
4122
5166
  e.preventDefault();
4123
5167
  try {
4124
5168
  const target = link.dataset.open;
4125
- if (!/^(https?:|mailto:)/i.test(target) && VIEWABLE_RE.test(target)) {
4126
- await viewFile(target);
5169
+ if (IMAGE_RE.test(target) && !/^(https?:|mailto:)/i.test(target) && /[\\/]/.test(target)) {
5170
+ openLightbox(imageUrl(target), target.split(/[\\/]/).pop());
4127
5171
  return;
4128
5172
  }
5173
+ if (readableInRoom(target)) {
5174
+ const spec = splitLine(target);
5175
+ const file = spec ? spec.path : target;
5176
+ const room = currentRoom();
5177
+ const found = await resolveInRoom(room ? room.id : "", file);
5178
+ if (!found || found.kind === "file") {
5179
+ await viewFile(file, spec && !VIEWABLE_RE.test(file) ? spec.from : 0);
5180
+ return;
5181
+ }
5182
+ }
4129
5183
  const r = await post("/api/open", { target });
4130
5184
  if (r.action !== "open-url") toast(r.message, "info");
4131
5185
  } catch (err) {
@@ -4138,7 +5192,10 @@
4138
5192
  const code = src.closest(".mermaid-block, .csv-block").querySelector(".mm-code");
4139
5193
  code.hidden = !code.hidden;
4140
5194
  src.textContent = code.hidden ? "source" : "hide source";
5195
+ return;
4141
5196
  }
5197
+ const expand = e.target.closest && e.target.closest(".mm-expand");
5198
+ if (expand) openDiagram(expand.closest(".mermaid-block"));
4142
5199
  });
4143
5200
  els.roomSettingsBtn.addEventListener("click", () => openDetails({ kind: "room" }));
4144
5201
  els.chatInfoBtn.addEventListener("click", () => openDetails({ kind: "room" }));
@@ -4194,13 +5251,14 @@
4194
5251
  const pinned = el.classList.contains("pinned");
4195
5252
  return `<div class="tl-row ${cls}${pinned ? " pinned" : ""}" data-i="${k}">${av}<span class="tl-text">${esc(timelineText(el))}</span>${pinned ? `<span class="tl-pin" title="Pinned">${ic("pin")}</span>` : ""}</div>`;
4196
5253
  }
4197
- function showPop(i) {
5254
+ function showPop(i, keepPlace) {
4198
5255
  const rows = [[i - 2, "faded far"], [i - 1, "faded"], [i, "current"], [i + 1, "faded"], [i + 2, "faded far"]].filter(([k]) => t.items[k]);
4199
5256
  t.pop.innerHTML = rows.map(([k, c]) => rowHtml(k, c)).join("");
4200
5257
  t.pop.hidden = false;
4201
5258
  t.ticks.querySelectorAll(".tl-tick.active").forEach((x) => x.classList.remove("active"));
4202
5259
  const tick = t.ticks.children[i];
4203
5260
  if (tick) tick.classList.add("active");
5261
+ if (keepPlace) return;
4204
5262
  const current = t.pop.querySelector(".tl-row.current");
4205
5263
  let top = (tick ? tick.offsetTop : 0) - (current ? current.offsetTop + current.offsetHeight / 2 : 20) + TICK_H / 2;
4206
5264
  top = Math.max(0, Math.min(top, t.el.clientHeight - t.pop.offsetHeight));
@@ -4228,6 +5286,24 @@
4228
5286
  const row = e.target.closest(".tl-row");
4229
5287
  if (row) jumpToMessage(t.items[Number(row.dataset.i)]);
4230
5288
  });
5289
+ const WHEEL_STEP = 30;
5290
+ let wheelAcc = 0;
5291
+ t.pop.addEventListener(
5292
+ "wheel",
5293
+ (e) => {
5294
+ if (t.pop.hidden || !t.items.length) return;
5295
+ e.preventDefault();
5296
+ wheelAcc += e.deltaY;
5297
+ if (Math.abs(wheelAcc) < WHEEL_STEP) return;
5298
+ const step = Math.sign(wheelAcc);
5299
+ wheelAcc = 0;
5300
+ const row = t.pop.querySelector(".tl-row.current");
5301
+ const current = row ? Number(row.dataset.i) : 0;
5302
+ const next = Math.max(0, Math.min(t.items.length - 1, current + step));
5303
+ if (next !== current) showPop(next, true);
5304
+ },
5305
+ { passive: false },
5306
+ );
4231
5307
  return { render, updateView };
4232
5308
  }
4233
5309
  function timelineText(el) {
@@ -4270,7 +5346,7 @@
4270
5346
  if (room.id !== state.currentRoomId || state.view !== "room") return;
4271
5347
  requestAnimationFrame(() => {
4272
5348
  if (bubbleInView(m.id)) return;
4273
- const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: "agent" };
5349
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: "agent" };
4274
5350
  pushNote({ id: m.id, kind: "done", p, text: `${p.name} finished`, sub: `started ${time(m.ts)}${m.durationMs ? ` · ${spanText(m.durationMs)}` : ""}` });
4275
5351
  });
4276
5352
  }
@@ -4278,7 +5354,7 @@
4278
5354
  if (room.id !== state.currentRoomId || state.view !== "room") return;
4279
5355
  requestAnimationFrame(() => {
4280
5356
  if (bubbleInView(m.id)) return;
4281
- const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: "agent" };
5357
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: "agent" };
4282
5358
  pushNote({ id: m.id, kind: "new", p, text: `${p.name} wrote below`, sub: time(m.ts) });
4283
5359
  });
4284
5360
  }
@@ -4315,16 +5391,20 @@
4315
5391
  const timelines = [
4316
5392
  createTimeline($("#timeline"), () => [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")], {}),
4317
5393
  createTimeline($("#timeline-left"), () => [...els.messages.querySelectorAll(".msg.agent:not(.hidden-by-search)")], {
4318
- colorOf: (room, el) => (authorOf(room, el) || {}).color || "#9ca3af",
5394
+ colorOf: (room, el) => (authorOf(room, el) || {}).color || FALLBACK_COLOR,
4319
5395
  avatarOf: (room, el) => { const p = authorOf(room, el); return p ? avatar(p, 16, {}) : ""; },
4320
5396
  }),
4321
5397
  ];
4322
5398
  function renderTimeline() {
5399
+ const t0 = performance.now();
4323
5400
  for (const t of timelines) t.render();
4324
5401
  renderPins();
5402
+ noteSlow("timeline strips", performance.now() - t0);
4325
5403
  }
4326
5404
  function updateTimelineView() { for (const t of timelines) t.updateView(); }
4327
- const composerFollowers = [$("#timeline"), $("#timeline-left"), els.mentionMenu, els.emojiMenu];
5405
+ attachScrollHints(els.pageInner);
5406
+ attachScrollHints(els.detailsInner);
5407
+ const composerFollowers = [$("#timeline"), $("#timeline-left"), els.mentionMenu, els.emojiMenu, els.jumpLatest];
4328
5408
  new ResizeObserver(() => {
4329
5409
  const h = `${els.composer.offsetHeight}px`;
4330
5410
  for (const el of composerFollowers) el.style.setProperty("--composer-h", h);
@@ -4347,10 +5427,10 @@
4347
5427
  let shown = 0;
4348
5428
  for (const b of buttons) {
4349
5429
  const draft = room.messages.find((x) => x.from === b.dataset.id && x.streaming);
4350
- const head = draft && els.messages.querySelector(`.msg[data-id="${draft.id}"] .head`);
5430
+ const el = draft && els.messages.querySelector(`.msg[data-id="${draft.id}"]`);
4351
5431
  let show = false;
4352
- if (head) {
4353
- const r = head.getBoundingClientRect();
5432
+ if (el) {
5433
+ const r = el.getBoundingClientRect();
4354
5434
  show = !(r.bottom > box.top && r.top < box.bottom);
4355
5435
  }
4356
5436
  b.hidden = !show;
@@ -4468,15 +5548,15 @@
4468
5548
  <span>Last reply</span><span>${last ? `<span title="tokens in">${ic("arrow-down")} ${fmtTokens(last.usage.inputTokens)}</span> <span title="tokens out">${ic("arrow-up")} ${fmtTokens(last.usage.outputTokens)}</span>` : "—"}</span>
4469
5549
  <span>Cost (estimate)</span><span>${fmtCost(p.cost) || "—"}</span>
4470
5550
  <span>Briefs sent</span><span>${p.briefsSent ?? 0}</span>
4471
- <span>Notes</span><span>${p.notes ? `taken at ${fmtTokens(p.notesAt || 0)} tokens` : "none yet"}${p.status === "offline" || p.status === "unstaffed" ? "" : ` · <button type="button" class="link-btn lp-take" title="A hidden turn: the vibemate writes 10 lines for a future restart; nothing is posted">${p.notes ? "refresh" : "take now"}</button>`}${p.notes ? ` · <button type="button" class="link-btn lp-edit">edit</button>` : ""}</span>
5551
+ <span>Notes</span><span>${p.notes ? `taken at ${fmtTokens(p.notesAt || 0)} tokens` : "none yet"}${p.notesTurn ? ` · <span class="taking" title="The hidden turn is running; nothing is posted">taking notes<i></i><i></i><i></i></span>` : `${p.status === "offline" || p.status === "unstaffed" ? "" : ` · <button type="button" class="link-btn lp-take" title="A hidden turn: the vibemate writes 10 lines for a future restart; nothing is posted">${p.notes ? "refresh" : "take now"}</button>`}${p.notes ? ` · <button type="button" class="link-btn lp-edit">edit</button>` : ""}`}</span>
4472
5552
  </div>
4473
- ${p.notes ? `<pre class="lp-notes">${esc(p.notes)}</pre><div class="lp-editor" hidden><textarea class="lp-notes-area" rows="6" maxlength="4000">${esc(p.notes)}</textarea><div class="row-btns"><button type="button" class="btn sm ghost lp-notes-clear">Clear</button><button type="button" class="btn sm primary lp-notes-save">Save</button></div></div>` : ""}
5553
+ ${p.notes ? `<pre class="lp-notes">${esc(p.notes)}</pre><div class="lp-editor" hidden><textarea class="lp-notes-area" rows="6" maxlength="4000">${esc(p.notes)}</textarea><div class="row-btns">${UI.html("button", { label: "Clear", kind: "ghost", size: "sm", hook: "lp-notes-clear" })}${UI.html("button", { label: "Save", kind: "primary", size: "sm", hook: "lp-notes-save" })}</div></div>` : ""}
4474
5554
  <div class="lp-respawn">
4475
- <button type="button" class="btn sm danger lp-empty" title="A new session that knows nothing of this conversation">${ic("bolt")}Respawn, empty head</button>
4476
- <label class="lp-with"><button type="button" class="btn sm lp-mem" title="A new session that re-reads only the last N messages${p.notes ? " and its own notes" : ""}">Respawn with the last</button><input type="number" class="lp-n" min="0" max="500" value="${n}"> messages</label>
5555
+ ${UI.html("button", { label: "Respawn, empty head", icon: "bolt", kind: "danger", size: "sm", hook: "lp-empty", title: "A new session that knows nothing of this conversation" })}
5556
+ <label class="lp-with">${UI.html("button", { label: "Respawn with the last", size: "sm", hook: "lp-mem", title: `A new session that re-reads only the last N messages${p.notes ? " and its own notes" : ""}` })}<input type="number" class="lp-n" min="0" max="500" value="${n}"> messages</label>
4477
5557
  </div>
4478
5558
  </div>
4479
- <div class="lp-side"><button type="button" class="icon-btn sm lp-x" title="Close">${ic("close")}</button><button type="button" class="icon-btn sm lp-more" title="Open this vibemate's panel">${ic("settings")}</button></div>`;
5559
+ <div class="lp-side">${UI.html("icon-button", { icon: "close", title: "Close", size: "sm", hook: "lp-x" })}${UI.html("icon-button", { icon: "settings", title: "Open this vibemate's panel", size: "sm", hook: "lp-more" })}</div>`;
4480
5560
  el.querySelector(".lp-x").addEventListener("click", closeLifePop);
4481
5561
  el.querySelector(".lp-more").addEventListener("click", () => {
4482
5562
  closeLifePop();
@@ -4517,10 +5597,9 @@
4517
5597
  if (save) save.addEventListener("click", () => saveNotes(el.querySelector(".lp-notes-area").value));
4518
5598
  const clear = el.querySelector(".lp-notes-clear");
4519
5599
  if (clear) clear.addEventListener("click", () => saveNotes(""));
4520
- const z = zoomFactor();
4521
5600
  const r = lifePop.anchor.getBoundingClientRect();
4522
- el.style.left = `${Math.round((r.right + 12) / z)}px`;
4523
- el.style.top = `${Math.round(Math.max(8, Math.min(r.top / z - 10, window.innerHeight / z - el.offsetHeight - 8)))}px`;
5601
+ el.style.left = `${Math.round(r.right + 12)}px`;
5602
+ el.style.top = `${Math.round(Math.max(8, Math.min(r.top - 10, window.innerHeight - el.offsetHeight - 8)))}px`;
4524
5603
  }
4525
5604
  async function respawnWith(p, n) {
4526
5605
  const text =
@@ -4677,11 +5756,7 @@
4677
5756
  for (const room of state.rooms.values()) if (room.dir && !dirs.some((d) => sameFolder(d, room.dir))) dirs.push(room.dir);
4678
5757
  fpEls.recent.innerHTML = "";
4679
5758
  for (const d of dirs.slice(0, 6)) {
4680
- const b = document.createElement("button");
4681
- b.type = "button";
4682
- b.className = "chip-btn";
4683
- b.title = d;
4684
- b.textContent = d.split(/[\\/]/).filter(Boolean).slice(-1)[0] || d;
5759
+ const b = UI.el("choice", { label: d.split(/[\\/]/).filter(Boolean).slice(-1)[0] || d, title: d });
4685
5760
  b.addEventListener("click", () => fpGoTo(d));
4686
5761
  fpEls.recent.appendChild(b);
4687
5762
  }
@@ -4802,5 +5877,7 @@
4802
5877
  window.addEventListener("pagehide", () => report(true));
4803
5878
  }
4804
5879
 
5880
+ setInterval(tickLive, 1000);
5881
+
4805
5882
  connect();
4806
5883
  })();