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