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/README.md +2 -0
- package/dist/agent-health.js +52 -0
- package/dist/duration.js +12 -0
- package/dist/hub.js +9 -0
- package/dist/launcher.js +19 -2
- package/dist/main.js +78 -35
- package/dist/mcp-skills-server.js +24 -0
- package/dist/persona.js +69 -14
- package/dist/quotes.js +41 -0
- package/dist/recipes.js +30 -2
- package/dist/room.js +136 -33
- package/dist/rows.js +19 -0
- package/dist/server.js +251 -27
- package/dist/shortcuts.js +1 -1
- package/dist/update.js +32 -2
- package/dist/viewer.js +69 -2
- package/dist/ws.js +175 -0
- package/package.json +2 -1
- package/ui/app.css +341 -251
- package/ui/app.js +1283 -278
- package/ui/avatars.js +6 -4
- package/ui/components.css +140 -0
- package/ui/components.js +342 -0
- package/ui/icons.js +9 -0
- package/ui/index.html +58 -36
- package/ui/theme.css +450 -183
- package/ui/tokens.js +620 -0
- package/ui/ui.js +113 -0
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
|
|
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…
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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="
|
|
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="
|
|
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
|
|
825
|
-
<div class="up-side"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
?
|
|
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" ?
|
|
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 ?
|
|
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 === "
|
|
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" ?
|
|
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
|
-
|
|
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
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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 ?
|
|
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
|
-
|
|
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("
|
|
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("
|
|
1881
|
+
const editBtn = el.querySelector('[data-act="edit"]');
|
|
1322
1882
|
if (editBtn) editBtn.addEventListener("click", () => openInlineEditor(el, room, m));
|
|
1323
|
-
el.querySelector(
|
|
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
|
|
1365
|
-
if (
|
|
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
|
|
1392
|
-
box.querySelector("
|
|
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
|
|
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("
|
|
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("
|
|
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
|
-
|
|
1445
|
-
if (!
|
|
1446
|
-
|
|
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) =>
|
|
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
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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"
|
|
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
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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(
|
|
2547
|
+
const card = document.querySelector(`[data-ui="ask-card"][data-kind="permission"][data-key="${CSS.escape(key)}"]`);
|
|
1819
2548
|
if (!card) return;
|
|
1820
|
-
|
|
1821
|
-
card.querySelector(".
|
|
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(
|
|
1847
|
-
const
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
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"
|
|
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
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
|
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
|
|
2275
|
-
|
|
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.
|
|
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:${(
|
|
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
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
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]) =>
|
|
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 ||
|
|
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
|
-
|
|
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(
|
|
3100
|
+
editorSection.querySelectorAll('.editor-modes [data-ui="choice"]').forEach((b) =>
|
|
2367
3101
|
b.addEventListener("click", () => {
|
|
2368
|
-
editorSection.querySelectorAll(
|
|
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(
|
|
3123
|
+
diagramSection.querySelectorAll('.diagram-presets [data-ui="choice"]').forEach((b) =>
|
|
2390
3124
|
b.addEventListener("click", () => {
|
|
2391
|
-
diagramSection.querySelectorAll(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2465
|
-
if (sk.agentInvocable === false) out.push(
|
|
2466
|
-
if (sk.author && sk.author !== "human") out.push(
|
|
2467
|
-
if (sk.draft) out.push(
|
|
2468
|
-
else if (sk.reviewed === false) out.push(
|
|
2469
|
-
if ((sk.problems || []).length) out.push(
|
|
2470
|
-
if ((sk.warnings || []).length) out.push(
|
|
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 ?
|
|
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" ?
|
|
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/<name>/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 ?
|
|
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 ?
|
|
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>
|
|
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 =
|
|
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
|
-
|
|
2800
|
-
els.invWhere.
|
|
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
|
|
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
|
|
3682
|
+
UI.setState(els.invSubmit, null);
|
|
2923
3683
|
}
|
|
2924
3684
|
}
|
|
2925
3685
|
|
|
2926
3686
|
|
|
2927
3687
|
const reconnectPrompted = new Set();
|
|
2928
|
-
function
|
|
2929
|
-
const
|
|
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(
|
|
2935
|
-
els.rcIntro.textContent =
|
|
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
|
|
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
|
|
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:
|
|
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 ? "" :
|
|
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) =>
|
|
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:
|
|
3129
|
-
<div class="stp-vm-top"><b>${esc(v.name)}</b
|
|
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"
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
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
|
-
|
|
3476
|
-
state.openRooms =
|
|
4255
|
+
},
|
|
4256
|
+
"rooms.opened": (m) => {
|
|
4257
|
+
state.openRooms = m.roomIds || [];
|
|
3477
4258
|
renderRail();
|
|
3478
|
-
}
|
|
3479
|
-
|
|
3480
|
-
const { roomId } =
|
|
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
|
-
|
|
3495
|
-
state.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
|
-
|
|
3500
|
-
state.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
|
-
|
|
3509
|
-
state.update =
|
|
4288
|
+
},
|
|
4289
|
+
update: (m) => {
|
|
4290
|
+
state.update = m.update;
|
|
3510
4291
|
renderUpdatePop();
|
|
3511
4292
|
if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
|
|
3512
|
-
}
|
|
3513
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}${
|
|
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
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
3728
|
-
if (
|
|
3729
|
-
|
|
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
|
-
|
|
4095
|
-
|
|
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
|
|
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) &&
|
|
4198
|
-
|
|
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:
|
|
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:
|
|
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 ||
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
4548
|
-
<label class="lp-with"
|
|
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"
|
|
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(
|
|
4595
|
-
el.style.top = `${Math.round(Math.max(8, Math.min(r.top
|
|
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 =
|
|
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
|
})();
|