@kevin5251984/guild 0.2.18 → 0.2.20

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,954 @@
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=enamel" />
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-steer" id="composer-steer" hidden></div>
43
+ <div class="composer-actions">
44
+ <button type="submit" class="send" id="send" data-i18n="send">送出</button>
45
+ <button type="button" class="stop-all" id="stop" hidden data-i18n="live.stop">停止</button>
46
+ <button type="button" class="steer" id="steer" hidden data-i18n="steer.insert">插入引導</button>
47
+ </div>
48
+ </div>
49
+ </form>
50
+ </section>
51
+ <div class="flash" id="flash" role="status" aria-live="polite" hidden></div>
52
+ <script>
53
+ (function () {
54
+ const COLORS = ["#7c6af7", "#5b8def", "#2ea887", "#e07a3d", "#d4537e"];
55
+ const SEAT_HUE = {
56
+ infra: "#5b8def",
57
+ pm: "#2ea887",
58
+ rd: "#e07a3d",
59
+ design: "#7c6af7",
60
+ marketing: "#d4537e",
61
+ };
62
+ const state = {
63
+ channels: [],
64
+ bots: [],
65
+ serverLive: [],
66
+ messages: [],
67
+ lives: [],
68
+ kind: "",
69
+ id: "",
70
+ posting: false,
71
+ mentionIndex: 0,
72
+ pollTimer: 0,
73
+ };
74
+
75
+ function hue(key) {
76
+ const seat = SEAT_HUE[String(key).toLowerCase()];
77
+ if (seat) return seat;
78
+ let n = 0;
79
+ for (const ch of String(key)) n = (n + ch.charCodeAt(0) * 17) % COLORS.length;
80
+ return COLORS[n];
81
+ }
82
+ function escapeHtml(value) {
83
+ return String(value)
84
+ .replace(/&/g, "&amp;")
85
+ .replace(/</g, "&lt;")
86
+ .replace(/>/g, "&gt;");
87
+ }
88
+ function initials(name) {
89
+ const parts = String(name)
90
+ .replace(/[^\p{L}\p{N} ]/gu, " ")
91
+ .trim()
92
+ .split(/\s+/);
93
+ if (parts[0] && /[A-Za-z]/.test(parts[0][0])) {
94
+ const letters = parts.slice(0, 2).map((part) => part[0]).filter(Boolean);
95
+ if (
96
+ letters.length === 2 &&
97
+ /[A-Za-z]/.test(letters[0]) &&
98
+ /[A-Za-z]/.test(letters[1])
99
+ ) {
100
+ return letters.join("").toUpperCase();
101
+ }
102
+ return letters[0].toUpperCase();
103
+ }
104
+ const glyph = Array.from(String(name).replace(/\s+/g, ""))[0] || "?";
105
+ return glyph;
106
+ }
107
+ function portraitUrl(bot) {
108
+ const url = bot && typeof bot.portrait === "string" ? bot.portrait.trim() : "";
109
+ return url.indexOf("/generated/") === 0 ? url : "";
110
+ }
111
+ function avatarFace(bot) {
112
+ const src = portraitUrl(bot);
113
+ if (src) return '<img alt="" src="' + escapeHtml(src) + '">';
114
+ return escapeHtml(initials((bot && (bot.name || bot.handle)) || "?"));
115
+ }
116
+ function botById(id) {
117
+ return (state.bots || []).find((bot) => bot.id === id);
118
+ }
119
+ function currentChannel() {
120
+ return (state.channels || []).find((ch) => ch.id === state.id);
121
+ }
122
+ function currentRoomRef() {
123
+ return state.kind ? { kind: state.kind, id: state.id } : null;
124
+ }
125
+ function roomMessagesUrl() {
126
+ return state.kind === "dm"
127
+ ? "/dms/" + encodeURIComponent(state.id) + "/messages"
128
+ : "/channels/" + encodeURIComponent(state.id) + "/messages";
129
+ }
130
+ function liveUrl() {
131
+ const r = currentRoomRef();
132
+ if (!r) return "";
133
+ return r.kind === "dm"
134
+ ? "/dms/" + encodeURIComponent(r.id) + "/live"
135
+ : "/channels/" + encodeURIComponent(r.id) + "/live";
136
+ }
137
+ function abortUrl() {
138
+ const r = currentRoomRef();
139
+ if (!r) return "";
140
+ return r.kind === "dm"
141
+ ? "/dms/" + encodeURIComponent(r.id) + "/abort"
142
+ : "/channels/" + encodeURIComponent(r.id) + "/abort";
143
+ }
144
+ function steerUrl() {
145
+ const r = currentRoomRef();
146
+ if (!r) return "";
147
+ return r.kind === "dm"
148
+ ? "/dms/" + encodeURIComponent(r.id) + "/steer"
149
+ : "/channels/" + encodeURIComponent(r.id) + "/steer";
150
+ }
151
+ function parseHash() {
152
+ const raw = location.hash.replace(/^#/, "");
153
+ if (raw.startsWith("d/")) {
154
+ state.kind = "dm";
155
+ state.id = decodeURIComponent(raw.slice(2));
156
+ return;
157
+ }
158
+ if (raw.startsWith("c/")) {
159
+ state.kind = "channel";
160
+ state.id = decodeURIComponent(raw.slice(2));
161
+ return;
162
+ }
163
+ state.kind = "";
164
+ state.id = "";
165
+ }
166
+ function showList() {
167
+ location.hash = "";
168
+ }
169
+ function fitShell() {
170
+ const h = window.visualViewport ? window.visualViewport.height : window.innerHeight;
171
+ document.documentElement.style.setProperty("--vvh", h + "px");
172
+ }
173
+ function toast(text, opts) {
174
+ const el = document.getElementById("flash");
175
+ const ok = Boolean(opts && opts.ok);
176
+ el.hidden = false;
177
+ el.textContent = text;
178
+ el.classList.toggle("err", !ok);
179
+ el.classList.add("on");
180
+ clearTimeout(toast.timer);
181
+ toast.timer = setTimeout(() => {
182
+ el.classList.remove("on");
183
+ }, (opts && opts.ms) || 1800);
184
+ }
185
+ function relTime(iso) {
186
+ if (!iso) return t("noActivity");
187
+ const at = Date.parse(iso);
188
+ if (!Number.isFinite(at)) return t("noActivity");
189
+ const d = Date.now() - at;
190
+ if (d < 45000) return t("justNow");
191
+ if (d < 3600000) {
192
+ return t("minutesAgo", { n: Math.max(1, Math.floor(d / 60000)) });
193
+ }
194
+ const when = new Date(at);
195
+ return (
196
+ String(when.getMonth() + 1).padStart(2, "0") +
197
+ "/" +
198
+ String(when.getDate()).padStart(2, "0")
199
+ );
200
+ }
201
+ function previewLine(text) {
202
+ return String(text || "")
203
+ .replace(/```[\s\S]*?```/g, " ")
204
+ .replace(/[*_`#]+/g, "")
205
+ .replace(/\s+/g, " ")
206
+ .trim()
207
+ .slice(0, 88);
208
+ }
209
+ function roomLiveRows(kind, id) {
210
+ return (state.serverLive || []).filter(
211
+ (row) => row.kind === kind && row.id === id,
212
+ );
213
+ }
214
+ function liveTurnsFrom(body) {
215
+ const rows =
216
+ Array.isArray(body && body.bots) && body.bots.length
217
+ ? body.bots
218
+ : body && body.botId
219
+ ? [body]
220
+ : [];
221
+ return rows
222
+ .filter((row) => row && row.botId)
223
+ .map((row) => ({
224
+ botId: row.botId,
225
+ thinking: row.thinking || "",
226
+ steps: Array.isArray(row.steps) ? row.steps.slice(-5) : [],
227
+ startedAt: row.startedAt || "",
228
+ }));
229
+ }
230
+ function isBusy() {
231
+ return state.posting || (state.lives || []).length > 0;
232
+ }
233
+ function mentionScanText(text) {
234
+ return String(text || "")
235
+ .replace(/```[\s\S]*?```/g, " ")
236
+ .replace(/`[^`]*`/g, " ");
237
+ }
238
+ function deferredHandles(text) {
239
+ const raw = String(text || "");
240
+ const out = [];
241
+ const add = (name) => {
242
+ const key = String(name || "").toLowerCase();
243
+ if (key && out.indexOf(key) < 0) out.push(key);
244
+ };
245
+ const scan = raw.replace(/```[\s\S]*?```/g, " ");
246
+ const cue =
247
+ /(?:完成後|通過後|最後(?:才|由)?|才需要|才叫|才交)[^@\n]{0,24}@([A-Za-z0-9_-]+)/gi;
248
+ const bare =
249
+ /(?:完成後|才需要|才叫)\s*(?:call|ask|由)\s*`?@?([A-Za-z0-9_-]+)`?/gi;
250
+ let row;
251
+ while ((row = cue.exec(scan))) add(row[1]);
252
+ while ((row = bare.exec(scan))) add(row[1]);
253
+ const later =
254
+ /^[ \t]*(?:\d+[.)、]\s*|[-*•]\s+)(?:通過後|最後|然後|之後|完成後)\s*`?@([A-Za-z0-9_-]+)`?/i;
255
+ const lines = raw.split(/\r?\n/);
256
+ for (let i = 0; i < lines.length; i++) {
257
+ const hit = lines[i].match(later);
258
+ if (hit) add(hit[1]);
259
+ }
260
+ return out;
261
+ }
262
+ function summonedBotIds(text) {
263
+ const raw = String(text || "");
264
+ const deferred = {};
265
+ deferredHandles(raw).forEach((handle) => {
266
+ deferred[handle] = true;
267
+ });
268
+ const members = new Set(
269
+ state.kind === "channel"
270
+ ? ((currentChannel() && currentChannel().memberIds) || [])
271
+ : state.kind === "dm"
272
+ ? [state.id]
273
+ : [],
274
+ );
275
+ const known = new Map(
276
+ (state.bots || [])
277
+ .filter((bot) => !members.size || members.has(bot.id))
278
+ .map((bot) => [
279
+ String(bot.handle).toLowerCase(),
280
+ bot.id,
281
+ ]),
282
+ );
283
+ const out = [];
284
+ const push = (id, handle) => {
285
+ if (id && !deferred[handle] && out.indexOf(id) < 0) out.push(id);
286
+ };
287
+ let fence = false;
288
+ const lines = raw.split(/\r?\n/);
289
+ const lead = /^[ \t]*((?:@[A-Za-z0-9_-]+[\s,、]*)+)/;
290
+ for (let i = 0; i < lines.length; i++) {
291
+ const line = lines[i];
292
+ const trimmed = line.trim();
293
+ if (trimmed.indexOf("```") === 0) {
294
+ fence = !fence;
295
+ continue;
296
+ }
297
+ if (fence) continue;
298
+ const m = line.match(lead);
299
+ if (!m) continue;
300
+ const names = m[1].match(/@([A-Za-z0-9_-]+)/g) || [];
301
+ for (let n = 0; n < names.length; n++) {
302
+ const handle = names[n].slice(1).toLowerCase();
303
+ push(known.get(handle), handle);
304
+ }
305
+ }
306
+ if (out.length) return out;
307
+ const scan = mentionScanText(raw);
308
+ const re = /(?:^|\s)@([A-Za-z0-9_-]+)\b/g;
309
+ let row;
310
+ while ((row = re.exec(scan))) {
311
+ const handle = String(row[1]).toLowerCase();
312
+ push(known.get(handle), handle);
313
+ }
314
+ return out;
315
+ }
316
+ function lastBotAuthor() {
317
+ const msgs = state.messages || [];
318
+ for (let i = msgs.length - 1; i >= 0; i--) {
319
+ const author = msgs[i].author;
320
+ if (author && author !== "you" && botById(author)) return author;
321
+ }
322
+ return "";
323
+ }
324
+ function botsFromSend(text) {
325
+ if (state.kind === "dm") return [state.id];
326
+ const scan = mentionScanText(text);
327
+ if (/(^|\s)@(channel|quest|here|all)\b/i.test(scan)) {
328
+ const ch = currentChannel();
329
+ return (ch && ch.memberIds) || [];
330
+ }
331
+ const ids = summonedBotIds(text);
332
+ if (ids.length) return ids;
333
+ const last = lastBotAuthor();
334
+ return last ? [last] : [];
335
+ }
336
+ function visibleAssistantText(text) {
337
+ return String(text || "")
338
+ .replace(/<skill_content\b[\s\S]*?<\/skill_content>/gi, "")
339
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, "")
340
+ .replace(/<available_skills>[\s\S]*?<\/available_skills>/gi, "")
341
+ .trim();
342
+ }
343
+ function formatBody(text) {
344
+ const cleaned = visibleAssistantText(text);
345
+ if (cleaned.length > 8000) return escapeHtml(cleaned.slice(-8000));
346
+ return typeof renderMarkdown === "function"
347
+ ? renderMarkdown(cleaned)
348
+ : escapeHtml(cleaned);
349
+ }
350
+ function liveStepLabel(name) {
351
+ if (name === "think") return t("think");
352
+ if (name === "skill") return t("skill");
353
+ if (name === "image_gen") return t("imageGen");
354
+ if (name === "spawn") return t("spawn");
355
+ if (name === "run") return t("bash");
356
+ if (name === "read") return t("read");
357
+ if (name === "write") return t("write");
358
+ if (name === "list") return t("list");
359
+ return name || "";
360
+ }
361
+ function liveStepsHtml(steps) {
362
+ const rows = (steps || []).slice(-5);
363
+ if (!rows.length) return "";
364
+ return (
365
+ '<ul class="live-steps">' +
366
+ rows
367
+ .map((step) => {
368
+ const running = step.running ? " running" : "";
369
+ const detail = String(step.detail || "").trim();
370
+ return (
371
+ '<li class="live-step' +
372
+ running +
373
+ '"><span class="live-name">' +
374
+ escapeHtml(liveStepLabel(step.name)) +
375
+ "</span>" +
376
+ (detail
377
+ ? '<span class="live-detail">' + escapeHtml(detail) + "</span>"
378
+ : "") +
379
+ "</li>"
380
+ );
381
+ })
382
+ .join("") +
383
+ "</ul>"
384
+ );
385
+ }
386
+ function turnStatusHtml() {
387
+ if (!isBusy() || !state.lives.length) return "";
388
+ return state.lives
389
+ .map((live) => {
390
+ const bot = botById(live.botId);
391
+ if (!bot) return "";
392
+ const started = live.startedAt ? Date.parse(live.startedAt) : NaN;
393
+ const elapsed = Number.isFinite(started)
394
+ ? Date.now() - started
395
+ : 0;
396
+ const clock =
397
+ elapsed >= 15000
398
+ ? '<span class="clock">' + Math.floor(elapsed / 1000) + "s</span>"
399
+ : "";
400
+ return (
401
+ '<article class="msg bot live" data-bot-id="' +
402
+ escapeHtml(bot.id) +
403
+ '"><span class="avatar" style="background:' +
404
+ hue(bot.handle) +
405
+ '">' +
406
+ avatarFace(bot) +
407
+ '</span><div class="msg-main"><div class="msg-head"><span class="name">' +
408
+ escapeHtml(bot.name) +
409
+ '</span><span class="handle">@' +
410
+ escapeHtml(bot.handle) +
411
+ '</span></div><div class="turn-live"><div class="turn-status">' +
412
+ t("deepDiving") +
413
+ clock +
414
+ "</div>" +
415
+ liveStepsHtml(live.steps) +
416
+ '<div class="turn-actions"><button type="button" class="turn-stop" data-live-stop="' +
417
+ escapeHtml(bot.id) +
418
+ '">' +
419
+ t("live.stop") +
420
+ '</button><button type="button" class="turn-steer" data-live-steer="' +
421
+ escapeHtml(bot.id) +
422
+ '">' +
423
+ t("live.steer") +
424
+ "</button></div></div></div></article>"
425
+ );
426
+ })
427
+ .join("");
428
+ }
429
+ function msgHtml(msg) {
430
+ const you = msg.author === "you";
431
+ const bot = you ? null : botById(msg.author);
432
+ const name = you ? t("you") : bot ? bot.name : msg.author;
433
+ const handle = bot ? bot.handle : "";
434
+ const avStyle = bot ? "background:" + hue(bot.handle) : "";
435
+ const face = you
436
+ ? escapeHtml(initials(t("you")))
437
+ : bot
438
+ ? avatarFace(bot)
439
+ : "?";
440
+ const body = you
441
+ ? '<p class="bubble">' + escapeHtml(msg.body || "") + "</p>"
442
+ : '<div class="bubble"><div class="assistant-text md">' +
443
+ formatBody(msg.body || "") +
444
+ "</div></div>";
445
+ return (
446
+ '<article class="msg' +
447
+ (you ? " you" : " bot") +
448
+ '"><span class="avatar" style="' +
449
+ avStyle +
450
+ '">' +
451
+ face +
452
+ '</span><div class="msg-main"><div class="msg-head"><span class="name">' +
453
+ escapeHtml(name) +
454
+ "</span>" +
455
+ (handle
456
+ ? '<span class="handle">@' + escapeHtml(handle) + "</span>"
457
+ : "") +
458
+ '<span class="clock">' +
459
+ escapeHtml(relTime(msg.createdAt)) +
460
+ "</span></div>" +
461
+ body +
462
+ "</div></article>"
463
+ );
464
+ }
465
+ function renderThread(opts) {
466
+ const root = document.getElementById("thread");
467
+ const pin =
468
+ Boolean(opts && opts.pinBottom) ||
469
+ root.scrollHeight - root.scrollTop - root.clientHeight < 80;
470
+ const all = state.messages || [];
471
+ const cap = 30;
472
+ const truncated = all.length > cap;
473
+ const rows = truncated ? all.slice(-cap) : all;
474
+ if (!rows.length && !isBusy()) {
475
+ root.innerHTML =
476
+ '<div class="empty-thread">' + escapeHtml(t("noMessages")) + "</div>";
477
+ return;
478
+ }
479
+ const notice = truncated
480
+ ? '<div class="empty-thread">' +
481
+ escapeHtml(t("mobile.recent", { n: cap })) +
482
+ "</div>"
483
+ : "";
484
+ root.innerHTML = notice + rows.map(msgHtml).join("") + turnStatusHtml();
485
+ if (pin) root.scrollTop = root.scrollHeight;
486
+ }
487
+ function steerModLabel() {
488
+ return /Mac|iPhone|iPad/.test(navigator.platform || "") ? "\u2318" : "Ctrl+";
489
+ }
490
+ function syncSteerHint() {
491
+ const hint = document.getElementById("composer-steer");
492
+ if (!hint) return;
493
+ if (!isBusy()) {
494
+ hint.hidden = true;
495
+ hint.textContent = "";
496
+ return;
497
+ }
498
+ const hintKey = t("steer.hint", { mod: steerModLabel() });
499
+ hint.hidden = false;
500
+ hint.textContent = hintKey === "steer.hint" ? t("live.steer") : hintKey;
501
+ }
502
+ function syncComposer() {
503
+ const busy = isBusy();
504
+ document.getElementById("send").hidden = busy;
505
+ document.getElementById("stop").hidden = !busy;
506
+ const steer = document.getElementById("steer");
507
+ if (steer) steer.hidden = !busy;
508
+ syncSteerHint();
509
+ }
510
+ function navRow(opts) {
511
+ const live = opts.busy
512
+ ? '<span class="live-dot">' + escapeHtml(t("busy")) + "</span>"
513
+ : "";
514
+ return (
515
+ '<li><a class="' +
516
+ (opts.branch ? "branch" : "") +
517
+ '" href="' +
518
+ escapeHtml(opts.href) +
519
+ '">' +
520
+ opts.av +
521
+ '<span class="nav-copy"><strong>' +
522
+ escapeHtml(opts.name) +
523
+ live +
524
+ "</strong><em>" +
525
+ escapeHtml(opts.preview || t("noActivity")) +
526
+ "</em></span></a></li>"
527
+ );
528
+ }
529
+ function renderList() {
530
+ const all = state.channels || [];
531
+ const byId = new Map(all.map((ch) => [ch.id, ch]));
532
+ const kidsOf = (id) => all.filter((ch) => ch.parentId === id);
533
+ const roots = all.filter((ch) => !ch.parentId);
534
+ const seen = new Set();
535
+ const rows = [];
536
+ function walk(ch, depth) {
537
+ if (seen.has(ch.id)) return;
538
+ seen.add(ch.id);
539
+ const last = ch.lastMessage;
540
+ rows.push(
541
+ navRow({
542
+ href: "#c/" + encodeURIComponent(ch.id),
543
+ branch: depth > 0,
544
+ name: (depth ? "↳ " : "#") + ch.name,
545
+ preview: last
546
+ ? previewLine(last.body) + " · " + relTime(last.createdAt)
547
+ : relTime(ch.updatedAt),
548
+ busy: roomLiveRows("channel", ch.id).length > 0,
549
+ av: '<span class="avatar">#</span>',
550
+ }),
551
+ );
552
+ kidsOf(ch.id).forEach((kid) => walk(kid, depth + 1));
553
+ }
554
+ roots.forEach((ch) => walk(ch, 0));
555
+ all.forEach((ch) => {
556
+ if (!seen.has(ch.id) && (!ch.parentId || !byId.has(ch.parentId))) {
557
+ walk(ch, 0);
558
+ }
559
+ });
560
+ document.getElementById("channels").innerHTML = rows.length
561
+ ? rows.join("")
562
+ : '<li class="empty-list">' + escapeHtml(t("noActivity")) + "</li>";
563
+ document.getElementById("dms").innerHTML = (state.bots || [])
564
+ .map((bot) => {
565
+ const last = bot.lastMessage;
566
+ return navRow({
567
+ href: "#d/" + encodeURIComponent(bot.id),
568
+ name: bot.name,
569
+ preview: last
570
+ ? previewLine(last.body) + " · " + relTime(last.createdAt)
571
+ : "@" + bot.handle,
572
+ busy: roomLiveRows("dm", bot.id).length > 0,
573
+ av:
574
+ '<span class="avatar" style="background:' +
575
+ hue(bot.handle) +
576
+ '">' +
577
+ avatarFace(bot) +
578
+ "</span>",
579
+ });
580
+ })
581
+ .join("");
582
+ }
583
+ function renderHead() {
584
+ const title = document.getElementById("title");
585
+ const sub = document.getElementById("subtitle");
586
+ if (state.kind === "dm") {
587
+ const bot = botById(state.id);
588
+ title.textContent = bot ? bot.name : state.id;
589
+ sub.textContent = bot ? "@" + bot.handle : "";
590
+ return;
591
+ }
592
+ const ch = currentChannel();
593
+ title.textContent = ch ? "#" + ch.name : state.id;
594
+ const n = ch && ch.members ? ch.members.length : 0;
595
+ sub.textContent = n ? String(n) : "";
596
+ }
597
+ function showScreens() {
598
+ const list = !state.kind;
599
+ document.getElementById("screen-list").hidden = !list;
600
+ document.getElementById("screen-room").hidden = list;
601
+ }
602
+ async function loadWorkspace() {
603
+ const data = await fetch("/workspace").then((r) => r.json());
604
+ state.channels = data.channels || [];
605
+ state.bots = data.bots || [];
606
+ state.serverLive = Array.isArray(data.live) ? data.live : [];
607
+ renderList();
608
+ if (state.kind) renderHead();
609
+ }
610
+ async function loadMessages(opts) {
611
+ if (!state.kind) return;
612
+ const res = await fetch(roomMessagesUrl());
613
+ const body = await res.json().catch(() => []);
614
+ if (!res.ok) throw new Error(body.error || "load failed");
615
+ state.messages = Array.isArray(body) ? body : [];
616
+ renderThread({ pinBottom: !(opts && opts.merge) });
617
+ }
618
+ async function pollLive() {
619
+ if (!state.kind) return;
620
+ const url = liveUrl();
621
+ if (!url) return;
622
+ try {
623
+ const res = await fetch(url);
624
+ const body = await res.json().catch(() => ({}));
625
+ if (!res.ok) return;
626
+ const lives = liveTurnsFrom(body);
627
+ const was = state.lives.map((row) => row.botId).join(",");
628
+ const now = lives.map((row) => row.botId).join(",");
629
+ state.lives = lives;
630
+ if (!lives.length && !state.posting) {
631
+ if (was) await loadMessages({ merge: true }).catch(() => {});
632
+ else renderThread();
633
+ } else {
634
+ if (now !== was) await loadMessages({ merge: true }).catch(() => {});
635
+ else renderThread();
636
+ }
637
+ syncComposer();
638
+ } catch {
639
+ /* poll is best-effort */
640
+ }
641
+ }
642
+ async function tick() {
643
+ try {
644
+ if (!state.kind) await loadWorkspace();
645
+ else {
646
+ await pollLive();
647
+ if (!isBusy()) await loadMessages({ merge: true }).catch(() => {});
648
+ }
649
+ } finally {
650
+ schedule();
651
+ }
652
+ }
653
+ function schedule() {
654
+ clearTimeout(state.pollTimer);
655
+ const ms = !state.kind ? 2500 : isBusy() ? 900 : 2200;
656
+ state.pollTimer = setTimeout(tick, ms);
657
+ }
658
+ async function openFromHash() {
659
+ parseHash();
660
+ hideMention();
661
+ showScreens();
662
+ await loadWorkspace();
663
+ if (state.kind) {
664
+ renderHead();
665
+ await loadMessages().catch((err) => toast(err.message || String(err)));
666
+ await pollLive();
667
+ syncComposer();
668
+ const thread = document.getElementById("thread");
669
+ thread.scrollTop = thread.scrollHeight;
670
+ }
671
+ schedule();
672
+ }
673
+ function mentionAtCursor(el) {
674
+ const start = el.selectionStart || 0;
675
+ const before = el.value.slice(0, start);
676
+ const m = before.match(/(^|\s)@([A-Za-z0-9_-]*)$/);
677
+ if (!m) return null;
678
+ return { start: start - m[2].length - 1, query: m[2].toLowerCase() };
679
+ }
680
+ function mentionChoices(query) {
681
+ const needle = String(query || "").toLowerCase();
682
+ return (state.bots || []).filter((bot) => {
683
+ if (!needle) return true;
684
+ return (
685
+ bot.handle.toLowerCase().indexOf(needle) >= 0 ||
686
+ bot.name.toLowerCase().indexOf(needle) >= 0
687
+ );
688
+ });
689
+ }
690
+ function hideMention() {
691
+ document.getElementById("mention-pop").hidden = true;
692
+ }
693
+ function renderMention() {
694
+ const draft = document.getElementById("draft");
695
+ const at = mentionAtCursor(draft);
696
+ const pop = document.getElementById("mention-pop");
697
+ if (!at) {
698
+ hideMention();
699
+ return;
700
+ }
701
+ const rows = mentionChoices(at.query);
702
+ if (!rows.length) {
703
+ hideMention();
704
+ return;
705
+ }
706
+ if (state.mentionIndex >= rows.length) state.mentionIndex = 0;
707
+ if (state.mentionIndex < 0) state.mentionIndex = rows.length - 1;
708
+ document.getElementById("mention-results").innerHTML = rows
709
+ .map((bot, index) => {
710
+ const on = index === state.mentionIndex ? " on" : "";
711
+ return (
712
+ '<button type="button" class="mention-item' +
713
+ on +
714
+ '" data-mention="' +
715
+ index +
716
+ '" data-handle="' +
717
+ escapeHtml(bot.handle) +
718
+ '"><span class="mention-glyph" style="background:' +
719
+ hue(bot.handle) +
720
+ '">' +
721
+ avatarFace(bot) +
722
+ "</span><span><strong>" +
723
+ escapeHtml(bot.name) +
724
+ "</strong><em>@" +
725
+ escapeHtml(bot.handle) +
726
+ "</em></span></button>"
727
+ );
728
+ })
729
+ .join("");
730
+ pop.hidden = false;
731
+ }
732
+ function insertMention(handle) {
733
+ const draft = document.getElementById("draft");
734
+ const at = mentionAtCursor(draft);
735
+ if (!at) return;
736
+ const after = draft.value.slice(draft.selectionStart || 0);
737
+ const next = draft.value.slice(0, at.start) + "@" + handle + " " + after;
738
+ draft.value = next;
739
+ const pos = at.start + handle.length + 2;
740
+ draft.setSelectionRange(pos, pos);
741
+ hideMention();
742
+ fitDraft();
743
+ draft.focus();
744
+ }
745
+ function pickMention(index) {
746
+ const btn = document.querySelector(
747
+ '.mention-item[data-mention="' + index + '"]',
748
+ );
749
+ if (btn) insertMention(btn.getAttribute("data-handle"));
750
+ }
751
+ function fitDraft() {
752
+ const el = document.getElementById("draft");
753
+ el.style.height = "auto";
754
+ el.style.height = Math.min(el.scrollHeight, 144) + "px";
755
+ }
756
+ async function stopTurn(botId) {
757
+ try {
758
+ await fetch(abortUrl(), {
759
+ method: "POST",
760
+ headers: { "content-type": "application/json" },
761
+ body: JSON.stringify(botId ? { botId: botId } : {}),
762
+ });
763
+ } catch {
764
+ /* ignore */
765
+ }
766
+ state.lives = botId
767
+ ? state.lives.filter((row) => row.botId !== botId)
768
+ : [];
769
+ state.posting = false;
770
+ syncComposer();
771
+ renderThread();
772
+ await loadMessages().catch(() => {});
773
+ }
774
+ async function sendSteer(raw, botId) {
775
+ const text = String(raw || "").trim();
776
+ if (!text || !state.kind) return;
777
+ const payload = { body: text };
778
+ if (botId) payload.botId = botId;
779
+ else {
780
+ const ids = botsFromSend(text);
781
+ if (ids.length === 1) payload.botId = ids[0];
782
+ }
783
+ const res = await fetch(steerUrl(), {
784
+ method: "POST",
785
+ headers: { "content-type": "application/json" },
786
+ body: JSON.stringify(payload),
787
+ });
788
+ const body = await res.json().catch(() => ({}));
789
+ if (!res.ok) throw new Error(body.error || "steer failed");
790
+ await loadMessages({ merge: true }).catch(() => {});
791
+ await pollLive();
792
+ const who = body.message && body.message.steerBotId
793
+ ? botById(body.message.steerBotId)
794
+ : null;
795
+ toast(
796
+ who ? t("steer.tagTo", { who: who.name || who.handle }) : t("steer.tag"),
797
+ { ok: true, ms: 2000 },
798
+ );
799
+ }
800
+ async function steerDraft(botId) {
801
+ const draft = document.getElementById("draft");
802
+ const raw = draft.value.trim();
803
+ if (!raw) {
804
+ draft.focus();
805
+ toast(t("live.steer"), { ok: true, ms: 1400 });
806
+ return;
807
+ }
808
+ draft.value = "";
809
+ fitDraft();
810
+ hideMention();
811
+ state.messages = state.messages.concat([
812
+ {
813
+ id: "pending-steer-" + Date.now(),
814
+ author: "you",
815
+ body: raw,
816
+ createdAt: new Date().toISOString(),
817
+ },
818
+ ]);
819
+ renderThread();
820
+ try {
821
+ await sendSteer(raw, botId);
822
+ } catch (err) {
823
+ draft.value = raw;
824
+ fitDraft();
825
+ await loadMessages().catch(() => {});
826
+ toast(err.message || String(err));
827
+ }
828
+ syncComposer();
829
+ schedule();
830
+ }
831
+ async function sendDraft() {
832
+ const draft = document.getElementById("draft");
833
+ const raw = draft.value.trim();
834
+ if (!raw || !state.kind || isBusy()) return;
835
+ const botIds = botsFromSend(raw);
836
+ const payload = { body: raw };
837
+ if (botIds.length) payload.mentions = botIds;
838
+ if (botIds.length === 1) payload.assigneeId = botIds[0];
839
+ draft.value = "";
840
+ fitDraft();
841
+ hideMention();
842
+ state.messages = state.messages.concat([
843
+ {
844
+ id: "pending-you-" + Date.now(),
845
+ author: "you",
846
+ body: raw,
847
+ createdAt: new Date().toISOString(),
848
+ },
849
+ ]);
850
+ state.posting = true;
851
+ syncComposer();
852
+ renderThread();
853
+ try {
854
+ const res = await fetch(roomMessagesUrl(), {
855
+ method: "POST",
856
+ headers: { "content-type": "application/json" },
857
+ body: JSON.stringify(payload),
858
+ });
859
+ const body = await res.json().catch(() => ({}));
860
+ if (!res.ok) throw new Error(body.error || "send failed");
861
+ await loadMessages();
862
+ await pollLive();
863
+ } catch (err) {
864
+ if (err && err.name === "AbortError") return;
865
+ toast(err.message || String(err));
866
+ draft.value = raw;
867
+ fitDraft();
868
+ await loadMessages().catch(() => {});
869
+ } finally {
870
+ state.posting = false;
871
+ syncComposer();
872
+ schedule();
873
+ }
874
+ }
875
+
876
+ document.getElementById("back").addEventListener("click", () => {
877
+ showList();
878
+ });
879
+ document.getElementById("composer").addEventListener("submit", (event) => {
880
+ event.preventDefault();
881
+ sendDraft();
882
+ });
883
+ document.getElementById("stop").addEventListener("click", () => {
884
+ stopTurn();
885
+ });
886
+ document.getElementById("steer").addEventListener("click", () => {
887
+ steerDraft().catch((err) => toast(err.message || String(err)));
888
+ });
889
+ document.getElementById("thread").addEventListener("click", (event) => {
890
+ const stop = event.target.closest("[data-live-stop]");
891
+ if (stop) {
892
+ event.preventDefault();
893
+ stopTurn(stop.getAttribute("data-live-stop"));
894
+ return;
895
+ }
896
+ const steer = event.target.closest("[data-live-steer]");
897
+ if (!steer) return;
898
+ event.preventDefault();
899
+ steerDraft(steer.getAttribute("data-live-steer")).catch((err) =>
900
+ toast(err.message || String(err)),
901
+ );
902
+ });
903
+ document.getElementById("draft").addEventListener("input", () => {
904
+ fitDraft();
905
+ renderMention();
906
+ });
907
+ document.getElementById("draft").addEventListener("keydown", (event) => {
908
+ const pop = document.getElementById("mention-pop");
909
+ if (
910
+ pop.hidden &&
911
+ event.key === "Enter" &&
912
+ !event.shiftKey &&
913
+ (event.metaKey || event.ctrlKey)
914
+ ) {
915
+ if (isBusy()) {
916
+ event.preventDefault();
917
+ steerDraft().catch((err) => toast(err.message || String(err)));
918
+ }
919
+ return;
920
+ }
921
+ if (pop.hidden) return;
922
+ if (event.key === "ArrowDown") {
923
+ event.preventDefault();
924
+ state.mentionIndex += 1;
925
+ renderMention();
926
+ } else if (event.key === "ArrowUp") {
927
+ event.preventDefault();
928
+ state.mentionIndex -= 1;
929
+ renderMention();
930
+ } else if (event.key === "Enter" || event.key === "Tab") {
931
+ event.preventDefault();
932
+ pickMention(state.mentionIndex);
933
+ } else if (event.key === "Escape") {
934
+ hideMention();
935
+ }
936
+ });
937
+ document.getElementById("mention-results").addEventListener("click", (event) => {
938
+ const btn = event.target.closest("[data-handle]");
939
+ if (!btn) return;
940
+ insertMention(btn.getAttribute("data-handle"));
941
+ });
942
+ window.addEventListener("hashchange", () => {
943
+ openFromHash().catch((err) => toast(err.message || String(err)));
944
+ });
945
+ window.addEventListener("resize", fitShell);
946
+ if (window.visualViewport) {
947
+ window.visualViewport.addEventListener("resize", fitShell);
948
+ }
949
+ fitShell();
950
+ openFromHash().catch((err) => toast(err.message || String(err)));
951
+ })();
952
+ </script>
953
+ </body>
954
+ </html>