privateboard 0.1.37 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -1089,6 +1117,30 @@
1089
1117
  return;
1090
1118
  }
1091
1119
 
1120
+ // Thread guard · the main room view is not the right surface
1121
+ // for a thread (it expects a chair, multiple directors, the
1122
+ // full round / vote / brief pipeline). If the URL hash routed
1123
+ // to a thread (manual paste, stale link), redirect to the
1124
+ // parent and surface the thread as a float window so the user
1125
+ // lands somewhere coherent.
1126
+ if (data.room && data.room.kind === "thread") {
1127
+ const parentId = data.room.parentRoomId;
1128
+ if (parentId) {
1129
+ // Navigate to parent and re-mount the thread window.
1130
+ location.hash = "#/r/" + encodeURIComponent(parentId);
1131
+ if (data.room.threadDirectorId) {
1132
+ // Defer until after the route handler settles into the
1133
+ // parent so currentRoomId / agentsById are populated.
1134
+ setTimeout(() => {
1135
+ this.openThreadWith(data.room.threadDirectorId);
1136
+ }, 200);
1137
+ }
1138
+ } else {
1139
+ this.closeRoom();
1140
+ }
1141
+ return;
1142
+ }
1143
+
1092
1144
  // We may be coming from the All Reports view OR an agent profile
1093
1145
  // — make sure the room main view is the visible one and the
1094
1146
  // others are hidden. The agent-view hide is what stops a stale
@@ -1296,6 +1348,12 @@
1296
1348
  console.error("[openRoom] renderRoom failed:", err);
1297
1349
  }
1298
1350
  this.markActiveRoom(roomId);
1351
+ // Restore any thread float windows the user had open for this
1352
+ // room before the last page reload · the threads themselves
1353
+ // live in the DB; this just rebuilds the floating UI for the
1354
+ // ones the user had visible. Fire-and-forget · failure here
1355
+ // never blocks room load.
1356
+ this.restoreThreadsForRoom(roomId);
1299
1357
  // Round-table stage · paint + decide whether the .chat or
1300
1358
  // .roundtable-stage should be visible for this room. Runs
1301
1359
  // AFTER renderRoom so the DOM exists and currentRoom /
@@ -1369,6 +1427,19 @@
1369
1427
  },
1370
1428
 
1371
1429
  async closeRoom() {
1430
+ // Thread cleanup · route through `closeThreadWindow` so the
1431
+ // localStorage persistence entry is dropped too. Per the
1432
+ // updated UX rule, leaving a room means the thread chat is
1433
+ // CLOSED — coming back to the same room later should land
1434
+ // on a clean main view, not auto-restore an old thread.
1435
+ // closeThreadWindow handles DOM, SSE, body.has-thread-dock,
1436
+ // and `pb.thread.open.<parentRoomId>` cleanup in one place.
1437
+ if (this._threads) {
1438
+ for (const tid of Object.keys(this._threads)) {
1439
+ this.closeThreadWindow(tid);
1440
+ }
1441
+ }
1442
+ document.body.classList.remove("has-thread-dock");
1372
1443
  // Recording guard · if a meeting capture is in progress for
1373
1444
  // the current room, intercept and let the user choose:
1374
1445
  // stop-and-save before leaving or cancel and stay. We re-route
@@ -2099,6 +2170,26 @@
2099
2170
  this._pendingAdjournAfterRecordStop = false;
2100
2171
  try { this.adjournRoom({ skipBrief: true }); }
2101
2172
  catch (e) { console.warn("[recorder] pending adjourn failed", e); }
2173
+ } else if (
2174
+ // Vanilla pause-after-current path · the user clicked
2175
+ // "Pause after current director" while a recording was
2176
+ // running. Without an explicit prompt the dialog would
2177
+ // never appear and the recording would keep capturing
2178
+ // a silent room. Surface a save dialog so the user can
2179
+ // commit the clip + optionally adjourn before audio
2180
+ // chunks of dead air pile up. Skip when:
2181
+ // · pauseMode === "hard" · interrupt path (user
2182
+ // already saw the choice modal)
2183
+ // · _pendingAdjournAfterRecordStop · handled above
2184
+ // · _pendingReloadOnPause · user picked reload
2185
+ // · recording-save modal already on screen (defensive)
2186
+ payload.mode === "soft"
2187
+ && !this._pendingReloadOnPause
2188
+ && window.BoardroomRecorder
2189
+ && window.BoardroomRecorder.isRecording()
2190
+ && !document.getElementById("rec-pause-save-overlay")
2191
+ ) {
2192
+ this.openRecordingPauseSaveModal();
2102
2193
  }
2103
2194
  } else if (kind === "room-resumed") {
2104
2195
  if (this.currentRoom) {
@@ -5147,6 +5238,1296 @@
5147
5238
  }
5148
5239
  },
5149
5240
 
5241
+ /* ─── Private thread · 1:1 director aside (Phase 1) ───────
5242
+ The user pulls a single director into a Slack-DM-style floating
5243
+ window for a private conversation. Persists as its own `rooms`
5244
+ row (kind="thread") with parent_room_id pointing here, so:
5245
+ · subscribe to its own SSE stream
5246
+ · messages flow through the existing POST /messages route
5247
+ · the orchestrator's buildDirectorContext detects kind==="thread"
5248
+ and seeds the LLM with the parent room's snapshot
5249
+ Storage / drag-window mounting / SSE plumbing all live in
5250
+ these methods. State map below tracks the active windows by
5251
+ threadId so subsequent opens reuse existing windows. */
5252
+
5253
+ /** SVG icon string · shared by the per-bubble trigger and the
5254
+ * head-cast badge. Lucide-style "reply" curve (arrow back to
5255
+ * the speaker) reads as IM-grammar for "respond privately". */
5256
+ _threadTriggerIconSvg() {
5257
+ 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>`;
5258
+ },
5259
+
5260
+ /** Discord-style floating message toolbar · appears on article
5261
+ * hover at the top-right of the bubble. Pure icons (no labels)
5262
+ * to keep the toolbar compact; the icon vocabulary is small and
5263
+ * reads instantly on hover. Future per-message actions (quote,
5264
+ * react, pin) slot as additional buttons inside the same
5265
+ * toolbar chrome. Returns "" when no actions apply (user / chair
5266
+ * / excused / thread room / adjourned). */
5267
+ _messageToolbarHtml(m, isUser, isChair, excused, author) {
5268
+ const trigger = this._threadTriggerHtml(m, isUser, isChair, excused, author);
5269
+ if (!trigger) return "";
5270
+ return `<div class="msg-toolbar">${trigger}</div>`;
5271
+ },
5272
+
5273
+ /** Reply chip · icon + "Thread" label as a compact pill. Single
5274
+ * slot inside the Discord-style hover toolbar. Returns "" when
5275
+ * threading doesn't apply (see `_messageToolbarHtml`'s wrapper). */
5276
+ _threadTriggerHtml(m, isUser, isChair, excused, author) {
5277
+ if (isUser || isChair || excused) return "";
5278
+ if (!author || !author.id) return "";
5279
+ // Skip when rendering inside a thread window · `_threadMessageHtml`
5280
+ // flips this flag for the duration of its messageHtml call so
5281
+ // thread bubbles don't ship a "open another thread with this
5282
+ // same director" trigger (which already exists · the user is
5283
+ // looking at that thread right now).
5284
+ if (this._renderingThread) return "";
5285
+ const room = this.currentRoom;
5286
+ // Threads can be spawned from a room in any status · live,
5287
+ // paused, OR adjourned. Re-reading an adjourned session and
5288
+ // wanting to follow up with one director privately is a real
5289
+ // use case ("I want to go deeper on what Socrates said in that
5290
+ // meeting from last week"). The thread itself is its own
5291
+ // independent room — the parent's status doesn't constrain it.
5292
+ // Only suppress when there's no room loaded OR we're already
5293
+ // inside a thread (no nested threads).
5294
+ if (!room) return "";
5295
+ if (room.kind === "thread") return "";
5296
+ const label = this._t("thread_trigger_label") || "Thread";
5297
+ const tip = this._t("thread_trigger_tip", { name: author.name || "" })
5298
+ || `Pull ${author.name || "this director"} aside privately`;
5299
+ 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>`;
5300
+ },
5301
+
5302
+ async openThreadWith(directorId) {
5303
+ if (!this.currentRoomId || !directorId) return;
5304
+ // De-dupe · if a thread window for (this room, this director)
5305
+ // already exists, just restore it. Avoids spawning a duplicate
5306
+ // DB row + duplicate SSE stream when the user clicks the
5307
+ // "thread" trigger repeatedly.
5308
+ this._threads = this._threads || {};
5309
+ for (const [tid, t] of Object.entries(this._threads)) {
5310
+ if (t.director && t.director.id === directorId) {
5311
+ this.restoreThreadWindow(tid);
5312
+ return;
5313
+ }
5314
+ }
5315
+ // In-flight create guard · the de-dupe above misses the race
5316
+ // where the user double-clicks (or two trigger buttons for the
5317
+ // same director fire concurrently): both clicks see _threads
5318
+ // empty, both POST, server creates TWO thread rows, both windows
5319
+ // mount, the user perceives "the app keeps spawning new rooms."
5320
+ // Track creates per-director with a Set so the second click
5321
+ // becomes a no-op until the first POST resolves.
5322
+ this._threadsCreating = this._threadsCreating || new Set();
5323
+ if (this._threadsCreating.has(directorId)) return;
5324
+ this._threadsCreating.add(directorId);
5325
+ // Capture the parent roomId at click time · if the user
5326
+ // navigates to a different room while the POST is in flight,
5327
+ // we still want the thread to attach to the room the click
5328
+ // originated from, not the room currently showing.
5329
+ const parentRoomId = this.currentRoomId;
5330
+ try {
5331
+ const res = await fetch(`/api/rooms/${encodeURIComponent(parentRoomId)}/threads`, {
5332
+ method: "POST",
5333
+ headers: { "content-type": "application/json" },
5334
+ body: JSON.stringify({ directorId }),
5335
+ });
5336
+ if (!res.ok) {
5337
+ const err = await res.json().catch(() => ({}));
5338
+ alert((err && err.error) || "Failed to open thread");
5339
+ return;
5340
+ }
5341
+ const data = await res.json();
5342
+ const director = (this.agentsById && this.agentsById[directorId])
5343
+ || (data.members || []).find((m) => m.id === directorId)
5344
+ || { id: directorId, name: directorId, avatarPath: "" };
5345
+ // Default to docked mode · the user explicitly asked for the
5346
+ // side-panel as the primary thread surface. Narrow viewports
5347
+ // (`_canDockHere` returns false below 1200px) silently fall
5348
+ // back to the floating mode since the chat-col would
5349
+ // otherwise be squeezed below readable width.
5350
+ this.mountThreadWindow(data.room, director, { docked: this._canDockHere() });
5351
+ } catch (e) {
5352
+ alert((e && e.message) || "Failed to open thread");
5353
+ } finally {
5354
+ this._threadsCreating.delete(directorId);
5355
+ }
5356
+ },
5357
+
5358
+ mountThreadWindow(threadRoom, director, opts) {
5359
+ this._threads = this._threads || {};
5360
+ // Idempotent · if the user already has this thread mounted
5361
+ // (e.g. localStorage restore racing with an active session),
5362
+ // just restore the existing window instead of building a
5363
+ // duplicate DOM.
5364
+ if (this._threads[threadRoom.id]) {
5365
+ this.restoreThreadWindow(threadRoom.id);
5366
+ return;
5367
+ }
5368
+ // Single-thread-per-room rule · the room may only have ONE
5369
+ // thread surfaced at a time (float OR docked). Opening a new
5370
+ // one closes any existing thread first. This sidesteps the
5371
+ // "main chat squeezed by stacked docked panels" problem and
5372
+ // keeps the maximize/restore toggle's mental model simple.
5373
+ const existingIds = Object.keys(this._threads);
5374
+ for (const oldId of existingIds) {
5375
+ if (oldId !== threadRoom.id) this.closeThreadWindow(oldId);
5376
+ }
5377
+ // Threads are PANEL-ONLY · always dock into the active room view
5378
+ // (the floating-window mode was removed). When no room view is
5379
+ // mounted (defensive · the trigger only fires from inside a room)
5380
+ // we fall back to a body-level overlay. On viewports < 1200px the
5381
+ // `.is-docked` CSS @media renders the panel as a fixed overlay,
5382
+ // which is the narrow-screen substitute for a full side panel.
5383
+ void opts;
5384
+ const dockHost = this._threadDockHost();
5385
+ const startDocked = !!dockHost;
5386
+
5387
+ const root = document.createElement("div");
5388
+ root.className = "thread-float" + (startDocked ? " is-docked" : "");
5389
+ root.setAttribute("data-thread-id", threadRoom.id);
5390
+ root.setAttribute("data-director-id", director.id);
5391
+ if (!startDocked) {
5392
+ root.style.right = "24px";
5393
+ root.style.bottom = "24px";
5394
+ }
5395
+
5396
+ const escape = (s) => String(s == null ? "" : s)
5397
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
5398
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5399
+ const avatarSrc = director.avatarPath ? escape(director.avatarPath) : "";
5400
+ // Title / subtitle resolution · same priority as the popover
5401
+ // row: LLM-distilled `room.name` > truncated `room.subject` >
5402
+ // director's name as last-resort fallback. Director's name
5403
+ // ALWAYS shows in the subtitle so the user reads "what we're
5404
+ // talking about · with whom".
5405
+ const headerTitle = this._resolveThreadTitle(threadRoom, director);
5406
+ const headerSubtitle = director.name || director.id || "";
5407
+ // Head buttons are icon-only (Lucide mask via CSS · see
5408
+ // .thread-float-head-btn rules in thread.css). aria-label
5409
+ // carries the action name for screen readers + the `title`
5410
+ // attribute provides the hover tooltip. No textContent.
5411
+ const minTip = this._t("thread_minimize_tip") || "Minimize";
5412
+ const closeTip = this._t("thread_close_tip") || "Close";
5413
+ const emptyTitle = this._t("thread_empty_title") || "Pull them aside";
5414
+ const emptyDeck = this._t("thread_empty_deck", { name: director.name || "" })
5415
+ || "What does {name} know that you want to dig into? They have the full room context up to this point.";
5416
+ const placeholder = this._t("thread_composer_placeholder", { name: director.name || "" })
5417
+ || `Ask ${director.name || "them"} anything…`;
5418
+ const sendLabel = this._t("thread_send") || "Send";
5419
+
5420
+ root.innerHTML = `
5421
+ <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>
5422
+ <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>
5423
+ <header class="thread-float-head" data-thread-drag>
5424
+ ${avatarSrc
5425
+ ? `<img class="thread-float-head-avatar" src="${avatarSrc}" alt="" data-agent="${escape(director.id)}" title="${escape(director.name || "")}">`
5426
+ : `<span class="thread-float-head-avatar" data-agent="${escape(director.id)}" title="${escape(director.name || "")}"></span>`}
5427
+ <div class="thread-float-meta-wrap thread-float-head-meta">
5428
+ <span class="thread-float-head-title">${escape(headerTitle)}</span>
5429
+ <span class="thread-float-head-subtitle">${escape(headerSubtitle)}</span>
5430
+ </div>
5431
+ <div class="thread-float-head-controls">
5432
+ <button type="button" class="thread-float-head-btn" data-thread-minimize title="${escape(minTip)}" aria-label="${escape(minTip)}"></button>
5433
+ <button type="button" class="thread-float-head-btn" data-thread-close title="${escape(closeTip)}" aria-label="${escape(closeTip)}"></button>
5434
+ </div>
5435
+ </header>
5436
+ <div class="thread-float-messages" data-thread-messages>
5437
+ <div class="thread-float-empty" data-thread-empty>
5438
+ <span class="thread-float-empty-tag">// ${escape(emptyTitle)}</span>
5439
+ <span>${escape(emptyDeck)}</span>
5440
+ </div>
5441
+ </div>
5442
+ <div class="thread-float-composer" data-thread-composer>
5443
+ <div class="thread-float-composer-text-row">
5444
+ <textarea class="thread-float-textarea" data-thread-input placeholder="${escape(placeholder)}" rows="1"></textarea>
5445
+ </div>
5446
+ <div class="thread-float-composer-controls-row">
5447
+ <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>
5448
+ <button type="button" class="thread-float-send" data-thread-send title="${escape(sendLabel)}" aria-label="${escape(sendLabel)}">${escape(sendLabel)}</button>
5449
+ <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>
5450
+ </div>
5451
+ </div>
5452
+ <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>
5453
+ `;
5454
+ // Mount target depends on docked vs float mode.
5455
+ // · Float (default): body-level overlay (fixed positioning).
5456
+ // · Docked: side-by-side with chat-col inside the active
5457
+ // room main-view, so the chat is genuinely beside the
5458
+ // thread and not under a floating chip.
5459
+ if (startDocked && dockHost) {
5460
+ dockHost.appendChild(root);
5461
+ document.body.classList.add("has-thread-dock");
5462
+ } else {
5463
+ document.body.appendChild(root);
5464
+ }
5465
+
5466
+ // Per-thread state · SSE + drag-offset bookkeeping. Stored on
5467
+ // app so the SSE callbacks + drag handlers can find their
5468
+ // window even when many threads are open.
5469
+ const state = {
5470
+ threadId: threadRoom.id,
5471
+ director,
5472
+ room: threadRoom,
5473
+ windowEl: root,
5474
+ sse: null,
5475
+ messages: [],
5476
+ minimized: false,
5477
+ // Threads are panel-only · `docked` is true whenever a room
5478
+ // view exists to host the side panel (the float-window mode +
5479
+ // its maximize/restore toggle were removed). The only time
5480
+ // this is false is the defensive no-room-view fallback, where
5481
+ // the panel renders as a body-level overlay.
5482
+ docked: startDocked,
5483
+ // Drag translate offset · still honoured for the narrow-
5484
+ // viewport fallback where `.is-docked` renders as a fixed,
5485
+ // draggable overlay (see thread.css @media max-width:1200px).
5486
+ dragX: 0,
5487
+ dragY: 0,
5488
+ // User-resized height (px) · null until the user drags the
5489
+ // top edge. Persisted across minimize/restore so the chosen
5490
+ // height survives toggling the chip. The window is bottom-
5491
+ // anchored, so a taller height grows upward.
5492
+ height: null,
5493
+ };
5494
+ this._threads[threadRoom.id] = state;
5495
+ // Persist open-window membership across page reloads · the
5496
+ // thread row itself lives in the DB regardless, but the user's
5497
+ // CHOICE to have a window open for it is client-only state.
5498
+ this._persistThreadOpen(threadRoom.parentRoomId, threadRoom.id, true);
5499
+
5500
+ // Wire interactions
5501
+ root.querySelector("[data-thread-minimize]").addEventListener("click", () => {
5502
+ this.minimizeThreadWindow(threadRoom.id);
5503
+ });
5504
+ root.querySelector("[data-thread-close]").addEventListener("click", () => {
5505
+ this.closeThreadWindow(threadRoom.id);
5506
+ });
5507
+ // Left-edge resize handle · only matters in docked mode (CSS
5508
+ // hides it in float). Wires up unconditionally so the toggle
5509
+ // between float/dock doesn't need to re-bind.
5510
+ const dockResizeHandle = root.querySelector("[data-thread-dock-resize]");
5511
+ if (dockResizeHandle) this._wireThreadDockResize(dockResizeHandle);
5512
+ const dragHead = root.querySelector("[data-thread-drag]");
5513
+ this._wireThreadDrag(dragHead, state);
5514
+ root.querySelectorAll("[data-thread-resize]").forEach((h) => {
5515
+ this._wireThreadResize(h, state, h.getAttribute("data-thread-resize") === "bottom" ? "bottom" : "top");
5516
+ });
5517
+ const sendBtn = root.querySelector("[data-thread-send]");
5518
+ const stopBtn = root.querySelector("[data-thread-stop]");
5519
+ const jumpTopBtn = root.querySelector("[data-thread-jump-top]");
5520
+ const input = root.querySelector("[data-thread-input]");
5521
+ sendBtn.addEventListener("click", () => this.sendThreadMessage(threadRoom.id));
5522
+ stopBtn.addEventListener("click", () => this.stopThreadDirector(threadRoom.id));
5523
+ // Jump-to-top · mirror of the room input-bar's `[data-jump-to-opener]`
5524
+ // affordance · scroll the thread message list back to its first
5525
+ // message. Smooth-scrolling the messages container itself rather
5526
+ // than the viewport · the thread panel has its own scroll context
5527
+ // (absolute-positioned `.thread-float-messages`).
5528
+ if (jumpTopBtn) {
5529
+ jumpTopBtn.addEventListener("click", () => {
5530
+ const list = root.querySelector("[data-thread-messages]");
5531
+ if (list) list.scrollTop = 0;
5532
+ });
5533
+ }
5534
+ input.addEventListener("keydown", (e) => {
5535
+ if (e.key !== "Enter" || e.shiftKey) return;
5536
+ // IME composition guard · while a Chinese / Japanese / Korean
5537
+ // input method is open (pinyin candidate row visible, kana
5538
+ // pre-conversion buffer, etc.) the Enter key is the user
5539
+ // committing their candidate selection, NOT submitting the
5540
+ // message. Firing send here would dispatch the half-typed
5541
+ // raw romanisation. `isComposing` is the standardised
5542
+ // property; `keyCode === 229` is the legacy Chrome/Edge
5543
+ // signal that fires before isComposing flips in some IME
5544
+ // engines. Both checks together cover the matrix.
5545
+ if (e.isComposing || e.keyCode === 229) return;
5546
+ e.preventDefault();
5547
+ this.sendThreadMessage(threadRoom.id);
5548
+ });
5549
+ // Auto-grow textarea up to max-height · 200px ceiling matches
5550
+ // the room's `.ib-textarea` so a long compose feels identical
5551
+ // across both surfaces (CSS cap is also 200px).
5552
+ input.addEventListener("input", () => {
5553
+ input.style.height = "auto";
5554
+ input.style.height = Math.min(200, input.scrollHeight) + "px";
5555
+ });
5556
+ // Quote seed · the quote-cta bar's Thread button stashes the
5557
+ // selected director text on `_threadPendingQuote[directorId]`
5558
+ // BEFORE calling openThreadWith. Consume it here so the new
5559
+ // thread's composer opens with a markdown blockquote ready —
5560
+ // the user just types their follow-up question and hits send.
5561
+ // Cleared after consumption so subsequent opens don't re-paste.
5562
+ const pending = this._threadPendingQuote && this._threadPendingQuote[director.id];
5563
+ if (pending && pending.text) {
5564
+ const quoteLines = String(pending.text).split(/\r?\n/).map((l) => "> " + l);
5565
+ if (pending.directorName) quoteLines.push("> — @" + pending.directorName);
5566
+ input.value = quoteLines.join("\n") + "\n\n";
5567
+ // Trigger autosize after the seed lands.
5568
+ input.style.height = "auto";
5569
+ input.style.height = Math.min(200, input.scrollHeight) + "px";
5570
+ // Cursor goes after the blank line so the user types
5571
+ // immediately at the follow-up position.
5572
+ const pos = input.value.length;
5573
+ try { input.setSelectionRange(pos, pos); } catch (_) {}
5574
+ delete this._threadPendingQuote[director.id];
5575
+ }
5576
+ setTimeout(() => { try { input.focus(); } catch (_) {} }, 30);
5577
+
5578
+ // SSE subscription · the thread's own per-room stream. We get
5579
+ // message-appended / message-token / message-final / message-updated
5580
+ // for the thread's messages; main room events fan out via the
5581
+ // main app.sse and don't reach here.
5582
+ try {
5583
+ const es = new EventSource(`/api/rooms/${encodeURIComponent(threadRoom.id)}/stream`);
5584
+ state.sse = es;
5585
+ es.addEventListener("message-appended", (ev) => {
5586
+ const data = JSON.parse(ev.data);
5587
+ this._threadAppendMessage(threadRoom.id, data);
5588
+ });
5589
+ es.addEventListener("message-token", (ev) => {
5590
+ const data = JSON.parse(ev.data);
5591
+ this._threadApplyToken(threadRoom.id, data);
5592
+ });
5593
+ es.addEventListener("message-updated", (ev) => {
5594
+ const data = JSON.parse(ev.data);
5595
+ this._threadUpdateMessage(threadRoom.id, data);
5596
+ });
5597
+ es.addEventListener("message-final", (ev) => {
5598
+ const data = JSON.parse(ev.data);
5599
+ this._threadFinalizeMessage(threadRoom.id, data);
5600
+ });
5601
+ es.addEventListener("message-removed", (ev) => {
5602
+ const data = JSON.parse(ev.data);
5603
+ this._threadRemoveMessage(threadRoom.id, data);
5604
+ });
5605
+ } catch (e) {
5606
+ console.warn("[thread] SSE attach failed:", e);
5607
+ }
5608
+ },
5609
+
5610
+ closeThreadWindow(threadId) {
5611
+ const state = this._threads && this._threads[threadId];
5612
+ if (!state) return;
5613
+ if (state.sse) {
5614
+ try { state.sse.close(); } catch (_) {}
5615
+ state.sse = null;
5616
+ }
5617
+ if (state.windowEl && state.windowEl.parentNode) {
5618
+ state.windowEl.parentNode.removeChild(state.windowEl);
5619
+ }
5620
+ const parentId = state.room && state.room.parentRoomId;
5621
+ delete this._threads[threadId];
5622
+ if (parentId) this._persistThreadOpen(parentId, threadId, false);
5623
+ // If no docked thread remains, drop the body class that
5624
+ // squeezed the chat-col into its left grid column.
5625
+ const anyDocked = Object.values(this._threads || {}).some((s) => s && s.docked);
5626
+ if (!anyDocked) document.body.classList.remove("has-thread-dock");
5627
+ },
5628
+
5629
+ /** Title shown in the panel header AND in the popover row · same
5630
+ * priority chain as `openThreadsListPopover` so both surfaces
5631
+ * read the same string. LLM-distilled `room.name` wins; until
5632
+ * the title-gen pipeline completes (or for legacy threads
5633
+ * with name_auto=0), the truncated subject stands in; the
5634
+ * director's name is a last-resort fallback for fresh threads
5635
+ * with no user message yet. */
5636
+ _resolveThreadTitle(threadRoom, director) {
5637
+ if (!threadRoom) return (director && director.name) || "";
5638
+ const isPlaceholder = typeof threadRoom.name === "string"
5639
+ && /^thread:/.test(threadRoom.name);
5640
+ if (!isPlaceholder && typeof threadRoom.name === "string" && threadRoom.name.trim()) {
5641
+ return threadRoom.name.trim();
5642
+ }
5643
+ if (typeof threadRoom.subject === "string" && threadRoom.subject.trim()) {
5644
+ const subj = threadRoom.subject.trim();
5645
+ return subj.length > 60 ? subj.slice(0, 60) + "…" : subj;
5646
+ }
5647
+ return (director && director.name) || "";
5648
+ },
5649
+
5650
+ /** Is the current viewport wide enough to honour docked mode?
5651
+ * Below ~1200px the 420px panel would squeeze the chat to an
5652
+ * uncomfortable width; in that case we keep the thread float-
5653
+ * only regardless of the user's maximize click. */
5654
+ _canDockHere() {
5655
+ try {
5656
+ const w = window.innerWidth || document.documentElement.clientWidth || 0;
5657
+ return w >= 1200;
5658
+ } catch (_) { return false; }
5659
+ },
5660
+
5661
+ /** Where to mount a docked thread panel · the active room
5662
+ * main-view, so the panel becomes a flex/grid sibling of the
5663
+ * chat-col. Returns null if no room view is active (defensive
5664
+ * · should never fire because the trigger is only reachable
5665
+ * from inside a room). */
5666
+ _threadDockHost() {
5667
+ const view = document.querySelector('.main-view[data-main-view="room"]');
5668
+ return view && !view.hidden ? view : null;
5669
+ },
5670
+
5671
+ /** Wire the left-edge resize handle so the user can drag-widen
5672
+ * the docked panel. Reads pointer-x on move, computes the new
5673
+ * width as `viewport_width - pointer_x` (panel is anchored to
5674
+ * the right edge), and writes the value to `--thread-dock-w`
5675
+ * on document body. CSS clamps the variable into a sane range
5676
+ * (320-720) so the chat-col never collapses below input-bar
5677
+ * content width. Persists to localStorage so the user's chosen
5678
+ * width survives page reload. */
5679
+ _wireThreadDockResize(handle) {
5680
+ // Hydrate any saved width on first wire so the dock opens at
5681
+ // the user's last-chosen size.
5682
+ const restorePersistedWidth = () => {
5683
+ try {
5684
+ const raw = localStorage.getItem("pb.thread.dock.width");
5685
+ const n = raw ? parseInt(raw, 10) : 0;
5686
+ if (n >= 320 && n <= 720) {
5687
+ document.body.style.setProperty("--thread-dock-w", `${n}px`);
5688
+ }
5689
+ } catch (_) { /* */ }
5690
+ };
5691
+ restorePersistedWidth();
5692
+
5693
+ let dragging = false;
5694
+ let rafId = 0;
5695
+ let pendingX = 0;
5696
+ const flush = () => {
5697
+ rafId = 0;
5698
+ if (!dragging) return;
5699
+ const vw = window.innerWidth || document.documentElement.clientWidth || 0;
5700
+ // Panel sits on the right; new width = distance from
5701
+ // viewport right edge to pointer. Clamp to handle range so
5702
+ // mid-drag the CSS clamp doesn't lag behind the variable.
5703
+ const next = Math.max(320, Math.min(720, vw - pendingX));
5704
+ document.body.style.setProperty("--thread-dock-w", `${next}px`);
5705
+ };
5706
+ const onMove = (e) => {
5707
+ if (!dragging) return;
5708
+ if (e.cancelable) e.preventDefault();
5709
+ pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
5710
+ if (!rafId) rafId = requestAnimationFrame(flush);
5711
+ };
5712
+ const onUp = () => {
5713
+ if (!dragging) return;
5714
+ dragging = false;
5715
+ document.body.classList.remove("is-thread-dock-resizing");
5716
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
5717
+ document.removeEventListener("mousemove", onMove);
5718
+ document.removeEventListener("mouseup", onUp);
5719
+ document.removeEventListener("touchmove", onMove);
5720
+ document.removeEventListener("touchend", onUp);
5721
+ // Persist whatever value we landed on. Read back the
5722
+ // computed style rather than the inline string so any
5723
+ // clamp-by-CSS adjustment is honoured.
5724
+ try {
5725
+ const v = document.body.style.getPropertyValue("--thread-dock-w");
5726
+ const n = parseInt(v, 10);
5727
+ if (n >= 320 && n <= 720) {
5728
+ localStorage.setItem("pb.thread.dock.width", String(n));
5729
+ }
5730
+ } catch (_) { /* private mode · ignore */ }
5731
+ };
5732
+ const onDown = (e) => {
5733
+ // Only honour the drag when the panel is actually docked ·
5734
+ // CSS already hides the handle off-screen otherwise, but
5735
+ // defensive guard for keyboard / programmatic invocations.
5736
+ const panel = handle.closest(".thread-float");
5737
+ if (!panel || !panel.classList.contains("is-docked")) return;
5738
+ dragging = true;
5739
+ document.body.classList.add("is-thread-dock-resizing");
5740
+ pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
5741
+ document.addEventListener("mousemove", onMove);
5742
+ document.addEventListener("mouseup", onUp);
5743
+ document.addEventListener("touchmove", onMove, { passive: false });
5744
+ document.addEventListener("touchend", onUp);
5745
+ };
5746
+ handle.addEventListener("mousedown", onDown);
5747
+ handle.addEventListener("touchstart", onDown, { passive: true });
5748
+ },
5749
+
5750
+ /** Client-side membership · which thread IDs the user has a
5751
+ * window open for, keyed by parent roomId. localStorage so the
5752
+ * set survives page reload. Stored as a JSON array per room
5753
+ * under `pb.thread.open.<roomId>` (mirrors the rest of the
5754
+ * app's `pb.*` localStorage namespace). */
5755
+ _persistThreadOpen(parentRoomId, threadId, isOpen) {
5756
+ if (!parentRoomId || !threadId) return;
5757
+ const key = `pb.thread.open.${parentRoomId}`;
5758
+ try {
5759
+ const raw = localStorage.getItem(key);
5760
+ const ids = raw ? JSON.parse(raw) : [];
5761
+ const next = isOpen
5762
+ ? Array.from(new Set([...ids, threadId]))
5763
+ : ids.filter((id) => id !== threadId);
5764
+ if (next.length) localStorage.setItem(key, JSON.stringify(next));
5765
+ else localStorage.removeItem(key);
5766
+ } catch (_) { /* quota / private mode · silently degrade */ }
5767
+ },
5768
+
5769
+ _persistedThreadOpen(parentRoomId) {
5770
+ if (!parentRoomId) return [];
5771
+ try {
5772
+ const raw = localStorage.getItem(`pb.thread.open.${parentRoomId}`);
5773
+ const arr = raw ? JSON.parse(raw) : [];
5774
+ return Array.isArray(arr) ? arr : [];
5775
+ } catch { return []; }
5776
+ },
5777
+
5778
+ /** Re-mount any thread windows the user previously had open for
5779
+ * the current room. Called from openRoom AFTER members /
5780
+ * agentsById are populated so the director resolution works.
5781
+ * Threads whose IDs no longer exist (deleted out from under
5782
+ * the user) are quietly dropped from the persisted list. */
5783
+ async restoreThreadsForRoom(parentRoomId) {
5784
+ if (!parentRoomId) return;
5785
+ const wanted = this._persistedThreadOpen(parentRoomId);
5786
+ if (wanted.length === 0) return;
5787
+ // Pull the canonical list from the server so we know which
5788
+ // threads still exist. The persisted IDs are advisory.
5789
+ let threads = [];
5790
+ try {
5791
+ const res = await fetch(`/api/rooms/${encodeURIComponent(parentRoomId)}/threads`);
5792
+ if (res.ok) {
5793
+ const data = await res.json();
5794
+ threads = Array.isArray(data.threads) ? data.threads : [];
5795
+ }
5796
+ } catch { return; }
5797
+ const live = new Map(threads.map((t) => [t.id, t]));
5798
+ const stillOpen = [];
5799
+ for (const id of wanted) {
5800
+ const room = live.get(id);
5801
+ if (!room) continue;
5802
+ const director = (this.agentsById && this.agentsById[room.threadDirectorId])
5803
+ || { id: room.threadDirectorId, name: room.threadDirectorId, avatarPath: "" };
5804
+ // Restore in the same mode the user opens NEW threads with.
5805
+ // localStorage doesn't track float-vs-dock yet; default to
5806
+ // dock when the viewport allows so the user's last session's
5807
+ // dock preference is honoured implicitly.
5808
+ this.mountThreadWindow(room, director, { docked: this._canDockHere() });
5809
+ stillOpen.push(id);
5810
+ // Seed the transcript · /threads list endpoint only returns
5811
+ // room metadata, so we fetch the per-thread state to get
5812
+ // messages and paint them into the freshly-mounted window.
5813
+ // Fire-and-forget · each thread loads independently and the
5814
+ // SSE stream picks up new messages from this point on.
5815
+ fetch(`/api/rooms/${encodeURIComponent(id)}`)
5816
+ .then((res) => (res.ok ? res.json() : null))
5817
+ .then((data) => {
5818
+ if (!data || !Array.isArray(data.messages) || data.messages.length === 0) return;
5819
+ this._seedThreadHistory(id, data.messages);
5820
+ })
5821
+ .catch(() => { /* per-thread network blip · log via the SSE error path */ });
5822
+ }
5823
+ // Prune the persisted list to what actually exists · keeps the
5824
+ // localStorage entry from accumulating stale IDs forever.
5825
+ try {
5826
+ const key = `pb.thread.open.${parentRoomId}`;
5827
+ if (stillOpen.length) localStorage.setItem(key, JSON.stringify(stillOpen));
5828
+ else localStorage.removeItem(key);
5829
+ } catch (_) { /* */ }
5830
+ },
5831
+
5832
+ /** Open a popover anchored under the head-threads icon listing
5833
+ * every thread (active or dormant) attached to this main room.
5834
+ * Each row is a click target that mounts / restores the thread
5835
+ * window. ESC + outside-click dismiss. */
5836
+ async openThreadsListPopover(anchorBtn) {
5837
+ this.closeThreadsListPopover();
5838
+ if (!this.currentRoomId) return;
5839
+ const pop = document.createElement("div");
5840
+ pop.id = "thread-list-pop";
5841
+ pop.className = "thread-list-pop";
5842
+ const title = this._t("threads_list_title") || "Private threads";
5843
+ const loading = this._t("threads_list_loading") || "Loading…";
5844
+ pop.innerHTML = `
5845
+ <div class="thread-list-head">${this.escape(title)}</div>
5846
+ <div class="thread-list-body" data-threads-body>
5847
+ <div class="thread-list-empty">${this.escape(loading)}</div>
5848
+ </div>
5849
+ `;
5850
+ document.body.appendChild(pop);
5851
+ // Anchor under the trigger button · `position: fixed` so values
5852
+ // are viewport coords. Right-align so the popover stays under
5853
+ // the icon when the head row contracts on narrow viewports.
5854
+ const r = anchorBtn.getBoundingClientRect();
5855
+ const POP_WIDTH = 320;
5856
+ pop.style.top = `${Math.round(r.bottom + 6)}px`;
5857
+ const rightSpace = window.innerWidth - r.right;
5858
+ pop.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - POP_WIDTH - 8, r.right - POP_WIDTH)))}px`;
5859
+ void rightSpace;
5860
+
5861
+ // Dismissal · outside click + ESC. Same pattern as
5862
+ // closeCastEditOverlay's wiring.
5863
+ this._threadsPopDocClick = (e) => {
5864
+ if (e.target.closest("#thread-list-pop")) return;
5865
+ if (e.target.closest("[data-threads-trigger]")) return;
5866
+ this.closeThreadsListPopover();
5867
+ };
5868
+ this._threadsPopEsc = (e) => {
5869
+ if (e.key === "Escape") {
5870
+ e.preventDefault();
5871
+ this.closeThreadsListPopover();
5872
+ }
5873
+ };
5874
+ setTimeout(() => document.addEventListener("click", this._threadsPopDocClick, true), 0);
5875
+ document.addEventListener("keydown", this._threadsPopEsc, true);
5876
+
5877
+ // Fetch threads · canonical source is the server's
5878
+ // /api/rooms/:id/threads endpoint we already shipped.
5879
+ let threads = [];
5880
+ try {
5881
+ const res = await fetch(`/api/rooms/${encodeURIComponent(this.currentRoomId)}/threads`);
5882
+ if (res.ok) {
5883
+ const data = await res.json();
5884
+ threads = Array.isArray(data.threads) ? data.threads : [];
5885
+ }
5886
+ } catch (_) { /* network · render empty state */ }
5887
+
5888
+ const body = pop.querySelector("[data-threads-body]");
5889
+ if (!body) return;
5890
+
5891
+ // Filter rules:
5892
+ // - messageCount === 0 · empty threads (user opened a thread
5893
+ // from the trigger but never sent a message) are clutter,
5894
+ // hide them.
5895
+ // - Dedupe by director id · keep ONLY the newest thread per
5896
+ // director. Older duplicates (created by past race bugs,
5897
+ // or by manual API hits) collapse so the user sees one row
5898
+ // per director — which is the mental model the rest of the
5899
+ // UI assumes (open-thread-with-director, not per-message).
5900
+ // `threads` arrives newest-first from the server, so the
5901
+ // first occurrence per directorId is the winner.
5902
+ const seenDirectors = new Set();
5903
+ const visible = [];
5904
+ for (const t of threads) {
5905
+ if (typeof t.messageCount === "number" && t.messageCount === 0) continue;
5906
+ if (t.threadDirectorId && seenDirectors.has(t.threadDirectorId)) continue;
5907
+ if (t.threadDirectorId) seenDirectors.add(t.threadDirectorId);
5908
+ visible.push(t);
5909
+ }
5910
+
5911
+ if (visible.length === 0) {
5912
+ const emptyMsg = this._t("threads_list_empty")
5913
+ || "No private threads yet · open one from the // Thread button on any director's message.";
5914
+ // Placeholder icon · Lucide MessagesSquare (two chat bubbles,
5915
+ // matching the head-threads trigger glyph) so the empty
5916
+ // state reads as "this is where your private threads
5917
+ // would appear" rather than as a bare line of text.
5918
+ 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>`;
5919
+ body.innerHTML = `
5920
+ <div class="thread-list-empty">
5921
+ <span class="thread-list-empty-icon" aria-hidden="true">${placeholderIcon}</span>
5922
+ <span>${this.escape(emptyMsg)}</span>
5923
+ </div>
5924
+ `;
5925
+ return;
5926
+ }
5927
+ const activeLabel = this._t("threads_list_active") || "open";
5928
+ 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>`;
5929
+ const deleteTip = this._t("threads_list_delete_tip") || "Delete this thread";
5930
+
5931
+ body.innerHTML = visible.map((t) => {
5932
+ const director = (this.agentsById && this.agentsById[t.threadDirectorId])
5933
+ || { name: t.threadDirectorId || "?", avatarPath: "" };
5934
+ const avatarSrc = director.avatarPath ? this.escape(director.avatarPath) : "";
5935
+ const ts = this.timeFmt ? this.timeFmt(t.createdAt) : "";
5936
+ const isOpen = !!(this._threads && this._threads[t.id]);
5937
+ // Title resolution · ALWAYS surface the topic phrase (same
5938
+ // convention as the sidebar's room titles). Priority:
5939
+ // 1. LLM-distilled `room.name` (set by generateRoomTitle
5940
+ // after the first user message · the canonical short
5941
+ // phrase mode)
5942
+ // 2. `room.subject` truncated (the raw first user message
5943
+ // · interim state while the LLM call is in flight or
5944
+ // if it failed to land a phrase)
5945
+ // 3. Director name (last-resort fallback for threads with
5946
+ // no user message yet — shouldn't appear in practice
5947
+ // since we filter messageCount === 0 above)
5948
+ // The director's name + timestamp ALWAYS live in the
5949
+ // subtitle, regardless of which title path was picked.
5950
+ const isPlaceholderName = typeof t.name === "string" && /^thread:/.test(t.name);
5951
+ let title = "";
5952
+ if (!isPlaceholderName && typeof t.name === "string" && t.name.trim()) {
5953
+ title = t.name.trim();
5954
+ } else if (typeof t.subject === "string" && t.subject.trim()) {
5955
+ const subj = t.subject.trim();
5956
+ title = subj.length > 60 ? subj.slice(0, 60) + "…" : subj;
5957
+ } else {
5958
+ title = director.name || "";
5959
+ }
5960
+ const subtitleParts = [];
5961
+ if (director.name) subtitleParts.push(director.name);
5962
+ if (ts) subtitleParts.push(ts);
5963
+ const subtitle = subtitleParts.join(" · ");
5964
+ return `
5965
+ <div class="thread-list-row">
5966
+ <button type="button" class="thread-list-row-body" data-thread-open="${this.escape(t.id)}">
5967
+ ${avatarSrc ? `<img class="thread-list-row-av" src="${avatarSrc}" alt="">` : `<span class="thread-list-row-av"></span>`}
5968
+ <span class="thread-list-row-meta">
5969
+ <span class="thread-list-row-name">${this.escape(title)}</span>
5970
+ <span class="thread-list-row-time">${this.escape(subtitle)}</span>
5971
+ </span>
5972
+ ${isOpen ? `<span class="thread-list-row-active">// ${this.escape(activeLabel)}</span>` : ""}
5973
+ </button>
5974
+ <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)}">
5975
+ ${trashIcon}
5976
+ </button>
5977
+ </div>
5978
+ `;
5979
+ }).join("");
5980
+ body.querySelectorAll("[data-thread-open]").forEach((row) => {
5981
+ row.addEventListener("click", (e) => {
5982
+ e.stopPropagation();
5983
+ const id = row.getAttribute("data-thread-open");
5984
+ this.closeThreadsListPopover();
5985
+ if (id) this.openExistingThread(id);
5986
+ });
5987
+ });
5988
+ body.querySelectorAll("[data-thread-delete]").forEach((btn) => {
5989
+ btn.addEventListener("click", (e) => {
5990
+ e.preventDefault();
5991
+ e.stopPropagation();
5992
+ const id = btn.getAttribute("data-thread-delete");
5993
+ const dirName = btn.getAttribute("data-director-name") || "";
5994
+ if (id) this.deleteThread(id, dirName, anchorBtn);
5995
+ });
5996
+ });
5997
+ },
5998
+
5999
+ /** Permanently delete a thread room · routes through the standard
6000
+ * DELETE /api/rooms/:id which cascades messages + members.
6001
+ * Confirms via `window.confirm` since the action is destructive
6002
+ * and not undoable. Closes any open window for the same thread,
6003
+ * prunes localStorage, and re-renders the popover. */
6004
+ async deleteThread(threadId, directorName, anchorBtn) {
6005
+ if (!threadId) return;
6006
+ const tmpl = this._t("threads_list_delete_confirm")
6007
+ || "Delete the private thread with {name}? This can't be undone.";
6008
+ const msg = tmpl.replace("{name}", directorName || "this director");
6009
+ if (!window.confirm(msg)) return;
6010
+ try {
6011
+ const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}`, { method: "DELETE" });
6012
+ if (!res.ok) {
6013
+ const err = await res.json().catch(() => ({}));
6014
+ alert((err && err.error) || "Delete failed");
6015
+ return;
6016
+ }
6017
+ } catch (e) {
6018
+ alert((e && e.message) || "Delete failed");
6019
+ return;
6020
+ }
6021
+ // Tear down any open window for this thread + drop from
6022
+ // persistence. closeThreadWindow does both.
6023
+ if (this._threads && this._threads[threadId]) {
6024
+ this.closeThreadWindow(threadId);
6025
+ }
6026
+ // Refresh the popover so the row disappears. Reuse the
6027
+ // original trigger as the anchor — it's still on screen since
6028
+ // we never navigated away.
6029
+ const trigger = anchorBtn || document.querySelector("[data-threads-trigger]");
6030
+ this.closeThreadsListPopover();
6031
+ if (trigger) this.openThreadsListPopover(trigger);
6032
+ },
6033
+
6034
+ closeThreadsListPopover() {
6035
+ const el = document.getElementById("thread-list-pop");
6036
+ if (el) el.remove();
6037
+ if (this._threadsPopDocClick) {
6038
+ document.removeEventListener("click", this._threadsPopDocClick, true);
6039
+ this._threadsPopDocClick = null;
6040
+ }
6041
+ if (this._threadsPopEsc) {
6042
+ document.removeEventListener("keydown", this._threadsPopEsc, true);
6043
+ this._threadsPopEsc = null;
6044
+ }
6045
+ },
6046
+
6047
+ /** Mount a window for an EXISTING thread (one the user hasn't
6048
+ * got open right now but exists in the DB). Used by the
6049
+ * "Threads" header popover entries · the user clicks a row to
6050
+ * resume an old aside without spawning a new one. */
6051
+ async openExistingThread(threadId) {
6052
+ if (!threadId) return;
6053
+ this._threads = this._threads || {};
6054
+ if (this._threads[threadId]) {
6055
+ this.restoreThreadWindow(threadId);
6056
+ return;
6057
+ }
6058
+ try {
6059
+ const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}`);
6060
+ if (!res.ok) return;
6061
+ const data = await res.json();
6062
+ const room = data.room;
6063
+ if (!room || room.kind !== "thread") return;
6064
+ const director = (this.agentsById && this.agentsById[room.threadDirectorId])
6065
+ || { id: room.threadDirectorId, name: room.threadDirectorId, avatarPath: "" };
6066
+ // Default docked · same as the trigger-button entry path.
6067
+ this.mountThreadWindow(room, director, { docked: this._canDockHere() });
6068
+ // Seed the historical transcript · without this the window
6069
+ // mounts empty and only catches messages arriving via SSE
6070
+ // FROM NOW ON, so reopening an old thread looked like a
6071
+ // brand-new conversation. The /api/rooms/:id state response
6072
+ // already carries the full message list — paint it now.
6073
+ if (Array.isArray(data.messages) && data.messages.length > 0) {
6074
+ this._seedThreadHistory(threadId, data.messages);
6075
+ }
6076
+ } catch (e) {
6077
+ try { console.warn("[thread] openExisting failed:", e); } catch (_) {}
6078
+ }
6079
+ },
6080
+
6081
+ /** Paint the thread room's stored messages into the window on
6082
+ * first mount. State + DOM both populated so subsequent SSE
6083
+ * token / update / final events can find the message refs and
6084
+ * patch the bubbles in place. */
6085
+ _seedThreadHistory(threadId, messages) {
6086
+ const state = this._threads && this._threads[threadId];
6087
+ if (!state || !state.windowEl) return;
6088
+ const list = state.windowEl.querySelector("[data-thread-messages]");
6089
+ if (!list) return;
6090
+ // Hide the empty-state placeholder if any messages exist.
6091
+ this._threadEmptyState(threadId, true);
6092
+ // Dedupe against anything already streamed in via SSE that
6093
+ // raced the seed (rare but possible). Skip if already known.
6094
+ const known = new Set(state.messages.map((m) => m.id));
6095
+ const html = [];
6096
+ for (const data of messages) {
6097
+ if (!data || !data.id || known.has(data.id)) continue;
6098
+ const msg = {
6099
+ id: data.id,
6100
+ authorKind: data.authorKind,
6101
+ authorId: data.authorId,
6102
+ body: data.body || "",
6103
+ meta: data.meta || {},
6104
+ roundNum: data.roundNum,
6105
+ createdAt: data.createdAt,
6106
+ };
6107
+ state.messages.push(msg);
6108
+ html.push(this._threadMessageHtml(state, msg));
6109
+ }
6110
+ if (html.length) {
6111
+ list.insertAdjacentHTML("beforeend", html.join(""));
6112
+ list.scrollTop = list.scrollHeight;
6113
+ }
6114
+ },
6115
+
6116
+ minimizeThreadWindow(threadId) {
6117
+ const state = this._threads && this._threads[threadId];
6118
+ if (!state || !state.windowEl) return;
6119
+ state.minimized = true;
6120
+ state.windowEl.classList.add("is-minimized");
6121
+ // Drop the user-resized inline height · the minimized pill sizes
6122
+ // to its content (CSS `height: auto`), and inline height would
6123
+ // win over that class rule and stretch the chip. The chosen
6124
+ // height is preserved on state.height and reapplied on restore.
6125
+ state.windowEl.style.height = "";
6126
+ // Reset transform so the minimized chip lives in its dock
6127
+ // anchor at the bottom-right rather than at the user's drag
6128
+ // location. The dragX/dragY values stay on state so restore
6129
+ // can put it back where they had it.
6130
+ state.windowEl.style.transform = "";
6131
+ // Pull into a shared dock so multiple minimized chips line up
6132
+ // along the bottom-right edge.
6133
+ const dock = this._ensureThreadDock();
6134
+ dock.appendChild(state.windowEl);
6135
+ state.windowEl.style.right = "auto";
6136
+ state.windowEl.style.bottom = "auto";
6137
+ // Click-to-restore on the whole chip (header is hidden,
6138
+ // controls are hidden, so the chip itself is the click target).
6139
+ const restoreHandler = (e) => {
6140
+ e.preventDefault();
6141
+ e.stopPropagation();
6142
+ this.restoreThreadWindow(threadId);
6143
+ };
6144
+ state.windowEl.addEventListener("click", restoreHandler, { once: true });
6145
+ },
6146
+
6147
+ restoreThreadWindow(threadId) {
6148
+ const state = this._threads && this._threads[threadId];
6149
+ if (!state || !state.windowEl) return;
6150
+ state.minimized = false;
6151
+ const el = state.windowEl;
6152
+ el.classList.remove("is-minimized");
6153
+ // Panel-only · restore re-docks into the active room view rather
6154
+ // than reviving a floating window (float mode was removed). Clear
6155
+ // any leftover float inline anchors first, then re-parent into the
6156
+ // dock host. Only when no room view is mounted (defensive) do we
6157
+ // fall back to a body-level overlay.
6158
+ el.style.right = "";
6159
+ el.style.bottom = "";
6160
+ el.style.transform = "";
6161
+ el.style.height = "";
6162
+ const dockHost = this._threadDockHost();
6163
+ if (dockHost) {
6164
+ el.classList.add("is-docked");
6165
+ state.docked = true;
6166
+ if (el.parentNode !== dockHost) dockHost.appendChild(el);
6167
+ document.body.classList.add("has-thread-dock");
6168
+ } else {
6169
+ el.classList.remove("is-docked");
6170
+ state.docked = false;
6171
+ if (el.parentNode !== document.body) document.body.appendChild(el);
6172
+ el.style.right = "24px";
6173
+ el.style.bottom = "24px";
6174
+ }
6175
+ },
6176
+
6177
+ _ensureThreadDock() {
6178
+ let dock = document.getElementById("thread-dock");
6179
+ if (!dock) {
6180
+ dock = document.createElement("div");
6181
+ dock.id = "thread-dock";
6182
+ dock.className = "thread-dock";
6183
+ document.body.appendChild(dock);
6184
+ }
6185
+ return dock;
6186
+ },
6187
+
6188
+ _wireThreadDrag(handle, state) {
6189
+ // Per-drag scratch + rAF coalescing · pointermove fires faster
6190
+ // than the browser can paint on most setups; without throttling
6191
+ // we'd issue multiple style writes per frame. Reading the
6192
+ // pending coords in a single rAF and writing transform once
6193
+ // keeps the window pinned to the cursor with no perceived
6194
+ // lag. The earlier implementation wrote transform on every
6195
+ // mousemove AND had a 0.15s transition (since removed from
6196
+ // .thread-float CSS), which compounded into visible drag jitter.
6197
+ let startX = 0, startY = 0, baseX = 0, baseY = 0;
6198
+ let dragging = false;
6199
+ let pendingX = 0, pendingY = 0;
6200
+ let rafId = 0;
6201
+ const flush = () => {
6202
+ rafId = 0;
6203
+ if (!dragging) return;
6204
+ state.dragX = baseX + (pendingX - startX);
6205
+ state.dragY = baseY + (pendingY - startY);
6206
+ state.windowEl.style.transform = `translate(${state.dragX}px, ${state.dragY}px)`;
6207
+ };
6208
+ const onMove = (e) => {
6209
+ if (!dragging) return;
6210
+ // touchmove may need preventDefault to suppress page scroll
6211
+ // while the user drags the window across the viewport.
6212
+ if (e.cancelable && e.type === "touchmove") e.preventDefault();
6213
+ pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
6214
+ pendingY = ("touches" in e ? e.touches[0].clientY : e.clientY);
6215
+ if (!rafId) rafId = requestAnimationFrame(flush);
6216
+ };
6217
+ const onUp = () => {
6218
+ dragging = false;
6219
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
6220
+ // Reset the compositor hint once dragging ends so the layer
6221
+ // is freed and the window doesn't keep paying the
6222
+ // promote-to-its-own-layer tax forever.
6223
+ if (state.windowEl) state.windowEl.style.willChange = "";
6224
+ document.removeEventListener("mousemove", onMove);
6225
+ document.removeEventListener("mouseup", onUp);
6226
+ document.removeEventListener("touchmove", onMove);
6227
+ document.removeEventListener("touchend", onUp);
6228
+ };
6229
+ const onDown = (e) => {
6230
+ // Ignore drags that originate on interactive children of the
6231
+ // header — buttons (close / min / max) AND the director
6232
+ // avatar, which carries `[data-agent]` and routes its click
6233
+ // to the lightweight intro overlay (agent-overlay.js).
6234
+ if (e.target && e.target.closest && e.target.closest("button, [data-agent]")) return;
6235
+ dragging = true;
6236
+ startX = ("touches" in e ? e.touches[0].clientX : e.clientX);
6237
+ startY = ("touches" in e ? e.touches[0].clientY : e.clientY);
6238
+ pendingX = startX;
6239
+ pendingY = startY;
6240
+ baseX = state.dragX || 0;
6241
+ baseY = state.dragY || 0;
6242
+ // Promote the window to its own compositor layer for the
6243
+ // drag duration so transform updates can be GPU-accelerated.
6244
+ // Cleared on mouseup so we don't permanently inflate
6245
+ // memory.
6246
+ if (state.windowEl) state.windowEl.style.willChange = "transform";
6247
+ document.addEventListener("mousemove", onMove);
6248
+ document.addEventListener("mouseup", onUp);
6249
+ // touchmove · passive:false so we can preventDefault inside
6250
+ // onMove (matters on iOS Safari where the browser otherwise
6251
+ // hijacks vertical drags for page scroll).
6252
+ document.addEventListener("touchmove", onMove, { passive: false });
6253
+ document.addEventListener("touchend", onUp);
6254
+ };
6255
+ handle.addEventListener("mousedown", onDown);
6256
+ handle.addEventListener("touchstart", onDown, { passive: true });
6257
+ },
6258
+
6259
+ /** Edge resize · the float is bottom-anchored.
6260
+ * · "top" handle · drag UP grows / DOWN shrinks; the bottom
6261
+ * edge stays pinned (height only).
6262
+ * · "bottom" handle · the bottom edge follows the cursor (drag
6263
+ * DOWN grows / UP shrinks) while the top edge stays pinned —
6264
+ * achieved by moving the drag-translate `dragY` by the same
6265
+ * delta the height grows, so the box's top doesn't shift.
6266
+ * Mirrors `_wireThreadDrag`'s rAF-coalesced pointer bookkeeping.
6267
+ * Height is clamped to [240, viewport-96] — the upper cap matches
6268
+ * `.thread-float`'s CSS `max-height` so the inline height never
6269
+ * fights it. Persisted on `state.height` (+ `dragY` for the
6270
+ * bottom edge) so minimize/restore keeps the chosen size + spot. */
6271
+ _wireThreadResize(handle, state, edge) {
6272
+ const MIN_H = 240;
6273
+ const maxH = () => Math.max(MIN_H, window.innerHeight - 96);
6274
+ const isBottom = edge === "bottom";
6275
+ let startY = 0, baseH = 0, baseDragY = 0;
6276
+ let resizing = false;
6277
+ let pendingY = 0;
6278
+ let rafId = 0;
6279
+ const flush = () => {
6280
+ rafId = 0;
6281
+ if (!resizing || !state.windowEl) return;
6282
+ // Drag delta along Y · top edge grows when the cursor moves UP
6283
+ // (startY - cur); bottom edge grows when it moves DOWN.
6284
+ const rawDelta = isBottom ? (pendingY - startY) : (startY - pendingY);
6285
+ const next = Math.min(maxH(), Math.max(MIN_H, baseH + rawDelta));
6286
+ state.height = next;
6287
+ state.windowEl.style.height = `${next}px`;
6288
+ if (isBottom) {
6289
+ // Shift the box down by however much it actually grew so the
6290
+ // top edge stays put (height was clamped, so use the applied
6291
+ // delta, not the raw cursor delta).
6292
+ state.dragY = baseDragY + (next - baseH);
6293
+ state.windowEl.style.transform = `translate(${state.dragX || 0}px, ${state.dragY}px)`;
6294
+ }
6295
+ };
6296
+ const onMove = (e) => {
6297
+ if (!resizing) return;
6298
+ if (e.cancelable && e.type === "touchmove") e.preventDefault();
6299
+ pendingY = ("touches" in e ? e.touches[0].clientY : e.clientY);
6300
+ if (!rafId) rafId = requestAnimationFrame(flush);
6301
+ };
6302
+ const onUp = () => {
6303
+ resizing = false;
6304
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
6305
+ // Re-enable text selection in the rest of the app.
6306
+ document.body.style.userSelect = "";
6307
+ document.removeEventListener("mousemove", onMove);
6308
+ document.removeEventListener("mouseup", onUp);
6309
+ document.removeEventListener("touchmove", onMove);
6310
+ document.removeEventListener("touchend", onUp);
6311
+ };
6312
+ const onDown = (e) => {
6313
+ // Don't start a resize while minimized · the handle is hidden
6314
+ // then anyway, but guard against stray events.
6315
+ if (state.minimized || !state.windowEl) return;
6316
+ // Swallow the event so it doesn't also reach the header's
6317
+ // drag-to-move handler underneath.
6318
+ if (e.stopPropagation) e.stopPropagation();
6319
+ // Suppress text selection for the whole drag · without this the
6320
+ // mousemove sweep selects whatever room text sits under the
6321
+ // cursor, which reads as a glitch while resizing the window.
6322
+ if (e.type === "mousedown" && e.preventDefault) e.preventDefault();
6323
+ document.body.style.userSelect = "none";
6324
+ resizing = true;
6325
+ startY = ("touches" in e ? e.touches[0].clientY : e.clientY);
6326
+ pendingY = startY;
6327
+ baseH = state.windowEl.getBoundingClientRect().height;
6328
+ baseDragY = state.dragY || 0;
6329
+ document.addEventListener("mousemove", onMove);
6330
+ document.addEventListener("mouseup", onUp);
6331
+ document.addEventListener("touchmove", onMove, { passive: false });
6332
+ document.addEventListener("touchend", onUp);
6333
+ };
6334
+ handle.addEventListener("mousedown", onDown);
6335
+ handle.addEventListener("touchstart", onDown, { passive: true });
6336
+ },
6337
+
6338
+ async sendThreadMessage(threadId) {
6339
+ const state = this._threads && this._threads[threadId];
6340
+ if (!state || !state.windowEl) return;
6341
+ const input = state.windowEl.querySelector("[data-thread-input]");
6342
+ if (!input) return;
6343
+ const text = (input.value || "").trim();
6344
+ if (!text) return;
6345
+ input.value = "";
6346
+ input.style.height = "auto";
6347
+ try {
6348
+ const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}/messages`, {
6349
+ method: "POST",
6350
+ headers: { "content-type": "application/json" },
6351
+ body: JSON.stringify({ body: text, mode: "now" }),
6352
+ });
6353
+ if (!res.ok) {
6354
+ const err = await res.json().catch(() => ({}));
6355
+ alert((err && err.error) || "Failed to send thread message");
6356
+ }
6357
+ // No need to optimistically render · the SSE message-appended
6358
+ // for the user message will fan out and our handler renders it.
6359
+ } catch (e) {
6360
+ alert((e && e.message) || "Failed to send thread message");
6361
+ }
6362
+ },
6363
+
6364
+ _threadEmptyState(threadId, hide) {
6365
+ const state = this._threads && this._threads[threadId];
6366
+ if (!state || !state.windowEl) return;
6367
+ const empty = state.windowEl.querySelector("[data-thread-empty]");
6368
+ if (empty) empty.style.display = hide ? "none" : "";
6369
+ },
6370
+
6371
+ /** Render a thread bubble using the main chat's full `messageHtml`
6372
+ * pipeline — keeps user avatars, director avatars, model badges,
6373
+ * the streaming dots animation, name+tag chips, etc. all
6374
+ * consistent with the main room. Earlier custom-mini renderer
6375
+ * produced visually broken bubbles (no user avatar, missing
6376
+ * meta chips) because it duplicated only a fraction of the
6377
+ * main path. Now reuses ONE source of truth.
6378
+ *
6379
+ * Side effect protection · sets `this._renderingThread = true`
6380
+ * for the duration of the call so the per-message thread
6381
+ * toolbar (`_threadTriggerHtml`) skips rendering inside thread
6382
+ * windows — there's no "thread the speaker again" inside an
6383
+ * already-open thread. The flag flips back in `finally`. */
6384
+ _threadMessageHtml(state, msg) {
6385
+ void state;
6386
+ this._renderingThread = true;
6387
+ try {
6388
+ return this.messageHtml(msg, false);
6389
+ } finally {
6390
+ this._renderingThread = false;
6391
+ }
6392
+ },
6393
+
6394
+ _threadAppendMessage(threadId, data) {
6395
+ const state = this._threads && this._threads[threadId];
6396
+ if (!state || !state.windowEl) return;
6397
+ const list = state.windowEl.querySelector("[data-thread-messages]");
6398
+ if (!list) return;
6399
+ this._threadEmptyState(threadId, true);
6400
+ // Track msg in state so streaming token appends find a stable
6401
+ // reference even if the article gets re-rendered.
6402
+ const msg = {
6403
+ id: data.messageId,
6404
+ authorKind: data.authorKind,
6405
+ authorId: data.authorId,
6406
+ body: data.body || "",
6407
+ meta: data.meta || {},
6408
+ roundNum: data.roundNum,
6409
+ createdAt: data.createdAt,
6410
+ };
6411
+ state.messages.push(msg);
6412
+ list.insertAdjacentHTML("beforeend", this._threadMessageHtml(state, msg));
6413
+ list.scrollTop = list.scrollHeight;
6414
+ // Director just started streaming · swap composer Send → Stop
6415
+ // so the user can interrupt the response in flight. The flag
6416
+ // stays on the message; later finalize / abort clears it.
6417
+ if (msg.authorKind === "agent" && msg.meta && msg.meta.streaming) {
6418
+ this._setThreadStreamingState(threadId, msg.id);
6419
+ }
6420
+ },
6421
+
6422
+ _threadApplyToken(threadId, data) {
6423
+ const state = this._threads && this._threads[threadId];
6424
+ if (!state) return;
6425
+ const msg = state.messages.find((m) => m.id === data.messageId);
6426
+ if (!msg) return;
6427
+ msg.body = (msg.body || "") + (data.delta || "");
6428
+ const article = state.windowEl.querySelector(
6429
+ `[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
6430
+ );
6431
+ if (!article) return;
6432
+ const bubble = article.querySelector(".msg-bubble");
6433
+ if (!bubble) return;
6434
+ article.classList.remove("thinking");
6435
+ bubble.innerHTML = this.renderBody ? this.renderBody(msg.body) : msg.body;
6436
+ const list = state.windowEl.querySelector("[data-thread-messages]");
6437
+ if (list) list.scrollTop = list.scrollHeight;
6438
+ },
6439
+
6440
+ _threadUpdateMessage(threadId, data) {
6441
+ const state = this._threads && this._threads[threadId];
6442
+ if (!state) return;
6443
+ const msg = state.messages.find((m) => m.id === data.messageId);
6444
+ if (msg) {
6445
+ msg.body = data.body || "";
6446
+ msg.meta = data.meta || {};
6447
+ }
6448
+ const article = state.windowEl.querySelector(
6449
+ `[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
6450
+ );
6451
+ if (!article) return;
6452
+ const bubble = article.querySelector(".msg-bubble");
6453
+ if (bubble) {
6454
+ bubble.innerHTML = this.renderBody ? this.renderBody(data.body || "") : (data.body || "");
6455
+ }
6456
+ const streaming = !!(data.meta && data.meta.streaming);
6457
+ article.classList.toggle("streaming", streaming);
6458
+ if (!streaming) {
6459
+ article.classList.remove("thinking");
6460
+ // Server flipped streaming:false on this director's message ·
6461
+ // restore the composer to Send mode if this was the message
6462
+ // we were tracking. Catches the abort + natural-finish paths
6463
+ // uniformly without needing a separate handler.
6464
+ if (state.streamingMessageId === data.messageId) {
6465
+ this._setThreadStreamingState(threadId, null);
6466
+ }
6467
+ }
6468
+ },
6469
+
6470
+ _threadFinalizeMessage(threadId, data) {
6471
+ const state = this._threads && this._threads[threadId];
6472
+ if (!state) return;
6473
+ const article = state.windowEl.querySelector(
6474
+ `[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
6475
+ );
6476
+ if (article) {
6477
+ article.classList.remove("streaming");
6478
+ article.classList.remove("thinking");
6479
+ }
6480
+ // Director's stream finished · flip composer back to Send.
6481
+ if (state.streamingMessageId === data.messageId) {
6482
+ this._setThreadStreamingState(threadId, null);
6483
+ }
6484
+ },
6485
+
6486
+ /** Flip the composer's Send/Stop visibility on the thread window
6487
+ * via a state class on `.thread-float-composer`. Tracks which
6488
+ * message we believe is currently streaming so duplicate
6489
+ * message-updated events (e.g. multiple token batches before
6490
+ * the streaming:false flag flips) don't toggle prematurely. */
6491
+ _setThreadStreamingState(threadId, messageId) {
6492
+ const state = this._threads && this._threads[threadId];
6493
+ if (!state || !state.windowEl) return;
6494
+ state.streamingMessageId = messageId;
6495
+ const composer = state.windowEl.querySelector("[data-thread-composer]");
6496
+ if (composer) composer.classList.toggle("is-streaming", !!messageId);
6497
+ },
6498
+
6499
+ /** Interrupt the in-flight director response in a thread. Hits
6500
+ * POST /api/rooms/:threadId/abort (the same endpoint the main
6501
+ * room uses for chair-interrupt + skip-current-speaker), which
6502
+ * aborts the LLM stream and drains voice waiters. The director's
6503
+ * partial body stays in chat; the composer flips back to Send
6504
+ * when the server's resulting message-updated (streaming:false)
6505
+ * arrives via SSE. */
6506
+ async stopThreadDirector(threadId) {
6507
+ const state = this._threads && this._threads[threadId];
6508
+ if (!state) return;
6509
+ try {
6510
+ await fetch(`/api/rooms/${encodeURIComponent(threadId)}/abort`, { method: "POST" });
6511
+ } catch (e) {
6512
+ try { console.warn("[thread] abort failed:", e); } catch (_) {}
6513
+ }
6514
+ // Optimistic UX · flip the composer back immediately rather
6515
+ // than waiting for the SSE round-trip. The streaming:false
6516
+ // update + message-final will arrive shortly and the toggle
6517
+ // is idempotent.
6518
+ this._setThreadStreamingState(threadId, null);
6519
+ },
6520
+
6521
+ _threadRemoveMessage(threadId, data) {
6522
+ const state = this._threads && this._threads[threadId];
6523
+ if (!state) return;
6524
+ state.messages = state.messages.filter((m) => m.id !== data.messageId);
6525
+ const article = state.windowEl.querySelector(
6526
+ `[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
6527
+ );
6528
+ if (article && article.parentNode) article.parentNode.removeChild(article);
6529
+ },
6530
+
5150
6531
  async submitFollowUp() {
5151
6532
  const overlay = document.getElementById("followup-overlay");
5152
6533
  if (!overlay) return;
@@ -6030,6 +7411,81 @@
6030
7411
  if (el) el.remove();
6031
7412
  },
6032
7413
 
7414
+ /** Pause-after-current save modal · the user soft-paused the room
7415
+ * while a recording was running. The recording itself doesn't
7416
+ * stop automatically (room can resume), so we ask the user
7417
+ * whether to save it now. Three choices:
7418
+ * · primary "Save & adjourn" — stopAndDownload + adjourn(skipBrief)
7419
+ * · secondary "Save & keep paused" — stopAndDownload, room stays paused
7420
+ * · ghost "Keep recording" — close modal, room paused, recorder
7421
+ * continues capturing the silence
7422
+ * Modal reuses the .pc-overlay / .pc-modal / .pc-choice chrome
7423
+ * for visual consistency with other pause-/reload-choice flows. */
7424
+ openRecordingPauseSaveModal() {
7425
+ this.closeRecordingPauseSaveModal();
7426
+ const html = `
7427
+ <div id="rec-pause-save-overlay" class="pc-overlay">
7428
+ <div class="pc-modal">
7429
+ <div class="pc-classification">
7430
+ <span><span class="dot">●</span> ${this.escape(this._t("rec_pause_save_class"))}</span>
7431
+ <span class="right">${this.escape(this._t("rec_pause_save_right"))}</span>
7432
+ </div>
7433
+ <div class="pc-head">
7434
+ <div class="pc-tag">${this.escape(this._t("rec_pause_save_tag"))}</div>
7435
+ <h2 class="pc-title">${this.escape(this._t("rec_pause_save_title"))}</h2>
7436
+ <p class="pc-deck">${this.escape(this._t("rec_pause_save_deck"))}</p>
7437
+ </div>
7438
+ <div class="pc-body">
7439
+ <button type="button" class="pc-choice primary" data-rec-pause-save-choice="save-adjourn">
7440
+ <div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_adjourn_mark"))}</div>
7441
+ <div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_adjourn_deck"))}</div>
7442
+ </button>
7443
+ <button type="button" class="pc-choice" data-rec-pause-save-choice="save-keep">
7444
+ <div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_keep_mark"))}</div>
7445
+ <div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_keep_deck"))}</div>
7446
+ </button>
7447
+ <button type="button" class="pc-choice ghost" data-rec-pause-save-choice="continue">
7448
+ <div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_continue_mark"))}</div>
7449
+ <div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_continue_deck"))}</div>
7450
+ </button>
7451
+ </div>
7452
+ </div>
7453
+ </div>
7454
+ `;
7455
+ document.body.insertAdjacentHTML("beforeend", html);
7456
+ },
7457
+
7458
+ closeRecordingPauseSaveModal() {
7459
+ const el = document.getElementById("rec-pause-save-overlay");
7460
+ if (el) el.remove();
7461
+ },
7462
+
7463
+ async handleRecordingPauseSaveChoice(mode) {
7464
+ this.closeRecordingPauseSaveModal();
7465
+ if (mode === "continue") return; // keep recording, room stays paused
7466
+ // Both save paths stop + download the clip first.
7467
+ let blob = null;
7468
+ try { blob = await window.BoardroomRecorder.stopAndDownload(); }
7469
+ catch (e) { console.error("[recorder] stopAndDownload failed", e); }
7470
+ if (blob && blob.size > 0) {
7471
+ try {
7472
+ this.showRoundTableToast({
7473
+ kind: "settings",
7474
+ glyph: "↓",
7475
+ htmlText: this.escape(this._t("rec_saved_toast")),
7476
+ lifetimeMs: 5200,
7477
+ });
7478
+ } catch (_) { /* toast best-effort */ }
7479
+ }
7480
+ if (mode === "save-adjourn") {
7481
+ // skipBrief · recording-driven adjourns never auto-generate
7482
+ // a report; the user files one manually.
7483
+ try { await this.adjournRoom({ skipBrief: true }); }
7484
+ catch (e) { console.warn("[recorder] adjournRoom failed", e); }
7485
+ }
7486
+ // save-keep · nothing else to do; room is already paused.
7487
+ },
7488
+
6033
7489
  async handleRecordingStopChoice(mode) {
6034
7490
  this.closeRecordingStopModal();
6035
7491
  if (mode === "cancel") return;
@@ -6920,9 +8376,17 @@
6920
8376
  timeFmt(ms) {
6921
8377
  if (!ms) return "";
6922
8378
  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}`;
8379
+ // Absolute timestamp · "May 24, 3:45 PM". When the year differs
8380
+ // from the current one (last year or earlier) the year is
8381
+ // prepended so e.g. "2024 May 24, 3:45 PM" disambiguates old
8382
+ // messages. en-US locale pins the short month + 12-hour "PM"
8383
+ // meridiem regardless of UI language (matches the notification
8384
+ // date format elsewhere in the app).
8385
+ const datePart = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
8386
+ const timePart = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
8387
+ const base = `${datePart}, ${timePart}`;
8388
+ const yr = d.getFullYear();
8389
+ return yr === new Date().getFullYear() ? base : `${yr} ${base}`;
6926
8390
  },
6927
8391
 
6928
8392
  relTime(ms) {
@@ -9712,7 +11176,11 @@
9712
11176
  const article = document.querySelector(`article[data-message-id="${openerId}"]`);
9713
11177
  if (!article) return;
9714
11178
  try {
9715
- article.scrollIntoView({ behavior: "smooth", block: "start" });
11179
+ // Instant · matches the thread panel's jump-top affordance.
11180
+ // User reads the result, not the animation; smooth scrolling
11181
+ // on long rooms (10+ screens of history) takes ≥800ms which
11182
+ // reads as sluggish.
11183
+ article.scrollIntoView({ behavior: "auto", block: "start" });
9716
11184
  } catch { /* */ }
9717
11185
  this.chatStuckToBottom = false;
9718
11186
  this._suppressBottomScrollUntil = Date.now() + 2000;
@@ -9994,6 +11462,15 @@
9994
11462
 
9995
11463
  showNoteTooltip(span) {
9996
11464
  if (!span) return;
11465
+ // Suppress while a text selection is active · the selection CTA
11466
+ // bar (quote-cta.js) already sits in this slot above the
11467
+ // selection. Selecting text that overlaps an already-saved
11468
+ // highlight would otherwise stack the "✓ Saved" hover tip on
11469
+ // top of the CTA bar — the user sees two toolbars. Once the
11470
+ // selection collapses (click elsewhere), hovering the highlight
11471
+ // shows the tip again as normal.
11472
+ const sel = window.getSelection ? window.getSelection() : null;
11473
+ if (sel && !sel.isCollapsed) return;
9997
11474
  const tip = this._ensureNoteTip();
9998
11475
  const noteId = span.dataset.noteId;
9999
11476
  // Resolve note metadata from currentNotes · used for the time
@@ -12817,9 +14294,8 @@
12817
14294
  <span class="brief-picker-num">${this.escape(num)}</span>
12818
14295
  <span class="brief-picker-main">
12819
14296
  <span class="brief-picker-title">${this.escape(b.title || "(untitled)")}</span>
12820
- ${subtitle ? `<span class="brief-picker-sub">${this.escape(subtitle)}</span>` : ""}
14297
+ ${(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
14298
  </span>
12822
- ${filedLabel ? `<span class="brief-picker-time">${this.escape(filedLabel)}</span>` : ""}
12823
14299
  <span class="brief-picker-arrow">↗</span>
12824
14300
  </a>
12825
14301
  `;
@@ -13723,10 +15199,27 @@
13723
15199
  // (instead of relying on agent-overlay's autoTagAvatars regex) is
13724
15200
  // required for custom agents whose avatarPath is a data: URL —
13725
15201
  // the regex only matches `/avatars/*.svg`.
15202
+ // Head-cast avatars · each director wrapped in a span so a
15203
+ // small thread-trigger badge can pin to the avatar's bottom-
15204
+ // right. Avatar click stays as the agent-overlay open (existing
15205
+ // data-agent behavior); the badge gets its own click target
15206
+ // (data-thread-trigger) so the two affordances don't fight.
15207
+ // Chair is never threadable — filter on roleKind. Allow every
15208
+ // status (live, paused, adjourned) — threads are independent
15209
+ // rooms; the parent's status doesn't gate them. Only block
15210
+ // when this room itself is a thread (no nested threads).
15211
+ const canThreadHere = r.kind !== "thread";
15212
+ const threadTipBase = this._t("thread_trigger") || "// thread";
15213
+ const threadIcon = this._threadTriggerIconSvg();
13726
15214
  const castImgs = this.currentMembers
13727
15215
  .map((a) => {
13728
15216
  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)}">`;
15217
+ 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)}">`;
15218
+ const showBadge = canThreadHere && a.roleKind !== "moderator";
15219
+ const badge = showBadge
15220
+ ? `<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>`
15221
+ : "";
15222
+ return `<span class="head-cast-wrap">${img}${badge}</span>`;
13730
15223
  })
13731
15224
  .join("");
13732
15225
  const castCount = this.currentMembers.length;
@@ -13799,9 +15292,10 @@
13799
15292
  </div>
13800
15293
  <div class="head-actions">
13801
15294
  <a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
15295
+ <a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
13802
15296
  <div class="head-cast">${castHtml}</div>
13803
15297
  <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>
15298
+ <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
15299
  <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
15300
  ${this.currentBrief
13807
15301
  ? (() => {
@@ -16382,7 +17876,13 @@
16382
17876
  // the text-mode "two directors flashing" race vanishes.
16383
17877
  const isDirectorMsg = !isUser && !isChair;
16384
17878
  const activeId = this.currentActiveMessageId || null;
16385
- const speakingGate = !isDirectorMsg || !activeId || m.id === activeId;
17879
+ // `currentActiveMessageId` tracks the MAIN room's currently-
17880
+ // visible speaker · doesn't apply to thread windows (which run
17881
+ // their own SSE / pump with a different message id pool). When
17882
+ // rendering inside a thread, skip the gate so the thread's own
17883
+ // streaming bubble shows the dots + animation normally.
17884
+ const speakingGate = this._renderingThread
17885
+ || !isDirectorMsg || !activeId || m.id === activeId;
16386
17886
  const wantStreaming = !!streaming && speakingGate;
16387
17887
  const stateCls = [];
16388
17888
  if (wantStreaming) stateCls.push("streaming");
@@ -16417,7 +17917,18 @@
16417
17917
  const webSearchUsed = !!(m.meta && m.meta.webSearchUsed);
16418
17918
  const webSearchQuery = (m.meta && typeof m.meta.webSearchQuery === "string") ? m.meta.webSearchQuery : "";
16419
17919
  const webSearchSources = (m.meta && Array.isArray(m.meta.webSearchSources)) ? m.meta.webSearchSources : [];
16420
- const webSearchBadge = webSearchUsed
17920
+ // Thread mode promotes the director's web-search into a
17921
+ // standalone `.msg-tool-card` rendered ABOVE the bubble,
17922
+ // mirroring the chair's tool-use card in main rooms (chair.ts
17923
+ // emits a tool-use message; here the director's webSearch
17924
+ // lives on the same message's meta, so we synthesise the same
17925
+ // visual treatment in-place). In the main room layout we
17926
+ // keep the original inline-badge-in-meta + sources-panel-
17927
+ // under-bubble pattern (matches every other director bubble
17928
+ // in the room and doesn't compete with the chair's tool-use
17929
+ // cards above).
17930
+ const renderWsAsCard = this._renderingThread === true && webSearchUsed;
17931
+ const webSearchBadge = (!renderWsAsCard && webSearchUsed)
16421
17932
  ? (() => {
16422
17933
  const n = webSearchSources.length;
16423
17934
  const title = this.escape(this._t("msg_ws_title", { query: webSearchQuery, n }));
@@ -16427,7 +17938,7 @@
16427
17938
  return `<button type="button" class="msg-web-search" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" title="${title}">🔍 ${label}</button>`;
16428
17939
  })()
16429
17940
  : "";
16430
- const webSearchSourcesPanel = webSearchUsed && webSearchSources.length > 0
17941
+ const webSearchSourcesPanel = (!renderWsAsCard && webSearchUsed && webSearchSources.length > 0)
16431
17942
  ? `<div class="msg-web-search-sources" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
16432
17943
  <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
17944
  <ol class="msg-web-search-list">
@@ -16442,6 +17953,76 @@
16442
17953
  </ol>
16443
17954
  </div>`
16444
17955
  : "";
17956
+ // Thread mode · synthesise a `.msg-tool-card` for the
17957
+ // director's web-search and surface it ABOVE the bubble.
17958
+ // Visual shape mirrors `chair.ts`'s standalone tool-use card:
17959
+ // banner kicker + body row (status mark + "Searched 'query'"
17960
+ // text + caret) + collapsible sources list + expand button.
17961
+ // Same `data-msg-ws-toggle` / `data-msg-ws-sources` attribute
17962
+ // pair the chair card uses → the existing toggle handler at
17963
+ // app.js:21376 works without modification. Always status=done
17964
+ // since the message meta is set after the search completed.
17965
+ const threadWebSearchCard = renderWsAsCard
17966
+ ? (() => {
17967
+ const n = webSearchSources.length;
17968
+ const hasSources = n > 0;
17969
+ const tail = "";
17970
+ const stamp = !hasSources
17971
+ ? this.escape(this._t("msg_ws_failed"))
17972
+ : n === 1
17973
+ ? this.escape(this._t("msg_ws_done_one", { tail }))
17974
+ : this.escape(this._t("msg_ws_done", { n, tail }));
17975
+ const stampClass = hasSources ? "status-done" : "status-failed";
17976
+ const mark = hasSources ? "✓" : "⚠";
17977
+ const bodyText = this.escape(this._t("msg_ws_card_body", { query: webSearchQuery }));
17978
+ const caret = hasSources ? `<span class="msg-tool-caret" aria-hidden="true">▸</span>` : "";
17979
+ const bodyToggleAttrs = hasSources
17980
+ ? ` data-msg-ws-toggle data-message-id="${this.escape(m.id)}" role="button" tabindex="0" aria-label="${this.escape(this._t("msg_ws_toggle"))}"`
17981
+ : "";
17982
+ const sourcesList = hasSources
17983
+ ? `
17984
+ <ol class="msg-tool-sources-list" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
17985
+ ${webSearchSources.map((s, i) => {
17986
+ const url = s && typeof s.url === "string" ? s.url : "";
17987
+ const title = s && typeof s.title === "string" ? s.title : url;
17988
+ const desc = s && typeof s.description === "string" ? s.description : "";
17989
+ const host = this.hostnameOf(url);
17990
+ const numStr = String(i + 1).padStart(2, "0");
17991
+ return `
17992
+ <li>
17993
+ <span class="msg-tool-sources-num">${numStr}</span>
17994
+ <a href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" class="msg-tool-sources-title">
17995
+ <span class="msg-tool-sources-title-text">${this.escape(title)}</span>
17996
+ <span class="msg-tool-sources-ext" aria-hidden="true">↗</span>
17997
+ </a>
17998
+ <span class="msg-tool-sources-host">${this.escape(host)}</span>
17999
+ ${desc ? `<span class="msg-tool-sources-desc">${this.escape(desc)}</span>` : ""}
18000
+ </li>
18001
+ `;
18002
+ }).join("")}
18003
+ </ol>
18004
+ <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"))}">
18005
+ <span class="msg-tool-sources-expand-icon" aria-hidden="true">▾</span>
18006
+ <span class="msg-tool-sources-expand-show">${this.escape(this._t("msg_ws_expand_show", { n }))}</span>
18007
+ <span class="msg-tool-sources-expand-hide">${this.escape(this._t("msg_ws_expand_hide"))}</span>
18008
+ </button>`
18009
+ : "";
18010
+ return `
18011
+ <div class="msg-tool-card ${stampClass} thread-tool-card" data-message-id="${this.escape(m.id)}">
18012
+ <div class="msg-tool-banner">
18013
+ <span class="msg-tool-banner-tag">${this.escape(this._t("msg_ws_banner"))}</span>
18014
+ <span class="msg-tool-banner-stamp">${stamp}</span>
18015
+ </div>
18016
+ <div class="msg-tool-card-body${hasSources ? " is-toggle" : ""}"${bodyToggleAttrs}>
18017
+ <span class="msg-tool-mark" aria-hidden="true">${mark}</span>
18018
+ <span class="msg-tool-card-text">${bodyText}</span>
18019
+ ${caret}
18020
+ </div>
18021
+ ${sourcesList}
18022
+ </div>
18023
+ `;
18024
+ })()
18025
+ : "";
16445
18026
 
16446
18027
  // Round-end card · render even DURING streaming so the user sees
16447
18028
  // a skeleton placeholder immediately after the chair's ping
@@ -16511,9 +18092,11 @@
16511
18092
  ${ctxBadge}
16512
18093
  <span class="msg-time">${this.timeFmt(m.createdAt)}</span>
16513
18094
  </div>
18095
+ ${threadWebSearchCard}
16514
18096
  <div class="msg-bubble">${bubbleHtml}</div>
16515
18097
  ${webSearchSourcesPanel}
16516
18098
  </div>
18099
+ ${this._messageToolbarHtml(m, isUser, isChair, excusedMember, author)}
16517
18100
  </article>
16518
18101
  ${roundEndCard}
16519
18102
  ${roundPromptCard}
@@ -20061,6 +21644,21 @@
20061
21644
  app.closeRecordingStopModal();
20062
21645
  return;
20063
21646
  }
21647
+ // Pause-after-current save modal · three-option grammar
21648
+ // (save-adjourn / save-keep / continue). Fired by SSE
21649
+ // room-paused when payload.mode==="soft" + recording active.
21650
+ const recPauseSaveChoice = e.target.closest("[data-rec-pause-save-choice]");
21651
+ if (recPauseSaveChoice) {
21652
+ e.preventDefault();
21653
+ app.handleRecordingPauseSaveChoice(recPauseSaveChoice.getAttribute("data-rec-pause-save-choice"));
21654
+ return;
21655
+ }
21656
+ if (e.target.id === "rec-pause-save-overlay") {
21657
+ // Backdrop click · keep recording (don't accidentally end
21658
+ // a recording the user explicitly hasn't decided to stop).
21659
+ app.closeRecordingPauseSaveModal();
21660
+ return;
21661
+ }
20064
21662
  // Record button in the room header · toggle on/off.
20065
21663
  if (e.target.closest("[data-record-toggle]")) {
20066
21664
  e.preventDefault();
@@ -21320,19 +22918,21 @@
21320
22918
  }
21321
22919
  });
21322
22920
 
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.
22921
+ // Scrollbar auto-reveal · `.ib-textarea` (room input bar) and
22922
+ // `.thread-float-messages` (thread chat) both render with
22923
+ // `scrollbar-width: none` by default and only flip visible
22924
+ // while the user is actively scrolling. We toggle `.is-scrolling`
22925
+ // with an 800 ms debounce so the scrollbar fades back out
22926
+ // shortly after the last scroll. Scroll events don't bubble,
22927
+ // hence the capture-phase listener.
21331
22928
  const _scrollDecayTimers = new WeakMap();
21332
22929
  document.addEventListener("scroll", (ev) => {
21333
22930
  const t = ev.target;
21334
- if (!t || !(t instanceof HTMLTextAreaElement)) return;
21335
- if (!t.classList.contains("ib-textarea")) return;
22931
+ if (!t || !(t instanceof HTMLElement)) return;
22932
+ const eligible =
22933
+ (t instanceof HTMLTextAreaElement && t.classList.contains("ib-textarea"))
22934
+ || t.classList.contains("thread-float-messages");
22935
+ if (!eligible) return;
21336
22936
  t.classList.add("is-scrolling");
21337
22937
  const prev = _scrollDecayTimers.get(t);
21338
22938
  if (prev) clearTimeout(prev);