privateboard 0.1.37 → 0.1.40

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.
Files changed (76) hide show
  1. package/dist/boot.js +1415 -91
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +1415 -91
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +1271 -81
  6. package/dist/server.js.map +1 -1
  7. package/dist/version.d.ts +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/version.js.map +1 -1
  10. package/package.json +1 -1
  11. package/public/__avatar3d_test.html +156 -0
  12. package/public/adjourn-overlay.css +2 -2
  13. package/public/agent-overlay.css +27 -15
  14. package/public/agent-overlay.js +3 -1
  15. package/public/agent-profile.css +331 -41
  16. package/public/agent-profile.js +499 -75
  17. package/public/app-updater.css +1 -1
  18. package/public/app.js +2090 -547
  19. package/public/avatar-3d-snap.js +205 -0
  20. package/public/avatar-3d.js +792 -0
  21. package/public/avatar-customizer.html +274 -0
  22. package/public/avatar3d-editor.css +240 -0
  23. package/public/avatar3d-editor.js +481 -0
  24. package/public/avatars/3d/chair.png +0 -0
  25. package/public/avatars/3d/first-principles.png +0 -0
  26. package/public/avatars/3d/historian.png +0 -0
  27. package/public/avatars/3d/long-horizon.png +0 -0
  28. package/public/avatars/3d/phenomenologist.png +0 -0
  29. package/public/avatars/3d/socrates.png +0 -0
  30. package/public/avatars/3d/user-empathy.png +0 -0
  31. package/public/avatars/3d/value-investor.png +0 -0
  32. package/public/core-avatars.js +86 -0
  33. package/public/home-3d-loader.js +15 -4
  34. package/public/home-3d-mock.js +18 -7
  35. package/public/home.html +80 -18
  36. package/public/i18n.js +279 -4
  37. package/public/icons/avatar_1779855104027.glb +0 -0
  38. package/public/icons/logo.png +0 -0
  39. package/public/icons/new-style.glb +0 -0
  40. package/public/icons/new-style2.glb +0 -0
  41. package/public/icons/new-style3.glb +0 -0
  42. package/public/icons/new-style4.glb +0 -0
  43. package/public/icons/new-style5.glb +0 -0
  44. package/public/icons/office.glb +0 -0
  45. package/public/icons/stuff.glb +0 -0
  46. package/public/index.html +203 -182
  47. package/public/mention-picker.js +1 -1
  48. package/public/new-agent.css +7 -7
  49. package/public/new-agent.js +46 -20
  50. package/public/office-viewer.html +340 -0
  51. package/public/onboarding.css +5 -5
  52. package/public/quote-cta.css +5 -4
  53. package/public/quote-cta.js +50 -5
  54. package/public/room-settings.css +24 -9
  55. package/public/stuff-viewer.html +330 -0
  56. package/public/thread.css +1211 -0
  57. package/public/user-settings.css +16 -19
  58. package/public/user-settings.js +86 -78
  59. package/public/vendor/BufferGeometryUtils.js +1434 -0
  60. package/public/vendor/DRACOLoader.js +739 -0
  61. package/public/vendor/GLTFLoader.js +4860 -0
  62. package/public/vendor/RoomEnvironment.js +185 -0
  63. package/public/vendor/SkeletonUtils.js +496 -0
  64. package/public/vendor/draco/draco_decoder.js +34 -0
  65. package/public/vendor/draco/draco_decoder.wasm +0 -0
  66. package/public/vendor/draco/draco_encoder.js +33 -0
  67. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  68. package/public/vendor/meshopt_decoder.module.js +196 -0
  69. package/public/voice-3d-banner.js +12 -0
  70. package/public/voice-3d.js +1407 -432
  71. package/public/voice-clone.css +875 -0
  72. package/public/voice-clone.js +1351 -0
  73. package/public/voice-replay.css +3 -3
  74. package/public/voice-replay.js +21 -0
  75. package/public/avatar-skill.js +0 -629
  76. package/public/icons/folded-sidebar.png +0 -0
package/public/app.js CHANGED
@@ -343,6 +343,34 @@
343
343
  : (btn.getAttribute("data-more") || this._t("convene_show_more"));
344
344
  btn.textContent = label;
345
345
  });
346
+
347
+ // Thread trigger · per-bubble "// thread" link on each director
348
+ // message. Doc-level delegate so it picks up bubbles rendered
349
+ // both at chat first paint AND via every message-appended /
350
+ // renderChat invalidation.
351
+ document.addEventListener("click", (e) => {
352
+ const btn = e.target.closest("[data-thread-trigger]");
353
+ if (!btn) return;
354
+ e.preventDefault();
355
+ e.stopPropagation();
356
+ const directorId = btn.getAttribute("data-thread-trigger");
357
+ if (directorId) this.openThreadWith(directorId);
358
+ });
359
+
360
+ // Threads-list popover trigger · the room head's "all threads"
361
+ // icon. Click toggles the popover open/closed; click outside
362
+ // dismisses (handled inside openThreadsListPopover).
363
+ document.addEventListener("click", (e) => {
364
+ const btn = e.target.closest("[data-threads-trigger]");
365
+ if (!btn) return;
366
+ e.preventDefault();
367
+ e.stopPropagation();
368
+ if (document.getElementById("thread-list-pop")) {
369
+ this.closeThreadsListPopover();
370
+ } else {
371
+ this.openThreadsListPopover(btn);
372
+ }
373
+ });
346
374
  // Live-subtitle minimize / restore · attached in CAPTURE phase
347
375
  // so it wins ahead of any outside-click handlers (picker dismiss
348
376
  // / overlay close) that might `stopPropagation` and swallow the
@@ -538,6 +566,17 @@
538
566
  // currentRoomId). Stage repaint still bails out when no
539
567
  // room is open, since the stage DOM isn't mounted there.
540
568
  this.refreshMiniPlayer();
569
+ // The Record button is offered while a replay runs (adjourned
570
+ // rooms), so re-render the header when the replay open-state
571
+ // flips. Guarded so we don't rebuild the header on every
572
+ // per-message item transition — only on open ↔ close.
573
+ const replOpen = this._isReplayActiveForRoom(this.currentRoomId);
574
+ if (replOpen !== this._lastReplayHeaderState) {
575
+ this._lastReplayHeaderState = replOpen;
576
+ if (this.currentRoomId && typeof this.renderHeader === "function") {
577
+ this.renderHeader();
578
+ }
579
+ }
541
580
  if (!this.currentRoomId) return;
542
581
  this.renderRoundTable();
543
582
  });
@@ -1089,6 +1128,30 @@
1089
1128
  return;
1090
1129
  }
1091
1130
 
1131
+ // Thread guard · the main room view is not the right surface
1132
+ // for a thread (it expects a chair, multiple directors, the
1133
+ // full round / vote / brief pipeline). If the URL hash routed
1134
+ // to a thread (manual paste, stale link), redirect to the
1135
+ // parent and surface the thread as a float window so the user
1136
+ // lands somewhere coherent.
1137
+ if (data.room && data.room.kind === "thread") {
1138
+ const parentId = data.room.parentRoomId;
1139
+ if (parentId) {
1140
+ // Navigate to parent and re-mount the thread window.
1141
+ location.hash = "#/r/" + encodeURIComponent(parentId);
1142
+ if (data.room.threadDirectorId) {
1143
+ // Defer until after the route handler settles into the
1144
+ // parent so currentRoomId / agentsById are populated.
1145
+ setTimeout(() => {
1146
+ this.openThreadWith(data.room.threadDirectorId);
1147
+ }, 200);
1148
+ }
1149
+ } else {
1150
+ this.closeRoom();
1151
+ }
1152
+ return;
1153
+ }
1154
+
1092
1155
  // We may be coming from the All Reports view OR an agent profile
1093
1156
  // — make sure the room main view is the visible one and the
1094
1157
  // others are hidden. The agent-view hide is what stops a stale
@@ -1296,6 +1359,12 @@
1296
1359
  console.error("[openRoom] renderRoom failed:", err);
1297
1360
  }
1298
1361
  this.markActiveRoom(roomId);
1362
+ // Restore any thread float windows the user had open for this
1363
+ // room before the last page reload · the threads themselves
1364
+ // live in the DB; this just rebuilds the floating UI for the
1365
+ // ones the user had visible. Fire-and-forget · failure here
1366
+ // never blocks room load.
1367
+ this.restoreThreadsForRoom(roomId);
1299
1368
  // Round-table stage · paint + decide whether the .chat or
1300
1369
  // .roundtable-stage should be visible for this room. Runs
1301
1370
  // AFTER renderRoom so the DOM exists and currentRoom /
@@ -1369,6 +1438,19 @@
1369
1438
  },
1370
1439
 
1371
1440
  async closeRoom() {
1441
+ // Thread cleanup · route through `closeThreadWindow` so the
1442
+ // localStorage persistence entry is dropped too. Per the
1443
+ // updated UX rule, leaving a room means the thread chat is
1444
+ // CLOSED — coming back to the same room later should land
1445
+ // on a clean main view, not auto-restore an old thread.
1446
+ // closeThreadWindow handles DOM, SSE, body.has-thread-dock,
1447
+ // and `pb.thread.open.<parentRoomId>` cleanup in one place.
1448
+ if (this._threads) {
1449
+ for (const tid of Object.keys(this._threads)) {
1450
+ this.closeThreadWindow(tid);
1451
+ }
1452
+ }
1453
+ document.body.classList.remove("has-thread-dock");
1372
1454
  // Recording guard · if a meeting capture is in progress for
1373
1455
  // the current room, intercept and let the user choose:
1374
1456
  // stop-and-save before leaving or cancel and stay. We re-route
@@ -2099,6 +2181,26 @@
2099
2181
  this._pendingAdjournAfterRecordStop = false;
2100
2182
  try { this.adjournRoom({ skipBrief: true }); }
2101
2183
  catch (e) { console.warn("[recorder] pending adjourn failed", e); }
2184
+ } else if (
2185
+ // Vanilla pause-after-current path · the user clicked
2186
+ // "Pause after current director" while a recording was
2187
+ // running. Without an explicit prompt the dialog would
2188
+ // never appear and the recording would keep capturing
2189
+ // a silent room. Surface a save dialog so the user can
2190
+ // commit the clip + optionally adjourn before audio
2191
+ // chunks of dead air pile up. Skip when:
2192
+ // · pauseMode === "hard" · interrupt path (user
2193
+ // already saw the choice modal)
2194
+ // · _pendingAdjournAfterRecordStop · handled above
2195
+ // · _pendingReloadOnPause · user picked reload
2196
+ // · recording-save modal already on screen (defensive)
2197
+ payload.mode === "soft"
2198
+ && !this._pendingReloadOnPause
2199
+ && window.BoardroomRecorder
2200
+ && window.BoardroomRecorder.isRecording()
2201
+ && !document.getElementById("rec-pause-save-overlay")
2202
+ ) {
2203
+ this.openRecordingPauseSaveModal();
2102
2204
  }
2103
2205
  } else if (kind === "room-resumed") {
2104
2206
  if (this.currentRoom) {
@@ -5147,6 +5249,1296 @@
5147
5249
  }
5148
5250
  },
5149
5251
 
5252
+ /* ─── Private thread · 1:1 director aside (Phase 1) ───────
5253
+ The user pulls a single director into a Slack-DM-style floating
5254
+ window for a private conversation. Persists as its own `rooms`
5255
+ row (kind="thread") with parent_room_id pointing here, so:
5256
+ · subscribe to its own SSE stream
5257
+ · messages flow through the existing POST /messages route
5258
+ · the orchestrator's buildDirectorContext detects kind==="thread"
5259
+ and seeds the LLM with the parent room's snapshot
5260
+ Storage / drag-window mounting / SSE plumbing all live in
5261
+ these methods. State map below tracks the active windows by
5262
+ threadId so subsequent opens reuse existing windows. */
5263
+
5264
+ /** SVG icon string · shared by the per-bubble trigger and the
5265
+ * head-cast badge. Lucide-style "reply" curve (arrow back to
5266
+ * the speaker) reads as IM-grammar for "respond privately". */
5267
+ _threadTriggerIconSvg() {
5268
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 17l-5-5 5-5"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>`;
5269
+ },
5270
+
5271
+ /** Discord-style floating message toolbar · appears on article
5272
+ * hover at the top-right of the bubble. Pure icons (no labels)
5273
+ * to keep the toolbar compact; the icon vocabulary is small and
5274
+ * reads instantly on hover. Future per-message actions (quote,
5275
+ * react, pin) slot as additional buttons inside the same
5276
+ * toolbar chrome. Returns "" when no actions apply (user / chair
5277
+ * / excused / thread room / adjourned). */
5278
+ _messageToolbarHtml(m, isUser, isChair, excused, author) {
5279
+ const trigger = this._threadTriggerHtml(m, isUser, isChair, excused, author);
5280
+ if (!trigger) return "";
5281
+ return `<div class="msg-toolbar">${trigger}</div>`;
5282
+ },
5283
+
5284
+ /** Reply chip · icon + "Thread" label as a compact pill. Single
5285
+ * slot inside the Discord-style hover toolbar. Returns "" when
5286
+ * threading doesn't apply (see `_messageToolbarHtml`'s wrapper). */
5287
+ _threadTriggerHtml(m, isUser, isChair, excused, author) {
5288
+ if (isUser || isChair || excused) return "";
5289
+ if (!author || !author.id) return "";
5290
+ // Skip when rendering inside a thread window · `_threadMessageHtml`
5291
+ // flips this flag for the duration of its messageHtml call so
5292
+ // thread bubbles don't ship a "open another thread with this
5293
+ // same director" trigger (which already exists · the user is
5294
+ // looking at that thread right now).
5295
+ if (this._renderingThread) return "";
5296
+ const room = this.currentRoom;
5297
+ // Threads can be spawned from a room in any status · live,
5298
+ // paused, OR adjourned. Re-reading an adjourned session and
5299
+ // wanting to follow up with one director privately is a real
5300
+ // use case ("I want to go deeper on what Socrates said in that
5301
+ // meeting from last week"). The thread itself is its own
5302
+ // independent room — the parent's status doesn't constrain it.
5303
+ // Only suppress when there's no room loaded OR we're already
5304
+ // inside a thread (no nested threads).
5305
+ if (!room) return "";
5306
+ if (room.kind === "thread") return "";
5307
+ const label = this._t("thread_trigger_label") || "Thread";
5308
+ const tip = this._t("thread_trigger_tip", { name: author.name || "" })
5309
+ || `Pull ${author.name || "this director"} aside privately`;
5310
+ return `<button type="button" class="msg-thread-trigger" data-thread-trigger="${this.escape(author.id)}" title="${this.escape(tip)}" aria-label="${this.escape(tip)}">${this._threadTriggerIconSvg()}<span>${this.escape(label)}</span></button>`;
5311
+ },
5312
+
5313
+ async openThreadWith(directorId) {
5314
+ if (!this.currentRoomId || !directorId) return;
5315
+ // De-dupe · if a thread window for (this room, this director)
5316
+ // already exists, just restore it. Avoids spawning a duplicate
5317
+ // DB row + duplicate SSE stream when the user clicks the
5318
+ // "thread" trigger repeatedly.
5319
+ this._threads = this._threads || {};
5320
+ for (const [tid, t] of Object.entries(this._threads)) {
5321
+ if (t.director && t.director.id === directorId) {
5322
+ this.restoreThreadWindow(tid);
5323
+ return;
5324
+ }
5325
+ }
5326
+ // In-flight create guard · the de-dupe above misses the race
5327
+ // where the user double-clicks (or two trigger buttons for the
5328
+ // same director fire concurrently): both clicks see _threads
5329
+ // empty, both POST, server creates TWO thread rows, both windows
5330
+ // mount, the user perceives "the app keeps spawning new rooms."
5331
+ // Track creates per-director with a Set so the second click
5332
+ // becomes a no-op until the first POST resolves.
5333
+ this._threadsCreating = this._threadsCreating || new Set();
5334
+ if (this._threadsCreating.has(directorId)) return;
5335
+ this._threadsCreating.add(directorId);
5336
+ // Capture the parent roomId at click time · if the user
5337
+ // navigates to a different room while the POST is in flight,
5338
+ // we still want the thread to attach to the room the click
5339
+ // originated from, not the room currently showing.
5340
+ const parentRoomId = this.currentRoomId;
5341
+ try {
5342
+ const res = await fetch(`/api/rooms/${encodeURIComponent(parentRoomId)}/threads`, {
5343
+ method: "POST",
5344
+ headers: { "content-type": "application/json" },
5345
+ body: JSON.stringify({ directorId }),
5346
+ });
5347
+ if (!res.ok) {
5348
+ const err = await res.json().catch(() => ({}));
5349
+ alert((err && err.error) || "Failed to open thread");
5350
+ return;
5351
+ }
5352
+ const data = await res.json();
5353
+ const director = (this.agentsById && this.agentsById[directorId])
5354
+ || (data.members || []).find((m) => m.id === directorId)
5355
+ || { id: directorId, name: directorId, avatarPath: "" };
5356
+ // Default to docked mode · the user explicitly asked for the
5357
+ // side-panel as the primary thread surface. Narrow viewports
5358
+ // (`_canDockHere` returns false below 1200px) silently fall
5359
+ // back to the floating mode since the chat-col would
5360
+ // otherwise be squeezed below readable width.
5361
+ this.mountThreadWindow(data.room, director, { docked: this._canDockHere() });
5362
+ } catch (e) {
5363
+ alert((e && e.message) || "Failed to open thread");
5364
+ } finally {
5365
+ this._threadsCreating.delete(directorId);
5366
+ }
5367
+ },
5368
+
5369
+ mountThreadWindow(threadRoom, director, opts) {
5370
+ this._threads = this._threads || {};
5371
+ // Idempotent · if the user already has this thread mounted
5372
+ // (e.g. localStorage restore racing with an active session),
5373
+ // just restore the existing window instead of building a
5374
+ // duplicate DOM.
5375
+ if (this._threads[threadRoom.id]) {
5376
+ this.restoreThreadWindow(threadRoom.id);
5377
+ return;
5378
+ }
5379
+ // Single-thread-per-room rule · the room may only have ONE
5380
+ // thread surfaced at a time (float OR docked). Opening a new
5381
+ // one closes any existing thread first. This sidesteps the
5382
+ // "main chat squeezed by stacked docked panels" problem and
5383
+ // keeps the maximize/restore toggle's mental model simple.
5384
+ const existingIds = Object.keys(this._threads);
5385
+ for (const oldId of existingIds) {
5386
+ if (oldId !== threadRoom.id) this.closeThreadWindow(oldId);
5387
+ }
5388
+ // Threads are PANEL-ONLY · always dock into the active room view
5389
+ // (the floating-window mode was removed). When no room view is
5390
+ // mounted (defensive · the trigger only fires from inside a room)
5391
+ // we fall back to a body-level overlay. On viewports < 1200px the
5392
+ // `.is-docked` CSS @media renders the panel as a fixed overlay,
5393
+ // which is the narrow-screen substitute for a full side panel.
5394
+ void opts;
5395
+ const dockHost = this._threadDockHost();
5396
+ const startDocked = !!dockHost;
5397
+
5398
+ const root = document.createElement("div");
5399
+ root.className = "thread-float" + (startDocked ? " is-docked" : "");
5400
+ root.setAttribute("data-thread-id", threadRoom.id);
5401
+ root.setAttribute("data-director-id", director.id);
5402
+ if (!startDocked) {
5403
+ root.style.right = "24px";
5404
+ root.style.bottom = "24px";
5405
+ }
5406
+
5407
+ const escape = (s) => String(s == null ? "" : s)
5408
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
5409
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5410
+ const avatarSrc = director.avatarPath ? escape(director.avatarPath) : "";
5411
+ // Title / subtitle resolution · same priority as the popover
5412
+ // row: LLM-distilled `room.name` > truncated `room.subject` >
5413
+ // director's name as last-resort fallback. Director's name
5414
+ // ALWAYS shows in the subtitle so the user reads "what we're
5415
+ // talking about · with whom".
5416
+ const headerTitle = this._resolveThreadTitle(threadRoom, director);
5417
+ const headerSubtitle = director.name || director.id || "";
5418
+ // Head buttons are icon-only (Lucide mask via CSS · see
5419
+ // .thread-float-head-btn rules in thread.css). aria-label
5420
+ // carries the action name for screen readers + the `title`
5421
+ // attribute provides the hover tooltip. No textContent.
5422
+ const minTip = this._t("thread_minimize_tip") || "Minimize";
5423
+ const closeTip = this._t("thread_close_tip") || "Close";
5424
+ const emptyTitle = this._t("thread_empty_title") || "Pull them aside";
5425
+ const emptyDeck = this._t("thread_empty_deck", { name: director.name || "" })
5426
+ || "What does {name} know that you want to dig into? They have the full room context up to this point.";
5427
+ const placeholder = this._t("thread_composer_placeholder", { name: director.name || "" })
5428
+ || `Ask ${director.name || "them"} anything…`;
5429
+ const sendLabel = this._t("thread_send") || "Send";
5430
+
5431
+ root.innerHTML = `
5432
+ <div class="thread-dock-resize-handle" data-thread-dock-resize aria-hidden="true" title="${escape(this._t("thread_dock_resize_tip") || "Drag to resize panel")}"></div>
5433
+ <div class="thread-float-resize thread-float-resize-top" data-thread-resize="top" aria-hidden="true" title="${escape(this._t("thread_resize_tip") || "Drag to resize")}"></div>
5434
+ <header class="thread-float-head" data-thread-drag>
5435
+ ${avatarSrc
5436
+ ? `<img class="thread-float-head-avatar" src="${avatarSrc}" alt="" data-agent="${escape(director.id)}" title="${escape(director.name || "")}">`
5437
+ : `<span class="thread-float-head-avatar" data-agent="${escape(director.id)}" title="${escape(director.name || "")}"></span>`}
5438
+ <div class="thread-float-meta-wrap thread-float-head-meta">
5439
+ <span class="thread-float-head-title">${escape(headerTitle)}</span>
5440
+ <span class="thread-float-head-subtitle">${escape(headerSubtitle)}</span>
5441
+ </div>
5442
+ <div class="thread-float-head-controls">
5443
+ <button type="button" class="thread-float-head-btn" data-thread-minimize title="${escape(minTip)}" aria-label="${escape(minTip)}"></button>
5444
+ <button type="button" class="thread-float-head-btn" data-thread-close title="${escape(closeTip)}" aria-label="${escape(closeTip)}"></button>
5445
+ </div>
5446
+ </header>
5447
+ <div class="thread-float-messages" data-thread-messages>
5448
+ <div class="thread-float-empty" data-thread-empty>
5449
+ <span class="thread-float-empty-tag">// ${escape(emptyTitle)}</span>
5450
+ <span>${escape(emptyDeck)}</span>
5451
+ </div>
5452
+ </div>
5453
+ <div class="thread-float-composer" data-thread-composer>
5454
+ <div class="thread-float-composer-text-row">
5455
+ <textarea class="thread-float-textarea" data-thread-input placeholder="${escape(placeholder)}" rows="1"></textarea>
5456
+ </div>
5457
+ <div class="thread-float-composer-controls-row">
5458
+ <button type="button" class="ib-action ib-jump-top" data-thread-jump-top title="${escape(this._t("ib_jump_top_tip") || "Jump to top")}" aria-label="${escape(this._t("ib_jump_top_label") || "Jump to top")}" data-tip="${escape(this._t("ib_jump_top_tip") || "Jump to top")}"></button>
5459
+ <button type="button" class="thread-float-send" data-thread-send title="${escape(sendLabel)}" aria-label="${escape(sendLabel)}">${escape(sendLabel)}</button>
5460
+ <button type="button" class="thread-float-stop" data-thread-stop title="${escape(this._t("thread_stop_tip") || "Stop director")}" aria-label="${escape(this._t("thread_stop") || "Stop")}">${escape(this._t("thread_stop") || "Stop")}</button>
5461
+ </div>
5462
+ </div>
5463
+ <div class="thread-float-resize thread-float-resize-bottom" data-thread-resize="bottom" aria-hidden="true" title="${escape(this._t("thread_resize_tip") || "Drag to resize")}"></div>
5464
+ `;
5465
+ // Mount target depends on docked vs float mode.
5466
+ // · Float (default): body-level overlay (fixed positioning).
5467
+ // · Docked: side-by-side with chat-col inside the active
5468
+ // room main-view, so the chat is genuinely beside the
5469
+ // thread and not under a floating chip.
5470
+ if (startDocked && dockHost) {
5471
+ dockHost.appendChild(root);
5472
+ document.body.classList.add("has-thread-dock");
5473
+ } else {
5474
+ document.body.appendChild(root);
5475
+ }
5476
+
5477
+ // Per-thread state · SSE + drag-offset bookkeeping. Stored on
5478
+ // app so the SSE callbacks + drag handlers can find their
5479
+ // window even when many threads are open.
5480
+ const state = {
5481
+ threadId: threadRoom.id,
5482
+ director,
5483
+ room: threadRoom,
5484
+ windowEl: root,
5485
+ sse: null,
5486
+ messages: [],
5487
+ minimized: false,
5488
+ // Threads are panel-only · `docked` is true whenever a room
5489
+ // view exists to host the side panel (the float-window mode +
5490
+ // its maximize/restore toggle were removed). The only time
5491
+ // this is false is the defensive no-room-view fallback, where
5492
+ // the panel renders as a body-level overlay.
5493
+ docked: startDocked,
5494
+ // Drag translate offset · still honoured for the narrow-
5495
+ // viewport fallback where `.is-docked` renders as a fixed,
5496
+ // draggable overlay (see thread.css @media max-width:1200px).
5497
+ dragX: 0,
5498
+ dragY: 0,
5499
+ // User-resized height (px) · null until the user drags the
5500
+ // top edge. Persisted across minimize/restore so the chosen
5501
+ // height survives toggling the chip. The window is bottom-
5502
+ // anchored, so a taller height grows upward.
5503
+ height: null,
5504
+ };
5505
+ this._threads[threadRoom.id] = state;
5506
+ // Persist open-window membership across page reloads · the
5507
+ // thread row itself lives in the DB regardless, but the user's
5508
+ // CHOICE to have a window open for it is client-only state.
5509
+ this._persistThreadOpen(threadRoom.parentRoomId, threadRoom.id, true);
5510
+
5511
+ // Wire interactions
5512
+ root.querySelector("[data-thread-minimize]").addEventListener("click", () => {
5513
+ this.minimizeThreadWindow(threadRoom.id);
5514
+ });
5515
+ root.querySelector("[data-thread-close]").addEventListener("click", () => {
5516
+ this.closeThreadWindow(threadRoom.id);
5517
+ });
5518
+ // Left-edge resize handle · only matters in docked mode (CSS
5519
+ // hides it in float). Wires up unconditionally so the toggle
5520
+ // between float/dock doesn't need to re-bind.
5521
+ const dockResizeHandle = root.querySelector("[data-thread-dock-resize]");
5522
+ if (dockResizeHandle) this._wireThreadDockResize(dockResizeHandle);
5523
+ const dragHead = root.querySelector("[data-thread-drag]");
5524
+ this._wireThreadDrag(dragHead, state);
5525
+ root.querySelectorAll("[data-thread-resize]").forEach((h) => {
5526
+ this._wireThreadResize(h, state, h.getAttribute("data-thread-resize") === "bottom" ? "bottom" : "top");
5527
+ });
5528
+ const sendBtn = root.querySelector("[data-thread-send]");
5529
+ const stopBtn = root.querySelector("[data-thread-stop]");
5530
+ const jumpTopBtn = root.querySelector("[data-thread-jump-top]");
5531
+ const input = root.querySelector("[data-thread-input]");
5532
+ sendBtn.addEventListener("click", () => this.sendThreadMessage(threadRoom.id));
5533
+ stopBtn.addEventListener("click", () => this.stopThreadDirector(threadRoom.id));
5534
+ // Jump-to-top · mirror of the room input-bar's `[data-jump-to-opener]`
5535
+ // affordance · scroll the thread message list back to its first
5536
+ // message. Smooth-scrolling the messages container itself rather
5537
+ // than the viewport · the thread panel has its own scroll context
5538
+ // (absolute-positioned `.thread-float-messages`).
5539
+ if (jumpTopBtn) {
5540
+ jumpTopBtn.addEventListener("click", () => {
5541
+ const list = root.querySelector("[data-thread-messages]");
5542
+ if (list) list.scrollTop = 0;
5543
+ });
5544
+ }
5545
+ input.addEventListener("keydown", (e) => {
5546
+ if (e.key !== "Enter" || e.shiftKey) return;
5547
+ // IME composition guard · while a Chinese / Japanese / Korean
5548
+ // input method is open (pinyin candidate row visible, kana
5549
+ // pre-conversion buffer, etc.) the Enter key is the user
5550
+ // committing their candidate selection, NOT submitting the
5551
+ // message. Firing send here would dispatch the half-typed
5552
+ // raw romanisation. `isComposing` is the standardised
5553
+ // property; `keyCode === 229` is the legacy Chrome/Edge
5554
+ // signal that fires before isComposing flips in some IME
5555
+ // engines. Both checks together cover the matrix.
5556
+ if (e.isComposing || e.keyCode === 229) return;
5557
+ e.preventDefault();
5558
+ this.sendThreadMessage(threadRoom.id);
5559
+ });
5560
+ // Auto-grow textarea up to max-height · 200px ceiling matches
5561
+ // the room's `.ib-textarea` so a long compose feels identical
5562
+ // across both surfaces (CSS cap is also 200px).
5563
+ input.addEventListener("input", () => {
5564
+ input.style.height = "auto";
5565
+ input.style.height = Math.min(200, input.scrollHeight) + "px";
5566
+ });
5567
+ // Quote seed · the quote-cta bar's Thread button stashes the
5568
+ // selected director text on `_threadPendingQuote[directorId]`
5569
+ // BEFORE calling openThreadWith. Consume it here so the new
5570
+ // thread's composer opens with a markdown blockquote ready —
5571
+ // the user just types their follow-up question and hits send.
5572
+ // Cleared after consumption so subsequent opens don't re-paste.
5573
+ const pending = this._threadPendingQuote && this._threadPendingQuote[director.id];
5574
+ if (pending && pending.text) {
5575
+ const quoteLines = String(pending.text).split(/\r?\n/).map((l) => "> " + l);
5576
+ if (pending.directorName) quoteLines.push("> — @" + pending.directorName);
5577
+ input.value = quoteLines.join("\n") + "\n\n";
5578
+ // Trigger autosize after the seed lands.
5579
+ input.style.height = "auto";
5580
+ input.style.height = Math.min(200, input.scrollHeight) + "px";
5581
+ // Cursor goes after the blank line so the user types
5582
+ // immediately at the follow-up position.
5583
+ const pos = input.value.length;
5584
+ try { input.setSelectionRange(pos, pos); } catch (_) {}
5585
+ delete this._threadPendingQuote[director.id];
5586
+ }
5587
+ setTimeout(() => { try { input.focus(); } catch (_) {} }, 30);
5588
+
5589
+ // SSE subscription · the thread's own per-room stream. We get
5590
+ // message-appended / message-token / message-final / message-updated
5591
+ // for the thread's messages; main room events fan out via the
5592
+ // main app.sse and don't reach here.
5593
+ try {
5594
+ const es = new EventSource(`/api/rooms/${encodeURIComponent(threadRoom.id)}/stream`);
5595
+ state.sse = es;
5596
+ es.addEventListener("message-appended", (ev) => {
5597
+ const data = JSON.parse(ev.data);
5598
+ this._threadAppendMessage(threadRoom.id, data);
5599
+ });
5600
+ es.addEventListener("message-token", (ev) => {
5601
+ const data = JSON.parse(ev.data);
5602
+ this._threadApplyToken(threadRoom.id, data);
5603
+ });
5604
+ es.addEventListener("message-updated", (ev) => {
5605
+ const data = JSON.parse(ev.data);
5606
+ this._threadUpdateMessage(threadRoom.id, data);
5607
+ });
5608
+ es.addEventListener("message-final", (ev) => {
5609
+ const data = JSON.parse(ev.data);
5610
+ this._threadFinalizeMessage(threadRoom.id, data);
5611
+ });
5612
+ es.addEventListener("message-removed", (ev) => {
5613
+ const data = JSON.parse(ev.data);
5614
+ this._threadRemoveMessage(threadRoom.id, data);
5615
+ });
5616
+ } catch (e) {
5617
+ console.warn("[thread] SSE attach failed:", e);
5618
+ }
5619
+ },
5620
+
5621
+ closeThreadWindow(threadId) {
5622
+ const state = this._threads && this._threads[threadId];
5623
+ if (!state) return;
5624
+ if (state.sse) {
5625
+ try { state.sse.close(); } catch (_) {}
5626
+ state.sse = null;
5627
+ }
5628
+ if (state.windowEl && state.windowEl.parentNode) {
5629
+ state.windowEl.parentNode.removeChild(state.windowEl);
5630
+ }
5631
+ const parentId = state.room && state.room.parentRoomId;
5632
+ delete this._threads[threadId];
5633
+ if (parentId) this._persistThreadOpen(parentId, threadId, false);
5634
+ // If no docked thread remains, drop the body class that
5635
+ // squeezed the chat-col into its left grid column.
5636
+ const anyDocked = Object.values(this._threads || {}).some((s) => s && s.docked);
5637
+ if (!anyDocked) document.body.classList.remove("has-thread-dock");
5638
+ },
5639
+
5640
+ /** Title shown in the panel header AND in the popover row · same
5641
+ * priority chain as `openThreadsListPopover` so both surfaces
5642
+ * read the same string. LLM-distilled `room.name` wins; until
5643
+ * the title-gen pipeline completes (or for legacy threads
5644
+ * with name_auto=0), the truncated subject stands in; the
5645
+ * director's name is a last-resort fallback for fresh threads
5646
+ * with no user message yet. */
5647
+ _resolveThreadTitle(threadRoom, director) {
5648
+ if (!threadRoom) return (director && director.name) || "";
5649
+ const isPlaceholder = typeof threadRoom.name === "string"
5650
+ && /^thread:/.test(threadRoom.name);
5651
+ if (!isPlaceholder && typeof threadRoom.name === "string" && threadRoom.name.trim()) {
5652
+ return threadRoom.name.trim();
5653
+ }
5654
+ if (typeof threadRoom.subject === "string" && threadRoom.subject.trim()) {
5655
+ const subj = threadRoom.subject.trim();
5656
+ return subj.length > 60 ? subj.slice(0, 60) + "…" : subj;
5657
+ }
5658
+ return (director && director.name) || "";
5659
+ },
5660
+
5661
+ /** Is the current viewport wide enough to honour docked mode?
5662
+ * Below ~1200px the 420px panel would squeeze the chat to an
5663
+ * uncomfortable width; in that case we keep the thread float-
5664
+ * only regardless of the user's maximize click. */
5665
+ _canDockHere() {
5666
+ try {
5667
+ const w = window.innerWidth || document.documentElement.clientWidth || 0;
5668
+ return w >= 1200;
5669
+ } catch (_) { return false; }
5670
+ },
5671
+
5672
+ /** Where to mount a docked thread panel · the active room
5673
+ * main-view, so the panel becomes a flex/grid sibling of the
5674
+ * chat-col. Returns null if no room view is active (defensive
5675
+ * · should never fire because the trigger is only reachable
5676
+ * from inside a room). */
5677
+ _threadDockHost() {
5678
+ const view = document.querySelector('.main-view[data-main-view="room"]');
5679
+ return view && !view.hidden ? view : null;
5680
+ },
5681
+
5682
+ /** Wire the left-edge resize handle so the user can drag-widen
5683
+ * the docked panel. Reads pointer-x on move, computes the new
5684
+ * width as `viewport_width - pointer_x` (panel is anchored to
5685
+ * the right edge), and writes the value to `--thread-dock-w`
5686
+ * on document body. CSS clamps the variable into a sane range
5687
+ * (320-720) so the chat-col never collapses below input-bar
5688
+ * content width. Persists to localStorage so the user's chosen
5689
+ * width survives page reload. */
5690
+ _wireThreadDockResize(handle) {
5691
+ // Hydrate any saved width on first wire so the dock opens at
5692
+ // the user's last-chosen size.
5693
+ const restorePersistedWidth = () => {
5694
+ try {
5695
+ const raw = localStorage.getItem("pb.thread.dock.width");
5696
+ const n = raw ? parseInt(raw, 10) : 0;
5697
+ if (n >= 320 && n <= 720) {
5698
+ document.body.style.setProperty("--thread-dock-w", `${n}px`);
5699
+ }
5700
+ } catch (_) { /* */ }
5701
+ };
5702
+ restorePersistedWidth();
5703
+
5704
+ let dragging = false;
5705
+ let rafId = 0;
5706
+ let pendingX = 0;
5707
+ const flush = () => {
5708
+ rafId = 0;
5709
+ if (!dragging) return;
5710
+ const vw = window.innerWidth || document.documentElement.clientWidth || 0;
5711
+ // Panel sits on the right; new width = distance from
5712
+ // viewport right edge to pointer. Clamp to handle range so
5713
+ // mid-drag the CSS clamp doesn't lag behind the variable.
5714
+ const next = Math.max(320, Math.min(720, vw - pendingX));
5715
+ document.body.style.setProperty("--thread-dock-w", `${next}px`);
5716
+ };
5717
+ const onMove = (e) => {
5718
+ if (!dragging) return;
5719
+ if (e.cancelable) e.preventDefault();
5720
+ pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
5721
+ if (!rafId) rafId = requestAnimationFrame(flush);
5722
+ };
5723
+ const onUp = () => {
5724
+ if (!dragging) return;
5725
+ dragging = false;
5726
+ document.body.classList.remove("is-thread-dock-resizing");
5727
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
5728
+ document.removeEventListener("mousemove", onMove);
5729
+ document.removeEventListener("mouseup", onUp);
5730
+ document.removeEventListener("touchmove", onMove);
5731
+ document.removeEventListener("touchend", onUp);
5732
+ // Persist whatever value we landed on. Read back the
5733
+ // computed style rather than the inline string so any
5734
+ // clamp-by-CSS adjustment is honoured.
5735
+ try {
5736
+ const v = document.body.style.getPropertyValue("--thread-dock-w");
5737
+ const n = parseInt(v, 10);
5738
+ if (n >= 320 && n <= 720) {
5739
+ localStorage.setItem("pb.thread.dock.width", String(n));
5740
+ }
5741
+ } catch (_) { /* private mode · ignore */ }
5742
+ };
5743
+ const onDown = (e) => {
5744
+ // Only honour the drag when the panel is actually docked ·
5745
+ // CSS already hides the handle off-screen otherwise, but
5746
+ // defensive guard for keyboard / programmatic invocations.
5747
+ const panel = handle.closest(".thread-float");
5748
+ if (!panel || !panel.classList.contains("is-docked")) return;
5749
+ dragging = true;
5750
+ document.body.classList.add("is-thread-dock-resizing");
5751
+ pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
5752
+ document.addEventListener("mousemove", onMove);
5753
+ document.addEventListener("mouseup", onUp);
5754
+ document.addEventListener("touchmove", onMove, { passive: false });
5755
+ document.addEventListener("touchend", onUp);
5756
+ };
5757
+ handle.addEventListener("mousedown", onDown);
5758
+ handle.addEventListener("touchstart", onDown, { passive: true });
5759
+ },
5760
+
5761
+ /** Client-side membership · which thread IDs the user has a
5762
+ * window open for, keyed by parent roomId. localStorage so the
5763
+ * set survives page reload. Stored as a JSON array per room
5764
+ * under `pb.thread.open.<roomId>` (mirrors the rest of the
5765
+ * app's `pb.*` localStorage namespace). */
5766
+ _persistThreadOpen(parentRoomId, threadId, isOpen) {
5767
+ if (!parentRoomId || !threadId) return;
5768
+ const key = `pb.thread.open.${parentRoomId}`;
5769
+ try {
5770
+ const raw = localStorage.getItem(key);
5771
+ const ids = raw ? JSON.parse(raw) : [];
5772
+ const next = isOpen
5773
+ ? Array.from(new Set([...ids, threadId]))
5774
+ : ids.filter((id) => id !== threadId);
5775
+ if (next.length) localStorage.setItem(key, JSON.stringify(next));
5776
+ else localStorage.removeItem(key);
5777
+ } catch (_) { /* quota / private mode · silently degrade */ }
5778
+ },
5779
+
5780
+ _persistedThreadOpen(parentRoomId) {
5781
+ if (!parentRoomId) return [];
5782
+ try {
5783
+ const raw = localStorage.getItem(`pb.thread.open.${parentRoomId}`);
5784
+ const arr = raw ? JSON.parse(raw) : [];
5785
+ return Array.isArray(arr) ? arr : [];
5786
+ } catch { return []; }
5787
+ },
5788
+
5789
+ /** Re-mount any thread windows the user previously had open for
5790
+ * the current room. Called from openRoom AFTER members /
5791
+ * agentsById are populated so the director resolution works.
5792
+ * Threads whose IDs no longer exist (deleted out from under
5793
+ * the user) are quietly dropped from the persisted list. */
5794
+ async restoreThreadsForRoom(parentRoomId) {
5795
+ if (!parentRoomId) return;
5796
+ const wanted = this._persistedThreadOpen(parentRoomId);
5797
+ if (wanted.length === 0) return;
5798
+ // Pull the canonical list from the server so we know which
5799
+ // threads still exist. The persisted IDs are advisory.
5800
+ let threads = [];
5801
+ try {
5802
+ const res = await fetch(`/api/rooms/${encodeURIComponent(parentRoomId)}/threads`);
5803
+ if (res.ok) {
5804
+ const data = await res.json();
5805
+ threads = Array.isArray(data.threads) ? data.threads : [];
5806
+ }
5807
+ } catch { return; }
5808
+ const live = new Map(threads.map((t) => [t.id, t]));
5809
+ const stillOpen = [];
5810
+ for (const id of wanted) {
5811
+ const room = live.get(id);
5812
+ if (!room) continue;
5813
+ const director = (this.agentsById && this.agentsById[room.threadDirectorId])
5814
+ || { id: room.threadDirectorId, name: room.threadDirectorId, avatarPath: "" };
5815
+ // Restore in the same mode the user opens NEW threads with.
5816
+ // localStorage doesn't track float-vs-dock yet; default to
5817
+ // dock when the viewport allows so the user's last session's
5818
+ // dock preference is honoured implicitly.
5819
+ this.mountThreadWindow(room, director, { docked: this._canDockHere() });
5820
+ stillOpen.push(id);
5821
+ // Seed the transcript · /threads list endpoint only returns
5822
+ // room metadata, so we fetch the per-thread state to get
5823
+ // messages and paint them into the freshly-mounted window.
5824
+ // Fire-and-forget · each thread loads independently and the
5825
+ // SSE stream picks up new messages from this point on.
5826
+ fetch(`/api/rooms/${encodeURIComponent(id)}`)
5827
+ .then((res) => (res.ok ? res.json() : null))
5828
+ .then((data) => {
5829
+ if (!data || !Array.isArray(data.messages) || data.messages.length === 0) return;
5830
+ this._seedThreadHistory(id, data.messages);
5831
+ })
5832
+ .catch(() => { /* per-thread network blip · log via the SSE error path */ });
5833
+ }
5834
+ // Prune the persisted list to what actually exists · keeps the
5835
+ // localStorage entry from accumulating stale IDs forever.
5836
+ try {
5837
+ const key = `pb.thread.open.${parentRoomId}`;
5838
+ if (stillOpen.length) localStorage.setItem(key, JSON.stringify(stillOpen));
5839
+ else localStorage.removeItem(key);
5840
+ } catch (_) { /* */ }
5841
+ },
5842
+
5843
+ /** Open a popover anchored under the head-threads icon listing
5844
+ * every thread (active or dormant) attached to this main room.
5845
+ * Each row is a click target that mounts / restores the thread
5846
+ * window. ESC + outside-click dismiss. */
5847
+ async openThreadsListPopover(anchorBtn) {
5848
+ this.closeThreadsListPopover();
5849
+ if (!this.currentRoomId) return;
5850
+ const pop = document.createElement("div");
5851
+ pop.id = "thread-list-pop";
5852
+ pop.className = "thread-list-pop";
5853
+ const title = this._t("threads_list_title") || "Private threads";
5854
+ const loading = this._t("threads_list_loading") || "Loading…";
5855
+ pop.innerHTML = `
5856
+ <div class="thread-list-head">${this.escape(title)}</div>
5857
+ <div class="thread-list-body" data-threads-body>
5858
+ <div class="thread-list-empty">${this.escape(loading)}</div>
5859
+ </div>
5860
+ `;
5861
+ document.body.appendChild(pop);
5862
+ // Anchor under the trigger button · `position: fixed` so values
5863
+ // are viewport coords. Right-align so the popover stays under
5864
+ // the icon when the head row contracts on narrow viewports.
5865
+ const r = anchorBtn.getBoundingClientRect();
5866
+ const POP_WIDTH = 320;
5867
+ pop.style.top = `${Math.round(r.bottom + 6)}px`;
5868
+ const rightSpace = window.innerWidth - r.right;
5869
+ pop.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - POP_WIDTH - 8, r.right - POP_WIDTH)))}px`;
5870
+ void rightSpace;
5871
+
5872
+ // Dismissal · outside click + ESC. Same pattern as
5873
+ // closeCastEditOverlay's wiring.
5874
+ this._threadsPopDocClick = (e) => {
5875
+ if (e.target.closest("#thread-list-pop")) return;
5876
+ if (e.target.closest("[data-threads-trigger]")) return;
5877
+ this.closeThreadsListPopover();
5878
+ };
5879
+ this._threadsPopEsc = (e) => {
5880
+ if (e.key === "Escape") {
5881
+ e.preventDefault();
5882
+ this.closeThreadsListPopover();
5883
+ }
5884
+ };
5885
+ setTimeout(() => document.addEventListener("click", this._threadsPopDocClick, true), 0);
5886
+ document.addEventListener("keydown", this._threadsPopEsc, true);
5887
+
5888
+ // Fetch threads · canonical source is the server's
5889
+ // /api/rooms/:id/threads endpoint we already shipped.
5890
+ let threads = [];
5891
+ try {
5892
+ const res = await fetch(`/api/rooms/${encodeURIComponent(this.currentRoomId)}/threads`);
5893
+ if (res.ok) {
5894
+ const data = await res.json();
5895
+ threads = Array.isArray(data.threads) ? data.threads : [];
5896
+ }
5897
+ } catch (_) { /* network · render empty state */ }
5898
+
5899
+ const body = pop.querySelector("[data-threads-body]");
5900
+ if (!body) return;
5901
+
5902
+ // Filter rules:
5903
+ // - messageCount === 0 · empty threads (user opened a thread
5904
+ // from the trigger but never sent a message) are clutter,
5905
+ // hide them.
5906
+ // - Dedupe by director id · keep ONLY the newest thread per
5907
+ // director. Older duplicates (created by past race bugs,
5908
+ // or by manual API hits) collapse so the user sees one row
5909
+ // per director — which is the mental model the rest of the
5910
+ // UI assumes (open-thread-with-director, not per-message).
5911
+ // `threads` arrives newest-first from the server, so the
5912
+ // first occurrence per directorId is the winner.
5913
+ const seenDirectors = new Set();
5914
+ const visible = [];
5915
+ for (const t of threads) {
5916
+ if (typeof t.messageCount === "number" && t.messageCount === 0) continue;
5917
+ if (t.threadDirectorId && seenDirectors.has(t.threadDirectorId)) continue;
5918
+ if (t.threadDirectorId) seenDirectors.add(t.threadDirectorId);
5919
+ visible.push(t);
5920
+ }
5921
+
5922
+ if (visible.length === 0) {
5923
+ const emptyMsg = this._t("threads_list_empty")
5924
+ || "No private threads yet · open one from the // Thread button on any director's message.";
5925
+ // Placeholder icon · Lucide MessagesSquare (two chat bubbles,
5926
+ // matching the head-threads trigger glyph) so the empty
5927
+ // state reads as "this is where your private threads
5928
+ // would appear" rather than as a bare line of text.
5929
+ const placeholderIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>`;
5930
+ body.innerHTML = `
5931
+ <div class="thread-list-empty">
5932
+ <span class="thread-list-empty-icon" aria-hidden="true">${placeholderIcon}</span>
5933
+ <span>${this.escape(emptyMsg)}</span>
5934
+ </div>
5935
+ `;
5936
+ return;
5937
+ }
5938
+ const activeLabel = this._t("threads_list_active") || "open";
5939
+ const trashIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
5940
+ const deleteTip = this._t("threads_list_delete_tip") || "Delete this thread";
5941
+
5942
+ body.innerHTML = visible.map((t) => {
5943
+ const director = (this.agentsById && this.agentsById[t.threadDirectorId])
5944
+ || { name: t.threadDirectorId || "?", avatarPath: "" };
5945
+ const avatarSrc = director.avatarPath ? this.escape(director.avatarPath) : "";
5946
+ const ts = this.timeFmt ? this.timeFmt(t.createdAt) : "";
5947
+ const isOpen = !!(this._threads && this._threads[t.id]);
5948
+ // Title resolution · ALWAYS surface the topic phrase (same
5949
+ // convention as the sidebar's room titles). Priority:
5950
+ // 1. LLM-distilled `room.name` (set by generateRoomTitle
5951
+ // after the first user message · the canonical short
5952
+ // phrase mode)
5953
+ // 2. `room.subject` truncated (the raw first user message
5954
+ // · interim state while the LLM call is in flight or
5955
+ // if it failed to land a phrase)
5956
+ // 3. Director name (last-resort fallback for threads with
5957
+ // no user message yet — shouldn't appear in practice
5958
+ // since we filter messageCount === 0 above)
5959
+ // The director's name + timestamp ALWAYS live in the
5960
+ // subtitle, regardless of which title path was picked.
5961
+ const isPlaceholderName = typeof t.name === "string" && /^thread:/.test(t.name);
5962
+ let title = "";
5963
+ if (!isPlaceholderName && typeof t.name === "string" && t.name.trim()) {
5964
+ title = t.name.trim();
5965
+ } else if (typeof t.subject === "string" && t.subject.trim()) {
5966
+ const subj = t.subject.trim();
5967
+ title = subj.length > 60 ? subj.slice(0, 60) + "…" : subj;
5968
+ } else {
5969
+ title = director.name || "";
5970
+ }
5971
+ const subtitleParts = [];
5972
+ if (director.name) subtitleParts.push(director.name);
5973
+ if (ts) subtitleParts.push(ts);
5974
+ const subtitle = subtitleParts.join(" · ");
5975
+ return `
5976
+ <div class="thread-list-row">
5977
+ <button type="button" class="thread-list-row-body" data-thread-open="${this.escape(t.id)}">
5978
+ ${avatarSrc ? `<img class="thread-list-row-av" src="${avatarSrc}" alt="">` : `<span class="thread-list-row-av"></span>`}
5979
+ <span class="thread-list-row-meta">
5980
+ <span class="thread-list-row-name">${this.escape(title)}</span>
5981
+ <span class="thread-list-row-time">${this.escape(subtitle)}</span>
5982
+ </span>
5983
+ ${isOpen ? `<span class="thread-list-row-active">// ${this.escape(activeLabel)}</span>` : ""}
5984
+ </button>
5985
+ <button type="button" class="thread-list-row-delete" data-thread-delete="${this.escape(t.id)}" data-director-name="${this.escape(director.name || "")}" title="${this.escape(deleteTip)}" aria-label="${this.escape(deleteTip)}">
5986
+ ${trashIcon}
5987
+ </button>
5988
+ </div>
5989
+ `;
5990
+ }).join("");
5991
+ body.querySelectorAll("[data-thread-open]").forEach((row) => {
5992
+ row.addEventListener("click", (e) => {
5993
+ e.stopPropagation();
5994
+ const id = row.getAttribute("data-thread-open");
5995
+ this.closeThreadsListPopover();
5996
+ if (id) this.openExistingThread(id);
5997
+ });
5998
+ });
5999
+ body.querySelectorAll("[data-thread-delete]").forEach((btn) => {
6000
+ btn.addEventListener("click", (e) => {
6001
+ e.preventDefault();
6002
+ e.stopPropagation();
6003
+ const id = btn.getAttribute("data-thread-delete");
6004
+ const dirName = btn.getAttribute("data-director-name") || "";
6005
+ if (id) this.deleteThread(id, dirName, anchorBtn);
6006
+ });
6007
+ });
6008
+ },
6009
+
6010
+ /** Permanently delete a thread room · routes through the standard
6011
+ * DELETE /api/rooms/:id which cascades messages + members.
6012
+ * Confirms via `window.confirm` since the action is destructive
6013
+ * and not undoable. Closes any open window for the same thread,
6014
+ * prunes localStorage, and re-renders the popover. */
6015
+ async deleteThread(threadId, directorName, anchorBtn) {
6016
+ if (!threadId) return;
6017
+ const tmpl = this._t("threads_list_delete_confirm")
6018
+ || "Delete the private thread with {name}? This can't be undone.";
6019
+ const msg = tmpl.replace("{name}", directorName || "this director");
6020
+ if (!window.confirm(msg)) return;
6021
+ try {
6022
+ const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}`, { method: "DELETE" });
6023
+ if (!res.ok) {
6024
+ const err = await res.json().catch(() => ({}));
6025
+ alert((err && err.error) || "Delete failed");
6026
+ return;
6027
+ }
6028
+ } catch (e) {
6029
+ alert((e && e.message) || "Delete failed");
6030
+ return;
6031
+ }
6032
+ // Tear down any open window for this thread + drop from
6033
+ // persistence. closeThreadWindow does both.
6034
+ if (this._threads && this._threads[threadId]) {
6035
+ this.closeThreadWindow(threadId);
6036
+ }
6037
+ // Refresh the popover so the row disappears. Reuse the
6038
+ // original trigger as the anchor — it's still on screen since
6039
+ // we never navigated away.
6040
+ const trigger = anchorBtn || document.querySelector("[data-threads-trigger]");
6041
+ this.closeThreadsListPopover();
6042
+ if (trigger) this.openThreadsListPopover(trigger);
6043
+ },
6044
+
6045
+ closeThreadsListPopover() {
6046
+ const el = document.getElementById("thread-list-pop");
6047
+ if (el) el.remove();
6048
+ if (this._threadsPopDocClick) {
6049
+ document.removeEventListener("click", this._threadsPopDocClick, true);
6050
+ this._threadsPopDocClick = null;
6051
+ }
6052
+ if (this._threadsPopEsc) {
6053
+ document.removeEventListener("keydown", this._threadsPopEsc, true);
6054
+ this._threadsPopEsc = null;
6055
+ }
6056
+ },
6057
+
6058
+ /** Mount a window for an EXISTING thread (one the user hasn't
6059
+ * got open right now but exists in the DB). Used by the
6060
+ * "Threads" header popover entries · the user clicks a row to
6061
+ * resume an old aside without spawning a new one. */
6062
+ async openExistingThread(threadId) {
6063
+ if (!threadId) return;
6064
+ this._threads = this._threads || {};
6065
+ if (this._threads[threadId]) {
6066
+ this.restoreThreadWindow(threadId);
6067
+ return;
6068
+ }
6069
+ try {
6070
+ const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}`);
6071
+ if (!res.ok) return;
6072
+ const data = await res.json();
6073
+ const room = data.room;
6074
+ if (!room || room.kind !== "thread") return;
6075
+ const director = (this.agentsById && this.agentsById[room.threadDirectorId])
6076
+ || { id: room.threadDirectorId, name: room.threadDirectorId, avatarPath: "" };
6077
+ // Default docked · same as the trigger-button entry path.
6078
+ this.mountThreadWindow(room, director, { docked: this._canDockHere() });
6079
+ // Seed the historical transcript · without this the window
6080
+ // mounts empty and only catches messages arriving via SSE
6081
+ // FROM NOW ON, so reopening an old thread looked like a
6082
+ // brand-new conversation. The /api/rooms/:id state response
6083
+ // already carries the full message list — paint it now.
6084
+ if (Array.isArray(data.messages) && data.messages.length > 0) {
6085
+ this._seedThreadHistory(threadId, data.messages);
6086
+ }
6087
+ } catch (e) {
6088
+ try { console.warn("[thread] openExisting failed:", e); } catch (_) {}
6089
+ }
6090
+ },
6091
+
6092
+ /** Paint the thread room's stored messages into the window on
6093
+ * first mount. State + DOM both populated so subsequent SSE
6094
+ * token / update / final events can find the message refs and
6095
+ * patch the bubbles in place. */
6096
+ _seedThreadHistory(threadId, messages) {
6097
+ const state = this._threads && this._threads[threadId];
6098
+ if (!state || !state.windowEl) return;
6099
+ const list = state.windowEl.querySelector("[data-thread-messages]");
6100
+ if (!list) return;
6101
+ // Hide the empty-state placeholder if any messages exist.
6102
+ this._threadEmptyState(threadId, true);
6103
+ // Dedupe against anything already streamed in via SSE that
6104
+ // raced the seed (rare but possible). Skip if already known.
6105
+ const known = new Set(state.messages.map((m) => m.id));
6106
+ const html = [];
6107
+ for (const data of messages) {
6108
+ if (!data || !data.id || known.has(data.id)) continue;
6109
+ const msg = {
6110
+ id: data.id,
6111
+ authorKind: data.authorKind,
6112
+ authorId: data.authorId,
6113
+ body: data.body || "",
6114
+ meta: data.meta || {},
6115
+ roundNum: data.roundNum,
6116
+ createdAt: data.createdAt,
6117
+ };
6118
+ state.messages.push(msg);
6119
+ html.push(this._threadMessageHtml(state, msg));
6120
+ }
6121
+ if (html.length) {
6122
+ list.insertAdjacentHTML("beforeend", html.join(""));
6123
+ list.scrollTop = list.scrollHeight;
6124
+ }
6125
+ },
6126
+
6127
+ minimizeThreadWindow(threadId) {
6128
+ const state = this._threads && this._threads[threadId];
6129
+ if (!state || !state.windowEl) return;
6130
+ state.minimized = true;
6131
+ state.windowEl.classList.add("is-minimized");
6132
+ // Drop the user-resized inline height · the minimized pill sizes
6133
+ // to its content (CSS `height: auto`), and inline height would
6134
+ // win over that class rule and stretch the chip. The chosen
6135
+ // height is preserved on state.height and reapplied on restore.
6136
+ state.windowEl.style.height = "";
6137
+ // Reset transform so the minimized chip lives in its dock
6138
+ // anchor at the bottom-right rather than at the user's drag
6139
+ // location. The dragX/dragY values stay on state so restore
6140
+ // can put it back where they had it.
6141
+ state.windowEl.style.transform = "";
6142
+ // Pull into a shared dock so multiple minimized chips line up
6143
+ // along the bottom-right edge.
6144
+ const dock = this._ensureThreadDock();
6145
+ dock.appendChild(state.windowEl);
6146
+ state.windowEl.style.right = "auto";
6147
+ state.windowEl.style.bottom = "auto";
6148
+ // Click-to-restore on the whole chip (header is hidden,
6149
+ // controls are hidden, so the chip itself is the click target).
6150
+ const restoreHandler = (e) => {
6151
+ e.preventDefault();
6152
+ e.stopPropagation();
6153
+ this.restoreThreadWindow(threadId);
6154
+ };
6155
+ state.windowEl.addEventListener("click", restoreHandler, { once: true });
6156
+ },
6157
+
6158
+ restoreThreadWindow(threadId) {
6159
+ const state = this._threads && this._threads[threadId];
6160
+ if (!state || !state.windowEl) return;
6161
+ state.minimized = false;
6162
+ const el = state.windowEl;
6163
+ el.classList.remove("is-minimized");
6164
+ // Panel-only · restore re-docks into the active room view rather
6165
+ // than reviving a floating window (float mode was removed). Clear
6166
+ // any leftover float inline anchors first, then re-parent into the
6167
+ // dock host. Only when no room view is mounted (defensive) do we
6168
+ // fall back to a body-level overlay.
6169
+ el.style.right = "";
6170
+ el.style.bottom = "";
6171
+ el.style.transform = "";
6172
+ el.style.height = "";
6173
+ const dockHost = this._threadDockHost();
6174
+ if (dockHost) {
6175
+ el.classList.add("is-docked");
6176
+ state.docked = true;
6177
+ if (el.parentNode !== dockHost) dockHost.appendChild(el);
6178
+ document.body.classList.add("has-thread-dock");
6179
+ } else {
6180
+ el.classList.remove("is-docked");
6181
+ state.docked = false;
6182
+ if (el.parentNode !== document.body) document.body.appendChild(el);
6183
+ el.style.right = "24px";
6184
+ el.style.bottom = "24px";
6185
+ }
6186
+ },
6187
+
6188
+ _ensureThreadDock() {
6189
+ let dock = document.getElementById("thread-dock");
6190
+ if (!dock) {
6191
+ dock = document.createElement("div");
6192
+ dock.id = "thread-dock";
6193
+ dock.className = "thread-dock";
6194
+ document.body.appendChild(dock);
6195
+ }
6196
+ return dock;
6197
+ },
6198
+
6199
+ _wireThreadDrag(handle, state) {
6200
+ // Per-drag scratch + rAF coalescing · pointermove fires faster
6201
+ // than the browser can paint on most setups; without throttling
6202
+ // we'd issue multiple style writes per frame. Reading the
6203
+ // pending coords in a single rAF and writing transform once
6204
+ // keeps the window pinned to the cursor with no perceived
6205
+ // lag. The earlier implementation wrote transform on every
6206
+ // mousemove AND had a 0.15s transition (since removed from
6207
+ // .thread-float CSS), which compounded into visible drag jitter.
6208
+ let startX = 0, startY = 0, baseX = 0, baseY = 0;
6209
+ let dragging = false;
6210
+ let pendingX = 0, pendingY = 0;
6211
+ let rafId = 0;
6212
+ const flush = () => {
6213
+ rafId = 0;
6214
+ if (!dragging) return;
6215
+ state.dragX = baseX + (pendingX - startX);
6216
+ state.dragY = baseY + (pendingY - startY);
6217
+ state.windowEl.style.transform = `translate(${state.dragX}px, ${state.dragY}px)`;
6218
+ };
6219
+ const onMove = (e) => {
6220
+ if (!dragging) return;
6221
+ // touchmove may need preventDefault to suppress page scroll
6222
+ // while the user drags the window across the viewport.
6223
+ if (e.cancelable && e.type === "touchmove") e.preventDefault();
6224
+ pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
6225
+ pendingY = ("touches" in e ? e.touches[0].clientY : e.clientY);
6226
+ if (!rafId) rafId = requestAnimationFrame(flush);
6227
+ };
6228
+ const onUp = () => {
6229
+ dragging = false;
6230
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
6231
+ // Reset the compositor hint once dragging ends so the layer
6232
+ // is freed and the window doesn't keep paying the
6233
+ // promote-to-its-own-layer tax forever.
6234
+ if (state.windowEl) state.windowEl.style.willChange = "";
6235
+ document.removeEventListener("mousemove", onMove);
6236
+ document.removeEventListener("mouseup", onUp);
6237
+ document.removeEventListener("touchmove", onMove);
6238
+ document.removeEventListener("touchend", onUp);
6239
+ };
6240
+ const onDown = (e) => {
6241
+ // Ignore drags that originate on interactive children of the
6242
+ // header — buttons (close / min / max) AND the director
6243
+ // avatar, which carries `[data-agent]` and routes its click
6244
+ // to the lightweight intro overlay (agent-overlay.js).
6245
+ if (e.target && e.target.closest && e.target.closest("button, [data-agent]")) return;
6246
+ dragging = true;
6247
+ startX = ("touches" in e ? e.touches[0].clientX : e.clientX);
6248
+ startY = ("touches" in e ? e.touches[0].clientY : e.clientY);
6249
+ pendingX = startX;
6250
+ pendingY = startY;
6251
+ baseX = state.dragX || 0;
6252
+ baseY = state.dragY || 0;
6253
+ // Promote the window to its own compositor layer for the
6254
+ // drag duration so transform updates can be GPU-accelerated.
6255
+ // Cleared on mouseup so we don't permanently inflate
6256
+ // memory.
6257
+ if (state.windowEl) state.windowEl.style.willChange = "transform";
6258
+ document.addEventListener("mousemove", onMove);
6259
+ document.addEventListener("mouseup", onUp);
6260
+ // touchmove · passive:false so we can preventDefault inside
6261
+ // onMove (matters on iOS Safari where the browser otherwise
6262
+ // hijacks vertical drags for page scroll).
6263
+ document.addEventListener("touchmove", onMove, { passive: false });
6264
+ document.addEventListener("touchend", onUp);
6265
+ };
6266
+ handle.addEventListener("mousedown", onDown);
6267
+ handle.addEventListener("touchstart", onDown, { passive: true });
6268
+ },
6269
+
6270
+ /** Edge resize · the float is bottom-anchored.
6271
+ * · "top" handle · drag UP grows / DOWN shrinks; the bottom
6272
+ * edge stays pinned (height only).
6273
+ * · "bottom" handle · the bottom edge follows the cursor (drag
6274
+ * DOWN grows / UP shrinks) while the top edge stays pinned —
6275
+ * achieved by moving the drag-translate `dragY` by the same
6276
+ * delta the height grows, so the box's top doesn't shift.
6277
+ * Mirrors `_wireThreadDrag`'s rAF-coalesced pointer bookkeeping.
6278
+ * Height is clamped to [240, viewport-96] — the upper cap matches
6279
+ * `.thread-float`'s CSS `max-height` so the inline height never
6280
+ * fights it. Persisted on `state.height` (+ `dragY` for the
6281
+ * bottom edge) so minimize/restore keeps the chosen size + spot. */
6282
+ _wireThreadResize(handle, state, edge) {
6283
+ const MIN_H = 240;
6284
+ const maxH = () => Math.max(MIN_H, window.innerHeight - 96);
6285
+ const isBottom = edge === "bottom";
6286
+ let startY = 0, baseH = 0, baseDragY = 0;
6287
+ let resizing = false;
6288
+ let pendingY = 0;
6289
+ let rafId = 0;
6290
+ const flush = () => {
6291
+ rafId = 0;
6292
+ if (!resizing || !state.windowEl) return;
6293
+ // Drag delta along Y · top edge grows when the cursor moves UP
6294
+ // (startY - cur); bottom edge grows when it moves DOWN.
6295
+ const rawDelta = isBottom ? (pendingY - startY) : (startY - pendingY);
6296
+ const next = Math.min(maxH(), Math.max(MIN_H, baseH + rawDelta));
6297
+ state.height = next;
6298
+ state.windowEl.style.height = `${next}px`;
6299
+ if (isBottom) {
6300
+ // Shift the box down by however much it actually grew so the
6301
+ // top edge stays put (height was clamped, so use the applied
6302
+ // delta, not the raw cursor delta).
6303
+ state.dragY = baseDragY + (next - baseH);
6304
+ state.windowEl.style.transform = `translate(${state.dragX || 0}px, ${state.dragY}px)`;
6305
+ }
6306
+ };
6307
+ const onMove = (e) => {
6308
+ if (!resizing) return;
6309
+ if (e.cancelable && e.type === "touchmove") e.preventDefault();
6310
+ pendingY = ("touches" in e ? e.touches[0].clientY : e.clientY);
6311
+ if (!rafId) rafId = requestAnimationFrame(flush);
6312
+ };
6313
+ const onUp = () => {
6314
+ resizing = false;
6315
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
6316
+ // Re-enable text selection in the rest of the app.
6317
+ document.body.style.userSelect = "";
6318
+ document.removeEventListener("mousemove", onMove);
6319
+ document.removeEventListener("mouseup", onUp);
6320
+ document.removeEventListener("touchmove", onMove);
6321
+ document.removeEventListener("touchend", onUp);
6322
+ };
6323
+ const onDown = (e) => {
6324
+ // Don't start a resize while minimized · the handle is hidden
6325
+ // then anyway, but guard against stray events.
6326
+ if (state.minimized || !state.windowEl) return;
6327
+ // Swallow the event so it doesn't also reach the header's
6328
+ // drag-to-move handler underneath.
6329
+ if (e.stopPropagation) e.stopPropagation();
6330
+ // Suppress text selection for the whole drag · without this the
6331
+ // mousemove sweep selects whatever room text sits under the
6332
+ // cursor, which reads as a glitch while resizing the window.
6333
+ if (e.type === "mousedown" && e.preventDefault) e.preventDefault();
6334
+ document.body.style.userSelect = "none";
6335
+ resizing = true;
6336
+ startY = ("touches" in e ? e.touches[0].clientY : e.clientY);
6337
+ pendingY = startY;
6338
+ baseH = state.windowEl.getBoundingClientRect().height;
6339
+ baseDragY = state.dragY || 0;
6340
+ document.addEventListener("mousemove", onMove);
6341
+ document.addEventListener("mouseup", onUp);
6342
+ document.addEventListener("touchmove", onMove, { passive: false });
6343
+ document.addEventListener("touchend", onUp);
6344
+ };
6345
+ handle.addEventListener("mousedown", onDown);
6346
+ handle.addEventListener("touchstart", onDown, { passive: true });
6347
+ },
6348
+
6349
+ async sendThreadMessage(threadId) {
6350
+ const state = this._threads && this._threads[threadId];
6351
+ if (!state || !state.windowEl) return;
6352
+ const input = state.windowEl.querySelector("[data-thread-input]");
6353
+ if (!input) return;
6354
+ const text = (input.value || "").trim();
6355
+ if (!text) return;
6356
+ input.value = "";
6357
+ input.style.height = "auto";
6358
+ try {
6359
+ const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}/messages`, {
6360
+ method: "POST",
6361
+ headers: { "content-type": "application/json" },
6362
+ body: JSON.stringify({ body: text, mode: "now" }),
6363
+ });
6364
+ if (!res.ok) {
6365
+ const err = await res.json().catch(() => ({}));
6366
+ alert((err && err.error) || "Failed to send thread message");
6367
+ }
6368
+ // No need to optimistically render · the SSE message-appended
6369
+ // for the user message will fan out and our handler renders it.
6370
+ } catch (e) {
6371
+ alert((e && e.message) || "Failed to send thread message");
6372
+ }
6373
+ },
6374
+
6375
+ _threadEmptyState(threadId, hide) {
6376
+ const state = this._threads && this._threads[threadId];
6377
+ if (!state || !state.windowEl) return;
6378
+ const empty = state.windowEl.querySelector("[data-thread-empty]");
6379
+ if (empty) empty.style.display = hide ? "none" : "";
6380
+ },
6381
+
6382
+ /** Render a thread bubble using the main chat's full `messageHtml`
6383
+ * pipeline — keeps user avatars, director avatars, model badges,
6384
+ * the streaming dots animation, name+tag chips, etc. all
6385
+ * consistent with the main room. Earlier custom-mini renderer
6386
+ * produced visually broken bubbles (no user avatar, missing
6387
+ * meta chips) because it duplicated only a fraction of the
6388
+ * main path. Now reuses ONE source of truth.
6389
+ *
6390
+ * Side effect protection · sets `this._renderingThread = true`
6391
+ * for the duration of the call so the per-message thread
6392
+ * toolbar (`_threadTriggerHtml`) skips rendering inside thread
6393
+ * windows — there's no "thread the speaker again" inside an
6394
+ * already-open thread. The flag flips back in `finally`. */
6395
+ _threadMessageHtml(state, msg) {
6396
+ void state;
6397
+ this._renderingThread = true;
6398
+ try {
6399
+ return this.messageHtml(msg, false);
6400
+ } finally {
6401
+ this._renderingThread = false;
6402
+ }
6403
+ },
6404
+
6405
+ _threadAppendMessage(threadId, data) {
6406
+ const state = this._threads && this._threads[threadId];
6407
+ if (!state || !state.windowEl) return;
6408
+ const list = state.windowEl.querySelector("[data-thread-messages]");
6409
+ if (!list) return;
6410
+ this._threadEmptyState(threadId, true);
6411
+ // Track msg in state so streaming token appends find a stable
6412
+ // reference even if the article gets re-rendered.
6413
+ const msg = {
6414
+ id: data.messageId,
6415
+ authorKind: data.authorKind,
6416
+ authorId: data.authorId,
6417
+ body: data.body || "",
6418
+ meta: data.meta || {},
6419
+ roundNum: data.roundNum,
6420
+ createdAt: data.createdAt,
6421
+ };
6422
+ state.messages.push(msg);
6423
+ list.insertAdjacentHTML("beforeend", this._threadMessageHtml(state, msg));
6424
+ list.scrollTop = list.scrollHeight;
6425
+ // Director just started streaming · swap composer Send → Stop
6426
+ // so the user can interrupt the response in flight. The flag
6427
+ // stays on the message; later finalize / abort clears it.
6428
+ if (msg.authorKind === "agent" && msg.meta && msg.meta.streaming) {
6429
+ this._setThreadStreamingState(threadId, msg.id);
6430
+ }
6431
+ },
6432
+
6433
+ _threadApplyToken(threadId, data) {
6434
+ const state = this._threads && this._threads[threadId];
6435
+ if (!state) return;
6436
+ const msg = state.messages.find((m) => m.id === data.messageId);
6437
+ if (!msg) return;
6438
+ msg.body = (msg.body || "") + (data.delta || "");
6439
+ const article = state.windowEl.querySelector(
6440
+ `[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
6441
+ );
6442
+ if (!article) return;
6443
+ const bubble = article.querySelector(".msg-bubble");
6444
+ if (!bubble) return;
6445
+ article.classList.remove("thinking");
6446
+ bubble.innerHTML = this.renderBody ? this.renderBody(msg.body) : msg.body;
6447
+ const list = state.windowEl.querySelector("[data-thread-messages]");
6448
+ if (list) list.scrollTop = list.scrollHeight;
6449
+ },
6450
+
6451
+ _threadUpdateMessage(threadId, data) {
6452
+ const state = this._threads && this._threads[threadId];
6453
+ if (!state) return;
6454
+ const msg = state.messages.find((m) => m.id === data.messageId);
6455
+ if (msg) {
6456
+ msg.body = data.body || "";
6457
+ msg.meta = data.meta || {};
6458
+ }
6459
+ const article = state.windowEl.querySelector(
6460
+ `[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
6461
+ );
6462
+ if (!article) return;
6463
+ const bubble = article.querySelector(".msg-bubble");
6464
+ if (bubble) {
6465
+ bubble.innerHTML = this.renderBody ? this.renderBody(data.body || "") : (data.body || "");
6466
+ }
6467
+ const streaming = !!(data.meta && data.meta.streaming);
6468
+ article.classList.toggle("streaming", streaming);
6469
+ if (!streaming) {
6470
+ article.classList.remove("thinking");
6471
+ // Server flipped streaming:false on this director's message ·
6472
+ // restore the composer to Send mode if this was the message
6473
+ // we were tracking. Catches the abort + natural-finish paths
6474
+ // uniformly without needing a separate handler.
6475
+ if (state.streamingMessageId === data.messageId) {
6476
+ this._setThreadStreamingState(threadId, null);
6477
+ }
6478
+ }
6479
+ },
6480
+
6481
+ _threadFinalizeMessage(threadId, data) {
6482
+ const state = this._threads && this._threads[threadId];
6483
+ if (!state) return;
6484
+ const article = state.windowEl.querySelector(
6485
+ `[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
6486
+ );
6487
+ if (article) {
6488
+ article.classList.remove("streaming");
6489
+ article.classList.remove("thinking");
6490
+ }
6491
+ // Director's stream finished · flip composer back to Send.
6492
+ if (state.streamingMessageId === data.messageId) {
6493
+ this._setThreadStreamingState(threadId, null);
6494
+ }
6495
+ },
6496
+
6497
+ /** Flip the composer's Send/Stop visibility on the thread window
6498
+ * via a state class on `.thread-float-composer`. Tracks which
6499
+ * message we believe is currently streaming so duplicate
6500
+ * message-updated events (e.g. multiple token batches before
6501
+ * the streaming:false flag flips) don't toggle prematurely. */
6502
+ _setThreadStreamingState(threadId, messageId) {
6503
+ const state = this._threads && this._threads[threadId];
6504
+ if (!state || !state.windowEl) return;
6505
+ state.streamingMessageId = messageId;
6506
+ const composer = state.windowEl.querySelector("[data-thread-composer]");
6507
+ if (composer) composer.classList.toggle("is-streaming", !!messageId);
6508
+ },
6509
+
6510
+ /** Interrupt the in-flight director response in a thread. Hits
6511
+ * POST /api/rooms/:threadId/abort (the same endpoint the main
6512
+ * room uses for chair-interrupt + skip-current-speaker), which
6513
+ * aborts the LLM stream and drains voice waiters. The director's
6514
+ * partial body stays in chat; the composer flips back to Send
6515
+ * when the server's resulting message-updated (streaming:false)
6516
+ * arrives via SSE. */
6517
+ async stopThreadDirector(threadId) {
6518
+ const state = this._threads && this._threads[threadId];
6519
+ if (!state) return;
6520
+ try {
6521
+ await fetch(`/api/rooms/${encodeURIComponent(threadId)}/abort`, { method: "POST" });
6522
+ } catch (e) {
6523
+ try { console.warn("[thread] abort failed:", e); } catch (_) {}
6524
+ }
6525
+ // Optimistic UX · flip the composer back immediately rather
6526
+ // than waiting for the SSE round-trip. The streaming:false
6527
+ // update + message-final will arrive shortly and the toggle
6528
+ // is idempotent.
6529
+ this._setThreadStreamingState(threadId, null);
6530
+ },
6531
+
6532
+ _threadRemoveMessage(threadId, data) {
6533
+ const state = this._threads && this._threads[threadId];
6534
+ if (!state) return;
6535
+ state.messages = state.messages.filter((m) => m.id !== data.messageId);
6536
+ const article = state.windowEl.querySelector(
6537
+ `[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
6538
+ );
6539
+ if (article && article.parentNode) article.parentNode.removeChild(article);
6540
+ },
6541
+
5150
6542
  async submitFollowUp() {
5151
6543
  const overlay = document.getElementById("followup-overlay");
5152
6544
  if (!overlay) return;
@@ -5906,6 +7298,19 @@
5906
7298
  }
5907
7299
  },
5908
7300
 
7301
+ /** True when a voice replay is currently running for `roomId`.
7302
+ * Drives the Record button's visibility in adjourned rooms ·
7303
+ * recording the replay reconstructs a meeting video (3D stage
7304
+ * driven by the replay + the replay's TTS audio) the same way
7305
+ * a live room records. `getRoomId()` is null for legacy replays
7306
+ * that didn't pass a roomId · treat that as "matches". */
7307
+ _isReplayActiveForRoom(roomId) {
7308
+ const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
7309
+ if (!vr || typeof vr.isOpen !== "function" || !vr.isOpen()) return false;
7310
+ const rid = typeof vr.getRoomId === "function" ? vr.getRoomId() : null;
7311
+ return !rid || rid === roomId;
7312
+ },
7313
+
5909
7314
  /** Toggle recording for the current voice room. Idempotent · if
5910
7315
  * already recording, stops + downloads; otherwise starts. */
5911
7316
  async handleRecordToggle() {
@@ -5914,15 +7319,30 @@
5914
7319
  console.warn("[recorder] module not loaded");
5915
7320
  return;
5916
7321
  }
7322
+ const room = this.currentRoom;
7323
+ const isLive = !!(room && room.status === "live");
5917
7324
  if (rec.isRecording()) {
5918
- // Live recording · open the stop-choice modal instead of
5919
- // stopping immediately. The user picks whether to also end
5920
- // the live room (interrupt / wait for speaker / keep room).
5921
- this.openRecordingStopModal();
7325
+ if (isLive) {
7326
+ // Live recording · open the stop-choice modal instead of
7327
+ // stopping immediately. The user picks whether to also end
7328
+ // the live room (interrupt / wait for speaker / keep room).
7329
+ this.openRecordingStopModal();
7330
+ } else {
7331
+ // Replay / adjourned recording · there's no live room to
7332
+ // interrupt or pause, so skip the lifecycle modal and just
7333
+ // stop + download the clip.
7334
+ await this._stopRecordingAndToast();
7335
+ }
5922
7336
  return;
5923
7337
  }
5924
- const room = this.currentRoom;
5925
7338
  if (!room || room.deliveryMode !== "voice") return;
7339
+ // Replay / adjourned · the round-table stage is opt-in there
7340
+ // (hidden by default), but the recorder crops the window capture
7341
+ // to the stage's on-screen rect — so the stage MUST be visible
7342
+ // before start() locks the composite size. Force the stage view
7343
+ // and collapse the floating replay player so it doesn't bleed
7344
+ // into the captured region.
7345
+ if (!isLive) this._ensureStageVisibleForRecording();
5926
7346
  try {
5927
7347
  await rec.start(room.id, room.subject || room.title || "Meeting");
5928
7348
  // Recorder is now capturing chunks · play the cinematic
@@ -5937,6 +7357,44 @@
5937
7357
  }
5938
7358
  },
5939
7359
 
7360
+ /** Stop + download the active recording and surface the saved
7361
+ * toast. Used by the replay / adjourned stop path where there's
7362
+ * no room lifecycle to reconcile (cf. handleRecordingStopChoice
7363
+ * for the live-room flow). */
7364
+ async _stopRecordingAndToast() {
7365
+ let blob = null;
7366
+ try { blob = await window.BoardroomRecorder.stopAndDownload(); }
7367
+ catch (e) { console.error("[recorder] stopAndDownload failed", e); }
7368
+ if (blob && blob.size > 0) {
7369
+ try {
7370
+ this.showRoundTableToast({
7371
+ kind: "settings",
7372
+ glyph: "↓",
7373
+ htmlText: this.escape(this._t("rec_saved_toast")),
7374
+ lifetimeMs: 5200,
7375
+ });
7376
+ } catch (_) { /* toast best-effort */ }
7377
+ }
7378
+ },
7379
+
7380
+ /** Reveal the round-table stage (opt-in for adjourned rooms) so
7381
+ * the recorder has a non-empty region to crop, and collapse the
7382
+ * floating voice-replay player so its panel stays out of the
7383
+ * captured frame. */
7384
+ _ensureStageVisibleForRecording() {
7385
+ const roomId = this.currentRoomId;
7386
+ if (!roomId) return;
7387
+ try { localStorage.setItem("rt-view-" + roomId, "stage"); } catch (_) { /* noop */ }
7388
+ if (typeof this.applyRoundTableVisibility === "function") {
7389
+ this.applyRoundTableVisibility(roomId);
7390
+ }
7391
+ const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
7392
+ if (vr && typeof vr.isOpen === "function" && vr.isOpen()
7393
+ && typeof vr.collapse === "function") {
7394
+ try { vr.collapse(); } catch (_) { /* noop */ }
7395
+ }
7396
+ },
7397
+
5940
7398
  /** Movie-trailer countdown · 4 frames (Ready, 3, 2, 1) each
5941
7399
  * ~850 ms with a pop+fade animation. Mounts inside the stage
5942
7400
  * so the recording captures the overlay as an opening title
@@ -6030,6 +7488,81 @@
6030
7488
  if (el) el.remove();
6031
7489
  },
6032
7490
 
7491
+ /** Pause-after-current save modal · the user soft-paused the room
7492
+ * while a recording was running. The recording itself doesn't
7493
+ * stop automatically (room can resume), so we ask the user
7494
+ * whether to save it now. Three choices:
7495
+ * · primary "Save & adjourn" — stopAndDownload + adjourn(skipBrief)
7496
+ * · secondary "Save & keep paused" — stopAndDownload, room stays paused
7497
+ * · ghost "Keep recording" — close modal, room paused, recorder
7498
+ * continues capturing the silence
7499
+ * Modal reuses the .pc-overlay / .pc-modal / .pc-choice chrome
7500
+ * for visual consistency with other pause-/reload-choice flows. */
7501
+ openRecordingPauseSaveModal() {
7502
+ this.closeRecordingPauseSaveModal();
7503
+ const html = `
7504
+ <div id="rec-pause-save-overlay" class="pc-overlay">
7505
+ <div class="pc-modal">
7506
+ <div class="pc-classification">
7507
+ <span><span class="dot">●</span> ${this.escape(this._t("rec_pause_save_class"))}</span>
7508
+ <span class="right">${this.escape(this._t("rec_pause_save_right"))}</span>
7509
+ </div>
7510
+ <div class="pc-head">
7511
+ <div class="pc-tag">${this.escape(this._t("rec_pause_save_tag"))}</div>
7512
+ <h2 class="pc-title">${this.escape(this._t("rec_pause_save_title"))}</h2>
7513
+ <p class="pc-deck">${this.escape(this._t("rec_pause_save_deck"))}</p>
7514
+ </div>
7515
+ <div class="pc-body">
7516
+ <button type="button" class="pc-choice primary" data-rec-pause-save-choice="save-adjourn">
7517
+ <div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_adjourn_mark"))}</div>
7518
+ <div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_adjourn_deck"))}</div>
7519
+ </button>
7520
+ <button type="button" class="pc-choice" data-rec-pause-save-choice="save-keep">
7521
+ <div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_keep_mark"))}</div>
7522
+ <div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_keep_deck"))}</div>
7523
+ </button>
7524
+ <button type="button" class="pc-choice ghost" data-rec-pause-save-choice="continue">
7525
+ <div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_continue_mark"))}</div>
7526
+ <div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_continue_deck"))}</div>
7527
+ </button>
7528
+ </div>
7529
+ </div>
7530
+ </div>
7531
+ `;
7532
+ document.body.insertAdjacentHTML("beforeend", html);
7533
+ },
7534
+
7535
+ closeRecordingPauseSaveModal() {
7536
+ const el = document.getElementById("rec-pause-save-overlay");
7537
+ if (el) el.remove();
7538
+ },
7539
+
7540
+ async handleRecordingPauseSaveChoice(mode) {
7541
+ this.closeRecordingPauseSaveModal();
7542
+ if (mode === "continue") return; // keep recording, room stays paused
7543
+ // Both save paths stop + download the clip first.
7544
+ let blob = null;
7545
+ try { blob = await window.BoardroomRecorder.stopAndDownload(); }
7546
+ catch (e) { console.error("[recorder] stopAndDownload failed", e); }
7547
+ if (blob && blob.size > 0) {
7548
+ try {
7549
+ this.showRoundTableToast({
7550
+ kind: "settings",
7551
+ glyph: "↓",
7552
+ htmlText: this.escape(this._t("rec_saved_toast")),
7553
+ lifetimeMs: 5200,
7554
+ });
7555
+ } catch (_) { /* toast best-effort */ }
7556
+ }
7557
+ if (mode === "save-adjourn") {
7558
+ // skipBrief · recording-driven adjourns never auto-generate
7559
+ // a report; the user files one manually.
7560
+ try { await this.adjournRoom({ skipBrief: true }); }
7561
+ catch (e) { console.warn("[recorder] adjournRoom failed", e); }
7562
+ }
7563
+ // save-keep · nothing else to do; room is already paused.
7564
+ },
7565
+
6033
7566
  async handleRecordingStopChoice(mode) {
6034
7567
  this.closeRecordingStopModal();
6035
7568
  if (mode === "cancel") return;
@@ -6920,9 +8453,17 @@
6920
8453
  timeFmt(ms) {
6921
8454
  if (!ms) return "";
6922
8455
  const d = new Date(ms);
6923
- const hh = String(d.getHours()).padStart(2, "0");
6924
- const mm = String(d.getMinutes()).padStart(2, "0");
6925
- return `${hh}:${mm}`;
8456
+ // Absolute timestamp · "May 24, 3:45 PM". When the year differs
8457
+ // from the current one (last year or earlier) the year is
8458
+ // prepended so e.g. "2024 May 24, 3:45 PM" disambiguates old
8459
+ // messages. en-US locale pins the short month + 12-hour "PM"
8460
+ // meridiem regardless of UI language (matches the notification
8461
+ // date format elsewhere in the app).
8462
+ const datePart = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
8463
+ const timePart = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
8464
+ const base = `${datePart}, ${timePart}`;
8465
+ const yr = d.getFullYear();
8466
+ return yr === new Date().getFullYear() ? base : `${yr} ${base}`;
6926
8467
  },
6927
8468
 
6928
8469
  relTime(ms) {
@@ -7363,29 +8904,42 @@
7363
8904
  meta = this._t("sidebar_host");
7364
8905
  }
7365
8906
 
7366
- // Avatar source-of-truth · prefs.avatarSeed (set by the
7367
- // preference overlay's "regenerate avatar" button). When a seed
7368
- // is present we render the AvatarSkill SVG so the sidebar foot
7369
- // matches what the user picked in settings; otherwise fall back
7370
- // to the initial-letter chip we shipped before AvatarSkill
7371
- // existed.
8907
+ // Avatar source-of-truth · prefs.avatarUrl (rendered 3D PNG
8908
+ // captured by the avatar customizer) wins. Otherwise we paint
8909
+ // the seed-derived 3D snap (Avatar3DSnap.generate) and fall
8910
+ // back to the initial-letter chip while the async render is
8911
+ // in flight / when WebGL isn't available.
7372
8912
  // Sync every avatar slot · the full sidebar foot AND the
7373
8913
  // collapsed mini-rail foot both carry [data-user-avatar], so
7374
8914
  // querySelectorAll keeps them identical regardless of which is
7375
8915
  // currently visible.
8916
+ const url = this.prefs?.avatarUrl;
7376
8917
  const seed = this.prefs?.avatarSeed;
7377
- const avHtml = (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function")
7378
- ? window.AvatarSkill.generate(seed)
7379
- : null;
8918
+ const snap = window.Avatar3DSnap;
8919
+ const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
8920
+ const imgSrc = url || cachedSnap || null;
7380
8921
  document.querySelectorAll("[data-user-avatar]").forEach((av) => {
7381
- if (avHtml) {
8922
+ if (imgSrc) {
7382
8923
  av.classList.add("has-pixel-av");
7383
- av.innerHTML = avHtml;
8924
+ av.innerHTML = `<img src="${imgSrc}" alt="" style="width:100%;height:100%;object-fit:cover;image-rendering:auto">`;
7384
8925
  } else {
7385
8926
  av.classList.remove("has-pixel-av");
7386
8927
  av.textContent = initial;
7387
8928
  }
7388
8929
  });
8930
+ if (!url && seed && !cachedSnap && snap && typeof snap.generate === "function") {
8931
+ snap.generate(seed).then((dataUrl) => {
8932
+ if (!dataUrl) return;
8933
+ // Only repaint if the same seed is still in play — guard
8934
+ // against fast-fire regenerate clicks landing stale renders.
8935
+ if (this.prefs?.avatarSeed !== seed) return;
8936
+ if (this.prefs?.avatarUrl) return;
8937
+ document.querySelectorAll("[data-user-avatar]").forEach((av) => {
8938
+ av.classList.add("has-pixel-av");
8939
+ av.innerHTML = `<img src="${dataUrl}" alt="" style="width:100%;height:100%;object-fit:cover;image-rendering:auto">`;
8940
+ });
8941
+ }).catch(() => { /* */ });
8942
+ }
7389
8943
  const nm = document.querySelector("[data-user-name]");
7390
8944
  if (nm) nm.textContent = name;
7391
8945
  const mt = document.querySelector("[data-user-meta]");
@@ -8074,6 +9628,12 @@
8074
9628
  if (chat) {
8075
9629
  if (this.composerMode === "agent") {
8076
9630
  chat.innerHTML = this.renderAgentComposerHtml();
9631
+ // Hire-a-known-mind portraits · upgrade the 2D placeholders
9632
+ // to deterministic 3D voxel renders. Fire-and-forget · the
9633
+ // hydrator lazy-loads three.js + avatar-3d.js on first use,
9634
+ // caches per-seed dataURLs, and re-skips already-rendered
9635
+ // cards. Safe against rapid re-renders (idempotent).
9636
+ try { void this._hydrateCelebrityAvatars3D(); } catch (_) { /* */ }
8077
9637
  // Focus the description textarea unless we're showing a preview.
8078
9638
  setTimeout(() => {
8079
9639
  const ta = chat.querySelector("[data-agent-composer-desc]");
@@ -9712,7 +11272,11 @@
9712
11272
  const article = document.querySelector(`article[data-message-id="${openerId}"]`);
9713
11273
  if (!article) return;
9714
11274
  try {
9715
- article.scrollIntoView({ behavior: "smooth", block: "start" });
11275
+ // Instant · matches the thread panel's jump-top affordance.
11276
+ // User reads the result, not the animation; smooth scrolling
11277
+ // on long rooms (10+ screens of history) takes ≥800ms which
11278
+ // reads as sluggish.
11279
+ article.scrollIntoView({ behavior: "auto", block: "start" });
9716
11280
  } catch { /* */ }
9717
11281
  this.chatStuckToBottom = false;
9718
11282
  this._suppressBottomScrollUntil = Date.now() + 2000;
@@ -9994,6 +11558,15 @@
9994
11558
 
9995
11559
  showNoteTooltip(span) {
9996
11560
  if (!span) return;
11561
+ // Suppress while a text selection is active · the selection CTA
11562
+ // bar (quote-cta.js) already sits in this slot above the
11563
+ // selection. Selecting text that overlaps an already-saved
11564
+ // highlight would otherwise stack the "✓ Saved" hover tip on
11565
+ // top of the CTA bar — the user sees two toolbars. Once the
11566
+ // selection collapses (click elsewhere), hovering the highlight
11567
+ // shows the tip again as normal.
11568
+ const sel = window.getSelection ? window.getSelection() : null;
11569
+ if (sel && !sel.isCollapsed) return;
9997
11570
  const tip = this._ensureNoteTip();
9998
11571
  const noteId = span.dataset.noteId;
9999
11572
  // Resolve note metadata from currentNotes · used for the time
@@ -10672,19 +12245,25 @@
10672
12245
  });
10673
12246
  },
10674
12247
 
10675
- /** Card HTML for one celebrity seed. Avatar comes from
10676
- * AvatarSkill (deterministic from seed.id), so the portrait
10677
- * is the same every render. Intro picks the en/zh field
10678
- * that matches the active locale; ja/es fall back to en. */
12248
+ /** Card HTML for one celebrity seed. Avatar tile mounts empty
12249
+ * (or with a cached 3D snap if one is hot) and gets painted by
12250
+ * the deterministic 3D voxel renderer asynchronously when WebGL
12251
+ * is available see `_hydrateCelebrityAvatars3D()`. The 3D
12252
+ * config is derived from `seed.id` via
12253
+ * `deriveDefaultAvatarConfig`, so the portrait is stable across
12254
+ * re-renders + matches the random face shipped with the persona
12255
+ * once it's hired. Intro picks the en/zh field that matches the
12256
+ * active locale; ja/es fall back to en. */
10679
12257
  celebrityCardHtml(seed) {
10680
12258
  const lang = this.composerLanguage();
10681
12259
  const intro = (seed.intro && (seed.intro[lang] || seed.intro.en || "")) || "";
10682
- const avatarUrl = (window.AvatarSkill && typeof window.AvatarSkill.generateDataUrl === "function")
10683
- ? window.AvatarSkill.generateDataUrl(seed.id)
10684
- : "";
10685
- const avatarHtml = avatarUrl
10686
- ? `<img class="cmp-celeb-img" src="${this.escape(avatarUrl)}" alt="${this.escape(seed.name)}">`
10687
- : `<span class="cmp-celeb-img-fallback" aria-hidden="true">·</span>`;
12260
+ // Prefer the cached 3D render if a previous open already
12261
+ // hydrated this seed (hot-path through the composer should NOT
12262
+ // flash an empty tile on a second open).
12263
+ const cached = (this._celebrityAvatar3dCache && this._celebrityAvatar3dCache.get(seed.id)) || null;
12264
+ const avatarHtml = cached
12265
+ ? `<img class="cmp-celeb-img" data-cmp-celeb-img="${this.escape(seed.id)}" src="${this.escape(cached)}" alt="${this.escape(seed.name)}">`
12266
+ : `<span class="cmp-celeb-img-fallback" data-cmp-celeb-img="${this.escape(seed.id)}" aria-hidden="true">·</span>`;
10688
12267
  return `
10689
12268
  <button type="button" class="cmp-celeb-card" data-celebrity-seed="${this.escape(seed.id)}" title="${this.escape(seed.name)}">
10690
12269
  <span class="cmp-celeb-av">${avatarHtml}</span>
@@ -10697,6 +12276,172 @@
10697
12276
  `;
10698
12277
  },
10699
12278
 
12279
+ /** Walk every `.cmp-celeb-card` currently mounted and render a
12280
+ * 3D voxel portrait into each one (writes the image dataURL
12281
+ * back to its `<img data-cmp-celeb-img>` src). Idempotent · the
12282
+ * per-seed cache means subsequent calls only render new seeds.
12283
+ * Lazy-loads three.js + avatar-3d.js on the first call so the
12284
+ * composer's first paint isn't blocked. Skips silently when
12285
+ * WebGL isn't available (the 2D fallback already rendered). */
12286
+ async _hydrateCelebrityAvatars3D() {
12287
+ if (!this._celebrityAvatar3dCache) this._celebrityAvatar3dCache = new Map();
12288
+ // Capability gate · skip on no-WebGL so the 2D fallback remains
12289
+ // the visible portrait. Cheap test, runs every call (the user
12290
+ // could plug in a WebGL-capable display between calls).
12291
+ let canWebGL = false;
12292
+ try {
12293
+ const c = document.createElement("canvas");
12294
+ canWebGL = !!(c.getContext("webgl2") || c.getContext("webgl"));
12295
+ } catch (_) { canWebGL = false; }
12296
+ if (!canWebGL) return;
12297
+ const cards = Array.from(document.querySelectorAll("img[data-cmp-celeb-img], span[data-cmp-celeb-img]"));
12298
+ if (cards.length === 0) return;
12299
+ const want = [];
12300
+ for (const el of cards) {
12301
+ const id = el.getAttribute("data-cmp-celeb-img");
12302
+ if (!id) continue;
12303
+ // Already 3D-rendered? Skip (cached value was injected by the
12304
+ // first paint). The check leans on the dataset marker rather
12305
+ // than reading the src so a re-render that fell back to 2D
12306
+ // gets reprocessed.
12307
+ if (el.tagName === "IMG" && el.dataset.cmpCelebRendered === "1") continue;
12308
+ want.push({ id, el });
12309
+ }
12310
+ if (want.length === 0) return;
12311
+ // Cache hot · just paint and bail without loading three.
12312
+ const hot = want.filter((j) => this._celebrityAvatar3dCache.has(j.id));
12313
+ for (const j of hot) {
12314
+ const url = this._celebrityAvatar3dCache.get(j.id);
12315
+ this._paintCelebrityImg(j.el, url);
12316
+ }
12317
+ const cold = want.filter((j) => !this._celebrityAvatar3dCache.has(j.id));
12318
+ if (cold.length === 0) return;
12319
+
12320
+ // Lazy-load three + avatar-3d once per session. The two imports
12321
+ // race in parallel and resolve together.
12322
+ let THREE, av;
12323
+ try {
12324
+ [THREE, av] = await Promise.all([
12325
+ import("/vendor/three.module.min.js"),
12326
+ import("/avatar-3d.js"),
12327
+ ]);
12328
+ } catch (e) {
12329
+ try { console.warn("[celebrity-3d] dep load failed", e); } catch (_) {}
12330
+ return;
12331
+ }
12332
+ // Preload every body / hair / outfit referenced by the cold
12333
+ // seeds before rendering · cross-model swaps need the source
12334
+ // GLB cached before buildAvatar3D walks the skeleton.
12335
+ const cfgs = cold.map((j) => ({ id: j.id, cfg: av.deriveDefaultAvatarConfig(j.id) }));
12336
+ const modelIds = new Set();
12337
+ for (const { cfg } of cfgs) {
12338
+ if (cfg.model) modelIds.add(cfg.model);
12339
+ if (cfg.hairStyle && cfg.hairStyle !== "none") modelIds.add(cfg.hairStyle);
12340
+ if (cfg.outfitStyle) modelIds.add(cfg.outfitStyle);
12341
+ }
12342
+ try {
12343
+ await Promise.all(Array.from(modelIds).map((m) => av.loadAvatar3D(m).catch(() => null)));
12344
+ } catch (_) { /* */ }
12345
+
12346
+ // Shared offscreen renderer · same recipe as home.html's
12347
+ // (transparent BG, ACES tone-map, IBL via PMREM + RoomEnv).
12348
+ // 112×112 is 2× retina for the 56-pixel display tile.
12349
+ const SIZE = 112;
12350
+ const off = document.createElement("canvas");
12351
+ off.width = SIZE * 2; off.height = SIZE * 2;
12352
+ let renderer;
12353
+ try {
12354
+ renderer = new THREE.WebGLRenderer({ canvas: off, antialias: true, alpha: true, preserveDrawingBuffer: true });
12355
+ } catch (e) {
12356
+ try { console.warn("[celebrity-3d] renderer init failed", e); } catch (_) {}
12357
+ return;
12358
+ }
12359
+ renderer.setSize(SIZE * 2, SIZE * 2, false);
12360
+ renderer.setClearColor(0x000000, 0);
12361
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
12362
+ renderer.toneMappingExposure = 1.0;
12363
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
12364
+
12365
+ const scene = new THREE.Scene();
12366
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x2a3140, 0.5));
12367
+ const key = new THREE.DirectionalLight(0xffffff, 1.2);
12368
+ key.position.set(2, 3, 2.5);
12369
+ scene.add(key);
12370
+ const rim = new THREE.DirectionalLight(0xbfd4ff, 0.4);
12371
+ rim.position.set(-2, 2, -2);
12372
+ scene.add(rim);
12373
+ // Camera FOV mirrors avatar3d-editor's capturePng so the
12374
+ // head-anchor framing math (`applyFaceFraming`) produces the
12375
+ // SAME crop the agent-profile portrait does. Position is
12376
+ // computed per-figure right before render — face mesh is
12377
+ // present at that point.
12378
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.05, 20);
12379
+
12380
+ for (const { id, cfg } of cfgs) {
12381
+ let figure = null;
12382
+ try {
12383
+ figure = av.buildAvatar3D(id, {
12384
+ model: cfg.model,
12385
+ hairStyle: cfg.hairStyle,
12386
+ outfitStyle: cfg.outfitStyle,
12387
+ accessory: cfg.accessory,
12388
+ height: 1.7,
12389
+ skin: cfg.skin, hair: cfg.hair, brow: cfg.brow, outfit: cfg.outfit,
12390
+ browStyle: cfg.browStyle, tieStyle: cfg.tieStyle,
12391
+ tie: cfg.tie, eye: cfg.eye,
12392
+ });
12393
+ } catch (_) { figure = null; }
12394
+ if (!figure) continue;
12395
+ // Faint 3/4 turn for visual interest, then frame on the
12396
+ // face mesh (same routine the agent-profile capture uses)
12397
+ // so every card shows the same head-and-shoulders crop.
12398
+ figure.rotation.y = -0.18;
12399
+ scene.add(figure);
12400
+ try {
12401
+ if (typeof av.applyFaceFraming === "function") {
12402
+ av.applyFaceFraming(camera, figure);
12403
+ }
12404
+ } catch (_) { /* fallback to constructor defaults */ }
12405
+ renderer.render(scene, camera);
12406
+ const dataUrl = renderer.domElement.toDataURL("image/png");
12407
+ this._celebrityAvatar3dCache.set(id, dataUrl);
12408
+ // Find every mounted card with this id (composer can be open
12409
+ // in two surfaces simultaneously in some flows) and paint.
12410
+ document.querySelectorAll(`[data-cmp-celeb-img="${CSS.escape(id)}"]`)
12411
+ .forEach((el) => this._paintCelebrityImg(el, dataUrl));
12412
+ scene.remove(figure);
12413
+ figure.traverse((n) => {
12414
+ if (n.material) {
12415
+ const ms = Array.isArray(n.material) ? n.material : [n.material];
12416
+ for (const m of ms) { try { m.dispose(); } catch (_) {} }
12417
+ }
12418
+ if (n.geometry) { try { n.geometry.dispose(); } catch (_) {} }
12419
+ });
12420
+ }
12421
+ renderer.dispose();
12422
+ },
12423
+
12424
+ /** Internal · set a celebrity-card image to a dataURL, swapping
12425
+ * the placeholder `<span>` for an `<img>` when the initial
12426
+ * paint used the fallback dot. Marks the node so the next
12427
+ * hydrate pass skips it. */
12428
+ _paintCelebrityImg(el, dataUrl) {
12429
+ if (!el || !dataUrl) return;
12430
+ if (el.tagName === "IMG") {
12431
+ el.src = dataUrl;
12432
+ el.dataset.cmpCelebRendered = "1";
12433
+ return;
12434
+ }
12435
+ // Replace the fallback span with an img inside the same `.cmp-celeb-av` tile.
12436
+ const img = document.createElement("img");
12437
+ img.className = "cmp-celeb-img";
12438
+ img.dataset.cmpCelebImg = el.getAttribute("data-cmp-celeb-img") || "";
12439
+ img.src = dataUrl;
12440
+ img.alt = "";
12441
+ img.dataset.cmpCelebRendered = "1";
12442
+ if (el.parentNode) el.parentNode.replaceChild(img, el);
12443
+ },
12444
+
10700
12445
  /** Click handler · kick the full-mode persona builder with the
10701
12446
  * celebrity's seed description, tag the live job with the seed
10702
12447
  * id so the save-success callback can mark it consumed. */
@@ -10942,8 +12687,8 @@
10942
12687
  * topped up via the LLM-driven `_topUpCelebritySeeds` path.
10943
12688
  *
10944
12689
  * Per-entry shape:
10945
- * · id · kebab-slug · doubles as `AvatarSkill` seed
10946
- * (deterministic 8-bit portrait)
12690
+ * · id · kebab-slug · doubles as `Avatar3DSnap` seed
12691
+ * (deterministic 3D voxel portrait)
10947
12692
  * · name · verbatim, no i18n (proper nouns)
10948
12693
  * · roleTag · short mono tag · kept English to match the
10949
12694
  * mono kicker register used elsewhere
@@ -11361,8 +13106,13 @@
11361
13106
 
11362
13107
  /** Preview card · all generated fields editable inline. */
11363
13108
  renderAgentSpecPreviewHtml(spec) { const seed = this.agentSpecAvatarSeed;
11364
- const avatarSvg = (window.AvatarSkill && seed)
11365
- ? window.AvatarSkill.generate(seed, { size: 96 })
13109
+ // Avatar paints async via Avatar3DSnap (see the post-render
13110
+ // hydration that follows). The frame mounts with a cached
13111
+ // snap if hot, otherwise empty + a CSS placeholder spinner.
13112
+ const snap = window.Avatar3DSnap;
13113
+ const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
13114
+ const avatarSvg = cachedSnap
13115
+ ? `<img src="${cachedSnap}" alt="">`
11366
13116
  : `<div class="ag-prev-av-empty">—</div>`;
11367
13117
  // Reachable models only · filter by `/api/models` cache so the
11368
13118
  // user can't pick a model their active credential can't route
@@ -11807,9 +13557,14 @@
11807
13557
  this.personaJob.finalGuessRoleTag = data.guessRoleTag || "director";
11808
13558
  // Avatar seed · matches Signal-mode's pattern. Random per
11809
13559
  // build, user can re-roll on the save card.
11810
- this.personaJob.avatarSeed = (window.AvatarSkill && window.AvatarSkill.randomSeed)
11811
- ? window.AvatarSkill.randomSeed()
13560
+ this.personaJob.avatarSeed = (window.Avatar3DSnap && window.Avatar3DSnap.randomSeed)
13561
+ ? window.Avatar3DSnap.randomSeed()
11812
13562
  : null;
13563
+ // Warm the 3D snap cache so the persona save overlay's
13564
+ // portrait paints instantly when it opens.
13565
+ if (this.personaJob.avatarSeed && window.Avatar3DSnap && typeof window.Avatar3DSnap.generate === "function") {
13566
+ window.Avatar3DSnap.generate(this.personaJob.avatarSeed).catch(() => { /* */ });
13567
+ }
11813
13568
  this._closePersonaSse();
11814
13569
  this._stopPersonaTick();
11815
13570
  this._personaRender();
@@ -12436,6 +14191,11 @@
12436
14191
  const e = await r.json().catch(() => ({}));
12437
14192
  throw new Error(e.error || ("HTTP " + r.status));
12438
14193
  }
14194
+ // Swap the 8-bit avatar for a 3D screenshot (best-effort) before the
14195
+ // roster refresh below picks the new director up.
14196
+ const saved = await r.json().catch(() => null);
14197
+ const newId = saved && (saved.id || (saved.agent && saved.agent.id));
14198
+ if (newId) await this.apply3dPortrait(newId);
12439
14199
  // Capture the celebrity seed id (if any) BEFORE nulling
12440
14200
  // personaJob below · `_markCelebritySeedConsumed` needs
12441
14201
  // it to update localStorage. Tagging happens in
@@ -12521,9 +14281,19 @@
12521
14281
  const userModel = this.loadAgentComposerModel();
12522
14282
  if (userModel && MODEL_LABELS[userModel]) this.agentSpec.modelV = userModel;
12523
14283
  }
12524
- this.agentSpecAvatarSeed = (window.AvatarSkill && window.AvatarSkill.randomSeed)
12525
- ? window.AvatarSkill.randomSeed()
14284
+ this.agentSpecAvatarSeed = (window.Avatar3DSnap && window.Avatar3DSnap.randomSeed)
14285
+ ? window.Avatar3DSnap.randomSeed()
12526
14286
  : null;
14287
+ // Kick off the 3D portrait render so the cache is hot by the
14288
+ // time the preview card mounts + the save handler reads it.
14289
+ if (this.agentSpecAvatarSeed && window.Avatar3DSnap && typeof window.Avatar3DSnap.generate === "function") {
14290
+ const seed = this.agentSpecAvatarSeed;
14291
+ window.Avatar3DSnap.generate(seed).then((dataUrl) => {
14292
+ if (!dataUrl || this.agentSpecAvatarSeed !== seed) return;
14293
+ const f = document.querySelector(".ag-prev-av-frame");
14294
+ if (f) f.innerHTML = `<img src="${dataUrl}" alt="">`;
14295
+ }).catch(() => { /* */ });
14296
+ }
12527
14297
  this.agentSpecError = null;
12528
14298
  } catch (e) {
12529
14299
  const isAbort = (e && (e.name === "AbortError" || /aborted/i.test(String(e.message))));
@@ -12614,12 +14384,14 @@
12614
14384
  throw new Error(e.error || ("HTTP " + r.status));
12615
14385
  }
12616
14386
  const j = await r.json();
14387
+ const newId = j && (j.id || (j.agent && j.agent.id));
14388
+ // 3D screenshot avatar (before refresh, so the roster shows it).
14389
+ if (newId) await this.apply3dPortrait(newId);
12617
14390
  await this.refreshAgents?.();
12618
14391
  this.agentSpec = null;
12619
14392
  this.agentSpecAvatarSeed = null;
12620
14393
  this.clearAgentComposerDraft();
12621
14394
  this.composerMode = "room";
12622
- const newId = j && (j.id || (j.agent && j.agent.id));
12623
14395
  // Land on the new agent's full profile (mirrors the prior
12624
14396
  // inline-preview save flow's post-success navigation).
12625
14397
  if (newId && typeof window.boardroomFocusAgent === "function") {
@@ -12653,14 +14425,19 @@
12653
14425
  },
12654
14426
 
12655
14427
  rerollAgentSpecAvatar() {
12656
- if (!window.AvatarSkill || !window.AvatarSkill.randomSeed || !window.AvatarSkill.generate) return;
12657
- this.agentSpecAvatarSeed = window.AvatarSkill.randomSeed();
12658
- // In-place re-render of just the avatar frame. AvatarSkill exposes
12659
- // `generate(seed, opts)` (returns SVG markup) — there's no
12660
- // renderSeedSvg helper, hence the previous reroll silently
12661
- // failed.
14428
+ const snap = window.Avatar3DSnap;
14429
+ if (!snap || !snap.randomSeed || !snap.generate) return;
14430
+ const seed = snap.randomSeed();
14431
+ this.agentSpecAvatarSeed = seed;
12662
14432
  const frame = document.querySelector(".ag-prev-av-frame");
12663
- if (frame) frame.innerHTML = window.AvatarSkill.generate(this.agentSpecAvatarSeed, { size: 96 });
14433
+ if (!frame) return;
14434
+ frame.innerHTML = '<div class="ag-prev-av-empty">…</div>';
14435
+ snap.generate(seed).then((dataUrl) => {
14436
+ if (!dataUrl) return;
14437
+ if (this.agentSpecAvatarSeed !== seed) return; // user rolled again
14438
+ const f2 = document.querySelector(".ag-prev-av-frame");
14439
+ if (f2) f2.innerHTML = `<img src="${dataUrl}" alt="">`;
14440
+ }).catch(() => { /* */ });
12664
14441
  },
12665
14442
 
12666
14443
  discardAgentSpec() {
@@ -12690,6 +14467,28 @@
12690
14467
  this._runAgentSpecGeneration(desc);
12691
14468
  },
12692
14469
 
14470
+ /** Give a freshly-created director a 3D screenshot avatar. Renders the
14471
+ * director's deterministic default 3D config (derived from its id — the
14472
+ * same look the room + editor show) to a head-and-shoulders PNG and PATCHes
14473
+ * it as the avatar, plus persists the config. Best-effort: on any failure
14474
+ * the director keeps the 8-bit SVG avatar it was created with. Call AFTER
14475
+ * creation (id known) and BEFORE refreshAgents so the roster picks it up. */
14476
+ async apply3dPortrait(agentId) {
14477
+ try {
14478
+ if (!agentId || !window.Avatar3D || typeof window.renderAvatar3DPortrait !== "function") return;
14479
+ const cfg = window.Avatar3D.deriveDefaultAvatarConfig(agentId);
14480
+ const png = await window.renderAvatar3DPortrait(cfg);
14481
+ if (!png) return;
14482
+ await fetch("/api/agents/" + encodeURIComponent(agentId), {
14483
+ method: "PATCH",
14484
+ headers: { "content-type": "application/json" },
14485
+ body: JSON.stringify({ avatarPath: png, avatar3d: cfg }),
14486
+ });
14487
+ } catch (e) {
14488
+ console.warn("[avatar3d] new-director portrait failed; keeping fallback avatar", e);
14489
+ }
14490
+ },
14491
+
12693
14492
  /** Read inline-edited values from the Signal preview card and
12694
14493
  * POST to /api/agents. Persona-mode save is a separate path
12695
14494
  * (`openPersonaConfirmOverlay` → manual-config overlay → its
@@ -12710,10 +14509,15 @@
12710
14509
  instruction: read("instruction").trim(),
12711
14510
  modelV: read("modelV").trim(),
12712
14511
  };
12713
- // Avatar — generated SVG from current seed, embedded as data: URL.
14512
+ // Avatar — rendered 3D portrait from the current seed, embedded
14513
+ // as a PNG data URL. Render is async (lazy three.js init) but
14514
+ // we already kicked it off when the user landed on the preview
14515
+ // card, so the seed should be hot in the per-seed cache by the
14516
+ // time they click save.
12714
14517
  let avatarPath = null;
12715
- if (window.AvatarSkill && this.agentSpecAvatarSeed && window.AvatarSkill.generateDataUrl) {
12716
- avatarPath = window.AvatarSkill.generateDataUrl(this.agentSpecAvatarSeed);
14518
+ const snap = window.Avatar3DSnap;
14519
+ if (snap && this.agentSpecAvatarSeed && typeof snap.generate === "function") {
14520
+ try { avatarPath = await snap.generate(this.agentSpecAvatarSeed); } catch (_) { avatarPath = null; }
12717
14521
  }
12718
14522
  // Ability axes · lifted from the spec produced by /api/agents/generate-spec.
12719
14523
  // The server validates + clamps + falls back to a heuristic if missing.
@@ -12737,6 +14541,11 @@
12737
14541
  throw new Error(e.error || ("HTTP " + r.status));
12738
14542
  }
12739
14543
  const j = await r.json();
14544
+ // POST /api/agents returns the agent record directly (not wrapped).
14545
+ const newId = j && (j.id || (j.agent && j.agent.id));
14546
+ // Replace the 8-bit avatar with a 3D screenshot before the roster
14547
+ // refresh so the sidebar + profile show the 3D portrait immediately.
14548
+ if (newId) await this.apply3dPortrait(newId);
12740
14549
  // Refresh local agent catalog so the new director shows up
12741
14550
  // in pickers + sidebar immediately.
12742
14551
  await this.refreshAgents?.();
@@ -12746,8 +14555,6 @@
12746
14555
  // a future visit to "+ New Agent" should land on a fresh textarea.
12747
14556
  this.clearAgentComposerDraft();
12748
14557
  this.composerMode = "room";
12749
- // POST /api/agents returns the agent record directly (not wrapped).
12750
- const newId = j && (j.id || (j.agent && j.agent.id));
12751
14558
  // Land the user on the new agent's full profile page · also
12752
14559
  // switches the sidebar to the Agents tab and persists the
12753
14560
  // sub-state so a refresh keeps them on the same agent.
@@ -12817,9 +14624,8 @@
12817
14624
  <span class="brief-picker-num">${this.escape(num)}</span>
12818
14625
  <span class="brief-picker-main">
12819
14626
  <span class="brief-picker-title">${this.escape(b.title || "(untitled)")}</span>
12820
- ${subtitle ? `<span class="brief-picker-sub">${this.escape(subtitle)}</span>` : ""}
14627
+ ${(subtitle || filedLabel) ? `<span class="brief-picker-subrow">${subtitle ? `<span class="brief-picker-sub">${this.escape(subtitle)}</span>` : ""}${filedLabel ? `<span class="brief-picker-time">${this.escape(filedLabel)}</span>` : ""}</span>` : ""}
12821
14628
  </span>
12822
- ${filedLabel ? `<span class="brief-picker-time">${this.escape(filedLabel)}</span>` : ""}
12823
14629
  <span class="brief-picker-arrow">↗</span>
12824
14630
  </a>
12825
14631
  `;
@@ -13723,10 +15529,27 @@
13723
15529
  // (instead of relying on agent-overlay's autoTagAvatars regex) is
13724
15530
  // required for custom agents whose avatarPath is a data: URL —
13725
15531
  // the regex only matches `/avatars/*.svg`.
15532
+ // Head-cast avatars · each director wrapped in a span so a
15533
+ // small thread-trigger badge can pin to the avatar's bottom-
15534
+ // right. Avatar click stays as the agent-overlay open (existing
15535
+ // data-agent behavior); the badge gets its own click target
15536
+ // (data-thread-trigger) so the two affordances don't fight.
15537
+ // Chair is never threadable — filter on roleKind. Allow every
15538
+ // status (live, paused, adjourned) — threads are independent
15539
+ // rooms; the parent's status doesn't gate them. Only block
15540
+ // when this room itself is a thread (no nested threads).
15541
+ const canThreadHere = r.kind !== "thread";
15542
+ const threadTipBase = this._t("thread_trigger") || "// thread";
15543
+ const threadIcon = this._threadTriggerIconSvg();
13726
15544
  const castImgs = this.currentMembers
13727
15545
  .map((a) => {
13728
15546
  const id = this.escape(a.id);
13729
- return `<img class="head-cast-av" data-agent="${id}" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`;
15547
+ const img = `<img class="head-cast-av" data-agent="${id}" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`;
15548
+ const showBadge = canThreadHere && a.roleKind !== "moderator";
15549
+ const badge = showBadge
15550
+ ? `<button type="button" class="head-cast-thread" data-thread-trigger="${id}" data-no-agent-overlay title="${this.escape(this._t("thread_trigger_tip", { name: a.name || "" }) || threadTipBase)}" aria-label="${this.escape(this._t("thread_trigger_tip", { name: a.name || "" }) || threadTipBase)}">${threadIcon}</button>`
15551
+ : "";
15552
+ return `<span class="head-cast-wrap">${img}${badge}</span>`;
13730
15553
  })
13731
15554
  .join("");
13732
15555
  const castCount = this.currentMembers.length;
@@ -13799,9 +15622,13 @@
13799
15622
  </div>
13800
15623
  <div class="head-actions">
13801
15624
  <a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
15625
+ <a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
15626
+ ${this._isReplayActiveForRoom(r.id)
15627
+ ? `<a href="#" class="replay-stop-btn" data-vr-header-stop aria-label="${this.escape(this._t("head_replay_stop_label") || "Stop")}">[ <span class="replay-stop-icon">■</span> ${this.escape(this._t("head_replay_stop_label") || "Stop")} ]</a>`
15628
+ : ""}
13802
15629
  <div class="head-cast">${castHtml}</div>
13803
15630
  <a href="#" class="head-icon-btn head-add-cast" data-cast-edit-trigger data-tip="${this.escape(this._t("head_add_cast_tip"))}" aria-label="${this.escape(this._t("head_add_cast_label"))}"></a>
13804
- <a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
15631
+ <a href="#" class="head-icon-btn head-threads" data-threads-trigger data-tip="${this.escape(this._t("head_threads_tip") || "All private threads in this room")}" aria-label="${this.escape(this._t("head_threads_label") || "All threads")}"></a>
13805
15632
  <a href="#" class="head-icon-btn head-divergence" data-divergence-open data-tip="See how widely the room has explored your question" aria-label="Coverage check"></a>
13806
15633
  ${this.currentBrief
13807
15634
  ? (() => {
@@ -13851,10 +15678,12 @@
13851
15678
  ? `<a href="#" class="head-icon-btn head-followup" data-room-followup data-tip="${this.escape(this._t("adj_followup_label"))}" aria-label="${this.escape(this._t("adj_followup_label"))}"></a>`
13852
15679
  : `<a href="#" class="head-icon-btn head-adjourn" data-adjourn data-tip="${this.escape(this._t("ib_adjourn_tip"))}" aria-label="${this.escape(this._t("ib_adjourn_label"))}"></a>`}
13853
15680
  ${(r.deliveryMode === "voice"
13854
- && r.status === "live"
13855
15681
  && window.BoardroomRecorder
13856
15682
  && typeof window.BoardroomRecorder.isAvailable === "function"
13857
- && window.BoardroomRecorder.isAvailable()) ? (() => {
15683
+ && window.BoardroomRecorder.isAvailable()
15684
+ && (r.status === "live"
15685
+ || this._isReplayActiveForRoom(r.id)
15686
+ || window.BoardroomRecorder.isRecording())) ? (() => {
13858
15687
  const isRec = window.BoardroomRecorder.isRecording();
13859
15688
  const tip = isRec ? this._t("head_record_stop_tip") : this._t("head_record_tip");
13860
15689
  const cls = "head-icon-btn head-record" + (isRec ? " is-recording" : "");
@@ -16349,17 +18178,30 @@
16349
18178
  // profile" kept regressing for custom directors.
16350
18179
  const agentTag = !isUser && author ? ` data-agent="${this.escape(author.id)}"` : "";
16351
18180
  // User avatar mirrors the preference-overlay setting · when
16352
- // prefs.avatarSeed is present we render the AvatarSkill SVG
16353
- // (same seed as the sidebar foot's user-av), otherwise fall back
16354
- // to the initial-letter chip we shipped before AvatarSkill
16355
- // existed.
18181
+ // prefs.avatarUrl (a captured 3D PNG) is present we use that
18182
+ // directly. Otherwise, if the avatarSeed has a cached 3D snap
18183
+ // we paint it synchronously; if not we fall back to the
18184
+ // initial-letter chip (the async snap render kicks in on the
18185
+ // next renderUserBlock paint).
16356
18186
  let userAvHtml;
16357
18187
  if (isUser) {
18188
+ const url = this.prefs?.avatarUrl;
16358
18189
  const seed = this.prefs?.avatarSeed;
16359
- if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
16360
- userAvHtml = `<div class="msg-av msg-av-pixel">${window.AvatarSkill.generate(seed)}</div>`;
18190
+ const snap = window.Avatar3DSnap;
18191
+ const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
18192
+ const src = url || cachedSnap || "";
18193
+ if (src) {
18194
+ userAvHtml = `<img class="msg-av" src="${this.escape(src)}" alt="">`;
16361
18195
  } else {
16362
18196
  userAvHtml = `<div class="msg-av">${this.escape((this.prefs?.name || "Y").charAt(0).toUpperCase())}</div>`;
18197
+ // Kick off the async render so future paints hit cache.
18198
+ if (seed && snap && typeof snap.generate === "function") {
18199
+ snap.generate(seed).then(() => {
18200
+ if (typeof this.renderChat === "function") {
18201
+ try { this.renderChat(); } catch (_) {}
18202
+ }
18203
+ }).catch(() => { /* */ });
18204
+ }
16363
18205
  }
16364
18206
  }
16365
18207
  const avatarHtml = isUser
@@ -16382,7 +18224,13 @@
16382
18224
  // the text-mode "two directors flashing" race vanishes.
16383
18225
  const isDirectorMsg = !isUser && !isChair;
16384
18226
  const activeId = this.currentActiveMessageId || null;
16385
- const speakingGate = !isDirectorMsg || !activeId || m.id === activeId;
18227
+ // `currentActiveMessageId` tracks the MAIN room's currently-
18228
+ // visible speaker · doesn't apply to thread windows (which run
18229
+ // their own SSE / pump with a different message id pool). When
18230
+ // rendering inside a thread, skip the gate so the thread's own
18231
+ // streaming bubble shows the dots + animation normally.
18232
+ const speakingGate = this._renderingThread
18233
+ || !isDirectorMsg || !activeId || m.id === activeId;
16386
18234
  const wantStreaming = !!streaming && speakingGate;
16387
18235
  const stateCls = [];
16388
18236
  if (wantStreaming) stateCls.push("streaming");
@@ -16417,7 +18265,18 @@
16417
18265
  const webSearchUsed = !!(m.meta && m.meta.webSearchUsed);
16418
18266
  const webSearchQuery = (m.meta && typeof m.meta.webSearchQuery === "string") ? m.meta.webSearchQuery : "";
16419
18267
  const webSearchSources = (m.meta && Array.isArray(m.meta.webSearchSources)) ? m.meta.webSearchSources : [];
16420
- const webSearchBadge = webSearchUsed
18268
+ // Thread mode promotes the director's web-search into a
18269
+ // standalone `.msg-tool-card` rendered ABOVE the bubble,
18270
+ // mirroring the chair's tool-use card in main rooms (chair.ts
18271
+ // emits a tool-use message; here the director's webSearch
18272
+ // lives on the same message's meta, so we synthesise the same
18273
+ // visual treatment in-place). In the main room layout we
18274
+ // keep the original inline-badge-in-meta + sources-panel-
18275
+ // under-bubble pattern (matches every other director bubble
18276
+ // in the room and doesn't compete with the chair's tool-use
18277
+ // cards above).
18278
+ const renderWsAsCard = this._renderingThread === true && webSearchUsed;
18279
+ const webSearchBadge = (!renderWsAsCard && webSearchUsed)
16421
18280
  ? (() => {
16422
18281
  const n = webSearchSources.length;
16423
18282
  const title = this.escape(this._t("msg_ws_title", { query: webSearchQuery, n }));
@@ -16427,7 +18286,7 @@
16427
18286
  return `<button type="button" class="msg-web-search" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" title="${title}">🔍 ${label}</button>`;
16428
18287
  })()
16429
18288
  : "";
16430
- const webSearchSourcesPanel = webSearchUsed && webSearchSources.length > 0
18289
+ const webSearchSourcesPanel = (!renderWsAsCard && webSearchUsed && webSearchSources.length > 0)
16431
18290
  ? `<div class="msg-web-search-sources" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
16432
18291
  <div class="msg-web-search-query"><span class="msg-web-search-query-label">${this.escape(this._t("msg_ws_query_label"))}</span><span class="msg-web-search-query-text">${this.escape(webSearchQuery)}</span></div>
16433
18292
  <ol class="msg-web-search-list">
@@ -16442,6 +18301,76 @@
16442
18301
  </ol>
16443
18302
  </div>`
16444
18303
  : "";
18304
+ // Thread mode · synthesise a `.msg-tool-card` for the
18305
+ // director's web-search and surface it ABOVE the bubble.
18306
+ // Visual shape mirrors `chair.ts`'s standalone tool-use card:
18307
+ // banner kicker + body row (status mark + "Searched 'query'"
18308
+ // text + caret) + collapsible sources list + expand button.
18309
+ // Same `data-msg-ws-toggle` / `data-msg-ws-sources` attribute
18310
+ // pair the chair card uses → the existing toggle handler at
18311
+ // app.js:21376 works without modification. Always status=done
18312
+ // since the message meta is set after the search completed.
18313
+ const threadWebSearchCard = renderWsAsCard
18314
+ ? (() => {
18315
+ const n = webSearchSources.length;
18316
+ const hasSources = n > 0;
18317
+ const tail = "";
18318
+ const stamp = !hasSources
18319
+ ? this.escape(this._t("msg_ws_failed"))
18320
+ : n === 1
18321
+ ? this.escape(this._t("msg_ws_done_one", { tail }))
18322
+ : this.escape(this._t("msg_ws_done", { n, tail }));
18323
+ const stampClass = hasSources ? "status-done" : "status-failed";
18324
+ const mark = hasSources ? "✓" : "⚠";
18325
+ const bodyText = this.escape(this._t("msg_ws_card_body", { query: webSearchQuery }));
18326
+ const caret = hasSources ? `<span class="msg-tool-caret" aria-hidden="true">▸</span>` : "";
18327
+ const bodyToggleAttrs = hasSources
18328
+ ? ` data-msg-ws-toggle data-message-id="${this.escape(m.id)}" role="button" tabindex="0" aria-label="${this.escape(this._t("msg_ws_toggle"))}"`
18329
+ : "";
18330
+ const sourcesList = hasSources
18331
+ ? `
18332
+ <ol class="msg-tool-sources-list" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
18333
+ ${webSearchSources.map((s, i) => {
18334
+ const url = s && typeof s.url === "string" ? s.url : "";
18335
+ const title = s && typeof s.title === "string" ? s.title : url;
18336
+ const desc = s && typeof s.description === "string" ? s.description : "";
18337
+ const host = this.hostnameOf(url);
18338
+ const numStr = String(i + 1).padStart(2, "0");
18339
+ return `
18340
+ <li>
18341
+ <span class="msg-tool-sources-num">${numStr}</span>
18342
+ <a href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" class="msg-tool-sources-title">
18343
+ <span class="msg-tool-sources-title-text">${this.escape(title)}</span>
18344
+ <span class="msg-tool-sources-ext" aria-hidden="true">↗</span>
18345
+ </a>
18346
+ <span class="msg-tool-sources-host">${this.escape(host)}</span>
18347
+ ${desc ? `<span class="msg-tool-sources-desc">${this.escape(desc)}</span>` : ""}
18348
+ </li>
18349
+ `;
18350
+ }).join("")}
18351
+ </ol>
18352
+ <button type="button" class="msg-tool-sources-expand" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" aria-label="${this.escape(this._t("msg_ws_toggle"))}">
18353
+ <span class="msg-tool-sources-expand-icon" aria-hidden="true">▾</span>
18354
+ <span class="msg-tool-sources-expand-show">${this.escape(this._t("msg_ws_expand_show", { n }))}</span>
18355
+ <span class="msg-tool-sources-expand-hide">${this.escape(this._t("msg_ws_expand_hide"))}</span>
18356
+ </button>`
18357
+ : "";
18358
+ return `
18359
+ <div class="msg-tool-card ${stampClass} thread-tool-card" data-message-id="${this.escape(m.id)}">
18360
+ <div class="msg-tool-banner">
18361
+ <span class="msg-tool-banner-tag">${this.escape(this._t("msg_ws_banner"))}</span>
18362
+ <span class="msg-tool-banner-stamp">${stamp}</span>
18363
+ </div>
18364
+ <div class="msg-tool-card-body${hasSources ? " is-toggle" : ""}"${bodyToggleAttrs}>
18365
+ <span class="msg-tool-mark" aria-hidden="true">${mark}</span>
18366
+ <span class="msg-tool-card-text">${bodyText}</span>
18367
+ ${caret}
18368
+ </div>
18369
+ ${sourcesList}
18370
+ </div>
18371
+ `;
18372
+ })()
18373
+ : "";
16445
18374
 
16446
18375
  // Round-end card · render even DURING streaming so the user sees
16447
18376
  // a skeleton placeholder immediately after the chair's ping
@@ -16511,9 +18440,11 @@
16511
18440
  ${ctxBadge}
16512
18441
  <span class="msg-time">${this.timeFmt(m.createdAt)}</span>
16513
18442
  </div>
18443
+ ${threadWebSearchCard}
16514
18444
  <div class="msg-bubble">${bubbleHtml}</div>
16515
18445
  ${webSearchSourcesPanel}
16516
18446
  </div>
18447
+ ${this._messageToolbarHtml(m, isUser, isChair, excusedMember, author)}
16517
18448
  </article>
16518
18449
  ${roundEndCard}
16519
18450
  ${roundPromptCard}
@@ -17171,8 +19102,12 @@
17171
19102
  members.push({
17172
19103
  id: "__user__",
17173
19104
  name: prefs.name || "You",
17174
- avatarPath: null,
19105
+ // Prefer the 3D portrait PNG; the seat sprite shows it directly.
19106
+ avatarPath: prefs.avatarUrl || null,
17175
19107
  __seed: prefs.avatarSeed || null,
19108
+ // When the user has a saved 3D avatar, the room renders them as a
19109
+ // full 3D figure (like directors) rather than a flat billboard.
19110
+ avatar3d: prefs.avatar3d || null,
17176
19111
  __isUser: true,
17177
19112
  });
17178
19113
  }
@@ -17465,6 +19400,38 @@
17465
19400
  return { id: null, state: null };
17466
19401
  },
17467
19402
 
19403
+ /** Is `authorId`'s TTS audio actually PRODUCING SOUND right now? Used by
19404
+ * the 3D room to sync the talking-mouth animation to real audio — the
19405
+ * "speaking" stage state can lead the audio (a streaming-text turn shows
19406
+ * "speaking" before its TTS chunk starts), which made mouths move
19407
+ * silently. This reads the live <audio> element, so it's frame-accurate.
19408
+ * Read each frame from voice-3d's tick (cheap · 0-2 queues). */
19409
+ isSpeakerAudible(authorId) {
19410
+ if (!authorId) return false;
19411
+ // Voice-replay (adjourned playback) drives the seat itself · audible
19412
+ // exactly when its playback state is "speaking".
19413
+ const replay = (typeof window !== "undefined" && window.boardroomVoiceReplay
19414
+ && typeof window.boardroomVoiceReplay.getActive === "function")
19415
+ ? window.boardroomVoiceReplay.getActive() : null;
19416
+ if (replay && replay.authorId) {
19417
+ return replay.authorId === authorId && replay.state === "speaking";
19418
+ }
19419
+ // Live room · a voice queue for this author whose <audio> is playing.
19420
+ const qs = this.voiceQueues;
19421
+ if (qs) {
19422
+ for (const k in qs) {
19423
+ const vq = qs[k];
19424
+ if (!vq || vq.playState !== "playing" || vq.authorId !== authorId) continue;
19425
+ const a = vq.audio;
19426
+ // The "playing" flag is set when audio.play() is invoked; confirm the
19427
+ // element is genuinely running (not paused/ended/buffering at 0).
19428
+ if (!a) return true; // flagged playing, no element to inspect → trust it
19429
+ if (!a.paused && !a.ended && a.currentTime > 0) return true;
19430
+ }
19431
+ }
19432
+ return false;
19433
+ },
19434
+
17468
19435
  /** Stage SFX driver · used by BOTH the 3D delegate path and the
17469
19436
  * legacy 2D path so the thinking-blip loop + speaker-change
17470
19437
  * chime fire regardless of which stage renderer is active.
@@ -17510,35 +19477,23 @@
17510
19477
  const floorMode = VALID_FLOORS.includes(tone) ? tone : "constructive";
17511
19478
  stage.setAttribute("data-floor", floorMode);
17512
19479
 
17513
- // ── 3D stage delegate ─────────────────────────────────────
17514
- // When the user's "voxel 3D stage" toggle is on (default) AND
17515
- // the voice-3d module loaded AND WebGL is available, hand off
17516
- // to VoiceStage3D and skip the legacy 2D seat DOM render.
17517
- // Falls through to the 2D path on toggle off / no WebGL / no
17518
- // module · the legacy SVG table + seats DOM stay in place
17519
- // for that case (we never delete them, only `.is-3d` hides).
17520
- const stage3dPref = (() => {
17521
- try { return localStorage.getItem("boardroom.stage3d") !== "off"; }
17522
- catch (_) { return true; }
17523
- })();
19480
+ // ── 3D stage · the only path now ──────────────────────────
19481
+ // The legacy 2D SVG stage was retired · the voice room is
19482
+ // 3D-only. WebGL is required; if it's not available, the stage
19483
+ // simply doesn't paint (caller is expected to gate room entry
19484
+ // on `VS3D.isSupported()` if a no-WebGL fallback ever returns).
19485
+ // The aria-label + HUD + subtitle + SFX tail still runs so
19486
+ // screen readers and the status panels stay in sync regardless.
19487
+ const members = this.roundTableMembers();
19488
+ const positions = this.computeSeatPositions(members);
17524
19489
  const VS3D = window.VoiceStage3D;
17525
- const use3d = stage3dPref && VS3D && typeof VS3D.mount === "function" && VS3D.isSupported();
17526
- const members3d = this.roundTableMembers();
17527
- const positions3d = this.computeSeatPositions(members3d);
17528
- if (use3d) {
19490
+ const sp = this._resolveStageSpeaker();
19491
+ let speakingId = sp && sp.id ? sp.id : null;
19492
+
19493
+ if (VS3D && typeof VS3D.mount === "function" && VS3D.isSupported()) {
17529
19494
  try {
17530
19495
  VS3D.mount(stage);
17531
- // Compact speaker resolution · mirrors the longer 2D path
17532
- // below (priority: voice-replay → audible voice queue →
17533
- // streaming agent message → chair pending / convene →
17534
- // queue head). Enough to drive the 3D overlay's bubble +
17535
- // nameplate-hide while-speaking behaviour.
17536
- const sp = this._resolveStageSpeaker();
17537
19496
  const votePopHtml = this._resolveStageVotePop();
17538
- // User-spoke bubble · mirrors the 2D `data-rt-user-bubble`
17539
- // element. Active when the latest user message landed
17540
- // within the last USER_BUBBLE_TTL_MS, computed once per
17541
- // render so the bubble's countdown progress is fresh.
17542
19497
  const ub = this.userBubble;
17543
19498
  const nowMs = Date.now();
17544
19499
  const ubActive = !!(ub && ub.text && !ub.dismissed && nowMs < ub.deadline);
@@ -17547,8 +19502,8 @@
17547
19502
  (this.USER_BUBBLE_TTL_MS - (ub.deadline - nowMs)) / this.USER_BUBBLE_TTL_MS))
17548
19503
  : 0;
17549
19504
  VS3D.update({
17550
- members: members3d,
17551
- positions: positions3d,
19505
+ members,
19506
+ positions,
17552
19507
  mode: floorMode,
17553
19508
  speakerId: sp.id,
17554
19509
  speakerState: sp.state,
@@ -17560,444 +19515,18 @@
17560
19515
  userWait: !!this.pendingUserMessage,
17561
19516
  userBubble: ubActive ? { text: ub.text, progress: ubProgress } : null,
17562
19517
  });
17563
- // The 2D path below ends with calls to renderRoundTableHud
17564
- // + renderRtSubtitle so the status panel + live subtitle
17565
- // stay in sync with each renderRoundTable. The 3D delegate
17566
- // `return`s above the 2D code, so without these two calls
17567
- // here the HUD div stays empty until some unrelated SSE
17568
- // (config-event / queue-update) happens to fire them ·
17569
- // user reported "HUD shows as a thin line, fixed by
17570
- // toggling the tone" which was exactly that race.
17571
- this.renderRoundTableHud();
17572
- this.renderRtSubtitle();
17573
- // Stage SFX · same call the 2D tail makes (thinking loop +
17574
- // speaker-change chime). Reuses `sp` resolved above so the
17575
- // helper sees the exact speaker/state the 3D scene rendered.
17576
- this._applyStageSfx(sp.id, sp.state);
17577
- return;
17578
19518
  } catch (e) {
17579
- // 3D path blew up · fall through to the legacy 2D render
17580
- // so the user still gets a working stage. Logged so we can
17581
- // diagnose later · the toggle stays on (user-flipped only).
17582
- console.warn("[voice-3d] mount/update failed, falling back to 2D:", e);
17583
- try { VS3D.unmount(); } catch (_) {}
17584
- }
17585
- } else if (VS3D && typeof VS3D.unmount === "function" && stage.classList.contains("is-3d")) {
17586
- // Toggle just flipped off · tear down the 3D canvas so the
17587
- // 2D path below paints into a clean stage.
17588
- try { VS3D.unmount(); } catch (_) {}
17589
- }
17590
-
17591
- const seatsHost = stage.querySelector("[data-rt-seats]");
17592
- if (!seatsHost) return;
17593
- const members = members3d;
17594
- const positions = positions3d;
17595
-
17596
- // Build seat HTML. Z-order via inline style based on the y
17597
- // coordinate so seats with larger y (front) paint last and
17598
- // occlude back-row seats / the table edge.
17599
- const seatsByZ = positions
17600
- .map((seat, i) => ({ seat, i, zScore: Math.round(seat.y * 10) }))
17601
- .sort((a, b) => a.zScore - b.zScore);
17602
-
17603
- // Determine the active speaker AND whether they're thinking
17604
- // (warming up · no tokens yet) or actively speaking (tokens
17605
- // flowing). Priority:
17606
- // 1. Most recent streaming message → that author. State
17607
- // depends on body content: empty body = thinking,
17608
- // non-empty = speaking.
17609
- // 2. Most recent message with an ACTIVE VOICE QUEUE (audio
17610
- // is being streamed/played for it). Catches chair
17611
- // templated announcements (announceRoundPrompt +
17612
- // announceIntervention) that emit voice-chunks without
17613
- // setting meta.streaming · the user hears them speaking,
17614
- // so the bubble must surface even though the streaming
17615
- // flag is false.
17616
- // 3. currentQueue[0] when status === "speaking" → queue head
17617
- // just promoted, no message-appended yet → thinking.
17618
- // 4. Otherwise null (idle).
17619
- let speakingId = null;
17620
- let speakerState = null; // "thinking" | "speaking"
17621
- let replayBody = null; // populated only during voice-replay
17622
- // (0) Voice-replay override · when an adjourned room is playing
17623
- // back its transcript via the replay overlay, the live
17624
- // `streaming` / queue signals are absent (the room is
17625
- // done). Read the replay's active speaker so the seat
17626
- // lights up + the bubble + subtitle reflect the playback.
17627
- // The `body` field powers the subtitle bar at the foot of
17628
- // the stage (`renderRoundTableSubtitle`). Falls through to
17629
- // the live-detection paths below when replay isn't active.
17630
- const replayActive = (typeof window !== "undefined"
17631
- && window.boardroomVoiceReplay
17632
- && typeof window.boardroomVoiceReplay.getActive === "function")
17633
- ? window.boardroomVoiceReplay.getActive()
17634
- : null;
17635
- if (replayActive && replayActive.authorId) {
17636
- speakingId = replayActive.authorId;
17637
- speakerState = replayActive.state === "speaking" ? "speaking" : "thinking";
17638
- replayBody = replayActive.body || "";
17639
- }
17640
- const msgs = this.currentMessages || [];
17641
- // (1) Audible voice queue takes precedence · with cross-director
17642
- // pipelining, the most-recent streaming message is often the
17643
- // PRE-WARMED next speaker (B's placeholder lands while A's
17644
- // audio still plays). The seat that lights up must match
17645
- // whoever the user is HEARING — not whoever's text is
17646
- // streaming in the background. Scan voiceQueues for the
17647
- // playing one and resolve back to its message's author.
17648
- if (!speakingId && this.voiceQueues) {
17649
- for (let i = msgs.length - 1; i >= 0; i--) {
17650
- const mm = msgs[i];
17651
- if (!mm || mm.authorKind !== "agent") continue;
17652
- const vq = this.voiceQueues[mm.id];
17653
- if (vq && vq.playState === "playing") {
17654
- speakingId = mm.authorId;
17655
- speakerState = "speaking";
17656
- break;
17657
- }
17658
- }
17659
- }
17660
- // (2) Streaming message fallback · only relevant when no audible
17661
- // queue exists (text mode, OR the brief warmup between
17662
- // message-appended and first voice-chunk). Picks the most
17663
- // recent streaming agent message that ISN'T pre-warmed —
17664
- // pre-warmed messages have a voiceQueue with playState
17665
- // "queued" and should NOT light up a seat.
17666
- if (!speakingId) {
17667
- for (let i = msgs.length - 1; i >= 0; i--) {
17668
- const mm = msgs[i];
17669
- if (!(mm && mm.meta && mm.meta.streaming === true && mm.authorKind === "agent")) continue;
17670
- // Skip queued (pre-warmed) speakers · their voice waits for
17671
- // the playing speaker's audio to finish.
17672
- const vq = this.voiceQueues && this.voiceQueues[mm.id];
17673
- if (vq && vq.playState === "queued") continue;
17674
- speakingId = mm.authorId;
17675
- const body = String(mm.body || "").trim();
17676
- speakerState = body.length > 0 ? "speaking" : "thinking";
17677
- break;
17678
- }
17679
- }
17680
- // (2b) Dead branch removed · the old "voice queue exists at all"
17681
- // path was replaced by the playing-queue priority above.
17682
- if (false && !speakingId && this.voiceQueues) {
17683
- for (let i = msgs.length - 1; i >= 0; i--) {
17684
- const mm = msgs[i];
17685
- if (!mm || mm.authorKind !== "agent") continue;
17686
- const vq = this.voiceQueues[mm.id];
17687
- if (vq && vq.playState === "playing") {
17688
- speakingId = mm.authorId;
17689
- speakerState = "speaking";
17690
- break;
17691
- }
19519
+ console.warn("[voice-3d] mount/update failed:", e);
17692
19520
  }
17693
19521
  }
17694
- // (3) Chair preparing (silent prep phase between user input
17695
- // and the chair's message-appended) · without this hook
17696
- // the user has no visual signal during the seconds the
17697
- // chair spends on tools + LLM startup. Show the thinking
17698
- // bubble on the chair seat so they know the chair is
17699
- // working. Cleared the moment the chair's real message
17700
- // appends (hideChairPending fires).
17701
- if (!speakingId && this.chairPending === true && this.currentChair) {
17702
- speakingId = this.currentChair.id;
17703
- speakerState = "thinking";
17704
- }
17705
- // (3b) Convening sequence active (auto-pick + chair prep) ·
17706
- // the chat-side convening card is hidden in voice mode,
17707
- // so without this the stage shows nothing happening for
17708
- // the 3-4s the picker LLM + chair tools + LLM startup
17709
- // take. chairPending above only fires AFTER auto-pick-
17710
- // complete; conveneState covers the EARLIER picker phase
17711
- // too. Cleared on the chair's first message-appended.
17712
- if (!speakingId && this.conveneState && this.currentChair) {
17713
- speakingId = this.currentChair.id;
17714
- speakerState = "thinking";
17715
- }
17716
- if (!speakingId && Array.isArray(this.currentQueue) && this.currentQueue[0] && this.currentQueue[0].status === "speaking") {
17717
- speakingId = this.currentQueue[0].agentId;
17718
- speakerState = "thinking";
17719
- }
17720
-
17721
- // Speaker's messageId · derived from the same priority chain
17722
- // above. Used by the stalled-audio bubble (per-queue watchdog
17723
- // surfaces on the speaker's bubble + the Skip click needs the
17724
- // messageId to POST /voice-done). Null when the speaker is
17725
- // thinking-only (no message id yet) or during replay.
17726
- let speakingMsgId = null;
17727
- if (speakingId && this.voiceQueues) {
17728
- for (let i = msgs.length - 1; i >= 0; i--) {
17729
- const mm = msgs[i];
17730
- if (!mm || mm.authorKind !== "agent") continue;
17731
- if (mm.authorId !== speakingId) continue;
17732
- if (this.voiceQueues[mm.id]) { speakingMsgId = mm.id; break; }
17733
- }
17734
- }
17735
- const stalledQ = (speakingMsgId && this.voiceQueues && this.voiceQueues[speakingMsgId] && this.voiceQueues[speakingMsgId].stalled)
17736
- ? this.voiceQueues[speakingMsgId]
17737
- : null;
17738
19522
 
17739
- // Stage SFX · thinking loop + speaker-change chime. Logic
17740
- // lives in _applyStageSfx so the 3D delegate path can reuse
17741
- // it; see that helper for the AudioContext / voice-queue
17742
- // gating rationale.
17743
- this._applyStageSfx(speakingId, speakerState);
17744
-
17745
- const html = seatsByZ.map(({ seat, i }) => {
17746
- const m = seat.member;
17747
- const isChair = seat.kind === "chair";
17748
- const isUser = !!m.__isUser;
17749
- const qIdx = isUser ? -1 : this.roundTableQueueIndex(m.id);
17750
- const isSpeaking = !isUser && m.id === speakingId;
17751
- const isQueued = qIdx >= 1; // anyone after the head
17752
-
17753
- // Chair sprite · user gets the plain (no-moderator-gem) chair
17754
- // sprite; the seat reads as "joined the table" not "running it".
17755
- const chairSvg = this.renderRoundTableChairSvg(isChair);
17756
- // Avatar · user takes prefs.avatarSeed via AvatarSkill when
17757
- // available, otherwise falls back to an initial-letter chip
17758
- // (mirrors the sidebar pattern at app.js:4847). Directors /
17759
- // chair use their stored avatarPath image.
17760
- let avatar;
17761
- if (isUser) {
17762
- const seed = m.__seed;
17763
- if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
17764
- avatar = `<div class="rt-avatar rt-avatar-user has-pixel-av">${window.AvatarSkill.generate(seed)}</div>`;
17765
- } else {
17766
- const initial = this.escape(((m.name || "?")[0] || "?").toUpperCase());
17767
- avatar = `<div class="rt-avatar rt-avatar-user rt-avatar-initial">${initial}</div>`;
17768
- }
17769
- } else {
17770
- // `data-agent` opens the lightweight director overlay (see
17771
- // public/agent-overlay.js bubble-phase click handler). Tagged
17772
- // on directors AND the chair so the user can peek at any
17773
- // seated voice from the stage; user seat is a div, not an
17774
- // img, and is intentionally not tagged.
17775
- avatar = `<img class="rt-avatar" data-agent="${this.escape(m.id)}" src="${this.escape(m.avatarPath || "")}" alt="${this.escape(m.name || "")}">`;
17776
- }
17777
- // Name plate · adds a small "Chairman / 董事长" title beneath
17778
- // the user's name so the user seat reads as the room owner /
17779
- // chairman. Pulled from i18n (`rt_user_title`) so the label
17780
- // follows the active UI locale: defaults to "Chairman" in
17781
- // English, "董事长" in Chinese. Directors and the chair
17782
- // render the plain single-line name.
17783
- const name = isUser
17784
- ? `<div class="rt-name">${this.escape(m.name || "")}<div class="rt-name-title">${this.escape(this._t("rt_user_title"))}</div></div>`
17785
- : `<div class="rt-name">${this.escape(m.name || "")}</div>`;
17786
- const bubbleState = isSpeaking ? speakerState : null;
17787
- // Bubble carries the speaker's NAME so the user always knows
17788
- // who's speaking — the name plate beneath/above the seat is
17789
- // hidden during speaking (display:none in CSS), and without a
17790
- // name on the bubble itself the user could only guess. Status
17791
- // word ("thinking" / "speaking") is rendered as a smaller
17792
- // mono kicker beneath the name. Color + dots animation still
17793
- // distinguishes thinking (amber) from speaking (lime).
17794
- // Convene override · while the room is mid-convening, the
17795
- // chair seat shows the current stage label ("Analyzing topic"
17796
- // / "Seating directors" / "Preparing remarks") instead of the
17797
- // generic "Thinking" so the user knows WHICH part of the
17798
- // setup is in flight. Falls back to the generic label for
17799
- // every non-chair speaker and for chair turns post-convene.
17800
- let statusWord = bubbleState === "thinking"
17801
- ? this._t("rt_thinking")
17802
- : this._t("rt_speaking");
17803
- if (bubbleState === "thinking" && isChair && this.conveneState) {
17804
- const stageKey = ({
17805
- analyzing: "conv_stage_analyzing_title",
17806
- seating: "conv_stage_seating_title",
17807
- preparing: "conv_stage_preparing_title",
17808
- })[this.conveneState.stage];
17809
- if (stageKey) statusWord = this._t(stageKey);
17810
- }
17811
- // Chair-pending phase (server-driven · chair-pending payload.phase).
17812
- // Maps known phase strings to i18n keys so the bubble label
17813
- // reflects WHAT silent work is happening — picker LLM, clarify
17814
- // gate, vote summary, brief stages, etc. Falls back to the
17815
- // existing convene-stage / "Thinking" label when phase is
17816
- // empty or unrecognised.
17817
- if (bubbleState === "thinking" && isChair && this.chairPending && this.chairPendingPhase) {
17818
- const phaseKey = ({
17819
- "clarify-deciding": "rt_phase_clarify_deciding",
17820
- "picker-deciding": "rt_phase_picker_deciding",
17821
- "next-speaker": "rt_phase_next_speaker",
17822
- "llm-warming": "rt_phase_llm_warming",
17823
- "vote-summary": "rt_phase_vote_summary",
17824
- "brief-extracting": "rt_phase_brief_extracting",
17825
- "brief-composing": "rt_phase_brief_composing",
17826
- "brief-writing": "rt_phase_brief_writing",
17827
- })[this.chairPendingPhase];
17828
- if (phaseKey) statusWord = this._t(phaseKey);
17829
- }
17830
- // Per-message LLM first-token watchdog · client-side belt for
17831
- // the server's 60s hard cap. When a streaming director message
17832
- // has gone >8s with no message-token arriving, _localPhase is
17833
- // flipped to "llm-warming" and the bubble shows that label
17834
- // until the first token lands (or the server auto-skips).
17835
- if (bubbleState === "thinking" && !isChair) {
17836
- const streamingMsg = (this.currentMessages || []).slice().reverse().find(
17837
- (mm) => mm && mm.authorKind === "agent" && mm.authorId === m.id
17838
- && mm.meta && mm.meta.streaming === true,
17839
- );
17840
- if (streamingMsg && streamingMsg.meta && streamingMsg.meta._localPhase === "llm-warming") {
17841
- statusWord = this._t("rt_phase_llm_warming");
17842
- }
17843
- }
17844
- const bubbleCls = bubbleState === "thinking"
17845
- ? "rt-bubble is-thinking"
17846
- : "rt-bubble";
17847
- // User bubble · ephemeral, shows latest typed message plus
17848
- // an `×` close button. Visible only when not dismissed and
17849
- // the deadline hasn't elapsed. The 10s countdown is
17850
- // rendered AS the bubble's border via a conic-gradient
17851
- // driven by `--rt-bubble-user-progress` (0 → 1 over 10s);
17852
- // the inline-text countdown digit was removed so prose
17853
- // can use the bubble's full interior width.
17854
- let bubble = "";
17855
- if (isUser) {
17856
- const ub = this.userBubble;
17857
- if (ub && !ub.dismissed && ub.text && Date.now() < ub.deadline) {
17858
- const elapsed = this.USER_BUBBLE_TTL_MS - (ub.deadline - Date.now());
17859
- const progress = Math.min(1, Math.max(0, elapsed / this.USER_BUBBLE_TTL_MS));
17860
- bubble = `<div class="rt-bubble rt-bubble-user" data-rt-user-bubble style="--rt-bubble-user-progress: ${progress.toFixed(3)}">` +
17861
- `<span class="rt-bubble-user-text">${this.escape(ub.text)}</span>` +
17862
- `<button type="button" class="rt-bubble-user-close" data-rt-user-bubble-close aria-label="Dismiss">✕</button>` +
17863
- `</div>`;
17864
- }
17865
- } else if (isChair && this.chairBubble && !this.chairBubble.dismissed
17866
- && this.chairBubble.text && Date.now() < this.chairBubble.deadline) {
17867
- // Chair clarify question · pinned to the chair seat with
17868
- // border countdown. Takes precedence over the "Speaking"
17869
- // status bubble since the chair has already finished its
17870
- // turn at this point (message-final flipped streaming off).
17871
- const cb = this.chairBubble;
17872
- const elapsed = this.CHAIR_BUBBLE_TTL_MS - (cb.deadline - Date.now());
17873
- const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
17874
- bubble = `<div class="rt-bubble rt-bubble-chair-clarify" data-rt-chair-bubble style="--rt-bubble-chair-progress: ${progress.toFixed(3)}">` +
17875
- `<span class="rt-bubble-chair-clarify-text">${this.escape(cb.text)}</span>` +
17876
- `<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
17877
- `</div>`;
17878
- } else if (isSpeaking) {
17879
- // Stalled-audio variant · the chunk-arrival watchdog flipped
17880
- // q.stalled when no new TTS chunks arrived for ~8s. Repaint
17881
- // the bubble amber to signal the issue; the 15s auto-skip
17882
- // inside the watchdog will recover automatically.
17883
- if (stalledQ && stalledQ.messageId === (this.voiceQueues[speakingMsgId]?.messageId)) {
17884
- // Audio stalled · the chunk-arrival watchdog flipped
17885
- // q.stalled when no new TTS chunks arrived for ~8s. Show
17886
- // an amber state on the bubble so the user understands
17887
- // why playback paused. The 15s auto-skip in the watchdog
17888
- // recovers automatically · no manual click affordance.
17889
- const stalledText = this._t("rt_audio_stalled");
17890
- bubble = `<div class="${bubbleCls} is-stalled"`
17891
- + ` title="${this.escape(stalledText)}">`
17892
- + `<span class="rt-bubble-name">${this.escape(m.name || "")}</span>`
17893
- + `<span class="rt-bubble-status">${this.escape(stalledText)}</span>`
17894
- + `</div>`;
17895
- } else {
17896
- bubble = `<div class="${bubbleCls}"><span class="rt-bubble-name">${this.escape(m.name || "")}</span><span class="rt-bubble-status">${this.escape(statusWord)}</span><span class="rt-bubble-dots" aria-hidden="true"><i></i><i></i><i></i></span></div>`;
17897
- }
17898
- }
17899
- const badge = (isQueued && !isSpeaking)
17900
- ? `<div class="rt-badge">${String(qIdx + 1).padStart(2, "0")}</div>`
17901
- : "";
19523
+ // Stage SFX · thinking loop + speaker-change chime. The 2D
19524
+ // path used to compute speakingId/state itself; we reuse the
19525
+ // resolver above so this stays single-source.
19526
+ this._applyStageSfx(sp.id, sp.state);
17902
19527
 
17903
- // Vote popover on the chair seat · single voice-mode surface
17904
- // for both phases of the vote flow:
17905
- // (A) round-prompt phase · chair has just prompted at round
17906
- // wrap; popover shows [Open vote] [Continue] [Adjourn].
17907
- // (B) round-end vote phase · awaitingContinue === true;
17908
- // popover shows the 3 key-points + Continue / Adjourn
17909
- // AFTER the chair has finished its TTS turn.
17910
- // The popover is suppressed while the chair is mid-presenting
17911
- // (preparing, streaming text, or audio still playing). The
17912
- // user wants to hear the chair speak first, THEN see the
17913
- // panel — without this gate the panel popped up under the
17914
- // chair's voice and split the user's attention.
17915
- const hasActivePrompt = (typeof this.activeRoundPromptId === "function")
17916
- ? !!this.activeRoundPromptId()
17917
- : false;
17918
- const isChairBusy = (() => {
17919
- if (this.chairPending === true) return true;
17920
- // Chair message landed but voice synthesis hasn't reached
17921
- // the client yet · the popover would otherwise flash for
17922
- // 0.5-2s before audio kicks in. See `_chairVoiceAwaiting`
17923
- // doc + the message-appended hook for the full reasoning.
17924
- if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.size > 0) return true;
17925
- const allMsgs = this.currentMessages || [];
17926
- for (let k = allMsgs.length - 1; k >= 0; k--) {
17927
- const mm = allMsgs[k];
17928
- if (!mm || mm.authorKind !== "agent") continue;
17929
- // Most recent agent turn · streaming text OR active voice
17930
- // playback counts as "busy". Voice queues stay live for
17931
- // templated chair messages too (announceRoundPrompt), so
17932
- // this catches round-prompt voice as well as the LLM
17933
- // round-end stream.
17934
- if (mm.meta && mm.meta.streaming === true) return true;
17935
- if (this.voiceQueues && this.voiceQueues[mm.id]) return true;
17936
- return false;
17937
- }
17938
- return false;
17939
- })();
17940
- const showVotePop = isChair && this.currentRoom
17941
- && this.currentRoom.status !== "adjourned"
17942
- && (this.currentRoom.awaitingContinue === true || hasActivePrompt)
17943
- && !isChairBusy;
17944
- const votePop = showVotePop ? this.renderRoundTableVotePop() : "";
17945
-
17946
- // Seats whose y is below the stage midline (chair + any
17947
- // front-row directors) get `rt-seat-below`. CSS flips the
17948
- // name plate to sit BELOW the chair sprite for these so
17949
- // names of front-row seats don't land on the table surface
17950
- // and obscure the props.
17951
- const isBelow = seat.y > 50;
17952
- const cls = [
17953
- "rt-seat",
17954
- isChair ? "rt-seat-chair" : (isUser ? "rt-seat-user" : "rt-seat-director"),
17955
- isSpeaking ? "rt-seat-speaking" : "",
17956
- isSpeaking && speakerState === "thinking" ? "rt-seat-thinking" : "",
17957
- showVotePop ? "rt-seat-voting" : "",
17958
- isBelow ? "rt-seat-below" : "",
17959
- ].filter(Boolean).join(" ");
17960
-
17961
- // Inline style carries position + scaleHint + stagger index.
17962
- const style = [
17963
- `left: ${seat.x.toFixed(2)}%`,
17964
- `top: ${seat.y.toFixed(2)}%`,
17965
- `--rt-scale: ${seat.scaleHint.toFixed(3)}`,
17966
- `--seat-i: ${i}`,
17967
- ].join("; ");
17968
-
17969
- // Wait-marker · only on the user seat, only when the user
17970
- // has picked "wait — flush after current speaker finishes"
17971
- // (pendingUserMessage is set). Reads as a small pixel pill
17972
- // anchored to the seat so a glance at the table answers
17973
- // "is my queued message still parked?" — clears the moment
17974
- // the message-appended SSE for the user's body lands.
17975
- const waitMark = (isUser && this.pendingUserMessage)
17976
- ? `<div class="rt-seat-wait-mark" aria-label="Waiting for current speaker to finish">⌛&nbsp;WAIT</div>`
17977
- : "";
17978
-
17979
- return `
17980
- <div class="${cls}" data-seat-index="${i}" data-agent-id="${this.escape(m.id)}" style="${style}">
17981
- ${chairSvg}
17982
- ${avatar}
17983
- ${bubble}
17984
- ${badge}
17985
- ${waitMark}
17986
- ${name}
17987
- ${votePop}
17988
- </div>
17989
- `;
17990
- }).join("");
17991
-
17992
- // Empty state · no directors yet.
17993
- const empty = members.length <= 1
17994
- ? `<div class="rt-empty"><span>// awaiting directors</span></div>`
17995
- : "";
17996
-
17997
- seatsHost.innerHTML = html + empty;
17998
-
17999
- // Update aria-label to keep screen readers in sync with the
18000
- // visual state · the speaking-queue strip below remains the
19528
+ // Aria-label keeps screen readers in sync with the visible
19529
+ // speaker · the speaking-queue strip below remains the
18001
19530
  // canonical accessible source of truth, this is just a hint.
18002
19531
  const speaker = members.find((m) => m.id === speakingId);
18003
19532
  const queuedNames = (this.currentQueue || [])
@@ -18016,18 +19545,15 @@
18016
19545
  }
18017
19546
  stage.setAttribute("aria-label", aria);
18018
19547
 
18019
- // Persistent HUD repaint · cheap (single template literal +
18020
- // innerHTML write) and the data sources are the same room
18021
- // state already consulted above. Always called on round-table
18022
- // re-render so the stat block stays in sync with round /
18023
- // member / vote changes that don't fire toasts.
19548
+ // Persistent HUD + live subtitle repaint · cheap and the data
19549
+ // sources are the same room state already consulted above. The
19550
+ // message-token hot path calls renderRtSubtitle directly to
19551
+ // bypass full re-render cost; this call covers queue-update /
19552
+ // config-event / SSE hello cases that flow through here.
18024
19553
  this.renderRoundTableHud();
18025
- // Live subtitle · keep in sync with the same speaker-detection
18026
- // signals the seats use. Repaints triggered from message-token
18027
- // already call renderRtSubtitle directly to bypass the full
18028
- // seat re-render cost; this call covers queue-update / config-
18029
- // event / SSE hello cases that flow through renderRoundTable.
18030
19554
  this.renderRtSubtitle();
19555
+ return;
19556
+
18031
19557
  },
18032
19558
 
18033
19559
  /** Paint the top-left HUD console · gamified RPG-style status
@@ -20061,6 +21587,21 @@
20061
21587
  app.closeRecordingStopModal();
20062
21588
  return;
20063
21589
  }
21590
+ // Pause-after-current save modal · three-option grammar
21591
+ // (save-adjourn / save-keep / continue). Fired by SSE
21592
+ // room-paused when payload.mode==="soft" + recording active.
21593
+ const recPauseSaveChoice = e.target.closest("[data-rec-pause-save-choice]");
21594
+ if (recPauseSaveChoice) {
21595
+ e.preventDefault();
21596
+ app.handleRecordingPauseSaveChoice(recPauseSaveChoice.getAttribute("data-rec-pause-save-choice"));
21597
+ return;
21598
+ }
21599
+ if (e.target.id === "rec-pause-save-overlay") {
21600
+ // Backdrop click · keep recording (don't accidentally end
21601
+ // a recording the user explicitly hasn't decided to stop).
21602
+ app.closeRecordingPauseSaveModal();
21603
+ return;
21604
+ }
20064
21605
  // Record button in the room header · toggle on/off.
20065
21606
  if (e.target.closest("[data-record-toggle]")) {
20066
21607
  e.preventDefault();
@@ -21320,19 +22861,21 @@
21320
22861
  }
21321
22862
  });
21322
22863
 
21323
- // Input-bar textarea scrollbar reveal · the scrollbar is
21324
- // permanently hidden via `.ib-textarea { scrollbar-width: none }`
21325
- // and only flips visible while the user is actively scrolling.
21326
- // We toggle a `.is-scrolling` class on the SCROLLING element with
21327
- // a debounced removal so the scrollbar fades back out ~800 ms
21328
- // after the last scroll event. Scroll events don't bubble, so the
21329
- // listener is in capture phase to catch them from any textarea
21330
- // anywhere in the document.
22864
+ // Scrollbar auto-reveal · `.ib-textarea` (room input bar) and
22865
+ // `.thread-float-messages` (thread chat) both render with
22866
+ // `scrollbar-width: none` by default and only flip visible
22867
+ // while the user is actively scrolling. We toggle `.is-scrolling`
22868
+ // with an 800 ms debounce so the scrollbar fades back out
22869
+ // shortly after the last scroll. Scroll events don't bubble,
22870
+ // hence the capture-phase listener.
21331
22871
  const _scrollDecayTimers = new WeakMap();
21332
22872
  document.addEventListener("scroll", (ev) => {
21333
22873
  const t = ev.target;
21334
- if (!t || !(t instanceof HTMLTextAreaElement)) return;
21335
- if (!t.classList.contains("ib-textarea")) return;
22874
+ if (!t || !(t instanceof HTMLElement)) return;
22875
+ const eligible =
22876
+ (t instanceof HTMLTextAreaElement && t.classList.contains("ib-textarea"))
22877
+ || t.classList.contains("thread-float-messages");
22878
+ if (!eligible) return;
21336
22879
  t.classList.add("is-scrolling");
21337
22880
  const prev = _scrollDecayTimers.get(t);
21338
22881
  if (prev) clearTimeout(prev);