privateboard 0.1.0 → 0.1.3

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,553 @@
1
+ /* ═══════════════════════════════════════════
2
+ QUOTE CTA · selection-driven follow-up
3
+ ═══════════════════════════════════════════
4
+ When the user selects text inside a director's message bubble,
5
+ a small floating bar appears above the selection with three
6
+ actions:
7
+
8
+ ✎ Probe / 追问 → opens an overlay; user types a question;
9
+ submits a user message that quotes the
10
+ selection (markdown blockquote) above the
11
+ question. Routes through the existing send
12
+ path: idle → send, mid-turn → interrupt-or-
13
+ queue choice modal, paused → server queues
14
+ for next round.
15
+
16
+ ★ Second / 附议 → one-click; submits the same shape with a
17
+ fixed parliamentary "Seconded." line below
18
+ the quote, signalling the user co-signs the
19
+ director's point. Same routing.
20
+
21
+ ⌖ Save / 收藏 → one-click; bookmarks the selection to the
22
+ chairman's notes (POST /api/notes). No room
23
+ message is created — this is a personal
24
+ collection, not a room interaction. Works
25
+ even in adjourned rooms (re-reading a
26
+ finished session is a primary use-case).
27
+ Keyboard shortcut: `S` (when a director
28
+ selection is live).
29
+
30
+ Director scope · selection only counts when both ends sit inside
31
+ the same `article.msg` whose class is neither `user` nor `chair`.
32
+
33
+ Probe / Second ride existing /api/rooms/:id/messages. Save POSTs
34
+ to /api/notes with quote + sentence-based context + char offsets
35
+ (computed against the bubble's textContent so the in-room overlay
36
+ can re-wrap the same span on next render).
37
+ */
38
+ (function () {
39
+ let cta = null; // floating button bar
40
+ let lastSelection = null; // { text, directorId, directorName } — captured on showCTA
41
+
42
+ // ── Selection scope · is the selection inside a director bubble? ──
43
+ function getDirectorContext() {
44
+ const sel = window.getSelection ? window.getSelection() : null;
45
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
46
+ const text = sel.toString().trim();
47
+ if (text.length < 4) return null;
48
+ const range = sel.getRangeAt(0);
49
+ const anchor = sel.anchorNode;
50
+ const focus = sel.focusNode;
51
+ if (!anchor || !focus) return null;
52
+ const elFor = (n) => (n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement);
53
+ const anchorEl = elFor(anchor);
54
+ const focusEl = elFor(focus);
55
+ if (!anchorEl || !focusEl) return null;
56
+ const bubble = anchorEl.closest(".msg-bubble");
57
+ if (!bubble) return null;
58
+ const article = bubble.closest("article.msg");
59
+ if (!article) return null;
60
+ // Reject user / chair messages — feature is director-only.
61
+ if (article.classList.contains("user") || article.classList.contains("chair")) return null;
62
+ // Both ends must be in the same message · cross-message selections
63
+ // make no sense for "quote this passage from director X".
64
+ if (focusEl.closest("article.msg") !== article) return null;
65
+
66
+ // Director identity · the article carries data-author-id for
67
+ // director bubbles (added in app.js messageHtml). Name comes from
68
+ // the visible .msg-name span in the same message header.
69
+ const directorId = article.dataset.authorId || "";
70
+ const messageId = article.dataset.messageId || "";
71
+ const nameEl = article.querySelector(".msg-name");
72
+ const directorName = nameEl ? nameEl.textContent.trim() : "";
73
+ // Adjourned rooms · the room is closed for new replies (Probe /
74
+ // Second are disabled), but the user can still save notes from
75
+ // it — re-reading a finished session is a primary use case.
76
+ const app = window.app;
77
+ const adjourned = !!(app && app.currentRoom && app.currentRoom.status === "adjourned");
78
+
79
+ // Char offsets relative to bubble.textContent · let the in-room
80
+ // overlay (Step 5) wrap the same span on next render. Computed
81
+ // once here so save can fire on either the button click or the
82
+ // `S` keyboard shortcut without re-walking the DOM.
83
+ const offsets = computeOffsets(bubble, range);
84
+
85
+ return {
86
+ article, bubble, range, text, messageId,
87
+ directorId, directorName, adjourned,
88
+ charOffsetStart: offsets.start,
89
+ charOffsetEnd: offsets.end,
90
+ bubbleText: offsets.bubbleText,
91
+ };
92
+ }
93
+
94
+ // Compute the char offset of a Range's start / end relative to a
95
+ // container's textContent. Uses Range.toString().length on a
96
+ // synthetic range that spans [container start → selection point],
97
+ // which honours rendered text the same way textContent does (skips
98
+ // markup, preserves visible characters). Returns -1 / -1 if the
99
+ // walk fails (renderer falls back to no overlay).
100
+ function computeOffsets(container, range) {
101
+ const bubbleText = container.textContent || "";
102
+ try {
103
+ const before = document.createRange();
104
+ before.setStart(container, 0);
105
+ before.setEnd(range.startContainer, range.startOffset);
106
+ const start = before.toString().length;
107
+ const inner = document.createRange();
108
+ inner.setStart(range.startContainer, range.startOffset);
109
+ inner.setEnd(range.endContainer, range.endOffset);
110
+ const end = start + inner.toString().length;
111
+ return { start, end, bubbleText };
112
+ } catch {
113
+ return { start: -1, end: -1, bubbleText };
114
+ }
115
+ }
116
+
117
+ // Sentence-based context expansion · grabs ~1–2 sentences on each
118
+ // side of the quote (capped at MAX_CHARS). Honours both ASCII
119
+ // (.!?) and CJK (。!?) sentence terminators. Falls back to the
120
+ // char cap if no boundary is found within the cap window.
121
+ function expandContext(fullText, quoteStart, quoteEnd) {
122
+ if (!fullText || quoteStart < 0 || quoteEnd < quoteStart) {
123
+ return { before: "", after: "" };
124
+ }
125
+ const MAX_CHARS = 200;
126
+ const SENTENCE_END = /[.!?。!?]/;
127
+ let beforeStart = Math.max(0, quoteStart - MAX_CHARS);
128
+ for (let i = quoteStart - 1; i >= beforeStart; i--) {
129
+ if (SENTENCE_END.test(fullText[i])) {
130
+ beforeStart = Math.min(i + 1, quoteStart);
131
+ break;
132
+ }
133
+ }
134
+ let afterEnd = Math.min(fullText.length, quoteEnd + MAX_CHARS);
135
+ for (let i = quoteEnd; i < afterEnd; i++) {
136
+ if (SENTENCE_END.test(fullText[i])) {
137
+ afterEnd = Math.min(i + 1, fullText.length);
138
+ break;
139
+ }
140
+ }
141
+ return {
142
+ before: fullText.slice(beforeStart, quoteStart),
143
+ after: fullText.slice(quoteEnd, afterEnd),
144
+ };
145
+ }
146
+
147
+ function lang() {
148
+ try {
149
+ if (window.app && typeof window.app.composerLanguage === "function") {
150
+ return window.app.composerLanguage();
151
+ }
152
+ } catch { /* */ }
153
+ return "en";
154
+ }
155
+
156
+ // ── Floating CTA bar ─────────────────────────────────────────
157
+ function ensureCTA() {
158
+ if (cta) return cta;
159
+ cta = document.createElement("div");
160
+ cta.className = "qcta";
161
+ cta.setAttribute("role", "toolbar");
162
+ const t = lang() === "zh"
163
+ ? { ask: "追问", love: "附议", save: "收藏" }
164
+ : { ask: "Probe", love: "Second", save: "Save" };
165
+ // Inline chat-bubble SVG · uses currentColor so it inherits the
166
+ // hover lime / base text colour like the ★ glyph does.
167
+ const askIcon = `
168
+ <svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="miter" stroke-linecap="square" aria-hidden="true">
169
+ <path d="M2 3 H12 V9 H6.5 L4 11 L4 9 H2 Z"/>
170
+ </svg>
171
+ `;
172
+ // Bookmark glyph · matches the All Notes sidebar entry's icon
173
+ // semantics (this action lands in that view).
174
+ const saveIcon = `
175
+ <svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="miter" stroke-linecap="square" aria-hidden="true">
176
+ <path d="M3.5 1.5 H10.5 V12.5 L7 9.5 L3.5 12.5 Z"/>
177
+ </svg>
178
+ `;
179
+ cta.innerHTML = `
180
+ <button type="button" class="qcta-btn" data-qcta="ask">
181
+ <span class="ico">${askIcon}</span><span>${t.ask}</span>
182
+ </button>
183
+ <button type="button" class="qcta-btn" data-qcta="second">
184
+ <span class="ico">★</span><span>${t.love}</span>
185
+ </button>
186
+ <button type="button" class="qcta-btn qcta-btn-save" data-qcta="save" title="Save to Notes · S">
187
+ <span class="ico">${saveIcon}</span><span>${t.save}</span>
188
+ </button>
189
+ <span class="qcta-hint" data-qcta-hint></span>
190
+ `;
191
+ // Prevent the bar's mousedown from collapsing the selection BEFORE
192
+ // the click reaches us — Chrome/Safari clear the selection on a
193
+ // click outside the active range, which would defeat the bar.
194
+ cta.addEventListener("mousedown", (e) => e.preventDefault());
195
+ cta.addEventListener("click", (e) => {
196
+ const btn = e.target.closest("[data-qcta]");
197
+ if (!btn) return;
198
+ const action = btn.getAttribute("data-qcta");
199
+ // Read-only state (adjourned rooms) blocks Probe / Second since
200
+ // they post messages to a closed room. Save is exempt — the
201
+ // user is bookmarking for personal review, not interacting.
202
+ if (cta.classList.contains("qcta-readonly") && action !== "save") return;
203
+ const sel = lastSelection;
204
+ hideCTA();
205
+ if (!sel || !sel.text) return;
206
+ if (action === "ask") openAskOverlay(sel);
207
+ else if (action === "second") submitSecond(sel);
208
+ else if (action === "save") submitSave(sel);
209
+ });
210
+ document.body.appendChild(cta);
211
+ return cta;
212
+ }
213
+
214
+ function showCTA(ctx) {
215
+ const bar = ensureCTA();
216
+ lastSelection = {
217
+ text: ctx.text,
218
+ directorId: ctx.directorId,
219
+ directorName: ctx.directorName,
220
+ messageId: ctx.messageId,
221
+ charOffsetStart: ctx.charOffsetStart,
222
+ charOffsetEnd: ctx.charOffsetEnd,
223
+ bubbleText: ctx.bubbleText,
224
+ };
225
+ // Read-only state · adjourned room. Hide Probe / Second (they
226
+ // post to a closed room); Save stays available — review-mode
227
+ // bookmarking is a primary use case for adjourned sessions.
228
+ bar.classList.toggle("qcta-readonly", !!ctx.adjourned);
229
+ const hint = bar.querySelector("[data-qcta-hint]");
230
+ if (hint) {
231
+ hint.textContent = ctx.adjourned
232
+ ? (lang() === "zh" ? "// 已结束的房间 · 只读" : "// adjourned · read-only")
233
+ : "";
234
+ }
235
+ const rect = ctx.range.getBoundingClientRect();
236
+ if (rect.width === 0 && rect.height === 0) return;
237
+ // Render first so we can measure width.
238
+ bar.classList.add("open");
239
+ const barWidth = bar.offsetWidth;
240
+ const barHeight = bar.offsetHeight;
241
+ let top = window.scrollY + rect.top - barHeight - 8;
242
+ if (top < window.scrollY + 4) {
243
+ // Not enough room above · drop below the selection.
244
+ top = window.scrollY + rect.bottom + 8;
245
+ }
246
+ let left = window.scrollX + rect.left + rect.width / 2 - barWidth / 2;
247
+ left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - barWidth - 8));
248
+ bar.style.top = top + "px";
249
+ bar.style.left = left + "px";
250
+ }
251
+
252
+ function hideCTA() {
253
+ if (cta) cta.classList.remove("open");
254
+ }
255
+
256
+ function refresh() {
257
+ // Skip when the ask overlay is open — don't stack a CTA on top
258
+ // of the modal's own selection.
259
+ if (document.getElementById("qask-overlay")) { hideCTA(); return; }
260
+ const ctx = getDirectorContext();
261
+ if (!ctx) { hideCTA(); return; }
262
+ showCTA(ctx);
263
+ }
264
+
265
+ // Show only AFTER the user finishes selecting · hide as soon as a
266
+ // new mousedown begins so the bar doesn't track mid-drag. Skip the
267
+ // mousedown-hide when the click originates inside the CTA itself
268
+ // (otherwise clicking a button hides the bar before the click
269
+ // handler can read the action).
270
+ document.addEventListener("mousedown", (e) => {
271
+ if (cta && cta.contains(e.target)) return;
272
+ hideCTA();
273
+ });
274
+ document.addEventListener("mouseup", () => {
275
+ // Defer one tick so the browser has finished updating the
276
+ // selection before we measure it.
277
+ requestAnimationFrame(refresh);
278
+ });
279
+
280
+ // Hide on scroll · the absolute-positioned bar would otherwise
281
+ // float in stale coords as the chat pane scrolls.
282
+ window.addEventListener("scroll", hideCTA, true);
283
+ document.addEventListener("keydown", (e) => {
284
+ if (e.key === "Escape") hideCTA();
285
+
286
+ // `S` shortcut · save current selection to Notes. Only fires
287
+ // when (a) a director-scoped selection is live, (b) no modifier
288
+ // keys are pressed (Cmd/Ctrl/Alt would clobber browser
289
+ // shortcuts), (c) the user isn't typing into an input. Skipping
290
+ // when an input/textarea is focused avoids hijacking the `s`
291
+ // key during composer typing — the qcta bar wouldn't have
292
+ // shown for a non-director selection anyway.
293
+ if ((e.key === "s" || e.key === "S")
294
+ && !e.metaKey && !e.ctrlKey && !e.altKey
295
+ && !isEditableTarget(e.target)
296
+ && lastSelection
297
+ && lastSelection.text
298
+ && cta && cta.classList.contains("open")) {
299
+ e.preventDefault();
300
+ const sel = lastSelection;
301
+ hideCTA();
302
+ submitSave(sel);
303
+ }
304
+ });
305
+
306
+ function isEditableTarget(node) {
307
+ if (!node) return false;
308
+ const tag = (node.tagName || "").toLowerCase();
309
+ if (tag === "input" || tag === "textarea") return true;
310
+ if (node.isContentEditable) return true;
311
+ return false;
312
+ }
313
+
314
+ // ── Ask-follow-up overlay ────────────────────────────────────
315
+ function openAskOverlay(sel) {
316
+ closeAskOverlay();
317
+ const dirName = sel.directorName || "director";
318
+ const t = lang() === "zh"
319
+ ? {
320
+ tag: "▸ 追问",
321
+ quoteTag: "// 引自 " + dirName,
322
+ placeholder: "把你想问的写在这里 · 回车发送,Shift+Enter 换行",
323
+ send: "发送",
324
+ cancel: "取消",
325
+ status: "选区追问 · " + dirName,
326
+ }
327
+ : {
328
+ tag: "▸ Probe",
329
+ quoteTag: "// quoting " + dirName,
330
+ placeholder: "Type your follow-up · Enter to send, Shift+Enter for newline",
331
+ send: "Send",
332
+ cancel: "Cancel",
333
+ status: "selection · probing " + dirName,
334
+ };
335
+ const overlay = document.createElement("div");
336
+ overlay.className = "qask-overlay";
337
+ overlay.id = "qask-overlay";
338
+ overlay.innerHTML = `
339
+ <div class="qask-modal" role="dialog" aria-modal="true">
340
+ <div class="qask-classification">
341
+ <span><span class="dot">●</span> ${t.status}</span>
342
+ <span class="right">${t.tag}</span>
343
+ </div>
344
+ <div class="qask-body">
345
+ <div class="qask-quote">
346
+ <div class="qask-quote-tag">${t.quoteTag}</div>
347
+ <div class="qask-quote-body" data-qask-quote></div>
348
+ </div>
349
+ <div class="qask-input-wrap">
350
+ <textarea class="qask-input" data-qask-input rows="3" placeholder="${escapeAttr(t.placeholder)}"></textarea>
351
+ </div>
352
+ </div>
353
+ <div class="qask-foot">
354
+ <button type="button" class="qask-btn" data-qask-cancel>${t.cancel}</button>
355
+ <button type="button" class="qask-btn primary" data-qask-send disabled>${t.send}</button>
356
+ </div>
357
+ </div>
358
+ `;
359
+ document.body.appendChild(overlay);
360
+ overlay.querySelector("[data-qask-quote]").textContent = sel.text;
361
+ const input = overlay.querySelector("[data-qask-input]");
362
+ const sendBtn = overlay.querySelector("[data-qask-send]");
363
+ setTimeout(() => input.focus(), 30);
364
+ input.addEventListener("input", () => {
365
+ sendBtn.disabled = input.value.trim().length === 0;
366
+ });
367
+ input.addEventListener("keydown", (e) => {
368
+ if (e.key === "Enter" && !e.shiftKey && !isImeComposing(e)) {
369
+ e.preventDefault();
370
+ if (!sendBtn.disabled) sendBtn.click();
371
+ } else if (e.key === "Escape") {
372
+ e.preventDefault();
373
+ closeAskOverlay();
374
+ }
375
+ });
376
+ overlay.addEventListener("click", (e) => {
377
+ if (e.target === overlay) closeAskOverlay();
378
+ });
379
+ overlay.querySelector("[data-qask-cancel]").addEventListener("click", closeAskOverlay);
380
+ sendBtn.addEventListener("click", () => {
381
+ const text = input.value.trim();
382
+ if (!text) return;
383
+ closeAskOverlay();
384
+ submitProbe(sel, text);
385
+ });
386
+ }
387
+
388
+ function closeAskOverlay() {
389
+ const el = document.getElementById("qask-overlay");
390
+ if (el) el.remove();
391
+ }
392
+
393
+ function isImeComposing(e) {
394
+ return !!(e.isComposing || (e.nativeEvent && e.nativeEvent.isComposing) || e.keyCode === 229);
395
+ }
396
+
397
+ function escapeAttr(s) {
398
+ return String(s).replace(/[&<>"']/g, (c) => ({
399
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
400
+ }[c]));
401
+ }
402
+
403
+ // ── Submit ──────────────────────────────────────────────────
404
+ // Quote prefix · markdown blockquote, one "> " per line so multi-
405
+ // line selections stay inside the quote when rendered by app.js's
406
+ // markdown-ish renderBody. The attribution line (`> — @Director`)
407
+ // sits inside the same blockquote so chair / readers see at a
408
+ // glance who the user is quoting.
409
+ function quoteBlock(text, directorName) {
410
+ const lines = text.split(/\r?\n/).map((line) => "> " + line);
411
+ if (directorName) lines.push("> — @" + directorName);
412
+ return lines.join("\n");
413
+ }
414
+
415
+ function submitSecond(sel) {
416
+ // Parliamentary acknowledgement · "I second this." · short and
417
+ // ceremonial, matches the boardroom motif. No mentions array — a
418
+ // second is a passive signal, not a question; the room continues
419
+ // its normal cadence rather than forcing the seconded director
420
+ // to immediately speak again.
421
+ const reaction = lang() === "zh" ? "附议。" : "Seconded.";
422
+ const body = quoteBlock(sel.text, sel.directorName) + "\n\n" + reaction;
423
+ routeSend(body, []);
424
+ }
425
+
426
+ function submitProbe(sel, userText) {
427
+ // Probe targets the quoted director · putting their id first in
428
+ // mentions makes them the forced speaker for the next tick (per
429
+ // tickRoom in src/orchestrator/room.ts), so the user's follow-up
430
+ // gets answered by the right voice, not whoever's next in the
431
+ // round-robin.
432
+ const body = quoteBlock(sel.text, sel.directorName) + "\n\n" + userText;
433
+ const mentions = sel.directorId ? [sel.directorId] : [];
434
+ routeSend(body, mentions);
435
+ }
436
+
437
+ // ── Save to Notes ─────────────────────────────────────────────
438
+ // POST /api/notes with quote + sentence-based context + char
439
+ // offsets. No room interaction — this is a personal bookmark.
440
+ async function submitSave(sel) {
441
+ const app = window.app;
442
+ const room = app && app.currentRoom;
443
+ if (!room || !room.id) {
444
+ toast(lang() === "zh" ? "无法保存:未打开房间" : "Can't save: no room open", "error");
445
+ return;
446
+ }
447
+ if (!sel.messageId) {
448
+ toast(lang() === "zh" ? "无法保存:未识别原文位置" : "Can't save: source not identified", "error");
449
+ return;
450
+ }
451
+ const ctx = expandContext(
452
+ sel.bubbleText || "",
453
+ typeof sel.charOffsetStart === "number" ? sel.charOffsetStart : -1,
454
+ typeof sel.charOffsetEnd === "number" ? sel.charOffsetEnd : -1,
455
+ );
456
+ const payload = {
457
+ roomId: room.id,
458
+ messageId: sel.messageId,
459
+ quoteText: sel.text,
460
+ contextBefore: ctx.before,
461
+ contextAfter: ctx.after,
462
+ charOffsetStart: sel.charOffsetStart,
463
+ charOffsetEnd: sel.charOffsetEnd,
464
+ authorName: sel.directorName,
465
+ };
466
+ try {
467
+ const res = await fetch("/api/notes", {
468
+ method: "POST",
469
+ headers: { "Content-Type": "application/json" },
470
+ body: JSON.stringify(payload),
471
+ });
472
+ if (!res.ok) {
473
+ const j = await res.json().catch(() => ({}));
474
+ throw new Error(j.error || ("HTTP " + res.status));
475
+ }
476
+ const note = await res.json();
477
+ toast(lang() === "zh" ? "已收藏到笔记" : "Saved to Notes", "ok");
478
+
479
+ // Tell the rest of the app a note was created · sidebar badge
480
+ // refreshes its count, in-room overlay (Step 5) wraps the
481
+ // saved span. Listeners that don't exist yet are no-ops.
482
+ try {
483
+ document.dispatchEvent(new CustomEvent("note:created", { detail: { note } }));
484
+ } catch { /* */ }
485
+ } catch (err) {
486
+ toast(
487
+ (lang() === "zh" ? "保存失败:" : "Save failed: ") + (err && err.message ? err.message : err),
488
+ "error",
489
+ );
490
+ }
491
+ }
492
+
493
+ // Lightweight toast · the app already has `app.notify(...)` in
494
+ // some paths but not all; using a self-contained one keeps this
495
+ // module independent. Lime for ok, red-tinted for error. Auto-
496
+ // dismisses after 1.8s; click to dismiss early.
497
+ let toastEl = null;
498
+ let toastTimer = null;
499
+ function toast(msg, kind) {
500
+ if (!toastEl) {
501
+ toastEl = document.createElement("div");
502
+ toastEl.className = "qcta-toast";
503
+ toastEl.addEventListener("click", () => toastEl.classList.remove("open"));
504
+ document.body.appendChild(toastEl);
505
+ }
506
+ toastEl.classList.remove("kind-ok", "kind-error");
507
+ toastEl.classList.add("kind-" + (kind === "error" ? "error" : "ok"));
508
+ toastEl.textContent = msg;
509
+ toastEl.classList.add("open");
510
+ if (toastTimer) clearTimeout(toastTimer);
511
+ toastTimer = setTimeout(() => {
512
+ if (toastEl) toastEl.classList.remove("open");
513
+ }, 1800);
514
+ }
515
+
516
+ /** Routing matrix:
517
+ * paused → auto-resume the room first, then send
518
+ * live + agent mid-turn → open the interrupt-or-queue modal
519
+ * live + idle → send straight through
520
+ *
521
+ * Auto-resume on paused matches the user intent: they wrote a
522
+ * follow-up, they expect it to land. The server rejects POST
523
+ * /messages on paused rooms (409 "room is not live"), so without
524
+ * this the probe button silently fails on every paused room. */
525
+ async function routeSend(body, mentions) {
526
+ const app = window.app;
527
+ if (!app || typeof app.sendMessage !== "function") return;
528
+ const ms = Array.isArray(mentions) ? mentions : [];
529
+ const status = app.currentRoom && app.currentRoom.status;
530
+ try {
531
+ if (status === "paused" && typeof app.resumeRoom === "function") {
532
+ await app.resumeRoom();
533
+ }
534
+ } catch (err) {
535
+ alert("Couldn't resume the room: " + (err && err.message ? err.message : err));
536
+ return;
537
+ }
538
+ if (typeof app.isAgentSpeaking === "function" && app.isAgentSpeaking() && !app.pendingUserMessage) {
539
+ if (typeof app.openSendChoiceModal === "function") {
540
+ // openSendChoiceModal currently doesn't carry a mentions array
541
+ // — the existing modal was built for the plain composer where
542
+ // mentions are inferred from "@" tokens in the text. Our
543
+ // attributed body already contains "@Director" inline, so the
544
+ // server's text-based @mention path will catch it.
545
+ app.openSendChoiceModal(body);
546
+ return;
547
+ }
548
+ }
549
+ app.sendMessage(body, ms).catch((err) => {
550
+ alert("Send failed: " + (err && err.message ? err.message : err));
551
+ });
552
+ }
553
+ })();