privateboard 0.1.13 → 0.1.16

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
@@ -94,6 +94,20 @@
94
94
  * miss falls back to the raw voiceId so the row never blocks on
95
95
  * the fetch. */
96
96
  voiceLabels: {},
97
+ /** Interest-driven topic recommendations · the home composer's
98
+ * "找你可能感兴趣的话题" tray. Always exactly 6 items (or
99
+ * fewer if no batch has been generated yet) — every fresh
100
+ * generation wipes the previous batch server-side, so
101
+ * there's no pagination or history. `loaded` flips true
102
+ * after the first `/api/topic-recs` fetch so first-paint
103
+ * can show a sensible empty state. `job` is the live
104
+ * generation job (id + phase + pct + detail) or null when
105
+ * idle. */
106
+ topicRecs: {
107
+ items: [],
108
+ loaded: false,
109
+ job: null, // { id, phase, label, pct, detail, eventSource }
110
+ },
97
111
  rooms: [],
98
112
  currentRoomId: null,
99
113
  currentRoom: null,
@@ -108,6 +122,16 @@
108
122
  currentChair: null, // chair agent for the current room
109
123
  currentQueue: [],
110
124
  voiceQueues: {},
125
+ /** Speaking-queue strip · auto expand/collapse state machine.
126
+ * `_queueAutoCollapsed` is what the auto-logic computed last
127
+ * paint (true = collapsed). `_queueUserOverride` is set true
128
+ * when the user clicks the chevron; their inverse-of-auto
129
+ * pick sticks until the room state changes "significantly"
130
+ * (a flip in awaitingClarify / awaitingContinue / pending
131
+ * user message / queue empty-vs-nonempty), at which point
132
+ * the override resets. See `_applyQueueAutoState`. */
133
+ _queueAutoCollapsed: true,
134
+ _queueUserOverride: false,
111
135
  /** Round progress from the orchestrator: how many directors have
112
136
  * spoken in the current round vs. the cap (= cast size). */
113
137
  currentRound: { spoken: 0, total: 0 },
@@ -144,6 +168,21 @@
144
168
  * stays after the bubble dismisses; only this object resets.
145
169
  * `dismissed: true` means no bubble is showing right now. */
146
170
  userBubble: { text: "", deadline: 0, intervalId: null, dismissed: true },
171
+ /** Ephemeral question bubble pinned to the CHAIR seat when the
172
+ * chair drops a clarifying question in voice mode. Mirrors the
173
+ * userBubble shape exactly so the render / tick / dismiss
174
+ * surfaces stay parallel. 10s border countdown. */
175
+ chairBubble: { text: "", deadline: 0, intervalId: null, dismissed: true },
176
+ /** Set of chair messageIds whose voice synthesis hasn't started yet.
177
+ * Bridges the gap between message-appended (the chair's round-prompt
178
+ * / round-end placeholder lands instantly) and the first voice-chunk
179
+ * arriving from the server's TTS pipeline (~0.5-2s later). Without
180
+ * this, isChairBusy briefly returns false in that window and the
181
+ * vote popover flashes onto the chair seat before the chair has
182
+ * even started speaking — then hides again as audio kicks in, then
183
+ * re-appears after audio ends. Cleared when the first voice-chunk
184
+ * arrives (or after a 12s safety timeout if TTS never delivers). */
185
+ _chairVoiceAwaiting: null,
147
186
  currentBrief: null,
148
187
  /** All briefs filed for the current room · newest first. */
149
188
  currentBriefs: [],
@@ -272,6 +311,65 @@
272
311
  if (Array.isArray(this._reportsCache)) this.renderReportsPage(this._reportsCache);
273
312
  if (Array.isArray(this._notesCache)) this.renderNotesPage(this._notesCache);
274
313
  });
314
+
315
+ // Track the floating .ib-stack's height so the round-table
316
+ // stage can reserve exactly that much bottom space. Covers
317
+ // every height-changing event: speaking-queue expand /
318
+ // collapse, textarea auto-grow, paused-strip show / hide.
319
+ // The CSS fallback of 130px stays for the initial paint
320
+ // before the observer fires.
321
+ this._setupIbStackReserve();
322
+
323
+ // Ctrl+R / Cmd+R intercept · when the user tries to reload
324
+ // while a director is mid-stream, pop the reload-confirm
325
+ // modal so they can decide between (a) abort the speaker
326
+ // now and reload, (b) wait for the current speaker to
327
+ // finish and then reload, or (c) cancel. Without this,
328
+ // refreshing during voice playback used to drop the SSE
329
+ // mid-stream, kill the in-flight TTS, and leave the room
330
+ // partially paused on the server side · users had to dig
331
+ // around to recover.
332
+ //
333
+ // Browsers honour preventDefault on Ctrl+R / Cmd+R for the
334
+ // page-reload shortcut (it's not a reserved keystroke).
335
+ // Menu reload / URL-bar reload still get caught by the
336
+ // `beforeunload` handler below.
337
+ document.addEventListener("keydown", (e) => {
338
+ const isReload = (e.ctrlKey || e.metaKey)
339
+ && !e.altKey
340
+ && (e.key === "r" || e.key === "R" || e.code === "KeyR");
341
+ if (!isReload) return;
342
+ if (this._suppressReloadConfirm) return;
343
+ if (!this.currentRoomId) return;
344
+ if (!this.isAgentSpeaking()) return;
345
+ // Skip when the modal is already open · let the user
346
+ // pick a choice from the visible modal instead of
347
+ // spawning a duplicate.
348
+ if (document.getElementById("reload-choice-overlay")) {
349
+ e.preventDefault();
350
+ return;
351
+ }
352
+ e.preventDefault();
353
+ this.openReloadChoiceModal();
354
+ });
355
+
356
+ // Defense-in-depth · the `beforeunload` event fires for
357
+ // menu reload, URL-bar reload, browser back/forward, tab
358
+ // close. Modern browsers don't let us replace the dialog
359
+ // with custom HTML — they show a generic "Reload site?"
360
+ // prompt — but returning a truthy value triggers that
361
+ // prompt, which is still better than silently dropping a
362
+ // live turn. Only arms while a speaker is mid-stream and
363
+ // the user hasn't already confirmed via the keydown
364
+ // modal (the `_suppressReloadConfirm` flag).
365
+ window.addEventListener("beforeunload", (e) => {
366
+ if (this._suppressReloadConfirm) return;
367
+ if (!this.currentRoomId) return;
368
+ if (!this.isAgentSpeaking()) return;
369
+ e.preventDefault();
370
+ e.returnValue = "";
371
+ return "";
372
+ });
275
373
  // Voice replay drives the round-table stage in adjourned rooms ·
276
374
  // every replay state transition (item start, thinking → speaking,
277
375
  // playlist end, close) emits `boardroom:replay-active` so the
@@ -717,6 +815,11 @@
717
815
  this.agentSpec = null;
718
816
  this.agentSpecGenerating = false;
719
817
  this.agentSpecError = null;
818
+ // Entering a room silences the agent-build ambient (the user
819
+ // has shifted focus away from the agent composer). If a
820
+ // build is still running, the sidebar Building row remains
821
+ // and the ambient resumes the moment they click back.
822
+ try { window.boardroomAgentBuildBgm?.stop?.(); } catch { /* */ }
720
823
 
721
824
  // Drop conveneState if it belongs to a different room — protects
722
825
  // against stale "preparing…" leaking into a sibling room when
@@ -768,6 +871,14 @@
768
871
  clearInterval(this.userBubble.intervalId);
769
872
  }
770
873
  this.userBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
874
+ if (this.chairBubble && this.chairBubble.intervalId) {
875
+ clearInterval(this.chairBubble.intervalId);
876
+ }
877
+ this.chairBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
878
+ // Drop any leftover voice-await IDs from a prior room · timeouts
879
+ // that fire later will harmlessly no-op (their messageId isn't
880
+ // in the set anymore).
881
+ if (this._chairVoiceAwaiting) this._chairVoiceAwaiting.clear();
771
882
  // Chairman's notes for this room · fetched in parallel-ish
772
883
  // with the room body so the in-room highlight overlay can
773
884
  // wrap saved spans on first paint. Stored as a Map keyed by
@@ -917,6 +1028,12 @@
917
1028
  closeRoom() {
918
1029
  this.disconnectSSE();
919
1030
  this.cancelContinueCountdown();
1031
+ // Thinking SFX loop is bound to a director thinking in THIS
1032
+ // room · stop it explicitly so it doesn't keep pulsing after
1033
+ // the user navigates away (renderRoundTable is the normal
1034
+ // off-switch; without this guard, leaving the room before
1035
+ // the next render leaves the interval orphaned).
1036
+ try { window.boardroomTypingSfx?.setThinking?.(false); } catch { /* ignore */ }
920
1037
  this.currentRoomId = null;
921
1038
  this.currentRoom = null;
922
1039
  this.currentMessages = [];
@@ -943,6 +1060,14 @@
943
1060
  clearInterval(this.userBubble.intervalId);
944
1061
  }
945
1062
  this.userBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
1063
+ if (this.chairBubble && this.chairBubble.intervalId) {
1064
+ clearInterval(this.chairBubble.intervalId);
1065
+ }
1066
+ this.chairBubble = { text: "", deadline: 0, intervalId: null, dismissed: true };
1067
+ // Drop any leftover voice-await IDs from a prior room · timeouts
1068
+ // that fire later will harmlessly no-op (their messageId isn't
1069
+ // in the set anymore).
1070
+ if (this._chairVoiceAwaiting) this._chairVoiceAwaiting.clear();
946
1071
  this.currentBrief = null;
947
1072
  this.currentBriefs = [];
948
1073
  this.currentParentRef = null;
@@ -1027,6 +1152,30 @@
1027
1152
  if (data.authorKind === "agent") {
1028
1153
  this.hideChairPending();
1029
1154
  }
1155
+ // Chair vote-trigger message · register it as awaiting voice
1156
+ // so the vote popover stays suppressed through the gap
1157
+ // between message-appended and the first voice-chunk. Scoped
1158
+ // to round-prompt + round-end (the two kinds that surface the
1159
+ // chair-seat popover) so unrelated chair pings don't gate
1160
+ // anything. Voice-mode only · text mode has no such gap.
1161
+ if (
1162
+ data.authorKind === "agent"
1163
+ && this.currentChair && data.authorId === this.currentChair.id
1164
+ && data.meta && (data.meta.kind === "round-prompt" || data.meta.kind === "round-end")
1165
+ && this.currentRoom && this.currentRoom.deliveryMode === "voice"
1166
+ ) {
1167
+ this._chairVoiceAwaiting = this._chairVoiceAwaiting || new Set();
1168
+ this._chairVoiceAwaiting.add(data.messageId);
1169
+ const mid = data.messageId;
1170
+ // Safety net · 12s ceiling in case TTS never delivers
1171
+ // (network drop, server failure). Triggers a repaint so the
1172
+ // panel can finally surface without the user being stuck.
1173
+ setTimeout(() => {
1174
+ if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.delete(mid)) {
1175
+ this.renderRoundTable();
1176
+ }
1177
+ }, 12000);
1178
+ }
1030
1179
  // Convening card · clear the moment any chair message lands
1031
1180
  // (convening speech, clarify, anything). The card has done
1032
1181
  // its job; the chair is taking over from here. We re-render
@@ -1067,6 +1216,21 @@
1067
1216
  this.userSeatVisible = true;
1068
1217
  this.showUserBubble(data.body || "");
1069
1218
  }
1219
+ // Chair clarify bubble · drop it the moment ANY new message
1220
+ // arrives that isn't another chair clarify (a user reply, a
1221
+ // director turn, or the chair moving past the clarify
1222
+ // phase). Without this the 10s timer alone leaves the
1223
+ // question hovering over the chair seat while the user's
1224
+ // own reply bubble or the next speaker's turn already
1225
+ // owns the stage — visually confusing.
1226
+ if (this.chairBubble && !this.chairBubble.dismissed) {
1227
+ const isAnotherClarify =
1228
+ data.authorKind === "agent" &&
1229
+ this.currentChair &&
1230
+ data.authorId === this.currentChair.id &&
1231
+ (data.meta || {}).kind === "clarify";
1232
+ if (!isAnotherClarify) this.dismissChairBubble();
1233
+ }
1070
1234
  // Round-table toasts · gamified surface for chair-emitted
1071
1235
  // template messages (round-open marker, etc.) that the
1072
1236
  // chat normally renders as system cards. Only toast for
@@ -1160,6 +1324,21 @@
1160
1324
  msg.meta.streaming = false;
1161
1325
  msg.meta.speakerStatus = "final";
1162
1326
  }
1327
+ // Chair clarify · in voice mode, the chair's clarifying
1328
+ // question just finished streaming. Pin the question text to
1329
+ // the chair seat as a 10s countdown bubble (border-progress
1330
+ // ring, same mechanic as the user bubble). Voice-mode only ·
1331
+ // text mode users read the question inline in the scroll, so
1332
+ // a duplicate bubble would just clutter the stage.
1333
+ if (
1334
+ msg && msg.authorKind === "agent"
1335
+ && this.currentChair && msg.authorId === this.currentChair.id
1336
+ && msg.meta && msg.meta.kind === "clarify"
1337
+ && this.currentRoom && this.currentRoom.deliveryMode === "voice"
1338
+ && this.currentRoom.status !== "adjourned"
1339
+ ) {
1340
+ this.showChairBubble(msg.body);
1341
+ }
1163
1342
  // Round-table stage · the speaker just finished, so the
1164
1343
  // bubble should drop. Repaint the seats so the lime ring
1165
1344
  // / bubble migrate to whoever's next (director queue head)
@@ -1208,6 +1387,13 @@
1208
1387
  // meta.streaming. Skipping when a queue already exists keeps
1209
1388
  // the SSE hot path cheap (we don't repaint per chunk).
1210
1389
  const fresh = !this.voiceQueues[data.messageId];
1390
+ // Voice has actually arrived for this messageId · clear it
1391
+ // from the awaiting-voice set so isChairBusy hands off to the
1392
+ // voiceQueues check (which keeps the panel suppressed until
1393
+ // _fireVoiceDone removes the queue at audio end).
1394
+ if (fresh && this._chairVoiceAwaiting) {
1395
+ this._chairVoiceAwaiting.delete(data.messageId);
1396
+ }
1211
1397
  // Chair gavel SFX · fire ONCE when fresh chair voice starts
1212
1398
  // streaming, so the user hears the courtroom "knock-knock"
1213
1399
  // calling for attention before the chair speaks. Detection:
@@ -1314,6 +1500,17 @@
1314
1500
  // pipeline failed (typically chair-llm-failed) so no bubble
1315
1501
  // is coming to replace it.
1316
1502
  this.hideChairPending();
1503
+ // Deferred reload · the user picked "refresh after current
1504
+ // speaker finishes" from the reload-confirm modal earlier.
1505
+ // Now that the room is officially paused, the speaker's
1506
+ // turn has concluded · safe to reload. We unset the flag
1507
+ // first so a stray re-emit of room-paused doesn't trigger
1508
+ // a second reload after the page comes back.
1509
+ if (this._pendingReloadOnPause) {
1510
+ this._pendingReloadOnPause = false;
1511
+ this._suppressReloadConfirm = true;
1512
+ location.reload();
1513
+ }
1317
1514
  } else if (kind === "room-resumed") {
1318
1515
  if (this.currentRoom) {
1319
1516
  this.currentRoom.status = "live";
@@ -1321,15 +1518,12 @@
1321
1518
  }
1322
1519
  document.documentElement.setAttribute("data-status", "live");
1323
1520
  this.renderHeader();
1324
- // Resume · the input-bar's toggle is now visible again
1325
- // (paused-bar went display:none). Re-sync both twins so
1326
- // glyph/aria/hidden land correctly on the live one.
1521
+ // Resume · the input-bar's left cluster swaps from
1522
+ // Resume back to Pause/Adjourn/Vote; the round-table
1523
+ // toggle re-syncs across all visible twins.
1524
+ this.renderInputBarControls();
1327
1525
  this.applyRoundTableVisibility(this.currentRoomId);
1328
1526
  syncSidebar({ status: "live", pausedAt: null });
1329
- // Drop any paused-supplement overlay · the supplement endpoint
1330
- // 409s once the room is live, so leaving the modal up is just
1331
- // a confusing no-op for the user.
1332
- this.closePausedSupplementOverlay?.();
1333
1527
  } else if (kind === "room-adjourned") {
1334
1528
  const ts = payload.adjournedAt || Date.now();
1335
1529
  if (this.currentRoom) {
@@ -1693,6 +1887,11 @@
1693
1887
  if (this.currentBrief && target && this.currentBrief.id === target.id) {
1694
1888
  this.renderBrief();
1695
1889
  }
1890
+ // Header was stuck in "Generating Report…" pending state · now
1891
+ // that the brief errored, the generating check returns false
1892
+ // and the regular [ View Report ] link mounts (its report page
1893
+ // surfaces the error / retry UI).
1894
+ this.renderHeader();
1696
1895
  } else if (kind === "settings-changed") {
1697
1896
  const ch = payload.changes || {};
1698
1897
  if (this.currentRoom) {
@@ -1711,7 +1910,9 @@
1711
1910
  // Server-driven deliveryMode flip · sync the round-table
1712
1911
  // stage's visibility so the user's view matches reality
1713
1912
  // even when the change came from another tab / device.
1714
- if (ch.deliveryMode) this.applyRoundTableVisibility(this.currentRoomId);
1913
+ if (ch.deliveryMode) {
1914
+ this.applyRoundTableVisibility(this.currentRoomId);
1915
+ }
1715
1916
  // Server-driven voteTrigger flip · show/hide the bottom-
1716
1917
  // bar manual button immediately.
1717
1918
  if (ch.voteTrigger) this.refreshManualVoteButton();
@@ -1904,10 +2105,14 @@
1904
2105
  // Show "analyzing topic" stage on the convening card. The
1905
2106
  // card was seeded by createRoom so it's already on screen;
1906
2107
  // we just confirm the stage in case SSE arrived before the
1907
- // card was rendered.
2108
+ // card was rendered. In voice mode the chat is hidden, so
2109
+ // also repaint the stage — renderRoundTable cascades to
2110
+ // the subtitle + HUD so the convene-stage label propagates
2111
+ // to every visible surface.
1908
2112
  if (this.conveneState) {
1909
2113
  this.conveneState.stage = "analyzing";
1910
2114
  this.renderChat();
2115
+ this.renderRoundTable();
1911
2116
  }
1912
2117
  } else if (kind === "member-added" && payload?.autoPicked) {
1913
2118
  // Director seated by the auto-picker · patch currentMembers
@@ -1924,7 +2129,10 @@
1924
2129
  this.renderQueue();
1925
2130
  // Convening card · advance to "seating" and append the
1926
2131
  // newly-seated director's avatar so the user watches the
1927
- // cast assemble in real time.
2132
+ // cast assemble in real time. renderQueue above already
2133
+ // re-paints the stage (and cascades to subtitle + HUD)
2134
+ // so the convene-stage label propagates to every visible
2135
+ // surface.
1928
2136
  if (this.conveneState) {
1929
2137
  this.conveneState.stage = "seating";
1930
2138
  if (!this.conveneState.seated.find((a) => a.id === agent.id)) {
@@ -1936,9 +2144,13 @@
1936
2144
  } else if (kind === "auto-pick-complete") {
1937
2145
  // Cast is fully seated. Advance the convening card to
1938
2146
  // "preparing" — chair's about to start streaming.
2147
+ // renderRoundTable cascades to subtitle + HUD so the new
2148
+ // stage label lands everywhere (voice mode hides the chat
2149
+ // card so the stage surfaces are the only feedback).
1939
2150
  if (this.conveneState) {
1940
2151
  this.conveneState.stage = "preparing";
1941
2152
  this.renderChat();
2153
+ this.renderRoundTable();
1942
2154
  }
1943
2155
  }
1944
2156
  });
@@ -1959,7 +2171,7 @@
1959
2171
  },
1960
2172
 
1961
2173
  // ── Actions ───────────────────────────────────────────────
1962
- async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode }) {
2174
+ async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode, seedContext }) {
1963
2175
  const r = await fetch("/api/rooms", {
1964
2176
  method: "POST",
1965
2177
  headers: { "content-type": "application/json" },
@@ -1971,6 +2183,11 @@
1971
2183
  briefStyle: briefStyle || "auto",
1972
2184
  deliveryMode: deliveryMode === "voice" ? "voice" : "text",
1973
2185
  ...(autoPick ? { autoPick: true } : {}),
2186
+ // seedContext · attached when the user opened this room
2187
+ // from a topic-rec card. Backend writes it to the
2188
+ // opening message's meta so the chair grounds clarify
2189
+ // in the actual source snippets.
2190
+ ...(seedContext ? { seedContext } : {}),
1974
2191
  }),
1975
2192
  });
1976
2193
  if (!r.ok) {
@@ -2065,6 +2282,36 @@
2065
2282
  // burst doesn't double-fire.
2066
2283
  if (this.sendInFlight) return false;
2067
2284
  if (Date.now() - this.lastSendAt < this.SEND_THROTTLE_MS) return false;
2285
+ // Paused-room path · the textarea stays usable while the
2286
+ // room is paused (no more dedicated "Add input" modal).
2287
+ // Send routes through the supplement endpoint so the
2288
+ // message lands in the transcript without auto-resuming
2289
+ // the discussion. User clicks Resume separately to restart.
2290
+ if (this.currentRoom && this.currentRoom.status === "paused") {
2291
+ input.value = "";
2292
+ if (input.matches?.(".ib-textarea[data-send-input]")) {
2293
+ this.autosizeRoomInputTextarea();
2294
+ }
2295
+ this.lastSendAt = Date.now();
2296
+ this.submitPausedInput(text.trim()).catch((err) => {
2297
+ alert("Add input failed: " + (err && err.message ? err.message : err));
2298
+ });
2299
+ return true;
2300
+ }
2301
+ // Adjourned-room path · the textarea opens a follow-up room
2302
+ // composer pre-filled with the typed text. The room itself is
2303
+ // archived; Send is the "continue this thread elsewhere"
2304
+ // gesture. User can refine the subject in the overlay before
2305
+ // confirming.
2306
+ if (this.currentRoom && this.currentRoom.status === "adjourned") {
2307
+ input.value = "";
2308
+ if (input.matches?.(".ib-textarea[data-send-input]")) {
2309
+ this.autosizeRoomInputTextarea();
2310
+ }
2311
+ this.lastSendAt = Date.now();
2312
+ this.openFollowUpOverlay({ subject: text.trim() });
2313
+ return true;
2314
+ }
2068
2315
  // If a director is mid-turn AND we don't already have a queued
2069
2316
  // message, ask the user how to proceed.
2070
2317
  if (this.isAgentSpeaking() && !this.pendingUserMessage) {
@@ -2072,6 +2319,14 @@
2072
2319
  return true;
2073
2320
  }
2074
2321
  input.value = "";
2322
+ // Shrink the textarea back to its single-line baseline ·
2323
+ // typed content may have grown the field, and the cleared
2324
+ // value should snap it back. Only fires when the cleared
2325
+ // input is the room input-bar textarea (not the legacy
2326
+ // single-line input — that doesn't auto-grow).
2327
+ if (input.matches?.(".ib-textarea[data-send-input]")) {
2328
+ this.autosizeRoomInputTextarea();
2329
+ }
2075
2330
  this.sendMessage(text).catch((err) => alert("Send failed: " + err.message));
2076
2331
  return true;
2077
2332
  },
@@ -2723,54 +2978,50 @@
2723
2978
  }
2724
2979
  },
2725
2980
 
2726
- /** Paused-supplement overlay · lets the user drop in an extra
2727
- * thought while the room is paused. The text is posted as a
2728
- * user message immediately (lands in the chat as the freshest
2729
- * user input) but the saved director queue is left untouched —
2730
- * so when they click Resume, the previously-paused director
2731
- * takes over with the supplement already in their context.
2732
- * Effectively the supplement plays "first" in the resumed
2733
- * flow, and the rest of the queue continues in order. Reuses
2734
- * the existing .supplement-* CSS classes for visual parity. */
2735
- openPausedSupplementOverlay() {
2736
- if (!this.currentRoomId || !this.currentRoom) return;
2737
- if (this.currentRoom.status !== "paused") return;
2738
- this.closePausedSupplementOverlay();
2739
- // System UI · always English. Paused-supplement overlay chrome.
2981
+ /** Open the divergence-report overlay for the current room.
2982
+ * Used to live as a panel inside the room-info modal; lifted
2983
+ * into its own overlay so it can be opened directly from the
2984
+ * input bar `[data-divergence-open]` button without forcing
2985
+ * the user to wade through room-settings chrome.
2986
+ *
2987
+ * Empty / new rooms · the panel body shows a "not enough turns
2988
+ * yet" hint instead of hiding · the user explicitly asked for
2989
+ * this overlay, so we owe them an empty state rather than a
2990
+ * silent no-op. */
2991
+ openDivergenceOverlay() {
2992
+ if (!this.currentRoomId) return;
2993
+ this.closeDivergenceOverlay();
2740
2994
  const t = {
2741
- classify: "room · paused supplement",
2742
- classifyRight: "// queued first",
2743
- title: "Add a supplemental input",
2744
- metaPrefix: "// Current room",
2745
- placeholder: "Drop in an extra thought, a follow-up question, or an angle you'd like the board to take into account.\n\nIt lands in the chat as your message right now; when you hit [ Resume ], the next director picks up with this in front of them.",
2746
- hint: "Posted while paused, the supplement lands as your message immediately; the saved speaker queue is untouched. After resume, the next director responds with the supplement first.",
2747
- cancel: "[ Cancel ]",
2748
- confirm: "[ Add to chat ]",
2749
- confirmBusy: "[ Posting… ]",
2995
+ classify: "ROOM · DIVERGENCE REPORT",
2996
+ classifyRight: "// local",
2997
+ title: "Divergence report",
2998
+ metaPrefix: "Room #" + (this.currentRoom?.number ?? "—"),
2999
+ loading: "loading…",
3000
+ empty: "Not enough turns yet · the room hasn't accumulated tagged messages for branches or behavioural scoring.",
3001
+ kickerMap: "// topic map",
3002
+ kickerCoverage: "// behavioural coverage",
3003
+ close: "Close",
2750
3004
  };
2751
- const subject = (this.currentRoom.subject || "").trim() || "(no subject)";
2752
3005
  const html = `
2753
- <div class="supplement-overlay" id="paused-supplement-overlay" role="dialog" aria-modal="true">
2754
- <div class="supplement-backdrop" data-paused-supplement-close></div>
2755
- <div class="supplement-modal" role="document">
3006
+ <div class="supplement-overlay" id="divergence-overlay" role="dialog" aria-modal="true">
3007
+ <div class="supplement-backdrop" data-divergence-close></div>
3008
+ <div class="supplement-modal divergence-modal" role="document">
2756
3009
  <div class="supplement-classification">
2757
3010
  <span><span class="dot">●</span> ${this.escape(t.classify)}</span>
2758
3011
  <span class="right">${this.escape(t.classifyRight)}</span>
2759
3012
  </div>
2760
3013
  <header class="supplement-head">
2761
3014
  <div>
2762
- <div class="meta">${this.escape(t.metaPrefix)} · <span>${this.escape(subject)}</span></div>
3015
+ <div class="meta">${this.escape(t.metaPrefix)}</div>
2763
3016
  <div class="title">${this.escape(t.title)}</div>
2764
3017
  </div>
2765
- <button type="button" class="supplement-close" data-paused-supplement-close aria-label="Close">✕</button>
3018
+ <button type="button" class="supplement-close" data-divergence-close aria-label="Close">✕</button>
2766
3019
  </header>
2767
- <div class="supplement-body">
2768
- <textarea class="supplement-input" data-paused-supplement-input rows="6" placeholder="${this.escape(t.placeholder)}"></textarea>
2769
- <p class="supplement-hint">${this.escape(t.hint)}</p>
3020
+ <div class="supplement-body" data-divergence-body>
3021
+ <div class="dv-loading">${this.escape(t.loading)}</div>
2770
3022
  </div>
2771
3023
  <footer class="supplement-foot">
2772
- <button type="button" class="supplement-cancel" data-paused-supplement-close>${this.escape(t.cancel)}</button>
2773
- <button type="button" class="supplement-confirm" data-paused-supplement-confirm data-busy-label="${this.escape(t.confirmBusy)}">${this.escape(t.confirm)}</button>
3024
+ <button type="button" class="supplement-cancel" data-divergence-close>${this.escape(t.close)}</button>
2774
3025
  </footer>
2775
3026
  </div>
2776
3027
  </div>
@@ -2779,76 +3030,231 @@
2779
3030
  wrap.innerHTML = html.trim();
2780
3031
  document.body.appendChild(wrap.firstChild);
2781
3032
  document.body.style.overflow = "hidden";
2782
- this._pausedSupplementEsc = (ev) => {
3033
+ this._divergenceEsc = (ev) => {
2783
3034
  if (ev.key === "Escape") {
2784
3035
  ev.stopImmediatePropagation();
2785
- this.closePausedSupplementOverlay();
3036
+ this.closeDivergenceOverlay();
2786
3037
  }
2787
3038
  };
2788
- document.addEventListener("keydown", this._pausedSupplementEsc, true);
2789
- // Cmd/Ctrl-Enter submits long-form textarea convention.
2790
- this._pausedSupplementSubmit = (ev) => {
2791
- if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
2792
- const overlay = document.getElementById("paused-supplement-overlay");
2793
- if (!overlay) return;
2794
- ev.preventDefault();
2795
- this.submitPausedSupplement();
2796
- }
3039
+ document.addEventListener("keydown", this._divergenceEsc, true);
3040
+ // Fire-and-forget fetch · idle "loading…" until the route
3041
+ // returns. Failure paints the empty state.
3042
+ void this._paintDivergenceOverlay();
3043
+ },
3044
+
3045
+ async _paintDivergenceOverlay() {
3046
+ const body = document.querySelector("[data-divergence-body]");
3047
+ if (!body) return;
3048
+ const roomId = this.currentRoomId;
3049
+ if (!roomId) {
3050
+ body.innerHTML = `<div class="dv-loading">no active room</div>`;
3051
+ return;
3052
+ }
3053
+ let data;
3054
+ try {
3055
+ const r = await fetch(`/api/rooms/${encodeURIComponent(roomId)}/diversity`);
3056
+ if (!r.ok) throw new Error("HTTP " + r.status);
3057
+ data = await r.json();
3058
+ } catch {
3059
+ body.innerHTML = this._renderDivergenceEmpty();
3060
+ return;
3061
+ }
3062
+ const branches = Array.isArray(data?.branches) ? data.branches : [];
3063
+ const coverage = data?.coverage || { filled: 0, total: 64, pct: 0 };
3064
+ const buckets = data?.buckets || { abstraction: [0,0,0,0], time: [0,0,0,0], stakeholder: [0,0,0,0] };
3065
+ const scored = typeof data?.messagesScored === "number" ? data.messagesScored : 0;
3066
+ const unexplored = Array.isArray(data?.unexplored) ? data.unexplored : [];
3067
+ if (branches.length === 0 && scored === 0) {
3068
+ body.innerHTML = this._renderDivergenceEmpty();
3069
+ return;
3070
+ }
3071
+ body.innerHTML = this._renderDivergenceHtml({ branches, coverage, buckets, scored, unexplored });
3072
+ },
3073
+
3074
+ _renderDivergenceEmpty() {
3075
+ return `
3076
+ <div class="dv-purpose">
3077
+ <p>Once your directors have run a couple of rounds, this panel will tell you whether the conversation is exploring widely or collapsing into one frame — and which angles haven't been touched yet.</p>
3078
+ <p class="dv-purpose-hint">Come back after a couple more turns.</p>
3079
+ </div>
3080
+ `;
3081
+ },
3082
+
3083
+ /** Compute a plain-language health verdict from the branch +
3084
+ * coverage data. Returns { tone, headline, body }. Tones map
3085
+ * to CSS classes for the verdict pill (healthy / narrowing /
3086
+ * converging / early). */
3087
+ _computeDivergenceVerdict(d) {
3088
+ const branches = d.branches || [];
3089
+ const totalTurns = branches.reduce((sum, b) => sum + (b.turnCount || 0), 0);
3090
+ if (totalTurns < 4 || branches.length === 0) {
3091
+ return {
3092
+ tone: "early",
3093
+ headline: "Too early to tell",
3094
+ body: "Let the room run a couple more rounds and this view will show you how widely the conversation has spread.",
3095
+ };
3096
+ }
3097
+ const top = branches[0]?.turnCount || 0;
3098
+ const topShare = totalTurns > 0 ? top / totalTurns : 0;
3099
+ if (branches.length >= 4 && topShare < 0.5) {
3100
+ return {
3101
+ tone: "healthy",
3102
+ headline: "Healthy divergence",
3103
+ body: `${branches.length} distinct angles across ${totalTurns} turns. No single thread dominates — the room is exploring widely.`,
3104
+ };
3105
+ }
3106
+ if (branches.length >= 3 && topShare < 0.65) {
3107
+ return {
3108
+ tone: "moderate",
3109
+ headline: "Reasonably broad",
3110
+ body: `${branches.length} angles touched. "${branches[0].label}" is the dominant thread but other directions are still getting airtime.`,
3111
+ };
3112
+ }
3113
+ if (topShare >= 0.65 || (branches.length <= 2 && totalTurns >= 6)) {
3114
+ return {
3115
+ tone: "narrowing",
3116
+ headline: "Narrowing on one frame",
3117
+ body: `${Math.round(topShare * 100)}% of recent turns are circling "${branches[0]?.label || "one angle"}". If you want fresh ground, try seeding a different lens in your next message.`,
3118
+ };
3119
+ }
3120
+ return {
3121
+ tone: "moderate",
3122
+ headline: "Building up",
3123
+ body: `${branches.length} angles touched over ${totalTurns} turns. Still developing — check back after the next round-end.`,
2797
3124
  };
2798
- document.addEventListener("keydown", this._pausedSupplementSubmit, true);
2799
- setTimeout(() => {
2800
- const input = document.querySelector("[data-paused-supplement-input]");
2801
- if (input) input.focus();
2802
- }, 30);
2803
3125
  },
2804
3126
 
2805
- closePausedSupplementOverlay() {
2806
- const el = document.getElementById("paused-supplement-overlay");
3127
+ /** Pure render for the divergence overlay body.
3128
+ *
3129
+ * Structure (top-down, in order of usefulness to the user):
3130
+ * 1. Purpose line · one sentence telling them what this is for
3131
+ * 2. Verdict pill + body · "Healthy / Narrowing / etc." with
3132
+ * a plain-language paragraph explaining what's happening
3133
+ * 3. Angles explored · the topic-tree branches as a friendly
3134
+ * list (most-discussed first), each annotated with how
3135
+ * many turns went into it
3136
+ * 4. Angles to explore next · negative-space data, only shown
3137
+ * when non-empty · actionable hooks the user can plug into
3138
+ * their next message
3139
+ * 5. (Collapsed by default) Behavioural breadth · the 64-cell
3140
+ * QD coverage + the three histograms. This is the dev-data
3141
+ * view; most users won't expand it.
3142
+ */
3143
+ _renderDivergenceHtml(d) {
3144
+ const esc = this.escape.bind(this);
3145
+ const verdict = this._computeDivergenceVerdict(d);
3146
+ const branchItems = d.branches.length > 0
3147
+ ? d.branches.map((b) => {
3148
+ const turns = b.turnCount || 0;
3149
+ const widthPct = Math.min(100, Math.max(8, turns * 18));
3150
+ const tone = turns >= 3 ? "dv-branch-hot" : turns === 2 ? "dv-branch-warm" : "dv-branch-cool";
3151
+ return `
3152
+ <li class="dv-branch-row ${tone}">
3153
+ <span class="dv-branch-bar" style="width: ${widthPct}%"></span>
3154
+ <span class="dv-branch-label">${esc(b.label)}</span>
3155
+ <span class="dv-branch-count">${turns} ${turns === 1 ? "turn" : "turns"}</span>
3156
+ </li>
3157
+ `;
3158
+ }).join("")
3159
+ : `<li class="dv-branch-empty">no angles tagged yet</li>`;
3160
+ const unexploredItems = (d.unexplored && d.unexplored.length > 0)
3161
+ ? d.unexplored.map((u) => `<li class="dv-unexplored-row">${esc(u.angle)}</li>`).join("")
3162
+ : "";
3163
+ const unexploredSection = unexploredItems ? `
3164
+ <section class="dv-section">
3165
+ <h3 class="dv-section-title">Angles worth exploring next</h3>
3166
+ <p class="dv-section-deck">The chair noticed these directions came up briefly but the room didn't follow them. Useful prompts for your next message.</p>
3167
+ <ul class="dv-unexplored-list">${unexploredItems}</ul>
3168
+ </section>
3169
+ ` : "";
3170
+ // Advanced section · collapsed by default. Uses native
3171
+ // <details> so no JS toggle is needed.
3172
+ const pctRound = Math.round((d.coverage.pct || 0) * 100);
3173
+ const histogram = (label, arr, leftEnd, rightEnd) => {
3174
+ const max = Math.max(1, ...arr);
3175
+ const cells = arr.map((v) => {
3176
+ const h = Math.max(4, Math.round((v / max) * 28));
3177
+ const dim = v === 0 ? "dv-hist-cell-empty" : "dv-hist-cell-filled";
3178
+ return `<span class="dv-hist-cell ${dim}" style="height: ${h}px" title="${v} ${v === 1 ? "turn" : "turns"}"></span>`;
3179
+ }).join("");
3180
+ return `
3181
+ <div class="dv-hist-row">
3182
+ <span class="dv-hist-label">${esc(label)}</span>
3183
+ <span class="dv-hist-axis">${esc(leftEnd)}</span>
3184
+ <span class="dv-hist-cells">${cells}</span>
3185
+ <span class="dv-hist-axis dv-hist-axis-right">${esc(rightEnd)}</span>
3186
+ </div>
3187
+ `;
3188
+ };
3189
+ return `
3190
+ <div class="dv-panel">
3191
+ <p class="dv-purpose">A quick health check on how widely this brainstorm has explored your question — what's been covered, what's been missed, and whether the room is staying broad or narrowing.</p>
3192
+
3193
+ <section class="dv-section dv-verdict dv-verdict-${esc(verdict.tone)}">
3194
+ <div class="dv-verdict-pill">${esc(verdict.headline)}</div>
3195
+ <p class="dv-verdict-body">${esc(verdict.body)}</p>
3196
+ </section>
3197
+
3198
+ <section class="dv-section">
3199
+ <h3 class="dv-section-title">Angles the room has explored</h3>
3200
+ <p class="dv-section-deck">Each line is one direction the conversation has taken. The bar shows how much airtime it got.</p>
3201
+ <ul class="dv-branch-list">${branchItems}</ul>
3202
+ </section>
3203
+
3204
+ ${unexploredSection}
3205
+
3206
+ <details class="dv-advanced">
3207
+ <summary class="dv-advanced-summary">Behavioural breadth (advanced)</summary>
3208
+ <div class="dv-advanced-body">
3209
+ <p class="dv-section-deck">Each director's turn gets scored on three axes (how abstract · what time scale · whose perspective). The grid below shows how many distinct combinations the room has touched — wider coverage = the room is sampling more of the possibility space.</p>
3210
+ <div class="dv-coverage-kpi">
3211
+ <span class="dv-coverage-num">${d.coverage.filled}</span>
3212
+ <span class="dv-coverage-sep">/</span>
3213
+ <span class="dv-coverage-total">${d.coverage.total}</span>
3214
+ <span class="dv-coverage-pct">combinations · ${pctRound}%</span>
3215
+ <span class="dv-coverage-scored">${d.scored} ${d.scored === 1 ? "turn" : "turns"} scored</span>
3216
+ </div>
3217
+ ${histogram("abstraction", d.buckets.abstraction, "concrete", "principle")}
3218
+ ${histogram("time scale", d.buckets.time, "this quarter", "civilisation")}
3219
+ ${histogram("perspective", d.buckets.stakeholder, "individual", "society")}
3220
+ </div>
3221
+ </details>
3222
+ </div>
3223
+ `;
3224
+ },
3225
+
3226
+ closeDivergenceOverlay() {
3227
+ const el = document.getElementById("divergence-overlay");
2807
3228
  if (el) el.remove();
2808
3229
  document.body.style.overflow = "";
2809
- if (this._pausedSupplementEsc) {
2810
- document.removeEventListener("keydown", this._pausedSupplementEsc, true);
2811
- this._pausedSupplementEsc = null;
2812
- }
2813
- if (this._pausedSupplementSubmit) {
2814
- document.removeEventListener("keydown", this._pausedSupplementSubmit, true);
2815
- this._pausedSupplementSubmit = null;
3230
+ if (this._divergenceEsc) {
3231
+ document.removeEventListener("keydown", this._divergenceEsc, true);
3232
+ this._divergenceEsc = null;
2816
3233
  }
2817
3234
  },
2818
3235
 
2819
- async submitPausedSupplement() {
2820
- const overlay = document.getElementById("paused-supplement-overlay");
2821
- if (!overlay) return;
2822
- const input = overlay.querySelector("[data-paused-supplement-input]");
2823
- const btn = overlay.querySelector("[data-paused-supplement-confirm]");
2824
- const text = input ? (input.value || "").trim() : "";
2825
- if (!text) {
2826
- if (input) input.focus();
2827
- return;
2828
- }
3236
+ /** POST a supplement message to a paused room · the textarea
3237
+ * Send button routes here when room.status === "paused".
3238
+ * The endpoint inserts the body into the transcript as a
3239
+ * user message but doesn't resume the room — the user has
3240
+ * to click Resume to restart the discussion. */
3241
+ async submitPausedInput(body) {
2829
3242
  if (!this.currentRoomId) return;
2830
- const origLabel = btn ? btn.textContent : "";
2831
- const busyLabel = btn ? btn.getAttribute("data-busy-label") || origLabel : "";
2832
- if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
2833
- try {
2834
- const r = await fetch(
2835
- "/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/paused-input",
2836
- {
2837
- method: "POST",
2838
- headers: { "content-type": "application/json" },
2839
- body: JSON.stringify({ body: text }),
2840
- },
2841
- );
2842
- if (!r.ok) {
2843
- const e = await r.json().catch(() => ({}));
2844
- throw new Error(e.error || ("HTTP " + r.status));
2845
- }
2846
- // SSE will push the message-appended event; chat updates itself.
2847
- this.closePausedSupplementOverlay();
2848
- } catch (e) {
2849
- if (btn) { btn.disabled = false; btn.textContent = origLabel; }
2850
- alert("Add input failed: " + (e && e.message ? e.message : e));
3243
+ const text = String(body || "").trim();
3244
+ if (!text) return;
3245
+ const r = await fetch(
3246
+ "/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/paused-input",
3247
+ {
3248
+ method: "POST",
3249
+ headers: { "content-type": "application/json" },
3250
+ body: JSON.stringify({ body: text }),
3251
+ },
3252
+ );
3253
+ if (!r.ok) {
3254
+ const e = await r.json().catch(() => ({}));
3255
+ throw new Error(e.error || ("HTTP " + r.status));
2851
3256
  }
3257
+ // SSE pushes message-appended; chat updates itself.
2852
3258
  },
2853
3259
 
2854
3260
  /* ─── Convene Follow-up · overlay + submit ─────────────────────
@@ -2863,55 +3269,37 @@
2863
3269
  * room's chair clarify + first tick fire server-side; the
2864
3270
  * follow-up's directors get the parent brief + Stage-1 signals
2865
3271
  * prepended to their system prompts (see room.ts orchestrator). */
2866
- openFollowUpOverlay() {
3272
+ openFollowUpOverlay({ subject } = {}) {
2867
3273
  if (!this.currentRoomId || !this.currentRoom) return;
2868
3274
  if (this.currentRoom.status !== "adjourned") return;
2869
3275
  this.closeFollowUpOverlay();
2870
3276
 
2871
- const lang = this.composerLanguage();
2872
- const t = lang === "zh"
2873
- ? {
2874
- classify: "follow-up · 跟进会议",
2875
- classifyRight: "// continuing",
2876
- title: "开一场跟进会议",
2877
- metaPrefix: "// following up",
2878
- placeholder: "在上一场判断之上,下一个要追问的问题是什么?",
2879
- contextNote: "上一场的议题、最终判断(brief)和每位 director 的关键观察会作为这场 follow-up 房间的上下文交给新一组 director —— 他们可以直接在已成型的判断上推进,不会从零开始。",
2880
- castLabel: "Directors",
2881
- castHint: "建议 2-4 位",
2882
- castSame: "沿用上一场的 cast",
2883
- pickerLabel: "选择董事",
2884
- autoLabel: "directors",
2885
- autoVal: "自动挑选",
2886
- countersDirectors: (n) => `${n} 位董事`,
2887
- cancel: "[ Cancel ]",
2888
- confirm: "[ Convene → ]",
2889
- confirmBusy: "[ Convening… ]",
2890
- adjournedAtPrefix: "adjourned",
2891
- briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2892
- noBrief: "no brief filed",
2893
- }
2894
- : {
2895
- classify: "follow-up · continuation room",
2896
- classifyRight: "// continuing",
2897
- title: "Convene a follow-up",
2898
- metaPrefix: "// following up",
2899
- placeholder: "What's the next question to chase, given what the prior session settled?",
2900
- contextNote: "The prior subject, the filed brief (room's settled judgement), and each director's load-bearing observations are bundled as context for this follow-up — the new cast picks up where the prior session left off rather than starting from scratch.",
2901
- castLabel: "Directors",
2902
- castHint: "2–4 recommended",
2903
- castSame: "Same cast as last session",
2904
- pickerLabel: "Pick directors",
2905
- autoLabel: "directors",
2906
- autoVal: "auto-pick",
2907
- countersDirectors: (n) => `${n} director${n === 1 ? "" : "s"}`,
2908
- cancel: "[ Cancel ]",
2909
- confirm: "[ Convene → ]",
2910
- confirmBusy: "[ Convening… ]",
2911
- adjournedAtPrefix: "adjourned",
2912
- briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2913
- noBrief: "no brief filed",
2914
- };
3277
+ // Pull copy from I18n · supports all 4 locales (en / zh /
3278
+ // ja / es) with EN fallback for missing keys. Previously
3279
+ // a `lang === "zh" ? <zh> : <en>` ternary that violated
3280
+ // the "no per-locale ternaries in app code" rule.
3281
+ const _t = this._t.bind(this);
3282
+ const t = {
3283
+ classify: _t("followup_classify"),
3284
+ classifyRight: _t("followup_classify_right"),
3285
+ title: _t("followup_title"),
3286
+ metaPrefix: _t("followup_meta_prefix"),
3287
+ placeholder: _t("followup_placeholder"),
3288
+ contextNote: _t("followup_context_note"),
3289
+ castLabel: _t("followup_cast_label"),
3290
+ castHint: _t("followup_cast_hint"),
3291
+ castSame: _t("followup_cast_same"),
3292
+ pickerLabel: _t("followup_picker_label"),
3293
+ autoLabel: _t("followup_auto_label"),
3294
+ autoVal: _t("followup_auto_val"),
3295
+ countersDirectors: (n) => _t(n === 1 ? "followup_directors_one" : "followup_directors_many", { n }),
3296
+ cancel: _t("followup_cancel"),
3297
+ confirm: _t("followup_confirm"),
3298
+ confirmBusy: _t("followup_confirm_busy"),
3299
+ adjournedAtPrefix: _t("followup_adjourned_at_prefix"),
3300
+ briefsCount: (n) => _t(n === 1 ? "followup_briefs_one" : "followup_briefs_many", { n }),
3301
+ noBrief: _t("followup_no_brief"),
3302
+ };
2915
3303
  const room = this.currentRoom;
2916
3304
  const briefCount = Array.isArray(this.currentBriefs) ? this.currentBriefs.length : 0;
2917
3305
  const briefLine = briefCount > 0 ? t.briefsCount(briefCount) : t.noBrief;
@@ -3109,7 +3497,15 @@
3109
3497
  document.addEventListener("keydown", this._followupSubmit, true);
3110
3498
  setTimeout(() => {
3111
3499
  const input = document.querySelector("[data-followup-subject]");
3112
- if (input) input.focus();
3500
+ if (!input) return;
3501
+ if (subject) {
3502
+ input.value = subject;
3503
+ // Cursor at end so the user can keep refining the question
3504
+ // without first having to navigate past their own prefill.
3505
+ const end = input.value.length;
3506
+ try { input.setSelectionRange(end, end); } catch { /* ignore */ }
3507
+ }
3508
+ input.focus();
3113
3509
  }, 30);
3114
3510
  },
3115
3511
 
@@ -3912,6 +4308,176 @@
3912
4308
  if (el) el.remove();
3913
4309
  },
3914
4310
 
4311
+ /** Reload-confirm modal · shown when the user hits Ctrl+R /
4312
+ * Cmd+R while a director is actively speaking. Reuses the
4313
+ * `.pc-overlay / .pc-modal / .pc-choice` chrome so the
4314
+ * modal sits in the same visual register as the regular
4315
+ * pause-choice modal. Three options:
4316
+ * · hard · interrupt now and reload (server abort + reload)
4317
+ * · soft · wait for current speaker to finish, then reload
4318
+ * (sets `_pendingReloadOnPause`; the SSE handler
4319
+ * triggers `location.reload()` when room-paused
4320
+ * fires)
4321
+ * · cancel · close the modal; don't reload */
4322
+ openReloadChoiceModal() {
4323
+ this.closeReloadChoiceModal();
4324
+ this.closePauseChoiceModal(); // never stack the two
4325
+ const speaker = this.currentQueue[0]
4326
+ ? this.agentsById[this.currentQueue[0].agentId]
4327
+ : null;
4328
+ const speakerName = speaker ? speaker.name : this._t("sc_speaker_fallback");
4329
+ const html = `
4330
+ <div id="reload-choice-overlay" class="pc-overlay">
4331
+ <div class="pc-modal">
4332
+ <div class="pc-classification">
4333
+ <span><span class="dot">●</span> ${this.escape(this._t("reload_class"))}</span>
4334
+ <span class="right">${this.escape(this._t("reload_right"))}</span>
4335
+ </div>
4336
+ <div class="pc-head">
4337
+ <div class="pc-tag">${this.escape(this._t("reload_tag"))}</div>
4338
+ <h2 class="pc-title">${this.escape(this._t("reload_title", { name: speakerName }))}</h2>
4339
+ <p class="pc-deck">${this.escape(this._t("reload_deck"))}</p>
4340
+ </div>
4341
+ <div class="pc-body">
4342
+ <button type="button" class="pc-choice danger" data-reload-choice="hard">
4343
+ <div class="pc-choice-mark">${this.escape(this._t("reload_hard_mark"))}</div>
4344
+ <div class="pc-choice-deck">${this.escape(this._t("reload_hard_deck"))}</div>
4345
+ </button>
4346
+ <button type="button" class="pc-choice primary" data-reload-choice="soft">
4347
+ <div class="pc-choice-mark">${this.escape(this._t("reload_soft_mark"))}</div>
4348
+ <div class="pc-choice-deck">${this.escape(this._t("reload_soft_deck", { name: speakerName }))}</div>
4349
+ </button>
4350
+ <button type="button" class="pc-choice ghost" data-reload-choice="cancel">
4351
+ <div class="pc-choice-mark">${this.escape(this._t("reload_cancel_mark"))}</div>
4352
+ <div class="pc-choice-deck">${this.escape(this._t("reload_cancel_deck"))}</div>
4353
+ </button>
4354
+ </div>
4355
+ </div>
4356
+ </div>
4357
+ `;
4358
+ document.body.insertAdjacentHTML("beforeend", html);
4359
+ },
4360
+
4361
+ closeReloadChoiceModal() {
4362
+ const el = document.getElementById("reload-choice-overlay");
4363
+ if (el) el.remove();
4364
+ },
4365
+
4366
+ /** Apply the user's pick from the reload-confirm modal. */
4367
+ async handleReloadChoice(mode) {
4368
+ this.closeReloadChoiceModal();
4369
+ if (mode === "cancel") return;
4370
+ if (mode === "hard") {
4371
+ // Suppress the beforeunload re-prompt · this reload is
4372
+ // user-confirmed via our modal. Then abort the in-flight
4373
+ // turn server-side and reload. The server abort is
4374
+ // async; we kick it off but don't wait — closing the
4375
+ // SSE connection on reload also signals abort.
4376
+ this._suppressReloadConfirm = true;
4377
+ try { await this.pauseRoom("hard"); } catch (_) { /* server-side abort failure shouldn't block reload */ }
4378
+ location.reload();
4379
+ return;
4380
+ }
4381
+ if (mode === "soft") {
4382
+ // Set the pending-reload flag · the SSE `room-paused`
4383
+ // handler triggers `location.reload()` once the current
4384
+ // speaker's turn finishes and the room enters paused
4385
+ // state. Until then the modal closes and the user sees
4386
+ // the room continuing normally to its current speaker
4387
+ // end.
4388
+ this._pendingReloadOnPause = true;
4389
+ try {
4390
+ await this.pauseRoom("soft");
4391
+ } catch (e) {
4392
+ this._pendingReloadOnPause = false;
4393
+ alert("Pause failed: " + (e && e.message ? e.message : e));
4394
+ }
4395
+ }
4396
+ },
4397
+
4398
+ /** Resume-choice modal · shown when the user clicks Resume on
4399
+ * a voice room with NO voice key configured. Replaces the
4400
+ * prior hard-block (which dead-ended the user with only one
4401
+ * way out: configure the same provider's key). Three
4402
+ * options:
4403
+ * · configure · open Settings → API Keys; room stays paused
4404
+ * · text · PATCH deliveryMode=text, then resumeRoom()
4405
+ * · cancel · close modal; room stays paused
4406
+ *
4407
+ * Reuses the `.pc-overlay / .pc-modal / .pc-choice` chrome —
4408
+ * same visual register as pause-choice + reload-choice. */
4409
+ openResumeChoiceModal() {
4410
+ this.closeResumeChoiceModal();
4411
+ // Never stack with the other choice modals.
4412
+ this.closePauseChoiceModal();
4413
+ this.closeReloadChoiceModal();
4414
+ const html = `
4415
+ <div id="resume-choice-overlay" class="pc-overlay">
4416
+ <div class="pc-modal">
4417
+ <div class="pc-classification">
4418
+ <span><span class="dot">●</span> ${this.escape(this._t("resume_class"))}</span>
4419
+ <span class="right">${this.escape(this._t("resume_right"))}</span>
4420
+ </div>
4421
+ <div class="pc-head">
4422
+ <div class="pc-tag">${this.escape(this._t("resume_tag"))}</div>
4423
+ <p class="pc-deck">${this.escape(this._t("resume_deck"))}</p>
4424
+ </div>
4425
+ <div class="pc-body">
4426
+ <button type="button" class="pc-choice primary" data-resume-choice="configure">
4427
+ <div class="pc-choice-mark">${this.escape(this._t("resume_configure_mark"))}</div>
4428
+ <div class="pc-choice-deck">${this.escape(this._t("resume_configure_deck"))}</div>
4429
+ </button>
4430
+ <button type="button" class="pc-choice danger" data-resume-choice="text">
4431
+ <div class="pc-choice-mark">${this.escape(this._t("resume_text_mark"))}</div>
4432
+ <div class="pc-choice-deck">${this.escape(this._t("resume_text_deck"))}</div>
4433
+ </button>
4434
+ <button type="button" class="pc-choice ghost" data-resume-choice="cancel">
4435
+ <div class="pc-choice-mark">${this.escape(this._t("resume_cancel_mark"))}</div>
4436
+ <div class="pc-choice-deck">${this.escape(this._t("resume_cancel_deck"))}</div>
4437
+ </button>
4438
+ </div>
4439
+ </div>
4440
+ </div>
4441
+ `;
4442
+ document.body.insertAdjacentHTML("beforeend", html);
4443
+ },
4444
+
4445
+ closeResumeChoiceModal() {
4446
+ const el = document.getElementById("resume-choice-overlay");
4447
+ if (el) el.remove();
4448
+ },
4449
+
4450
+ /** Apply the user's pick from the resume-choice modal. */
4451
+ async handleResumeChoice(mode) {
4452
+ this.closeResumeChoiceModal();
4453
+ if (mode === "cancel") return;
4454
+ if (mode === "configure") {
4455
+ // Same redirect the legacy hard-block did · keep the
4456
+ // room paused, open Settings scoped to the voice
4457
+ // provider so the user lands on the right field.
4458
+ if (typeof window.openUserSettings === "function") {
4459
+ window.openUserSettings({ section: "keys", focusProvider: "minimax" });
4460
+ }
4461
+ return;
4462
+ }
4463
+ if (mode === "text") {
4464
+ // Flip the room to text mode, then resume. The PATCH
4465
+ // helper broadcasts `settings-changed` over SSE so
4466
+ // other tabs of the same room pick up the new mode.
4467
+ try {
4468
+ await this.updateRoomSettings({ deliveryMode: "text" });
4469
+ } catch (e) {
4470
+ alert(this._t("room_delivery_switch_err", { msg: e && e.message ? e.message : String(e) }));
4471
+ return;
4472
+ }
4473
+ try {
4474
+ await this.resumeRoom();
4475
+ } catch (e) {
4476
+ alert("Resume failed: " + (e && e.message ? e.message : e));
4477
+ }
4478
+ }
4479
+ },
4480
+
3915
4481
  /**
3916
4482
  * Patch room settings (tone, intensity, report style). Pushes to the
3917
4483
  * backend, then mirrors the result back into local state. The SSE
@@ -4068,6 +4634,12 @@
4068
4634
  this.continueCountdown.autoFiring = false;
4069
4635
  this.continueCountdown.interval = setInterval(() => this.tickContinueCountdown(), 1000);
4070
4636
  this.refreshContinueButton();
4637
+ // First audible beep fires immediately so the 10-tick cadence
4638
+ // (10, 9, 8 … 1) starts at the same instant the visible "10"
4639
+ // appears on the button — without this the first beep would
4640
+ // land 1s later at 9 and the user's mental "10!" cue would be
4641
+ // silent. Subsequent beeps come from tickContinueCountdown.
4642
+ try { window.boardroomTypingSfx?.countdownTick?.(total); } catch { /* ignore */ }
4071
4643
  },
4072
4644
 
4073
4645
  cancelContinueCountdown() {
@@ -4084,6 +4656,11 @@
4084
4656
  const left = Math.max(0, Math.ceil((this.continueCountdown.deadline - Date.now()) / 1000));
4085
4657
  this.continueCountdown.secondsLeft = left;
4086
4658
  this.refreshContinueButton();
4659
+ // Audible countdown · square-wave beep on each visible tick so
4660
+ // the user feels the timer urgency build up (last 3s flip to a
4661
+ // higher / louder "alarm" register inside countdownTick). User-
4662
+ // settings sound toggle controls all SFX uniformly.
4663
+ try { window.boardroomTypingSfx?.countdownTick?.(left); } catch { /* ignore */ }
4087
4664
  if (left <= 0 && !this.continueCountdown.autoFiring) {
4088
4665
  this.continueCountdown.autoFiring = true;
4089
4666
  clearInterval(this.continueCountdown.interval);
@@ -4235,6 +4812,23 @@
4235
4812
  // Pre-flight · the next round will fire a fresh director queue,
4236
4813
  // each turn requiring a model key.
4237
4814
  if (!(await this.requireModelKey())) return;
4815
+ // Paused-room handling · the chair's vote popover stays mounted
4816
+ // through pause (the user can park the room mid-vote to think),
4817
+ // so its Continue / Switch / Keep buttons must still work. The
4818
+ // /continue endpoint requires `status === "live"`, so resume
4819
+ // first when the room is paused — without this, the POST 409s
4820
+ // and benignRace silently swallows the error, leaving the user
4821
+ // clicking a dead button. The shift-accept flow funnels through
4822
+ // here too via acceptModeShiftAndContinue, so this single hop
4823
+ // covers both buttons.
4824
+ if (this.currentRoom && this.currentRoom.status === "paused") {
4825
+ try {
4826
+ await this.resumeRoom();
4827
+ } catch (err) {
4828
+ alert("Resume failed: " + (err && err.message ? err.message : err));
4829
+ return;
4830
+ }
4831
+ }
4238
4832
  const r = await fetch(
4239
4833
  "/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/continue",
4240
4834
  { method: "POST" },
@@ -5231,8 +5825,14 @@
5231
5825
  * reading order is analytics → brief → follow-ups. Idempotent:
5232
5826
  * re-renders by removing the prior card first. */
5233
5827
  renderSessionAnalytics() {
5234
- const existing = document.querySelector(".session-analytics");
5235
- if (existing) existing.remove();
5828
+ // Tear down the prior card + its sibling section-break marker
5829
+ // so this function stays idempotent across re-renders. The
5830
+ // marker is a `.round-open-card.is-adjourned` chip-on-lines
5831
+ // that visually separates the chat above from the analytics
5832
+ // tile below.
5833
+ document
5834
+ .querySelectorAll(".session-analytics, .session-end-marker")
5835
+ .forEach((el) => el.remove());
5236
5836
  const stats = this.computeSessionStats();
5237
5837
  if (!stats) return;
5238
5838
  const briefCard = document.querySelector("[data-brief-card]");
@@ -5386,6 +5986,20 @@
5386
5986
  </div>
5387
5987
  `;
5388
5988
 
5989
+ // Section break · reuses the `.round-open-card` chip-on-lines
5990
+ // pattern (same as "Round 01 · parallel") but tinted amber via
5991
+ // the `is-adjourned` variant. The marker is inserted right
5992
+ // above the analytics block so the post-adjourn reading order
5993
+ // is: chat … ──[SESSION ADJOURNED]── analytics → brief → follow-ups.
5994
+ const marker = document.createElement("div");
5995
+ marker.className = "round-open-card is-adjourned session-end-marker";
5996
+ marker.innerHTML = `
5997
+ <span class="ro-chip">
5998
+ <span class="ro-mark">⊘</span>
5999
+ <span class="ro-round">${this.escape(this._t("session_end_marker"))}</span>
6000
+ </span>
6001
+ `;
6002
+
5389
6003
  const block = document.createElement("div");
5390
6004
  block.className = "session-analytics";
5391
6005
  block.innerHTML = `
@@ -5423,23 +6037,18 @@
5423
6037
  </div>
5424
6038
  `;
5425
6039
 
5426
- // Insert ABOVE the brief card BUT BELOW the `▼ session output ▼`
5427
- // divider, so the ceremonial header still frames everything in
5428
- // the post-adjourn section. Layout inside [data-brief-card]:
5429
- //
5430
- // <header.ending-block-head> (the divider)
5431
- // analytics tile inserted here
5432
- // <div.brief-card> (the report)
5433
- // <footer.ending-block-foot>
5434
- //
5435
- // Falls back to inserting above the entire [data-brief-card]
5436
- // container when the divider isn't present (e.g. error state
5437
- // before renderBrief has populated the container).
5438
- const dividerHead = briefCard.querySelector(":scope > .ending-block-head");
6040
+ // Insert marker + analytics tile as siblings ABOVE the
6041
+ // [data-brief-card] container. The prior version peeked
6042
+ // inside the brief card for a `.ending-block-head` divider
6043
+ // to slot in between the divider and the brief, but that
6044
+ // ceremonial header was retired — the amber adjourned marker
6045
+ // alone is enough closing chrome.
5439
6046
  const briefInner = briefCard.querySelector(":scope > .brief-card");
5440
- if (dividerHead && briefInner) {
6047
+ if (briefInner) {
6048
+ briefCard.insertBefore(marker, briefInner);
5441
6049
  briefCard.insertBefore(block, briefInner);
5442
6050
  } else {
6051
+ briefCard.parentNode.insertBefore(marker, briefCard);
5443
6052
  briefCard.parentNode.insertBefore(block, briefCard);
5444
6053
  }
5445
6054
 
@@ -5459,43 +6068,169 @@
5459
6068
  }
5460
6069
  },
5461
6070
 
6071
+ /** Compatibility alias · existing call sites (room-paused
6072
+ * SSE handler, openRoom path, etc.) still call
6073
+ * `renderPausedBar`. After the paused-bar removal the
6074
+ * function just delegates to `renderInputBarControls` so
6075
+ * the room's left-cluster (Pause vs Resume + Adjourn)
6076
+ * reflects the new status. */
5462
6077
  renderPausedBar() {
5463
- const bar = document.querySelector(".paused-bar");
5464
- if (!bar || !this.currentRoom) return;
5465
- // Next director that would speak when discussion resumes · the
5466
- // only other piece of info still useful here. The "last input"
5467
- // line was dropped as redundant — the user's last message is
5468
- // visible right above the bar.
5469
- const nextSpeaker = this.currentQueue[0]
5470
- ? this.agentsById[this.currentQueue[0].agentId]
5471
- : this.currentMembers[0];
5472
- const nextHandle = nextSpeaker
5473
- ? this.escape(nextSpeaker.handle.replace(/^\//, ""))
5474
- : "";
6078
+ this.renderInputBarControls();
6079
+ },
6080
+
6081
+ /** Swap the input-bar's left-cluster buttons based on the
6082
+ * current room status. Live [Pause][VoiceMode][Adjourn]
6083
+ * [Vote-if-manual]; Paused [VoiceMode][Adjourn]; Adjourned
6084
+ * → [VoiceMode][Export][Replay][Followup]. The Resume
6085
+ * affordance lives in the `.paused-strip` above the textarea
6086
+ * (a quieter info-bar treatment). The head-rt-toggle
6087
+ * (voice-mode + stage/transcript) is included in every
6088
+ * branch and re-populated by `applyRoundTableVisibility`
6089
+ * immediately afterwards.
6090
+ *
6091
+ * Called from openRoom (initial mount) and from the SSE
6092
+ * `room-paused` / `room-resumed` handlers so the cluster
6093
+ * swap follows the server-side state. */
6094
+ renderInputBarControls() {
6095
+ if (!this.currentRoom) return;
6096
+ const leftCluster = document.querySelector(".input-bar .ib-controls-left");
6097
+ if (!leftCluster) return;
6098
+ const status = this.currentRoom.status;
6099
+ const rtToggleHtml = `<button type="button" class="head-rt-toggle" data-room-rt-toggle aria-pressed="false" hidden></button>`;
6100
+ // Divergence-report button · same shape across all room
6101
+ // states. Sits in the left cluster so it's reachable
6102
+ // alongside pause / adjourn / replay. Click opens the
6103
+ // overlay; the overlay shows an empty-state hint when the
6104
+ // room has no scored turns yet. `data-tip` drives the fast
6105
+ // hover tooltip via the existing `.ib-action::after` CSS.
6106
+ const dvBtnHtml = `<button type="button" class="ib-action ib-divergence" data-divergence-open title="Coverage check" aria-label="Coverage check" data-tip="See how widely the room has explored your question"></button>`;
6107
+ // Jump-to-opener · same shape across all room states, hidden in
6108
+ // voice-mode stage view via CSS. Click handler is doc-level
6109
+ // delegation on [data-jump-to-opener]. i18n attrs get rewritten
6110
+ // by I18n.applyDom further down.
6111
+ const jtBtnHtml = `<button type="button" class="ib-action ib-jump-top" data-jump-to-opener data-i18n-title="ib_jump_top_tip" data-i18n-aria="ib_jump_top_label" data-i18n-tip="ib_jump_top_tip"></button>`;
6112
+ if (status === "paused") {
6113
+ leftCluster.innerHTML = `
6114
+ ${rtToggleHtml}
6115
+ <button type="button" class="ib-action ib-adjourn has-label" data-adjourn data-i18n-title="ib_adjourn_tip" data-i18n-aria="ib_adjourn_label"><span class="ib-action-label" data-i18n="ib_adjourn_text">Adjourn</span></button>
6116
+ ${dvBtnHtml}
6117
+ ${jtBtnHtml}
6118
+ `;
6119
+ } else if (status === "adjourned") {
6120
+ // Adjourned state · the bottom bar becomes a follow-up
6121
+ // entry point. Send routes the typed text through
6122
+ // openFollowUpOverlay({ subject }) (see submitFromComposer).
6123
+ // Export / Replay / Followup icons reuse the same data
6124
+ // attributes the old `.adjourned-bar` exposed, so the
6125
+ // existing doc-level click handlers fire unchanged.
6126
+ leftCluster.innerHTML = `
6127
+ ${rtToggleHtml}
6128
+ <button type="button" class="ib-action ib-export" data-room-export data-i18n-title="adj_export_tip" data-i18n-aria="adj_export_label" data-i18n-tip="adj_export_tip"></button>
6129
+ <button type="button" class="ib-action ib-replay" data-room-replay data-i18n-title="adj_replay_tip" data-i18n-aria="adj_replay_label" data-i18n-tip="adj_replay_tip"></button>
6130
+ <button type="button" class="ib-action ib-followup" data-room-followup data-i18n-title="adj_followup_tip" data-i18n-aria="adj_followup_label" data-i18n-tip="adj_followup_tip"></button>
6131
+ ${dvBtnHtml}
6132
+ ${jtBtnHtml}
6133
+ `;
6134
+ } else {
6135
+ // Live state · the standard action trio. Markup
6136
+ // mirrors the static template in index.html so a
6137
+ // round-trip through the SSE event keeps the controls
6138
+ // identical to first-paint.
6139
+ leftCluster.innerHTML = `
6140
+ <button type="button" class="ib-action ib-pause" data-pause data-i18n-title="ib_pause_tip" data-i18n-aria="ib_pause_label" data-i18n-tip="ib_pause_tip"></button>
6141
+ ${rtToggleHtml}
6142
+ <button type="button" class="ib-action ib-adjourn has-label" data-adjourn data-i18n-title="ib_adjourn_tip" data-i18n-aria="ib_adjourn_label"><span class="ib-action-label" data-i18n="ib_adjourn_text">Adjourn</span></button>
6143
+ <button type="button" class="ib-action ib-vote" data-room-end-manual hidden data-i18n-title="ib_vote_tip" data-i18n-aria="ib_vote_label" data-i18n-tip="ib_vote_tip"></button>
6144
+ ${dvBtnHtml}
6145
+ ${jtBtnHtml}
6146
+ `;
6147
+ }
6148
+ // applyDom rewrites the i18n attrs we just inserted, otherwise
6149
+ // the title / aria-label stay as literal "ib_pause_tip" string.
6150
+ if (window.I18n && typeof window.I18n.applyDom === "function") {
6151
+ try { window.I18n.applyDom(leftCluster); } catch { /* ignore */ }
6152
+ }
6153
+ // refreshManualVoteButton owns the ib-vote button's hidden
6154
+ // state · re-run after the live-state markup mounts so the
6155
+ // [hidden] attribute reflects current room.voteTrigger /
6156
+ // queue state.
6157
+ if (typeof this.refreshManualVoteButton === "function") {
6158
+ try { this.refreshManualVoteButton(); } catch { /* ignore */ }
6159
+ }
6160
+ // Re-populate the round-table toggle · innerHTML rewrite above
6161
+ // wiped its inner glyph + label + hover preview. Re-running
6162
+ // applyRoundTableVisibility re-fills the freshly-mounted button
6163
+ // and re-binds its hover handlers. Idempotent · safe even if
6164
+ // the toggle ends up [hidden] (text room with no eligibility).
6165
+ if (this.currentRoomId && typeof this.applyRoundTableVisibility === "function") {
6166
+ try { this.applyRoundTableVisibility(this.currentRoomId); } catch { /* ignore */ }
6167
+ }
6168
+ // Swap the textarea's placeholder to match the active
6169
+ // state. Paused → "this becomes a supplement"; adjourned →
6170
+ // "this opens a follow-up room"; live → the regular
6171
+ // interject prompt.
6172
+ const ta = document.querySelector(".ib-textarea[data-send-input]");
6173
+ if (ta) {
6174
+ const placeholderKey =
6175
+ status === "paused" ? "ib_paused_placeholder" :
6176
+ status === "adjourned" ? "ib_followup_placeholder" :
6177
+ "send_placeholder";
6178
+ ta.setAttribute("placeholder", this._t(placeholderKey));
6179
+ }
6180
+ // Populate the adjourned-strip's meta slot with the relative
6181
+ // time since adjourn (e.g., "filed 2h"). Empty when the room
6182
+ // isn't adjourned or the timestamp is missing — the CSS
6183
+ // `:empty` selector hides the span so the strip head doesn't
6184
+ // render a bare separator.
6185
+ const adjMeta = document.querySelector("[data-adjourned-strip-meta]");
6186
+ if (adjMeta) {
6187
+ if (status === "adjourned" && this.currentRoom.adjournedAt) {
6188
+ adjMeta.textContent = this._t("adjourned_strip_meta", {
6189
+ time: this.relTime(this.currentRoom.adjournedAt),
6190
+ });
6191
+ } else {
6192
+ adjMeta.textContent = "";
6193
+ }
6194
+ }
6195
+ },
5475
6196
 
5476
- const addInputLabel = this._t("pause_bar_add_input");
5477
- const adjournLabel = this._t("pause_bar_adjourn");
5478
- const resumeLabel = this._t("pause_bar_resume");
5479
- const pausedLabel = this._t("pause_bar_paused");
5480
- const nextLabel = this._t("pause_bar_next");
5481
- const nextChunk = nextHandle
5482
- ? ` · ${nextLabel} <span class="lime">${nextHandle}</span>`
5483
- : "";
5484
- bar.innerHTML = `
5485
- <button type="button" class="head-rt-toggle" data-room-rt-toggle aria-pressed="false" hidden></button>
5486
- <div class="paused-bar-text">
5487
- <strong>// ${pausedLabel}</strong>${nextChunk}
5488
- </div>
5489
- <div class="paused-bar-actions">
5490
- <a href="#" class="ghost-btn" data-paused-supplement>${this.escape(addInputLabel)}</a>
5491
- <a href="#" class="ghost-btn" data-adjourn>${this.escape(adjournLabel)}</a>
5492
- <a href="#" class="resume-btn-lg" data-resume>${this.escape(resumeLabel)}</a>
5493
- </div>
5494
- `;
5495
- // The innerHTML write just blew away our static toggle button ·
5496
- // re-paint it so the user keeps the voice/transcript switch
5497
- // available while paused. Idempotent · no-ops in non-voice rooms.
5498
- this.applyRoundTableVisibility(this.currentRoomId);
6197
+ /** Observe the floating .ib-stack and write its measured height
6198
+ * to every visible .roundtable-stage as the CSS variable
6199
+ * `--rt-bottom-reserve`. The stage's inner wrapper shrinks by
6200
+ * that amount, so seats / table / subtitle / HUD all lift
6201
+ * exactly above the input group — covering speaking-queue
6202
+ * expand / collapse, textarea growth, paused-strip toggles.
6203
+ * When the stack is in `display: contents` mode (chat view),
6204
+ * offsetHeight is 0 and we clear the variable; the stage's
6205
+ * CSS fallback of 0 takes over.
6206
+ *
6207
+ * MutationObserver on each stage's [hidden] attr fires the
6208
+ * re-apply when the stage first becomes visible (the size
6209
+ * observer doesn't always fire for display:contents → block
6210
+ * transitions). */
6211
+ _setupIbStackReserve() {
6212
+ const ibStack = document.querySelector(".ib-stack");
6213
+ if (!ibStack || typeof ResizeObserver !== "function") return;
6214
+ const apply = () => {
6215
+ const h = ibStack.offsetHeight;
6216
+ const stages = document.querySelectorAll(".roundtable-stage");
6217
+ stages.forEach((stage) => {
6218
+ if (h > 0 && !stage.hasAttribute("hidden")) {
6219
+ stage.style.setProperty("--rt-bottom-reserve", `${h}px`);
6220
+ } else {
6221
+ stage.style.removeProperty("--rt-bottom-reserve");
6222
+ }
6223
+ });
6224
+ };
6225
+ const ro = new ResizeObserver(apply);
6226
+ ro.observe(ibStack);
6227
+ const mo = new MutationObserver(apply);
6228
+ document.querySelectorAll(".roundtable-stage").forEach((s) => {
6229
+ mo.observe(s, { attributes: true, attributeFilter: ["hidden"] });
6230
+ });
6231
+ apply();
6232
+ this._ibStackRO = ro;
6233
+ this._ibStackMO = mo;
5499
6234
  },
5500
6235
 
5501
6236
  /** Composer state · persisted to localStorage so each new-room
@@ -5517,10 +6252,19 @@
5517
6252
  const raw = localStorage.getItem("boardroom.composer");
5518
6253
  if (raw) saved = JSON.parse(raw);
5519
6254
  } catch { /* ignore */ }
6255
+ // seedContext · pre-fetched web snippets the user attached
6256
+ // by clicking a topic-rec card on the home composer. Round-
6257
+ // tripped through localStorage so a page refresh between
6258
+ // pick and convene doesn't lose the attached source
6259
+ // material. Shape: { topicRecId?: string, snippets?: [] }.
6260
+ const savedSeed = saved && typeof saved.seedContext === "object" && saved.seedContext
6261
+ ? saved.seedContext
6262
+ : null;
5520
6263
  this.composerState = {
5521
6264
  ...this.DEFAULT_COMPOSER,
5522
6265
  ...(saved || {}),
5523
6266
  subject: (saved && typeof saved.subject === "string") ? saved.subject : "",
6267
+ seedContext: savedSeed,
5524
6268
  };
5525
6269
  return this.composerState;
5526
6270
  },
@@ -5528,10 +6272,10 @@
5528
6272
  saveComposerState() {
5529
6273
  if (!this.composerState) return;
5530
6274
  try {
5531
- const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject } = this.composerState;
6275
+ const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject, seedContext } = this.composerState;
5532
6276
  localStorage.setItem(
5533
6277
  "boardroom.composer",
5534
- JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject }),
6278
+ JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject, seedContext: seedContext || null }),
5535
6279
  );
5536
6280
  } catch { /* ignore */ }
5537
6281
  },
@@ -5574,6 +6318,15 @@
5574
6318
  // overlay — typing in this view + Enter creates the room.
5575
6319
  const head = document.querySelector("[data-room-head]");
5576
6320
  if (head) head.innerHTML = ""; // CSS hides via html.no-room
6321
+ // One-shot lazy fetch · the composer's "or try a starter"
6322
+ // tray renders the latest topic recommendations when any
6323
+ // exist (replacing the legacy hardcoded starters). Skipping
6324
+ // when already loaded keeps re-renders cheap; the trigger
6325
+ // button's SSE path explicitly refreshes after a successful
6326
+ // generation so the list lands without polling.
6327
+ if (!this.topicRecs.loaded) {
6328
+ void this.refreshTopicRecs();
6329
+ }
5577
6330
 
5578
6331
  // Strip stale follow-up fragments inserted by renderFollowUp-
5579
6332
  // Fragments() when a follow-up room was previously open. These
@@ -5590,7 +6343,7 @@
5590
6343
  // this sweep, the previous room's analytics tile (totals /
5591
6344
  // models / upvoted points) bleeds through into the new-room
5592
6345
  // composer.
5593
- document.querySelectorAll(".followup-parent-banner, .followup-children, .session-analytics").forEach((el) => el.remove());
6346
+ document.querySelectorAll(".followup-parent-banner, .followup-children, .session-analytics, .session-end-marker").forEach((el) => el.remove());
5594
6347
 
5595
6348
  const chat = document.querySelector("[data-chat-messages]");
5596
6349
  if (chat) {
@@ -5664,12 +6417,19 @@
5664
6417
  },
5665
6418
 
5666
6419
  /** Toggle `.chat--composer-overflow` on the chat scroller based on
5667
- * whether the composer's natural height exceeds the visible chat
5668
- * area. In overflow mode the hero (`.cmp-fold`) anchors at the
5669
- * viewport centre and `.cmp-starters` flow below the fold;
5670
- * without overflow, the parent's `align-content: center` keeps
5671
- * the whole composer block centred. Called after composer render
5672
- * and on window resize. */
6420
+ * whether the composer's natural height exceeds 70% of the
6421
+ * visible chat area. In overflow mode the hero pins to a fixed
6422
+ * 120px top offset (`.cmp` `padding-top: 120px`) and the
6423
+ * starters tray flows below; without overflow, the parent's
6424
+ * `align-content: center` keeps the whole composer block
6425
+ * centred. Called after composer render and on window resize.
6426
+ *
6427
+ * The 0.7 threshold (was "strictly > viewport") catches the
6428
+ * in-between case where the input + ~6 topic-rec cards fit
6429
+ * vertically but only just — centred layout would visually
6430
+ * cram the hero against the top edge. Flipping to overflow
6431
+ * mode at 70% gives the hero comfortable breathing room
6432
+ * before any actual scroll is needed. */
5673
6433
  updateComposerOverflow() {
5674
6434
  const chat = document.querySelector(".chat.chat--composer");
5675
6435
  if (!chat) return;
@@ -5677,7 +6437,21 @@
5677
6437
  if (!cmp) return;
5678
6438
  requestAnimationFrame(() => {
5679
6439
  chat.classList.remove("chat--composer-overflow");
5680
- const overflows = cmp.scrollHeight > chat.clientHeight + 1;
6440
+ // Threshold · the default 0.7 * chat.clientHeight is tuned
6441
+ // for the new-room / new-agent hero (input + ~6 starter cards).
6442
+ // The persona-builder dashboard is a different beast: by
6443
+ // design it's tall (header + dossier + intel feed + abort
6444
+ // foot). For pb-stage, switch to a viewport-based threshold
6445
+ // (0.9 * window.innerHeight) per user spec — only flip to
6446
+ // overflow mode when the dashboard exceeds 90% of the
6447
+ // visible window, so the natural `align-content: center`
6448
+ // path handles the common case and we only pay the
6449
+ // overflow-padding cost when there genuinely isn't room.
6450
+ const hasPbStage = !!cmp.querySelector(".pb-stage");
6451
+ const threshold = hasPbStage
6452
+ ? (window.innerHeight || chat.clientHeight) * 0.9
6453
+ : chat.clientHeight * 0.7;
6454
+ const overflows = cmp.scrollHeight > threshold;
5681
6455
  chat.classList.toggle("chat--composer-overflow", overflows);
5682
6456
  });
5683
6457
  if (!this._composerResizeAttached) {
@@ -6951,40 +7725,538 @@
6951
7725
  const author = n.authorName || this._t("notes_director_fallback");
6952
7726
  // The jump link uses the room's hash route + the note id as a
6953
7727
  // fragment-style query so openRoom can scroll to + flash the
6954
- // matching span. The delete button sits as a SIBLING of the
6955
- // anchor (inside the .notes-item) so its click never bubbles
7728
+ // matching span. The action buttons sit as siblings of the
7729
+ // anchor (inside the .notes-item) so their clicks never bubble
6956
7730
  // through the navigation link — same pattern as session-row
6957
7731
  // delete in the rooms sidebar.
6958
7732
  const href = `#/r/${this.escape(n.roomId)}?note=${this.escape(n.id)}`;
6959
- const deleteTitle = "Delete this note";
6960
7733
  return `
6961
- <li class="notes-item" data-note-id="${this.escape(n.id)}">
6962
- <a class="notes-item-link" href="${href}" data-note-jump="${this.escape(n.id)}" data-note-room="${this.escape(n.roomId)}">
6963
- <div class="notes-item-meta">
6964
- <span class="notes-item-room">${this.escape(this._t("notes_item_room"))} ${this.escape(roomNum)}</span>
6965
- ${roomSubject ? `<span class="notes-item-sep">·</span><span class="notes-item-subject">${this.escape(roomSubject)}</span>` : ""}
6966
- <span class="notes-item-sep">·</span>
6967
- <span class="notes-item-director">${this.escape(author)}</span>
6968
- <span class="notes-item-time">${this.escape(time)}</span>
7734
+ <li class="notes-item" data-note-id="${this.escape(n.id)}">
7735
+ <a class="notes-item-link" href="${href}" data-note-jump="${this.escape(n.id)}" data-note-room="${this.escape(n.roomId)}">
7736
+ <div class="notes-item-meta">
7737
+ <span class="notes-item-room">${this.escape(this._t("notes_item_room"))} ${this.escape(roomNum)}</span>
7738
+ ${roomSubject ? `<span class="notes-item-sep">·</span><span class="notes-item-subject">${this.escape(roomSubject)}</span>` : ""}
7739
+ <span class="notes-item-sep">·</span>
7740
+ <span class="notes-item-director">${this.escape(author)}</span>
7741
+ <span class="notes-item-time">${this.escape(time)}</span>
7742
+ </div>
7743
+ <p class="notes-item-passage">${
7744
+ n.contextBefore ? `<span class="note-context note-context-before">${this.escape(n.contextBefore)}</span>` : ""
7745
+ }<span class="note-quote">${this.escape(n.quoteText)}</span>${
7746
+ n.contextAfter ? `<span class="note-context note-context-after">${this.escape(n.contextAfter)}</span>` : ""
7747
+ }</p>
7748
+ </a>
7749
+ <div class="notes-item-actions">
7750
+ <button type="button" class="notes-item-action notes-item-share" data-note-share aria-label="Share this note">
7751
+ <span class="notes-action-glyph" aria-hidden="true">↗</span>
7752
+ <span class="notes-action-label">Share</span>
7753
+ </button>
7754
+ <button type="button" class="notes-item-action notes-item-unfav" data-note-delete aria-label="Remove this note from your saved list">
7755
+ <span class="notes-action-glyph" aria-hidden="true">✕</span>
7756
+ <span class="notes-action-label">Unfavorite</span>
7757
+ </button>
7758
+ </div>
7759
+ </li>
7760
+ `;
7761
+ },
7762
+
7763
+ /** Share-card overlay · mounts a modal with multiple card
7764
+ * templates rendering the selected note as a shareable image.
7765
+ * Templates live in CSS (data-share-template attribute swaps
7766
+ * the visual register without re-rendering markup). PNG export
7767
+ * reuses the html-to-image CDN loader pattern from
7768
+ * `public/magazine.html` · lazy-loaded on first download so
7769
+ * the All Notes page doesn't pay the network cost upfront.
7770
+ *
7771
+ * Each template is fixed at 540×675 (4:5 portrait) — a sweet
7772
+ * spot for IG / Weibo / WeChat moments / Twitter portrait. PNG
7773
+ * export captures at pixelRatio = 800/540 so the saved file
7774
+ * comes out at exactly 800 × 1185 (moderate-size PNG, easy to
7775
+ * share without being unwieldy). */
7776
+ SHARE_CARD_TEMPLATES: ["boardroom", "terminal"],
7777
+ DEFAULT_SHARE_CARD_TEMPLATE: "boardroom",
7778
+
7779
+ /** Open the share-card overlay for a given note id. Resolves the
7780
+ * note from the in-memory cache (`_notesCache`) so no network
7781
+ * round-trip is needed; the cache is populated by the All Notes
7782
+ * fetch and stays fresh through SSE note:created events. */
7783
+ openShareCard(noteId) {
7784
+ const note = (this._notesCache || []).find((n) => n && n.id === noteId);
7785
+ if (!note) {
7786
+ alert("Couldn't find that note — try reloading the page.");
7787
+ return;
7788
+ }
7789
+ // Tear down any prior overlay first · prevents stacking when
7790
+ // the user click-spams Share across different notes.
7791
+ this.closeShareCard();
7792
+ this._shareCardNote = note;
7793
+ this._shareCardTemplate = this.DEFAULT_SHARE_CARD_TEMPLATE;
7794
+ // Roll a fresh randomized boardroom cover (sky palette, river
7795
+ // curve, stones, flowers, dirt, ground material). Cached for
7796
+ // the lifetime of this modal so chip-switching between
7797
+ // templates and back to "boardroom" keeps the SAME variation
7798
+ // visible — only the next openShareCard() rerolls. Fallback
7799
+ // to null if the module failed to load (CSS gradient + empty
7800
+ // SVG keep the card paintable).
7801
+ this._shareCardCover = (typeof window !== "undefined"
7802
+ && window.shareCoverSvgCreator
7803
+ && typeof window.shareCoverSvgCreator.generate === "function")
7804
+ ? window.shareCoverSvgCreator.generate()
7805
+ : null;
7806
+
7807
+ // Share-card overlay reuses the room-settings overlay chrome
7808
+ // (`.room-settings-overlay` + `.room-settings-modal` + lime
7809
+ // corner brackets + `.rs-classification` strip + `.rs-head`
7810
+ // header + `.rs-body` scroll area + `.rs-foot` footer) so the
7811
+ // app's overlays share a single visual register. Inner content
7812
+ // (template chips + preview + download button) is local to
7813
+ // this feature.
7814
+ const roomNum = note.roomNumber != null
7815
+ ? `#${String(note.roomNumber).padStart(3, "0")}` : "—";
7816
+ const author = note.authorName || "Director";
7817
+ const overlay = document.createElement("div");
7818
+ overlay.className = "room-settings-overlay open";
7819
+ overlay.id = "share-card-overlay";
7820
+ overlay.setAttribute("role", "dialog");
7821
+ overlay.setAttribute("aria-modal", "true");
7822
+ overlay.setAttribute("aria-label", "Share this note");
7823
+ overlay.innerHTML = `
7824
+ <div class="room-settings-modal" role="document">
7825
+ <div class="rs-classification">
7826
+ <span><span class="dot">●</span> share · note</span>
7827
+ <span class="right">// privateboard.ai</span>
7828
+ </div>
7829
+ <header class="rs-head">
7830
+ <div class="rs-head-text">
7831
+ <div class="meta">// from room ${this.escape(roomNum)} · ${this.escape(author)}</div>
7832
+ <div class="rs-title-wrap">
7833
+ <div class="title rs-title">Share this note as a card</div>
7834
+ </div>
7835
+ </div>
7836
+ <button type="button" class="close-btn" data-share-card-close aria-label="Close">✕</button>
7837
+ </header>
7838
+ <div class="rs-body">
7839
+ <div class="share-card-templates" role="tablist" aria-label="Card template">
7840
+ ${this.SHARE_CARD_TEMPLATES.map((t) => `
7841
+ <button type="button"
7842
+ class="share-card-template-chip${t === this._shareCardTemplate ? " active" : ""}"
7843
+ data-share-card-template="${t}"
7844
+ role="tab"
7845
+ aria-selected="${t === this._shareCardTemplate ? "true" : "false"}"
7846
+ >${this.escape(t)}</button>
7847
+ `).join("")}
7848
+ </div>
7849
+ <div class="share-card-preview" data-share-card-preview>
7850
+ <div class="share-card-preview-inner" data-share-card-preview-inner>
7851
+ ${this.renderShareCardHtml(note, this._shareCardTemplate)}
7852
+ </div>
7853
+ </div>
7854
+ </div>
7855
+ <footer class="rs-foot">
7856
+ <span class="share-card-hint">// 800 × 1185 png</span>
7857
+ <button type="button" class="rs-action dirty" data-share-card-download>
7858
+ ↓ Download PNG
7859
+ </button>
7860
+ </footer>
7861
+ </div>
7862
+ `;
7863
+ document.body.appendChild(overlay);
7864
+
7865
+ // Fit the 540×800 preview into whatever container the viewport
7866
+ // gives us. CSS caps both width (max-width: 540) AND height
7867
+ // (max-height: calc(100vh - 260px)) so on short viewports the
7868
+ // preview shrinks proportionally — scaling must read whichever
7869
+ // dimension landed smaller so the inner 540×800 native render
7870
+ // fits cleanly without any scrollbars on the modal body.
7871
+ const fitPreview = () => {
7872
+ const preview = overlay.querySelector("[data-share-card-preview]");
7873
+ const inner = overlay.querySelector("[data-share-card-preview-inner]");
7874
+ if (!preview || !inner) return;
7875
+ const rect = preview.getBoundingClientRect();
7876
+ const w = rect.width || 540;
7877
+ const h = rect.height || 800;
7878
+ const scale = Math.min(1, w / 540, h / 800);
7879
+ inner.style.setProperty("--share-card-scale", scale.toFixed(4));
7880
+ };
7881
+ fitPreview();
7882
+ this._shareCardResize = fitPreview;
7883
+ window.addEventListener("resize", this._shareCardResize);
7884
+
7885
+ // Esc closes · same shortcut family as other overlays.
7886
+ this._shareCardEsc = (ev) => {
7887
+ if (ev.key === "Escape") {
7888
+ ev.preventDefault();
7889
+ this.closeShareCard();
7890
+ }
7891
+ };
7892
+ document.addEventListener("keydown", this._shareCardEsc, true);
7893
+
7894
+ // Pre-warm html-to-image so the first Download click doesn't
7895
+ // pay the ~50KB CDN fetch latency. Best-effort; if the network
7896
+ // is slow the download path awaits its own ensure() anyway.
7897
+ this.ensureShareCardHtmlToImage().catch(() => { /* swallow */ });
7898
+ },
7899
+
7900
+ closeShareCard() {
7901
+ const el = document.getElementById("share-card-overlay");
7902
+ if (el) el.remove();
7903
+ if (this._shareCardEsc) {
7904
+ document.removeEventListener("keydown", this._shareCardEsc, true);
7905
+ this._shareCardEsc = null;
7906
+ }
7907
+ if (this._shareCardResize) {
7908
+ window.removeEventListener("resize", this._shareCardResize);
7909
+ this._shareCardResize = null;
7910
+ }
7911
+ this._shareCardNote = null;
7912
+ this._shareCardTemplate = null;
7913
+ },
7914
+
7915
+ /** Swap the active template · re-renders only the preview's
7916
+ * inner block (keeps the chip row + modal chrome stable). The
7917
+ * data-share-template attribute on `.share-card` is what every
7918
+ * template's CSS keys off, so the visual change is purely a
7919
+ * class swap — markup is identical across templates. */
7920
+ setShareCardTemplate(key) {
7921
+ if (!this.SHARE_CARD_TEMPLATES.includes(key)) return;
7922
+ this._shareCardTemplate = key;
7923
+ const overlay = document.getElementById("share-card-overlay");
7924
+ if (!overlay) return;
7925
+ overlay.querySelectorAll("[data-share-card-template]").forEach((btn) => {
7926
+ const active = btn.getAttribute("data-share-card-template") === key;
7927
+ btn.classList.toggle("active", active);
7928
+ btn.setAttribute("aria-selected", active ? "true" : "false");
7929
+ });
7930
+ const inner = overlay.querySelector("[data-share-card-preview-inner]");
7931
+ if (inner && this._shareCardNote) {
7932
+ inner.innerHTML = this.renderShareCardHtml(this._shareCardNote, key);
7933
+ }
7934
+ },
7935
+
7936
+ /** Length-based font-size step-down for share-card quotes.
7937
+ * Quotes never get truncated — instead the font shrinks so the
7938
+ * full passage fits. Returns the font-size in px for a given
7939
+ * template at the supplied character count. Tiers were tuned
7940
+ * empirically against each template's content area: the
7941
+ * boardroom bubble is the smallest box (≈424 × 200); the
7942
+ * terminal card's tinted code-block has roughly the same
7943
+ * width but runs slightly shorter to leave room for the
7944
+ * ASCII logo / status bar / cursor lines above and below.
7945
+ *
7946
+ * Length tiers are deliberately coarse — 5-6 buckets keep the
7947
+ * output predictable and avoid the "text snaps a pixel smaller
7948
+ * every keystroke" feel a continuous formula would produce. */
7949
+ shareCardQuoteSize(template, len) {
7950
+ const TIERS = {
7951
+ boardroom: [
7952
+ { max: 40, size: 27 },
7953
+ { max: 70, size: 25 },
7954
+ { max: 100, size: 23 },
7955
+ { max: 140, size: 21 },
7956
+ { max: 170, size: 20 },
7957
+ { max: 200, size: 19 },
7958
+ ],
7959
+ };
7960
+ // Floor: never render the boardroom quote below 19 px. Below
7961
+ // that the 8-bit bubble starts to look cramped against the
7962
+ // surrounding pixel-art; long quotes get line-clamped by the
7963
+ // bubble's max-height instead.
7964
+ const MIN_SIZE = 19;
7965
+ const tiers = TIERS[template] || TIERS.boardroom;
7966
+ for (const t of tiers) {
7967
+ if (len <= t.max) return Math.max(MIN_SIZE, t.size);
7968
+ }
7969
+ // > 200 chars · keep the floor rather than shrinking further.
7970
+ return MIN_SIZE;
7971
+ },
7972
+
7973
+ /** Wrap CJK runs in `<span class="cjk">…</span>` so per-script
7974
+ * font + weight rules apply: CJK switches to PingFang at bold
7975
+ * weight (no italic-skew), Latin keeps the surrounding serif
7976
+ * italic. Always called AFTER `escape()` so the regex only
7977
+ * matches CJK glyphs and never sees raw `<` / `>` / `&`. */
7978
+ wrapCjk(text) {
7979
+ if (!text) return "";
7980
+ return String(text).replace(
7981
+ /([ -〿぀-ゟ゠-ヿ㐀-䶿一-鿿豈-﫿＀-￯]+)/g,
7982
+ '<span class="cjk">$1</span>',
7983
+ );
7984
+ },
7985
+
7986
+ /** Render the 540×800 card HTML for a given note + template key.
7987
+ * All four templates share the SAME inner data slots — kicker,
7988
+ * quote, byline, watermark, stamp — so this single function
7989
+ * works for all of them; CSS handles the visual divergence. */
7990
+ renderShareCardHtml(n, templateKey) {
7991
+ const tpl = templateKey || this.DEFAULT_SHARE_CARD_TEMPLATE;
7992
+ const quote = String(n.quoteText || "").trim();
7993
+ const author = (n.authorName || "Director").trim();
7994
+ const roomNum = n.roomNumber != null ? `#${String(n.roomNumber).padStart(3, "0")}` : "";
7995
+ const stampDate = n.createdAt
7996
+ ? new Date(n.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })
7997
+ : "";
7998
+ const esc = (s) => this.escape(String(s || ""));
7999
+
8000
+ // Template-specific markup · "boardroom" renders the
8001
+ // 8-bit pixel-art scene, "terminal" renders a CLI / code
8002
+ // card with an ASCII PRIVATE BOARD logo. Watermark on
8003
+ // every template is the domain `Privateboard.ai`
8004
+ // (lowercased / capitalised by the template's
8005
+ // text-transform).
8006
+ const DOMAIN = "Privateboard.ai";
8007
+ // ISO-style date for the terminal status bar — looks more
8008
+ // like real CLI output than a localised "May 12, 2026".
8009
+ const isoDate = n.createdAt
8010
+ ? new Date(n.createdAt).toISOString().slice(0, 10)
8011
+ : "";
8012
+
8013
+ // Block-letter "PRIVATE BOARD" ASCII title — three typeface
8014
+ // variants. All use the FULL BLOCK char (█) so the texture
8015
+ // stays consistent; only the letter PROPORTIONS change per
8016
+ // roll. PRIVATE sits on the top half, BOARD centred on the
8017
+ // bottom half, separated by a blank line.
8018
+ // · block — 4-col-wide letters, total 34 cols (default)
8019
+ // · slim — 3-col-wide letters, total 27 cols (sleek)
8020
+ // · thick — 5-col-wide letters, total 41 cols (chunky)
8021
+ const LOGO_FONTS = {
8022
+ block: [
8023
+ "████ ████ ████ █ █ ██ ████ ████",
8024
+ "█ █ █ █ ██ █ █ █ █ ██ █ ",
8025
+ "████ ████ ██ █ █ ████ ██ ███ ",
8026
+ "█ █ █ ██ █ █ █ █ ██ █ ",
8027
+ "█ █ █ ████ ██ █ █ ██ ████",
8028
+ " ",
8029
+ " ███ ██ ██ ████ ███ ",
8030
+ " █ █ █ █ █ █ █ █ █ █ ",
8031
+ " ███ █ █ ████ ████ █ █ ",
8032
+ " █ █ █ █ █ █ █ █ █ █ ",
8033
+ " ███ ██ █ █ █ █ ███ ",
8034
+ ].join("\n"),
8035
+ slim: [
8036
+ "███ ███ ███ █ █ █ ███ ███",
8037
+ "█ █ █ █ █ █ █ █ █ █ █ ",
8038
+ "███ ██ █ █ █ ███ █ ██ ",
8039
+ "█ █ █ █ █ █ █ █ █ █ ",
8040
+ "█ █ █ ███ █ █ █ █ ███",
8041
+ " ",
8042
+ " ██ ███ █ ███ ██ ",
8043
+ " █ █ █ █ █ █ █ █ █ █ ",
8044
+ " ██ █ █ ███ ██ █ █ ",
8045
+ " █ █ █ █ █ █ █ █ █ █ ",
8046
+ " ██ ███ █ █ █ █ ██ ",
8047
+ ].join("\n"),
8048
+ thick: [
8049
+ "█████ █████ █████ █ █ █ █████ █████",
8050
+ "█ █ █ █ █ █ █ █ █ █ █ ",
8051
+ "█████ █████ █ █ █ █████ █ ████ ",
8052
+ "█ █ █ █ █ █ █ █ █ █ ",
8053
+ "█ █ █ █████ █ █ █ █ █████ ",
8054
+ " ",
8055
+ " ████ █████ █ █████ ████ ",
8056
+ " █ █ █ █ █ █ █ █ █ █ ",
8057
+ " ████ █ █ █████ █████ █ █ ",
8058
+ " █ █ █ █ █ █ █ █ █ █ ",
8059
+ " ████ █████ █ █ █ █ ████ ",
8060
+ ].join("\n"),
8061
+ };
8062
+
8063
+ // Quote font-size · scaled by char count so long quotes (up
8064
+ // to 200 chars) shrink to fit rather than getting truncated.
8065
+ const qLen = quote.length;
8066
+ const qFont = this.shareCardQuoteSize(tpl, qLen);
8067
+ const qStyle = `font-size: ${qFont}px;`;
8068
+
8069
+ if (tpl === "boardroom") {
8070
+ // Boardroom cover · randomized 8-bit landscape generated by
8071
+ // the standalone `share-cover-svg-creator.js` skill. The
8072
+ // module yields a fresh roll (sky palette, river curve,
8073
+ // stones, flowers, dirt patches, ground material) once per
8074
+ // `openShareCard()` and the result is cached on
8075
+ // `this._shareCardCover`, so chip-switching between templates
8076
+ // in the same modal keeps the SAME boardroom variation
8077
+ // visible. If the module didn't load, fall back to an empty
8078
+ // SVG layer + the CSS background-color set on `.share-card`.
8079
+ const cover = this._shareCardCover || {
8080
+ skyGradient: "",
8081
+ skyDeco: "",
8082
+ groundDeco: "",
8083
+ groundFg: "",
8084
+ };
8085
+ // Inline style carries two things:
8086
+ // 1. The randomized sky-gradient (overrides the flat
8087
+ // fallback color in CSS).
8088
+ // 2. A `--sc-fg` CSS variable holding a watermark/stamp
8089
+ // color picked to stay legible against whatever ground
8090
+ // material rolled (dark cream on volcanic-dark grounds,
8091
+ // dark purple on light grounds).
8092
+ const styleParts = [];
8093
+ if (cover.skyGradient) styleParts.push(`background: ${cover.skyGradient}`);
8094
+ if (cover.groundFg) styleParts.push(`--sc-fg: ${cover.groundFg}`);
8095
+ const bgStyle = styleParts.length
8096
+ ? ` style="${styleParts.join("; ")}"`
8097
+ : "";
8098
+ // Per-roll ASCII-logo TYPEFACE · the creator picks one of
8099
+ // three pre-baked letter-form designs (block / slim /
8100
+ // thick). All three use the same FULL BLOCK char + same
8101
+ // cream colour + same drop-shadow (defined in CSS) — the
8102
+ // only thing that changes is the letter proportions.
8103
+ const logoText = LOGO_FONTS[cover.logoFont] || LOGO_FONTS.block;
8104
+ return `
8105
+ <div class="share-card" data-share-template="boardroom"${bgStyle}>
8106
+ <div class="sc-header">
8107
+ <span>◆ PRIVATEBOARD<span class="sc-heart">♥</span></span>
8108
+ <span class="sc-header-right">${esc(DOMAIN)}</span>
8109
+ </div>
8110
+ <svg class="sc-sky-deco" viewBox="0 0 540 800" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
8111
+ ${cover.skyDeco}
8112
+ ${cover.groundDeco}
8113
+ </svg>
8114
+ <pre class="sc-logo" aria-label="Private Board">${logoText}</pre>
8115
+ <div class="sc-bubble">
8116
+ <!-- Decorative pixel blocks · scattered colored squares
8117
+ echo the retro-party poster's color accents on the
8118
+ black panel. Each block is 4-8 px in pixel-art
8119
+ register. Sits behind the text rows via z-index. -->
8120
+ <span class="sc-block" style="top:14px; left:18px; width:8px; height:8px; background:#E26AA0;"></span>
8121
+ <span class="sc-block" style="top:8px; right:30px; width:6px; height:6px; background:#F0D848;"></span>
8122
+ <span class="sc-block" style="top:42%; left:10px; width:6px; height:6px; background:#5BC0EB;"></span>
8123
+ <span class="sc-block" style="top:24px; right:14px; width:4px; height:4px; background:#6FB572;"></span>
8124
+ <span class="sc-block" style="bottom:18px; left:30px; width:6px; height:6px; background:#F86868;"></span>
8125
+ <span class="sc-block" style="bottom:12px; right:24px; width:8px; height:8px; background:#ED8E48;"></span>
8126
+ <!-- Section 1 · director name as the panel header.
8127
+ The "Distilled · X" prefix follows the user's i18n
8128
+ setting (en / zh / ja / es). CJK runs inside the
8129
+ resolved string render in STHeiti bold via .cjk. -->
8130
+ <div class="sc-bubble-name">${this.wrapCjk(esc(this._t("share_card_distilled", { name: author })))}</div>
8131
+ <!-- 8-bit pixel divider between name and quote -->
8132
+ <div class="sc-divider" aria-hidden="true"></div>
8133
+ <!-- Section 2 · the quote body -->
8134
+ <p class="sc-bubble-text" style="${qStyle}">${this.wrapCjk(esc(quote))}</p>
8135
+ ${n.roomSubject ? `
8136
+ <!-- 8-bit pixel divider between quote and annotation -->
8137
+ <div class="sc-divider" aria-hidden="true"></div>
8138
+ <!-- Section 3 · room subject (initial question) -->
8139
+ <div class="sc-bubble-meta">// re: ${this.wrapCjk(esc(String(n.roomSubject).trim()))}</div>
8140
+ ` : ""}
8141
+ </div>
8142
+ <div class="share-card-watermark">${esc(DOMAIN)}</div>
8143
+ <div class="share-card-stamp">${esc(stampDate)}</div>
8144
+ </div>
8145
+ `;
8146
+ }
8147
+ // terminal · CLI-style card with a big ASCII "PRIVATE
8148
+ // BOARD" logo at the top, dashed frame, code-style body.
8149
+ // Inspired by terminal-agent CLI screenshots: status bar
8150
+ // top-right, ASCII title centred, `// tagline`, then the
8151
+ // note rendered as `$ cat note.txt` with the quote as
8152
+ // the file contents and a blinking cursor below.
8153
+ // Terminal template uses a fixed block typeface (no per-roll
8154
+ // typeface variation — that's a boardroom-card feature). The
8155
+ // shared LOGO_FONTS dict is defined at the top of this
8156
+ // function so the boardroom branch can pull its rolled variant
8157
+ // from the same source.
8158
+ const distilledLabel = this._t("share_card_distilled", { name: author });
8159
+ return `
8160
+ <div class="share-card" data-share-template="terminal">
8161
+ <div class="sc-tt-frame">
8162
+ <div class="sc-tt-statusbar">
8163
+ <span class="sc-tt-version">pb-cli v0.1 · ${esc(isoDate)}</span>
6969
8164
  </div>
6970
- <p class="notes-item-passage">${
6971
- n.contextBefore ? `<span class="note-context note-context-before">${this.escape(n.contextBefore)}</span>` : ""
6972
- }<span class="note-quote">${this.escape(n.quoteText)}</span>${
6973
- n.contextAfter ? `<span class="note-context note-context-after">${this.escape(n.contextAfter)}</span>` : ""
6974
- }</p>
6975
- </a>
6976
- <button type="button" class="notes-item-delete" data-note-delete title="${this.escape(deleteTitle)}" aria-label="${this.escape(deleteTitle)}">✕</button>
6977
- </li>
8165
+ <pre class="sc-tt-logo">${LOGO_FONTS.block}</pre>
8166
+ <div class="sc-tt-tagline">// ${this.wrapCjk(esc(distilledLabel))}</div>
8167
+ <div class="sc-tt-body">
8168
+ <div class="sc-tt-line"><span class="sc-tt-prompt">$</span> cat note.txt</div>
8169
+ <div class="sc-tt-quote">${this.wrapCjk(esc(quote))}</div>
8170
+ ${n.roomSubject ? `<div class="sc-tt-comment"># re: ${this.wrapCjk(esc(String(n.roomSubject).trim()))}</div>` : ""}
8171
+ ${roomNum ? `<div class="sc-tt-comment"># source: room ${esc(roomNum)}</div>` : ""}
8172
+ <div class="sc-tt-line"><span class="sc-tt-prompt">$</span> <span class="sc-tt-cursor">▌</span></div>
8173
+ </div>
8174
+ </div>
8175
+ <div class="share-card-watermark">${esc(DOMAIN)}</div>
8176
+ <div class="share-card-stamp">${esc(stampDate)}</div>
8177
+ </div>
6978
8178
  `;
6979
8179
  },
6980
8180
 
8181
+ /** Lazy-load html-to-image from CDN. Same loader pattern as
8182
+ * `public/magazine.html`'s `ensureHtmlToImage` so the All Notes
8183
+ * page doesn't ship the lib unless the user actually exports. */
8184
+ _h2iLoaded: null,
8185
+ async ensureShareCardHtmlToImage() {
8186
+ if (window.htmlToImage) return;
8187
+ if (!this._h2iLoaded) {
8188
+ this._h2iLoaded = new Promise((res, rej) => {
8189
+ const s = document.createElement("script");
8190
+ s.src = "https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js";
8191
+ s.onload = res;
8192
+ s.onerror = rej;
8193
+ document.head.appendChild(s);
8194
+ });
8195
+ }
8196
+ await this._h2iLoaded;
8197
+ },
8198
+
8199
+ /** Capture the current preview at native 540×675 and save it as
8200
+ * an 800×1185 PNG. The visible preview is scaled down for fit;
8201
+ * we capture the un-scaled inner card so the export resolution
8202
+ * is independent of viewport width. Errors surface as a soft
8203
+ * alert + console.warn — same pattern as magazine/ppt exports. */
8204
+ async downloadShareCard() {
8205
+ if (!this._shareCardNote) return;
8206
+ const btn = document.querySelector("[data-share-card-download]");
8207
+ const overlay = document.getElementById("share-card-overlay");
8208
+ if (!overlay) return;
8209
+ try {
8210
+ if (btn) { btn.disabled = true; btn.textContent = "rendering…"; }
8211
+ await this.ensureShareCardHtmlToImage();
8212
+ if (document.fonts && document.fonts.ready) {
8213
+ try { await document.fonts.ready; } catch { /* best-effort */ }
8214
+ }
8215
+ // Find the live card element (the inner-most .share-card div
8216
+ // inside the preview, NOT the scaling wrapper). The native
8217
+ // card is 540 × 800; we capture at `pixelRatio = 800/540`
8218
+ // so the exported PNG comes out at exactly 800 × 1185 — a
8219
+ // moderate file size that scales proportionally from the
8220
+ // source layout. (Was previously pixelRatio: 2 → 1080 × 1600,
8221
+ // which the user found a touch too large.)
8222
+ const card = overlay.querySelector(".share-card");
8223
+ if (!card) throw new Error("share card not mounted");
8224
+ const TARGET_W = 800;
8225
+ const NATIVE_W = 540;
8226
+ const exportRatio = TARGET_W / NATIVE_W;
8227
+ const dataUrl = await window.htmlToImage.toPng(card, {
8228
+ pixelRatio: exportRatio,
8229
+ cacheBust: true,
8230
+ width: 540,
8231
+ height: 800,
8232
+ canvasWidth: 540,
8233
+ canvasHeight: 800,
8234
+ style: { transform: "none", margin: "0", width: "540px", height: "800px" },
8235
+ });
8236
+ const slug = (this._shareCardNote.authorName || "note")
8237
+ .toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40) || "note";
8238
+ const a = document.createElement("a");
8239
+ a.download = `privateboard-${slug}-${(this._shareCardNote.id || "").slice(0, 6)}.png`;
8240
+ a.href = dataUrl;
8241
+ a.click();
8242
+ } catch (e) {
8243
+ console.warn("[share-card] PNG export failed:", e);
8244
+ alert("PNG export failed — check the browser console for details.");
8245
+ } finally {
8246
+ if (btn) {
8247
+ btn.disabled = false;
8248
+ btn.innerHTML = `<span aria-hidden="true">↓</span><span>Download PNG</span>`;
8249
+ }
8250
+ }
8251
+ },
8252
+
6981
8253
  /** DELETE /api/notes/:id · drops a saved excerpt from the index.
6982
8254
  * Confirmation is light because the action is reversible only by
6983
8255
  * re-saving the same passage; we keep the prompt as a single
6984
8256
  * click-confirm rather than a full overlay. */
6985
8257
  async deleteNoteAt(id) {
6986
8258
  if (!id) return;
6987
- if (!confirm("Delete this note? You can save it again from the room.")) return;
8259
+ if (!confirm("Remove this note from your saved list? You can re-save it any time by highlighting the same passage in the room.")) return;
6988
8260
  try {
6989
8261
  const r = await fetch("/api/notes/" + encodeURIComponent(id), { method: "DELETE" });
6990
8262
  if (!r.ok) {
@@ -7137,6 +8409,27 @@
7137
8409
  * Returns true when the article was found + scrolled · the
7138
8410
  * caller uses this to know whether to keep the pending flag
7139
8411
  * armed. */
8412
+ /** Scroll the chat to the room's first user message · the
8413
+ * convene-opener at the very top of the transcript. Bound to
8414
+ * the `[data-jump-to-opener]` button in the input-bar's left
8415
+ * cluster. Smooth-scrolls so the user sees the trip; bails
8416
+ * silently if the room has no opener yet (fresh room mid-
8417
+ * convene) or the article isn't mounted. Suppresses the
8418
+ * bottom-snap window in scrollChatToBottom so an SSE-driven
8419
+ * re-render doesn't immediately yank the view back to the
8420
+ * newest message. */
8421
+ scrollToOpener() {
8422
+ const openerId = this.firstUserMessageId();
8423
+ if (!openerId) return;
8424
+ const article = document.querySelector(`article[data-message-id="${openerId}"]`);
8425
+ if (!article) return;
8426
+ try {
8427
+ article.scrollIntoView({ behavior: "smooth", block: "start" });
8428
+ } catch { /* */ }
8429
+ this.chatStuckToBottom = false;
8430
+ this._suppressBottomScrollUntil = Date.now() + 2000;
8431
+ },
8432
+
7140
8433
  scrollToMessage(messageId) {
7141
8434
  if (!messageId) return false;
7142
8435
  const article = document.querySelector(`article[data-message-id="${messageId}"]`);
@@ -7611,6 +8904,12 @@
7611
8904
  this.renderEmptyState();
7612
8905
  this.markActiveRoom(null);
7613
8906
  }
8907
+ // Composer mode just changed · re-evaluate whether the
8908
+ // agent-build ambient should be playing. Entering "agent"
8909
+ // with a live build resumes it; leaving "agent" silences it
8910
+ // even if the build is still chugging away (it'll resume
8911
+ // once the user navigates back via the sidebar building row).
8912
+ this._syncAgentBuildBgm();
7614
8913
  },
7615
8914
 
7616
8915
  /** Pitch copy for the new-room composer's textarea placeholder.
@@ -7880,8 +9179,29 @@
7880
9179
  // unmistakeably a select) without taking up visual real estate.
7881
9180
  const toneLbl = this._t("cmp_tone_label");
7882
9181
  const intensityLbl = this._t("cmp_intensity_label");
7883
- // Starter grid · 2-col responsive cards.
7884
- const starters = Array.isArray(window.BOARDROOM_STARTERS) ? window.BOARDROOM_STARTERS : [];
9182
+ // Starter grid · 2-col responsive cards. Two parallel
9183
+ // pools render in the same `.cmp-starters-grid`:
9184
+ // 1. Topic recommendations (newest-first, paged, visible
9185
+ // list, capped at 5 in the tray). Each card carries a
9186
+ // synthesiser-generated tag (e.g. "strategy") in the
9187
+ // left column. Clicking populates the composer with
9188
+ // the rec's subject + attaches its seedContext
9189
+ // snippets so the room opens grounded in the source
9190
+ // material.
9191
+ // 2. Legacy hardcoded starters from window.BOARDROOM_STARTERS.
9192
+ // Show only when there are no recommendations yet (or
9193
+ // when recs are still loading on first paint) so a
9194
+ // brand-new user sees something useful in the tray.
9195
+ const recs = (this.topicRecs.items || []).slice(0, 5);
9196
+ // Card markup lives in `topicRecCardHtml` so the
9197
+ // initial render path and any later append paths can't
9198
+ // drift in shape / attributes.
9199
+ const recCards = recs.map((rec) => this.topicRecCardHtml(rec)).join("");
9200
+
9201
+ const showLegacyStarters = recs.length === 0;
9202
+ const starters = showLegacyStarters && Array.isArray(window.BOARDROOM_STARTERS)
9203
+ ? window.BOARDROOM_STARTERS
9204
+ : [];
7885
9205
  const starterCards = starters.map((q, idx) => {
7886
9206
  const tag = (q.tag || "").replace(/^\/\/\s*/, "");
7887
9207
  return `
@@ -7892,6 +9212,59 @@
7892
9212
  </button>
7893
9213
  `;
7894
9214
  }).join("");
9215
+ // Trigger card · disguised as the first row in the
9216
+ // starters grid, so the "recommend" action lives in the
9217
+ // same visual rhythm as the suggestions it produces. The
9218
+ // card has TWO states wired through the same DOM hooks
9219
+ // ([data-trec-label] / [data-trec-detail] / [data-trec-pct]
9220
+ // / [data-trec-bar]) so the SSE handler can patch them
9221
+ // in place without re-rendering the whole composer:
9222
+ // · IDLE (no job) · tag = "✦ discover", text = the
9223
+ // localised label (EN/ZH via i18n), arrow = "+".
9224
+ // Looks like a starter but with a lime accent +
9225
+ // dashed bottom rule.
9226
+ // · BUSY (job live) · tag = phase label (LLM-emitted,
9227
+ // stays English — those come from the orchestrator's
9228
+ // phaseLabels constant), text = animated dots +
9229
+ // phase detail, arrow = "N%". A thin lime progress
9230
+ // bar lives along the bottom edge and fills as the
9231
+ // pipeline ticks.
9232
+ //
9233
+ // The trigger LABEL is i18n'd (cmp_recs_trigger_label)
9234
+ // because it's user-facing prompt copy, parallel to
9235
+ // `cmp_prompt` and the time-of-day greeting above. The
9236
+ // tag column ("discover") and starting placeholder are
9237
+ // also keyed so locale switches flip them together.
9238
+ const job = this.topicRecs.job;
9239
+ const triggerBusy = !!job;
9240
+ const triggerLabel = this._t("cmp_recs_trigger_label");
9241
+ const triggerTag = this._t("cmp_recs_trigger_tag");
9242
+ const triggerStarting = this._t("cmp_recs_trigger_starting");
9243
+ const triggerCard = `
9244
+ <button type="button"
9245
+ class="cmp-starter cmp-recs-trigger-card${triggerBusy ? " is-busy" : ""}"
9246
+ data-cmp-recs-trigger
9247
+ data-topic-rec-progress
9248
+ ${triggerBusy ? "disabled" : ""}
9249
+ title="${this.escape(triggerLabel)}">
9250
+ <div class="cmp-starter-tag" data-trec-label>${this.escape(triggerBusy ? (job.label || triggerStarting) : `✦ ${triggerTag}`)}</div>
9251
+ <div class="cmp-starter-text">
9252
+ ${triggerBusy
9253
+ ? `<span class="cmp-recs-trigger-dots" aria-hidden="true"><i></i><i></i><i></i></span><span data-trec-detail>${this.escape(job.detail || "")}</span>`
9254
+ : `<span>${this.escape(triggerLabel)}</span>`}
9255
+ </div>
9256
+ <div class="cmp-starter-arrow" data-trec-pct>${this.escape(triggerBusy ? (job.pct ? `${job.pct}%` : "·") : "+")}</div>
9257
+ <div class="cmp-recs-trigger-bar" aria-hidden="true"><div class="cmp-recs-trigger-fill" data-trec-bar style="width: ${triggerBusy ? (job.pct || 0) : 0}%"></div></div>
9258
+ </button>
9259
+ `;
9260
+
9261
+ // Trigger card always leads; suggestions (or legacy
9262
+ // starters as empty-state fallback) follow.
9263
+ const trayCards = triggerCard + (recs.length > 0 ? recCards : starterCards);
9264
+ // Pagination is intentionally OFF — the server keeps only
9265
+ // the latest batch (6 rows). Every fresh generation wipes
9266
+ // the previous batch, so there's never "older" data to
9267
+ // page back to. The "+ N more" surface is gone.
7895
9268
 
7896
9269
  return `
7897
9270
  <section class="cmp">
@@ -7967,50 +9340,52 @@
7967
9340
  </div>
7968
9341
  </div>
7969
9342
 
7970
- ${starters.length ? `
7971
- <div class="cmp-starters">
7972
- <div class="cmp-starters-rule">
7973
- <span class="cmp-starters-rule-line"></span>
7974
- <span class="cmp-starters-rule-label">${this.escape(t.starterCaption)}</span>
7975
- <span class="cmp-starters-rule-line"></span>
7976
- </div>
7977
- <div class="cmp-starters-grid">${starterCards}</div>
9343
+ <div class="cmp-starters">
9344
+ <div class="cmp-starters-rule">
9345
+ <span class="cmp-starters-rule-line"></span>
9346
+ <span class="cmp-starters-rule-label">${this.escape(t.starterCaption)}</span>
9347
+ <span class="cmp-starters-rule-line"></span>
7978
9348
  </div>
7979
- ` : ""}
9349
+ <div class="cmp-starters-grid">${trayCards}</div>
9350
+ </div>
7980
9351
  </section>
7981
9352
  `;
7982
9353
  },
7983
9354
 
7984
- /** Time-of-day greeting like "// good evening, Kay" / "// 晚上好,Kay". */
7985
- composerGreeting(lang, name) {
7986
- // Follows composer chrome locale (`lang` · en or zh via I18n).
9355
+ /** Time-of-day greeting like "// good evening, Kay" / "// 晚上好,Kay".
9356
+ * Four locale-agnostic buckets · the underlying greet_0..3 keys
9357
+ * are translated per-locale in i18n.js. Previously branched on
9358
+ * `lang === "zh"` to pick a 6-bucket zh variant; collapsed to 4
9359
+ * so adding new locales is mechanical (each locale just fills
9360
+ * the four keys). The `lang` arg is preserved for callers but
9361
+ * no longer drives bucket selection. */
9362
+ composerGreeting(_lang, name) {
7987
9363
  const h = new Date().getHours();
7988
- const isZh = lang === "zh";
7989
9364
  let key;
7990
- if (isZh) {
7991
- if (h < 5) key = "greet_zh_0";
7992
- else if (h < 12) key = "greet_zh_1";
7993
- else if (h < 14) key = "greet_zh_2";
7994
- else if (h < 18) key = "greet_zh_3";
7995
- else if (h < 23) key = "greet_zh_4";
7996
- else key = "greet_zh_5";
7997
- } else if (h < 5) key = "greet_en_0";
7998
- else if (h < 12) key = "greet_en_1";
7999
- else if (h < 18) key = "greet_en_2";
8000
- else key = "greet_en_3";
8001
- return this._t(key, { name }); },
9365
+ if (h < 5) key = "greet_0"; // late night / early morning
9366
+ else if (h < 12) key = "greet_1"; // morning
9367
+ else if (h < 18) key = "greet_2"; // afternoon
9368
+ else key = "greet_3"; // evening
9369
+ return this._t(key, { name });
9370
+ },
8002
9371
 
9372
+ /** Returns the active UI locale code · one of the 4 supported
9373
+ * locales (en / zh / ja / es). Used by composer chrome that
9374
+ * used to branch en/zh only — now passes through all four
9375
+ * so each locale can localise hero / greeting / placeholder. */
8003
9376
  composerLanguage() {
8004
9377
  try {
8005
9378
  if (window.I18n && typeof window.I18n.getLocale === "function") {
8006
- return window.I18n.getLocale() === "zh" ? "zh" : "en";
9379
+ const supported = window.I18n.SUPPORTED_LOCALES || ["en", "zh"];
9380
+ const loc = window.I18n.getLocale();
9381
+ return supported.indexOf(loc) >= 0 ? loc : "en";
8007
9382
  }
8008
9383
  } catch { /* ignore */ }
8009
9384
  try {
8010
9385
  const lang = (navigator.language || "").toLowerCase();
8011
- if (lang.startsWith("zh") || lang.includes("cn") || lang.includes("hans") || lang.includes("hant")) {
8012
- return "zh";
8013
- }
9386
+ if (lang.startsWith("zh")) return "zh";
9387
+ if (lang.startsWith("ja")) return "ja";
9388
+ if (lang.startsWith("es")) return "es";
8014
9389
  } catch { /* ignore */ }
8015
9390
  return "en";
8016
9391
  },
@@ -8034,6 +9409,24 @@
8034
9409
  ta.style.height = h + "px";
8035
9410
  },
8036
9411
 
9412
+ /** Auto-grow the room input-bar's textarea. Smaller bounds
9413
+ * than the composer's autosize since the input-bar sits in
9414
+ * a tighter floating-card frame · 24px single-line baseline,
9415
+ * 200px cap (then internal scroll). Always cycles through
9416
+ * `height: auto` so the field SHRINKS back to baseline after
9417
+ * the user sends + the value is cleared (the composer's
9418
+ * quick-path skip behaviour would leave a stale tall field
9419
+ * here). */
9420
+ autosizeRoomInputTextarea() {
9421
+ const ta = document.querySelector(".ib-textarea[data-send-input]");
9422
+ if (!ta) return;
9423
+ const MIN = 24;
9424
+ const MAX = 200;
9425
+ ta.style.height = "auto";
9426
+ const h = Math.min(MAX, Math.max(MIN, ta.scrollHeight));
9427
+ ta.style.height = h + "px";
9428
+ },
9429
+
8037
9430
  /* ─────────── New-agent composer ────────────────────────────────
8038
9431
  Inline AI-first agent creation. User describes what kind of
8039
9432
  director they want; LLM generates name / role / bio / cover
@@ -8884,11 +10277,20 @@
8884
10277
  };
8885
10278
  this._personaStartedAt = Date.now();
8886
10279
  this.renderEmptyState();
10280
+ // Start the sci-fi ambient · runs while personaJob.status is
10281
+ // "starting" or "running". SSE terminal handlers (final /
10282
+ // error / aborted) and cancelPersonaBuild flip the status and
10283
+ // call _syncAgentBuildBgm again to stop the loop.
10284
+ this._syncAgentBuildBgm();
8887
10285
  try {
10286
+ // Pass the active UI locale so the narrator pass at the end of
10287
+ // phase 7 writes the build-log summary in the user's language.
10288
+ // Falls back to "en" server-side if the field is missing.
10289
+ const locale = (window.I18n && typeof window.I18n.getLocale === "function") ? window.I18n.getLocale() : "en";
8888
10290
  const r = await fetch("/api/agents/generate-persona", {
8889
10291
  method: "POST",
8890
10292
  headers: { "content-type": "application/json" },
8891
- body: JSON.stringify({ description }),
10293
+ body: JSON.stringify({ description, locale }),
8892
10294
  });
8893
10295
  if (!r.ok) {
8894
10296
  const e = await r.json().catch(() => ({}));
@@ -8912,6 +10314,7 @@
8912
10314
  this.personaJob.errorMessage = (e && e.message) ? e.message : String(e);
8913
10315
  this._personaRender();
8914
10316
  this.renderSidebarAgents();
10317
+ this._syncAgentBuildBgm();
8915
10318
  }
8916
10319
  },
8917
10320
 
@@ -9044,6 +10447,10 @@
9044
10447
  // Sidebar row flips from "BUILD · phase X · Y%" to "READY"
9045
10448
  // so the user can spot it from anywhere.
9046
10449
  this.renderSidebarAgents();
10450
+ // Build phase is over · stop the sci-fi ambient. The save
10451
+ // overlay (if it opens immediately below) gets a clean
10452
+ // audio backdrop.
10453
+ this._syncAgentBuildBgm();
9047
10454
  // Auto-open the manual-config overlay one-shot · only when
9048
10455
  // the user is actually on the agent composer (no room
9049
10456
  // loaded). If they navigated into a room mid-build, opening
@@ -9067,6 +10474,7 @@
9067
10474
  // user recovers via the inline error card on the agent
9068
10475
  // composer view.
9069
10476
  this.renderSidebarAgents();
10477
+ this._syncAgentBuildBgm();
9070
10478
  }));
9071
10479
  sse.addEventListener("persona-aborted", onMsg(() => {
9072
10480
  this.personaJob.status = "aborted";
@@ -9074,6 +10482,7 @@
9074
10482
  this._stopPersonaTick();
9075
10483
  this._personaRender();
9076
10484
  this.renderSidebarAgents();
10485
+ this._syncAgentBuildBgm();
9077
10486
  }));
9078
10487
 
9079
10488
  sse.onerror = () => {
@@ -9146,6 +10555,7 @@
9146
10555
  if (silent) {
9147
10556
  this.personaJob = null;
9148
10557
  }
10558
+ this._syncAgentBuildBgm();
9149
10559
  },
9150
10560
 
9151
10561
  /** User clicked Discard from the failed / aborted card. Drops
@@ -9155,6 +10565,7 @@
9155
10565
  this._stopPersonaTick();
9156
10566
  this.personaJob = null;
9157
10567
  this._personaOverlayShown = false;
10568
+ this._syncAgentBuildBgm();
9158
10569
  // Close the manual overlay if it's still open · the user
9159
10570
  // explicitly discarded, no need to leave a stale form.
9160
10571
  if (typeof window.closeNewAgent === "function") {
@@ -9457,6 +10868,39 @@
9457
10868
  if (this._personaUiActive()) this.renderEmptyState();
9458
10869
  },
9459
10870
 
10871
+ /** Agent-build ambient BGM controller · drives
10872
+ * `window.boardroomAgentBuildBgm` based on the user's current
10873
+ * view + the live build state. Plays during Signal-mode
10874
+ * generation OR Full-persona builds when the user is on the
10875
+ * agent composer (not buried inside a room). Stops the
10876
+ * moment any of those preconditions stops being true.
10877
+ * Idempotent · safe to call from anywhere; the module's own
10878
+ * start/stop are no-ops when already in the target state. */
10879
+ _syncAgentBuildBgm() {
10880
+ const bgm = window.boardroomAgentBuildBgm;
10881
+ if (!bgm) return;
10882
+ // Global SFX gate · the BGM module re-checks this internally,
10883
+ // but checking here saves an audio-graph construction on the
10884
+ // start path when the user already has SFX off.
10885
+ let sfxOn = true;
10886
+ try {
10887
+ if (typeof window.boardroomTypingSfx?.isEnabled === "function") {
10888
+ sfxOn = window.boardroomTypingSfx.isEnabled() !== false;
10889
+ }
10890
+ } catch { /* default permissive */ }
10891
+ const onComposer = this.composerMode === "agent" && !this.currentRoomId;
10892
+ const signalGenerating = this.agentSpecGenerating === true;
10893
+ const personaActive = !!(this.personaJob && (
10894
+ this.personaJob.status === "starting" ||
10895
+ this.personaJob.status === "running"
10896
+ ));
10897
+ const shouldPlay = sfxOn && onComposer && (signalGenerating || personaActive);
10898
+ try {
10899
+ if (shouldPlay) bgm.start();
10900
+ else bgm.stop();
10901
+ } catch { /* audio failures must not break the build flow */ }
10902
+ },
10903
+
9460
10904
  /** Format seconds as MM:SS. Used by the build-stage header for
9461
10905
  * elapsed + ETA. */
9462
10906
  _fmtMmSs(secs) {
@@ -9647,6 +11091,11 @@
9647
11091
  this._agentGenUsingWebSearch = this.loadAgentComposerWebSearch();
9648
11092
  this.renderEmptyState();
9649
11093
  this.startAgentGenTick();
11094
+ // Start the sci-fi ambient · runs while agentSpecGenerating is
11095
+ // true. The `finally` block below flips the flag and calls
11096
+ // _syncAgentBuildBgm again, stopping the BGM. Same helper
11097
+ // governs Full-mode (see startFullPersonaBuild).
11098
+ this._syncAgentBuildBgm();
9650
11099
 
9651
11100
  // AbortController · used to enforce the 5-min hard timeout AND
9652
11101
  // to clean up cleanly if the user discards mid-flight.
@@ -9702,6 +11151,10 @@
9702
11151
  this.stopAgentGenTick();
9703
11152
  this.agentSpecGenerating = false;
9704
11153
  this.renderEmptyState();
11154
+ // Stop the ambient · success and failure both end the build
11155
+ // phase, and the overlay (if it opens) is its own UI moment
11156
+ // that doesn't want lingering audio behind it.
11157
+ this._syncAgentBuildBgm();
9705
11158
  // Successful fetch path · open the floating save overlay (same
9706
11159
  // component Full-mode uses) once the underlying empty composer
9707
11160
  // has painted. Skipped on the error path · the inline error
@@ -9830,6 +11283,7 @@
9830
11283
  this.agentSpecGenerating = false;
9831
11284
  this.agentSpecError = null;
9832
11285
  this.renderEmptyState();
11286
+ this._syncAgentBuildBgm();
9833
11287
  },
9834
11288
 
9835
11289
  redoAgentSpec() {
@@ -10054,7 +11508,7 @@
10054
11508
  .filter((a) => a.roleKind !== "moderator")
10055
11509
  .sort((a, b) => (a.name || "").localeCompare(b.name || ""));
10056
11510
  // System UI · always English (composer director picker chrome).
10057
- const t = { title: "Pick directors", hint: "2-4 recommended", done: "Done", info: "View profile" };
11511
+ const t = { title: "Pick directors", hint: "2-4 recommended", info: "View profile" };
10058
11512
  const rows = dirs.map((a) => {
10059
11513
  const checked = state.directorIds.includes(a.id);
10060
11514
  const modelLabel = MODEL_LABELS[a.modelV] || a.modelV || "";
@@ -10082,9 +11536,6 @@
10082
11536
  <span class="composer-pick-hint">${this.escape(t.hint)}</span>
10083
11537
  </div>
10084
11538
  <div class="composer-pick-list">${rows || `<div class="composer-pick-empty">${this.escape(this._t("picker_no_directors"))}</div>`}</div>
10085
- <div class="composer-pick-foot">
10086
- <button type="button" class="composer-pick-done" data-composer-pick-done>${this.escape(t.done)}</button>
10087
- </div>
10088
11539
  `;
10089
11540
  document.body.appendChild(pop);
10090
11541
  // Position with viewport collision detection. Generous buffer
@@ -10167,7 +11618,7 @@
10167
11618
  // it so the user can fall back to chair-pick by clearing.
10168
11619
  state.autoPickDirectors = state.directorIds.length === 0;
10169
11620
  this.saveComposerState();
10170
- // Don't close the picker — let the user toggle multiple before Done.
11621
+ // Don't close the picker on each toggle — let the user pick multiple in one open.
10171
11622
  // But re-render the chip strip in the composer so the count updates.
10172
11623
  this.refreshComposerCast();
10173
11624
  // Update the picker row's visual state in place.
@@ -10259,6 +11710,19 @@
10259
11710
  }
10260
11711
  },
10261
11712
 
11713
+ /** Flip the CURRENT room's delivery mode (text ↔ voice) ·
11714
+ * parallel to setComposerDeliveryMode but for an active
11715
+ * room. Optimistically repaints the toggle for instant
11716
+ * feedback, then PATCHes the server. The SSE
11717
+ * `settings-changed` event broadcasts to other tabs and
11718
+ * re-evaluates the round-table / transcript visibility.
11719
+ *
11720
+ * When flipping TO voice without a voice key configured,
11721
+ * follows the composer's pattern: leaves the toggle in its
11722
+ * current state and opens Settings → API Keys (focused on
11723
+ * minimax). The server gracefully degrades to text-only
11724
+ * chunks regardless, so the worst case is "no audio" not
11725
+ * "broken room". */
10262
11726
  /** Generic option-list dropdown anchored under a tune trigger
10263
11727
  * button. Used for both tone and intensity. Each option is a
10264
11728
  * full-text row with a short hint (current/calmer/etc). Click an
@@ -10311,15 +11775,13 @@
10311
11775
  current = state.intensity;
10312
11776
  }
10313
11777
  } else if (kind === "delivery") {
10314
- opts = lang === "zh"
10315
- ? [
10316
- { v: "text", label: "Text", hint: "最快的文字会议" },
10317
- { v: "voice", label: "Voice", hint: "口语化 · 逐位播放" },
10318
- ]
10319
- : [
10320
- { v: "text", label: "Text", hint: "fast written meeting" },
10321
- { v: "voice", label: "Voice", hint: "spoken · paced turns" },
10322
- ];
11778
+ // Delivery-mode picker · text vs voice. Labels stay
11779
+ // "Text" / "Voice" (universally recognisable in this
11780
+ // domain), hints flip with locale via i18n.
11781
+ opts = [
11782
+ { v: "text", label: "Text", hint: this._t("delivery_text_hint") },
11783
+ { v: "voice", label: "Voice", hint: this._t("delivery_voice_hint") },
11784
+ ];
10323
11785
  current = state.deliveryMode === "voice" ? "voice" : "text";
10324
11786
  } else if (kind === "locale") {
10325
11787
  // Interface language picker · routes through the shared
@@ -10327,10 +11789,17 @@
10327
11789
  // doesn't ship a separate two-button toggle. Hints describe
10328
11790
  // what the locale switch affects so the user knows it's the
10329
11791
  // chrome language, not the brief language.
10330
- opts = [
10331
- { v: "en", label: "EN", hint: "interface in english" },
10332
- { v: "zh", label: "中文", hint: "界面语言切到中文" },
10333
- ];
11792
+ // Locale picker · uses the I18n.SUPPORTED_LOCALES list as
11793
+ // the source of truth so adding a new language only needs
11794
+ // changes in i18n.js. Labels come from the `locale_<code>`
11795
+ // keys translated in EACH locale's STR block.
11796
+ const supported = (window.I18n && window.I18n.SUPPORTED_LOCALES) || ["en", "zh"];
11797
+ const tr = (k) => (window.I18n && window.I18n.t ? window.I18n.t(k) : k);
11798
+ opts = supported.map((code) => ({
11799
+ v: code,
11800
+ label: tr(`locale_${code}`),
11801
+ hint: tr(`locale_${code}`),
11802
+ }));
10334
11803
  current = (window.I18n && window.I18n.getLocale && window.I18n.getLocale()) || "en";
10335
11804
  } else if (kind === "agent-builder-mode") {
10336
11805
  // Build-mode picker · Signal vs Full persona. Same `.cmp-dd`
@@ -10575,11 +12044,16 @@
10575
12044
  intensity: state.intensity,
10576
12045
  deliveryMode: state.deliveryMode,
10577
12046
  autoPick: useAutoPick,
12047
+ seedContext: state.seedContext || null,
10578
12048
  });
10579
12049
  // Clear the saved draft now that the room is convened — next
10580
12050
  // visit to "+ New Room" should land on a fresh textarea, not
10581
- // re-show the just-submitted subject.
12051
+ // re-show the just-submitted subject. Also drop the attached
12052
+ // seedContext — the snippets travelled into the room's
12053
+ // opening message; we don't want them piggy-backing on the
12054
+ // NEXT room the user opens.
10582
12055
  state.subject = "";
12056
+ state.seedContext = null;
10583
12057
  this.saveComposerState();
10584
12058
  } catch (e) {
10585
12059
  if (btn) btn.classList.remove("busy");
@@ -10587,6 +12061,242 @@
10587
12061
  }
10588
12062
  },
10589
12063
 
12064
+ /** Fetch the (single) page of topic recommendations · the
12065
+ * server keeps only the latest batch (6 rows), so one
12066
+ * request is the whole story. Idempotent — safe to call
12067
+ * from renderEmptyState() boot AND after a generation job
12068
+ * completes. Sets `topicRecs.loaded` so first-paint can
12069
+ * fall back to legacy hardcoded starters until this
12070
+ * resolves. */
12071
+ async refreshTopicRecs() {
12072
+ try {
12073
+ const r = await fetch("/api/topic-recs?limit=6");
12074
+ if (!r.ok) {
12075
+ this.topicRecs.loaded = true;
12076
+ this.renderEmptyState();
12077
+ return;
12078
+ }
12079
+ const j = await r.json();
12080
+ this.topicRecs.items = Array.isArray(j.items) ? j.items : [];
12081
+ this.topicRecs.loaded = true;
12082
+ // Re-render only when the user is still on the empty
12083
+ // (composer) state · openRoom paths have already moved
12084
+ // on and an unsolicited re-render would steal focus.
12085
+ if (!this.currentRoomId) this.renderEmptyState();
12086
+ } catch { /* network error · keep stale list, surface nothing */ }
12087
+ },
12088
+
12089
+ /** Derive a short tag from a subject string · used as the
12090
+ * client-side fallback when a rec row has no `tag` field
12091
+ * (legacy rows from before migration 035, or the rare
12092
+ * case where the LLM forgot to include one and the
12093
+ * orchestrator's safety-net path also fired). Mirrors the
12094
+ * orchestrator's `deriveTagFromSubject` logic so the
12095
+ * vocabulary stays consistent across paths. */
12096
+ _deriveTagFromSubject(subject) {
12097
+ const STOP = new Set([
12098
+ "the", "and", "for", "are", "you", "your", "what", "how",
12099
+ "why", "when", "with", "from", "this", "that", "should",
12100
+ "could", "would", "have", "has", "will", "into", "about",
12101
+ ]);
12102
+ const words = (subject || "")
12103
+ .toLowerCase()
12104
+ .replace(/[^a-z0-9\s-]/g, " ")
12105
+ .split(/\s+/)
12106
+ .filter((w) => w.length > 2 && !STOP.has(w));
12107
+ return words.slice(0, 2).join(" ").slice(0, 28) || "topic";
12108
+ },
12109
+
12110
+ /** Build the HTML for a single topic-rec card. Used both by
12111
+ * `renderComposerHtml` (initial render) and `loadMoreTopicRecs`
12112
+ * (in-place append). Keeping the markup in one helper means
12113
+ * the two paths can't drift in shape or attributes. */
12114
+ topicRecCardHtml(rec) {
12115
+ // Tag priority: synthesiser-produced category > derive
12116
+ // from subject. We NEVER fall back to "web" / "memory"
12117
+ // as the visible tag — those are data-provenance tokens,
12118
+ // not topic categories. The `data-source` attribute still
12119
+ // carries the provenance for CSS to colour-tint with.
12120
+ const tag = (typeof rec.tag === "string" && rec.tag.trim().length > 0)
12121
+ ? rec.tag
12122
+ : this._deriveTagFromSubject(rec.subject);
12123
+ const hint = rec.rationale || "";
12124
+ return `
12125
+ <button type="button" class="cmp-starter cmp-rec" data-cmp-rec="${this.escape(rec.id)}" data-source="${this.escape(rec.source)}">
12126
+ <div class="cmp-starter-tag">${this.escape(tag)}</div>
12127
+ <div class="cmp-starter-text">${this.escape(rec.subject || "")}</div>
12128
+ ${hint ? `<div class="cmp-rec-hint" title="${this.escape(hint)}">${this.escape(hint)}</div>` : ""}
12129
+ <div class="cmp-starter-arrow">→</div>
12130
+ </button>
12131
+ `;
12132
+ },
12133
+
12134
+ // (no `loadMoreTopicRecs` — see comments at the
12135
+ // pagination-removed render block above.)
12136
+
12137
+ /** Kick off a new topic-recommendation generation job and
12138
+ * attach an SSE stream so the composer's progress strip
12139
+ * ticks through phases live. On `topic-final` we refresh
12140
+ * the tray from the API; on error we surface the message
12141
+ * inline so the user understands why nothing landed. */
12142
+ async startTopicRecJob() {
12143
+ if (this.topicRecs.job) return; // already running · idempotent click
12144
+ // Pre-flight · API gate requires a model key. Surface the
12145
+ // same prompt as Convene so the user fixes it once.
12146
+ if (!(await this.requireModelKey())) return;
12147
+ try {
12148
+ const r = await fetch("/api/topic-recs", {
12149
+ method: "POST",
12150
+ headers: { "content-type": "application/json" },
12151
+ body: JSON.stringify({}),
12152
+ });
12153
+ if (!r.ok) {
12154
+ const j = await r.json().catch(() => ({}));
12155
+ alert("Couldn't start: " + (j.error || r.statusText));
12156
+ return;
12157
+ }
12158
+ const { jobId } = await r.json();
12159
+ this.topicRecs.job = {
12160
+ id: jobId,
12161
+ phase: 0,
12162
+ label: this._t("cmp_recs_trigger_starting"),
12163
+ pct: 0,
12164
+ detail: "",
12165
+ es: null,
12166
+ error: null,
12167
+ };
12168
+ if (!this.currentRoomId) this.renderEmptyState();
12169
+ this._attachTopicRecJobSSE(jobId);
12170
+ } catch (e) {
12171
+ alert("Couldn't start: " + (e && e.message ? e.message : e));
12172
+ }
12173
+ },
12174
+
12175
+ /** Internal · attach the EventSource for a running job and
12176
+ * wire each event type to the in-memory job state. */
12177
+ _attachTopicRecJobSSE(jobId) {
12178
+ const url = `/api/topic-recs/jobs/${encodeURIComponent(jobId)}/stream`;
12179
+ const es = new EventSource(url);
12180
+ this.topicRecs.job.es = es;
12181
+ const updateStrip = () => {
12182
+ const el = document.querySelector("[data-topic-rec-progress]");
12183
+ if (!el || !this.topicRecs.job) return;
12184
+ const j = this.topicRecs.job;
12185
+ const labelEl = el.querySelector("[data-trec-label]");
12186
+ if (labelEl) labelEl.textContent = j.label || "";
12187
+ const detailEl = el.querySelector("[data-trec-detail]");
12188
+ if (detailEl) detailEl.textContent = j.detail || "";
12189
+ const pctEl = el.querySelector("[data-trec-pct]");
12190
+ if (pctEl) pctEl.textContent = j.pct ? `${j.pct}%` : "·";
12191
+ const bar = el.querySelector("[data-trec-bar]");
12192
+ if (bar) bar.style.width = `${j.pct || 0}%`;
12193
+ };
12194
+ const terminate = () => {
12195
+ try { es.close(); } catch { /* noop */ }
12196
+ this.topicRecs.job = null;
12197
+ if (!this.currentRoomId) this.renderEmptyState();
12198
+ };
12199
+ es.addEventListener("hello", (ev) => {
12200
+ try {
12201
+ const data = JSON.parse(ev.data);
12202
+ if (this.topicRecs.job) {
12203
+ this.topicRecs.job.phase = data.currentPhase || 0;
12204
+ this.topicRecs.job.pct = data.progressPct || 0;
12205
+ }
12206
+ updateStrip();
12207
+ } catch { /* noop */ }
12208
+ });
12209
+ es.addEventListener("topic-phase-start", (ev) => {
12210
+ try {
12211
+ const data = JSON.parse(ev.data);
12212
+ if (this.topicRecs.job) {
12213
+ this.topicRecs.job.phase = data.phase;
12214
+ this.topicRecs.job.label = data.label;
12215
+ this.topicRecs.job.detail = "";
12216
+ }
12217
+ updateStrip();
12218
+ } catch { /* noop */ }
12219
+ });
12220
+ es.addEventListener("topic-phase-progress", (ev) => {
12221
+ try {
12222
+ const data = JSON.parse(ev.data);
12223
+ if (this.topicRecs.job) {
12224
+ this.topicRecs.job.phase = data.phase;
12225
+ this.topicRecs.job.detail = data.detail || "";
12226
+ this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
12227
+ }
12228
+ updateStrip();
12229
+ } catch { /* noop */ }
12230
+ });
12231
+ es.addEventListener("topic-phase-end", (ev) => {
12232
+ try {
12233
+ const data = JSON.parse(ev.data);
12234
+ if (this.topicRecs.job) {
12235
+ this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
12236
+ }
12237
+ updateStrip();
12238
+ } catch { /* noop */ }
12239
+ });
12240
+ es.addEventListener("topic-final", () => {
12241
+ // Pull the freshest first page so the new cards land
12242
+ // immediately; closing the EventSource is on the same
12243
+ // tick so the next click doesn't see a stale job.
12244
+ terminate();
12245
+ void this.refreshTopicRecs();
12246
+ });
12247
+ es.addEventListener("topic-error", (ev) => {
12248
+ let msg = "generation failed";
12249
+ try { msg = (JSON.parse(ev.data).message) || msg; } catch { /* noop */ }
12250
+ alert("Topic generation failed: " + msg);
12251
+ terminate();
12252
+ });
12253
+ es.addEventListener("topic-aborted", () => {
12254
+ terminate();
12255
+ });
12256
+ es.onerror = () => {
12257
+ // Transport hiccup · treat as terminal so the UI doesn't
12258
+ // stay stuck on a phantom progress strip.
12259
+ if (this.topicRecs.job) terminate();
12260
+ };
12261
+ },
12262
+
12263
+ /** Click handler on a recommendation card · fetches the
12264
+ * full row (to recover seedContext) then applies it to the
12265
+ * composer state so the next Convene carries the snippets
12266
+ * through to the opening message's meta. */
12267
+ async applyTopicRec(id) {
12268
+ if (!id) return;
12269
+ try {
12270
+ const r = await fetch(`/api/topic-recs/${encodeURIComponent(id)}`);
12271
+ if (!r.ok) return;
12272
+ const row = await r.json();
12273
+ if (!row || typeof row.subject !== "string") return;
12274
+ const state = this.loadComposerState();
12275
+ state.subject = row.subject;
12276
+ state.seedContext = {
12277
+ topicRecId: row.id,
12278
+ // Rationale is the "why this fits you" line the
12279
+ // synthesiser produced · it's hidden from the card
12280
+ // UI but forwarded as background context so the
12281
+ // chair's clarify prompt can ground its first turn
12282
+ // in the same reasoning the recommendation was
12283
+ // built on.
12284
+ rationale: typeof row.rationale === "string" ? row.rationale : "",
12285
+ snippets: Array.isArray(row.seedContext) ? row.seedContext : [],
12286
+ };
12287
+ this.saveComposerState();
12288
+ this.renderEmptyState();
12289
+ setTimeout(() => {
12290
+ const ta = document.querySelector("[data-composer-subject]");
12291
+ if (ta) {
12292
+ ta.focus();
12293
+ ta.setSelectionRange(ta.value.length, ta.value.length);
12294
+ this.autosizeComposerTextarea?.();
12295
+ }
12296
+ }, 50);
12297
+ } catch { /* noop */ }
12298
+ },
12299
+
10590
12300
  /** Apply a starter spec into the composer state — fills the
10591
12301
  * textarea, swaps the cast / tone / intensity to the starter's
10592
12302
  * presets, then re-renders. User can adjust before hitting Enter. */
@@ -10814,6 +12524,25 @@
10814
12524
  const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
10815
12525
  const multi = briefs.length > 1;
10816
12526
  const directHref = this.briefViewerHref(this.currentBrief, r.id) || `/report.html?r=${this.escape(r.id)}`;
12527
+ // Brief mid-pipeline · render the View Report slot in
12528
+ // its pending shape: a non-anchor `<span>` so the
12529
+ // browser can't navigate to a half-empty report, plus
12530
+ // `data-pending="1"` so the shared CSS rule applies
12531
+ // (cursor: progress, opacity 0.7, pointer-events: none).
12532
+ // Exclude errored / interrupted / timed-out briefs ·
12533
+ // those carry no body but are NOT generating, so the
12534
+ // recovery UI inside the report page is the right
12535
+ // destination (still clickable).
12536
+ const errored = !!(this.currentBrief.error
12537
+ || this.currentBrief.interrupted
12538
+ || this.currentBrief.timedOut);
12539
+ const generating = !errored
12540
+ && (this.currentBrief.isGenerating === true
12541
+ || !this.briefHasBody(this.currentBrief)
12542
+ || this.currentBrief.title === "Generating…");
12543
+ if (generating) {
12544
+ return `<span class="view-report-btn generate-report" data-view-report data-pending="1" role="button" aria-disabled="true" title="${this.escape(this._t("room_view_report_generating_title"))}"><span class="vr-mark">·</span> ${this.escape(this._t("room_view_report_generating"))}</span>`;
12545
+ }
10817
12546
  if (!multi) {
10818
12547
  return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>${this.escape(this._t("room_view_report"))}</a>`;
10819
12548
  }
@@ -11088,6 +12817,13 @@
11088
12817
 
11089
12818
  enqueueVoiceChunk(roomId, chunk) {
11090
12819
  if (!chunk || !chunk.messageId || !chunk.audioBase64 || !chunk.mimeType) return;
12820
+ // Stop the thinking SFX loop proactively · the next render
12821
+ // cycle would do this anyway via `anyVoiceQueued`, but doing
12822
+ // it here guarantees the AudioContext is idle BEFORE
12823
+ // `_createVoiceStream` calls `audio.play()`. On Safari / iOS
12824
+ // a continuously-active AudioContext can claim the audio
12825
+ // session and prevent the HTMLAudioElement from starting.
12826
+ try { window.boardroomTypingSfx?.setThinking?.(false); } catch { /* ignore */ }
11091
12827
  let q = this.voiceQueues[chunk.messageId];
11092
12828
  if (!q) {
11093
12829
  // Serialize voice playback · only one TTS clip plays at a
@@ -11253,6 +12989,56 @@
11253
12989
  this.renderRoundTable();
11254
12990
  },
11255
12991
 
12992
+ /** Chair clarify bubble · parallel surface to the user bubble.
12993
+ * Pins the chair's clarifying question to the chair seat with a
12994
+ * 10s border countdown (same conic-gradient mechanic as the
12995
+ * user bubble). Voice-mode only · in text mode the question
12996
+ * appears as a normal chat bubble and the user can read it
12997
+ * inline. Calling again before timeout REPLACES the text and
12998
+ * resets the deadline. */
12999
+ CHAIR_BUBBLE_TTL_MS: 10000,
13000
+ showChairBubble(text) {
13001
+ const trimmed = String(text || "").trim();
13002
+ if (!trimmed) return;
13003
+ if (this.currentRoom && this.currentRoom.status === "adjourned") return;
13004
+ this.chairBubble.text = trimmed;
13005
+ this.chairBubble.deadline = Date.now() + this.CHAIR_BUBBLE_TTL_MS;
13006
+ this.chairBubble.dismissed = false;
13007
+ if (!this.chairBubble.intervalId) {
13008
+ this.chairBubble.intervalId = setInterval(() => this.tickChairBubble(), 1000);
13009
+ }
13010
+ this.renderRoundTable();
13011
+ },
13012
+
13013
+ /** 1Hz tick · surgical update of the border-progress CSS var.
13014
+ * Mirrors tickUserBubble exactly · see that method for the
13015
+ * full reasoning around surgical-vs-full rerender. */
13016
+ tickChairBubble() {
13017
+ if (this.chairBubble.dismissed) return;
13018
+ const now = Date.now();
13019
+ if (now >= this.chairBubble.deadline) {
13020
+ this.dismissChairBubble();
13021
+ return;
13022
+ }
13023
+ const bubbleEl = document.querySelector("[data-rt-chair-bubble]");
13024
+ if (bubbleEl) {
13025
+ const elapsed = this.CHAIR_BUBBLE_TTL_MS - (this.chairBubble.deadline - now);
13026
+ const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
13027
+ bubbleEl.style.setProperty("--rt-bubble-chair-progress", progress.toFixed(3));
13028
+ }
13029
+ },
13030
+
13031
+ dismissChairBubble() {
13032
+ if (this.chairBubble.intervalId) {
13033
+ clearInterval(this.chairBubble.intervalId);
13034
+ this.chairBubble.intervalId = null;
13035
+ }
13036
+ this.chairBubble.dismissed = true;
13037
+ this.chairBubble.text = "";
13038
+ this.chairBubble.deadline = 0;
13039
+ this.renderRoundTable();
13040
+ },
13041
+
11256
13042
  /** Cycle to the next preset rate · wraps around at the top.
11257
13043
  * Persists to localStorage and applies the new rate to every
11258
13044
  * audio element currently playing in `voiceQueues`. The HUD
@@ -11939,11 +13725,13 @@
11939
13725
  <div class="convene-eyebrow">${this.escape(this._t("convene_eyebrow"))}</div>
11940
13726
  ${originHtml}
11941
13727
  <h2 class="convene-body">${this.renderBody(m.body)}</h2>
11942
- ${toggleHtml}
11943
13728
  <div class="convene-meta">
11944
- <span class="convene-by">${who}</span>
11945
- <span class="convene-time">· ${this.timeFmt(m.createdAt)}</span>
11946
- <span class="convene-cast">· ${this.escape(this._t("convene_meta_to"))} ${this.currentMembers.map((a) => this.escape(a.handle)).join(" ")}</span>
13729
+ <span class="convene-meta-info">
13730
+ <span class="convene-by">${who}</span>
13731
+ <span class="convene-time">· ${this.timeFmt(m.createdAt)}</span>
13732
+ <span class="convene-cast">· ${this.escape(this._t("convene_meta_to"))} ${this.currentMembers.map((a) => this.escape(a.handle)).join(" ")}</span>
13733
+ </span>
13734
+ ${toggleHtml}
11947
13735
  </div>
11948
13736
  </article>
11949
13737
  `;
@@ -12159,45 +13947,12 @@
12159
13947
  }
12160
13948
 
12161
13949
  // No-brief closing marker · chair posts this when the user
12162
- // adjourned with skipBrief. Rendered as a milestone card (chip +
12163
- // flanking lines) instead of a chat bubble so the transcript
12164
- // ends on a clear "no report filed" beat that doesn't read as
12165
- // just another chair turn.
12166
- //
12167
- // CTA · "Generate report now" button surfaces when the room is
12168
- // adjourned + no brief exists yet. The user can change their
12169
- // mind without opening a follow-up room. Hidden once a brief
12170
- // has been filed (currentBrief !== null) — the card then reads
12171
- // as a historical marker only.
12172
- if (isChair && metaKind === "no-brief") {
12173
- const ts = this.timeFmt(m.createdAt);
12174
- // System UI · always English (no-brief card chrome).
12175
- const hasBrief = !!this.currentBrief;
12176
- const chairName = (this.prefs?.name || "").trim() || this._t("nb_chair_fallback");
12177
- const cta = hasBrief
12178
- ? ""
12179
- : `
12180
- <div class="nb-actions">
12181
- <button type="button" class="nb-cta" data-generate-brief>
12182
- <span class="nb-cta-mark">▸</span>
12183
- <span class="nb-cta-text">${this.escape(this._t("nb_cta"))}</span>
12184
- </button>
12185
- </div>
12186
- `;
12187
- return `
12188
- <div class="no-brief-card" data-message-id="${this.escape(m.id)}">
12189
- <span class="nb-chip">
12190
- <span class="nb-mark">⊘</span>
12191
- <span class="nb-eyebrow">${this.escape(this._t("nb_eyebrow"))}</span>
12192
- </span>
12193
- <div class="nb-body">
12194
- <strong>${this.escape(chairName)}</strong> ${this.escape(this._t("nb_body"))}
12195
- </div>
12196
- ${cta}
12197
- <div class="nb-meta">${this.escape(ts)}</div>
12198
- </div>
12199
- `;
12200
- }
13950
+ // adjourned with skipBrief. Previously rendered as a special
13951
+ // milestone card; now falls through to the regular chair
13952
+ // bubble below so the closer reads as the chair speaking,
13953
+ // not as separate UI chrome. The "Generate report" affordance
13954
+ // still lives in the room header for users who change their
13955
+ // mind.
12201
13956
 
12202
13957
  const baseCls = isUser
12203
13958
  ? "user"
@@ -12588,6 +14343,51 @@
12588
14343
  }
12589
14344
  },
12590
14345
 
14346
+ /** Decide whether the speaking-queue strip should be collapsed
14347
+ * given the current room state. The rule: collapse by default
14348
+ * (one-liner is enough when a single director is streaming),
14349
+ * expand when the user benefits from seeing the full cast:
14350
+ * · awaitingClarify · cast preview before chair releases them
14351
+ * · awaitingContinue · vote pending, next-round preview
14352
+ * · pendingUserMessage · user just queued, show what comes
14353
+ * before / after their message
14354
+ * Empty queue + idle stays collapsed (nothing to expand to).
14355
+ *
14356
+ * This is the "auto" baseline; manual user clicks on the
14357
+ * chevron inverse it until the next significant state shift
14358
+ * (see `_applyQueueAutoState`). */
14359
+ _computeQueueAutoCollapsed() {
14360
+ const room = this.currentRoom;
14361
+ if (!room) return true;
14362
+ if (room.awaitingClarify) return false;
14363
+ if (room.awaitingContinue) return false;
14364
+ if (this.pendingUserMessage) return false;
14365
+ // Default collapsed otherwise · the one-liner summary is the
14366
+ // right shape when a director is streaming or queued.
14367
+ return true;
14368
+ },
14369
+
14370
+ /** Apply the auto-collapse decision to the speaking-queue
14371
+ * DOM. Resets the user override when the auto state would
14372
+ * flip (so a clarify→continue transition picks up the new
14373
+ * auto baseline rather than carrying a stale manual pick). */
14374
+ _applyQueueAutoState() {
14375
+ const queue = document.getElementById("speaking-queue");
14376
+ if (!queue) return;
14377
+ const newAuto = this._computeQueueAutoCollapsed();
14378
+ if (newAuto !== this._queueAutoCollapsed) {
14379
+ this._queueAutoCollapsed = newAuto;
14380
+ // Auto baseline flipped · drop the user override so the
14381
+ // new auto-target shows through. The user can re-flip
14382
+ // explicitly any time by clicking the chevron.
14383
+ this._queueUserOverride = false;
14384
+ }
14385
+ const finalCollapsed = this._queueUserOverride ? !newAuto : newAuto;
14386
+ queue.classList.toggle("collapsed", finalCollapsed);
14387
+ const btn = queue.querySelector(".queue-toggle");
14388
+ if (btn) btn.setAttribute("aria-expanded", finalCollapsed ? "false" : "true");
14389
+ },
14390
+
12591
14391
  /** Build the one-line summary shown in the collapsed speaking-queue
12592
14392
  * strip. Mirrors the expanded queue: speaking, pending, or idle. */
12593
14393
  renderQueueCollapsed(items) {
@@ -12678,6 +14478,13 @@
12678
14478
  // Update the collapsed summary alongside the expanded list so the
12679
14479
  // collapsed strip never shows stale text.
12680
14480
  this.renderQueueCollapsed(renderItems);
14481
+ // Auto-expand/collapse logic · the strip flips based on the
14482
+ // room state (clarify / continue / pending user message
14483
+ // expand it; everything else collapses). User can still
14484
+ // override via the chevron — see _applyQueueAutoState. Has
14485
+ // to run after we've populated renderItems so the auto
14486
+ // decision sees the current queue shape.
14487
+ this._applyQueueAutoState();
12681
14488
 
12682
14489
  if (renderItems.length === 0) {
12683
14490
  list.innerHTML = "";
@@ -13218,23 +15025,58 @@
13218
15025
  speakingId = this.currentChair.id;
13219
15026
  speakerState = "thinking";
13220
15027
  }
15028
+ // (3b) Convening sequence active (auto-pick + chair prep) ·
15029
+ // the chat-side convening card is hidden in voice mode,
15030
+ // so without this the stage shows nothing happening for
15031
+ // the 3-4s the picker LLM + chair tools + LLM startup
15032
+ // take. chairPending above only fires AFTER auto-pick-
15033
+ // complete; conveneState covers the EARLIER picker phase
15034
+ // too. Cleared on the chair's first message-appended.
15035
+ if (!speakingId && this.conveneState && this.currentChair) {
15036
+ speakingId = this.currentChair.id;
15037
+ speakerState = "thinking";
15038
+ }
13221
15039
  if (!speakingId && Array.isArray(this.currentQueue) && this.currentQueue[0] && this.currentQueue[0].status === "speaking") {
13222
15040
  speakingId = this.currentQueue[0].agentId;
13223
15041
  speakerState = "thinking";
13224
15042
  }
13225
15043
 
13226
- // Speaker-change SFX · fire a soft chime when the active
13227
- // speaker flips (idle first speaker, or A → B). Skips
13228
- // speaker idle (no chime when the room goes quiet) and
13229
- // skips repeated calls within the same speaker. Same toggle
13230
- // gate as the typing tick · user-settings "sound" toggle.
13231
- if (speakingId !== this._lastSpeakerId) {
13232
- if (speakingId && window.boardroomTypingSfx
13233
- && typeof window.boardroomTypingSfx.speakerChange === "function") {
13234
- window.boardroomTypingSfx.speakerChange();
13235
- }
13236
- this._lastSpeakerId = speakingId;
13237
- }
15044
+ // Speaker / thinking SFX · two distinct cues:
15045
+ // · `setThinking(true)` starts a looping 8-bit pulse hum
15046
+ // while a seat shows the thought-bubble. Idempotent ·
15047
+ // calling repeatedly during the thinking phase is free.
15048
+ // Switched off as soon as the speaker starts streaming
15049
+ // (state flips to "speaking") OR the room goes idle.
15050
+ // · `speakerChange()` (legacy triangle-wave chime) only
15051
+ // fires for the rarer direct-to-speaking transition
15052
+ // where there's no thinking phase between A → B (e.g.
15053
+ // chair templated announcements that stream voice
15054
+ // immediately). Skips speaker → idle.
15055
+ // Critical · the thinking loop's AudioContext oscillators
15056
+ // overlap with the HTMLAudioElement used for TTS playback,
15057
+ // and on some browsers (Safari / iOS) a continuously-active
15058
+ // AudioContext claims the audio session and prevents a fresh
15059
+ // `audio.play()` from proceeding. Gate the loop on
15060
+ // `voiceQueues` being empty so the moment any director's
15061
+ // TTS audio is queued the loop stops and the audio session
15062
+ // is free for the media element to grab.
15063
+ // Same toggle gate as the typing tick · user-settings
15064
+ // "sound" toggle controls all of these uniformly.
15065
+ const sfx = window.boardroomTypingSfx;
15066
+ const anyVoiceQueued = !!(this.voiceQueues && Object.keys(this.voiceQueues).length > 0);
15067
+ const shouldBeThinking = !!speakingId
15068
+ && speakerState === "thinking"
15069
+ && !anyVoiceQueued;
15070
+ if (sfx && typeof sfx.setThinking === "function") {
15071
+ sfx.setThinking(shouldBeThinking);
15072
+ }
15073
+ const speakerChanged = speakingId !== this._lastSpeakerId;
15074
+ if (speakerChanged && speakingId && speakerState === "speaking"
15075
+ && sfx && typeof sfx.speakerChange === "function") {
15076
+ sfx.speakerChange();
15077
+ }
15078
+ if (speakerChanged) this._lastSpeakerId = speakingId;
15079
+ this._lastSpeakerState = speakerState;
13238
15080
 
13239
15081
  const html = seatsByZ.map(({ seat, i }) => {
13240
15082
  const m = seat.member;
@@ -13280,9 +15122,23 @@
13280
15122
  // word ("thinking" / "speaking") is rendered as a smaller
13281
15123
  // mono kicker beneath the name. Color + dots animation still
13282
15124
  // distinguishes thinking (amber) from speaking (lime).
13283
- const statusWord = bubbleState === "thinking"
15125
+ // Convene override · while the room is mid-convening, the
15126
+ // chair seat shows the current stage label ("Analyzing topic"
15127
+ // / "Seating directors" / "Preparing remarks") instead of the
15128
+ // generic "Thinking" so the user knows WHICH part of the
15129
+ // setup is in flight. Falls back to the generic label for
15130
+ // every non-chair speaker and for chair turns post-convene.
15131
+ let statusWord = bubbleState === "thinking"
13284
15132
  ? this._t("rt_thinking")
13285
15133
  : this._t("rt_speaking");
15134
+ if (bubbleState === "thinking" && isChair && this.conveneState) {
15135
+ const stageKey = ({
15136
+ analyzing: "conv_stage_analyzing_title",
15137
+ seating: "conv_stage_seating_title",
15138
+ preparing: "conv_stage_preparing_title",
15139
+ })[this.conveneState.stage];
15140
+ if (stageKey) statusWord = this._t(stageKey);
15141
+ }
13286
15142
  const bubbleCls = bubbleState === "thinking"
13287
15143
  ? "rt-bubble is-thinking"
13288
15144
  : "rt-bubble";
@@ -13304,6 +15160,19 @@
13304
15160
  `<button type="button" class="rt-bubble-user-close" data-rt-user-bubble-close aria-label="Dismiss">✕</button>` +
13305
15161
  `</div>`;
13306
15162
  }
15163
+ } else if (isChair && this.chairBubble && !this.chairBubble.dismissed
15164
+ && this.chairBubble.text && Date.now() < this.chairBubble.deadline) {
15165
+ // Chair clarify question · pinned to the chair seat with
15166
+ // border countdown. Takes precedence over the "Speaking"
15167
+ // status bubble since the chair has already finished its
15168
+ // turn at this point (message-final flipped streaming off).
15169
+ const cb = this.chairBubble;
15170
+ const elapsed = this.CHAIR_BUBBLE_TTL_MS - (cb.deadline - Date.now());
15171
+ const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
15172
+ bubble = `<div class="rt-bubble rt-bubble-chair-clarify" data-rt-chair-bubble style="--rt-bubble-chair-progress: ${progress.toFixed(3)}">` +
15173
+ `<span class="rt-bubble-chair-clarify-text">${this.escape(cb.text)}</span>` +
15174
+ `<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
15175
+ `</div>`;
13307
15176
  } else if (isSpeaking) {
13308
15177
  bubble = `<div class="${bubbleCls}"><span class="rt-bubble-name">${this.escape(m.name || "")}</span><span class="rt-bubble-status">${this.escape(statusWord)}</span><span class="rt-bubble-dots" aria-hidden="true"><i></i><i></i><i></i></span></div>`;
13309
15178
  }
@@ -13328,6 +15197,11 @@
13328
15197
  : false;
13329
15198
  const isChairBusy = (() => {
13330
15199
  if (this.chairPending === true) return true;
15200
+ // Chair message landed but voice synthesis hasn't reached
15201
+ // the client yet · the popover would otherwise flash for
15202
+ // 0.5-2s before audio kicks in. See `_chairVoiceAwaiting`
15203
+ // doc + the message-appended hook for the full reasoning.
15204
+ if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.size > 0) return true;
13331
15205
  const allMsgs = this.currentMessages || [];
13332
15206
  for (let k = allMsgs.length - 1; k >= 0; k--) {
13333
15207
  const mm = allMsgs[k];
@@ -13516,6 +15390,35 @@
13516
15390
  }
13517
15391
  }
13518
15392
  }
15393
+ // (3) Convene fallback · no live speaker, but the room is
15394
+ // mid-convening (auto-pick + chair preparing). Surface the
15395
+ // stage's deck text so the subtitle bar gives the user
15396
+ // explicit textual feedback during the 3-4s of silent
15397
+ // setup. Falls back to hide-when-empty for non-convening
15398
+ // idle states (the prior behaviour).
15399
+ if (!speakerId && this.conveneState && this.currentChair) {
15400
+ const stage = this.conveneState.stage;
15401
+ const titleKey = ({
15402
+ analyzing: "conv_stage_analyzing_title",
15403
+ seating: "conv_stage_seating_title",
15404
+ preparing: "conv_stage_preparing_title",
15405
+ })[stage];
15406
+ const deckKey = ({
15407
+ analyzing: "conv_stage_analyzing_deck",
15408
+ seating: "conv_stage_seating_deck",
15409
+ preparing: "conv_stage_preparing_deck",
15410
+ })[stage];
15411
+ if (titleKey && deckKey) {
15412
+ slot.hidden = false;
15413
+ slot.innerHTML =
15414
+ `<span class="rt-sub-kicker">${this.escape(this.currentChair.name || "")} · ${this.escape(this._t(titleKey))}</span>` +
15415
+ `<p class="rt-sub-text rt-sub-thinking">` +
15416
+ `<span class="rt-sub-dots" aria-hidden="true"><i></i><i></i><i></i></span>` +
15417
+ `<span class="rt-sub-thinking-label">${this.escape(this._t(deckKey))}</span>` +
15418
+ `</p>`;
15419
+ return;
15420
+ }
15421
+ }
13519
15422
  if (!speakerId) {
13520
15423
  slot.hidden = true;
13521
15424
  slot.innerHTML = "";
@@ -13539,6 +15442,22 @@
13539
15442
  .replace(/\s+/g, " ")
13540
15443
  .trim();
13541
15444
  if (!text) {
15445
+ // Empty body but the speaker placeholder IS streaming ·
15446
+ // director is "thinking" (no tokens yet). Show a loading
15447
+ // panel with the speaker's name + animated dots so the
15448
+ // subtitle band doesn't pop in/out as the first token
15449
+ // lands. When the body fills in this branch falls through
15450
+ // to the normal caption render below.
15451
+ if (isStreaming) {
15452
+ slot.hidden = false;
15453
+ slot.innerHTML =
15454
+ `<span class="rt-sub-kicker">${this.escape(speaker.name || "")}</span>` +
15455
+ `<p class="rt-sub-text rt-sub-thinking">` +
15456
+ `<span class="rt-sub-dots" aria-hidden="true"><i></i><i></i><i></i></span>` +
15457
+ `<span class="rt-sub-thinking-label">${this.escape(this._t("rt_thinking"))}</span>` +
15458
+ `</p>`;
15459
+ return;
15460
+ }
13542
15461
  slot.hidden = true;
13543
15462
  slot.innerHTML = "";
13544
15463
  return;
@@ -13661,6 +15580,11 @@
13661
15580
  else if (r.awaitingClarify) { stateLabel = "WAIT"; stateKind = "wait"; }
13662
15581
  else if (r.awaitingContinue) { stateLabel = "VOTE"; stateKind = "vote"; }
13663
15582
  }
15583
+ // Convening overrides the live-state label for the 3-4s of
15584
+ // auto-pick + chair-prep. Without this the HUD reads "LIVE"
15585
+ // while nothing visible is happening — the user wonders if
15586
+ // the room is broken. Cleared on chair's first message.
15587
+ if (this.conveneState) { stateLabel = "CONVENE"; stateKind = "wait"; }
13664
15588
  const replayOn = !!(typeof window !== "undefined"
13665
15589
  && window.boardroomVoiceReplay
13666
15590
  && typeof window.boardroomVoiceReplay.isOpen === "function"
@@ -13909,6 +15833,7 @@
13909
15833
  + `</svg>`;
13910
15834
  const innerHTML =
13911
15835
  `<span class="ib-rt-glyph" aria-hidden="true">${currentGlyphSvg}</span>` +
15836
+ `<span class="ib-rt-label">${this.escape(destLabel)}</span>` +
13912
15837
  `<div class="ib-rt-preview" role="tooltip" aria-hidden="true" data-rt-preview>` +
13913
15838
  `<div class="ib-rt-preview-kicker">// ${this.escape(destLabel)}</div>` +
13914
15839
  previewSvg +
@@ -13916,6 +15841,7 @@
13916
15841
  `</div>`;
13917
15842
  btns.forEach((btn) => {
13918
15843
  btn.innerHTML = innerHTML;
15844
+ btn.classList.add("has-label");
13919
15845
  // Aria-pressed reflects "round-table view active" not the
13920
15846
  // toggle's destination · `inStage` is the active state.
13921
15847
  btn.setAttribute("aria-pressed", inStage ? "true" : "false");
@@ -14246,15 +16172,14 @@
14246
16172
  // `tabsStripHtml` so both error and success paths can mount it.
14247
16173
  const tabsHtml = tabsStripHtml;
14248
16174
 
14249
- // Ceremonial wrapper · the deliverable hits the table inside an
14250
- // ending-block frame.
16175
+ // Brief-card wrapper · the ceremonial `.ending-block-head`
16176
+ // divider that used to live above this block was removed — the
16177
+ // amber `.round-open-card.is-adjourned` marker that
16178
+ // `renderSessionAnalytics()` injects right above the analytics
16179
+ // tile already provides the closing-section break for the
16180
+ // room, so a second lime "BRIEF OUTPUT" rule below it was
16181
+ // redundant.
14251
16182
  card.innerHTML = `
14252
- <header class="ending-block-head">
14253
- <span class="ending-block-line"></span>
14254
- <span class="ending-block-label">${this.escape(this._t("brief_output_head"))}</span>
14255
- <span class="ending-block-line"></span>
14256
- </header>
14257
-
14258
16183
  <div class="brief-card">
14259
16184
  ${tabsHtml}
14260
16185
 
@@ -14439,12 +16364,10 @@
14439
16364
  const open = b.llmLogOpen === true;
14440
16365
  const running = logs.filter((l) => l.status === "running").length;
14441
16366
  const failed = logs.filter((l) => l.status === "failed").length;
14442
- const label = open
14443
- ? (b.language === "zh" ? "收起模型流水" : "Hide model stream")
14444
- : (b.language === "zh" ? "查看模型流水" : "View model stream");
16367
+ const label = this._t(open ? "brief_llm_hide_stream" : "brief_llm_view_stream");
14445
16368
  const countText = logs.length
14446
16369
  ? `${logs.length} call${logs.length === 1 ? "" : "s"}${running ? ` · ${running} running` : ""}${failed ? ` · ${failed} failed` : ""}`
14447
- : (b.language === "zh" ? "等待首个调用" : "waiting for first call");
16370
+ : this._t("brief_llm_waiting_first");
14448
16371
  const button = `
14449
16372
  <button type="button" class="brief-llm-toggle" data-brief-llm-toggle data-brief-id="${this.escape(b.id || "")}" aria-expanded="${open ? "true" : "false"}">
14450
16373
  <span class="brief-llm-toggle-mark">${open ? "−" : "+"}</span>
@@ -14463,7 +16386,7 @@
14463
16386
  const text = (l.text || "").trim();
14464
16387
  const preview = text
14465
16388
  ? this.escape(text)
14466
- : `<span class="brief-llm-empty">${this.escape(b.language === "zh" ? "等待模型输出…" : "waiting for output…")}</span>`;
16389
+ : `<span class="brief-llm-empty">${this.escape(this._t("brief_llm_waiting_output"))}</span>`;
14467
16390
  const meta = [
14468
16391
  l.modelV || "",
14469
16392
  elapsed,
@@ -15111,6 +17034,13 @@
15111
17034
  app.dismissUserBubble();
15112
17035
  return;
15113
17036
  }
17037
+ // Same `×` pattern on the chair clarify bubble.
17038
+ const chairBubbleClose = e.target.closest("[data-rt-chair-bubble-close]");
17039
+ if (chairBubbleClose) {
17040
+ e.preventDefault();
17041
+ app.dismissChairBubble();
17042
+ return;
17043
+ }
15114
17044
  // Web Search · click the badge to expand / collapse the source list
15115
17045
  // beneath the bubble.
15116
17046
  const wsBtn = e.target.closest("[data-msg-ws-toggle]");
@@ -15199,27 +17129,103 @@
15199
17129
  app.closePauseChoiceModal();
15200
17130
  return;
15201
17131
  }
15202
- // Resume (paused live)
17132
+ // Reload-confirm modal buttons (Ctrl+R intercept). Same
17133
+ // three-option grammar as pause-choice but the action is
17134
+ // "reload after handling the in-flight speaker."
17135
+ const rChoice = e.target.closest("[data-reload-choice]");
17136
+ if (rChoice) {
17137
+ e.preventDefault();
17138
+ app.handleReloadChoice(rChoice.getAttribute("data-reload-choice"));
17139
+ return;
17140
+ }
17141
+ if (e.target.id === "reload-choice-overlay") {
17142
+ app.closeReloadChoiceModal();
17143
+ return;
17144
+ }
17145
+ // Resume (paused → live). Voice rooms need a TTS provider
17146
+ // key · if the user previously configured one, opened the
17147
+ // room, then later removed the key from settings, every
17148
+ // director's speech would silently degrade to text-only
17149
+ // chunks. Instead of hard-blocking (the old behaviour,
17150
+ // which dead-ended the user), surface the resume-choice
17151
+ // modal so they can configure a key OR convert the room
17152
+ // to text mode and resume.
15203
17153
  if (e.target.closest("[data-resume]")) {
15204
17154
  e.preventDefault();
17155
+ const room = app.currentRoom;
17156
+ const needsVoiceKey = !!room
17157
+ && room.deliveryMode === "voice"
17158
+ && typeof app.hasAnyVoiceKey === "function"
17159
+ && !app.hasAnyVoiceKey();
17160
+ if (needsVoiceKey) {
17161
+ app.openResumeChoiceModal();
17162
+ return;
17163
+ }
15205
17164
  app.resumeRoom().catch((err) => alert("Resume failed: " + err.message));
15206
17165
  return;
15207
17166
  }
15208
- // Export · adjourned-bar action. Browser handles the download
15209
- // natively from the route's Content-Disposition header.
17167
+ // Resume-choice modal buttons. Same three-option grammar as
17168
+ // pause-choice / reload-choice modals.
17169
+ const resChoice = e.target.closest("[data-resume-choice]");
17170
+ if (resChoice) {
17171
+ e.preventDefault();
17172
+ app.handleResumeChoice(resChoice.getAttribute("data-resume-choice"));
17173
+ return;
17174
+ }
17175
+ if (e.target.id === "resume-choice-overlay") {
17176
+ app.closeResumeChoiceModal();
17177
+ return;
17178
+ }
17179
+ // Export · adjourned-room action (input-bar's left cluster).
17180
+ // Browser handles the download natively from the route's
17181
+ // Content-Disposition header.
15210
17182
  if (e.target.closest("[data-room-export]")) {
15211
17183
  e.preventDefault();
15212
17184
  if (!app.currentRoomId) return;
15213
17185
  window.location.href = "/api/rooms/" + encodeURIComponent(app.currentRoomId) + "/export.md";
15214
17186
  return;
15215
17187
  }
15216
- // Voice Replay · adjourned-bar action. Plays the transcript
17188
+ // Divergence report · opens a read-only overlay for the
17189
+ // current room. Available across live / paused / adjourned.
17190
+ if (e.target.closest("[data-divergence-open]")) {
17191
+ e.preventDefault();
17192
+ app.openDivergenceOverlay();
17193
+ return;
17194
+ }
17195
+ // Jump-to-opener · scroll the chat back to the room's first
17196
+ // user message (the convene question). Useful when a long room
17197
+ // has scrolled the opener many screens up.
17198
+ if (e.target.closest("[data-jump-to-opener]")) {
17199
+ e.preventDefault();
17200
+ app.scrollToOpener();
17201
+ return;
17202
+ }
17203
+ if (e.target.closest("[data-divergence-close]")) {
17204
+ e.preventDefault();
17205
+ app.closeDivergenceOverlay();
17206
+ return;
17207
+ }
17208
+ // Voice Replay · adjourned-room action (input-bar's left cluster). Plays the transcript
15217
17209
  // back via TTS in chronological order, each director in their
15218
17210
  // own voice. Routed through the standalone voice-replay module
15219
17211
  // so the playback state machine + overlay live in one place.
15220
17212
  if (e.target.closest("[data-room-replay]")) {
15221
17213
  e.preventDefault();
15222
17214
  if (!app.currentRoomId) return;
17215
+ // Key gate · the replay module also checks /api/voices and
17216
+ // shows an in-overlay [Configure] CTA when no TTS key is set,
17217
+ // but that's a two-step (click replay → click Configure). For
17218
+ // the common case where the user previously had a key, opened
17219
+ // a voice room, then later removed the key, deep-link straight
17220
+ // to user-settings → keys → minimax so they're one click from
17221
+ // pasting the key. Falls through to the overlay path on
17222
+ // platforms where openUserSettings isn't wired (degrades to
17223
+ // the existing CTA).
17224
+ if (typeof app.hasAnyVoiceKey === "function" && !app.hasAnyVoiceKey()
17225
+ && typeof window.openUserSettings === "function") {
17226
+ window.openUserSettings({ section: "keys", focusProvider: "minimax" });
17227
+ return;
17228
+ }
15223
17229
  if (window.boardroomVoiceReplay && typeof window.boardroomVoiceReplay.open === "function") {
15224
17230
  // Pass historicalMembers (includes excused directors) so
15225
17231
  // past messages from a director the chair has excused still
@@ -15304,8 +17310,11 @@
15304
17310
  // does the navigation.
15305
17311
  }
15306
17312
 
15307
- // Convene Follow-up · adjourned-bar action. Opens the follow-up
15308
- // overlay with parent reference + form for the new question.
17313
+ // Convene Follow-up · adjourned-room action (input-bar's left
17314
+ // cluster icon, or via the input-bar's Send button which routes
17315
+ // through submitFromComposer with a prefilled subject). Opens
17316
+ // the follow-up overlay with parent reference + form for the
17317
+ // new question.
15309
17318
  if (e.target.closest("[data-room-followup]")) {
15310
17319
  e.preventDefault();
15311
17320
  app.openFollowUpOverlay();
@@ -15557,24 +17566,6 @@
15557
17566
  app.submitSupplement();
15558
17567
  return;
15559
17568
  }
15560
- // Paused-bar · open the supplement overlay (add a thought while paused).
15561
- if (e.target.closest("[data-paused-supplement]")) {
15562
- e.preventDefault();
15563
- app.openPausedSupplementOverlay();
15564
- return;
15565
- }
15566
- // Paused-supplement overlay · close / cancel / backdrop.
15567
- if (e.target.closest("[data-paused-supplement-close]")) {
15568
- e.preventDefault();
15569
- app.closePausedSupplementOverlay();
15570
- return;
15571
- }
15572
- // Paused-supplement overlay · confirm.
15573
- if (e.target.closest("[data-paused-supplement-confirm]")) {
15574
- e.preventDefault();
15575
- app.submitPausedSupplement();
15576
- return;
15577
- }
15578
17569
  // Continue · resume the directors after a chair-driven round-end.
15579
17570
  {
15580
17571
  const cont = e.target.closest("[data-continue]");
@@ -15840,7 +17831,12 @@
15840
17831
  if (!configured) {
15841
17832
  // Stale `data-configured` may say 1 if the user added then
15842
17833
  // removed a key without a re-render; live cache wins.
15843
- if (typeof window.openUserSettings === "function") {
17834
+ // First-stop: voice-mode onboarding overlay (themed promo).
17835
+ // Its CTA opens user-settings → MiniMax key row.
17836
+ if (typeof window.openVoiceOnboarding === "function") {
17837
+ window.openVoiceOnboarding();
17838
+ } else if (typeof window.openUserSettings === "function") {
17839
+ // Defensive fallback if the onboarding module fails to load.
15844
17840
  window.openUserSettings({ section: "keys", focusProvider: "minimax" });
15845
17841
  }
15846
17842
  return;
@@ -15987,11 +17983,8 @@
15987
17983
  // ALSO toggle on label clicks, but it skipped checkbox clicks,
15988
17984
  // which left direct-checkbox clicks dead. Owning toggling from
15989
17985
  // the change event is the simpler, correct path.
15990
- if (e.target.closest("[data-composer-pick-done]")) {
15991
- e.preventDefault();
15992
- app.closeComposerDirectorPicker();
15993
- return;
15994
- }
17986
+ // (Done button removed · checkbox toggles take effect immediately;
17987
+ // the picker auto-dismisses on outside click.)
15995
17988
  // Tune dropdown trigger (tone / intensity)
15996
17989
  const ddTrigger = e.target.closest("[data-cmp-dropdown]");
15997
17990
  if (ddTrigger) {
@@ -16065,8 +18058,8 @@
16065
18058
  }
16066
18059
  if (trigger) {
16067
18060
  const valSpan = trigger.querySelector("[data-cmp-dd-value]");
16068
- if (valSpan) {
16069
- valSpan.textContent = v === "zh" ? "中文" : "EN";
18061
+ if (valSpan && window.I18n && typeof window.I18n.t === "function") {
18062
+ valSpan.textContent = window.I18n.t(`locale_${v}`);
16070
18063
  }
16071
18064
  }
16072
18065
  }
@@ -16096,6 +18089,23 @@
16096
18089
  if (Number.isFinite(idx)) app.applyComposerStarter(idx);
16097
18090
  return;
16098
18091
  }
18092
+ // Topic-rec card → apply via /api/topic-recs/:id so the
18093
+ // full seedContext lands in composer state.
18094
+ const composerRec = e.target.closest("[data-cmp-rec]");
18095
+ if (composerRec) {
18096
+ e.preventDefault();
18097
+ const id = composerRec.getAttribute("data-cmp-rec");
18098
+ if (id) app.applyTopicRec(id);
18099
+ return;
18100
+ }
18101
+ // Topic-rec trigger button → start a fresh generation job.
18102
+ if (e.target.closest("[data-cmp-recs-trigger]")) {
18103
+ e.preventDefault();
18104
+ void app.startTopicRecJob();
18105
+ return;
18106
+ }
18107
+ // ("+ N more" pagination removed · tray now always shows
18108
+ // the latest 6 recs and wipes on each fresh generation.)
16099
18109
  // Delete a room (any state): confirm, then real DELETE on the backend.
16100
18110
  const del = e.target.closest("[data-room-delete]");
16101
18111
  if (del) {
@@ -16106,6 +18116,42 @@
16106
18116
  if (id) app.deleteRoom(id);
16107
18117
  return;
16108
18118
  }
18119
+ // Share a saved note · opens the share-card overlay.
18120
+ const noteShare = e.target.closest("[data-note-share]");
18121
+ if (noteShare) {
18122
+ e.preventDefault();
18123
+ e.stopPropagation();
18124
+ const item = noteShare.closest("[data-note-id]");
18125
+ const id = item?.dataset.noteId;
18126
+ if (id) app.openShareCard(id);
18127
+ return;
18128
+ }
18129
+ // Share-card overlay · template chip click swaps the visual.
18130
+ const shareTplBtn = e.target.closest("[data-share-card-template]");
18131
+ if (shareTplBtn) {
18132
+ e.preventDefault();
18133
+ const key = shareTplBtn.getAttribute("data-share-card-template");
18134
+ if (key) app.setShareCardTemplate(key);
18135
+ return;
18136
+ }
18137
+ // Share-card overlay · download.
18138
+ if (e.target.closest("[data-share-card-download]")) {
18139
+ e.preventDefault();
18140
+ app.downloadShareCard();
18141
+ return;
18142
+ }
18143
+ // Share-card overlay · close button.
18144
+ if (e.target.closest("[data-share-card-close]")) {
18145
+ e.preventDefault();
18146
+ app.closeShareCard();
18147
+ return;
18148
+ }
18149
+ // Share-card overlay · backdrop click dismisses (mirrors the
18150
+ // pause-choice / vote-trigger overlay convention).
18151
+ if (e.target.id === "share-card-overlay") {
18152
+ app.closeShareCard();
18153
+ return;
18154
+ }
16109
18155
  // Delete a saved note from the All Notes list. The button is a
16110
18156
  // sibling of the note's anchor (inside .notes-item) so its click
16111
18157
  // never bubbles through the navigation link — but we still
@@ -16136,11 +18182,38 @@
16136
18182
  // submit. Browsers signal this via:
16137
18183
  // · `e.isComposing === true` (standard, all modern browsers)
16138
18184
  // · `e.keyCode === 229` (legacy fallback for old WebKit / Chromium)
16139
- // Either of those means "this Enter belongs to the IME, leave it
16140
- // alone." Without the guard, every pinyin confirmation accidentally
16141
- // sends the message a constant misclick for CJK users.
18185
+ // ··· third case ···
18186
+ // · Safari (and some Chrome builds) fire `compositionend` SLIGHTLY
18187
+ // BEFORE the Enter keydown that confirmed the candidate. By the
18188
+ // time JS reads the keydown, `isComposing` is already false and
18189
+ // `keyCode === 13`, so the first two signals miss it and the
18190
+ // pinyin confirmation accidentally fires submit. We patch this
18191
+ // by tracking the time of the most recent `compositionend` per
18192
+ // input element and treating a keydown within ~120 ms of it as
18193
+ // IME-owned.
18194
+ //
18195
+ // The map is keyed by the input element; the global compositionend
18196
+ // listener stamps the timestamp; the per-input grace window survives
18197
+ // re-renders because the map is GC'd when the element is detached.
18198
+ const _compositionEndAt = new WeakMap();
18199
+ document.addEventListener("compositionend", (ev) => {
18200
+ const t = ev.target;
18201
+ if (t && (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement)) {
18202
+ _compositionEndAt.set(t, Date.now());
18203
+ }
18204
+ }, true);
16142
18205
  function isImeComposing(ev) {
16143
- return !!(ev && (ev.isComposing || ev.keyCode === 229));
18206
+ if (!ev) return false;
18207
+ if (ev.isComposing || ev.keyCode === 229) return true;
18208
+ const t = ev.target;
18209
+ if (t && _compositionEndAt.has(t)) {
18210
+ // 120 ms covers the Safari / Chrome window between
18211
+ // compositionend and the IME-confirm Enter without
18212
+ // affecting genuine post-paste Enter taps (those land
18213
+ // well after the grace window).
18214
+ if (Date.now() - _compositionEndAt.get(t) < 120) return true;
18215
+ }
18216
+ return false;
16144
18217
  }
16145
18218
  document.addEventListener("keydown", (e) => {
16146
18219
  const target = e.target;
@@ -16189,6 +18262,13 @@
16189
18262
  app.autosizeAgentComposerTextarea();
16190
18263
  } else if (e.target && e.target.matches && e.target.matches("[data-search-input]")) {
16191
18264
  app.runSearch(e.target.value);
18265
+ } else if (e.target && e.target.matches && e.target.matches(".ib-textarea[data-send-input]")) {
18266
+ // Room input-bar textarea · autosize as the user types so
18267
+ // the floating card grows up to the max-height cap, then
18268
+ // scrolls internally. No state persistence here · the
18269
+ // input-bar's value is fired-and-cleared on send (unlike
18270
+ // the composer's persistent draft).
18271
+ app.autosizeRoomInputTextarea();
16192
18272
  }
16193
18273
  });
16194
18274
  // Keep agentSpec in sync as the user edits any preview field — guards
@@ -16325,6 +18405,28 @@
16325
18405
  }
16326
18406
  });
16327
18407
 
18408
+ // Input-bar textarea scrollbar reveal · the scrollbar is
18409
+ // permanently hidden via `.ib-textarea { scrollbar-width: none }`
18410
+ // and only flips visible while the user is actively scrolling.
18411
+ // We toggle a `.is-scrolling` class on the SCROLLING element with
18412
+ // a debounced removal so the scrollbar fades back out ~800 ms
18413
+ // after the last scroll event. Scroll events don't bubble, so the
18414
+ // listener is in capture phase to catch them from any textarea
18415
+ // anywhere in the document.
18416
+ const _scrollDecayTimers = new WeakMap();
18417
+ document.addEventListener("scroll", (ev) => {
18418
+ const t = ev.target;
18419
+ if (!t || !(t instanceof HTMLTextAreaElement)) return;
18420
+ if (!t.classList.contains("ib-textarea")) return;
18421
+ t.classList.add("is-scrolling");
18422
+ const prev = _scrollDecayTimers.get(t);
18423
+ if (prev) clearTimeout(prev);
18424
+ _scrollDecayTimers.set(t, setTimeout(() => {
18425
+ t.classList.remove("is-scrolling");
18426
+ _scrollDecayTimers.delete(t);
18427
+ }, 800));
18428
+ }, true);
18429
+
16328
18430
  // Global one-shot listener: unlock audio playback on first user
16329
18431
  // interaction so voice mode works regardless of which button was
16330
18432
  // clicked first.