privateboard 0.1.36 → 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
@@ -148,6 +148,15 @@
148
148
  * appendMessageDom, drained in _revealPrewarmedBubble, baked into
149
149
  * messageHtml so every render preserves the hidden state. */
150
150
  _prewarmedHidden: new Set(),
151
+ /** Server-authoritative "currently-visible speaker" messageId.
152
+ * Pushed via queue-update SSE (orchestrator/room.ts pumpQueue).
153
+ * Drives the .streaming class gate in `updateMessageBodyDom` so
154
+ * only the one director the server marks as the active turn
155
+ * shows the typing-dots / pulse animation, even in the brief
156
+ * same-frame window where A's finalize and B's appended SSE
157
+ * events both reach the DOM. Null between turns / before first
158
+ * turn. */
159
+ currentActiveMessageId: null,
151
160
  /** Mini-player anchor · when set, the cross-room bar persists
152
161
  * for this voice room throughout its whole live/paused life
153
162
  * (between speakers, during votes / clarify, idle phases) —
@@ -334,6 +343,34 @@
334
343
  : (btn.getAttribute("data-more") || this._t("convene_show_more"));
335
344
  btn.textContent = label;
336
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
+ });
337
374
  // Live-subtitle minimize / restore · attached in CAPTURE phase
338
375
  // so it wins ahead of any outside-click handlers (picker dismiss
339
376
  // / overlay close) that might `stopPropagation` and swallow the
@@ -1080,6 +1117,30 @@
1080
1117
  return;
1081
1118
  }
1082
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
+
1083
1144
  // We may be coming from the All Reports view OR an agent profile
1084
1145
  // — make sure the room main view is the visible one and the
1085
1146
  // others are hidden. The agent-view hide is what stops a stale
@@ -1164,6 +1225,12 @@
1164
1225
  : (data.members || []).map((m) => ({ ...m, removedAt: null }));
1165
1226
  this.currentChair = data.chair || null;
1166
1227
  this.currentQueue = data.queue || [];
1228
+ // Reset to null on room open · the queue-update SSE will push
1229
+ // the real value if a director is currently speaking. The
1230
+ // initial GET /api/rooms/:id snapshot doesn't carry this field
1231
+ // (it's a transient pump state, not persisted to messages), so
1232
+ // we wait for the first SSE tick.
1233
+ this.currentActiveMessageId = null;
1167
1234
  this.currentRound = data.round || { spoken: 0, total: 0 };
1168
1235
  this.currentKeyPoints = data.keyPoints || [];
1169
1236
  // Reset the round-table HUD log when (re)opening a room. Past
@@ -1281,6 +1348,12 @@
1281
1348
  console.error("[openRoom] renderRoom failed:", err);
1282
1349
  }
1283
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);
1284
1357
  // Round-table stage · paint + decide whether the .chat or
1285
1358
  // .roundtable-stage should be visible for this room. Runs
1286
1359
  // AFTER renderRoom so the DOM exists and currentRoom /
@@ -1354,6 +1427,19 @@
1354
1427
  },
1355
1428
 
1356
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");
1357
1443
  // Recording guard · if a meeting capture is in progress for
1358
1444
  // the current room, intercept and let the user choose:
1359
1445
  // stop-and-save before leaving or cancel and stay. We re-route
@@ -1402,6 +1488,7 @@
1402
1488
  // re-opened (since `openRoom` re-sets it from data.chair),
1403
1489
  // but the empty + composer states looked broken.
1404
1490
  this.currentQueue = [];
1491
+ this.currentActiveMessageId = null;
1405
1492
  this.currentRound = { spoken: 0, total: 0 };
1406
1493
  this.currentKeyPoints = [];
1407
1494
  this.rtChairLog = [];
@@ -1978,6 +2065,15 @@
1978
2065
  msg.body = data.body;
1979
2066
  msg.meta = data.meta || {};
1980
2067
  }
2068
+ // Server cleared meta.preWarmed (pump just consumed this
2069
+ // speaker · it is now the visible active turn). Drain from
2070
+ // the hidden set BEFORE renderChat so the article re-paints
2071
+ // without `data-prewarmed`. Otherwise the CSS `display:none`
2072
+ // would persist across this re-render until the next event.
2073
+ if (data.meta && data.meta.preWarmed === false
2074
+ && this._prewarmedHidden && this._prewarmedHidden.has(data.messageId)) {
2075
+ this._revealPrewarmedBubble(data.messageId);
2076
+ }
1981
2077
  // Re-render via a chat repaint · the tool-use renderer is
1982
2078
  // selected by meta.kind so it rebuilds with the new status.
1983
2079
  this.renderChat();
@@ -1987,6 +2083,32 @@
1987
2083
  const data = JSON.parse(e.data);
1988
2084
  this.currentQueue = data.queue || [];
1989
2085
  if (data.round) this.currentRound = data.round;
2086
+ // activeMessageId · server tells us which director's bubble
2087
+ // is the currently-visible "speaking" turn. Two consumers:
2088
+ // (1) `updateMessageBodyDom` gates the streaming animation
2089
+ // on this id (suppresses the brief text-mode flash where
2090
+ // A's finalize and B's append cross paths in the same rAF),
2091
+ // (2) a hidden pre-warmed bubble whose id matches gets
2092
+ // revealed here (defense in depth alongside the message-
2093
+ // updated meta.preWarmed:false signal — whichever arrives
2094
+ // first wins).
2095
+ const prevActive = this.currentActiveMessageId || null;
2096
+ this.currentActiveMessageId = (data && typeof data.activeMessageId === "string")
2097
+ ? data.activeMessageId
2098
+ : (data && data.activeMessageId === null ? null : prevActive);
2099
+ if (this.currentActiveMessageId
2100
+ && this._prewarmedHidden
2101
+ && this._prewarmedHidden.has(this.currentActiveMessageId)) {
2102
+ this._revealPrewarmedBubble(this.currentActiveMessageId);
2103
+ }
2104
+ // If the active id changed AND the streaming class is now on
2105
+ // the wrong bubble, refresh both. Only affects bubbles that
2106
+ // are still in streaming state; finalized bubbles never carry
2107
+ // .streaming so they're not re-touched.
2108
+ if (prevActive !== this.currentActiveMessageId) {
2109
+ this._refreshStreamingClassForId(prevActive);
2110
+ this._refreshStreamingClassForId(this.currentActiveMessageId);
2111
+ }
1990
2112
  this.renderQueue();
1991
2113
  });
1992
2114
 
@@ -2048,6 +2170,26 @@
2048
2170
  this._pendingAdjournAfterRecordStop = false;
2049
2171
  try { this.adjournRoom({ skipBrief: true }); }
2050
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();
2051
2193
  }
2052
2194
  } else if (kind === "room-resumed") {
2053
2195
  if (this.currentRoom) {
@@ -3542,12 +3684,53 @@
3542
3684
  `;
3543
3685
  }).join("");
3544
3686
  document.body.appendChild(pop);
3545
- // Anchor under the wrapper · `.ap-model-picker` is
3546
- // `position: fixed` so we set top/left relative to viewport.
3687
+ // Anchor relative to the wrapper · `.ap-model-picker` is
3688
+ // `position: fixed` so we set top/left in viewport coords.
3689
+ // Flip-above logic · the house-style dropdown sits in the
3690
+ // SECOND row of the modal and the modal is centred in the
3691
+ // viewport, so the trigger often lands close to the lower
3692
+ // half of the screen. A naive "always below" placement gets
3693
+ // clipped at the viewport bottom (visually: half the list
3694
+ // hidden under the chrome). We measure the rendered popover
3695
+ // height and choose the side with more room — preferring
3696
+ // below by default, flipping above when below is tight and
3697
+ // above has more space. The base `.ap-model-picker` rule
3698
+ // caps height at 60vh + scrolls inside, so a long menu still
3699
+ // fits even after flip.
3547
3700
  const r = wrap.getBoundingClientRect();
3548
- pop.style.top = `${Math.round(r.bottom + 4)}px`;
3549
3701
  pop.style.left = `${Math.round(r.left)}px`;
3550
3702
  pop.style.width = `${Math.round(r.width)}px`;
3703
+ // First paint to measure; the visible top is intentionally
3704
+ // off-screen until the position decision lands (avoids a
3705
+ // one-frame flash at the wrong location).
3706
+ pop.style.top = "-9999px";
3707
+ pop.style.visibility = "hidden";
3708
+ const PAD = 8;
3709
+ const GAP = 4;
3710
+ const popH = pop.getBoundingClientRect().height;
3711
+ const spaceBelow = Math.max(0, window.innerHeight - r.bottom - PAD);
3712
+ const spaceAbove = Math.max(0, r.top - PAD);
3713
+ let topPx;
3714
+ if (popH + GAP <= spaceBelow) {
3715
+ // Fits below comfortably.
3716
+ topPx = Math.round(r.bottom + GAP);
3717
+ } else if (popH + GAP <= spaceAbove) {
3718
+ // Fits above comfortably · flip.
3719
+ topPx = Math.round(r.top - popH - GAP);
3720
+ } else if (spaceAbove > spaceBelow) {
3721
+ // Neither side fits the full list — pick the bigger side
3722
+ // (above) and cap max-height so the popover's own internal
3723
+ // scroll (`.ap-model-picker { overflow-y: auto }`) handles
3724
+ // the overflow rather than the viewport edge clipping us.
3725
+ topPx = PAD;
3726
+ pop.style.maxHeight = `${spaceAbove}px`;
3727
+ } else {
3728
+ // Stay below · same cap-and-scroll trick.
3729
+ topPx = Math.round(r.bottom + GAP);
3730
+ pop.style.maxHeight = `${spaceBelow}px`;
3731
+ }
3732
+ pop.style.top = `${topPx}px`;
3733
+ pop.style.visibility = "";
3551
3734
  // Dismiss handlers · attached synchronously, but the trigger's
3552
3735
  // own wrap is whitelisted so the click that OPENED the popover
3553
3736
  // (which fires AFTER mousedown · same user gesture) doesn't
@@ -5055,6 +5238,1296 @@
5055
5238
  }
5056
5239
  },
5057
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
+
5058
6531
  async submitFollowUp() {
5059
6532
  const overlay = document.getElementById("followup-overlay");
5060
6533
  if (!overlay) return;
@@ -5938,6 +7411,81 @@
5938
7411
  if (el) el.remove();
5939
7412
  },
5940
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
+
5941
7489
  async handleRecordingStopChoice(mode) {
5942
7490
  this.closeRecordingStopModal();
5943
7491
  if (mode === "cancel") return;
@@ -6828,9 +8376,17 @@
6828
8376
  timeFmt(ms) {
6829
8377
  if (!ms) return "";
6830
8378
  const d = new Date(ms);
6831
- const hh = String(d.getHours()).padStart(2, "0");
6832
- const mm = String(d.getMinutes()).padStart(2, "0");
6833
- 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}`;
6834
8390
  },
6835
8391
 
6836
8392
  relTime(ms) {
@@ -6964,6 +8520,25 @@
6964
8520
  // when the agent profile takes focus, and otherwise the agent
6965
8521
  // rows shouldn't be highlighted at all.
6966
8522
  }
8523
+ // Collapsed mini-rail · light the "rooms" icon when an actual
8524
+ // room is the main view; clear it for composers (the new-room /
8525
+ // new-agent mini icon carries the highlight there instead).
8526
+ this.setMiniRailContext(roomId !== null ? "rooms" : null);
8527
+ },
8528
+
8529
+ /** Highlight the rooms / agents switcher in the collapsed mini
8530
+ * rail. Mutually exclusive · passing `null` clears both (used by
8531
+ * the reports / notes / composer destinations, which light their
8532
+ * own mini icon via the shared trigger attributes). Mirrors the
8533
+ * full sidebar's tab selection but scoped to the icon rail so the
8534
+ * collapsed sidebar still shows "where you are". */
8535
+ setMiniRailContext(ctx) {
8536
+ document.querySelectorAll('.mini-tab[data-mini-tab="rooms"]').forEach((el) => {
8537
+ el.classList.toggle("active", ctx === "rooms");
8538
+ });
8539
+ document.querySelectorAll('.mini-tab[data-mini-tab="agents"]').forEach((el) => {
8540
+ el.classList.toggle("active", ctx === "agents");
8541
+ });
6967
8542
  },
6968
8543
 
6969
8544
  /** Sidebar focus when an agent profile takes the main view. The
@@ -6993,6 +8568,9 @@
6993
8568
  document.querySelectorAll(".agent-row").forEach((r) => {
6994
8569
  r.classList.toggle("active", r.dataset.agentProfile === slug);
6995
8570
  });
8571
+ // Collapsed mini-rail · an agent profile is the main view → light
8572
+ // the agents switcher icon.
8573
+ this.setMiniRailContext("agents");
6996
8574
  // Reset composer mode so subsequent transitions are clean — the
6997
8575
  // user is no longer "creating" anything.
6998
8576
  this.composerMode = "room";
@@ -7255,17 +8833,23 @@
7255
8833
  // matches what the user picked in settings; otherwise fall back
7256
8834
  // to the initial-letter chip we shipped before AvatarSkill
7257
8835
  // existed.
7258
- const av = document.querySelector("[data-user-avatar]");
7259
- if (av) {
7260
- const seed = this.prefs?.avatarSeed;
7261
- if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
8836
+ // Sync every avatar slot · the full sidebar foot AND the
8837
+ // collapsed mini-rail foot both carry [data-user-avatar], so
8838
+ // querySelectorAll keeps them identical regardless of which is
8839
+ // currently visible.
8840
+ const seed = this.prefs?.avatarSeed;
8841
+ const avHtml = (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function")
8842
+ ? window.AvatarSkill.generate(seed)
8843
+ : null;
8844
+ document.querySelectorAll("[data-user-avatar]").forEach((av) => {
8845
+ if (avHtml) {
7262
8846
  av.classList.add("has-pixel-av");
7263
- av.innerHTML = window.AvatarSkill.generate(seed);
8847
+ av.innerHTML = avHtml;
7264
8848
  } else {
7265
8849
  av.classList.remove("has-pixel-av");
7266
8850
  av.textContent = initial;
7267
8851
  }
7268
- }
8852
+ });
7269
8853
  const nm = document.querySelector("[data-user-name]");
7270
8854
  if (nm) nm.textContent = name;
7271
8855
  const mt = document.querySelector("[data-user-meta]");
@@ -8124,6 +9708,9 @@
8124
9708
  document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
8125
9709
  document.querySelectorAll("[data-search-trigger].active").forEach((el) => el.classList.remove("active"));
8126
9710
  document.querySelectorAll("[data-reports-trigger]").forEach((el) => el.classList.add("active"));
9711
+ // Reports is its own destination · clear the mini-rail rooms /
9712
+ // agents switchers so only the reports mini icon reads active.
9713
+ this.setMiniRailContext(null);
8127
9714
 
8128
9715
  // Persist the view via URL hash so refresh / back-button restore
8129
9716
  // both the page content AND the sidebar highlight. replaceState
@@ -8610,6 +10197,9 @@
8610
10197
  document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
8611
10198
  document.querySelectorAll("[data-search-trigger].active").forEach((el) => el.classList.remove("active"));
8612
10199
  document.querySelectorAll("[data-notes-trigger]").forEach((el) => el.classList.add("active"));
10200
+ // Notes is its own destination · clear the mini-rail rooms /
10201
+ // agents switchers so only the notes mini icon reads active.
10202
+ this.setMiniRailContext(null);
8613
10203
 
8614
10204
  if (location.hash !== "#/notes") {
8615
10205
  try { history.replaceState(null, "", "#/notes"); } catch { /* ignore */ }
@@ -9586,7 +11176,11 @@
9586
11176
  const article = document.querySelector(`article[data-message-id="${openerId}"]`);
9587
11177
  if (!article) return;
9588
11178
  try {
9589
- 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" });
9590
11184
  } catch { /* */ }
9591
11185
  this.chatStuckToBottom = false;
9592
11186
  this._suppressBottomScrollUntil = Date.now() + 2000;
@@ -9868,6 +11462,15 @@
9868
11462
 
9869
11463
  showNoteTooltip(span) {
9870
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;
9871
11474
  const tip = this._ensureNoteTip();
9872
11475
  const noteId = span.dataset.noteId;
9873
11476
  // Resolve note metadata from currentNotes · used for the time
@@ -12691,9 +14294,8 @@
12691
14294
  <span class="brief-picker-num">${this.escape(num)}</span>
12692
14295
  <span class="brief-picker-main">
12693
14296
  <span class="brief-picker-title">${this.escape(b.title || "(untitled)")}</span>
12694
- ${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>` : ""}
12695
14298
  </span>
12696
- ${filedLabel ? `<span class="brief-picker-time">${this.escape(filedLabel)}</span>` : ""}
12697
14299
  <span class="brief-picker-arrow">↗</span>
12698
14300
  </a>
12699
14301
  `;
@@ -13597,10 +15199,27 @@
13597
15199
  // (instead of relying on agent-overlay's autoTagAvatars regex) is
13598
15200
  // required for custom agents whose avatarPath is a data: URL —
13599
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();
13600
15214
  const castImgs = this.currentMembers
13601
15215
  .map((a) => {
13602
15216
  const id = this.escape(a.id);
13603
- 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>`;
13604
15223
  })
13605
15224
  .join("");
13606
15225
  const castCount = this.currentMembers.length;
@@ -13660,7 +15279,6 @@
13660
15279
  // tokens are not routed through `_t()`.
13661
15280
  const statusWord = r.status !== "live" ? String(r.status).toUpperCase() : "";
13662
15281
  head.innerHTML = `
13663
- <button type="button" class="room-head-expand" data-sidebar-expand title="${this.escape(this._t("sidebar_expand"))}" aria-label="${this.escape(this._t("sidebar_expand"))}" data-tip="${this.escape(this._t("sidebar_expand"))}"></button>
13664
15282
  <div class="room-info">
13665
15283
  <div class="room-kicker">
13666
15284
  <span class="kicker-num">// ROOM #${r.number}</span>
@@ -13674,9 +15292,10 @@
13674
15292
  </div>
13675
15293
  <div class="head-actions">
13676
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>
13677
15296
  <div class="head-cast">${castHtml}</div>
13678
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>
13679
- <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>
13680
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>
13681
15300
  ${this.currentBrief
13682
15301
  ? (() => {
@@ -15530,14 +17149,36 @@
15530
17149
  const isChairCard =
15531
17150
  article.classList.contains("chair-direct") ||
15532
17151
  article.classList.contains("chair-intervention");
15533
- if (empty && streaming) {
17152
+ // Single-active-speaker gate · the streaming animation belongs
17153
+ // only to the director whose turn is currently visible per the
17154
+ // server's queue-update activeMessageId field. In text mode this
17155
+ // erases the brief same-frame flash where A's finalize and B's
17156
+ // appended SSE events both run their DOM mutations in the same
17157
+ // rAF — without the gate, both bubbles render `.streaming` and
17158
+ // the user reads "two directors speaking at once." Chair cards
17159
+ // and non-agent messages (user, system) keep their own
17160
+ // streaming semantics; the gate only applies to director
17161
+ // (agent.roleKind === "director") bubbles. We can't read
17162
+ // roleKind here without a getter, so use the same heuristic
17163
+ // updateMessageBodyDom already trusts: a regular .msg article
17164
+ // that is NOT a chair-card is a director or user; user
17165
+ // messages don't carry the streaming class anyway, so this
17166
+ // narrows safely.
17167
+ const isAgentArticle =
17168
+ article.classList.contains("msg") &&
17169
+ !article.classList.contains("user") &&
17170
+ !isChairCard;
17171
+ const activeId = this.currentActiveMessageId || null;
17172
+ const speakingGate = !isAgentArticle || !activeId || messageId === activeId;
17173
+ const wantStreaming = !!streaming && speakingGate;
17174
+ if (empty && wantStreaming) {
15534
17175
  bubble.innerHTML = this.thinkingHtml();
15535
17176
  article.classList.add("thinking");
15536
17177
  article.classList.add(isChairCard ? "is-streaming" : "streaming");
15537
17178
  } else {
15538
17179
  bubble.innerHTML = this.renderBody(display);
15539
17180
  article.classList.toggle("thinking", false);
15540
- article.classList.toggle(isChairCard ? "is-streaming" : "streaming", !!streaming);
17181
+ article.classList.toggle(isChairCard ? "is-streaming" : "streaming", wantStreaming);
15541
17182
  // Re-apply chairman's-notes highlights · the bubble's
15542
17183
  // innerHTML rewrite above wipes any previously-injected
15543
17184
  // .note-highlight spans. Skip mid-stream (offsets won't match
@@ -15548,6 +17189,21 @@
15548
17189
  }
15549
17190
  },
15550
17191
 
17192
+ /** Re-evaluate the streaming class on a single bubble using its
17193
+ * current `meta.streaming` and the current activeMessageId. Used
17194
+ * by the queue-update SSE handler to flip the speaking animation
17195
+ * between A (finalized) and B (newly active) without waiting
17196
+ * for a token tick. No-op when the message isn't streaming or
17197
+ * the article isn't in the DOM yet. */
17198
+ _refreshStreamingClassForId(messageId) {
17199
+ if (!messageId) return;
17200
+ const msg = this.currentMessages.find((m) => m.id === messageId);
17201
+ if (!msg) return;
17202
+ const streaming = !!(msg.meta && msg.meta.streaming);
17203
+ if (!streaming) return;
17204
+ this.updateMessageBodyDom(messageId, msg.body || "", true);
17205
+ },
17206
+
15551
17207
  /** Count of prior non-empty messages this speaker would see as context. */
15552
17208
  contextCountAt(messageId) {
15553
17209
  const idx = this.currentMessages.findIndex((m) => m.id === messageId);
@@ -16212,12 +17868,28 @@
16212
17868
 
16213
17869
  const empty = !displayBody;
16214
17870
  const streaming = m.meta && m.meta.streaming === true;
17871
+ // Single-active-speaker gate · mirrors `updateMessageBodyDom`.
17872
+ // For director (non-chair, non-user, agent) messages, only the
17873
+ // bubble whose id matches the server's activeMessageId may
17874
+ // wear the .streaming + .thinking classes. This keeps the
17875
+ // first-paint and the live-update class state consistent so
17876
+ // the text-mode "two directors flashing" race vanishes.
17877
+ const isDirectorMsg = !isUser && !isChair;
17878
+ const activeId = this.currentActiveMessageId || null;
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;
17886
+ const wantStreaming = !!streaming && speakingGate;
16215
17887
  const stateCls = [];
16216
- if (streaming) stateCls.push("streaming");
16217
- if (empty && streaming && !isUser) stateCls.push("thinking");
17888
+ if (wantStreaming) stateCls.push("streaming");
17889
+ if (empty && wantStreaming) stateCls.push("thinking");
16218
17890
  if (metaKind) stateCls.push(`kind-${metaKind}`);
16219
17891
 
16220
- const bubbleHtml = (empty && streaming && !isUser)
17892
+ const bubbleHtml = (empty && wantStreaming)
16221
17893
  ? this.thinkingHtml()
16222
17894
  : this.renderBody(displayBody);
16223
17895
 
@@ -16245,7 +17917,18 @@
16245
17917
  const webSearchUsed = !!(m.meta && m.meta.webSearchUsed);
16246
17918
  const webSearchQuery = (m.meta && typeof m.meta.webSearchQuery === "string") ? m.meta.webSearchQuery : "";
16247
17919
  const webSearchSources = (m.meta && Array.isArray(m.meta.webSearchSources)) ? m.meta.webSearchSources : [];
16248
- 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)
16249
17932
  ? (() => {
16250
17933
  const n = webSearchSources.length;
16251
17934
  const title = this.escape(this._t("msg_ws_title", { query: webSearchQuery, n }));
@@ -16255,7 +17938,7 @@
16255
17938
  return `<button type="button" class="msg-web-search" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" title="${title}">🔍 ${label}</button>`;
16256
17939
  })()
16257
17940
  : "";
16258
- const webSearchSourcesPanel = webSearchUsed && webSearchSources.length > 0
17941
+ const webSearchSourcesPanel = (!renderWsAsCard && webSearchUsed && webSearchSources.length > 0)
16259
17942
  ? `<div class="msg-web-search-sources" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
16260
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>
16261
17944
  <ol class="msg-web-search-list">
@@ -16270,6 +17953,76 @@
16270
17953
  </ol>
16271
17954
  </div>`
16272
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
+ : "";
16273
18026
 
16274
18027
  // Round-end card · render even DURING streaming so the user sees
16275
18028
  // a skeleton placeholder immediately after the chair's ping
@@ -16339,9 +18092,11 @@
16339
18092
  ${ctxBadge}
16340
18093
  <span class="msg-time">${this.timeFmt(m.createdAt)}</span>
16341
18094
  </div>
18095
+ ${threadWebSearchCard}
16342
18096
  <div class="msg-bubble">${bubbleHtml}</div>
16343
18097
  ${webSearchSourcesPanel}
16344
18098
  </div>
18099
+ ${this._messageToolbarHtml(m, isUser, isChair, excusedMember, author)}
16345
18100
  </article>
16346
18101
  ${roundEndCard}
16347
18102
  ${roundPromptCard}
@@ -19889,6 +21644,21 @@
19889
21644
  app.closeRecordingStopModal();
19890
21645
  return;
19891
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
+ }
19892
21662
  // Record button in the room header · toggle on/off.
19893
21663
  if (e.target.closest("[data-record-toggle]")) {
19894
21664
  e.preventDefault();
@@ -21148,19 +22918,21 @@
21148
22918
  }
21149
22919
  });
21150
22920
 
21151
- // Input-bar textarea scrollbar reveal · the scrollbar is
21152
- // permanently hidden via `.ib-textarea { scrollbar-width: none }`
21153
- // and only flips visible while the user is actively scrolling.
21154
- // We toggle a `.is-scrolling` class on the SCROLLING element with
21155
- // a debounced removal so the scrollbar fades back out ~800 ms
21156
- // after the last scroll event. Scroll events don't bubble, so the
21157
- // listener is in capture phase to catch them from any textarea
21158
- // 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.
21159
22928
  const _scrollDecayTimers = new WeakMap();
21160
22929
  document.addEventListener("scroll", (ev) => {
21161
22930
  const t = ev.target;
21162
- if (!t || !(t instanceof HTMLTextAreaElement)) return;
21163
- 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;
21164
22936
  t.classList.add("is-scrolling");
21165
22937
  const prev = _scrollDecayTimers.get(t);
21166
22938
  if (prev) clearTimeout(prev);