privateboard 0.1.23 → 0.1.25

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
@@ -93,20 +93,6 @@
93
93
  * miss falls back to the raw voiceId so the row never blocks on
94
94
  * the fetch. */
95
95
  voiceLabels: {},
96
- /** Interest-driven topic recommendations · the home composer's
97
- * "找你可能感兴趣的话题" tray. Always exactly 6 items (or
98
- * fewer if no batch has been generated yet) — every fresh
99
- * generation wipes the previous batch server-side, so
100
- * there's no pagination or history. `loaded` flips true
101
- * after the first `/api/topic-recs` fetch so first-paint
102
- * can show a sensible empty state. `job` is the live
103
- * generation job (id + phase + pct + detail) or null when
104
- * idle. */
105
- topicRecs: {
106
- items: [],
107
- loaded: false,
108
- job: null, // { id, phase, label, pct, detail, eventSource }
109
- },
110
96
  rooms: [],
111
97
  currentRoomId: null,
112
98
  currentRoom: null,
@@ -121,6 +107,16 @@
121
107
  currentChair: null, // chair agent for the current room
122
108
  currentQueue: [],
123
109
  voiceQueues: {},
110
+ /** Cross-director pipeline · pre-warmed messages whose chat
111
+ * bubble should be HIDDEN (data-prewarmed) until their audio
112
+ * starts playing. Set membership is the source of truth so the
113
+ * attribute survives any `renderChat()` re-render — without this,
114
+ * message-updated SSE (or brief-update / round-ended) wipes the
115
+ * attribute and the still-pre-warmed bubble flashes its streaming
116
+ * animation alongside the current speaker. Populated in
117
+ * appendMessageDom, drained in _revealPrewarmedBubble, baked into
118
+ * messageHtml so every render preserves the hidden state. */
119
+ _prewarmedHidden: new Set(),
124
120
  /** Mini-player anchor · when set, the cross-room bar persists
125
121
  * for this voice room throughout its whole live/paused life
126
122
  * (between speakers, during votes / clarify, idle phases) —
@@ -586,15 +582,28 @@
586
582
  closeBtn.addEventListener("click", dismiss, { once: true });
587
583
  },
588
584
 
589
- /** Refetch /api/keys and update the local cache. Called by
590
- * user-settings on close so the requireModelKey gate sees the
591
- * user's just-configured keys without a full page reload. */
585
+ /** Refetch /api/keys + /api/credentials + the /api/models cache.
586
+ * Called by user-settings on close so the requireModelKey gate
587
+ * sees the user's just-configured credentials without a page
588
+ * reload. `this.keys` covers voice + skill rows;
589
+ * `window.keysStore.llmCredentials` covers LLM credentials. */
592
590
  async refreshKeys() {
593
591
  try {
594
592
  const r = await fetch("/api/keys");
595
- if (!r.ok) return;
596
- const j = await r.json();
597
- this.keys = Object.fromEntries((j.keys || []).map((k) => [k.provider, k]));
593
+ if (r.ok) {
594
+ const j = await r.json();
595
+ this.keys = Object.fromEntries((j.keys || []).map((k) => [k.provider, k]));
596
+ }
597
+ } catch (e) { /* ignore */ }
598
+ try {
599
+ if (window.keysStore && typeof window.keysStore.fetchLlmCredentials === "function") {
600
+ await window.keysStore.fetchLlmCredentials();
601
+ }
602
+ } catch (e) { /* ignore */ }
603
+ try {
604
+ if (typeof window.boardroomModelsRefresh === "function") {
605
+ await window.boardroomModelsRefresh();
606
+ }
598
607
  } catch (e) { /* ignore */ }
599
608
  },
600
609
 
@@ -788,6 +797,12 @@
788
797
 
789
798
  // ── Room lifecycle ────────────────────────────────────────
790
799
  async openRoom(roomId) {
800
+ // Close the @-mention picker if open · its director list is
801
+ // scoped to the previous room and would point at a stale cast
802
+ // mid-navigation. Idempotent · no-op when already closed.
803
+ if (window.MentionPicker && typeof window.MentionPicker.close === "function") {
804
+ window.MentionPicker.close();
805
+ }
791
806
  // Hand off SSE to a background tracker BEFORE closing the
792
807
  // foreground · openRoom is the room-to-room nav path
793
808
  // (closeRoom only fires when going back to "no room") so
@@ -896,6 +911,11 @@
896
911
  this.agentSpec = null;
897
912
  this.agentSpecGenerating = false;
898
913
  this.agentSpecError = null;
914
+ // Cross-director pre-warm hidden set · scope is per-room.
915
+ // Carrying over from a prior room would mark messages in the
916
+ // new room as hidden if message IDs collide (rare but
917
+ // defensive). Empty on every room open.
918
+ this._prewarmedHidden = new Set();
899
919
  // Entering a room silences the agent-build ambient (the user
900
920
  // has shifted focus away from the agent composer). If a
901
921
  // build is still running, the sidebar Building row remains
@@ -1248,6 +1268,17 @@
1248
1268
  roundNum: data.roundNum || 1,
1249
1269
  createdAt: data.createdAt,
1250
1270
  });
1271
+ // Client-side LLM first-token watchdog · mirrors the TTS
1272
+ // chunk-arrival watchdog in mechanism but for the LLM stream
1273
+ // itself. Streaming agent message just landed · start an 8s
1274
+ // timer; if no message-token arrives in that window, flip
1275
+ // _localPhase to "llm-warming" so the round-table bubble's
1276
+ // status word changes to "Warming up" and the user sees the
1277
+ // model is still working (not just frozen). Server-side 60s
1278
+ // hard cap will eventually auto-skip if it really hung.
1279
+ if (data.authorKind === "agent" && data.meta && data.meta.streaming === true) {
1280
+ this._startLlmFirstTokenWatchdog(data.messageId);
1281
+ }
1251
1282
  // Chair-pending placeholder · clear the moment ANY agent
1252
1283
  // message lands (chair OR director). The placeholder is a
1253
1284
  // stand-in while the chair did silent server-side work
@@ -1258,7 +1289,26 @@
1258
1289
  // OR a director turn — only the chair-restricted clear left
1259
1290
  // the placeholder lingering when the picker decided no
1260
1291
  // intervention was needed.
1261
- if (data.authorKind === "agent") {
1292
+ // Hide chair-pending on agent message-appended · BUT keep
1293
+ // it visible when the incoming message is a TEMPLATED chair
1294
+ // vote message (round-prompt / round-end / intervention) in
1295
+ // voice mode. Those have meta.streaming === false (the body
1296
+ // is template-built, not LLM-streamed), so neither the
1297
+ // streaming-message path nor the message-token path lights
1298
+ // up the chair seat — there'd be a 0.5-2s dark window
1299
+ // between message-appended and first voice-chunk. Keep
1300
+ // chair-pending visible during that window; the voice-chunk
1301
+ // handler clears _chairVoiceAwaiting and the next render
1302
+ // takes over from there.
1303
+ const isTemplatedChairVote = !!(
1304
+ data.authorKind === "agent"
1305
+ && this.currentChair && data.authorId === this.currentChair.id
1306
+ && data.meta
1307
+ && data.meta.streaming === false
1308
+ && (data.meta.kind === "round-prompt" || data.meta.kind === "round-end" || data.meta.kind === "intervention")
1309
+ && this.currentRoom && this.currentRoom.deliveryMode === "voice"
1310
+ );
1311
+ if (data.authorKind === "agent" && !isTemplatedChairVote) {
1262
1312
  this.hideChairPending();
1263
1313
  }
1264
1314
  // Chair vote-trigger message · register it as awaiting voice
@@ -1407,6 +1457,14 @@
1407
1457
  if (!msg) return;
1408
1458
  const wasEmpty = !String(msg.body || "").trim();
1409
1459
  msg.body += data.delta;
1460
+ // First token landed · disarm the first-token watchdog and
1461
+ // drop the local "warming" phase if it was set. Cheap no-op
1462
+ // when the watchdog was never armed for this message (e.g.
1463
+ // user-authored message, system message).
1464
+ this._clearLlmFirstTokenWatchdog(data.messageId);
1465
+ if (msg.meta && msg.meta._localPhase === "llm-warming") {
1466
+ delete msg.meta._localPhase;
1467
+ }
1410
1468
  // Mirror the body onto any voiceQueue for this message so
1411
1469
  // the cross-room mini-player can still show the "lyrics"
1412
1470
  // line after the user navigates away (SSE disconnects, but
@@ -1441,6 +1499,22 @@
1441
1499
  msg.meta = msg.meta || {};
1442
1500
  msg.meta.streaming = false;
1443
1501
  msg.meta.speakerStatus = "final";
1502
+ if (msg.meta._localPhase === "llm-warming") delete msg.meta._localPhase;
1503
+ }
1504
+ // Stream finished · disarm the first-token watchdog as a
1505
+ // safety net (normally cleared by the first message-token,
1506
+ // but a stream that errors without a single token still
1507
+ // emits message-final, so this catches it).
1508
+ this._clearLlmFirstTokenWatchdog(data.messageId);
1509
+ // Reveal hidden pre-warmed bubble · if this message had NO
1510
+ // voice queue (text-mode rooms, OR voice-mode rooms where TTS
1511
+ // failed silently for every sentence so no voice-chunk ever
1512
+ // arrived), _fireVoiceDone's promote path can't reveal it.
1513
+ // The voice path (queued → promoted on prior audio.ended)
1514
+ // still owns reveals when a queue exists; this branch is the
1515
+ // safety net for "LLM said something but audio never came".
1516
+ if (!this.voiceQueues || !this.voiceQueues[data.messageId]) {
1517
+ this._revealPrewarmedBubble(data.messageId);
1444
1518
  }
1445
1519
  // Chair clarify · in voice mode, the chair's clarifying
1446
1520
  // question just finished streaming. Pin the question text to
@@ -1467,7 +1541,14 @@
1467
1541
  // renderRtSubtitle's fallback branch); in text mode it
1468
1542
  // hides immediately since no voice clip is queued.
1469
1543
  this.renderRtSubtitle();
1470
- this.updateMessageBodyDom(data.messageId, msg ? msg.body : "", false);
1544
+ // Keep the chat bubble's streaming pulse animation alive
1545
+ // while TTS audio is still playing for this message. LLM
1546
+ // text done ≠ speaker done · the user is still hearing the
1547
+ // sentence. _fireVoiceDone explicitly clears the class when
1548
+ // audio actually ends. In text-mode rooms (no voice queue)
1549
+ // this collapses to the original `false` behaviour.
1550
+ const ttsStillActive = !!(this.voiceQueues && this.voiceQueues[data.messageId]);
1551
+ this.updateMessageBodyDom(data.messageId, msg ? msg.body : "", ttsStillActive);
1471
1552
  // A director just stopped streaming → the manual round-end
1472
1553
  // button may have flipped from disabled to enabled, and the
1473
1554
  // auto-continue countdown may now be eligible to start.
@@ -1480,6 +1561,10 @@
1480
1561
  const msg = this.currentMessages.find((m) => m.id === data.messageId);
1481
1562
  if (msg) msg.meta = { ...(msg.meta || {}), error: data.message };
1482
1563
  this.updateMessageBodyDom(data.messageId, msg ? msg.body : `[error: ${data.message}]`, false);
1564
+ // Reveal hidden pre-warmed bubble · the LLM stream failed for
1565
+ // this speaker, no audio will play, so the user needs to see
1566
+ // the error message in chat rather than have it stay hidden.
1567
+ this._revealPrewarmedBubble(data.messageId);
1483
1568
  });
1484
1569
 
1485
1570
  this.sse.addEventListener("message-removed", (e) => {
@@ -1487,10 +1572,14 @@
1487
1572
  // Stop any voice playback for this message (e.g. READY control token)
1488
1573
  const vq = this.voiceQueues[data.messageId];
1489
1574
  if (vq) {
1575
+ if (vq.watchdogId) { try { clearInterval(vq.watchdogId); } catch(_) {} vq.watchdogId = null; }
1490
1576
  if (vq.audio) { try { vq.audio.pause(); } catch(_) {} }
1491
1577
  delete this.voiceQueues[data.messageId];
1492
1578
  this.refreshMiniPlayer();
1493
1579
  }
1580
+ // Also disarm first-token watchdog · message gone means no
1581
+ // further token-arrival signal matters.
1582
+ this._clearLlmFirstTokenWatchdog(data.messageId);
1494
1583
  // Drop the empty placeholder bubble.
1495
1584
  this.currentMessages = this.currentMessages.filter((m) => m.id !== data.messageId);
1496
1585
  const article = document.querySelector(`[data-message-id="${data.messageId}"]`);
@@ -1523,6 +1612,16 @@
1523
1612
  if (fresh && this._chairVoiceAwaiting) {
1524
1613
  this._chairVoiceAwaiting.delete(data.messageId);
1525
1614
  }
1615
+ // Chair templated voice has actually started · NOW hide the
1616
+ // chair-pending placeholder (we deferred it earlier in the
1617
+ // message-appended handler for templated chair vote messages
1618
+ // so the user wasn't staring at a dark chair seat for the
1619
+ // 0.5-2s window between message-appended and first voice-
1620
+ // chunk). The voice queue will take over rendering via the
1621
+ // playing-queue check in renderRoundTable.
1622
+ if (fresh && this.chairPending) {
1623
+ this.hideChairPending();
1624
+ }
1526
1625
  // Chair gavel SFX · fire ONCE when fresh chair voice starts
1527
1626
  // streaming, so the user hears the courtroom "knock-knock"
1528
1627
  // calling for attention before the chair speaks. Detection:
@@ -1578,6 +1677,34 @@
1578
1677
  this.drainVoiceQueue(roomId, data.messageId);
1579
1678
  });
1580
1679
 
1680
+ // TTS billing failure · MiniMax insufficient-balance / ElevenLabs
1681
+ // out-of-credits / paid-plan-required gates. Server-side TTS
1682
+ // streamers catch the tagged error and forward this event so the
1683
+ // user gets an actionable overlay (with a top-up CTA) instead of
1684
+ // silent failure. `openPaidOverlay` is itself idempotent (it
1685
+ // no-ops when the overlay node is already in the DOM), so this
1686
+ // handler can stay dumb · repeated events in a round just hit
1687
+ // the early return inside the overlay function · no flashing.
1688
+ this.sse.addEventListener("voice-error", (e) => {
1689
+ try {
1690
+ const data = JSON.parse(e.data);
1691
+ if (!data || data.code !== "paid-plan-required") return;
1692
+ if (window.AgentProfileVoice && typeof window.AgentProfileVoice.openPaidOverlay === "function") {
1693
+ window.AgentProfileVoice.openPaidOverlay({
1694
+ provider: data.provider || "",
1695
+ upgradeUrl: data.upgradeUrl || "",
1696
+ message: data.message || "",
1697
+ });
1698
+ } else if (data.message) {
1699
+ // Fallback if the overlay module didn't load · still tell
1700
+ // the user what happened rather than failing silently.
1701
+ alert(data.message + (data.upgradeUrl ? ("\n\n" + data.upgradeUrl) : ""));
1702
+ }
1703
+ } catch (err) {
1704
+ console.warn("[voice-error] handler failed:", err);
1705
+ }
1706
+ });
1707
+
1581
1708
  // Full body+meta replacement · used by tool-use rows whose
1582
1709
  // status flips running → done|failed once the side-effect
1583
1710
  // (URL fetch) completes. We re-render the affected message in
@@ -2249,6 +2376,14 @@
2249
2376
  // LLM startup). Show a transient placeholder so the user has
2250
2377
  // visible feedback until the real chair bubble arrives.
2251
2378
  this.showChairPending(payload?.phase || "");
2379
+ } else if (kind === "auto-skipped") {
2380
+ // Server-side timeout fired (TTS / LLM / picker / clarify)
2381
+ // and the orchestrator auto-recovered. Surface a 3s toast
2382
+ // so the user understands WHY the room jumped forward. The
2383
+ // skip itself has already happened — this is purely the
2384
+ // notification. messageId in payload (when present) is the
2385
+ // target message id; reserved for future per-bubble hints.
2386
+ this._showAutoSkipToast(payload?.phase || "", payload?.reason || "");
2252
2387
  } else if (kind === "room-opened") {
2253
2388
  // no-op: we already have full state
2254
2389
  } else if (kind === "auto-pick-started") {
@@ -2517,7 +2652,7 @@
2517
2652
  },
2518
2653
 
2519
2654
  // ── Actions ───────────────────────────────────────────────
2520
- async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode, seedContext }) {
2655
+ async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode }) {
2521
2656
  const r = await fetch("/api/rooms", {
2522
2657
  method: "POST",
2523
2658
  headers: { "content-type": "application/json" },
@@ -2529,11 +2664,6 @@
2529
2664
  briefStyle: briefStyle || "auto",
2530
2665
  deliveryMode: deliveryMode === "voice" ? "voice" : "text",
2531
2666
  ...(autoPick ? { autoPick: true } : {}),
2532
- // seedContext · attached when the user opened this room
2533
- // from a topic-rec card. Backend writes it to the
2534
- // opening message's meta so the chair grounds clarify
2535
- // in the actual source snippets.
2536
- ...(seedContext ? { seedContext } : {}),
2537
2667
  }),
2538
2668
  });
2539
2669
  if (!r.ok) {
@@ -2661,9 +2791,24 @@
2661
2791
  // If a director is mid-turn AND we don't already have a queued
2662
2792
  // message, ask the user how to proceed.
2663
2793
  if (this.isAgentSpeaking() && !this.pendingUserMessage) {
2664
- this.openSendChoiceModal(text);
2794
+ // Drain @-mentions NOW so the choice modal can carry them
2795
+ // along to interrupt / queue · waiting until handleSendChoice
2796
+ // would be too late (the textarea is cleared by then, and
2797
+ // the mention-picker filters by handle-still-present-in-text).
2798
+ const midTurnMentions = (window.MentionPicker && typeof window.MentionPicker.consumePendingMentions === "function")
2799
+ ? window.MentionPicker.consumePendingMentions(text)
2800
+ : [];
2801
+ this.openSendChoiceModal(text, midTurnMentions);
2665
2802
  return true;
2666
2803
  }
2804
+ // Drain any pending @-mentions the user picked via the
2805
+ // mention-picker overlay. The picker filters out handles the
2806
+ // user later backspaced out, so the resulting id list is
2807
+ // already consistent with the text body. Falls back to an
2808
+ // empty array when mention-picker.js hasn't loaded yet.
2809
+ const mentions = (window.MentionPicker && typeof window.MentionPicker.consumePendingMentions === "function")
2810
+ ? window.MentionPicker.consumePendingMentions(text)
2811
+ : [];
2667
2812
  input.value = "";
2668
2813
  // Shrink the textarea back to its single-line baseline ·
2669
2814
  // typed content may have grown the field, and the cleared
@@ -2673,7 +2818,7 @@
2673
2818
  if (input.matches?.(".ib-textarea[data-send-input]")) {
2674
2819
  this.autosizeRoomInputTextarea();
2675
2820
  }
2676
- this.sendMessage(text).catch((err) => alert("Send failed: " + err.message));
2821
+ this.sendMessage(text, mentions).catch((err) => alert("Send failed: " + err.message));
2677
2822
  return true;
2678
2823
  },
2679
2824
 
@@ -2894,20 +3039,25 @@
2894
3039
  } catch { /* private-mode etc. — silently ignore */ }
2895
3040
  },
2896
3041
 
2897
- /** Pure cache read · returns true when the local app.keys map
2898
- * reports any model provider as configured. Used by the gate
2899
- * and (via wrappers) anywhere downstream UI needs the answer
2900
- * cheaply.
2901
- *
2902
- * Mirror the server-side LLM_PROVIDERS set (`routes/keys.ts:47`).
2903
- * Without B.AI in this list, a user who configured ONLY a B.AI
2904
- * key gets bounced to "configure an API key first" on every
2905
- * pre-flight gate (topic-recs trigger, convene a room, etc.)
2906
- * even though every model with a baiId is fully reachable. */
3042
+ /** True iff the user has at least one LLM credential AND one is
3043
+ * flagged active. LLM credentials live in their own table
3044
+ * (multi-instance, see `/api/credentials`) they're NOT in
3045
+ * `this.keys`, which only tracks voice + skill keys from
3046
+ * `/api/keys`. Read through the shared `/api/models` cache so
3047
+ * the server-side authority (active credential → reachable
3048
+ * provider) wins; fall back to the local keys-store when the
3049
+ * models cache hasn't loaded yet (cold start). */
2907
3050
  hasAnyModelKey() {
2908
- const MODEL_PROVIDERS = ["anthropic", "openai", "google", "xai", "deepseek", "openrouter", "bai"];
2909
- const keys = this.keys || {};
2910
- return MODEL_PROVIDERS.some((p) => keys[p] && keys[p].configured);
3051
+ try {
3052
+ const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
3053
+ if (cache && typeof cache.hasAnyKey === "boolean") return cache.hasAnyKey;
3054
+ } catch (_) { /* */ }
3055
+ try {
3056
+ const creds = (window.keysStore && Array.isArray(window.keysStore.llmCredentials))
3057
+ ? window.keysStore.llmCredentials : [];
3058
+ const activeId = window.keysStore ? window.keysStore.activeLlmCredentialId : null;
3059
+ return creds.length > 0 && !!activeId && creds.some((c) => c.id === activeId);
3060
+ } catch (_) { return false; }
2911
3061
  },
2912
3062
 
2913
3063
  /** Pure cache read · true when at least one voice provider
@@ -2988,8 +3138,12 @@
2988
3138
  /** Modal · "an agent is speaking · interrupt or wait?". Mirrors the
2989
3139
  * pause-choice overlay's chrome (.pc-overlay / .pc-modal) so the
2990
3140
  * visual treatment is consistent across modal prompts. */
2991
- openSendChoiceModal(text) {
3141
+ openSendChoiceModal(text, mentions) {
2992
3142
  this.closeSendChoiceModal();
3143
+ // Stash mentions alongside text so handleSendChoice forwards
3144
+ // them on interrupt / queue. Falls back to [] when called from
3145
+ // legacy / non-mention paths.
3146
+ this._sendChoiceMentions = Array.isArray(mentions) ? mentions.slice() : [];
2993
3147
  const speaker = this.currentQueue[0]
2994
3148
  ? this.agentsById[this.currentQueue[0].agentId]
2995
3149
  : null;
@@ -3036,10 +3190,12 @@
3036
3190
  if (el) el.remove();
3037
3191
  this._sendChoiceText = null;
3038
3192
  this._sendChoiceSpeakerId = null;
3193
+ this._sendChoiceMentions = null;
3039
3194
  },
3040
3195
  handleSendChoice(choice) {
3041
3196
  const text = this._sendChoiceText || "";
3042
3197
  const speakerId = this._sendChoiceSpeakerId;
3198
+ const mentions = Array.isArray(this._sendChoiceMentions) ? this._sendChoiceMentions.slice() : [];
3043
3199
  const input = document.querySelector('.input-bar input, [data-send-input]');
3044
3200
  this.closeSendChoiceModal();
3045
3201
  if (choice === "cancel" || !text.trim()) return;
@@ -3047,7 +3203,7 @@
3047
3203
  input.value = "";
3048
3204
  }
3049
3205
  if (choice === "interrupt") {
3050
- this.sendMessage(text).catch((err) => alert("Send failed: " + err.message));
3206
+ this.sendMessage(text, mentions).catch((err) => alert("Send failed: " + err.message));
3051
3207
  return;
3052
3208
  }
3053
3209
  if (choice === "queue") {
@@ -3063,7 +3219,7 @@
3063
3219
  // turns, AFTER current speaker finishes and BEFORE the next
3064
3220
  // speaker starts. The placeholder clears when the
3065
3221
  // message-appended SSE comes back.
3066
- this.sendMessage(text, [], "after-speaker").catch((err) => {
3222
+ this.sendMessage(text, mentions, "after-speaker").catch((err) => {
3067
3223
  alert("Queue failed: " + err.message);
3068
3224
  this.pendingUserMessage = null;
3069
3225
  this.pendingForSpeakerId = null;
@@ -6616,19 +6772,10 @@
6616
6772
  const raw = localStorage.getItem("boardroom.composer");
6617
6773
  if (raw) saved = JSON.parse(raw);
6618
6774
  } catch { /* ignore */ }
6619
- // seedContext · pre-fetched web snippets the user attached
6620
- // by clicking a topic-rec card on the home composer. Round-
6621
- // tripped through localStorage so a page refresh between
6622
- // pick and convene doesn't lose the attached source
6623
- // material. Shape: { topicRecId?: string, snippets?: [] }.
6624
- const savedSeed = saved && typeof saved.seedContext === "object" && saved.seedContext
6625
- ? saved.seedContext
6626
- : null;
6627
6775
  this.composerState = {
6628
6776
  ...this.DEFAULT_COMPOSER,
6629
6777
  ...(saved || {}),
6630
6778
  subject: (saved && typeof saved.subject === "string") ? saved.subject : "",
6631
- seedContext: savedSeed,
6632
6779
  };
6633
6780
  return this.composerState;
6634
6781
  },
@@ -6636,10 +6783,10 @@
6636
6783
  saveComposerState() {
6637
6784
  if (!this.composerState) return;
6638
6785
  try {
6639
- const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject, seedContext } = this.composerState;
6786
+ const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject } = this.composerState;
6640
6787
  localStorage.setItem(
6641
6788
  "boardroom.composer",
6642
- JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject, seedContext: seedContext || null }),
6789
+ JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject }),
6643
6790
  );
6644
6791
  } catch { /* ignore */ }
6645
6792
  },
@@ -6682,15 +6829,6 @@
6682
6829
  // overlay — typing in this view + Enter creates the room.
6683
6830
  const head = document.querySelector("[data-room-head]");
6684
6831
  if (head) head.innerHTML = ""; // CSS hides via html.no-room
6685
- // One-shot lazy fetch · the composer's "or try a starter"
6686
- // tray renders the latest topic recommendations when any
6687
- // exist (replacing the legacy hardcoded starters). Skipping
6688
- // when already loaded keeps re-renders cheap; the trigger
6689
- // button's SSE path explicitly refreshes after a successful
6690
- // generation so the list lands without polling.
6691
- if (!this.topicRecs.loaded) {
6692
- void this.refreshTopicRecs();
6693
- }
6694
6832
 
6695
6833
  // Strip stale follow-up fragments inserted by renderFollowUp-
6696
6834
  // Fragments() when a follow-up room was previously open. These
@@ -6789,7 +6927,7 @@
6789
6927
  * centred. Called after composer render and on window resize.
6790
6928
  *
6791
6929
  * The 0.7 threshold (was "strictly > viewport") catches the
6792
- * in-between case where the input + ~6 topic-rec cards fit
6930
+ * in-between case where the input + scenario ad cards fit
6793
6931
  * vertically but only just — centred layout would visually
6794
6932
  * cram the hero against the top edge. Flipping to overflow
6795
6933
  * mode at 70% gives the hero comfortable breathing room
@@ -7681,17 +7819,23 @@
7681
7819
  sparkles + horizon ticks + CSS scanlines (in CSS).
7682
7820
  Hidden in is-initial via opacity. -->
7683
7821
  <div class="search-results-deco">${RESULTS_DECO_SVG}</div>
7684
- <!-- Hero · longer wordmark + mono subline. Only visible
7685
- in .is-initial · faded + collapsed via CSS once
7686
- .has-results lands. -->
7822
+ <!-- Hero · matches the new-room composer's two-row header ·
7823
+ a mono caps greeting (cmp-greet · "Good afternoon, Kay")
7824
+ above the headline. The old "across every room ..." sub-
7825
+ line moved INSIDE the search-card's topbar so the card
7826
+ itself surfaces its scope, matching .cmp-input-frame. -->
7687
7827
  <div class="search-hero">
7828
+ <div class="cmp-greet">${this.escape(this.composerGreeting(this.composerLanguage(), (this.prefs?.name || "you").trim() || "you"))}</div>
7688
7829
  <h1 class="search-hero-title">Search every conversation</h1>
7689
- <p class="search-hero-sub">across every room · keyword · message body · room name</p>
7690
7830
  </div>
7691
- <!-- Card · is-initial = standalone framed input with
7692
- internal toolbar; has-results = flex row with the
7693
- meta beside it, toolbar hidden. -->
7831
+ <!-- Card · is-initial = framed input with a brand-tinted
7832
+ topbar hosting the scope hint, then the input below;
7833
+ has-results = flex row with the meta beside it, topbar
7834
+ collapsed via CSS. Mirrors new-room .cmp-input-frame. -->
7694
7835
  <div class="search-card${lastQuery ? "" : " is-empty"}" data-search-card>
7836
+ <div class="search-topbar">
7837
+ <span class="search-topbar-hint">across every room · keyword · message body · room name</span>
7838
+ </div>
7695
7839
  <div class="search-input-wrap">
7696
7840
  <span class="search-input-icon" aria-hidden="true">
7697
7841
  <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
@@ -9159,6 +9303,10 @@
9159
9303
  this.agentSpec = null;
9160
9304
  this.agentSpecGenerating = false;
9161
9305
  }
9306
+ // Scenario placeholder hint is per-visit · drop it on any
9307
+ // composer-mode change so the next render shows the default
9308
+ // framing copy unless the user clicks a scenario again.
9309
+ this._scenarioPlaceholder = null;
9162
9310
  // Make sure the room main view is the visible one — otherwise
9163
9311
  // our composer would render into a hidden container. Three
9164
9312
  // possible "previous" states: agent profile, all-reports, or a
@@ -9235,6 +9383,15 @@
9235
9383
  * was retired at the user's request — it was a CTA-style nudge
9236
9384
  * redundant with the heading question above the textarea. */
9237
9385
  _composerSubjectPlaceholder() {
9386
+ // Scenario cards stash their starter subject here when
9387
+ // clicked · we surface it as the placeholder so the user
9388
+ // sees the suggested framing without it overwriting any
9389
+ // draft they've already typed. Cleared on submit and on
9390
+ // composer-mode switch so it doesn't follow the user
9391
+ // across sessions / view changes.
9392
+ if (typeof this._scenarioPlaceholder === "string" && this._scenarioPlaceholder.length > 0) {
9393
+ return this._scenarioPlaceholder;
9394
+ }
9238
9395
  return "Convening a room can stress-test a thesis, force a decision you've been avoiding, or surface the angle nobody on your real team brings.";
9239
9396
  },
9240
9397
 
@@ -9435,8 +9592,6 @@
9435
9592
  placeholder: this._composerSubjectPlaceholder(),
9436
9593
  convene: "Convene",
9437
9594
  tuneLabel: "tune",
9438
- starterLabel: "starter",
9439
- starterCaption: "or try a starter",
9440
9595
  pickerLabel: "Pick directors",
9441
9596
  directorsLabel: (n) => `${n} director${n === 1 ? "" : "s"}`,
9442
9597
  directorsAdd: "add",
@@ -9494,92 +9649,22 @@
9494
9649
  // unmistakeably a select) without taking up visual real estate.
9495
9650
  const toneLbl = this._t("cmp_tone_label");
9496
9651
  const intensityLbl = this._t("cmp_intensity_label");
9497
- // Starter grid · 2-col responsive cards. Two parallel
9498
- // pools render in the same `.cmp-starters-grid`:
9499
- // 1. Topic recommendations (newest-first, paged, visible
9500
- // list, capped at 5 in the tray). Each card carries a
9501
- // synthesiser-generated tag (e.g. "strategy") in the
9502
- // left column. Clicking populates the composer with
9503
- // the rec's subject + attaches its seedContext
9504
- // snippets so the room opens grounded in the source
9505
- // material.
9506
- // 2. Legacy hardcoded starters from window.BOARDROOM_STARTERS.
9507
- // Show only when there are no recommendations yet (or
9508
- // when recs are still loading on first paint) so a
9509
- // brand-new user sees something useful in the tray.
9510
- const recs = (this.topicRecs.items || []).slice(0, 5);
9511
- // Card markup lives in `topicRecCardHtml` so the
9512
- // initial render path and any later append paths can't
9513
- // drift in shape / attributes.
9514
- const recCards = recs.map((rec) => this.topicRecCardHtml(rec)).join("");
9515
-
9516
- const showLegacyStarters = recs.length === 0;
9517
- const starters = showLegacyStarters && Array.isArray(window.BOARDROOM_STARTERS)
9518
- ? window.BOARDROOM_STARTERS
9519
- : [];
9520
- const starterCards = starters.map((q, idx) => {
9521
- const tag = (q.tag || "").replace(/^\/\/\s*/, "");
9522
- return `
9523
- <button type="button" class="cmp-starter" data-composer-starter="${idx}">
9524
- <div class="cmp-starter-tag">${this.escape(tag)}</div>
9525
- <div class="cmp-starter-text">${this.escape(q.text || "")}</div>
9526
- <div class="cmp-starter-arrow">→</div>
9527
- </button>
9528
- `;
9529
- }).join("");
9530
- // Trigger card · disguised as the first row in the
9531
- // starters grid, so the "recommend" action lives in the
9532
- // same visual rhythm as the suggestions it produces. The
9533
- // card has TWO states wired through the same DOM hooks
9534
- // ([data-trec-label] / [data-trec-detail] / [data-trec-pct]
9535
- // / [data-trec-bar]) so the SSE handler can patch them
9536
- // in place without re-rendering the whole composer:
9537
- // · IDLE (no job) · tag = "✦ discover", text = the
9538
- // localised label (EN/ZH via i18n), arrow = "+".
9539
- // Looks like a starter but with a lime accent +
9540
- // dashed bottom rule.
9541
- // · BUSY (job live) · tag = phase label (LLM-emitted,
9542
- // stays English — those come from the orchestrator's
9543
- // phaseLabels constant), text = animated dots +
9544
- // phase detail, arrow = "N%". A thin lime progress
9545
- // bar lives along the bottom edge and fills as the
9546
- // pipeline ticks.
9652
+ // Scenario ad cards · 5 hand-picked use-cases that double
9653
+ // as long-form promotional units AND one-click room
9654
+ // presets. Each card autoconfigures tone + intensity + a
9655
+ // sensible 3-director cast, then drops a starter subject
9656
+ // into the composer so the user lands on a working room
9657
+ // shape instead of staring at a blank form.
9547
9658
  //
9548
- // The trigger LABEL is i18n'd (cmp_recs_trigger_label)
9549
- // because it's user-facing prompt copy, parallel to
9550
- // `cmp_prompt` and the time-of-day greeting above. The
9551
- // tag column ("discover") and starting placeholder are
9552
- // also keyed so locale switches flip them together.
9553
- const job = this.topicRecs.job;
9554
- const triggerBusy = !!job;
9555
- const triggerLabel = this._t("cmp_recs_trigger_label");
9556
- const triggerTag = this._t("cmp_recs_trigger_tag");
9557
- const triggerStarting = this._t("cmp_recs_trigger_starting");
9558
- const triggerCard = `
9559
- <button type="button"
9560
- class="cmp-starter cmp-recs-trigger-card${triggerBusy ? " is-busy" : ""}"
9561
- data-cmp-recs-trigger
9562
- data-topic-rec-progress
9563
- ${triggerBusy ? "disabled" : ""}
9564
- title="${this.escape(triggerLabel)}">
9565
- <div class="cmp-starter-tag" data-trec-label>${this.escape(triggerBusy ? (job.label || triggerStarting) : `✦ ${triggerTag}`)}</div>
9566
- <div class="cmp-starter-text">
9567
- ${triggerBusy
9568
- ? `<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>`
9569
- : `<span>${this.escape(triggerLabel)}</span>`}
9570
- </div>
9571
- <div class="cmp-starter-arrow" data-trec-pct>${this.escape(triggerBusy ? (job.pct ? `${job.pct}%` : "·") : "+")}</div>
9572
- <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>
9573
- </button>
9574
- `;
9575
-
9576
- // Trigger card always leads; suggestions (or legacy
9577
- // starters as empty-state fallback) follow.
9578
- const trayCards = triggerCard + (recs.length > 0 ? recCards : starterCards);
9579
- // Pagination is intentionally OFF — the server keeps only
9580
- // the latest batch (6 rows). Every fresh generation wipes
9581
- // the previous batch, so there's never "older" data to
9582
- // page back to. The "+ N more" surface is gone.
9659
+ // Card model (kept in one place so copy + visuals + click
9660
+ // behaviour stay aligned · see this.SCENARIO_CARDS):
9661
+ // { id, icon, tag, headline, body, tonePill, directors,
9662
+ // starterSubject, tone, intensity }
9663
+ // The button is a single click target; the inner avatar
9664
+ // strip uses [data-agent-profile] (handled in capture by
9665
+ // agent-profile.js) so users can still inspect a director
9666
+ // without launching the scenario.
9667
+ const scenarioCards = this.scenarioAdCardsHtml();
9583
9668
 
9584
9669
  return `
9585
9670
  <section class="cmp">
@@ -9672,13 +9757,13 @@
9672
9757
  </div>
9673
9758
  </div>
9674
9759
 
9675
- <div class="cmp-starters">
9760
+ <div class="cmp-scenarios">
9676
9761
  <div class="cmp-starters-rule">
9677
9762
  <span class="cmp-starters-rule-line"></span>
9678
- <span class="cmp-starters-rule-label">${this.escape(t.starterCaption)}</span>
9763
+ <span class="cmp-starters-rule-label">${this.escape(this._t("cmp_scenarios_caption"))}</span>
9679
9764
  <span class="cmp-starters-rule-line"></span>
9680
9765
  </div>
9681
- <div class="cmp-starters-grid">${trayCards}</div>
9766
+ <div class="cmp-scenarios-stack">${scenarioCards}</div>
9682
9767
  </div>
9683
9768
  </section>
9684
9769
  `;
@@ -9785,19 +9870,16 @@
9785
9870
  }
9786
9871
  },
9787
9872
 
9788
- /** Tiny route badge for a ModelAvailability row from /api/models.
9789
- * Returns "" when neither route works (caller shouldn't render
9790
- * this row anyway), "direct" / "OR" alone, or "direct · OR" when
9791
- * both are reachable. Mirrors the badges in user-settings.js
9792
- * Available-models block so the visual vocabulary stays consistent
9793
- * across pickers. */
9794
- modelRouteBadge(m) {
9795
- const d = !!(m && m.routes && m.routes.direct);
9796
- const o = !!(m && m.routes && m.routes.openrouter);
9797
- if (d && o) return "direct · OR";
9798
- if (d) return "direct";
9799
- if (o) return "OR";
9800
- return "";
9873
+ /** Tiny route caption · returns "via {provider}" when the active
9874
+ * LLM provider is a multi-model carrier (openrouter / bai), or
9875
+ * "" when single-model active (the per-group header already
9876
+ * carries the provider identity, no caption needed). */
9877
+ modelRouteCaption() {
9878
+ const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
9879
+ const active = cache && cache.activeLlmProvider;
9880
+ const cls = cache && cache.activeLlmClassification;
9881
+ if (!active || cls !== "multi-model") return "";
9882
+ return "via " + this.providerLabel(active);
9801
9883
  },
9802
9884
 
9803
9885
  loadAgentComposerModel() {
@@ -9921,20 +10003,47 @@
9921
10003
  });
9922
10004
  },
9923
10005
 
9924
- /** Click handler for the agent-composer starter list. Drops the
9925
- * starter's text into the textarea, focuses it, autosizes. The
9926
- * user can edit before submitting. */
9927
- applyAgentStarter(idx) {
10006
+ /** Card HTML for one celebrity seed. Avatar comes from
10007
+ * AvatarSkill (deterministic from seed.id), so the portrait
10008
+ * is the same every render. Intro picks the en/zh field
10009
+ * that matches the active locale; ja/es fall back to en. */
10010
+ celebrityCardHtml(seed) {
9928
10011
  const lang = this.composerLanguage();
9929
- const list = this.AGENT_STARTERS_EN;
9930
- const item = list[idx];
9931
- if (!item) return;
9932
- const ta = document.querySelector("[data-agent-composer-desc]");
9933
- if (!ta) return;
9934
- ta.value = item.text;
9935
- ta.focus();
9936
- ta.setSelectionRange(ta.value.length, ta.value.length);
9937
- this.autosizeAgentComposerTextarea();
10012
+ const intro = (seed.intro && (seed.intro[lang] || seed.intro.en || "")) || "";
10013
+ const avatarUrl = (window.AvatarSkill && typeof window.AvatarSkill.generateDataUrl === "function")
10014
+ ? window.AvatarSkill.generateDataUrl(seed.id)
10015
+ : "";
10016
+ const avatarHtml = avatarUrl
10017
+ ? `<img class="cmp-celeb-img" src="${this.escape(avatarUrl)}" alt="${this.escape(seed.name)}">`
10018
+ : `<span class="cmp-celeb-img-fallback" aria-hidden="true">·</span>`;
10019
+ return `
10020
+ <button type="button" class="cmp-celeb-card" data-celebrity-seed="${this.escape(seed.id)}" title="${this.escape(seed.name)}">
10021
+ <span class="cmp-celeb-av">${avatarHtml}</span>
10022
+ <span class="cmp-celeb-body">
10023
+ <span class="cmp-celeb-name">${this.escape(seed.name)}</span>
10024
+ <span class="cmp-celeb-role">${this.escape(seed.roleTag || "")}</span>
10025
+ <span class="cmp-celeb-intro">${this.escape(intro)}</span>
10026
+ </span>
10027
+ </button>
10028
+ `;
10029
+ },
10030
+
10031
+ /** Click handler · kick the full-mode persona builder with the
10032
+ * celebrity's seed description, tag the live job with the seed
10033
+ * id so the save-success callback can mark it consumed. */
10034
+ applyCelebritySeed(id) {
10035
+ if (typeof id !== "string" || !id) return;
10036
+ const pool = this._celebrityPool();
10037
+ const seed = pool.find((s) => s.id === id);
10038
+ if (!seed) return;
10039
+ // Persist the description as the recovery draft (so a failed
10040
+ // build can be retried via the existing error-card flow),
10041
+ // then start the build. After startFullPersonaBuild() returns,
10042
+ // `this.personaJob` is populated; tag it.
10043
+ this._agentComposerLastDesc = seed.description;
10044
+ this.startFullPersonaBuild(seed.description).then(() => {
10045
+ if (this.personaJob) this.personaJob.celebritySeedId = seed.id;
10046
+ }).catch(() => { /* startFullPersonaBuild surfaces its own error */ });
9938
10047
  },
9939
10048
 
9940
10049
  /** Stages shown during agent generation · each ticks active → done
@@ -10150,21 +10259,246 @@
10150
10259
  `;
10151
10260
  },
10152
10261
 
10153
- /** Starter prompts for the agent composer · 6 archetypal director
10154
- * ideas that span the boardroom's style. Click fills textarea. */
10155
- AGENT_STARTERS_EN: [
10156
- { tag: "long-horizon", text: "A strategist who plays four moves out — distinguishes 'right now' from 'right at the time horizon that matters'." },
10157
- { tag: "user-empathy", text: "A product hand who reasons from the user's moment of friction. Refuses any argument that doesn't name what the user is doing right then." },
10158
- { tag: "first-principles", text: "A physicist who strips problems to observables and causal chains. Refuses to import assumptions from analogy." },
10159
- { tag: "value-investor", text: "A long-pattern reader who tests every novel idea against thirty years of category history before believing it." },
10160
- { tag: "critique-reviewer", text: "A senior critic who audits any deliverable systematically — labels each flaw blocker / major / minor, points at the load-bearing piece, names the mechanism. Won't praise without finding at least one major issue." },
10161
- { tag: "phenomenologist", text: "An observer who notices what the room ISN'T saying. Tracks tone, what got skipped, who agreed too fast." },
10262
+ /** Celebrity seed cards · 12 hand-curated famous figures that
10263
+ * act as one-click presets for the full-mode persona builder.
10264
+ * Click → `applyCelebritySeed(id)` → `startFullPersonaBuild(seed.description)`
10265
+ * → existing 7-phase build confirmation overlay save.
10266
+ * On save success the seed id is marked consumed and never
10267
+ * re-offered (see `_markCelebritySeedConsumed`). Pool stays
10268
+ * topped up via the LLM-driven `_topUpCelebritySeeds` path.
10269
+ *
10270
+ * Per-entry shape:
10271
+ * · id · kebab-slug · doubles as `AvatarSkill` seed
10272
+ * (deterministic 8-bit portrait)
10273
+ * · name · verbatim, no i18n (proper nouns)
10274
+ * · roleTag · short mono tag · kept English to match the
10275
+ * mono kicker register used elsewhere
10276
+ * · intro · { en, zh } one-line tagline shown on the card;
10277
+ * ja / es fall back to en (existing pattern)
10278
+ * · description · seed text handed to `startFullPersonaBuild`;
10279
+ * drives the full ReAct + 7-phase synthesis */
10280
+ CELEBRITY_SEEDS_V1: [
10281
+ {
10282
+ id: "elon-musk",
10283
+ name: "Elon Musk",
10284
+ roleTag: "founder",
10285
+ intro: {
10286
+ en: "First-principles industrialist · electric / orbital / planetary",
10287
+ zh: "第一性原理产业家 · 电动 / 轨道 / 行星尺度",
10288
+ },
10289
+ description: "A first-principles industrialist in the mold of Elon Musk — strips problems to physics, picks impossible-looking goals on civilizational time horizons (electric ground transport, orbital launch reuse, planetary multi-species existence), ships through aggressive execution. Refuses any plan whose constraints are 'industry standard' rather than physically forced.",
10290
+ },
10291
+ {
10292
+ id: "marc-andreessen",
10293
+ name: "Marc Andreessen",
10294
+ roleTag: "venture",
10295
+ intro: {
10296
+ en: "Tech-optimist venture partner · software is eating the world",
10297
+ zh: "技术乐观主义风险投资人 · 软件正在吞噬世界",
10298
+ },
10299
+ description: "A tech-optimist venture partner in the mold of Marc Andreessen — believes software is eating every industry, hunts for category-defining founders, frames every market in terms of long-arc compounding. Pushes founders to think 10x, distrusts incrementalism, treats history of tech disruption as the only valid reference class.",
10300
+ },
10301
+ {
10302
+ id: "paul-graham",
10303
+ name: "Paul Graham",
10304
+ roleTag: "essayist",
10305
+ intro: {
10306
+ en: "Startup essayist · make something people want",
10307
+ zh: "创业散文家 · 做用户真的想要的东西",
10308
+ },
10309
+ description: "An essayist-investor in the mold of Paul Graham — writes in clear short sentences, reasons from concrete observations of founder behaviour, distrusts hype and corporate vocabulary. Reduces strategy to two tests: do users love it, and is the team relentlessly resourceful. Treats writing as a debugging tool for thinking.",
10310
+ },
10311
+ {
10312
+ id: "wittgenstein",
10313
+ name: "Ludwig Wittgenstein",
10314
+ roleTag: "philosopher",
10315
+ intro: {
10316
+ en: "Language philosopher · the limits of my language are the limits of my world",
10317
+ zh: "语言哲学家 · 我语言的界限即我世界的界限",
10318
+ },
10319
+ description: "A language philosopher in the mold of Ludwig Wittgenstein — refuses any argument until the meaning of each word is shown in actual use. Treats philosophical problems as confusions of grammar, not deep mysteries. Pushes the room to describe what is happening rather than what we think is happening. Will stop a discussion cold to ask 'what would count as evidence that we are wrong'.",
10320
+ },
10321
+ {
10322
+ id: "charlie-munger",
10323
+ name: "Charlie Munger",
10324
+ roleTag: "investor",
10325
+ intro: {
10326
+ en: "Mental-models compounder · invert, always invert",
10327
+ zh: "心智模型复利大师 · 反过来想,永远反过来想",
10328
+ },
10329
+ description: "A latticework-of-mental-models investor in the mold of Charlie Munger — assembles answers by combining biology, history, psychology, and engineering. Inverts every question (what would make us fail?), insists on multidisciplinary referents, dismisses any argument that depends on a single discipline's framing. Patient, dry, allergic to the institutional imperative.",
10330
+ },
10331
+ {
10332
+ id: "steve-jobs",
10333
+ name: "Steve Jobs",
10334
+ roleTag: "product",
10335
+ intro: {
10336
+ en: "Product taste-maker · design is how it works",
10337
+ zh: "产品品味教父 · 设计是它如何运作",
10338
+ },
10339
+ description: "A product taste-maker in the mold of Steve Jobs — believes great products come from saying no to a thousand things, that taste is a real skill, that the user's hand-and-eye experience IS the product. Treats every feature decision as a question of what to cut. Refuses any spec that doesn't survive a 5-second hands-on demo.",
10340
+ },
10341
+ {
10342
+ id: "wang-dingding",
10343
+ name: "汪丁丁",
10344
+ roleTag: "economist",
10345
+ intro: {
10346
+ en: "Institutional economist · ideas first, institutions follow",
10347
+ zh: "制度经济学家 · 思想先行,制度跟随",
10348
+ },
10349
+ description: "An institutional economist in the mold of 汪丁丁 — reasons across economics, philosophy, and Chinese intellectual history. Treats every policy question as a question of what kind of human being the institution produces over time. Refuses purely quantitative framings; insists the room name the implicit anthropology behind any proposal.",
10350
+ },
10351
+ {
10352
+ id: "naval-ravikant",
10353
+ name: "Naval Ravikant",
10354
+ roleTag: "philosopher",
10355
+ intro: {
10356
+ en: "Modern philosopher of wealth · seek leverage, not effort",
10357
+ zh: "现代财富哲学家 · 追求杠杆,不要苦劳",
10358
+ },
10359
+ description: "A modern aphorist in the mold of Naval Ravikant — compresses arguments to single tweet-length lines, frames every life decision in terms of leverage (capital, labour, code, media), specific knowledge, and accountability. Distrusts traditional career narratives, treats reading and reflection as the primary capital-allocation activities.",
10360
+ },
10361
+ {
10362
+ id: "peter-thiel",
10363
+ name: "Peter Thiel",
10364
+ roleTag: "contrarian",
10365
+ intro: {
10366
+ en: "Contrarian VC · what important truth do very few agree with you on",
10367
+ zh: "反共识 VC · 你相信什么很少有人同意的重要真理",
10368
+ },
10369
+ description: "A contrarian venture capitalist in the mold of Peter Thiel — opens every conversation with 'what important truth do very few people agree with you on', treats competition as wasteful, hunts for monopolies and definite optimism. Distrusts consensus, refuses to validate ideas on social proof, reads history as a sequence of contingent crossings rather than progress.",
10370
+ },
10371
+ {
10372
+ id: "richard-feynman",
10373
+ name: "Richard Feynman",
10374
+ roleTag: "physicist",
10375
+ intro: {
10376
+ en: "Intuitive physicist · what I cannot create, I do not understand",
10377
+ zh: "直觉物理学家 · 我不能创造的,我就不理解",
10378
+ },
10379
+ description: "A first-principles physicist-teacher in the mold of Richard Feynman — explains hard ideas in everyday words, refuses to use jargon as a shield, demands the simplest experiment that would falsify any claim. Treats not understanding something as the most exciting state to be in. Will derive answers from scratch rather than quote them.",
10380
+ },
10381
+ {
10382
+ id: "jensen-huang",
10383
+ name: "Jensen Huang",
10384
+ roleTag: "founder",
10385
+ intro: {
10386
+ en: "Accelerated-computing founder · the more you buy, the more you save",
10387
+ zh: "加速计算缔造者 · 越买越省",
10388
+ },
10389
+ description: "An accelerated-computing visionary in the mold of Jensen Huang — sees every computing problem through the lens of parallel hardware, picks platform-scale bets a decade ahead of demand, builds developer ecosystems as a moat. Treats software, hardware, and developer mind-share as a single optimisation problem. Patient with technology, impatient with execution.",
10390
+ },
10391
+ {
10392
+ id: "ray-dalio",
10393
+ name: "Ray Dalio",
10394
+ roleTag: "macro",
10395
+ intro: {
10396
+ en: "Principles-based macro investor · pain + reflection = progress",
10397
+ zh: "原则派宏观投资人 · 痛苦 + 反思 = 进步",
10398
+ },
10399
+ description: "A principles-based macro investor in the mold of Ray Dalio — frames every market call as a debt-cycle / productivity-cycle / political-cycle interaction, insists every decision is written down as a testable principle, treats radical transparency and idea-meritocracy as institutional disciplines. Refuses unstructured opinions; asks the room to make their reasoning auditable.",
10400
+ },
10162
10401
  ],
10163
- /** Legacy ZH list · kept as an alias of EN so any external caller
10164
- * reading the property still resolves. System UI is English-only
10165
- * per the global rule (the brief language doesn't change app
10166
- * chrome). New code should reference AGENT_STARTERS_EN directly. */
10167
- get AGENT_STARTERS_ZH() { return this.AGENT_STARTERS_EN; },
10402
+
10403
+ /* ─── Celebrity seed · localStorage state + pool helpers ───
10404
+ Single key `boardroom.celebrity_seeds` carries two arrays:
10405
+ · consumed · ids of seeds the user has successfully built
10406
+ a director from. Never re-offered.
10407
+ · generated · LLM-produced extra seeds, appended over time
10408
+ when the pool falls below the 12 threshold.
10409
+ Pool = (CELEBRITY_SEEDS_V1 ∪ generated) − consumed. */
10410
+ _CELEBRITY_KEY: "boardroom.celebrity_seeds",
10411
+ _loadCelebrityState() {
10412
+ try {
10413
+ const raw = localStorage.getItem(this._CELEBRITY_KEY);
10414
+ if (!raw) return { consumed: [], generated: [] };
10415
+ const j = JSON.parse(raw);
10416
+ return {
10417
+ consumed: Array.isArray(j?.consumed) ? j.consumed.filter((x) => typeof x === "string") : [],
10418
+ generated: Array.isArray(j?.generated) ? j.generated.filter((x) => x && typeof x === "object" && typeof x.id === "string") : [],
10419
+ };
10420
+ } catch { return { consumed: [], generated: [] }; }
10421
+ },
10422
+ _saveCelebrityState(state) {
10423
+ try { localStorage.setItem(this._CELEBRITY_KEY, JSON.stringify(state)); }
10424
+ catch { /* swallow · localStorage may be locked */ }
10425
+ },
10426
+ /** Returns all seeds (v1 + generated) NOT in consumed[]. Used as
10427
+ * the source pool for the random pick on each render. */
10428
+ _celebrityPool() {
10429
+ const state = this._loadCelebrityState();
10430
+ const consumed = new Set(state.consumed);
10431
+ const all = [...this.CELEBRITY_SEEDS_V1, ...state.generated];
10432
+ // Deduplicate by id · LLM might occasionally collide with the
10433
+ // hand-curated v1 catalog despite the excludeIds preamble.
10434
+ const seen = new Set();
10435
+ const out = [];
10436
+ for (const s of all) {
10437
+ if (!s || typeof s.id !== "string") continue;
10438
+ if (consumed.has(s.id)) continue;
10439
+ if (seen.has(s.id)) continue;
10440
+ seen.add(s.id);
10441
+ out.push(s);
10442
+ }
10443
+ return out;
10444
+ },
10445
+ /** Fisher-Yates partial · pick up to n distinct entries from pool. */
10446
+ _pickRandomCelebrities(n) {
10447
+ const pool = this._celebrityPool().slice();
10448
+ const take = Math.min(n, pool.length);
10449
+ for (let i = 0; i < take; i++) {
10450
+ const j = i + Math.floor(Math.random() * (pool.length - i));
10451
+ const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
10452
+ }
10453
+ return pool.slice(0, take);
10454
+ },
10455
+ /** Tag a seed as consumed (called after a successful persona
10456
+ * save). If the pool then dips below 12, fire-and-forget the
10457
+ * LLM top-up — silently swallowed on failure since the user's
10458
+ * primary action (creating a director) already succeeded. */
10459
+ _markCelebritySeedConsumed(id) {
10460
+ if (typeof id !== "string" || !id) return;
10461
+ const state = this._loadCelebrityState();
10462
+ if (!state.consumed.includes(id)) {
10463
+ state.consumed.push(id);
10464
+ this._saveCelebrityState(state);
10465
+ }
10466
+ if (this._celebrityPool().length < 12) {
10467
+ void this._topUpCelebritySeeds().catch(() => { /* swallow */ });
10468
+ }
10469
+ },
10470
+ /** POST /api/agents/celebrity-seed with all known ids (v1 +
10471
+ * generated + consumed) as excludeIds, append the returned
10472
+ * fresh seed to localStorage.generated. */
10473
+ async _topUpCelebritySeeds() {
10474
+ const state = this._loadCelebrityState();
10475
+ const excludeIds = [
10476
+ ...this.CELEBRITY_SEEDS_V1.map((s) => s.id),
10477
+ ...state.generated.map((s) => s.id),
10478
+ ...state.consumed,
10479
+ ];
10480
+ const r = await fetch("/api/agents/celebrity-seed", {
10481
+ method: "POST",
10482
+ headers: { "content-type": "application/json" },
10483
+ body: JSON.stringify({ excludeIds }),
10484
+ });
10485
+ if (!r.ok) throw new Error("celebrity-seed " + r.status);
10486
+ const j = await r.json().catch(() => ({}));
10487
+ const seed = j && typeof j === "object" ? j.seed : null;
10488
+ if (!seed || typeof seed.id !== "string" || typeof seed.name !== "string") {
10489
+ throw new Error("celebrity-seed · bad payload");
10490
+ }
10491
+ const fresh = this._loadCelebrityState();
10492
+ // Guard against the rare race where the server returns an id
10493
+ // we already have (we asked it to exclude, but trust no one).
10494
+ const known = new Set([
10495
+ ...this.CELEBRITY_SEEDS_V1.map((s) => s.id),
10496
+ ...fresh.generated.map((s) => s.id),
10497
+ ]);
10498
+ if (known.has(seed.id)) return;
10499
+ fresh.generated.push(seed);
10500
+ this._saveCelebrityState(fresh);
10501
+ },
10168
10502
 
10169
10503
  /** Persisted toggle · "signal" (current quick path) or "full"
10170
10504
  * (the deep 5-10 min persona builder). Defaults to signal so
@@ -10192,7 +10526,8 @@
10192
10526
  ctaHint: this._t("ag_cmp_cta_hint"),
10193
10527
  manual: this._t("ag_cmp_manual"),
10194
10528
  generating: this._t("ag_cmp_generating"),
10195
- starterCaption: this._t("ag_cmp_starter_caption"), };
10529
+ celebrityCaption: this._t("ag_cmp_celebrity_caption"),
10530
+ };
10196
10531
  // Full-mode build in flight or finished · separate render path.
10197
10532
  // Returns a different surface (SSE progress block or save card)
10198
10533
  // that hides the textarea + starters · the user is committed to
@@ -10223,14 +10558,12 @@
10223
10558
  const ctaHint = builderMode === "full"
10224
10559
  ? "5–10 min · ReAct research + 7-phase build"
10225
10560
  : t.ctaHint;
10226
- const starters = this.AGENT_STARTERS_EN;
10227
- const starterCards = starters.map((q, idx) => `
10228
- <button type="button" class="cmp-starter" data-agent-starter="${idx}">
10229
- <div class="cmp-starter-tag">${this.escape(q.tag)}</div>
10230
- <div class="cmp-starter-text">${this.escape(q.text)}</div>
10231
- <div class="cmp-starter-arrow">→</div>
10232
- </button>
10233
- `).join("");
10561
+ // Celebrity seed cards · 6 random portraits from the pool of
10562
+ // (CELEBRITY_SEEDS_V1 generated) − consumed. New random
10563
+ // pick on every render so users browse a rotating set.
10564
+ const celebrityCards = this._pickRandomCelebrities(6)
10565
+ .map((seed) => this.celebrityCardHtml(seed))
10566
+ .join("");
10234
10567
  return `
10235
10568
  <section class="cmp ag-cmp">
10236
10569
  <div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("agent")}</div>
@@ -10340,13 +10673,13 @@
10340
10673
  ` : ""}
10341
10674
 
10342
10675
  ${!generating ? `
10343
- <div class="cmp-starters">
10676
+ <div class="cmp-celeb-stack">
10344
10677
  <div class="cmp-starters-rule">
10345
10678
  <span class="cmp-starters-rule-line"></span>
10346
- <span class="cmp-starters-rule-label">${this.escape(t.starterCaption)}</span>
10679
+ <span class="cmp-starters-rule-label">${this.escape(t.celebrityCaption)}</span>
10347
10680
  <span class="cmp-starters-rule-line"></span>
10348
10681
  </div>
10349
- <div class="cmp-starters-grid">${starterCards}</div>
10682
+ <div class="cmp-celeb-grid">${celebrityCards}</div>
10350
10683
  </div>
10351
10684
  ` : ""}
10352
10685
  </section>
@@ -10358,9 +10691,22 @@
10358
10691
  const avatarSvg = (window.AvatarSkill && seed)
10359
10692
  ? window.AvatarSkill.generate(seed, { size: 96 })
10360
10693
  : `<div class="ag-prev-av-empty">—</div>`;
10361
- // Ordered + grouped by provider · same set as the toolbar
10362
- // dropdown so the user sees consistent picks pre/post generate.
10363
- const modelGroups = AGENT_COMPOSER_MODELS.reduce((acc, m) => {
10694
+ // Reachable models only · filter by `/api/models` cache so the
10695
+ // user can't pick a model their active credential can't route
10696
+ // (e.g. Claude when active credential is OpenAI direct). Falls
10697
+ // back to AGENT_COMPOSER_MODELS only when the cache hasn't
10698
+ // loaded yet — rare cold-start path, browser will re-render
10699
+ // after the first refresh.
10700
+ const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
10701
+ const reachable = (cache && Array.isArray(cache.reachable) && cache.reachable.length > 0)
10702
+ ? cache.reachable.map((m) => ({
10703
+ v: m.modelV,
10704
+ label: m.displayName,
10705
+ provider: this.providerLabel(m.provider),
10706
+ deck: m.deck || "",
10707
+ }))
10708
+ : AGENT_COMPOSER_MODELS;
10709
+ const modelGroups = reachable.reduce((acc, m) => {
10364
10710
  const last = acc[acc.length - 1];
10365
10711
  if (!last || last.provider !== m.provider) {
10366
10712
  acc.push({ provider: m.provider, items: [m] });
@@ -11402,6 +11748,12 @@
11402
11748
  const e = await r.json().catch(() => ({}));
11403
11749
  throw new Error(e.error || ("HTTP " + r.status));
11404
11750
  }
11751
+ // Capture the celebrity seed id (if any) BEFORE nulling
11752
+ // personaJob below · `_markCelebritySeedConsumed` needs
11753
+ // it to update localStorage. Tagging happens in
11754
+ // `applyCelebritySeed` when the build was kicked from a
11755
+ // celebrity card; null for manual textarea builds.
11756
+ const celebritySeedId = this.personaJob?.celebritySeedId || null;
11405
11757
  // Sync local state to mirror successful save. Null
11406
11758
  // `personaJob` BEFORE refreshAgents so the sidebar
11407
11759
  // re-render inside refreshAgents drops the Building
@@ -11410,6 +11762,10 @@
11410
11762
  this.personaJob = null;
11411
11763
  this._personaOverlayShown = false;
11412
11764
  await this.refreshAgents?.();
11765
+ // Mark the celebrity seed consumed AFTER save + refresh
11766
+ // both succeed. Fire-and-forget LLM top-up runs when the
11767
+ // pool dips below 12 — never blocks the user's flow.
11768
+ if (celebritySeedId) this._markCelebritySeedConsumed(celebritySeedId);
11413
11769
  this.composerMode = "room";
11414
11770
  this.setComposerMode?.("room");
11415
11771
  this.renderEmptyState();
@@ -12179,12 +12535,12 @@
12179
12535
  current = this.loadAgentBuilderMode();
12180
12536
  } else if (kind === "agent-model") {
12181
12537
  // Reachable-only model catalog · pulls from the shared
12182
- // /api/models cache so the picker reflects the user's
12183
- // current key set. Each row carries provider + deck + a
12184
- // route badge ("direct" / "OR" / "direct · OR") so power
12185
- // users can see how each model would route. When the cache
12186
- // hasn't loaded yet we fall back to the registry mirror so
12187
- // the picker isn't empty during first paint.
12538
+ // /api/models cache. Under the single-active-LLM-provider
12539
+ // invariant, every reachable model arrives via the SAME
12540
+ // active carrier so per-row route badges are redundant
12541
+ // (a single "via {provider}" caption above the picker
12542
+ // covers it for multi-model carriers). For single-model
12543
+ // active, the per-group header already names the family.
12188
12544
  const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
12189
12545
  if (cache && Array.isArray(cache.reachable) && cache.reachable.length > 0) {
12190
12546
  opts = cache.reachable.map((m) => ({
@@ -12192,7 +12548,6 @@
12192
12548
  label: m.displayName,
12193
12549
  hint: m.deck || "",
12194
12550
  provider: this.providerLabel(m.provider),
12195
- badge: this.modelRouteBadge(m),
12196
12551
  }));
12197
12552
  } else {
12198
12553
  opts = AGENT_COMPOSER_MODELS.map((m) => ({
@@ -12200,7 +12555,6 @@
12200
12555
  label: m.label,
12201
12556
  hint: m.deck,
12202
12557
  provider: m.provider,
12203
- badge: "",
12204
12558
  }));
12205
12559
  // Kick off a refresh in the background so the next open
12206
12560
  // shows reachable-only data. No re-render of the current
@@ -12214,25 +12568,28 @@
12214
12568
  return;
12215
12569
  }
12216
12570
  // For the agent-model picker we group rows by provider with a
12217
- // tiny header label between groups. Tone / intensity remain a
12218
- // flat list (just 3-4 picks each no grouping needed).
12571
+ // tiny header label between groups. A single "via {provider}"
12572
+ // caption sits at the top of the popover when a multi-model
12573
+ // carrier is the active LLM provider (every reachable model
12574
+ // arrives through the SAME carrier under the single-active
12575
+ // invariant). Tone / intensity stay a flat list.
12219
12576
  let rows;
12220
12577
  if (kind === "agent-model") {
12221
12578
  const groups = [];
12579
+ const caption = this.modelRouteCaption();
12580
+ if (caption) {
12581
+ groups.push(`<div class="cmp-dd-caption">${this.escape(caption)}</div>`);
12582
+ }
12222
12583
  let lastProv = null;
12223
12584
  for (const o of opts) {
12224
12585
  if (o.provider !== lastProv) {
12225
12586
  groups.push(`<div class="cmp-dd-group">${this.escape(o.provider)}</div>`);
12226
12587
  lastProv = o.provider;
12227
12588
  }
12228
- const badge = o.badge
12229
- ? `<span class="cmp-dd-opt-route">${this.escape(o.badge)}</span>`
12230
- : "";
12231
12589
  groups.push(`
12232
12590
  <button type="button" class="cmp-dd-opt${o.v === current ? " active" : ""}" data-cmp-dd-pick="${this.escape(o.v)}" data-cmp-dd-kind="${this.escape(kind)}">
12233
12591
  <span class="cmp-dd-opt-label">${this.escape(o.label)}</span>
12234
12592
  <span class="cmp-dd-opt-hint">${this.escape(o.hint)}</span>
12235
- ${badge}
12236
12593
  </button>
12237
12594
  `);
12238
12595
  }
@@ -12394,16 +12751,14 @@
12394
12751
  intensity: state.intensity,
12395
12752
  deliveryMode: state.deliveryMode,
12396
12753
  autoPick: useAutoPick,
12397
- seedContext: state.seedContext || null,
12398
12754
  });
12399
12755
  // Clear the saved draft now that the room is convened — next
12400
12756
  // visit to "+ New Room" should land on a fresh textarea, not
12401
- // re-show the just-submitted subject. Also drop the attached
12402
- // seedContext the snippets travelled into the room's
12403
- // opening message; we don't want them piggy-backing on the
12404
- // NEXT room the user opens.
12757
+ // re-show the just-submitted subject. The scenario-card
12758
+ // placeholder hint is also a one-shot · drop it so the next
12759
+ // composer paint shows the default framing copy again.
12405
12760
  state.subject = "";
12406
- state.seedContext = null;
12761
+ this._scenarioPlaceholder = null;
12407
12762
  this.saveComposerState();
12408
12763
  } catch (e) {
12409
12764
  if (btn) btn.classList.remove("busy");
@@ -12411,349 +12766,238 @@
12411
12766
  }
12412
12767
  },
12413
12768
 
12414
- /** Fetch the (single) page of topic recommendations · the
12415
- * server keeps only the latest batch (6 rows), so one
12416
- * request is the whole story. Idempotent safe to call
12417
- * from renderEmptyState() boot AND after a generation job
12418
- * completes. Sets `topicRecs.loaded` so first-paint can
12419
- * fall back to legacy hardcoded starters until this
12420
- * resolves. */
12421
- async refreshTopicRecs() {
12422
- try {
12423
- const r = await fetch("/api/topic-recs?limit=6");
12424
- if (!r.ok) {
12425
- this.topicRecs.loaded = true;
12426
- this.renderEmptyState();
12427
- return;
12428
- }
12429
- const j = await r.json();
12430
- this.topicRecs.items = Array.isArray(j.items) ? j.items : [];
12431
- this.topicRecs.loaded = true;
12432
- // Re-render only when the user is still on the empty
12433
- // (composer) state · openRoom paths have already moved
12434
- // on and an unsolicited re-render would steal focus.
12435
- if (!this.currentRoomId) this.renderEmptyState();
12436
- } catch { /* network error · keep stale list, surface nothing */ }
12437
- },
12438
-
12439
- /** Derive a short tag from a subject string · used as the
12440
- * client-side fallback when a rec row has no `tag` field
12441
- * (legacy rows from before migration 035, or the rare
12442
- * case where the LLM forgot to include one and the
12443
- * orchestrator's safety-net path also fired). Mirrors the
12444
- * orchestrator's `deriveTagFromSubject` logic so the
12445
- * vocabulary stays consistent across paths. */
12446
- _deriveTagFromSubject(subject) {
12447
- const STOP = new Set([
12448
- "the", "and", "for", "are", "you", "your", "what", "how",
12449
- "why", "when", "with", "from", "this", "that", "should",
12450
- "could", "would", "have", "has", "will", "into", "about",
12451
- ]);
12452
- const words = (subject || "")
12453
- .toLowerCase()
12454
- .replace(/[^a-z0-9\s-]/g, " ")
12455
- .split(/\s+/)
12456
- .filter((w) => w.length > 2 && !STOP.has(w));
12457
- return words.slice(0, 2).join(" ").slice(0, 28) || "topic";
12458
- },
12459
-
12460
- /** Build the HTML for a single topic-rec card. Used both by
12461
- * `renderComposerHtml` (initial render) and `loadMoreTopicRecs`
12462
- * (in-place append). Keeping the markup in one helper means
12463
- * the two paths can't drift in shape or attributes. */
12464
- topicRecCardHtml(rec) {
12465
- // Tag priority: synthesiser-produced category > derive
12466
- // from subject. We NEVER fall back to "web" / "memory"
12467
- // as the visible tag — those are data-provenance tokens,
12468
- // not topic categories. The `data-source` attribute still
12469
- // carries the provenance for CSS to colour-tint with.
12470
- const tag = (typeof rec.tag === "string" && rec.tag.trim().length > 0)
12471
- ? rec.tag
12472
- : this._deriveTagFromSubject(rec.subject);
12473
- const hint = rec.rationale || "";
12474
- return `
12475
- <button type="button" class="cmp-starter cmp-rec" data-cmp-rec="${this.escape(rec.id)}" data-source="${this.escape(rec.source)}">
12476
- <div class="cmp-starter-tag">${this.escape(tag)}</div>
12477
- <div class="cmp-starter-text">${this.escape(rec.subject || "")}</div>
12478
- ${hint ? `<div class="cmp-rec-hint" title="${this.escape(hint)}">${this.escape(hint)}</div>` : ""}
12479
- <div class="cmp-starter-arrow">→</div>
12480
- </button>
12481
- `;
12482
- },
12483
-
12484
- // (no `loadMoreTopicRecs` — see comments at the
12485
- // pagination-removed render block above.)
12769
+ /** Scenario use-case cards · 12 hand-curated boardroom
12770
+ * situations that also act as one-click room presets. Six
12771
+ * are picked at random on each composer render, so the user
12772
+ * sees a rotating set instead of a fixed list.
12773
+ *
12774
+ * Each card:
12775
+ * · id · stable kebab key (used in click handler)
12776
+ * · tagKey · i18n key for the uppercase mono kicker
12777
+ * (e.g. "INVESTOR · PITCH")
12778
+ * · headlineKey · i18n key for the short serif italic title
12779
+ * · bodyKey · i18n key for the italic 1-2 line intro
12780
+ * · directors · 2-3 director slugs the click auto-picks
12781
+ * · tone · room mode set on click
12782
+ * · intensity · room intensity set on click
12783
+ * · subjectKey · i18n key for the starter subject text
12784
+ * dropped into the composer
12785
+ *
12786
+ * Tone/intensity vocab matches what the room schema accepts
12787
+ * (brainstorm | constructive | research | debate | critique
12788
+ * × calm | sharp). Cards differentiate by content alone no
12789
+ * accent colour, no rail, no icon. */
12790
+ SCENARIO_CARDS: [
12791
+ {
12792
+ id: "investor-pitch",
12793
+ tagKey: "scn_pitch_tag",
12794
+ headlineKey: "scn_pitch_headline",
12795
+ bodyKey: "scn_pitch_body",
12796
+ directors: ["socrates", "value-investor", "first-principles"],
12797
+ tone: "debate",
12798
+ intensity: "sharp",
12799
+ subjectKey: "scn_pitch_subject",
12800
+ },
12801
+ {
12802
+ id: "product-brainstorm",
12803
+ tagKey: "scn_brainstorm_tag",
12804
+ headlineKey: "scn_brainstorm_headline",
12805
+ bodyKey: "scn_brainstorm_body",
12806
+ directors: ["first-principles", "user-empathy", "long-horizon"],
12807
+ tone: "brainstorm",
12808
+ intensity: "calm",
12809
+ subjectKey: "scn_brainstorm_subject",
12810
+ },
12811
+ {
12812
+ id: "topic-pk",
12813
+ tagKey: "scn_pk_tag",
12814
+ headlineKey: "scn_pk_headline",
12815
+ bodyKey: "scn_pk_body",
12816
+ directors: ["socrates", "value-investor"],
12817
+ tone: "debate",
12818
+ intensity: "sharp",
12819
+ subjectKey: "scn_pk_subject",
12820
+ },
12821
+ {
12822
+ id: "find-flaws",
12823
+ tagKey: "scn_audit_tag",
12824
+ headlineKey: "scn_audit_headline",
12825
+ bodyKey: "scn_audit_body",
12826
+ directors: ["socrates", "first-principles", "long-horizon"],
12827
+ tone: "critique",
12828
+ intensity: "sharp",
12829
+ subjectKey: "scn_audit_subject",
12830
+ },
12831
+ {
12832
+ id: "research-learn",
12833
+ tagKey: "scn_research_tag",
12834
+ headlineKey: "scn_research_headline",
12835
+ bodyKey: "scn_research_body",
12836
+ directors: ["first-principles", "long-horizon", "user-empathy"],
12837
+ tone: "research",
12838
+ intensity: "calm",
12839
+ subjectKey: "scn_research_subject",
12840
+ },
12841
+ {
12842
+ id: "negotiation-prep",
12843
+ tagKey: "scn_negotiation_tag",
12844
+ headlineKey: "scn_negotiation_headline",
12845
+ bodyKey: "scn_negotiation_body",
12846
+ directors: ["socrates", "value-investor", "user-empathy"],
12847
+ tone: "debate",
12848
+ intensity: "sharp",
12849
+ subjectKey: "scn_negotiation_subject",
12850
+ },
12851
+ {
12852
+ id: "decision-fork",
12853
+ tagKey: "scn_decision_tag",
12854
+ headlineKey: "scn_decision_headline",
12855
+ bodyKey: "scn_decision_body",
12856
+ directors: ["long-horizon", "first-principles", "value-investor"],
12857
+ tone: "debate",
12858
+ intensity: "calm",
12859
+ subjectKey: "scn_decision_subject",
12860
+ },
12861
+ {
12862
+ id: "red-team",
12863
+ tagKey: "scn_red_team_tag",
12864
+ headlineKey: "scn_red_team_headline",
12865
+ bodyKey: "scn_red_team_body",
12866
+ directors: ["socrates", "value-investor", "first-principles"],
12867
+ tone: "critique",
12868
+ intensity: "sharp",
12869
+ subjectKey: "scn_red_team_subject",
12870
+ },
12871
+ {
12872
+ id: "post-mortem",
12873
+ tagKey: "scn_post_mortem_tag",
12874
+ headlineKey: "scn_post_mortem_headline",
12875
+ bodyKey: "scn_post_mortem_body",
12876
+ directors: ["socrates", "first-principles", "long-horizon"],
12877
+ tone: "research",
12878
+ intensity: "calm",
12879
+ subjectKey: "scn_post_mortem_subject",
12880
+ },
12881
+ {
12882
+ id: "career-fork",
12883
+ tagKey: "scn_career_tag",
12884
+ headlineKey: "scn_career_headline",
12885
+ bodyKey: "scn_career_body",
12886
+ directors: ["long-horizon", "value-investor", "user-empathy"],
12887
+ tone: "constructive",
12888
+ intensity: "calm",
12889
+ subjectKey: "scn_career_subject",
12890
+ },
12891
+ {
12892
+ id: "hire-evaluation",
12893
+ tagKey: "scn_hire_tag",
12894
+ headlineKey: "scn_hire_headline",
12895
+ bodyKey: "scn_hire_body",
12896
+ directors: ["first-principles", "value-investor", "user-empathy"],
12897
+ tone: "critique",
12898
+ intensity: "sharp",
12899
+ subjectKey: "scn_hire_subject",
12900
+ },
12901
+ {
12902
+ id: "roadmap-priority",
12903
+ tagKey: "scn_roadmap_tag",
12904
+ headlineKey: "scn_roadmap_headline",
12905
+ bodyKey: "scn_roadmap_body",
12906
+ directors: ["user-empathy", "first-principles", "long-horizon"],
12907
+ tone: "constructive",
12908
+ intensity: "sharp",
12909
+ subjectKey: "scn_roadmap_subject",
12910
+ },
12911
+ ],
12486
12912
 
12487
- /** Kick off a new topic-recommendation generation job and
12488
- * attach an SSE stream so the composer's progress strip
12489
- * ticks through phases live. On `topic-final` we refresh
12490
- * the tray from the API; on error we surface the message
12491
- * inline so the user understands why nothing landed. */
12492
- async startTopicRecJob() {
12493
- if (this.topicRecs.job) return; // already running · idempotent click
12494
- // Pre-flight · API gate requires a model key. Surface the
12495
- // same prompt as Convene so the user fixes it once.
12496
- if (!(await this.requireModelKey())) return;
12497
- try {
12498
- const r = await fetch("/api/topic-recs", {
12499
- method: "POST",
12500
- headers: { "content-type": "application/json" },
12501
- body: JSON.stringify({}),
12502
- });
12503
- if (!r.ok) {
12504
- const j = await r.json().catch(() => ({}));
12505
- alert("Couldn't start: " + (j.error || r.statusText));
12506
- return;
12507
- }
12508
- const { jobId } = await r.json();
12509
- this.topicRecs.job = {
12510
- id: jobId,
12511
- phase: 0,
12512
- label: this._t("cmp_recs_trigger_starting"),
12513
- pct: 0,
12514
- detail: "",
12515
- es: null,
12516
- error: null,
12517
- };
12518
- if (!this.currentRoomId) this.renderEmptyState();
12519
- this._attachTopicRecJobSSE(jobId);
12520
- } catch (e) {
12521
- alert("Couldn't start: " + (e && e.message ? e.message : e));
12522
- }
12523
- },
12524
-
12525
- /** Internal · attach the EventSource for a running job and
12526
- * wire each event type to the in-memory job state. */
12527
- _attachTopicRecJobSSE(jobId) {
12528
- const url = `/api/topic-recs/jobs/${encodeURIComponent(jobId)}/stream`;
12529
- const es = new EventSource(url);
12530
- this.topicRecs.job.es = es;
12531
- const updateStrip = () => {
12532
- const el = document.querySelector("[data-topic-rec-progress]");
12533
- if (!el || !this.topicRecs.job) return;
12534
- const j = this.topicRecs.job;
12535
- const labelEl = el.querySelector("[data-trec-label]");
12536
- if (labelEl) labelEl.textContent = j.label || "";
12537
- const detailEl = el.querySelector("[data-trec-detail]");
12538
- if (detailEl) detailEl.textContent = j.detail || "";
12539
- const pctEl = el.querySelector("[data-trec-pct]");
12540
- if (pctEl) pctEl.textContent = j.pct ? `${j.pct}%` : "·";
12541
- const bar = el.querySelector("[data-trec-bar]");
12542
- if (bar) bar.style.width = `${j.pct || 0}%`;
12543
- };
12544
- const terminate = () => {
12545
- try { es.close(); } catch { /* noop */ }
12546
- this.topicRecs.job = null;
12547
- if (!this.currentRoomId) this.renderEmptyState();
12548
- };
12549
- es.addEventListener("hello", (ev) => {
12550
- try {
12551
- const data = JSON.parse(ev.data);
12552
- if (this.topicRecs.job) {
12553
- this.topicRecs.job.phase = data.currentPhase || 0;
12554
- this.topicRecs.job.pct = data.progressPct || 0;
12555
- }
12556
- updateStrip();
12557
- } catch { /* noop */ }
12558
- });
12559
- es.addEventListener("topic-phase-start", (ev) => {
12560
- try {
12561
- const data = JSON.parse(ev.data);
12562
- if (this.topicRecs.job) {
12563
- this.topicRecs.job.phase = data.phase;
12564
- this.topicRecs.job.label = data.label;
12565
- this.topicRecs.job.detail = "";
12566
- }
12567
- updateStrip();
12568
- } catch { /* noop */ }
12569
- });
12570
- es.addEventListener("topic-phase-progress", (ev) => {
12571
- try {
12572
- const data = JSON.parse(ev.data);
12573
- if (this.topicRecs.job) {
12574
- this.topicRecs.job.phase = data.phase;
12575
- this.topicRecs.job.detail = data.detail || "";
12576
- this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
12577
- }
12578
- updateStrip();
12579
- } catch { /* noop */ }
12580
- });
12581
- es.addEventListener("topic-phase-end", (ev) => {
12582
- try {
12583
- const data = JSON.parse(ev.data);
12584
- if (this.topicRecs.job) {
12585
- this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
12586
- }
12587
- updateStrip();
12588
- } catch { /* noop */ }
12589
- });
12590
- es.addEventListener("topic-final", () => {
12591
- // Pull the freshest first page so the new cards land
12592
- // immediately; closing the EventSource is on the same
12593
- // tick so the next click doesn't see a stale job.
12594
- terminate();
12595
- void this.refreshTopicRecs();
12596
- });
12597
- es.addEventListener("topic-error", (ev) => {
12598
- let msg = "generation failed";
12599
- try { msg = (JSON.parse(ev.data).message) || msg; } catch { /* noop */ }
12600
- alert("Topic generation failed: " + msg);
12601
- terminate();
12602
- });
12603
- es.addEventListener("topic-aborted", () => {
12604
- terminate();
12605
- });
12606
- es.onerror = () => {
12607
- // Transport hiccup · treat as terminal so the UI doesn't
12608
- // stay stuck on a phantom progress strip.
12609
- if (this.topicRecs.job) terminate();
12610
- };
12913
+ /** Fisher-Yates partial · pick up to n distinct scenarios from
12914
+ * the catalog. Re-rolled on every composer render so the user
12915
+ * browses a rotating set. Unlike celebrity seeds, scenarios are
12916
+ * not "consumed" the same set can be replayed forever. */
12917
+ _pickRandomScenarios(n) {
12918
+ const pool = (this.SCENARIO_CARDS || []).slice();
12919
+ const take = Math.min(n, pool.length);
12920
+ for (let i = 0; i < take; i++) {
12921
+ const j = i + Math.floor(Math.random() * (pool.length - i));
12922
+ const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
12923
+ }
12924
+ return pool.slice(0, take);
12925
+ },
12926
+
12927
+ /** Build the markup for the 4 visible scenario cards. Compact
12928
+ * 2-col grid layout — matches the celebrity seed cards on the
12929
+ * new-agent page (mono kicker → serif italic headline → italic
12930
+ * intro). No pills, no avatars, no CTA arrow — the entire card
12931
+ * is one click target. */
12932
+ scenarioAdCardsHtml() {
12933
+ return this._pickRandomScenarios(4)
12934
+ .map((c) => this.scenarioCardHtml(c))
12935
+ .join("");
12611
12936
  },
12612
12937
 
12613
- /** Click handler on a recommendation card · fetches the
12614
- * full row (to recover seedContext) then applies it to the
12615
- * composer state so the next Convene carries the snippets
12616
- * through to the opening message's meta. */
12617
- async applyTopicRec(id) {
12618
- if (!id) return;
12619
- try {
12620
- const r = await fetch(`/api/topic-recs/${encodeURIComponent(id)}`);
12621
- if (!r.ok) return;
12622
- const row = await r.json();
12623
- if (!row || typeof row.subject !== "string") return;
12624
- const state = this.loadComposerState();
12625
- state.subject = row.subject;
12626
- state.seedContext = {
12627
- topicRecId: row.id,
12628
- // Rationale is the "why this fits you" line the
12629
- // synthesiser produced · it's hidden from the card
12630
- // UI but forwarded as background context so the
12631
- // chair's clarify prompt can ground its first turn
12632
- // in the same reasoning the recommendation was
12633
- // built on.
12634
- rationale: typeof row.rationale === "string" ? row.rationale : "",
12635
- snippets: Array.isArray(row.seedContext) ? row.seedContext : [],
12636
- };
12637
- this.saveComposerState();
12638
- this.renderEmptyState();
12639
- setTimeout(() => {
12640
- const ta = document.querySelector("[data-composer-subject]");
12641
- if (ta) {
12642
- ta.focus();
12643
- ta.setSelectionRange(ta.value.length, ta.value.length);
12644
- this.autosizeComposerTextarea?.();
12645
- }
12646
- }, 50);
12647
- } catch { /* noop */ }
12938
+ scenarioCardHtml(card) {
12939
+ const tag = this._t(card.tagKey);
12940
+ const headline = this._t(card.headlineKey);
12941
+ const body = this._t(card.bodyKey);
12942
+ // Cast strip · resolve director slugs against this.agentsById
12943
+ // and render up to 3 small overlap-stacked avatars at the
12944
+ // card foot. Each avatar carries [data-agent-profile] so a
12945
+ // click on the avatar opens the profile overlay (handled in
12946
+ // capture phase by agent-profile.js); clicks anywhere ELSE
12947
+ // on the card still fire the scenario action via the
12948
+ // [data-scenario-id] delegate.
12949
+ const cast = (card.directors || [])
12950
+ .map((id) => this.agentsById[id])
12951
+ .filter(Boolean)
12952
+ .slice(0, 3);
12953
+ const castHtml = cast.map((a) => `
12954
+ <span class="cmp-scn-av" title="${this.escape(a.name)}" data-agent-profile="${this.escape(a.id)}">
12955
+ <img src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}">
12956
+ </span>
12957
+ `).join("");
12958
+ return `
12959
+ <button type="button" class="cmp-scn-card" data-scenario-id="${this.escape(card.id)}">
12960
+ <div class="cmp-scn-tag">${this.escape(tag)}</div>
12961
+ <h3 class="cmp-scn-headline">${this.escape(headline)}</h3>
12962
+ <p class="cmp-scn-desc">${this.escape(body)}</p>
12963
+ ${cast.length > 0 ? `<div class="cmp-scn-cast">${castHtml}</div>` : ""}
12964
+ </button>
12965
+ `;
12648
12966
  },
12649
12967
 
12650
- /** Apply a starter spec into the composer state — fills the
12651
- * textarea, swaps the cast / tone / intensity to the starter's
12652
- * presets, then re-renders. User can adjust before hitting Enter. */
12653
- applyComposerStarter(idx) {
12654
- const list = window.BOARDROOM_STARTERS || [];
12655
- const q = list[idx];
12656
- if (!q) return;
12968
+ /** Click handler · apply a scenario preset into composer
12969
+ * state. Sets the director cast + tone + intensity and
12970
+ * replaces the textarea PLACEHOLDER (not the value) with the
12971
+ * scenario's starter subject — the user still types their
12972
+ * own real question. Submitting / leaving the composer wipes
12973
+ * the hint (see submitComposer / setComposerMode). */
12974
+ applyScenarioCard(id) {
12975
+ const card = (this.SCENARIO_CARDS || []).find((c) => c.id === id);
12976
+ if (!card) return;
12657
12977
  const state = this.loadComposerState();
12658
- // Map starter agent slugs to actual ids if necessary; the spec
12659
- // already stores ids so just keep agents that exist.
12660
- const want = (q.agents || []).filter((id) => this.agentsById[id]);
12661
- if (want.length) state.directorIds = want;
12662
- if (q.tone) state.mode = q.tone;
12663
- if (q.intensity) state.intensity = q.intensity;
12664
- // Write the starter text into the persisted draft so it survives
12665
- // a navigation away and back, just like manual typing does.
12666
- state.subject = q.text || "";
12978
+ const want = (card.directors || []).filter((aid) => this.agentsById[aid]);
12979
+ if (want.length) {
12980
+ state.directorIds = want;
12981
+ state.autoPickDirectors = false;
12982
+ }
12983
+ if (card.tone) state.mode = card.tone;
12984
+ if (card.intensity) state.intensity = card.intensity;
12985
+ const subject = this._t(card.subjectKey) || "";
12986
+ if (subject && subject !== card.subjectKey) {
12987
+ this._scenarioPlaceholder = subject;
12988
+ }
12667
12989
  this.saveComposerState();
12668
- // Re-render to reflect the new selections; keep autofocus at end of subject.
12669
12990
  this.renderEmptyState();
12670
12991
  setTimeout(() => {
12671
12992
  const ta = document.querySelector("[data-composer-subject]");
12672
12993
  if (ta) {
12673
12994
  ta.focus();
12674
12995
  ta.setSelectionRange(ta.value.length, ta.value.length);
12675
- this.autosizeComposerTextarea();
12996
+ this.autosizeComposerTextarea?.();
12676
12997
  }
12677
12998
  }, 50);
12678
12999
  },
12679
13000
 
12680
- /**
12681
- * Render one starter card. The cast + config come from the starter spec
12682
- * (window.BOARDROOM_STARTERS); each avatar is clickable to open the
12683
- * agent profile, the card body fires the starter action, and a
12684
- * [▶ Start] button appears on hover at the right.
12685
- */
12686
- starterCardHtml(q, idx) {
12687
- const cast = (q.agents || [])
12688
- .map((id) => this.agentsById[id])
12689
- .filter(Boolean);
12690
- const castHtml = cast.map((a) => `
12691
- <span class="starter-agent" title="${this.escape(a.name)} · ${this.escape(a.roleTag || "")}" data-agent-profile="${this.escape(a.id)}">
12692
- <img class="starter-av" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" data-agent-profile="${this.escape(a.id)}">
12693
- <span class="starter-name">${this.escape(a.name)}</span>
12694
- </span>
12695
- `).join("");
12696
- return `
12697
- <div class="starter-card" data-starter-idx="${idx}">
12698
- <div class="starter-tag">${this.escape(q.tag)}</div>
12699
- <div class="starter-main">
12700
- <div class="starter-text">${this.escape(q.text)}</div>
12701
- <div class="starter-hint">${this.escape(q.hint)}</div>
12702
- <div class="starter-meta">
12703
- <span class="meta-tag tag-tone"><span class="k">tone</span><span class="v">${this.escape(q.tone)}</span></span>
12704
- <span class="meta-tag tag-intensity"><span class="k">intensity</span><span class="v">${this.escape(q.intensity)}</span></span>
12705
- </div>
12706
- </div>
12707
- <div class="starter-cast" aria-label="${cast.length} directors">${castHtml}</div>
12708
- <button type="button" class="starter-start" data-starter-go="${idx}" title="Start this room">
12709
- <span class="starter-start-arrow">▶</span><span class="starter-start-label">Start</span>
12710
- </button>
12711
- </div>
12712
- `;
12713
- },
12714
-
12715
- /**
12716
- * Create a room from a starter spec. The cast + tone + intensity come
12717
- * from `window.BOARDROOM_STARTERS` (or the explicit subject string for
12718
- * legacy callers). Falls back to the default trio if a referenced
12719
- * agent isn't seeded.
12720
- */
12721
- async createStarterRoom(subjectOrSpec) {
12722
- const starters = (typeof window !== "undefined" && Array.isArray(window.BOARDROOM_STARTERS))
12723
- ? window.BOARDROOM_STARTERS : [];
12724
- let spec = null;
12725
- if (subjectOrSpec && typeof subjectOrSpec === "object") {
12726
- spec = subjectOrSpec;
12727
- } else if (typeof subjectOrSpec === "string") {
12728
- const text = subjectOrSpec.trim();
12729
- spec = starters.find((s) => s.text === text) || { text };
12730
- }
12731
- if (!spec || !spec.text) return;
12732
-
12733
- const have = new Set(this.agents.map((a) => a.id));
12734
- let agentIds = (spec.agents || []).filter((id) => have.has(id));
12735
- // Fallback to the canonical trio, then to any three seeded agents.
12736
- if (agentIds.length < 2) {
12737
- const trio = ["socrates", "first-principles", "value-investor"].filter((id) => have.has(id));
12738
- agentIds = trio.length >= 2 ? trio : this.agents.slice(0, 3).map((a) => a.id);
12739
- }
12740
- if (agentIds.length === 0) {
12741
- alert("No directors available — the agent catalog is empty.");
12742
- return;
12743
- }
12744
- try {
12745
- await this.createRoom({
12746
- subject: spec.text,
12747
- agentIds,
12748
- mode: spec.tone || "constructive",
12749
- intensity: spec.intensity || "sharp",
12750
- briefStyle: spec.briefStyle || "auto",
12751
- });
12752
- } catch (e) {
12753
- alert("Couldn't open the starter room: " + (e && e.message ? e.message : e));
12754
- }
12755
- },
12756
-
12757
13001
  /** Tone hover copy · i18n keyed by mode, falls back to TONE_TIPS. */
12758
13002
  toneTipFor(mode) {
12759
13003
  const m = mode || "";
@@ -12864,7 +13108,7 @@
12864
13108
  <span class="kicker-intensity">${this.escape(intensity.toUpperCase())}</span>
12865
13109
  ${statusWord ? `<span class="kicker-sep">·</span><span class="kicker-status status-${this.escape(r.status)}">${statusWord}</span>` : ""}
12866
13110
  </div>
12867
- <h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(this._truncateRoomSubject(r.subject, 30))}</h1>
13111
+ <h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(this._truncateRoomSubject(r.subject, 60))}</h1>
12868
13112
  </div>
12869
13113
  <div class="head-actions">
12870
13114
  <div class="head-cast">${castHtml}</div>
@@ -13089,6 +13333,36 @@
13089
13333
  appendMessageDom(msg) {
13090
13334
  const chat = document.querySelector("[data-chat-messages]");
13091
13335
  if (!chat) return;
13336
+ // Cross-director pipeline · MUST decide hide-on-insert BEFORE
13337
+ // calling messageHtml so the article's very first paint already
13338
+ // carries `data-prewarmed=""`. Without this pre-mark, the
13339
+ // article briefly paints with `.streaming` and no
13340
+ // `data-prewarmed`, and the corner-bracket pulse flashes for
13341
+ // one frame.
13342
+ //
13343
+ // Single authority · `msg.meta.preWarmed === true`. The server
13344
+ // stamps this flag ONLY in the runPickerThenPrewarm path
13345
+ // (room.ts streamSpeakerTurn with preWarmed:true). That path
13346
+ // by definition runs while another director's TTS is playing,
13347
+ // so the flag alone is sufficient. The earlier double-check
13348
+ // against client-side voiceQueues `hasPlaying` introduced a
13349
+ // race: if B's message-appended SSE arrived one frame before
13350
+ // A's first voice-chunk created A's queue client-side,
13351
+ // hasPlaying was false, B skipped the hidden set, and B's
13352
+ // animation flashed. Server flag is the truth — trust it.
13353
+ if (msg.meta && msg.meta.preWarmed === true && msg.authorKind === "agent") {
13354
+ this._prewarmedHidden.add(msg.id);
13355
+ console.log(`[pre-warm] hide msg=${msg.id} author=${msg.authorId} set-size=${this._prewarmedHidden.size}`);
13356
+ } else if (msg.authorKind === "agent" && msg.meta) {
13357
+ // Diagnostic · helps spot the case where the server didn't
13358
+ // stamp meta.preWarmed but the client still saw the bubble
13359
+ // animate during a prior speaker's TTS. If you see this log
13360
+ // for a director that "appeared" mid-prior-speech, the bug
13361
+ // is server-side: the placeholder was inserted via the fresh
13362
+ // path (not runPickerThenPrewarm). Inspect pumpQueue's
13363
+ // preWarmedHit branch for the relevant iteration.
13364
+ console.log(`[pre-warm] NOT hide msg=${msg.id} author=${msg.authorId} preWarmed=${String(msg.meta.preWarmed)} streaming=${String(msg.meta.streaming)}`);
13365
+ }
13092
13366
  // The very first user message in the room renders as the convene block.
13093
13367
  const isOpener = msg.id === this.firstUserMessageId();
13094
13368
  chat.insertAdjacentHTML("beforeend", this.messageHtml(msg, isOpener));
@@ -13096,6 +13370,58 @@
13096
13370
  // (single message, single map lookup) and keeps brand-new
13097
13371
  // bubbles consistent with the rest of the chat.
13098
13372
  this.applyNoteHighlightsForMessage(msg.id);
13373
+ // Safety reveal · defense in depth, NOT a hard 30s cap.
13374
+ //
13375
+ // BUG history · a flat 30s force-reveal here turned into a real
13376
+ // user-visible bug: if the predecessor director's TTS is longer
13377
+ // than 30s, the pre-warmed bubble was sitting legitimately in
13378
+ // "queued" state waiting its turn — the safety fired and ripped
13379
+ // off `data-prewarmed`, the bubble flashed into view alongside
13380
+ // the speaking director, and the user reported "C 出现了 和
13381
+ // B 重叠". A long but healthy wait is exactly what the pre-warm
13382
+ // pipeline is supposed to do.
13383
+ //
13384
+ // Correct semantics · only force-reveal when the pre-warm is
13385
+ // genuinely STUCK (no voice queue ever materialized for this
13386
+ // message, or its state went sideways). If the voice queue is
13387
+ // present and "queued", a sibling MUST be "playing" — the
13388
+ // hard concurrency guard enforces that invariant — so the
13389
+ // normal `_fireVoiceDone` promote will eventually reveal us.
13390
+ // Re-poll instead of giving up.
13391
+ if (this._prewarmedHidden.has(msg.id)) {
13392
+ const msgId = msg.id;
13393
+ const SAFETY_POLL_MS = 30_000;
13394
+ const safetyTick = () => {
13395
+ if (!this._prewarmedHidden.has(msgId)) return; // already revealed
13396
+ const vq = this.voiceQueues && this.voiceQueues[msgId];
13397
+ if (vq && vq.playState === "queued") {
13398
+ // Healthy wait · predecessor's TTS still playing.
13399
+ // Re-poll; do NOT reveal.
13400
+ setTimeout(safetyTick, SAFETY_POLL_MS);
13401
+ return;
13402
+ }
13403
+ // No queue or unexpected state · the pre-warm path went
13404
+ // wrong. Reveal so the user at least sees the text.
13405
+ console.warn(
13406
+ `[pre-warm] safety reveal msg=${msgId} `
13407
+ + `vq=${vq ? vq.playState : "missing"}`,
13408
+ );
13409
+ this._revealPrewarmedBubble(msgId);
13410
+ };
13411
+ setTimeout(safetyTick, SAFETY_POLL_MS);
13412
+ }
13413
+ },
13414
+
13415
+ /** Reveal a previously-hidden pre-warmed chat bubble. Called from
13416
+ * _fireVoiceDone's promote logic and from the message-error SSE
13417
+ * handler. Idempotent · no-op if the bubble isn't marked. Drains
13418
+ * the persistent _prewarmedHidden set so subsequent renderChat
13419
+ * re-renders don't re-hide the bubble. */
13420
+ _revealPrewarmedBubble(messageId) {
13421
+ if (!messageId) return;
13422
+ this._prewarmedHidden.delete(messageId);
13423
+ const bubble = document.querySelector(`[data-message-id="${messageId}"]`);
13424
+ if (bubble && bubble.removeAttribute) bubble.removeAttribute("data-prewarmed");
13099
13425
  },
13100
13426
 
13101
13427
  /** "Thinking…" bouncing-dots placeholder shown before the first token. */
@@ -13120,6 +13446,12 @@
13120
13446
  // chair-pending placeholder card (which is hidden in voice mode
13121
13447
  // because the chat is hidden).
13122
13448
  this.chairPending = true;
13449
+ // Phase string · drives the status-word substitution in the
13450
+ // round-table bubble so the user sees "Picking next speaker" /
13451
+ // "Considering clarify" / etc. instead of generic "Thinking".
13452
+ // i18n lookup happens at render time so locale changes apply
13453
+ // without re-firing the event.
13454
+ this.chairPendingPhase = String(phase || "");
13123
13455
  // Repaint the stage so the seat picks up the thinking state.
13124
13456
  // Cheap when stage is hidden (renderRoundTable bails fast).
13125
13457
  this.renderRoundTable();
@@ -13173,6 +13505,7 @@
13173
13505
  // ready / room-paused / etc).
13174
13506
  const wasPending = this.chairPending === true;
13175
13507
  this.chairPending = false;
13508
+ this.chairPendingPhase = "";
13176
13509
  const chat = document.querySelector("[data-chat-messages]");
13177
13510
  if (chat) {
13178
13511
  const existing = chat.querySelector("[data-chair-pending]");
@@ -13210,34 +13543,42 @@
13210
13543
  try { window.boardroomTypingSfx?.setThinking?.(false); } catch { /* ignore */ }
13211
13544
  let q = this.voiceQueues[chunk.messageId];
13212
13545
  if (!q) {
13213
- // Serialize voice playback · only one TTS clip plays at a
13214
- // time. When a new voice-chunk arrives for a NEW messageId,
13215
- // tear down any currently-active voice queues so their audio
13216
- // doesn't overlap the incoming chair / director's speech.
13217
- // The most common overlap pattern was: round-prompt voice
13218
- // still playing user clicks [Open vote] → server fires
13219
- // runChairRoundEnd new voice chunks arrive → both audios
13220
- // double up. Cancelling stale queues at this seam serializes
13221
- // the voice timeline cleanly without changing the SSE shape.
13222
- const stale = Object.keys(this.voiceQueues || {});
13223
- for (const sid of stale) {
13224
- if (sid === chunk.messageId) continue;
13225
- const sq = this.voiceQueues[sid];
13226
- if (!sq) continue;
13227
- try { if (sq.audio) sq.audio.pause(); } catch (_) {}
13228
- try { if (sq.audio && sq.audio.src) URL.revokeObjectURL(sq.audio.src); } catch (_) {}
13229
- try { if (sq.audio && sq.audio.parentNode) sq.audio.parentNode.removeChild(sq.audio); } catch (_) {}
13230
- try {
13231
- if (sq.mediaSource && sq.mediaSource.readyState === "open") {
13232
- sq.mediaSource.endOfStream();
13233
- }
13234
- } catch (_) {}
13235
- delete this.voiceQueues[sid];
13546
+ // Cross-director pipelining · the server now pre-warms B's
13547
+ // LLM/TTS while A's audio still plays on this client. When
13548
+ // chunks for B's messageId arrive, we MUST NOT tear down A's
13549
+ // queue · A still has audio buffered. Instead, create B's
13550
+ // queue in `playState: "queued"` so it doesn't auto-play.
13551
+ // _fireVoiceDone (when A finishes) promotes the oldest
13552
+ // "queued" sibling to "playing" + calls audio.play().
13553
+ // Serialisation race guard · check ONLY playState, NOT
13554
+ // audio.ended. Scenario the previous `!audio.ended` filter
13555
+ // hit: B is playing, last buffer drains → audio.ended becomes
13556
+ // true audio.ended event queued in the event loop → BEFORE
13557
+ // _fireVoiceDone(B) runs, C's first voice-chunk arrives. With
13558
+ // the `!audio.ended` filter, B looks "done" → hasPlaying=false
13559
+ // → C marked "playing" → C.audio.play() → B AND C overlap
13560
+ // until _fireVoiceDone(B) finally ticks. The explicit promote
13561
+ // inside _fireVoiceDone is the single source of truth for
13562
+ // play-state transitions; until it runs, any playState===
13563
+ // "playing" entry in voiceQueues must block C from auto-play.
13564
+ const hasPlaying = Object.values(this.voiceQueues || {}).some(
13565
+ (other) => other && other.playState === "playing",
13566
+ );
13567
+ const playState = hasPlaying ? "queued" : "playing";
13568
+ q = this._createVoiceStream(roomId, chunk.messageId, playState);
13569
+ // Reveal a previously-hidden chat bubble · race fix · when
13570
+ // A's audio.ended fires BEFORE B's first voice-chunk arrives,
13571
+ // _fireVoiceDone(A) doesn't find B in voiceQueues yet (no
13572
+ // queue created), so it can't promote+reveal B. Now B's first
13573
+ // chunk arrives with no playing sibling left → B is created
13574
+ // as "playing" directly. Without this reveal, B's bubble
13575
+ // remains marked data-prewarmed from appendMessageDom and
13576
+ // never reappears in the chat. Safe to call unconditionally
13577
+ // when playState === "playing" · it's a no-op if the bubble
13578
+ // was never marked.
13579
+ if (playState === "playing") {
13580
+ this._revealPrewarmedBubble(chunk.messageId);
13236
13581
  }
13237
- // Stale queues torn down · the active queue is about to be
13238
- // recreated below. Mini-player refresh runs once after the
13239
- // new queue is inserted (next call site).
13240
- q = this._createVoiceStream(roomId, chunk.messageId);
13241
13582
  // Stash the speaker + current body on the queue so the
13242
13583
  // mini-player can render speaker name / avatar / subtitle.
13243
13584
  // Two sources to consult:
@@ -13262,6 +13603,25 @@
13262
13603
  this._activeVoiceRoomId = roomId;
13263
13604
  // Mini-player · refresh now that a new queue exists.
13264
13605
  this.refreshMiniPlayer();
13606
+ // Topbar queue-head · a fresh "playing" queue (chair templated
13607
+ // voice that arrives outside the director queue, or the first
13608
+ // director on a cold start) means the audible speaker just
13609
+ // changed. Re-render so the head bar reflects who's actually
13610
+ // audible right now instead of whatever the last queue-update
13611
+ // SSE event said.
13612
+ if (playState === "playing") {
13613
+ this.renderQueue();
13614
+ }
13615
+ }
13616
+ // Watchdog feed · every arriving chunk resets the stall timer.
13617
+ // If we had previously surfaced a "stalled" state in the bubble,
13618
+ // un-flag it and repaint so the warning goes away the moment
13619
+ // chunks resume flowing.
13620
+ q.lastChunkAt = Date.now();
13621
+ if (q.stalled) {
13622
+ q.stalled = false;
13623
+ this.renderRoundTable();
13624
+ this.refreshMiniPlayer();
13265
13625
  }
13266
13626
  // Convert base64 to Uint8Array and append to the MSE SourceBuffer
13267
13627
  const binary = atob(chunk.audioBase64);
@@ -13484,7 +13844,7 @@
13484
13844
  },
13485
13845
 
13486
13846
  /** Create a MediaSource-backed audio stream for one speaker's turn. */
13487
- _createVoiceStream(roomId, messageId) {
13847
+ _createVoiceStream(roomId, messageId, playState = "playing") {
13488
13848
  const audio = new Audio();
13489
13849
  // Attach to the DOM · without this Chrome treats the element
13490
13850
  // as "detached media" and the browser is free to pause it
@@ -13569,6 +13929,13 @@
13569
13929
  this.refreshMiniPlayer();
13570
13930
  // Re-assert without logging · fires ~4 Hz, would spam.
13571
13931
  applyRate();
13932
+ // Heartbeat · while audio actively advances, POST
13933
+ // /voice-progress every ~5s so the orchestrator's
13934
+ // waitForVoicePlayback doesn't trip the no-heartbeat
13935
+ // fallback for long responses (slow TTS rate / many
13936
+ // sentences). The server resets its clock on each POST;
13937
+ // only true silence (tab killed) trips the fallback.
13938
+ this._maybeHeartbeatVoiceQueue(q);
13572
13939
  });
13573
13940
 
13574
13941
  const q = {
@@ -13581,6 +13948,14 @@
13581
13948
  final: false,
13582
13949
  doneSent: false,
13583
13950
  ready: false,
13951
+ /** "playing" · audio.play() was called, browser is consuming
13952
+ * buffer through speaker. "queued" · pre-warmed for a later
13953
+ * promote; audio element exists + SourceBuffer fills + chunks
13954
+ * accumulate, but play() has not been called yet. _fireVoiceDone
13955
+ * promotes the oldest queued sibling when the playing queue
13956
+ * ends. enqueueVoiceChunk decides which to create based on
13957
+ * whether ANY existing queue is currently playing. */
13958
+ playState,
13584
13959
  // Caption timing · the index of the caption whose audio bytes
13585
13960
  // are currently being appended to the SourceBuffer. After
13586
13961
  // `updateend` we record `buffered.end(0)` as that caption's
@@ -13588,7 +13963,83 @@
13588
13963
  // finishes. Reading audio.currentTime against these ranges
13589
13964
  // gives accurate per-chunk caption sync (no CBR assumption).
13590
13965
  flushingIdx: -1,
13966
+ // Chunk-arrival watchdog · upstream TTS (MiniMax / ElevenLabs)
13967
+ // occasionally hangs mid-stream and the SourceBuffer just sits
13968
+ // there silently. lastChunkAt is bumped from enqueueVoiceChunk
13969
+ // on every chunk; the interval below checks "have we gone too
13970
+ // long without one?" and surfaces a Skip affordance in the
13971
+ // round-table bubble + auto-fires _fireVoiceDone after a
13972
+ // grace window so the server's waitForVoicePlayback resolves
13973
+ // and the next director starts.
13974
+ lastChunkAt: Date.now(),
13975
+ stalled: false,
13976
+ watchdogId: null,
13591
13977
  };
13978
+ // Start the watchdog · 2s tick, warn at 8s idle, auto-skip at 15s.
13979
+ // Stops itself once q.final is true (audio.ended timeout in
13980
+ // _flushVoiceBuffer takes over) or when the queue is gone.
13981
+ //
13982
+ // Critical · "idle" means BOTH (no new chunks arriving) AND
13983
+ // (audio has caught up to whatever is currently buffered). If
13984
+ // the audio element is still playing through buffer we already
13985
+ // received, we're NOT stalled · we're mid-sentence, and the
13986
+ // server may take 5-15s of "real time" to flush the next
13987
+ // sentence's TTS chunks while the user keeps hearing the
13988
+ // previous one. Without this gate the watchdog fires
13989
+ // mid-utterance on perfectly healthy long-sentence playback.
13990
+ const VOICE_STALL_WARN_MS = 8000;
13991
+ const VOICE_STALL_SKIP_MS = 15000;
13992
+ const VOICE_AUDIO_AHEAD_GRACE_S = 0.5;
13993
+ q.watchdogId = setInterval(() => {
13994
+ const live = this.voiceQueues[q.messageId];
13995
+ if (!live || live !== q) { clearInterval(q.watchdogId); q.watchdogId = null; return; }
13996
+ if (q.final) { clearInterval(q.watchdogId); q.watchdogId = null; return; }
13997
+ // If audio still has buffer to consume, the user is hearing
13998
+ // sound · don't treat this as idle even if no new chunks
13999
+ // arrived recently. Three positive cases:
14000
+ // 1. We have undrained pendingBuffers that haven't been
14001
+ // appended to the SourceBuffer yet.
14002
+ // 2. The SourceBuffer has audio ahead of currentTime.
14003
+ // 3. Audio is paused with content remaining (user paused).
14004
+ let stillConsuming = false;
14005
+ try {
14006
+ if (q.pendingBuffers && q.pendingBuffers.length > 0) {
14007
+ stillConsuming = true;
14008
+ } else if (q.audio && !q.audio.ended) {
14009
+ const buf = q.audio.buffered;
14010
+ if (buf && buf.length > 0) {
14011
+ const aheadOfPlayhead = buf.end(buf.length - 1) - q.audio.currentTime;
14012
+ if (aheadOfPlayhead > VOICE_AUDIO_AHEAD_GRACE_S) stillConsuming = true;
14013
+ // Paused-with-content also counts · user explicitly held
14014
+ // playback, the watchdog shouldn't penalise the pause.
14015
+ if (q.audio.paused && aheadOfPlayhead > 0) stillConsuming = true;
14016
+ }
14017
+ }
14018
+ } catch (_) { /* defensive · treat as not-consuming on error */ }
14019
+ if (stillConsuming) {
14020
+ // Reset the stall warning if it was previously set ·
14021
+ // playback resumed naturally, no need for the amber pill.
14022
+ if (q.stalled) {
14023
+ q.stalled = false;
14024
+ this.renderRoundTable();
14025
+ this.refreshMiniPlayer();
14026
+ }
14027
+ return;
14028
+ }
14029
+ const idle = Date.now() - q.lastChunkAt;
14030
+ if (idle >= VOICE_STALL_SKIP_MS) {
14031
+ console.warn(`[voice-watchdog] auto-skip msg=${q.messageId} idle=${idle}ms (audio exhausted)`);
14032
+ clearInterval(q.watchdogId); q.watchdogId = null;
14033
+ this._fireVoiceDone(q);
14034
+ return;
14035
+ }
14036
+ if (idle >= VOICE_STALL_WARN_MS && !q.stalled) {
14037
+ q.stalled = true;
14038
+ console.warn(`[voice-watchdog] stall warn msg=${q.messageId} idle=${idle}ms (audio exhausted)`);
14039
+ this.renderRoundTable();
14040
+ this.refreshMiniPlayer();
14041
+ }
14042
+ }, 2000);
13592
14043
 
13593
14044
  ms.addEventListener("sourceopen", () => {
13594
14045
  try {
@@ -13614,12 +14065,91 @@
13614
14065
  }
13615
14066
  });
13616
14067
 
13617
- // Start playing as soon as we have some data
13618
- audio.play().catch(() => {});
14068
+ // Start playing as soon as we have some data — but only if this
14069
+ // queue is the active "playing" one. Pre-warmed queues stay in
14070
+ // "queued" state and have their audio.play() deferred until
14071
+ // _fireVoiceDone promotes them when the prior speaker finishes.
14072
+ // Routed through _playVoiceQueueAudio so the hard concurrency
14073
+ // guard catches any cross-director overlap (audio.play() called
14074
+ // while another queue's audio is still mid-playback).
14075
+ if (q.playState === "playing") {
14076
+ this._playVoiceQueueAudio(q, "create-stream");
14077
+ }
13619
14078
 
13620
14079
  return q;
13621
14080
  },
13622
14081
 
14082
+ /** Hard concurrency guard around HTMLAudioElement.play() for voice
14083
+ * queues. Multiple voice elements playing concurrently is the
14084
+ * entire class of "B starts before A finishes" overlap bugs — no
14085
+ * matter what code path requests the play, the guarantee is:
14086
+ * AT MOST ONE voice queue's audio is unpaused at any time.
14087
+ *
14088
+ * Behaviour: before calling .play(), scan every other queue's
14089
+ * audio element. If any has `!paused && !ended`, refuse the new
14090
+ * play (keep this queue in "queued" state) and log a stack trace
14091
+ * so the offending call path can be identified. _fireVoiceDone
14092
+ * for the actually-playing queue will retry the promote loop
14093
+ * when its audio ends, picking this queue back up.
14094
+ *
14095
+ * Returns true when play was started, false when refused. */
14096
+ _playVoiceQueueAudio(q, reason) {
14097
+ if (!q || !q.audio) return false;
14098
+ const conflicts = [];
14099
+ for (const mid of Object.keys(this.voiceQueues)) {
14100
+ if (mid === q.messageId) continue;
14101
+ const other = this.voiceQueues[mid];
14102
+ if (!other || !other.audio) continue;
14103
+ if (other.audio.paused) continue;
14104
+ if (other.audio.ended) continue;
14105
+ conflicts.push({
14106
+ messageId: mid,
14107
+ playState: other.playState,
14108
+ currentTime: other.audio.currentTime,
14109
+ duration: other.audio.duration,
14110
+ });
14111
+ }
14112
+ if (conflicts.length > 0) {
14113
+ console.warn(
14114
+ `[voice-overlap-guard] refusing audio.play() msg=${q.messageId} reason=${reason} · `
14115
+ + `${conflicts.length} other queue(s) still audible:`,
14116
+ conflicts,
14117
+ );
14118
+ // Keep this one queued · _fireVoiceDone's promote loop will
14119
+ // retry once the conflicting audio ends.
14120
+ q.playState = "queued";
14121
+ return false;
14122
+ }
14123
+ q.playState = "playing";
14124
+ try {
14125
+ const p = q.audio.play();
14126
+ if (p && typeof p.catch === "function") p.catch(() => { /* autoplay block */ });
14127
+ } catch (_) { /* defensive */ }
14128
+ return true;
14129
+ },
14130
+
14131
+ /** Throttled heartbeat POST · keeps the server's
14132
+ * waitForVoicePlayback timer alive while this queue's audio is
14133
+ * actively producing sound. Only the currently-playing queue
14134
+ * heartbeats; queued (pre-warmed) queues stay silent so the
14135
+ * server doesn't think they're already playing. Fires at most
14136
+ * once per HEARTBEAT_INTERVAL_MS · 5000 leaves plenty of buffer
14137
+ * inside the server's 60s no-heartbeat fallback while keeping
14138
+ * the network noise low (~12 reqs/min per voice queue). */
14139
+ _maybeHeartbeatVoiceQueue(q) {
14140
+ if (!q || !q.audio || !q.roomId || !q.messageId) return;
14141
+ if (q.playState !== "playing") return;
14142
+ if (q.audio.paused || q.audio.ended) return;
14143
+ const HEARTBEAT_INTERVAL_MS = 5000;
14144
+ const now = Date.now();
14145
+ if (q._lastHeartbeatAt && now - q._lastHeartbeatAt < HEARTBEAT_INTERVAL_MS) return;
14146
+ q._lastHeartbeatAt = now;
14147
+ fetch(
14148
+ `/api/rooms/${encodeURIComponent(q.roomId)}/messages/${encodeURIComponent(q.messageId)}/voice-progress`,
14149
+ { method: "POST" },
14150
+ ).catch(() => { /* best-effort */ });
14151
+ },
14152
+
13623
14153
  /** Flush pending buffers into the SourceBuffer one at a time. */
13624
14154
  _flushVoiceBuffer(q) {
13625
14155
  if (!q.ready || !q.sourceBuffer || q.sourceBuffer.updating) return;
@@ -13650,24 +14180,201 @@
13650
14180
  } catch (_) {}
13651
14181
  // Wait for audio to finish playing, then fire voice-done
13652
14182
  q.audio.addEventListener("ended", () => this._fireVoiceDone(q));
13653
- // Safety timeout in case 'ended' doesn't fire (e.g. empty stream)
13654
- const duration = q.audio.duration;
13655
- const remaining = isFinite(duration) ? (duration - q.audio.currentTime) : 0;
13656
- setTimeout(() => {
13657
- if (!this.voiceQueues[q.messageId]) return; // already fired
13658
- this._fireVoiceDone(q);
13659
- }, (remaining * 1000) + 2000);
14183
+ // Safety timeout · ONLY arm for queues that are actually
14184
+ // playing. A queued (pre-warmed) queue has audio.currentTime
14185
+ // === 0 because audio.play() was never called; the duration-
14186
+ // based remaining estimate is then "all of B's audio" and the
14187
+ // timer fires (duration + 2s) AFTER voice-final which is
14188
+ // typically BEFORE A's audio.ended. _fireVoiceDone(B) then
14189
+ // runs prematurely · the promote loop runs while A is still
14190
+ // playing, promotes the next queued sibling, and B/C overlap
14191
+ // with A. _fireVoiceDone's promote loop re-arms the safety
14192
+ // timer for the newly-promoted queue.
14193
+ if (q.playState === "playing") {
14194
+ this._armVoiceFinalSafetyTimer(q);
14195
+ }
13660
14196
  }
13661
14197
  },
13662
14198
 
14199
+ /** Arm the safety timeout that force-fires _fireVoiceDone if the
14200
+ * HTMLAudioElement's `ended` event never arrives (empty stream,
14201
+ * browser bug, etc.). Computed at arm-time so duration / current-
14202
+ * Time reflect the actual playback state — never set this while
14203
+ * the queue is "queued" (currentTime is stuck at 0 and the timer
14204
+ * fires before the queue even gets to play). Idempotent · second
14205
+ * call is a no-op. */
14206
+ _armVoiceFinalSafetyTimer(q) {
14207
+ if (q.safetyTimerArmed) return;
14208
+ q.safetyTimerArmed = true;
14209
+ const duration = q.audio.duration;
14210
+ const remaining = isFinite(duration) ? (duration - q.audio.currentTime) : 0;
14211
+ setTimeout(() => {
14212
+ if (!this.voiceQueues[q.messageId]) return; // already fired
14213
+ // Don't kill a queue whose audio is still actively advancing.
14214
+ // If currentTime is still meaningfully short of duration, the
14215
+ // 'ended' event hasn't fired yet for legitimate reasons (slow
14216
+ // browser tick, paused-then-resumed, etc.). Re-arm with a
14217
+ // shorter grace and bail.
14218
+ try {
14219
+ if (q.audio && !q.audio.ended) {
14220
+ const cur = q.audio.currentTime || 0;
14221
+ const dur = isFinite(q.audio.duration) ? q.audio.duration : 0;
14222
+ if (dur > 0 && cur < dur - 0.25) {
14223
+ q.safetyTimerArmed = false;
14224
+ setTimeout(() => {
14225
+ if (!this.voiceQueues[q.messageId]) return;
14226
+ this._fireVoiceDone(q);
14227
+ }, Math.max(1000, (dur - cur) * 1000 + 1500));
14228
+ return;
14229
+ }
14230
+ }
14231
+ } catch (_) { /* fall through · force-fire on inspection error */ }
14232
+ this._fireVoiceDone(q);
14233
+ }, (remaining * 1000) + 2000);
14234
+ },
14235
+
13663
14236
  _scheduleVoiceDone(q) {
13664
14237
  // Called from voice-final handler — trigger flush check which will
13665
14238
  // endOfStream + wait for playback to finish.
13666
14239
  this._flushVoiceBuffer(q);
13667
14240
  },
13668
14241
 
14242
+ /* ── LLM first-token watchdog ──────────────────────────────────
14243
+ Per-message timer that flips a streaming agent message's local
14244
+ phase to "llm-warming" when no token has arrived in 8s. Pairs
14245
+ with the server-side 60s hard cap in streamSpeakerTurn —
14246
+ client surfaces an early visual hint long before the server
14247
+ times out, so the user doesn't sit looking at a static
14248
+ "Thinking" bubble. */
14249
+ _llmFirstTokenTimers: null,
14250
+ _LLM_WARM_DELAY_MS: 8000,
14251
+ _startLlmFirstTokenWatchdog(messageId) {
14252
+ if (!messageId) return;
14253
+ if (!this._llmFirstTokenTimers) this._llmFirstTokenTimers = {};
14254
+ this._clearLlmFirstTokenWatchdog(messageId);
14255
+ this._llmFirstTokenTimers[messageId] = setTimeout(() => {
14256
+ const msg = this.currentMessages.find((m) => m.id === messageId);
14257
+ if (!msg || !msg.meta) return;
14258
+ if (msg.meta.streaming !== true) return;
14259
+ if (String(msg.body || "").trim().length > 0) return; // tokens already arrived
14260
+ msg.meta._localPhase = "llm-warming";
14261
+ this.renderRoundTable();
14262
+ this.renderRtSubtitle();
14263
+ }, this._LLM_WARM_DELAY_MS);
14264
+ },
14265
+ _clearLlmFirstTokenWatchdog(messageId) {
14266
+ if (!this._llmFirstTokenTimers || !this._llmFirstTokenTimers[messageId]) return;
14267
+ clearTimeout(this._llmFirstTokenTimers[messageId]);
14268
+ delete this._llmFirstTokenTimers[messageId];
14269
+ },
14270
+
14271
+ /* ── Auto-skip toast ───────────────────────────────────────────
14272
+ Server emits config-event kind=auto-skipped with
14273
+ payload={phase, reason, messageId?}. We render a 3s amber
14274
+ toast at the top of the stage so the user understands WHY the
14275
+ speaker jumped / why the chair didn't ask. The reason field
14276
+ maps to a specific i18n key (snake_case translation of the
14277
+ reason string), with a generic phase-based fallback. */
14278
+ _showAutoSkipToast(phase, reason) {
14279
+ const reasonKey = `auto_skip_${String(reason || "").replace(/-/g, "_")}`;
14280
+ const phaseKey = `auto_skip_${String(phase || "")}_timeout`;
14281
+ let label = this._t(reasonKey);
14282
+ if (label === reasonKey) label = this._t(phaseKey);
14283
+ if (label === phaseKey) label = this._t("rt_audio_stalled"); // last-ditch
14284
+ const stage = document.querySelector("[data-roundtable-stage]");
14285
+ const host = stage || document.body;
14286
+ const toast = document.createElement("div");
14287
+ toast.className = "auto-skip-toast";
14288
+ toast.textContent = label;
14289
+ host.appendChild(toast);
14290
+ // Force a layout read so the CSS animation kicks in reliably.
14291
+ void toast.offsetHeight;
14292
+ toast.classList.add("is-visible");
14293
+ setTimeout(() => {
14294
+ toast.classList.remove("is-visible");
14295
+ setTimeout(() => { try { toast.remove(); } catch (_) {} }, 250);
14296
+ }, 3000);
14297
+ },
14298
+
13669
14299
  _fireVoiceDone(q) {
13670
14300
  if (!this.voiceQueues[q.messageId]) return; // already fired
14301
+ // Stop the chunk-arrival watchdog before tearing down the queue
14302
+ // so a final tick can't race the delete and end up trying to
14303
+ // skip again.
14304
+ if (q.watchdogId) {
14305
+ clearInterval(q.watchdogId);
14306
+ q.watchdogId = null;
14307
+ }
14308
+ // PAUSE FIRST · before the promote loop starts the next queue's
14309
+ // audio. The concurrency guard (_playVoiceQueueAudio) scans all
14310
+ // OTHER queues for unpaused audio — if q.audio is still playing
14311
+ // (e.g. watchdog / safety-timer-triggered force-fire path), the
14312
+ // guard would refuse to promote the next queue. Pausing q here
14313
+ // makes the guard's conflict check correctly exclude q. Also
14314
+ // mark playState='ended' as belt-and-braces so callers that
14315
+ // check playState see "definitely not playing" even before the
14316
+ // delete below.
14317
+ try { q.audio && q.audio.pause(); } catch (_) {}
14318
+ q.playState = "ended";
14319
+ // Audio truly ended · NOW clear the chat bubble's streaming
14320
+ // pulse animation. message-final kept the class alive while
14321
+ // the voice queue existed (LLM done but TTS still playing).
14322
+ // Look up the message's body so updateMessageBodyDom paints
14323
+ // the final text; falls back to empty string when the message
14324
+ // was removed (message-removed race).
14325
+ const finishedMsg = this.currentMessages
14326
+ ? this.currentMessages.find((m) => m.id === q.messageId)
14327
+ : null;
14328
+ this.updateMessageBodyDom(q.messageId, finishedMsg ? finishedMsg.body : "", false);
14329
+ // Cross-director pipeline · before deleting the just-finished
14330
+ // queue, find the next "queued" sibling (pre-warmed by the
14331
+ // server while q was playing) and promote it. The promoted
14332
+ // queue's audio.play() starts immediately — its chunks are
14333
+ // already buffered, so the user hears B with near-zero gap
14334
+ // after A.ended. Insertion order in voiceQueues mirrors
14335
+ // arrival order; iterate Object.keys to pick the OLDEST queued
14336
+ // (the one that has been waiting longest = closest to ready).
14337
+ for (const otherId of Object.keys(this.voiceQueues)) {
14338
+ if (otherId === q.messageId) continue;
14339
+ const other = this.voiceQueues[otherId];
14340
+ if (!other || other.playState !== "queued") continue;
14341
+ // Concurrency guard · q is already paused (q.audio.pause()
14342
+ // at top of _fireVoiceDone). promote sets playState + calls
14343
+ // play() through _playVoiceQueueAudio so any concurrent
14344
+ // audible queue gets surfaced before we start.
14345
+ const started = this._playVoiceQueueAudio(other, "promote-after-end");
14346
+ if (!started) {
14347
+ // Refused · some other queue still has audible audio.
14348
+ // Schedule a one-shot retry so the queued queue isn't
14349
+ // stranded forever. The conflicting queue will eventually
14350
+ // fire _fireVoiceDone (audio.ended / safety timer); at that
14351
+ // point its own promote loop will pick this one up. The
14352
+ // retry is just a belt-and-braces in case both signals
14353
+ // miss (browser glitch in audio.ended).
14354
+ setTimeout(() => {
14355
+ const liveOther = this.voiceQueues[otherId];
14356
+ if (!liveOther || liveOther.playState !== "queued") return;
14357
+ this._playVoiceQueueAudio(liveOther, "promote-retry");
14358
+ if (liveOther.playState === "playing") {
14359
+ this._revealPrewarmedBubble(otherId);
14360
+ if (liveOther.final && liveOther.doneSent && !liveOther.safetyTimerArmed) {
14361
+ this._armVoiceFinalSafetyTimer(liveOther);
14362
+ }
14363
+ }
14364
+ }, 500);
14365
+ break;
14366
+ }
14367
+ // Reveal the chat bubble for the newly-audible director ·
14368
+ // appendMessageDom hid it earlier because A was still playing.
14369
+ this._revealPrewarmedBubble(otherId);
14370
+ // Re-arm the safety timer if it was deferred · queued queues
14371
+ // skip _armVoiceFinalSafetyTimer in _flushVoiceBuffer because
14372
+ // currentTime=0 would make the timer fire mid-playback.
14373
+ if (other.final && other.doneSent && !other.safetyTimerArmed) {
14374
+ this._armVoiceFinalSafetyTimer(other);
14375
+ }
14376
+ break;
14377
+ }
13671
14378
  delete this.voiceQueues[q.messageId];
13672
14379
  if (this._voiceSeenSeqs) delete this._voiceSeenSeqs[q.messageId];
13673
14380
  // Detach from the DOM host · we appended on creation in
@@ -13679,8 +14386,8 @@
13679
14386
  // cross-room queue?" so hide it the moment the active queue
13680
14387
  // drains. Subsequent queues for the same room re-trigger it.
13681
14388
  this.refreshMiniPlayer();
13682
- // Clean up audio element
13683
- try { q.audio.pause(); } catch (_) {}
14389
+ // q.audio.pause() already called at top · revoke the URL +
14390
+ // detach from DOM only.
13684
14391
  try { URL.revokeObjectURL(q.audio.src); } catch (_) {}
13685
14392
  fetch(`/api/rooms/${encodeURIComponent(q.roomId)}/messages/${encodeURIComponent(q.messageId)}/voice-done`, {
13686
14393
  method: "POST",
@@ -13694,6 +14401,13 @@
13694
14401
  // Subtitle · voice playback ended. If nobody else is mid-
13695
14402
  // stream / queued the panel hides itself.
13696
14403
  this.renderRtSubtitle();
14404
+ // Topbar queue-head · the audible speaker may have just
14405
+ // rotated (promote in the loop above) and the server's
14406
+ // queue-update for the new speaker hasn't landed yet.
14407
+ // renderQueueCollapsed prefers the voiceQueues "playing"
14408
+ // speaker, so a re-render here keeps the topbar in sync with
14409
+ // the round-table seat instead of lagging behind the SSE.
14410
+ this.renderQueue();
13697
14411
  // Auto-continue countdown · canAutoContinue refuses to spin
13698
14412
  // up the timer while the chair's voice queue is still active,
13699
14413
  // so the round-prompt's ~5-10s read-aloud doesn't eat into
@@ -13716,6 +14430,7 @@
13716
14430
  stopVoicePlayback() {
13717
14431
  for (const messageId of Object.keys(this.voiceQueues)) {
13718
14432
  const q = this.voiceQueues[messageId];
14433
+ if (q && q.watchdogId) { try { clearInterval(q.watchdogId); } catch (_) {} q.watchdogId = null; }
13719
14434
  if (q && q.audio) {
13720
14435
  try { q.audio.pause(); } catch (_) {}
13721
14436
  try { URL.revokeObjectURL(q.audio.src); } catch (_) {}
@@ -13849,11 +14564,17 @@
13849
14564
  if (!el) return;
13850
14565
  this._ensureMiniPlayerWave();
13851
14566
  let source = null;
13852
- // (1) Live TTS · directors / chair streaming in a voice room.
14567
+ // (1) Live TTS · the queue that is currently AUDIBLE. With
14568
+ // cross-director pipelining we may also have a "queued"
14569
+ // pre-warmed queue sitting in voiceQueues with audio
14570
+ // buffered but play() not yet called · the mini-player
14571
+ // reflects who the user is hearing right now, not who's
14572
+ // ready next.
13853
14573
  for (const mid of Object.keys(this.voiceQueues || {})) {
13854
14574
  const q = this.voiceQueues[mid];
13855
14575
  if (!q || !q.audio) continue;
13856
14576
  if (q.audio.ended) continue;
14577
+ if (q.playState !== "playing") continue;
13857
14578
  source = {
13858
14579
  kind: "live",
13859
14580
  roomId: q.roomId,
@@ -13959,8 +14680,24 @@
13959
14680
  source = null;
13960
14681
  }
13961
14682
  }
13962
- const inOtherRoom = !!(source && source.roomId && source.roomId !== this.currentRoomId);
13963
- if (!inOtherRoom) {
14683
+ // Visibility · hide only when the user is actively viewing the
14684
+ // same room as the playing source. "Actively viewing" means BOTH
14685
+ // currentRoomId matches AND the sidebar's active tab is rooms.
14686
+ // When the user switches to the Agents tab (or any non-rooms
14687
+ // tab), currentRoomId stays sticky to the last-opened room, but
14688
+ // the user is no longer looking at the room's stream — the
14689
+ // player is now their only anchor back to the live audio, so it
14690
+ // has to surface even though the ids match.
14691
+ const activeTabEl = document.querySelector(".sidebar-tab.active[data-sidebar-tab]");
14692
+ const activeTab = activeTabEl ? activeTabEl.dataset.sidebarTab : null;
14693
+ const viewingRoomsTab = !activeTab || activeTab === "rooms";
14694
+ const viewingSameRoom = !!(
14695
+ source &&
14696
+ source.roomId &&
14697
+ source.roomId === this.currentRoomId &&
14698
+ viewingRoomsTab
14699
+ );
14700
+ if (!source || viewingSameRoom) {
13964
14701
  el.hidden = true;
13965
14702
  el.removeAttribute("data-mini-player-room-id");
13966
14703
  el.removeAttribute("data-mini-player-kind");
@@ -14998,8 +15735,19 @@
14998
15735
  const authorIdAttr = (!isUser && !isChair && author?.id)
14999
15736
  ? ` data-author-id="${this.escape(author.id)}"`
15000
15737
  : "";
15738
+ // Pre-warm hidden state · baked into the article tag so it
15739
+ // survives every renderChat re-render. Set membership is the
15740
+ // source of truth (appendMessageDom adds, _revealPrewarmedBubble
15741
+ // drains). Without this, a renderChat that fires between
15742
+ // message-appended and audio promote (e.g. message-updated,
15743
+ // brief-update, round-ended) wipes the data-prewarmed attribute
15744
+ // and the still-pre-warmed bubble flashes its streaming
15745
+ // animation behind the current speaker.
15746
+ const prewarmedAttr = (this._prewarmedHidden && this._prewarmedHidden.has(m.id))
15747
+ ? " data-prewarmed=\"\""
15748
+ : "";
15001
15749
  return `
15002
- <article class="msg ${baseCls}${stateCls.length ? " " + stateCls.join(" ") : ""}" data-message-id="${this.escape(m.id)}" data-meta-kind="${this.escape(metaKind || "")}"${authorIdAttr}>
15750
+ <article class="msg ${baseCls}${stateCls.length ? " " + stateCls.join(" ") : ""}" data-message-id="${this.escape(m.id)}" data-meta-kind="${this.escape(metaKind || "")}"${authorIdAttr}${prewarmedAttr}>
15003
15751
  ${avatarHtml}
15004
15752
  <div class="msg-content">
15005
15753
  ${chairPickKicker}
@@ -15286,15 +16034,56 @@
15286
16034
  renderQueueCollapsed(items) {
15287
16035
  const slot = document.querySelector("[data-queue-collapsed]");
15288
16036
  if (!slot) return;
16037
+ // Cross-director pipeline · prefer the client's actually-audible
16038
+ // speaker over server's queue[0]. With pre-warm, server's queue
16039
+ // can lag the client briefly (between `_fireVoiceDone(A)` →
16040
+ // promote B → server's queue-update for B speaking) — during
16041
+ // that window queue[0] is still A but the user is hearing B.
16042
+ // The round-table seat already uses voiceQueues, so without this
16043
+ // sync the topbar queue-head shows a different name than the
16044
+ // round-table bubble.
16045
+ let liveAuthorId = null;
16046
+ const voiceQueues = this.voiceQueues || {};
16047
+ for (const mid of Object.keys(voiceQueues)) {
16048
+ const q = voiceQueues[mid];
16049
+ if (q && q.playState === "playing" && q.authorId) {
16050
+ liveAuthorId = q.authorId;
16051
+ break;
16052
+ }
16053
+ }
15289
16054
  if (!items || items.length === 0) {
16055
+ // Even with no server queue, if a chair / director is audible
16056
+ // on the client (e.g. chair templated voice plays after the
16057
+ // round drains), surface them in the head bar instead of
16058
+ // sitting on "idle".
16059
+ if (liveAuthorId && this.agentsById[liveAuthorId]) {
16060
+ const liveAgent = this.agentsById[liveAuthorId];
16061
+ slot.innerHTML = `<span class="sum-marker">▶</span>`
16062
+ + `<img class="sum-av" src="${this.escape(liveAgent.avatarPath)}" alt="" data-agent="${this.escape(liveAgent.id)}">`
16063
+ + `<span class="sum-who">${this.escape(liveAgent.name)}</span>`
16064
+ + `<span class="sum-state">${this.escape(this._t("q_speaking"))}</span>`;
16065
+ return;
16066
+ }
15290
16067
  slot.innerHTML = `<span class="sum-marker">·</span><span class="sum-state">${this.escape(this._t("q_idle"))}</span>`;
15291
16068
  return;
15292
16069
  }
15293
16070
  const head = items[0];
15294
- const a = this.agentsById[head.agentId];
16071
+ // If a different director is currently audible (server queue is
16072
+ // racing behind client promote), build the head from the live
16073
+ // speaker. Otherwise fall back to queue[0]. Match against items
16074
+ // first so the queue's own status pill is preserved when the
16075
+ // live speaker IS queue[0].
16076
+ let a = null;
16077
+ let displayStatus = head.status;
16078
+ if (liveAuthorId && this.agentsById[liveAuthorId] && liveAuthorId !== head.agentId) {
16079
+ a = this.agentsById[liveAuthorId];
16080
+ displayStatus = "speaking";
16081
+ } else {
16082
+ a = this.agentsById[head.agentId];
16083
+ }
15295
16084
  if (!a) { slot.innerHTML = ""; return; }
15296
- const speaking = head.status === "speaking";
15297
- const pending = head.status === "pending";
16085
+ const speaking = displayStatus === "speaking";
16086
+ const pending = displayStatus === "pending";
15298
16087
  const stateLabel = speaking
15299
16088
  ? this._t("q_speaking")
15300
16089
  : pending
@@ -15896,22 +16685,53 @@
15896
16685
  replayBody = replayActive.body || "";
15897
16686
  }
15898
16687
  const msgs = this.currentMessages || [];
15899
- if (!speakingId) {
16688
+ // (1) Audible voice queue takes precedence · with cross-director
16689
+ // pipelining, the most-recent streaming message is often the
16690
+ // PRE-WARMED next speaker (B's placeholder lands while A's
16691
+ // audio still plays). The seat that lights up must match
16692
+ // whoever the user is HEARING — not whoever's text is
16693
+ // streaming in the background. Scan voiceQueues for the
16694
+ // playing one and resolve back to its message's author.
16695
+ if (!speakingId && this.voiceQueues) {
15900
16696
  for (let i = msgs.length - 1; i >= 0; i--) {
15901
16697
  const mm = msgs[i];
15902
- if (mm && mm.meta && mm.meta.streaming === true && mm.authorKind === "agent") {
16698
+ if (!mm || mm.authorKind !== "agent") continue;
16699
+ const vq = this.voiceQueues[mm.id];
16700
+ if (vq && vq.playState === "playing") {
15903
16701
  speakingId = mm.authorId;
15904
- const body = String(mm.body || "").trim();
15905
- speakerState = body.length > 0 ? "speaking" : "thinking";
16702
+ speakerState = "speaking";
15906
16703
  break;
15907
16704
  }
15908
16705
  }
15909
16706
  }
15910
- if (!speakingId && this.voiceQueues) {
16707
+ // (2) Streaming message fallback · only relevant when no audible
16708
+ // queue exists (text mode, OR the brief warmup between
16709
+ // message-appended and first voice-chunk). Picks the most
16710
+ // recent streaming agent message that ISN'T pre-warmed —
16711
+ // pre-warmed messages have a voiceQueue with playState
16712
+ // "queued" and should NOT light up a seat.
16713
+ if (!speakingId) {
16714
+ for (let i = msgs.length - 1; i >= 0; i--) {
16715
+ const mm = msgs[i];
16716
+ if (!(mm && mm.meta && mm.meta.streaming === true && mm.authorKind === "agent")) continue;
16717
+ // Skip queued (pre-warmed) speakers · their voice waits for
16718
+ // the playing speaker's audio to finish.
16719
+ const vq = this.voiceQueues && this.voiceQueues[mm.id];
16720
+ if (vq && vq.playState === "queued") continue;
16721
+ speakingId = mm.authorId;
16722
+ const body = String(mm.body || "").trim();
16723
+ speakerState = body.length > 0 ? "speaking" : "thinking";
16724
+ break;
16725
+ }
16726
+ }
16727
+ // (2b) Dead branch removed · the old "voice queue exists at all"
16728
+ // path was replaced by the playing-queue priority above.
16729
+ if (false && !speakingId && this.voiceQueues) {
15911
16730
  for (let i = msgs.length - 1; i >= 0; i--) {
15912
16731
  const mm = msgs[i];
15913
16732
  if (!mm || mm.authorKind !== "agent") continue;
15914
- if (this.voiceQueues[mm.id]) {
16733
+ const vq = this.voiceQueues[mm.id];
16734
+ if (vq && vq.playState === "playing") {
15915
16735
  speakingId = mm.authorId;
15916
16736
  speakerState = "speaking";
15917
16737
  break;
@@ -15945,6 +16765,24 @@
15945
16765
  speakerState = "thinking";
15946
16766
  }
15947
16767
 
16768
+ // Speaker's messageId · derived from the same priority chain
16769
+ // above. Used by the stalled-audio bubble (per-queue watchdog
16770
+ // surfaces on the speaker's bubble + the Skip click needs the
16771
+ // messageId to POST /voice-done). Null when the speaker is
16772
+ // thinking-only (no message id yet) or during replay.
16773
+ let speakingMsgId = null;
16774
+ if (speakingId && this.voiceQueues) {
16775
+ for (let i = msgs.length - 1; i >= 0; i--) {
16776
+ const mm = msgs[i];
16777
+ if (!mm || mm.authorKind !== "agent") continue;
16778
+ if (mm.authorId !== speakingId) continue;
16779
+ if (this.voiceQueues[mm.id]) { speakingMsgId = mm.id; break; }
16780
+ }
16781
+ }
16782
+ const stalledQ = (speakingMsgId && this.voiceQueues && this.voiceQueues[speakingMsgId] && this.voiceQueues[speakingMsgId].stalled)
16783
+ ? this.voiceQueues[speakingMsgId]
16784
+ : null;
16785
+
15948
16786
  // Speaker / thinking SFX · two distinct cues:
15949
16787
  // · `setThinking(true)` starts a looping 8-bit pulse hum
15950
16788
  // while a seat shows the thought-bubble. Idempotent ·
@@ -16048,6 +16886,39 @@
16048
16886
  })[this.conveneState.stage];
16049
16887
  if (stageKey) statusWord = this._t(stageKey);
16050
16888
  }
16889
+ // Chair-pending phase (server-driven · chair-pending payload.phase).
16890
+ // Maps known phase strings to i18n keys so the bubble label
16891
+ // reflects WHAT silent work is happening — picker LLM, clarify
16892
+ // gate, vote summary, brief stages, etc. Falls back to the
16893
+ // existing convene-stage / "Thinking" label when phase is
16894
+ // empty or unrecognised.
16895
+ if (bubbleState === "thinking" && isChair && this.chairPending && this.chairPendingPhase) {
16896
+ const phaseKey = ({
16897
+ "clarify-deciding": "rt_phase_clarify_deciding",
16898
+ "picker-deciding": "rt_phase_picker_deciding",
16899
+ "next-speaker": "rt_phase_next_speaker",
16900
+ "llm-warming": "rt_phase_llm_warming",
16901
+ "vote-summary": "rt_phase_vote_summary",
16902
+ "brief-extracting": "rt_phase_brief_extracting",
16903
+ "brief-composing": "rt_phase_brief_composing",
16904
+ "brief-writing": "rt_phase_brief_writing",
16905
+ })[this.chairPendingPhase];
16906
+ if (phaseKey) statusWord = this._t(phaseKey);
16907
+ }
16908
+ // Per-message LLM first-token watchdog · client-side belt for
16909
+ // the server's 60s hard cap. When a streaming director message
16910
+ // has gone >8s with no message-token arriving, _localPhase is
16911
+ // flipped to "llm-warming" and the bubble shows that label
16912
+ // until the first token lands (or the server auto-skips).
16913
+ if (bubbleState === "thinking" && !isChair) {
16914
+ const streamingMsg = (this.currentMessages || []).slice().reverse().find(
16915
+ (mm) => mm && mm.authorKind === "agent" && mm.authorId === m.id
16916
+ && mm.meta && mm.meta.streaming === true,
16917
+ );
16918
+ if (streamingMsg && streamingMsg.meta && streamingMsg.meta._localPhase === "llm-warming") {
16919
+ statusWord = this._t("rt_phase_llm_warming");
16920
+ }
16921
+ }
16051
16922
  const bubbleCls = bubbleState === "thinking"
16052
16923
  ? "rt-bubble is-thinking"
16053
16924
  : "rt-bubble";
@@ -16083,7 +16954,25 @@
16083
16954
  `<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
16084
16955
  `</div>`;
16085
16956
  } else if (isSpeaking) {
16086
- 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>`;
16957
+ // Stalled-audio variant · the chunk-arrival watchdog flipped
16958
+ // q.stalled when no new TTS chunks arrived for ~8s. Repaint
16959
+ // the bubble amber to signal the issue; the 15s auto-skip
16960
+ // inside the watchdog will recover automatically.
16961
+ if (stalledQ && stalledQ.messageId === (this.voiceQueues[speakingMsgId]?.messageId)) {
16962
+ // Audio stalled · the chunk-arrival watchdog flipped
16963
+ // q.stalled when no new TTS chunks arrived for ~8s. Show
16964
+ // an amber state on the bubble so the user understands
16965
+ // why playback paused. The 15s auto-skip in the watchdog
16966
+ // recovers automatically · no manual click affordance.
16967
+ const stalledText = this._t("rt_audio_stalled");
16968
+ bubble = `<div class="${bubbleCls} is-stalled"`
16969
+ + ` title="${this.escape(stalledText)}">`
16970
+ + `<span class="rt-bubble-name">${this.escape(m.name || "")}</span>`
16971
+ + `<span class="rt-bubble-status">${this.escape(stalledText)}</span>`
16972
+ + `</div>`;
16973
+ } else {
16974
+ 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>`;
16975
+ }
16087
16976
  }
16088
16977
  const badge = (isQueued && !isSpeaking)
16089
16978
  ? `<div class="rt-badge">${String(qIdx + 1).padStart(2, "0")}</div>`
@@ -16271,35 +17160,48 @@
16271
17160
  replayAudio = window.boardroomVoiceReplay.getActiveAudio() || null;
16272
17161
  }
16273
17162
  }
16274
- // (1) Most recent streaming agent message · authoritative for
16275
- // text-mode and the streaming phase of voice-mode turns.
16276
- if (!speakerId) {
17163
+ // (1) Currently audible voice queue · with cross-director
17164
+ // pipelining, the most-recent streaming message may be the
17165
+ // pre-warmed next speaker whose audio is still queued. The
17166
+ // subtitle should reflect who the USER IS HEARING — the
17167
+ // "playing" queue — not whoever is streaming text in the
17168
+ // background. Check audible-playing queue first; only fall
17169
+ // back to "most recent streaming" when no queue is playing
17170
+ // (text mode or pre-stream warmup).
17171
+ if (!speakerId && this.voiceQueues) {
16277
17172
  for (let i = msgs.length - 1; i >= 0; i--) {
16278
17173
  const m = msgs[i];
16279
- if (m && m.meta && m.meta.streaming === true && m.authorKind === "agent") {
17174
+ if (!m || m.authorKind !== "agent") continue;
17175
+ const vq = this.voiceQueues[m.id];
17176
+ if (vq && vq.playState === "playing") {
16280
17177
  speakerId = m.authorId;
16281
17178
  body = m.body || "";
16282
- isStreaming = true;
16283
- if (this.voiceQueues && this.voiceQueues[m.id]) {
16284
- activeQueue = this.voiceQueues[m.id];
16285
- }
17179
+ activeQueue = vq;
17180
+ // isStreaming stays false · this branch covers
17181
+ // playback whether the LLM stream is still going OR done.
16286
17182
  break;
16287
17183
  }
16288
17184
  }
16289
17185
  }
16290
- // (2) Fallback · stream finished but voice clip is still
16291
- // playing (voice queue not yet drained). Keep the caption
16292
- // up so the user reads while listening. Catches chair
16293
- // templated voice (announceRoundPrompt / announceIntervention)
16294
- // too those emit voice without setting meta.streaming.
16295
- if (!speakerId && this.voiceQueues) {
17186
+ // (1b) Streaming-text fallback · no audible queue (text mode
17187
+ // OR pre-warm gap). The most-recent streaming agent
17188
+ // message wins, even if its voice queue is "queued" —
17189
+ // this captures text-mode turns and the brief moment
17190
+ // between message-appended and first voice-chunk.
17191
+ if (!speakerId) {
16296
17192
  for (let i = msgs.length - 1; i >= 0; i--) {
16297
17193
  const m = msgs[i];
16298
- if (!m || m.authorKind !== "agent") continue;
16299
- if (this.voiceQueues[m.id]) {
17194
+ if (m && m.meta && m.meta.streaming === true && m.authorKind === "agent") {
16300
17195
  speakerId = m.authorId;
16301
17196
  body = m.body || "";
16302
- activeQueue = this.voiceQueues[m.id];
17197
+ isStreaming = true;
17198
+ // Only attach a voice queue if it's the playing one ·
17199
+ // a queued voice queue stays in the background until
17200
+ // promote.
17201
+ if (this.voiceQueues && this.voiceQueues[m.id]
17202
+ && this.voiceQueues[m.id].playState === "playing") {
17203
+ activeQueue = this.voiceQueues[m.id];
17204
+ }
16303
17205
  break;
16304
17206
  }
16305
17207
  }
@@ -16556,6 +17458,11 @@
16556
17458
  const isVoice = !!(this.currentRoom && this.currentRoom.deliveryMode === "voice");
16557
17459
  const rate = this.voicePlaybackRate();
16558
17460
  const rateLabel = (rate === 1 ? "1.0" : String(rate)) + "X";
17461
+ // Skip Current Speaker affordance lives inline on the queue
17462
+ // row's `.actions` slot (see renderQueue) · the user's eye is
17463
+ // already on the speaker name there, and a button on the HUD's
17464
+ // status panel felt detached. The HUD keeps the Rate control
17465
+ // (voice-mode only) as its single tab.
16559
17466
  const rateRow = isVoice
16560
17467
  ? `
16561
17468
  <div class="rt-hud-controls">
@@ -18099,13 +19006,31 @@
18099
19006
  }
18100
19007
  // Floating mini-player · the whole bar is the click target.
18101
19008
  // A click anywhere on the surface (including the inner jump
18102
- // button) navigates to the room. Match the outer aside so
18103
- // any descendant click bubbles up to the same handler.
19009
+ // button) jumps back to the live room. Match the outer aside
19010
+ // so any descendant click bubbles up to the same handler.
19011
+ //
19012
+ // Use `boardroomFocusRoom` (not the bare `navigateToRoom`)
19013
+ // because the common entry path — Agents tab with an open
19014
+ // agent profile — has BOTH `location.hash` already pointing
19015
+ // at `#/r/<id>` AND `currentRoomId === id`. Plain hash
19016
+ // assignment is a no-op for the browser and `handleRoute`
19017
+ // would early-return either way; the agent profile would
19018
+ // stay covering the main view and the sidebar would stay
19019
+ // on the Agents tab. boardroomFocusRoom closes the profile
19020
+ // overlay, switches the sidebar tab back to Rooms, and
19021
+ // re-lights the room's sidebar row via markActiveRoom.
19022
+ // Falls back to navigateToRoom on the rare path where the
19023
+ // index.html IIFE hasn't published its window globals yet.
18104
19024
  if (e.target.closest("[data-mini-player]")) {
18105
19025
  e.preventDefault();
18106
19026
  const el = document.querySelector("[data-mini-player]");
18107
19027
  const roomId = el && el.getAttribute("data-mini-player-room-id");
18108
- if (roomId) app.navigateToRoom(roomId);
19028
+ if (!roomId) return;
19029
+ if (typeof window.boardroomFocusRoom === "function") {
19030
+ window.boardroomFocusRoom(roomId);
19031
+ } else {
19032
+ app.navigateToRoom(roomId);
19033
+ }
18109
19034
  return;
18110
19035
  }
18111
19036
  if (e.target.closest("[data-divergence-close]")) {
@@ -18614,31 +19539,6 @@
18614
19539
  }
18615
19540
  // View Report — link opens /report.html?r=<id> in a new tab; let the
18616
19541
  // anchor handle navigation. No preventDefault.
18617
- // Starter card · empty-state quick-convene. Avatars opt out (their
18618
- // own [data-agent-profile] handler runs via agent-profile.js capture
18619
- // phase). Both the [▶ Start] button and a click anywhere else on the
18620
- // card body fire the starter action.
18621
- const starterAv = e.target.closest("[data-agent-profile]");
18622
- if (starterAv && e.target.closest(".starter-card")) {
18623
- // Let agent-profile.js handle this; don't also start the room.
18624
- return;
18625
- }
18626
- const starterGo = e.target.closest("[data-starter-go]");
18627
- if (starterGo) {
18628
- e.preventDefault();
18629
- const idx = parseInt(starterGo.getAttribute("data-starter-go"), 10);
18630
- const list = window.BOARDROOM_STARTERS || [];
18631
- if (Number.isFinite(idx) && list[idx]) app.createStarterRoom(list[idx]);
18632
- return;
18633
- }
18634
- const starterCard = e.target.closest("[data-starter-idx]");
18635
- if (starterCard) {
18636
- e.preventDefault();
18637
- const idx = parseInt(starterCard.getAttribute("data-starter-idx"), 10);
18638
- const list = window.BOARDROOM_STARTERS || [];
18639
- if (Number.isFinite(idx) && list[idx]) app.createStarterRoom(list[idx]);
18640
- return;
18641
- }
18642
19542
  // ─── New room trigger (sidebar "+ New room" button, etc.) ──────
18643
19543
  // Was bound to the overlay; now just closes any active room so the
18644
19544
  // composer empty state shows. setComposerMode also flips the main
@@ -18988,12 +19888,15 @@
18988
19888
  app.closeComposerDropdown();
18989
19889
  return;
18990
19890
  }
18991
- // ─── Agent composer · starter promptfill textarea
18992
- const agStarter = e.target.closest("[data-agent-starter]");
18993
- if (agStarter) {
19891
+ // ─── Agent composer · celebrity seed card kick full-mode
19892
+ // persona build with the seed's `description`. The card
19893
+ // tags `personaJob.celebritySeedId` so the save-success
19894
+ // handler can mark the seed permanently consumed.
19895
+ const celebCard = e.target.closest("[data-celebrity-seed]");
19896
+ if (celebCard) {
18994
19897
  e.preventDefault();
18995
- const idx = parseInt(agStarter.getAttribute("data-agent-starter"), 10);
18996
- app.applyAgentStarter(idx);
19898
+ const id = celebCard.getAttribute("data-celebrity-seed");
19899
+ if (id) app.applyCelebritySeed(id);
18997
19900
  return;
18998
19901
  }
18999
19902
  // Convene button → submit
@@ -19002,31 +19905,18 @@
19002
19905
  app.submitComposer();
19003
19906
  return;
19004
19907
  }
19005
- // Starter rowfill composer + scroll into view
19006
- const composerStarter = e.target.closest("[data-composer-starter]");
19007
- if (composerStarter) {
19908
+ // Scenario ad card autoconfigure composer state with the
19909
+ // card's preset (tone + intensity + 3 directors + starter
19910
+ // subject), then re-render so the user lands on a working
19911
+ // room shape. Inner [data-agent-profile] still fires the
19912
+ // profile overlay via agent-profile.js's capture handler.
19913
+ const scenarioCard = e.target.closest("[data-scenario-id]");
19914
+ if (scenarioCard && !e.target.closest("[data-agent-profile]")) {
19008
19915
  e.preventDefault();
19009
- const idx = parseInt(composerStarter.getAttribute("data-composer-starter"), 10);
19010
- if (Number.isFinite(idx)) app.applyComposerStarter(idx);
19916
+ const id = scenarioCard.getAttribute("data-scenario-id");
19917
+ if (id) app.applyScenarioCard(id);
19011
19918
  return;
19012
19919
  }
19013
- // Topic-rec card → apply via /api/topic-recs/:id so the
19014
- // full seedContext lands in composer state.
19015
- const composerRec = e.target.closest("[data-cmp-rec]");
19016
- if (composerRec) {
19017
- e.preventDefault();
19018
- const id = composerRec.getAttribute("data-cmp-rec");
19019
- if (id) app.applyTopicRec(id);
19020
- return;
19021
- }
19022
- // Topic-rec trigger button → start a fresh generation job.
19023
- if (e.target.closest("[data-cmp-recs-trigger]")) {
19024
- e.preventDefault();
19025
- void app.startTopicRecJob();
19026
- return;
19027
- }
19028
- // ("+ N more" pagination removed · tray now always shows
19029
- // the latest 6 recs and wipes on each fresh generation.)
19030
19920
  // Delete a room (any state): confirm, then real DELETE on the backend.
19031
19921
  const del = e.target.closest("[data-room-delete]");
19032
19922
  if (del) {
@@ -19328,10 +20218,19 @@
19328
20218
  // queue while the tab was backgrounded (some Chrome versions
19329
20219
  // do this even with Media Session declared), resume them on
19330
20220
  // tab return so the user doesn't have to manually re-start.
20221
+ //
20222
+ // CRITICAL · only resume queues whose playState is "playing".
20223
+ // Pre-warmed queues sit in "queued" state with audio.paused=true
20224
+ // (audio.play() was never called); blindly calling .play() on
20225
+ // them starts the next speaker in parallel with the current one
20226
+ // and produces the A/B overlap reported on macOS workspace
20227
+ // switches, Cmd+Tab, Mission Control · each visibilitychange
20228
+ // would resume every queue indiscriminately.
19331
20229
  if (app && app.voiceQueues) {
19332
20230
  for (const mid of Object.keys(app.voiceQueues)) {
19333
20231
  const q = app.voiceQueues[mid];
19334
20232
  if (!q || !q.audio) continue;
20233
+ if (q.playState !== "playing") continue;
19335
20234
  if (q.audio.paused && !q.audio.ended) {
19336
20235
  try {
19337
20236
  const p = q.audio.play();