viberoom 0.5.9 → 0.6.1

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"/>'
@@ -187,7 +224,7 @@
187
224
  const timer = setTimeout(() => {
188
225
  if (Date.now() - stallNoticeAt > 30000) {
189
226
  stallNoticeAt = Date.now();
190
- 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");
191
228
  }
192
229
  }, 8000);
193
230
  return promise.finally(() => clearTimeout(timer));
@@ -285,7 +322,7 @@
285
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>`;
286
323
  }
287
324
  function mermaidBlock(code) {
288
- 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>`;
289
326
  }
290
327
  function mentions(room, html) {
291
328
  return html.replace(/(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu, (m, name) => {
@@ -297,6 +334,21 @@
297
334
  const md = window.marked ? new window.marked.Marked({ gfm: true, breaks: true }) : null;
298
335
  if (md) {
299
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
+ ],
300
352
  renderer: {
301
353
  html(token) {
302
354
  return esc(token.text != null ? token.text : token.raw || "");
@@ -305,7 +357,9 @@
305
357
  const lang = token.lang || "";
306
358
  if (/^\s*(csv|tsv)\b/i.test(lang)) return csvBlock(String(token.text || ""));
307
359
  const code = esc(String(token.text || "")).trim();
308
- 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>`;
309
363
  },
310
364
  link(token) {
311
365
  const inner = this.parser.parseInline(token.tokens || []);
@@ -332,9 +386,27 @@
332
386
  }
333
387
  return out;
334
388
  }
335
- function renderText(room, text, images) {
336
- const html = md ? decorate(room, md.parse(String(text == null ? "" : text))) : renderTextLight(room, text);
337
- 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>`;
338
410
  }
339
411
  function imageRefs(html, images) {
340
412
  const numbers = new Set(images.map((image, i) => image.n || i + 1));
@@ -358,8 +430,298 @@
358
430
  return html;
359
431
  }
360
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
+
361
515
 
362
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
+ }
363
725
  function csvTable(rows) {
364
726
  if (!rows.length) return '<p class="lead">Empty file.</p>';
365
727
  const cell = (tag, v) => `<${tag}>${esc(v)}</${tag}>`;
@@ -368,35 +730,98 @@
368
730
  const pad = (r) => r.concat(Array(Math.max(0, width - r.length)).fill(""));
369
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>`;
370
732
  }
371
- async function viewFile(path) {
372
- 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 };
373
739
  els.fvTitle.textContent = r.path.split(/[\\/]/).pop();
374
740
  els.fvPath.textContent = r.path;
375
741
  els.fvBody.className = `file-view ${r.kind}`;
376
- 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);
377
745
  els.fvOpen.dataset.path = r.path;
378
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
+ }
379
754
  openDialog(els.fileDialog);
380
755
  if (r.kind === "markdown") renderDiagrams(els.fvBody);
381
- }
756
+ if (r.kind === "text") {
757
+ await highlightBlocks(els.fvBody);
758
+ if (at > 1) markLine(at);
759
+ }
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
+ });
382
822
 
383
823
 
384
- const POP_PALETTE = [
385
- { fill: "#e4e6fb", stroke: "#5b5bf0" },
386
- { fill: "#d9f7e8", stroke: "#1d8f6a" },
387
- { fill: "#fff3cc", stroke: "#b8860b" },
388
- { fill: "#ffe4d6", stroke: "#d2691e" },
389
- { fill: "#ffe3ec", stroke: "#d6336c" },
390
- { fill: "#dcefff", stroke: "#2a6fbf" },
391
- { fill: "#f1e3fb", stroke: "#8a3fb8" },
392
- ];
393
- const DIAGRAM_PRESETS = {
394
- 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" },
395
- 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" },
396
- 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" },
397
- 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" },
398
- 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" },
399
- };
824
+ const DIAGRAM_PRESETS = TOKENS.diagrams.presets;
400
825
  function diagramSettings(d) {
401
826
  return d || (state.settings && state.settings.diagrams) || {};
402
827
  }
@@ -421,14 +846,14 @@
421
846
  }
422
847
  const MERMAID_CSS = [
423
848
  ".node rect, .node .label-container, .node .basic, .cluster rect, rect.actor { rx: 12px; ry: 12px; }",
424
- ".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}); }`,
425
850
  ".edgePath .path, .flowchart-link, .messageLine0, .messageLine1, .transition, .relation { stroke-width: 2px; }",
426
851
  ".edgeLabel, .edgeLabel p { font-weight: 700; }",
427
852
  ".cluster rect { stroke-dasharray: 4 3; stroke-width: 1.5px; }",
428
853
  ".cluster-label, .cluster-label p { font-weight: 800; }",
429
854
  ].join(" ");
430
855
  function paintDiagram(root, palette) {
431
- const ink = "#1c1b33";
856
+ const ink = TOKENS.current.elements.diagram.nodeInk;
432
857
  const byKey = new Map();
433
858
  const pick = (key) => {
434
859
  if (!byKey.has(key)) byKey.set(key, palette[byKey.size % palette.length]);
@@ -493,11 +918,13 @@
493
918
  const out = block.querySelector(".mm-out");
494
919
  const src = block.dataset.src || "";
495
920
  try {
921
+ const t0 = performance.now();
496
922
  const { svg } = await mermaid.render(`mm-${++mermaidSeq}`, src);
497
923
  out.innerHTML = svg;
498
924
  if (palette) paintDiagram(out, palette);
499
925
  block.classList.add("ok");
500
926
  block.classList.remove("failed");
927
+ noteSlow("diagram drawn", performance.now() - t0);
501
928
  } catch (e) {
502
929
  block.classList.add("failed");
503
930
  out.innerHTML = `<pre>${esc(src)}</pre><div class="hint error">Mermaid: ${esc(String((e && e.message) || e).split("\n")[0])}</div>`;
@@ -566,7 +993,26 @@
566
993
  }
567
994
  function meAvatarData() {
568
995
  const s = state.settings || {};
569
- 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
+ }
570
1016
  }
571
1017
  function toast(text, level) {
572
1018
  const el = document.createElement("div");
@@ -604,6 +1050,17 @@
604
1050
  function vendorLogo(r) {
605
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>`;
606
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
+ }
607
1064
  function sectionTitle(iconName, text) {
608
1065
  return `<h4>${ic(iconName)}${text}</h4>`;
609
1066
  }
@@ -691,9 +1148,9 @@
691
1148
  function roomMark(room, lg) {
692
1149
  const hue = roomHue(room);
693
1150
  const emoji = (room.settings && room.settings.emoji) || "";
694
- 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>`;
695
1152
  const letter = (room.name.trim()[0] || "?").toUpperCase();
696
- 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>`;
697
1154
  }
698
1155
  function roomTitle(room) {
699
1156
  const emoji = (room.settings && room.settings.emoji) || "";
@@ -709,11 +1166,120 @@
709
1166
  }, 2200);
710
1167
  }
711
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
+
712
1199
  function openDialog(dialog) {
713
1200
  if (dialog.open) return;
714
1201
  dialog.classList.remove("closing");
1202
+ restoreDialogSize(dialog);
715
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);
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
+ }
716
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
+ });
717
1283
  function closeDialog(dialog) {
718
1284
  if (!dialog.open) return;
719
1285
  dialog.classList.add("closing");
@@ -821,8 +1387,8 @@
821
1387
  pop.id = "update-pop";
822
1388
  pop.className = "update-pop";
823
1389
  pop.dataset.version = u.latest;
824
- 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>
825
- <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>`;
826
1392
  pop.querySelector(".up-x").addEventListener("click", () => {
827
1393
  remember("updateDismissed", u.latest);
828
1394
  pop.remove();
@@ -836,16 +1402,16 @@
836
1402
  const text = pop.querySelector(".up-text");
837
1403
  pop.dataset.busy = "1";
838
1404
  go.disabled = true;
839
- go.classList.add("loading");
1405
+ UI.setState(go, "loading");
840
1406
  text.innerHTML = `Installing <b>viberoom ${esc(version)}</b>… this takes a moment.`;
841
1407
  try {
842
1408
  await post("/api/update/install", {});
843
- go.classList.remove("loading");
1409
+ UI.setState(go, null);
844
1410
  text.innerHTML = `<b>viberoom ${esc(version)}</b> is installed. Restarting…`;
845
1411
  go.hidden = true;
846
1412
  } catch (e) {
847
1413
  delete pop.dataset.busy;
848
- go.classList.remove("loading");
1414
+ UI.setState(go, null);
849
1415
  go.disabled = false;
850
1416
  go.textContent = "Try again";
851
1417
  text.innerHTML = `<span class="error">${esc(e.message || String(e))}</span>`;
@@ -958,7 +1524,7 @@
958
1524
  const li = document.createElement("li");
959
1525
  li.className = state.skillsRoom === room.id ? "selected" : "";
960
1526
  const selected = state.skillsRoom === room.id;
961
- 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>` : ""}`;
962
1528
  li.addEventListener("click", (e) => {
963
1529
  if (e.target.closest(".goto-room")) return selectRoom(room.id);
964
1530
  state.skillsRoom = room.id;
@@ -1022,7 +1588,7 @@
1022
1588
  <h1>${state.freshVibe ? "Welcome" : "Welcome back"}, ${esc(name)} 👋</h1>
1023
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>
1024
1590
  <div class="hero-actions">
1025
- <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" })}
1026
1592
  </div>
1027
1593
  </div>
1028
1594
  <div class="hero-art" aria-hidden="true">
@@ -1136,25 +1702,24 @@
1136
1702
  const sub = p.kind === "human" ? "you, the human" : [p.tagline ? `"${p.tagline}"` : "", p.agentVendor || p.agentLabel, p.model].filter(Boolean).join(" · ");
1137
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>` : "";
1138
1704
  const status = unstaffed
1139
- ? `<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" })
1140
1706
  : asleep
1141
1707
  ? `<span class="zzz" title="${esc(STATUS_LABEL[p.status] || p.status)}">zzz</span>`
1142
- : p.kind === "agent" && p.status !== "idle" ? `<span class="badge status-${shown}">${p.status === "thinking" ? '<span class="dot"></span>' : ""}${STATUS_LABEL[shown] || shown}</span>` : "";
1708
+ : p.kind === "agent" && p.status !== "idle" ? UI.html("badge", { label: STATUS_LABEL[shown] || shown, tone: STATUS_TONE[shown] || "plain", dot: p.status === "thinking" }) : "";
1143
1709
  const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true, muted: p.muted });
1144
1710
  const statusClass = p.kind === "agent" ? `avatar-status status-${esc(shown || "idle")}` : "";
1145
1711
  const bodyHtml = `<div class="p-body">
1146
- <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>
1147
1713
  <div class="p-sub">${esc(sub)}</div>
1148
1714
  ${warn}
1149
1715
  </div>
1150
1716
  <div class="p-actions">
1151
- ${p.kind === "agent" && p.status === "thinking" ? `<button class="icon-btn sm stop-btn" title="Stop this reply">${ic("stop")}</button>` : ""}
1152
- ${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" }) : ""}
1153
1718
  </div>`;
1154
1719
  if (!li) {
1155
1720
  li = document.createElement("li");
1156
1721
  li.dataset.id = p.id;
1157
- 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" }) : "");
1158
1723
  li.dataset.avatar = avatarHtml;
1159
1724
  if (statusClass) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
1160
1725
  li.dataset.body = bodyHtml;
@@ -1226,14 +1791,14 @@
1226
1791
  els.chatRoomSub.innerHTML =
1227
1792
  `<span>${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}${st.waiting ? ` · ${st.waiting} waiting` : ""}</span>` +
1228
1793
  (room.settings.topic ? `<span>· ${esc(room.settings.topic)}</span>` : "") +
1229
- `<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" });
1230
1795
  }
1231
1796
 
1232
1797
 
1233
1798
  function messageMatches(m) {
1234
1799
  if (!state.search) return true;
1235
1800
  const q = state.search.toLowerCase();
1236
- 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));
1237
1802
  }
1238
1803
 
1239
1804
  function renderHidden(el, m) {
@@ -1273,23 +1838,17 @@
1273
1838
  return el;
1274
1839
  }
1275
1840
  if (m.kind === "system") {
1276
- el.className = "msg system";
1277
- if (m.audience === "human") {
1278
- el.className = "msg system done";
1279
- const who = m.details && m.details.agentId ? findById(room, m.details.agentId) : null;
1280
- 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>`;
1281
- return el;
1282
- }
1283
1841
  if (m.audience === "agents") {
1284
1842
  el.className = "msg hidden";
1285
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>`;
1286
1844
  return el;
1287
1845
  }
1288
- const hush = /^(Hush|Focus):/.test(m.text);
1289
- const warn = !hush && /could not answer|reported an error|Hop limit|was stopped/.test(m.text);
1290
- el.innerHTML = hush
1291
- ? `<div class="focus-pill" title="${esc(fullTime(m.ts))}"><span class="hush-face">🤫</span>${esc(m.text.replace(/^Focus:/, "Hush:"))}</div>`
1292
- : `<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) });
1293
1852
  return el;
1294
1853
  }
1295
1854
  const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: m.from === "human" ? "human" : "agent" };
@@ -1297,7 +1856,7 @@
1297
1856
  el.className = "msg " + (mine ? "mine" : "agent");
1298
1857
  el.innerHTML = `
1299
1858
  <div class="bubble-col">
1300
- <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>
1301
1860
  <div class="bubble">
1302
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>` : ""}
1303
1862
  <div class="edit-box" hidden></div>
@@ -1307,20 +1866,23 @@
1307
1866
  <div class="plan" hidden></div>
1308
1867
  <div class="text"></div>
1309
1868
  <div class="shots"></div>
1310
- <button class="more" hidden></button>
1869
+ ${UI.html("button", { label: "Show more", kind: "link", act: "more", hidden: true })}
1870
+ <div class="stop-note"></div>
1311
1871
  <div class="perms"></div>
1312
1872
  <div class="waiting" hidden></div>
1313
1873
  </div>
1314
1874
  <div class="meta"></div>
1315
1875
  </div>`;
1316
- el.querySelector(".more").addEventListener("click", () => {
1876
+ el.querySelector('[data-act="more"]').addEventListener("click", () => {
1317
1877
  if (state.expanded.has(m.id)) state.expanded.delete(m.id);
1318
1878
  else state.expanded.add(m.id);
1319
1879
  updateMessageElement(el, room, m);
1320
1880
  });
1321
- const editBtn = el.querySelector(".edit-btn");
1881
+ const editBtn = el.querySelector('[data-act="edit"]');
1322
1882
  if (editBtn) editBtn.addEventListener("click", () => openInlineEditor(el, room, m));
1323
- 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 () => {
1324
1886
  if (m.pending) return;
1325
1887
  try {
1326
1888
  await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(m.id)}/pin`, { pinned: !m.pinned });
@@ -1360,11 +1922,111 @@
1360
1922
  document.addEventListener("keydown", (e) => {
1361
1923
  if (e.key === "Escape" && !els.lightbox.hidden) closeLightbox();
1362
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
+ });
1363
2023
  els.messages.addEventListener("click", (e) => {
1364
- const done = e.target.closest(".done-row");
1365
- 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}"]`));
1366
2026
  const shot = e.target.closest(".shot");
1367
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}"]`));
1368
2030
  const ref = e.target.closest(".img-ref");
1369
2031
  if (!ref) return;
1370
2032
  const msg = ref.closest(".msg");
@@ -1388,8 +2050,8 @@
1388
2050
  const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
1389
2051
  const verb = names.length === 1 ? "is" : "are";
1390
2052
  const stopLabel = names.length === 1 ? `Stop ${names[0]} and send now` : "Stop them and send now";
1391
- 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>`;
1392
- 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) => {
1393
2055
  const btn = e.currentTarget;
1394
2056
  btn.disabled = true;
1395
2057
  btn.textContent = "Stopping…";
@@ -1404,8 +2066,33 @@
1404
2066
  }
1405
2067
 
1406
2068
  const openTools = new Set();
2069
+ const openToolGroups = new Set();
1407
2070
  els.messages.addEventListener("click", (e) => {
1408
- 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"]');
1409
2096
  if (!chip) return;
1410
2097
  const msgEl = chip.closest(".msg");
1411
2098
  const room = currentRoom();
@@ -1419,7 +2106,7 @@
1419
2106
  el.classList.toggle("hidden-by-search", !messageMatches(m));
1420
2107
  const wasPinned = el.classList.contains("pinned");
1421
2108
  el.classList.toggle("pinned", !!m.pinned);
1422
- const pinBtn = el.querySelector(".pin-btn");
2109
+ const pinBtn = el.querySelector('[data-act="pin"]');
1423
2110
  if (pinBtn) pinBtn.title = m.pinned ? "Unpin this message" : "Pin this message";
1424
2111
  if (wasPinned !== !!m.pinned) renderTimeline();
1425
2112
  const editedEl = el.querySelector(".edited");
@@ -1438,15 +2125,39 @@
1438
2125
  }
1439
2126
  if (m.kind === "system") return;
1440
2127
  const text = el.querySelector(".text");
1441
- const more = el.querySelector(".more");
2128
+ const more = el.querySelector('[data-act="more"]');
1442
2129
  const long = !m.streaming && m.text.length > CLAMP_CHARS;
1443
2130
  const expanded = state.expanded.has(m.id);
1444
- text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? WORKING_SVG : "");
1445
- if (!m.streaming) renderDiagrams(text);
1446
- 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();
1447
2156
  text.classList.toggle("clamped", long && !expanded);
1448
2157
  more.hidden = !long;
1449
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" }) : "";
1450
2161
  renderShots(el.querySelector(".shots"), room, m);
1451
2162
  renderWaiting(el, room, m);
1452
2163
  const thought = el.querySelector(".thought");
@@ -1454,20 +2165,25 @@
1454
2165
  thought.hidden = false;
1455
2166
  thought.querySelector(".thought-text").textContent = m.thought;
1456
2167
  }
1457
- 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("");
1458
2169
  const tools = el.querySelector(".tools");
1459
2170
  tools.innerHTML = "";
1460
- for (const call of m.toolCalls || []) {
1461
- const open = openTools.has(call.toolCallId);
1462
- const box = document.createElement("div");
1463
- 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) {
1464
2185
  const input = call.rawInput === undefined ? "" : typeof call.rawInput === "string" ? call.rawInput : JSON.stringify(call.rawInput, null, 1);
1465
- box.innerHTML =
1466
- `<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>` +
1467
- (open
1468
- ? `<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>`
1469
- : "");
1470
- 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 }));
1471
2187
  }
1472
2188
  const plan = el.querySelector(".plan");
1473
2189
  if (m.plan && m.plan.length) {
@@ -1477,13 +2193,34 @@
1477
2193
  const meta = el.querySelector(".meta");
1478
2194
  if (!m.streaming && m.from !== "human") {
1479
2195
  const parts = [];
1480
- 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>`);
1481
2197
  if (m.durationMs) parts.push(`<span title="how long the reply took">${ic("clock")} ${(m.durationMs / 1000).toFixed(1)} s</span>`);
1482
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>` : ""}`);
1483
- meta.innerHTML = parts.join("<span class=\"sep\">·</span>");
2199
+ meta.innerHTML = parts.length ? `<span class="stats">${parts.join('<span class="sep">·</span>')}</span>` : "";
1484
2200
  fillSeen(el, room, m);
1485
2201
  } else if (m.from === "human") fillSeen(el, room, m);
1486
- 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
+ }
1487
2224
  }
1488
2225
 
1489
2226
  function lastHumanMessage(room) {
@@ -1580,6 +2317,7 @@
1580
2317
  }
1581
2318
 
1582
2319
  function renderMessages() {
2320
+ const t0 = performance.now();
1583
2321
  const room = currentRoom();
1584
2322
  els.messages.innerHTML = "";
1585
2323
  els.messages.classList.toggle("searching", !!state.search);
@@ -1618,6 +2356,7 @@
1618
2356
  refreshSeen(room);
1619
2357
  scrollToBottom();
1620
2358
  renderTimeline();
2359
+ noteSlow("full render of the list", performance.now() - t0);
1621
2360
  }
1622
2361
 
1623
2362
  function upsertMessage(roomId, m) {
@@ -1671,6 +2410,10 @@
1671
2410
 
1672
2411
  const dirty = new Map();
1673
2412
  let flushScheduled = false;
2413
+ let lastTypedAt = 0;
2414
+ const TYPING_FLUSH_MS = 100;
2415
+ const TYPING_WINDOW_MS = 1500;
2416
+ const watchingTheBottom = () => stuck;
1674
2417
  function patchMessage(roomId, id, fn) {
1675
2418
  const room = state.rooms.get(roomId);
1676
2419
  if (!room) return;
@@ -1681,10 +2424,12 @@
1681
2424
  dirty.set(id, room);
1682
2425
  if (flushScheduled) return;
1683
2426
  flushScheduled = true;
1684
- requestAnimationFrame(flushPatches);
2427
+ if (Date.now() - lastTypedAt < TYPING_WINDOW_MS || !watchingTheBottom()) setTimeout(() => requestAnimationFrame(flushPatches), TYPING_FLUSH_MS);
2428
+ else requestAnimationFrame(flushPatches);
1685
2429
  }
1686
2430
  function flushPatches() {
1687
2431
  flushScheduled = false;
2432
+ const t0 = performance.now();
1688
2433
  const batch = [...dirty];
1689
2434
  dirty.clear();
1690
2435
  let touched = false;
@@ -1698,6 +2443,7 @@
1698
2443
  }
1699
2444
  if (touched && stuck) scrollToBottom();
1700
2445
  if (touched) updateWorkingNow();
2446
+ if (touched) noteSlow("streamed frame", performance.now() - t0);
1701
2447
  }
1702
2448
 
1703
2449
 
@@ -1705,7 +2451,7 @@
1705
2451
  const box = el.querySelector(".edit-box");
1706
2452
  const text = el.querySelector(".text");
1707
2453
  if (!box.hidden) return;
1708
- 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>`;
1709
2455
  const area = box.querySelector(".edit-area");
1710
2456
  area.value = m.text;
1711
2457
  box.hidden = false;
@@ -1784,30 +2530,13 @@
1784
2530
  if (anchor) anchor.insertAdjacentElement("afterend", card);
1785
2531
  else els.messages.appendChild(card);
1786
2532
  }
2533
+ const OPTION_TONE = { allow_once: "ok", allow_always: "ok", reject_once: "no", reject_always: "no" };
1787
2534
  function renderPermission(room, perm) {
1788
2535
  const p = findById(room, perm.participantId);
1789
- const card = document.createElement("div");
1790
- card.className = "perm";
1791
- card.dataset.key = perm.key;
1792
2536
  const tc = perm.toolCall || {};
1793
- card.innerHTML = `
1794
- <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>
1795
- ${tc.rawInput ? `<pre class="perm-input">${esc(JSON.stringify(tc.rawInput, null, 1).slice(0, 1200))}</pre>` : ""}
1796
- <div class="perm-actions"></div>`;
1797
- const actions = card.querySelector(".perm-actions");
1798
- for (const option of perm.options || []) {
1799
- const btn = document.createElement("button");
1800
- btn.className = `perm-btn kind-${option.kind}`;
1801
- btn.textContent = option.name;
1802
- btn.title = option.kind;
1803
- btn.addEventListener("click", () => post(roomApi(`/permissions/${encodeURIComponent(perm.key)}`), { optionId: option.optionId }).catch(showError));
1804
- actions.appendChild(btn);
1805
- }
1806
- const cancel = document.createElement("button");
1807
- cancel.className = "perm-btn";
1808
- cancel.textContent = "Dismiss (cancelled)";
1809
- cancel.addEventListener("click", () => post(roomApi(`/permissions/${encodeURIComponent(perm.key)}`), { optionId: null }).catch(showError));
1810
- 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 } });
1811
2540
  const draft = [...room.messages].reverse().find((m) => m.streaming && m.from === perm.participantId);
1812
2541
  const host = draft ? els.messages.querySelector(`.msg[data-id="${draft.id}"] .perms`) : null;
1813
2542
  if (host) host.appendChild(card);
@@ -1815,10 +2544,10 @@
1815
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" }));
1822
2551
  }
1823
2552
 
1824
2553
  function proposalValue(v) {
@@ -1843,30 +2572,16 @@
1843
2572
  return rows.join("");
1844
2573
  }
1845
2574
  function renderProposal(room, p) {
1846
- const existing = els.messages.querySelector(`.proposal[data-key="${p.key}"]`);
1847
- const card = existing || document.createElement("div");
1848
- card.className = `perm proposal${p.status !== "pending" ? " resolved" : ""}`;
1849
- card.dataset.key = p.key;
1850
- card.innerHTML = `
1851
- <div class="perm-title">${ic("pencil")} ${esc(p.participantName)} proposes changes to the room</div>
1852
- ${p.why ? `<div class="prop-why">${esc(p.why)}</div>` : ""}
1853
- <div class="prop-diff">${proposalDiffHtml(p)}</div>
1854
- ${p.warnings && p.warnings.length ? `<ul class="prop-warn">${p.warnings.map((w) => `<li>${esc(w)}</li>`).join("")}</ul>` : ""}
1855
- ${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>` : ""}
1856
- <div class="perm-actions"></div>`;
1857
- const actions = card.querySelector(".perm-actions");
1858
- if (p.status === "pending") {
1859
- const apply = document.createElement("button");
1860
- apply.className = "perm-btn kind-allow_once";
1861
- apply.textContent = "Apply";
1862
- apply.addEventListener("click", () => post(roomApi(`/proposals/${encodeURIComponent(p.key)}`), { accept: true }).catch(showError));
1863
- const reject = document.createElement("button");
1864
- reject.className = "perm-btn kind-reject_once";
1865
- reject.textContent = "Reject";
1866
- reject.addEventListener("click", () => post(roomApi(`/proposals/${encodeURIComponent(p.key)}`), { accept: false }).catch(showError));
1867
- actions.append(apply, reject);
1868
- } else actions.innerHTML = `<span class="perm-result">${p.status === "applied" ? `applied${p.skipped && p.skipped.length ? ` · not applied: ${esc(p.skipped.join("; "))}` : ""}` : "rejected"}</span>`;
1869
- if (!existing) {
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 {
1870
2585
  placeCard(card, room, p.ts || Date.now());
1871
2586
  if (stuck) scrollToBottom();
1872
2587
  }
@@ -1940,14 +2655,14 @@
1940
2655
  return `${avatar(p, 76, { vendor: true, status: true, muted: p.muted })}
1941
2656
  <h3>${esc(p.name)}</h3>
1942
2657
  <div class="tagline">${esc(p.tagline || "no vibersona")}</div>
1943
- <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>`;
1944
2659
  }
1945
2660
  function refreshDetailsHeader(p) {
1946
2661
  const header = els.detailsInner.querySelector(".profile");
1947
2662
  if (header) header.innerHTML = profileHeader(p);
1948
2663
  }
1949
2664
  function panelTitle(title, sub) {
1950
- 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>`;
1951
2666
  }
1952
2667
  function wireDetailsClose() {
1953
2668
  const b = $("#details-close");
@@ -1965,7 +2680,7 @@
1965
2680
  <div class="action-row">
1966
2681
  <button class="action" data-act="mention"><span class="ico">${ic("at")}</span>Mention</button>
1967
2682
  <button class="action" data-act="${p.muted ? "unmute" : "mute"}"><span class="ico">${ic(p.muted ? "mute" : "unmute")}</span>${p.muted ? "Unmute" : "Mute"}</button>
1968
- ${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>`}
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>`}
1969
2684
  <button class="action danger" data-act="remove"><span class="ico">${ic("trash")}</span>Remove</button>
1970
2685
  </div>
1971
2686
  <div class="section" id="pp-persona">
@@ -1975,6 +2690,7 @@
1975
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)">`)}
1976
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.")}
1977
2692
  </div>
2693
+ ${p.trouble ? `<div class="trouble"><b>${esc(p.trouble.what)}</b><span>${esc(p.trouble.advice)}</span></div>` : ""}
1978
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>` : ""}
1979
2695
  <div class="section" id="pp-engine">
1980
2696
  ${sectionTitle("spark", "Coding agent")}
@@ -1995,7 +2711,7 @@
1995
2711
  <div class="section danger">
1996
2712
  ${sectionTitle("bolt", "Respawn")}
1997
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>
1998
- <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>
1999
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>
2000
2716
  </div>
2001
2717
  <div class="section">
@@ -2029,7 +2745,7 @@
2029
2745
  await post(roomApi(`/participants/${encodeURIComponent(p.id)}/remove`));
2030
2746
  closeDetails();
2031
2747
  }
2032
- } else if (act === "reconnect") openReconnectDialog(room);
2748
+ } else if (act === "reconnect") openReconnectDialog(room, p);
2033
2749
  else await post(roomApi(`/participants/${encodeURIComponent(p.id)}/${act}`));
2034
2750
  } catch (e) {
2035
2751
  showError(e);
@@ -2120,7 +2836,7 @@
2120
2836
  <div class="section danger">
2121
2837
  ${sectionTitle("alert", "Danger zone")}
2122
2838
  <p class="field-note">Erases everything in this viberoom: your vibe, all rooms and their history, vibemate sessions, your skills. Not undoable.</p>
2123
- <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>
2124
2840
  </div>`;
2125
2841
  wireDetailsClose();
2126
2842
  $("#me-avatar-picker").appendChild(
@@ -2145,7 +2861,7 @@
2145
2861
  ${field("Name", `<input type="text" id="rp-name" maxlength="60" value="${esc(room.name)}">`)}
2146
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.")}
2147
2863
  ${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
2148
- ${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.")}
2149
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>
2150
2866
  ${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
2151
2867
  </div>
@@ -2159,7 +2875,7 @@
2159
2875
  <div class="section template">
2160
2876
  ${sectionTitle("rooms", "Turn this room into a template")}
2161
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>
2162
- <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>
2163
2879
  </div>
2164
2880
  ${geek(
2165
2881
  "rp-geek",
@@ -2197,7 +2913,7 @@
2197
2913
  <div class="section danger" style="margin-top:12px">
2198
2914
  ${sectionTitle("alert", "Danger zone")}
2199
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>
2200
- <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>
2201
2917
  </div>`;
2202
2918
  wireDetailsClose();
2203
2919
  rulesToNodes($("#rp-rules"), room.customRulesText != null ? room.customRulesText : rs.customRules || "", room);
@@ -2260,7 +2976,7 @@
2260
2976
  .map((r) => {
2261
2977
  const v = (s.vendorPresets || {})[r.id] || {};
2262
2978
  return `<div class="vendor-card">
2263
- <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>
2264
2980
  <div class="vc-fields">
2265
2981
  ${field("Model", `<input type="text" data-vendor="${r.id}" data-key="model" value="${esc(v.model || "")}" placeholder="${esc(r.defaultModel || "vibemate default")}">`)}
2266
2982
  ${field("Effort", `<input type="text" data-vendor="${r.id}" data-key="effort" value="${esc(v.effort || "")}" placeholder="${esc(r.defaultEffort || "vibemate default")}">`)}
@@ -2271,8 +2987,14 @@
2271
2987
  .join("");
2272
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>`;
2273
2989
  const machine =
2274
- 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("") +
2275
- 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("");
2276
2998
  els.pageInner.innerHTML = `
2277
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>
2278
3000
  <div id="sp-form">
@@ -2289,18 +3011,18 @@
2289
3011
  </div>
2290
3012
  <div class="section" id="sp-appearance">
2291
3013
  ${sectionTitle("eye", "Appearance")}
2292
- ${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.")}
2293
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.")}
2294
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.")}
2295
- <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>
2296
3018
  </div>
2297
3019
  <div class="section" id="sp-editor">
2298
3020
  ${sectionTitle("pencil", "Open files at a line")}
2299
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>
2300
3022
  <div class="chips editor-modes">
2301
- <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>
2302
- <button type="button" class="chip-btn${(s.editor || {}).mode === "default-app" ? " on" : ""}" data-mode="default-app">The default app</button>
2303
- <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" } })}
2304
3026
  </div>
2305
3027
  <input type="hidden" id="sp-editor-mode" value="${esc((s.editor || {}).mode || "auto")}">
2306
3028
  </label>
@@ -2309,11 +3031,11 @@
2309
3031
  <div class="section" id="sp-diagrams">
2310
3032
  ${sectionTitle("wand", "Diagrams")}
2311
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>
2312
- <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>
2313
3035
  <input type="hidden" id="sp-diagram-preset" value="${esc(dg.preset)}">
2314
3036
  </div>
2315
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>
2316
- <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>
2317
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"')}
2318
3040
  </div>
2319
3041
  </div>
@@ -2322,7 +3044,13 @@
2322
3044
  ${sectionTitle("refresh", "Updates")}
2323
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>
2324
3046
  <p class="hint" id="sp-update-status">${updateStatusText()}</p>
2325
- <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" })}
2326
3054
  </div>
2327
3055
  <div class="section">
2328
3056
  ${sectionTitle("spark", "Vibemates on this machine")}
@@ -2349,6 +3077,12 @@
2349
3077
  </div>
2350
3078
  </div>
2351
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>
2352
3086
  <div class="section">
2353
3087
  ${sectionTitle("settings", "Presets per vibemate")}
2354
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>
@@ -2356,16 +3090,16 @@
2356
3090
  </div>
2357
3091
  </div>
2358
3092
  </div>`,
2359
- "room defaults, presets per vibemate, vibemate skills",
3093
+ "room defaults, presets per vibemate, vibemate skills, rendering",
2360
3094
  )}
2361
3095
  </div>`;
2362
3096
  const editorSection = $("#sp-editor");
2363
3097
  const editorCmdRow = $("#sp-editor-cmd").closest(".field");
2364
3098
  const showEditorCmd = () => (editorCmdRow.hidden = $("#sp-editor-mode").value !== "custom");
2365
3099
  showEditorCmd();
2366
- editorSection.querySelectorAll(".editor-modes .chip-btn").forEach((b) =>
3100
+ editorSection.querySelectorAll('.editor-modes [data-ui="choice"]').forEach((b) =>
2367
3101
  b.addEventListener("click", () => {
2368
- 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));
2369
3103
  const input = $("#sp-editor-mode");
2370
3104
  input.value = b.dataset.mode;
2371
3105
  input.dispatchEvent(new Event("change", { bubbles: true }));
@@ -2386,9 +3120,9 @@
2386
3120
  block.querySelector(".mm-out").innerHTML = `<pre>${esc(block.dataset.src)}</pre>`;
2387
3121
  renderDiagrams(diagramSection, previewTheme());
2388
3122
  };
2389
- diagramSection.querySelectorAll(".diagram-presets .chip-btn").forEach((b) =>
3123
+ diagramSection.querySelectorAll('.diagram-presets [data-ui="choice"]').forEach((b) =>
2390
3124
  b.addEventListener("click", () => {
2391
- 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));
2392
3126
  const input = $("#sp-diagram-preset");
2393
3127
  input.value = b.dataset.preset;
2394
3128
  input.dispatchEvent(new Event("change", { bubbles: true }));
@@ -2404,14 +3138,39 @@
2404
3138
  const sample = $("#sp-chat-sample");
2405
3139
  $("#sp-chat-fs").addEventListener("input", () => {
2406
3140
  const px = Number($("#sp-chat-fs").value);
2407
- if (px >= 12 && px <= 24) sample.style.fontSize = `${px / zoomFactor()}px`;
3141
+ if (px >= 12 && px <= 24) sample.style.fontSize = `${px}px`;
2408
3142
  });
2409
3143
  $("#sp-font").addEventListener("change", () => (sample.style.fontFamily = FONTS.text[$("#sp-font").value].stack));
2410
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
+ });
2411
3170
  $("#sp-update-check").addEventListener("click", async () => {
2412
3171
  const b = $("#sp-update-check");
2413
3172
  b.disabled = true;
2414
- b.classList.add("loading");
3173
+ UI.setState(b, "loading");
2415
3174
  try {
2416
3175
  state.update = await get("/api/update?check=1");
2417
3176
  $("#sp-update-status").textContent = updateStatusText();
@@ -2420,7 +3179,7 @@
2420
3179
  showError(e);
2421
3180
  }
2422
3181
  b.disabled = false;
2423
- b.classList.remove("loading");
3182
+ UI.setState(b, null);
2424
3183
  });
2425
3184
  bindSave($("#sp-form"), async () => {
2426
3185
  const vendorPresets = {};
@@ -2461,13 +3220,13 @@
2461
3220
 
2462
3221
  function skillBadges(sk) {
2463
3222
  const out = [];
2464
- if (sk.userInvocable === false) out.push('<span class="badge">vibemate only</span>');
2465
- if (sk.agentInvocable === false) out.push('<span class="badge">human only</span>');
2466
- 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>`);
2467
- if (sk.draft) out.push('<span class="badge status-queued">draft: awaiting your approval</span>');
2468
- else if (sk.reviewed === false) out.push('<span class="badge status-thinking">unreviewed</span>');
2469
- if ((sk.problems || []).length) out.push(`<span class="badge status-error">${esc(sk.problems.join("; "))}</span>`);
2470
- 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("; ") }));
2471
3230
  return out.join(" ");
2472
3231
  }
2473
3232
 
@@ -2485,7 +3244,7 @@
2485
3244
  <div class="hint">${esc(sk.description)}</div>
2486
3245
  ${room ? `<div class="hint sk-holders">${ic("user")}${esc(holdersOf(sk.name).map((p) => p.name).join(", "))}</div>` : ""}
2487
3246
  </div>
2488
- <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>
2489
3248
  </li>`;
2490
3249
  const list = shown.length
2491
3250
  ? `<ul class="skill-list">${shown.map(item).join("")}</ul>`
@@ -2501,14 +3260,14 @@
2501
3260
  <label class="switch"><span class="label">Vibemates may load it themselves</span><input type="checkbox" id="sk-agent" ${ed.agentInvocable === false ? "" : "checked"}></label>
2502
3261
  <p class="error" id="sk-error" hidden></p>
2503
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>` : ""}
2504
- <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>
2505
3264
  </div>`
2506
3265
  : "";
2507
3266
  const about = ed
2508
3267
  ? ""
2509
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>`;
2510
3269
  els.pageInner.innerHTML = `
2511
- <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>
2512
3271
  <div class="${ed ? "page-cols" : ""}"><div>${list}${about}</div>${editor}</div>`;
2513
3272
  const all = $("#sk-all");
2514
3273
  if (all)
@@ -2591,14 +3350,14 @@
2591
3350
  const row = document.createElement("label");
2592
3351
  row.className = "check-row";
2593
3352
  const broken = (s.problems && s.problems.length) || s.draft;
2594
- 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>`;
2595
3354
  container.appendChild(row);
2596
3355
  }
2597
3356
  for (const name of selected || []) {
2598
3357
  if (known.has(name.toLowerCase())) continue;
2599
3358
  const row = document.createElement("label");
2600
3359
  row.className = "check-row";
2601
- 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>`;
2602
3361
  container.appendChild(row);
2603
3362
  }
2604
3363
  if (!container.children.length) container.innerHTML = '<span class="hint">No skills in the library yet (Skills in the menu).</span>';
@@ -2725,11 +3484,7 @@
2725
3484
  return;
2726
3485
  }
2727
3486
  for (const o of options) {
2728
- const b = document.createElement("button");
2729
- b.type = "button";
2730
- b.className = `chip-btn${o.selected ? " on" : ""}${o.value === "" ? " none" : ""}`;
2731
- b.textContent = o.textContent;
2732
- 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 });
2733
3488
  b.addEventListener("click", () => {
2734
3489
  select.value = o.value;
2735
3490
  select.dispatchEvent(new Event("change", { bubbles: true }));
@@ -2796,8 +3551,13 @@
2796
3551
  const bypassOn = (state.settings || {}).bypassPermissionsByDefault !== false;
2797
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).` : "");
2798
3553
  els.invSubmit.disabled = !!recipe.unavailableReason;
2799
- els.invWhere.textContent = recipe.unavailableReason ? `Not installed on this machine. To install: ${recipe.installHint || ""}` : `Found on this machine: ${recipe.installedAt || "bundled"}`;
2800
- 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";
2801
3561
  fillSelect(els.invModel, recipe.modelPresets, preset.model, "vibemate default");
2802
3562
  fillSelect(els.invEffort, recipe.effortPresets, preset.effort, "vibemate default");
2803
3563
  fillSelect(els.invMode, recipe.modePresets, preset.mode, "vibemate default");
@@ -2820,7 +3580,7 @@
2820
3580
  els.invAgents.innerHTML = state.recipes
2821
3581
  .map(
2822
3582
  (r) =>
2823
- `<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>`,
2824
3584
  )
2825
3585
  .join("");
2826
3586
  els.invAgents.querySelectorAll(".agent-tile:not(.off)").forEach((b) =>
@@ -2883,7 +3643,7 @@
2883
3643
  return;
2884
3644
  }
2885
3645
  els.invSubmit.disabled = true;
2886
- els.invSubmit.classList.add("loading");
3646
+ UI.setState(els.invSubmit, "loading");
2887
3647
  els.invError.hidden = true;
2888
3648
  try {
2889
3649
  if (staffing.id) {
@@ -2919,20 +3679,45 @@
2919
3679
  els.invError.hidden = false;
2920
3680
  } finally {
2921
3681
  els.invSubmit.disabled = false;
2922
- els.invSubmit.classList.remove("loading");
3682
+ UI.setState(els.invSubmit, null);
2923
3683
  }
2924
3684
  }
2925
3685
 
2926
3686
 
2927
3687
  const reconnectPrompted = new Set();
2928
- function openReconnectDialog(room) {
2929
- 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);
2930
3713
  if (!offline.length) return;
2931
- reconnectPrompted.add(room.id);
3714
+ if (!only) reconnectPrompted.add(room.id);
2932
3715
  els.rcError.hidden = true;
2933
3716
  els.rcReplay.value = room.settings.replayAfterRestart ?? 10;
2934
- els.rcForm.querySelector('input[name="rc-mode"][value="replay"]').checked = true;
2935
- 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:`;
2936
3721
  els.rcTable.innerHTML = offline
2937
3722
  .map(
2938
3723
  (p) => `<tr data-id="${esc(p.id)}">
@@ -2952,7 +3737,7 @@
2952
3737
  const replay = Number(els.rcReplay.value);
2953
3738
  const rows = [...els.rcTable.querySelectorAll("tr")].map((tr) => ({ id: tr.dataset.id, choice: tr.querySelector(".rc-per").value || globalMode }));
2954
3739
  els.rcSubmit.disabled = true;
2955
- els.rcSubmit.classList.add("loading");
3740
+ UI.setState(els.rcSubmit, "loading");
2956
3741
  els.rcError.hidden = true;
2957
3742
  const failures = [];
2958
3743
  for (const row of rows) {
@@ -2964,7 +3749,7 @@
2964
3749
  }
2965
3750
  }
2966
3751
  els.rcSubmit.disabled = false;
2967
- els.rcSubmit.classList.remove("loading");
3752
+ UI.setState(els.rcSubmit, null);
2968
3753
  if (failures.length) {
2969
3754
  els.rcError.textContent = failures.join(" · ");
2970
3755
  els.rcError.hidden = false;
@@ -3002,10 +3787,10 @@
3002
3787
  tplEls.list.innerHTML = tpl.items
3003
3788
  .map((t) => {
3004
3789
  const on = tpl.current && t.id === tpl.current.id;
3005
- 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("");
3006
3791
  return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
3007
3792
  <span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
3008
- <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>
3009
3794
  <span class="tpl-check">${ic("check")}</span>
3010
3795
  </button>`;
3011
3796
  })
@@ -3069,7 +3854,7 @@
3069
3854
  if (!v.agentType) return "";
3070
3855
  const rec = state.recipes.find((r) => r.id === v.agentType);
3071
3856
  const parts = [rec ? rec.vendor : v.agentType, v.model, v.effort, v.mode].filter(Boolean);
3072
- 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>`;
3073
3858
  }
3074
3859
 
3075
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") };
@@ -3125,8 +3910,8 @@
3125
3910
  ${stpSwitch("Show vendor and model to other vibemates", "stp-vendor", !!st.showVendorInRoster)}
3126
3911
  </div>`;
3127
3912
  const agentOptions = [["", "none yet: cast when the room opens"], ...state.recipes.map((r) => [r.id, r.vendor + (r.unavailableReason ? " (not installed here)" : "")])];
3128
- 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>
3129
- <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>
3130
3915
  <div class="stp-vm-fields">
3131
3916
  ${stpField("Vibename", `<input type="text" data-k="name" maxlength="40" value="${esc(v.name)}" required>`)}
3132
3917
  ${stpField("Vibersona", `<input type="text" data-k="tagline" maxlength="80" value="${esc(v.tagline || "")}">`)}
@@ -3143,7 +3928,7 @@
3143
3928
  return `
3144
3929
  <div class="stp-section"><h5>Room</h5>${room}</div>
3145
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>
3146
- <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>
3147
3932
  <div class="stp-section"><h5>Vibemates · <span id="stp-vm-count">${(t.vibemates || []).length}</span></h5>${vms || '<span class="hint">none</span>'}</div>`;
3148
3933
  }
3149
3934
  function readTemplateForm() {
@@ -3263,7 +4048,7 @@
3263
4048
  els.eraseForm.addEventListener("submit", async (event) => {
3264
4049
  event.preventDefault();
3265
4050
  try {
3266
- els.eraseSubmit.classList.add("loading");
4051
+ UI.setState(els.eraseSubmit, "loading");
3267
4052
  await post("/api/profile/erase", { confirm: els.eraseWord.value.trim().toLowerCase() });
3268
4053
  try {
3269
4054
  localStorage.clear();
@@ -3271,7 +4056,7 @@
3271
4056
  }
3272
4057
  location.href = "/";
3273
4058
  } catch (error) {
3274
- els.eraseSubmit.classList.remove("loading");
4059
+ UI.setState(els.eraseSubmit, null);
3275
4060
  els.eraseError.textContent = error.message;
3276
4061
  els.eraseError.hidden = false;
3277
4062
  }
@@ -3299,13 +4084,20 @@
3299
4084
  function applyAppearance() {
3300
4085
  const a = (state.settings || {}).appearance || {};
3301
4086
  const root = document.documentElement;
3302
- root.style.zoom = String((a.chatFontSize || 14.5) / 14.5);
4087
+ root.style.setProperty("--fs-scale", String((a.chatFontSize || 14.5) / 14.5));
3303
4088
  root.style.setProperty("--font", (FONTS.text[a.font] || FONTS.text.nunito).stack);
3304
4089
  root.style.setProperty("--mono", (FONTS.mono[a.mono] || FONTS.mono["jetbrains-mono"]).stack);
3305
4090
  }
3306
- const zoomFactor = () => Number(document.documentElement.style.zoom) || 1;
3307
4091
 
4092
+ let hubIdentity = null;
3308
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;
3309
4101
  state.settings = snapshot.settings;
3310
4102
  applyAppearance();
3311
4103
  state.update = snapshot.update || null;
@@ -3450,34 +4242,23 @@
3450
4242
  }
3451
4243
  }
3452
4244
 
3453
- let stream = null;
3454
- let releaseTimer = null;
3455
- function connect() {
3456
- if (stream) return;
3457
- const es = new EventSource("/events");
3458
- stream = es;
3459
- es.onopen = () => els.conn.classList.add("ok");
3460
- es.onerror = () => els.conn.classList.remove("ok");
3461
- es.addEventListener("snapshot", (e) => loadSnapshot(JSON.parse(e.data).snapshot));
3462
- es.addEventListener("room.event", (e) => {
3463
- const { roomId, event } = JSON.parse(e.data);
3464
- onRoomEvent(roomId, event);
3465
- });
3466
- es.addEventListener("room.created", (e) => {
3467
- const { room } = JSON.parse(e.data);
3468
- 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);
3469
4250
  if ((state.view === "rooms" || state.view === "home")) {
3470
4251
  renderSideRooms();
3471
4252
  renderRoomsGrid();
3472
4253
  }
3473
4254
  renderRail();
3474
- });
3475
- es.addEventListener("rooms.opened", (e) => {
3476
- state.openRooms = JSON.parse(e.data).roomIds || [];
4255
+ },
4256
+ "rooms.opened": (m) => {
4257
+ state.openRooms = m.roomIds || [];
3477
4258
  renderRail();
3478
- });
3479
- es.addEventListener("room.removed", (e) => {
3480
- const { roomId } = JSON.parse(e.data);
4259
+ },
4260
+ "room.removed": (m) => {
4261
+ const { roomId } = m;
3481
4262
  state.rooms.delete(roomId);
3482
4263
  state.openRooms = state.openRooms.filter((id) => id !== roomId);
3483
4264
  if (state.currentRoomId === roomId) {
@@ -3490,35 +4271,68 @@
3490
4271
  renderRoomsGrid();
3491
4272
  }
3492
4273
  renderRail();
3493
- });
3494
- es.addEventListener("skills", (e) => {
3495
- state.skills = JSON.parse(e.data).skills || [];
4274
+ },
4275
+ skills: (m) => {
4276
+ state.skills = m.skills || [];
3496
4277
  if (state.view === "skills" && !editingInDetails()) renderSkillsPage();
3497
4278
  if (state.detailsOpen && !editingInDetails()) renderDetails();
3498
- });
3499
- es.addEventListener("settings", (e) => {
3500
- state.settings = JSON.parse(e.data).settings;
4279
+ },
4280
+ settings: (m) => {
4281
+ state.settings = m.settings;
3501
4282
  applyAppearance();
3502
4283
  rerenderDiagrams();
3503
4284
  renderRail();
3504
4285
  if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
3505
4286
  if (state.detailsOpen && state.selection.kind === "me" && !editingInDetails()) renderDetails();
3506
4287
  if (state.view === "room") renderSideRoom();
3507
- });
3508
- es.addEventListener("update", (e) => {
3509
- state.update = JSON.parse(e.data).update;
4288
+ },
4289
+ update: (m) => {
4290
+ state.update = m.update;
3510
4291
  renderUpdatePop();
3511
4292
  if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
3512
- });
3513
- 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
+ };
3514
4322
  }
3515
4323
  function releaseStream() {
3516
4324
  if (!stream) return;
3517
- stream.close();
4325
+ const ws = stream;
3518
4326
  stream = null;
4327
+ ws.close();
3519
4328
  els.conn.classList.remove("ok");
3520
4329
  els.conn.title = "Paused while this tab is in the background; resumes when you come back";
3521
4330
  }
4331
+ function resyncStream() {
4332
+ releaseStream();
4333
+ els.conn.title = "Connection to the hub";
4334
+ connect();
4335
+ }
3522
4336
  document.addEventListener("visibilitychange", () => {
3523
4337
  clearTimeout(releaseTimer);
3524
4338
  if (document.hidden) releaseTimer = setTimeout(releaseStream, 15000);
@@ -3563,14 +4377,14 @@
3563
4377
  let drag = null;
3564
4378
  grip.addEventListener("pointerdown", (e) => {
3565
4379
  if (e.button !== 0) return;
3566
- drag = { y: e.clientY / zoomFactor(), h: els.input.offsetHeight };
4380
+ drag = { y: e.clientY, h: els.input.offsetHeight };
3567
4381
  grip.setPointerCapture(e.pointerId);
3568
4382
  els.composer.classList.add("resizing");
3569
4383
  e.preventDefault();
3570
4384
  });
3571
4385
  grip.addEventListener("pointermove", (e) => {
3572
4386
  if (!drag) return;
3573
- 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)));
3574
4388
  autosize();
3575
4389
  });
3576
4390
  const stop = () => {
@@ -3596,11 +4410,12 @@
3596
4410
 
3597
4411
  const composerClear = $("#composer-clear");
3598
4412
  function updateComposerClear() {
3599
- composerClear.hidden = !(els.input.value.trim() || pendingShots.length);
4413
+ composerClear.hidden = !(els.input.value.trim() || pendingShots.length || pendingQuotes.length);
3600
4414
  }
3601
4415
  composerClear.addEventListener("click", () => {
3602
4416
  els.input.value = "";
3603
4417
  clearShots();
4418
+ clearQuotes();
3604
4419
  autosize();
3605
4420
  updateComposerClear();
3606
4421
  els.input.focus();
@@ -3620,7 +4435,7 @@
3620
4435
  renderShotsTray();
3621
4436
  }
3622
4437
 
3623
- function insertShotMarker(n) {
4438
+ function insertMarker(text) {
3624
4439
  const el = els.input;
3625
4440
  const value = el.value;
3626
4441
  const start = el.selectionStart ?? value.length;
@@ -3629,16 +4444,15 @@
3629
4444
  const after = value.slice(end);
3630
4445
  const lead = before && !/\s$/.test(before) ? " " : "";
3631
4446
  const tail = after && !/^\s/.test(after) ? " " : "";
3632
- const marker = `${lead}${shotMarker(n)}${tail}`;
4447
+ const marker = `${lead}${text}${tail}`;
3633
4448
  el.value = before + marker + after;
3634
4449
  const caret = before.length + marker.length;
3635
4450
  el.setSelectionRange(caret, caret);
3636
4451
  autosize();
3637
4452
  }
3638
4453
 
3639
- function removeShotMarker(n) {
4454
+ function removeMarker(pattern) {
3640
4455
  const el = els.input;
3641
- const pattern = new RegExp(` ?\\[img ${n}\\]`, "gi");
3642
4456
  const caret = el.selectionStart ?? el.value.length;
3643
4457
  const removedBefore = (el.value.slice(0, caret).match(pattern) || []).join("").length;
3644
4458
  el.value = el.value.replace(pattern, "");
@@ -3663,7 +4477,7 @@
3663
4477
  const n = ++shotSeq;
3664
4478
  pendingShots.push({ n, name, mimeType: file.type, data: String(reader.result) });
3665
4479
  renderShotsTray();
3666
- insertShotMarker(n);
4480
+ insertMarker(shotMarker(n));
3667
4481
  };
3668
4482
  reader.onerror = () => showError(new Error(`could not read ${name || "the image"}`));
3669
4483
  reader.readAsDataURL(file);
@@ -3679,14 +4493,146 @@
3679
4493
  const drop = e.target.closest(".shot-drop");
3680
4494
  if (!drop) return;
3681
4495
  const [shot] = pendingShots.splice(Number(drop.dataset.i), 1);
3682
- if (shot) removeShotMarker(shot.n);
4496
+ if (shot) removeMarker(new RegExp(` ?\\[img ${shot.n}\\]`, "gi"));
3683
4497
  renderShotsTray();
3684
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
+
3685
4625
  els.input.addEventListener("paste", (e) => {
3686
4626
  const files = imageFilesFrom(e.clipboardData);
3687
- 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;
3688
4634
  e.preventDefault();
3689
- addShotFiles(files);
4635
+ for (const q of quotes) addQuote(q.m, q.text);
3690
4636
  });
3691
4637
  for (const target of [els.composer, els.messages]) {
3692
4638
  target.addEventListener("dragover", (e) => {
@@ -3706,6 +4652,7 @@
3706
4652
  }
3707
4653
  let typingSentAt = 0;
3708
4654
  els.input.addEventListener("input", () => {
4655
+ lastTypedAt = Date.now();
3709
4656
  if (!currentRoom() || !els.input.value.trim()) return;
3710
4657
  const now = Date.now();
3711
4658
  if (now - typingSentAt < 2000) return;
@@ -3724,9 +4671,19 @@
3724
4671
  else closeLifePop();
3725
4672
  return;
3726
4673
  }
3727
- if (e.target.closest(".p-gear")) return openDetails({ kind: "participant", id: p.id });
3728
- if (e.target.closest(".stop-btn")) return void post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`)).catch(showError);
3729
- 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
+ }
3730
4687
  if (e.target.closest("button")) return;
3731
4688
  if (p.kind === "human") openDetails({ kind: "me" });
3732
4689
  else if (p.status === "unstaffed") openStaffDialog(p);
@@ -3758,18 +4715,21 @@
3758
4715
  event.preventDefault();
3759
4716
  const text = els.input.value.trim();
3760
4717
  const room = currentRoom();
3761
- if ((!text && !pendingShots.length) || !room) return;
4718
+ if ((!text && !pendingShots.length && !pendingQuotes.length) || !room) return;
3762
4719
  const shots = pendingShots;
4720
+ const quotes = pendingQuotes;
3763
4721
  els.input.value = "";
3764
4722
  clearShots();
4723
+ clearQuotes();
3765
4724
  typingSentAt = 0;
3766
4725
  autosize();
3767
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 };
3768
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 }));
3769
4729
  const localId = local.id;
3770
4730
  upsertMessage(room.id, local);
3771
4731
  try {
3772
- const r = await post(roomApi("/send"), { text, images: shots });
4732
+ const r = await post(roomApi("/send"), { text, images: shots, quotes: quotes.map((q) => ({ n: q.n, seq: q.seq, text: q.text })) });
3773
4733
  if (r.command) removeMessage(room.id, localId);
3774
4734
  else adoptLocalMessage(room.id, localId, r.id);
3775
4735
  } catch (error) {
@@ -3777,7 +4737,9 @@
3777
4737
  showError(error);
3778
4738
  els.input.value = text;
3779
4739
  pendingShots = shots;
4740
+ pendingQuotes = quotes;
3780
4741
  renderShotsTray();
4742
+ renderQuotesTray();
3781
4743
  }
3782
4744
  });
3783
4745
  function adoptLocalMessage(roomId, localId, realId) {
@@ -4089,10 +5051,18 @@
4089
5051
  }
4090
5052
  els.sideToggle.addEventListener("click", () => setSideOpen(els.app.classList.contains("side-collapsed")));
4091
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
+ }
4092
5058
  els.messages.addEventListener("scroll", () => {
4093
5059
  const top = els.messages.scrollTop;
4094
- if (nearBottom()) stuck = true;
4095
- 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;
4096
5066
  lastScrollTop = top;
4097
5067
  els.jumpLatest.hidden = stuck;
4098
5068
  updateTimelineView();
@@ -4134,7 +5104,9 @@
4134
5104
  document.querySelectorAll("[data-close]").forEach((b) => b.addEventListener("click", () => closeDialog(b.closest("dialog"))));
4135
5105
  els.fvOpen.addEventListener("click", async () => {
4136
5106
  try {
4137
- 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}` });
4138
5110
  toast(r.message, "info");
4139
5111
  } catch (err) {
4140
5112
  showError(err);
@@ -4194,10 +5166,20 @@
4194
5166
  e.preventDefault();
4195
5167
  try {
4196
5168
  const target = link.dataset.open;
4197
- if (!/^(https?:|mailto:)/i.test(target) && VIEWABLE_RE.test(target)) {
4198
- await viewFile(target);
5169
+ if (IMAGE_RE.test(target) && !/^(https?:|mailto:)/i.test(target) && /[\\/]/.test(target)) {
5170
+ openLightbox(imageUrl(target), target.split(/[\\/]/).pop());
4199
5171
  return;
4200
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
+ }
4201
5183
  const r = await post("/api/open", { target });
4202
5184
  if (r.action !== "open-url") toast(r.message, "info");
4203
5185
  } catch (err) {
@@ -4210,7 +5192,10 @@
4210
5192
  const code = src.closest(".mermaid-block, .csv-block").querySelector(".mm-code");
4211
5193
  code.hidden = !code.hidden;
4212
5194
  src.textContent = code.hidden ? "source" : "hide source";
5195
+ return;
4213
5196
  }
5197
+ const expand = e.target.closest && e.target.closest(".mm-expand");
5198
+ if (expand) openDiagram(expand.closest(".mermaid-block"));
4214
5199
  });
4215
5200
  els.roomSettingsBtn.addEventListener("click", () => openDetails({ kind: "room" }));
4216
5201
  els.chatInfoBtn.addEventListener("click", () => openDetails({ kind: "room" }));
@@ -4266,13 +5251,14 @@
4266
5251
  const pinned = el.classList.contains("pinned");
4267
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>`;
4268
5253
  }
4269
- function showPop(i) {
5254
+ function showPop(i, keepPlace) {
4270
5255
  const rows = [[i - 2, "faded far"], [i - 1, "faded"], [i, "current"], [i + 1, "faded"], [i + 2, "faded far"]].filter(([k]) => t.items[k]);
4271
5256
  t.pop.innerHTML = rows.map(([k, c]) => rowHtml(k, c)).join("");
4272
5257
  t.pop.hidden = false;
4273
5258
  t.ticks.querySelectorAll(".tl-tick.active").forEach((x) => x.classList.remove("active"));
4274
5259
  const tick = t.ticks.children[i];
4275
5260
  if (tick) tick.classList.add("active");
5261
+ if (keepPlace) return;
4276
5262
  const current = t.pop.querySelector(".tl-row.current");
4277
5263
  let top = (tick ? tick.offsetTop : 0) - (current ? current.offsetTop + current.offsetHeight / 2 : 20) + TICK_H / 2;
4278
5264
  top = Math.max(0, Math.min(top, t.el.clientHeight - t.pop.offsetHeight));
@@ -4300,6 +5286,24 @@
4300
5286
  const row = e.target.closest(".tl-row");
4301
5287
  if (row) jumpToMessage(t.items[Number(row.dataset.i)]);
4302
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
+ );
4303
5307
  return { render, updateView };
4304
5308
  }
4305
5309
  function timelineText(el) {
@@ -4342,7 +5346,7 @@
4342
5346
  if (room.id !== state.currentRoomId || state.view !== "room") return;
4343
5347
  requestAnimationFrame(() => {
4344
5348
  if (bubbleInView(m.id)) return;
4345
- 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" };
4346
5350
  pushNote({ id: m.id, kind: "done", p, text: `${p.name} finished`, sub: `started ${time(m.ts)}${m.durationMs ? ` · ${spanText(m.durationMs)}` : ""}` });
4347
5351
  });
4348
5352
  }
@@ -4350,7 +5354,7 @@
4350
5354
  if (room.id !== state.currentRoomId || state.view !== "room") return;
4351
5355
  requestAnimationFrame(() => {
4352
5356
  if (bubbleInView(m.id)) return;
4353
- 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" };
4354
5358
  pushNote({ id: m.id, kind: "new", p, text: `${p.name} wrote below`, sub: time(m.ts) });
4355
5359
  });
4356
5360
  }
@@ -4387,16 +5391,20 @@
4387
5391
  const timelines = [
4388
5392
  createTimeline($("#timeline"), () => [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")], {}),
4389
5393
  createTimeline($("#timeline-left"), () => [...els.messages.querySelectorAll(".msg.agent:not(.hidden-by-search)")], {
4390
- colorOf: (room, el) => (authorOf(room, el) || {}).color || "#9ca3af",
5394
+ colorOf: (room, el) => (authorOf(room, el) || {}).color || FALLBACK_COLOR,
4391
5395
  avatarOf: (room, el) => { const p = authorOf(room, el); return p ? avatar(p, 16, {}) : ""; },
4392
5396
  }),
4393
5397
  ];
4394
5398
  function renderTimeline() {
5399
+ const t0 = performance.now();
4395
5400
  for (const t of timelines) t.render();
4396
5401
  renderPins();
5402
+ noteSlow("timeline strips", performance.now() - t0);
4397
5403
  }
4398
5404
  function updateTimelineView() { for (const t of timelines) t.updateView(); }
4399
- 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];
4400
5408
  new ResizeObserver(() => {
4401
5409
  const h = `${els.composer.offsetHeight}px`;
4402
5410
  for (const el of composerFollowers) el.style.setProperty("--composer-h", h);
@@ -4540,15 +5548,15 @@
4540
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>
4541
5549
  <span>Cost (estimate)</span><span>${fmtCost(p.cost) || "—"}</span>
4542
5550
  <span>Briefs sent</span><span>${p.briefsSent ?? 0}</span>
4543
- <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>
4544
5552
  </div>
4545
- ${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>` : ""}
4546
5554
  <div class="lp-respawn">
4547
- <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>
4548
- <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>
4549
5557
  </div>
4550
5558
  </div>
4551
- <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>`;
4552
5560
  el.querySelector(".lp-x").addEventListener("click", closeLifePop);
4553
5561
  el.querySelector(".lp-more").addEventListener("click", () => {
4554
5562
  closeLifePop();
@@ -4589,10 +5597,9 @@
4589
5597
  if (save) save.addEventListener("click", () => saveNotes(el.querySelector(".lp-notes-area").value));
4590
5598
  const clear = el.querySelector(".lp-notes-clear");
4591
5599
  if (clear) clear.addEventListener("click", () => saveNotes(""));
4592
- const z = zoomFactor();
4593
5600
  const r = lifePop.anchor.getBoundingClientRect();
4594
- el.style.left = `${Math.round((r.right + 12) / z)}px`;
4595
- 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`;
4596
5603
  }
4597
5604
  async function respawnWith(p, n) {
4598
5605
  const text =
@@ -4749,11 +5756,7 @@
4749
5756
  for (const room of state.rooms.values()) if (room.dir && !dirs.some((d) => sameFolder(d, room.dir))) dirs.push(room.dir);
4750
5757
  fpEls.recent.innerHTML = "";
4751
5758
  for (const d of dirs.slice(0, 6)) {
4752
- const b = document.createElement("button");
4753
- b.type = "button";
4754
- b.className = "chip-btn";
4755
- b.title = d;
4756
- 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 });
4757
5760
  b.addEventListener("click", () => fpGoTo(d));
4758
5761
  fpEls.recent.appendChild(b);
4759
5762
  }
@@ -4874,5 +5877,7 @@
4874
5877
  window.addEventListener("pagehide", () => report(true));
4875
5878
  }
4876
5879
 
5880
+ setInterval(tickLive, 1000);
5881
+
4877
5882
  connect();
4878
5883
  })();