@kevin5251984/guild 0.2.17 → 0.2.19

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.
@@ -0,0 +1,784 @@
1
+ <!doctype html>
2
+ <html lang="zh-Hant">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#0B0E12" />
7
+ <link rel="icon" href="/favicon.ico" sizes="32x32" />
8
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
9
+ <link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
10
+ <link rel="icon" href="/favicon-16.png" type="image/png" sizes="16x16" />
11
+ <title>Guild — 外出</title>
12
+ <link rel="stylesheet" href="/mobile.css?v=away" />
13
+ <script src="/i18n.js"></script>
14
+ <script src="/md.js"></script>
15
+ </head>
16
+ <body class="m-away" data-i18n-page="mobile">
17
+ <section class="screen" id="screen-list">
18
+ <header class="list-head">
19
+ <h1>Guild</h1>
20
+ </header>
21
+ <p class="hint" data-i18n="mobile.hint">
22
+ 外出時用 Tailscale 連到家裡這台 guildd(埠 7420)。
23
+ </p>
24
+ <div class="section-h" data-i18n="channels">委託</div>
25
+ <ul class="navlist" id="channels"></ul>
26
+ <div class="section-h" data-i18n="dms">密談</div>
27
+ <ul class="navlist" id="dms"></ul>
28
+ </section>
29
+ <section class="screen" id="screen-room" hidden>
30
+ <header class="room-head">
31
+ <button type="button" class="back" id="back" data-i18n-title="mobile.back" data-i18n-aria="mobile.back" title="返回委託" aria-label="返回委託">‹</button>
32
+ <h1 id="title"></h1>
33
+ <div class="meta" id="subtitle"></div>
34
+ </header>
35
+ <div class="thread" id="thread"></div>
36
+ <form class="composer" id="composer">
37
+ <div class="composer-card">
38
+ <div class="mention-pop" id="mention-pop" hidden>
39
+ <div id="mention-results"></div>
40
+ </div>
41
+ <textarea id="draft" rows="1" placeholder="Ask anything" data-i18n-placeholder="askAnything"></textarea>
42
+ <div class="composer-actions">
43
+ <button type="submit" class="send" id="send" data-i18n="send">送出</button>
44
+ <button type="button" class="stop-all" id="stop" hidden data-i18n="live.stop">停止</button>
45
+ </div>
46
+ </div>
47
+ </form>
48
+ </section>
49
+ <div class="flash" id="flash" hidden></div>
50
+ <script>
51
+ (function () {
52
+ const COLORS = ["#7c6af7", "#5b8def", "#2ea887", "#e07a3d", "#d4537e"];
53
+ const state = {
54
+ channels: [],
55
+ bots: [],
56
+ serverLive: [],
57
+ messages: [],
58
+ lives: [],
59
+ kind: "",
60
+ id: "",
61
+ posting: false,
62
+ mentionIndex: 0,
63
+ pollTimer: 0,
64
+ };
65
+
66
+ function hue(key) {
67
+ let n = 0;
68
+ for (const ch of String(key)) n = (n + ch.charCodeAt(0) * 17) % COLORS.length;
69
+ return COLORS[n];
70
+ }
71
+ function escapeHtml(value) {
72
+ return String(value)
73
+ .replace(/&/g, "&amp;")
74
+ .replace(/</g, "&lt;")
75
+ .replace(/>/g, "&gt;");
76
+ }
77
+ function initials(name) {
78
+ const parts = String(name)
79
+ .replace(/[^\p{L}\p{N} ]/gu, " ")
80
+ .trim()
81
+ .split(/\s+/);
82
+ if (parts[0] && /[A-Za-z]/.test(parts[0][0])) {
83
+ return parts[0][0].toUpperCase();
84
+ }
85
+ return Array.from(String(name).replace(/\s+/g, ""))[0] || "?";
86
+ }
87
+ function portraitUrl(bot) {
88
+ const url = bot && typeof bot.portrait === "string" ? bot.portrait.trim() : "";
89
+ return url.indexOf("/generated/") === 0 ? url : "";
90
+ }
91
+ function avatarFace(bot) {
92
+ const src = portraitUrl(bot);
93
+ if (src) return '<img alt="" src="' + escapeHtml(src) + '">';
94
+ return escapeHtml(initials((bot && (bot.name || bot.handle)) || "?"));
95
+ }
96
+ function botById(id) {
97
+ return (state.bots || []).find((bot) => bot.id === id);
98
+ }
99
+ function currentChannel() {
100
+ return (state.channels || []).find((ch) => ch.id === state.id);
101
+ }
102
+ function currentRoomRef() {
103
+ return state.kind ? { kind: state.kind, id: state.id } : null;
104
+ }
105
+ function roomMessagesUrl() {
106
+ return state.kind === "dm"
107
+ ? "/dms/" + encodeURIComponent(state.id) + "/messages"
108
+ : "/channels/" + encodeURIComponent(state.id) + "/messages";
109
+ }
110
+ function liveUrl() {
111
+ const r = currentRoomRef();
112
+ if (!r) return "";
113
+ return r.kind === "dm"
114
+ ? "/dms/" + encodeURIComponent(r.id) + "/live"
115
+ : "/channels/" + encodeURIComponent(r.id) + "/live";
116
+ }
117
+ function abortUrl() {
118
+ const r = currentRoomRef();
119
+ if (!r) return "";
120
+ return r.kind === "dm"
121
+ ? "/dms/" + encodeURIComponent(r.id) + "/abort"
122
+ : "/channels/" + encodeURIComponent(r.id) + "/abort";
123
+ }
124
+ function parseHash() {
125
+ const raw = location.hash.replace(/^#/, "");
126
+ if (raw.startsWith("d/")) {
127
+ state.kind = "dm";
128
+ state.id = decodeURIComponent(raw.slice(2));
129
+ return;
130
+ }
131
+ if (raw.startsWith("c/")) {
132
+ state.kind = "channel";
133
+ state.id = decodeURIComponent(raw.slice(2));
134
+ return;
135
+ }
136
+ state.kind = "";
137
+ state.id = "";
138
+ }
139
+ function showList() {
140
+ location.hash = "";
141
+ }
142
+ function fitShell() {
143
+ const h = window.visualViewport ? window.visualViewport.height : window.innerHeight;
144
+ document.documentElement.style.setProperty("--vvh", h + "px");
145
+ }
146
+ function toast(text) {
147
+ const el = document.getElementById("flash");
148
+ el.hidden = false;
149
+ el.textContent = text;
150
+ el.classList.add("on");
151
+ clearTimeout(toast.timer);
152
+ toast.timer = setTimeout(() => {
153
+ el.classList.remove("on");
154
+ }, 1800);
155
+ }
156
+ function relTime(iso) {
157
+ if (!iso) return t("noActivity");
158
+ const at = Date.parse(iso);
159
+ if (!Number.isFinite(at)) return t("noActivity");
160
+ const d = Date.now() - at;
161
+ if (d < 45000) return t("justNow");
162
+ if (d < 3600000) {
163
+ return t("minutesAgo", { n: Math.max(1, Math.floor(d / 60000)) });
164
+ }
165
+ const when = new Date(at);
166
+ return (
167
+ String(when.getMonth() + 1).padStart(2, "0") +
168
+ "/" +
169
+ String(when.getDate()).padStart(2, "0")
170
+ );
171
+ }
172
+ function previewLine(text) {
173
+ return String(text || "")
174
+ .replace(/```[\s\S]*?```/g, " ")
175
+ .replace(/[*_`#]+/g, "")
176
+ .replace(/\s+/g, " ")
177
+ .trim()
178
+ .slice(0, 88);
179
+ }
180
+ function roomLiveRows(kind, id) {
181
+ return (state.serverLive || []).filter(
182
+ (row) => row.kind === kind && row.id === id,
183
+ );
184
+ }
185
+ function liveTurnsFrom(body) {
186
+ const rows =
187
+ Array.isArray(body && body.bots) && body.bots.length
188
+ ? body.bots
189
+ : body && body.botId
190
+ ? [body]
191
+ : [];
192
+ return rows
193
+ .filter((row) => row && row.botId)
194
+ .map((row) => ({
195
+ botId: row.botId,
196
+ thinking: row.thinking || "",
197
+ steps: Array.isArray(row.steps) ? row.steps.slice(-5) : [],
198
+ startedAt: row.startedAt || "",
199
+ }));
200
+ }
201
+ function isBusy() {
202
+ return state.posting || (state.lives || []).length > 0;
203
+ }
204
+ function mentionScanText(text) {
205
+ return String(text || "")
206
+ .replace(/```[\s\S]*?```/g, " ")
207
+ .replace(/`[^`]*`/g, " ");
208
+ }
209
+ function summonedBotIds(text) {
210
+ const raw = String(text || "");
211
+ const known = new Map(
212
+ (state.bots || []).map((bot) => [
213
+ String(bot.handle).toLowerCase(),
214
+ bot.id,
215
+ ]),
216
+ );
217
+ const out = [];
218
+ const push = (id) => {
219
+ if (id && out.indexOf(id) < 0) out.push(id);
220
+ };
221
+ let fence = false;
222
+ const lines = raw.split(/\r?\n/);
223
+ const lead = /^[ \t]*((?:@[A-Za-z0-9_-]+[\s,、]*)+)/;
224
+ for (let i = 0; i < lines.length; i++) {
225
+ const line = lines[i];
226
+ const trimmed = line.trim();
227
+ if (trimmed.indexOf("```") === 0) {
228
+ fence = !fence;
229
+ continue;
230
+ }
231
+ if (fence) continue;
232
+ const m = line.match(lead);
233
+ if (!m) continue;
234
+ const names = m[1].match(/@([A-Za-z0-9_-]+)/g) || [];
235
+ for (let n = 0; n < names.length; n++) {
236
+ push(known.get(names[n].slice(1).toLowerCase()));
237
+ }
238
+ }
239
+ if (out.length) return out;
240
+ const scan = mentionScanText(raw);
241
+ const re = /(?:^|\s)@([A-Za-z0-9_-]+)\b/g;
242
+ let row;
243
+ while ((row = re.exec(scan))) {
244
+ push(known.get(String(row[1]).toLowerCase()));
245
+ }
246
+ return out;
247
+ }
248
+ function lastBotAuthor() {
249
+ const msgs = state.messages || [];
250
+ for (let i = msgs.length - 1; i >= 0; i--) {
251
+ const author = msgs[i].author;
252
+ if (author && author !== "you" && botById(author)) return author;
253
+ }
254
+ return "";
255
+ }
256
+ function botsFromSend(text) {
257
+ if (state.kind === "dm") return [state.id];
258
+ const scan = mentionScanText(text);
259
+ if (/(^|\s)@(channel|quest|here|all)\b/i.test(scan)) {
260
+ const ch = currentChannel();
261
+ return (ch && ch.memberIds) || [];
262
+ }
263
+ const ids = summonedBotIds(text);
264
+ if (ids.length) return ids;
265
+ const last = lastBotAuthor();
266
+ return last ? [last] : [];
267
+ }
268
+ function visibleAssistantText(text) {
269
+ return String(text || "")
270
+ .replace(/<skill_content\b[\s\S]*?<\/skill_content>/gi, "")
271
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, "")
272
+ .replace(/<available_skills>[\s\S]*?<\/available_skills>/gi, "")
273
+ .trim();
274
+ }
275
+ function formatBody(text) {
276
+ const cleaned = visibleAssistantText(text);
277
+ if (cleaned.length > 8000) return escapeHtml(cleaned.slice(-8000));
278
+ return typeof renderMarkdown === "function"
279
+ ? renderMarkdown(cleaned)
280
+ : escapeHtml(cleaned);
281
+ }
282
+ function liveStepLabel(name) {
283
+ if (name === "think") return t("think");
284
+ if (name === "skill") return t("skill");
285
+ if (name === "image_gen") return t("imageGen");
286
+ if (name === "spawn") return t("spawn");
287
+ if (name === "run") return t("bash");
288
+ if (name === "read") return t("read");
289
+ if (name === "write") return t("write");
290
+ if (name === "list") return t("list");
291
+ return name || "";
292
+ }
293
+ function liveStepsHtml(steps) {
294
+ const rows = (steps || []).slice(-5);
295
+ if (!rows.length) return "";
296
+ return (
297
+ '<ul class="live-steps">' +
298
+ rows
299
+ .map((step) => {
300
+ const running = step.running ? " running" : "";
301
+ const detail = String(step.detail || "").trim();
302
+ return (
303
+ '<li class="live-step' +
304
+ running +
305
+ '"><span class="live-name">' +
306
+ escapeHtml(liveStepLabel(step.name)) +
307
+ "</span>" +
308
+ (detail
309
+ ? '<span class="live-detail">' + escapeHtml(detail) + "</span>"
310
+ : "") +
311
+ "</li>"
312
+ );
313
+ })
314
+ .join("") +
315
+ "</ul>"
316
+ );
317
+ }
318
+ function turnStatusHtml() {
319
+ if (!isBusy() || !state.lives.length) return "";
320
+ return state.lives
321
+ .map((live) => {
322
+ const bot = botById(live.botId);
323
+ if (!bot) return "";
324
+ const started = live.startedAt ? Date.parse(live.startedAt) : NaN;
325
+ const elapsed = Number.isFinite(started)
326
+ ? Date.now() - started
327
+ : 0;
328
+ const clock =
329
+ elapsed >= 15000
330
+ ? '<span class="clock">' + Math.floor(elapsed / 1000) + "s</span>"
331
+ : "";
332
+ return (
333
+ '<article class="msg bot live" data-bot-id="' +
334
+ escapeHtml(bot.id) +
335
+ '"><span class="avatar" style="background:' +
336
+ hue(bot.handle) +
337
+ '">' +
338
+ avatarFace(bot) +
339
+ '</span><div class="msg-main"><div class="msg-head"><span class="name">' +
340
+ escapeHtml(bot.name) +
341
+ '</span><span class="handle">@' +
342
+ escapeHtml(bot.handle) +
343
+ '</span></div><div class="turn-live"><div class="turn-status">' +
344
+ t("deepDiving") +
345
+ clock +
346
+ "</div>" +
347
+ liveStepsHtml(live.steps) +
348
+ '<div class="turn-actions"><button type="button" class="turn-stop" data-live-stop="' +
349
+ escapeHtml(bot.id) +
350
+ '">' +
351
+ t("live.stop") +
352
+ "</button></div></div></div></article>"
353
+ );
354
+ })
355
+ .join("");
356
+ }
357
+ function msgHtml(msg) {
358
+ const you = msg.author === "you";
359
+ const bot = you ? null : botById(msg.author);
360
+ const name = you ? t("you") : bot ? bot.name : msg.author;
361
+ const handle = bot ? bot.handle : "";
362
+ const avStyle = bot ? "background:" + hue(bot.handle) : "";
363
+ const face = you
364
+ ? escapeHtml(initials(t("you")))
365
+ : bot
366
+ ? avatarFace(bot)
367
+ : "?";
368
+ const body = you
369
+ ? '<p class="bubble">' + escapeHtml(msg.body || "") + "</p>"
370
+ : '<div class="bubble"><div class="assistant-text md">' +
371
+ formatBody(msg.body || "") +
372
+ "</div></div>";
373
+ return (
374
+ '<article class="msg' +
375
+ (you ? " you" : " bot") +
376
+ '"><span class="avatar" style="' +
377
+ avStyle +
378
+ '">' +
379
+ face +
380
+ '</span><div class="msg-main"><div class="msg-head"><span class="name">' +
381
+ escapeHtml(name) +
382
+ "</span>" +
383
+ (handle
384
+ ? '<span class="handle">@' + escapeHtml(handle) + "</span>"
385
+ : "") +
386
+ '<span class="clock">' +
387
+ escapeHtml(relTime(msg.createdAt)) +
388
+ "</span></div>" +
389
+ body +
390
+ "</div></article>"
391
+ );
392
+ }
393
+ function renderThread(opts) {
394
+ const root = document.getElementById("thread");
395
+ const pin =
396
+ Boolean(opts && opts.pinBottom) ||
397
+ root.scrollHeight - root.scrollTop - root.clientHeight < 80;
398
+ const all = state.messages || [];
399
+ const cap = 30;
400
+ const truncated = all.length > cap;
401
+ const rows = truncated ? all.slice(-cap) : all;
402
+ if (!rows.length && !isBusy()) {
403
+ root.innerHTML =
404
+ '<div class="empty-thread">' + escapeHtml(t("noMessages")) + "</div>";
405
+ return;
406
+ }
407
+ const notice = truncated
408
+ ? '<div class="empty-thread">' +
409
+ escapeHtml(t("mobile.recent", { n: cap })) +
410
+ "</div>"
411
+ : "";
412
+ root.innerHTML = notice + rows.map(msgHtml).join("") + turnStatusHtml();
413
+ if (pin) root.scrollTop = root.scrollHeight;
414
+ }
415
+ function syncComposer() {
416
+ const busy = isBusy();
417
+ document.getElementById("send").hidden = busy;
418
+ document.getElementById("stop").hidden = !busy;
419
+ }
420
+ function navRow(opts) {
421
+ const live = opts.busy
422
+ ? '<span class="live-dot">' + escapeHtml(t("busy")) + "</span>"
423
+ : "";
424
+ return (
425
+ '<li><a class="' +
426
+ (opts.branch ? "branch" : "") +
427
+ '" href="' +
428
+ escapeHtml(opts.href) +
429
+ '">' +
430
+ opts.av +
431
+ '<span class="nav-copy"><strong>' +
432
+ escapeHtml(opts.name) +
433
+ live +
434
+ "</strong><em>" +
435
+ escapeHtml(opts.preview || t("noActivity")) +
436
+ "</em></span></a></li>"
437
+ );
438
+ }
439
+ function renderList() {
440
+ const all = state.channels || [];
441
+ const byId = new Map(all.map((ch) => [ch.id, ch]));
442
+ const kidsOf = (id) => all.filter((ch) => ch.parentId === id);
443
+ const roots = all.filter((ch) => !ch.parentId);
444
+ const seen = new Set();
445
+ const rows = [];
446
+ function walk(ch, depth) {
447
+ if (seen.has(ch.id)) return;
448
+ seen.add(ch.id);
449
+ const last = ch.lastMessage;
450
+ rows.push(
451
+ navRow({
452
+ href: "#c/" + encodeURIComponent(ch.id),
453
+ branch: depth > 0,
454
+ name: (depth ? "↳ " : "#") + ch.name,
455
+ preview: last
456
+ ? previewLine(last.body) + " · " + relTime(last.createdAt)
457
+ : relTime(ch.updatedAt),
458
+ busy: roomLiveRows("channel", ch.id).length > 0,
459
+ av: '<span class="avatar">#</span>',
460
+ }),
461
+ );
462
+ kidsOf(ch.id).forEach((kid) => walk(kid, depth + 1));
463
+ }
464
+ roots.forEach((ch) => walk(ch, 0));
465
+ all.forEach((ch) => {
466
+ if (!seen.has(ch.id) && (!ch.parentId || !byId.has(ch.parentId))) {
467
+ walk(ch, 0);
468
+ }
469
+ });
470
+ document.getElementById("channels").innerHTML = rows.length
471
+ ? rows.join("")
472
+ : '<li class="empty-list">' + escapeHtml(t("noActivity")) + "</li>";
473
+ document.getElementById("dms").innerHTML = (state.bots || [])
474
+ .map((bot) => {
475
+ const last = bot.lastMessage;
476
+ return navRow({
477
+ href: "#d/" + encodeURIComponent(bot.id),
478
+ name: bot.name,
479
+ preview: last
480
+ ? previewLine(last.body) + " · " + relTime(last.createdAt)
481
+ : "@" + bot.handle,
482
+ busy: roomLiveRows("dm", bot.id).length > 0,
483
+ av:
484
+ '<span class="avatar" style="background:' +
485
+ hue(bot.handle) +
486
+ '">' +
487
+ avatarFace(bot) +
488
+ "</span>",
489
+ });
490
+ })
491
+ .join("");
492
+ }
493
+ function renderHead() {
494
+ const title = document.getElementById("title");
495
+ const sub = document.getElementById("subtitle");
496
+ if (state.kind === "dm") {
497
+ const bot = botById(state.id);
498
+ title.textContent = bot ? bot.name : state.id;
499
+ sub.textContent = bot ? "@" + bot.handle : "";
500
+ return;
501
+ }
502
+ const ch = currentChannel();
503
+ title.textContent = ch ? "#" + ch.name : state.id;
504
+ const n = ch && ch.members ? ch.members.length : 0;
505
+ sub.textContent = n ? String(n) : "";
506
+ }
507
+ function showScreens() {
508
+ const list = !state.kind;
509
+ document.getElementById("screen-list").hidden = !list;
510
+ document.getElementById("screen-room").hidden = list;
511
+ }
512
+ async function loadWorkspace() {
513
+ const data = await fetch("/workspace").then((r) => r.json());
514
+ state.channels = data.channels || [];
515
+ state.bots = data.bots || [];
516
+ state.serverLive = Array.isArray(data.live) ? data.live : [];
517
+ renderList();
518
+ if (state.kind) renderHead();
519
+ }
520
+ async function loadMessages(opts) {
521
+ if (!state.kind) return;
522
+ const res = await fetch(roomMessagesUrl());
523
+ const body = await res.json().catch(() => []);
524
+ if (!res.ok) throw new Error(body.error || "load failed");
525
+ state.messages = Array.isArray(body) ? body : [];
526
+ renderThread({ pinBottom: !(opts && opts.merge) });
527
+ }
528
+ async function pollLive() {
529
+ if (!state.kind) return;
530
+ const url = liveUrl();
531
+ if (!url) return;
532
+ try {
533
+ const res = await fetch(url);
534
+ const body = await res.json().catch(() => ({}));
535
+ if (!res.ok) return;
536
+ const lives = liveTurnsFrom(body);
537
+ const was = state.lives.map((row) => row.botId).join(",");
538
+ const now = lives.map((row) => row.botId).join(",");
539
+ state.lives = lives;
540
+ if (!lives.length && !state.posting) {
541
+ if (was) await loadMessages({ merge: true }).catch(() => {});
542
+ else renderThread();
543
+ } else {
544
+ if (now !== was) await loadMessages({ merge: true }).catch(() => {});
545
+ else renderThread();
546
+ }
547
+ syncComposer();
548
+ } catch {
549
+ /* poll is best-effort */
550
+ }
551
+ }
552
+ async function tick() {
553
+ try {
554
+ if (!state.kind) await loadWorkspace();
555
+ else {
556
+ await pollLive();
557
+ if (!isBusy()) await loadMessages({ merge: true }).catch(() => {});
558
+ }
559
+ } finally {
560
+ schedule();
561
+ }
562
+ }
563
+ function schedule() {
564
+ clearTimeout(state.pollTimer);
565
+ const ms = !state.kind ? 2500 : isBusy() ? 900 : 2200;
566
+ state.pollTimer = setTimeout(tick, ms);
567
+ }
568
+ async function openFromHash() {
569
+ parseHash();
570
+ hideMention();
571
+ showScreens();
572
+ await loadWorkspace();
573
+ if (state.kind) {
574
+ renderHead();
575
+ await loadMessages().catch((err) => toast(err.message || String(err)));
576
+ await pollLive();
577
+ syncComposer();
578
+ const thread = document.getElementById("thread");
579
+ thread.scrollTop = thread.scrollHeight;
580
+ }
581
+ schedule();
582
+ }
583
+ function mentionAtCursor(el) {
584
+ const start = el.selectionStart || 0;
585
+ const before = el.value.slice(0, start);
586
+ const m = before.match(/(^|\s)@([A-Za-z0-9_-]*)$/);
587
+ if (!m) return null;
588
+ return { start: start - m[2].length - 1, query: m[2].toLowerCase() };
589
+ }
590
+ function mentionChoices(query) {
591
+ const needle = String(query || "").toLowerCase();
592
+ return (state.bots || []).filter((bot) => {
593
+ if (!needle) return true;
594
+ return (
595
+ bot.handle.toLowerCase().indexOf(needle) >= 0 ||
596
+ bot.name.toLowerCase().indexOf(needle) >= 0
597
+ );
598
+ });
599
+ }
600
+ function hideMention() {
601
+ document.getElementById("mention-pop").hidden = true;
602
+ }
603
+ function renderMention() {
604
+ const draft = document.getElementById("draft");
605
+ const at = mentionAtCursor(draft);
606
+ const pop = document.getElementById("mention-pop");
607
+ if (!at) {
608
+ hideMention();
609
+ return;
610
+ }
611
+ const rows = mentionChoices(at.query);
612
+ if (!rows.length) {
613
+ hideMention();
614
+ return;
615
+ }
616
+ if (state.mentionIndex >= rows.length) state.mentionIndex = 0;
617
+ if (state.mentionIndex < 0) state.mentionIndex = rows.length - 1;
618
+ document.getElementById("mention-results").innerHTML = rows
619
+ .map((bot, index) => {
620
+ const on = index === state.mentionIndex ? " on" : "";
621
+ return (
622
+ '<button type="button" class="mention-item' +
623
+ on +
624
+ '" data-mention="' +
625
+ index +
626
+ '" data-handle="' +
627
+ escapeHtml(bot.handle) +
628
+ '"><span class="mention-glyph" style="background:' +
629
+ hue(bot.handle) +
630
+ '">' +
631
+ avatarFace(bot) +
632
+ "</span><span><strong>" +
633
+ escapeHtml(bot.name) +
634
+ "</strong><em>@" +
635
+ escapeHtml(bot.handle) +
636
+ "</em></span></button>"
637
+ );
638
+ })
639
+ .join("");
640
+ pop.hidden = false;
641
+ }
642
+ function insertMention(handle) {
643
+ const draft = document.getElementById("draft");
644
+ const at = mentionAtCursor(draft);
645
+ if (!at) return;
646
+ const after = draft.value.slice(draft.selectionStart || 0);
647
+ const next = draft.value.slice(0, at.start) + "@" + handle + " " + after;
648
+ draft.value = next;
649
+ const pos = at.start + handle.length + 2;
650
+ draft.setSelectionRange(pos, pos);
651
+ hideMention();
652
+ fitDraft();
653
+ draft.focus();
654
+ }
655
+ function pickMention(index) {
656
+ const btn = document.querySelector(
657
+ '.mention-item[data-mention="' + index + '"]',
658
+ );
659
+ if (btn) insertMention(btn.getAttribute("data-handle"));
660
+ }
661
+ function fitDraft() {
662
+ const el = document.getElementById("draft");
663
+ el.style.height = "auto";
664
+ el.style.height = Math.min(el.scrollHeight, 144) + "px";
665
+ }
666
+ async function stopTurn(botId) {
667
+ try {
668
+ await fetch(abortUrl(), {
669
+ method: "POST",
670
+ headers: { "content-type": "application/json" },
671
+ body: JSON.stringify(botId ? { botId: botId } : {}),
672
+ });
673
+ } catch {
674
+ /* ignore */
675
+ }
676
+ state.lives = botId
677
+ ? state.lives.filter((row) => row.botId !== botId)
678
+ : [];
679
+ state.posting = false;
680
+ syncComposer();
681
+ renderThread();
682
+ await loadMessages().catch(() => {});
683
+ }
684
+ async function sendDraft() {
685
+ const draft = document.getElementById("draft");
686
+ const raw = draft.value.trim();
687
+ if (!raw || !state.kind || isBusy()) return;
688
+ const botIds = botsFromSend(raw);
689
+ const payload = { body: raw };
690
+ if (botIds.length) payload.mentions = botIds;
691
+ if (botIds.length === 1) payload.assigneeId = botIds[0];
692
+ draft.value = "";
693
+ fitDraft();
694
+ hideMention();
695
+ state.messages = state.messages.concat([
696
+ {
697
+ id: "pending-you-" + Date.now(),
698
+ author: "you",
699
+ body: raw,
700
+ createdAt: new Date().toISOString(),
701
+ },
702
+ ]);
703
+ state.posting = true;
704
+ syncComposer();
705
+ renderThread();
706
+ try {
707
+ const res = await fetch(roomMessagesUrl(), {
708
+ method: "POST",
709
+ headers: { "content-type": "application/json" },
710
+ body: JSON.stringify(payload),
711
+ });
712
+ const body = await res.json().catch(() => ({}));
713
+ if (!res.ok) throw new Error(body.error || "send failed");
714
+ await loadMessages();
715
+ await pollLive();
716
+ } catch (err) {
717
+ if (err && err.name === "AbortError") return;
718
+ toast(err.message || String(err));
719
+ draft.value = raw;
720
+ fitDraft();
721
+ await loadMessages().catch(() => {});
722
+ } finally {
723
+ state.posting = false;
724
+ syncComposer();
725
+ schedule();
726
+ }
727
+ }
728
+
729
+ document.getElementById("back").addEventListener("click", () => {
730
+ showList();
731
+ });
732
+ document.getElementById("composer").addEventListener("submit", (event) => {
733
+ event.preventDefault();
734
+ sendDraft();
735
+ });
736
+ document.getElementById("stop").addEventListener("click", () => {
737
+ stopTurn();
738
+ });
739
+ document.getElementById("thread").addEventListener("click", (event) => {
740
+ const stop = event.target.closest("[data-live-stop]");
741
+ if (!stop) return;
742
+ event.preventDefault();
743
+ stopTurn(stop.getAttribute("data-live-stop"));
744
+ });
745
+ document.getElementById("draft").addEventListener("input", () => {
746
+ fitDraft();
747
+ renderMention();
748
+ });
749
+ document.getElementById("draft").addEventListener("keydown", (event) => {
750
+ const pop = document.getElementById("mention-pop");
751
+ if (pop.hidden) return;
752
+ if (event.key === "ArrowDown") {
753
+ event.preventDefault();
754
+ state.mentionIndex += 1;
755
+ renderMention();
756
+ } else if (event.key === "ArrowUp") {
757
+ event.preventDefault();
758
+ state.mentionIndex -= 1;
759
+ renderMention();
760
+ } else if (event.key === "Enter" || event.key === "Tab") {
761
+ event.preventDefault();
762
+ pickMention(state.mentionIndex);
763
+ } else if (event.key === "Escape") {
764
+ hideMention();
765
+ }
766
+ });
767
+ document.getElementById("mention-results").addEventListener("click", (event) => {
768
+ const btn = event.target.closest("[data-handle]");
769
+ if (!btn) return;
770
+ insertMention(btn.getAttribute("data-handle"));
771
+ });
772
+ window.addEventListener("hashchange", () => {
773
+ openFromHash().catch((err) => toast(err.message || String(err)));
774
+ });
775
+ window.addEventListener("resize", fitShell);
776
+ if (window.visualViewport) {
777
+ window.visualViewport.addEventListener("resize", fitShell);
778
+ }
779
+ fitShell();
780
+ openFromHash().catch((err) => toast(err.message || String(err)));
781
+ })();
782
+ </script>
783
+ </body>
784
+ </html>