privateboard 0.1.32 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -148,6 +148,15 @@
148
148
  * appendMessageDom, drained in _revealPrewarmedBubble, baked into
149
149
  * messageHtml so every render preserves the hidden state. */
150
150
  _prewarmedHidden: new Set(),
151
+ /** Server-authoritative "currently-visible speaker" messageId.
152
+ * Pushed via queue-update SSE (orchestrator/room.ts pumpQueue).
153
+ * Drives the .streaming class gate in `updateMessageBodyDom` so
154
+ * only the one director the server marks as the active turn
155
+ * shows the typing-dots / pulse animation, even in the brief
156
+ * same-frame window where A's finalize and B's appended SSE
157
+ * events both reach the DOM. Null between turns / before first
158
+ * turn. */
159
+ currentActiveMessageId: null,
151
160
  /** Mini-player anchor · when set, the cross-room bar persists
152
161
  * for this voice room throughout its whole live/paused life
153
162
  * (between speakers, during votes / clarify, idle phases) —
@@ -391,6 +400,31 @@
391
400
  // page-reload shortcut (it's not a reserved keystroke).
392
401
  // Menu reload / URL-bar reload still get caught by the
393
402
  // `beforeunload` handler below.
403
+ // Search overlay keyboard · Cmd+K / Ctrl+K toggles the
404
+ // floating search palette; Esc closes it when open. Both
405
+ // pre-empt other handlers — registered with capture so the
406
+ // overlay reliably opens / closes regardless of where focus
407
+ // sits in the document.
408
+ document.addEventListener("keydown", (e) => {
409
+ const isCmdK = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey
410
+ && (e.key === "k" || e.key === "K" || e.code === "KeyK");
411
+ if (isCmdK) {
412
+ e.preventDefault();
413
+ const overlay = document.querySelector("[data-search-overlay]");
414
+ if (!overlay) return;
415
+ if (overlay.hasAttribute("hidden")) this.openSearch();
416
+ else this.closeSearchOverlay();
417
+ return;
418
+ }
419
+ if (e.key === "Escape") {
420
+ const overlay = document.querySelector("[data-search-overlay]");
421
+ if (overlay && !overlay.hasAttribute("hidden")) {
422
+ e.preventDefault();
423
+ this.closeSearchOverlay();
424
+ }
425
+ }
426
+ }, true);
427
+
394
428
  document.addEventListener("keydown", (e) => {
395
429
  const isReload = (e.ctrlKey || e.metaKey)
396
430
  && !e.altKey
@@ -528,6 +562,19 @@
528
562
  this.refreshMiniPlayer();
529
563
  if (!this.currentRoomId) return;
530
564
  this.renderRtSubtitle();
565
+ // Karaoke-style sentence highlight inside the chat bubble of
566
+ // the currently-playing message · long agent replies are easy
567
+ // to lose your place in when TTS reads them aloud, so the
568
+ // sentence at the audio cursor gets a theme-color tint.
569
+ this.updateTtsSentenceHighlight();
570
+ });
571
+ // Replay state transitions · when playback stops (manual close,
572
+ // playlist exhausted), clear any lingering sentence highlight
573
+ // so the chat doesn't keep glowing the last-read line.
574
+ document.addEventListener("boardroom:replay-active", () => {
575
+ const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
576
+ const active = (vr && typeof vr.getActive === "function") ? vr.getActive() : null;
577
+ if (!active) this._clearTtsSentenceHighlights();
531
578
  });
532
579
  window.addEventListener("hashchange", () => this.handleRoute());
533
580
  this.handleRoute();
@@ -1126,6 +1173,12 @@
1126
1173
  : (data.members || []).map((m) => ({ ...m, removedAt: null }));
1127
1174
  this.currentChair = data.chair || null;
1128
1175
  this.currentQueue = data.queue || [];
1176
+ // Reset to null on room open · the queue-update SSE will push
1177
+ // the real value if a director is currently speaking. The
1178
+ // initial GET /api/rooms/:id snapshot doesn't carry this field
1179
+ // (it's a transient pump state, not persisted to messages), so
1180
+ // we wait for the first SSE tick.
1181
+ this.currentActiveMessageId = null;
1129
1182
  this.currentRound = data.round || { spoken: 0, total: 0 };
1130
1183
  this.currentKeyPoints = data.keyPoints || [];
1131
1184
  // Reset the round-table HUD log when (re)opening a room. Past
@@ -1364,6 +1417,7 @@
1364
1417
  // re-opened (since `openRoom` re-sets it from data.chair),
1365
1418
  // but the empty + composer states looked broken.
1366
1419
  this.currentQueue = [];
1420
+ this.currentActiveMessageId = null;
1367
1421
  this.currentRound = { spoken: 0, total: 0 };
1368
1422
  this.currentKeyPoints = [];
1369
1423
  this.rtChairLog = [];
@@ -1940,6 +1994,15 @@
1940
1994
  msg.body = data.body;
1941
1995
  msg.meta = data.meta || {};
1942
1996
  }
1997
+ // Server cleared meta.preWarmed (pump just consumed this
1998
+ // speaker · it is now the visible active turn). Drain from
1999
+ // the hidden set BEFORE renderChat so the article re-paints
2000
+ // without `data-prewarmed`. Otherwise the CSS `display:none`
2001
+ // would persist across this re-render until the next event.
2002
+ if (data.meta && data.meta.preWarmed === false
2003
+ && this._prewarmedHidden && this._prewarmedHidden.has(data.messageId)) {
2004
+ this._revealPrewarmedBubble(data.messageId);
2005
+ }
1943
2006
  // Re-render via a chat repaint · the tool-use renderer is
1944
2007
  // selected by meta.kind so it rebuilds with the new status.
1945
2008
  this.renderChat();
@@ -1949,6 +2012,32 @@
1949
2012
  const data = JSON.parse(e.data);
1950
2013
  this.currentQueue = data.queue || [];
1951
2014
  if (data.round) this.currentRound = data.round;
2015
+ // activeMessageId · server tells us which director's bubble
2016
+ // is the currently-visible "speaking" turn. Two consumers:
2017
+ // (1) `updateMessageBodyDom` gates the streaming animation
2018
+ // on this id (suppresses the brief text-mode flash where
2019
+ // A's finalize and B's append cross paths in the same rAF),
2020
+ // (2) a hidden pre-warmed bubble whose id matches gets
2021
+ // revealed here (defense in depth alongside the message-
2022
+ // updated meta.preWarmed:false signal — whichever arrives
2023
+ // first wins).
2024
+ const prevActive = this.currentActiveMessageId || null;
2025
+ this.currentActiveMessageId = (data && typeof data.activeMessageId === "string")
2026
+ ? data.activeMessageId
2027
+ : (data && data.activeMessageId === null ? null : prevActive);
2028
+ if (this.currentActiveMessageId
2029
+ && this._prewarmedHidden
2030
+ && this._prewarmedHidden.has(this.currentActiveMessageId)) {
2031
+ this._revealPrewarmedBubble(this.currentActiveMessageId);
2032
+ }
2033
+ // If the active id changed AND the streaming class is now on
2034
+ // the wrong bubble, refresh both. Only affects bubbles that
2035
+ // are still in streaming state; finalized bubbles never carry
2036
+ // .streaming so they're not re-touched.
2037
+ if (prevActive !== this.currentActiveMessageId) {
2038
+ this._refreshStreamingClassForId(prevActive);
2039
+ this._refreshStreamingClassForId(this.currentActiveMessageId);
2040
+ }
1952
2041
  this.renderQueue();
1953
2042
  });
1954
2043
 
@@ -3504,12 +3593,53 @@
3504
3593
  `;
3505
3594
  }).join("");
3506
3595
  document.body.appendChild(pop);
3507
- // Anchor under the wrapper · `.ap-model-picker` is
3508
- // `position: fixed` so we set top/left relative to viewport.
3596
+ // Anchor relative to the wrapper · `.ap-model-picker` is
3597
+ // `position: fixed` so we set top/left in viewport coords.
3598
+ // Flip-above logic · the house-style dropdown sits in the
3599
+ // SECOND row of the modal and the modal is centred in the
3600
+ // viewport, so the trigger often lands close to the lower
3601
+ // half of the screen. A naive "always below" placement gets
3602
+ // clipped at the viewport bottom (visually: half the list
3603
+ // hidden under the chrome). We measure the rendered popover
3604
+ // height and choose the side with more room — preferring
3605
+ // below by default, flipping above when below is tight and
3606
+ // above has more space. The base `.ap-model-picker` rule
3607
+ // caps height at 60vh + scrolls inside, so a long menu still
3608
+ // fits even after flip.
3509
3609
  const r = wrap.getBoundingClientRect();
3510
- pop.style.top = `${Math.round(r.bottom + 4)}px`;
3511
3610
  pop.style.left = `${Math.round(r.left)}px`;
3512
3611
  pop.style.width = `${Math.round(r.width)}px`;
3612
+ // First paint to measure; the visible top is intentionally
3613
+ // off-screen until the position decision lands (avoids a
3614
+ // one-frame flash at the wrong location).
3615
+ pop.style.top = "-9999px";
3616
+ pop.style.visibility = "hidden";
3617
+ const PAD = 8;
3618
+ const GAP = 4;
3619
+ const popH = pop.getBoundingClientRect().height;
3620
+ const spaceBelow = Math.max(0, window.innerHeight - r.bottom - PAD);
3621
+ const spaceAbove = Math.max(0, r.top - PAD);
3622
+ let topPx;
3623
+ if (popH + GAP <= spaceBelow) {
3624
+ // Fits below comfortably.
3625
+ topPx = Math.round(r.bottom + GAP);
3626
+ } else if (popH + GAP <= spaceAbove) {
3627
+ // Fits above comfortably · flip.
3628
+ topPx = Math.round(r.top - popH - GAP);
3629
+ } else if (spaceAbove > spaceBelow) {
3630
+ // Neither side fits the full list — pick the bigger side
3631
+ // (above) and cap max-height so the popover's own internal
3632
+ // scroll (`.ap-model-picker { overflow-y: auto }`) handles
3633
+ // the overflow rather than the viewport edge clipping us.
3634
+ topPx = PAD;
3635
+ pop.style.maxHeight = `${spaceAbove}px`;
3636
+ } else {
3637
+ // Stay below · same cap-and-scroll trick.
3638
+ topPx = Math.round(r.bottom + GAP);
3639
+ pop.style.maxHeight = `${spaceBelow}px`;
3640
+ }
3641
+ pop.style.top = `${topPx}px`;
3642
+ pop.style.visibility = "";
3513
3643
  // Dismiss handlers · attached synchronously, but the trigger's
3514
3644
  // own wrap is whitelisted so the click that OPENED the popover
3515
3645
  // (which fires AFTER mousedown · same user gesture) doesn't
@@ -3745,6 +3875,14 @@
3745
3875
  if (choice === "cancel" || !text.trim()) return;
3746
3876
  if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
3747
3877
  input.value = "";
3878
+ // Shrink the room input-bar textarea back to baseline · the
3879
+ // user grew it by typing the message we're now sending, so
3880
+ // the post-clear field would otherwise stay at the prior
3881
+ // tall height. Mirrors the same call submitFromComposer
3882
+ // makes on the direct (no-modal) send path.
3883
+ if (input.matches?.(".ib-textarea[data-send-input]")) {
3884
+ this.autosizeRoomInputTextarea();
3885
+ }
3748
3886
  }
3749
3887
  if (choice === "interrupt") {
3750
3888
  this.sendMessage(text, mentions).catch((err) => alert("Send failed: " + err.message));
@@ -4798,6 +4936,217 @@
4798
4936
  this.refreshFollowUpCastButton();
4799
4937
  },
4800
4938
 
4939
+ /* ─── Cast-edit popover · room-head "Add director" button ───
4940
+ Anchored under the head icon. Lists every director with
4941
+ checkboxes pre-checked for current cast members. Diffs against
4942
+ initial state on confirm; PATCH /api/rooms/:id/members applies
4943
+ adds + removes atomically. The orchestrator's announceMember-
4944
+ Change posts a chair welcome / farewell in chat, and any newly-
4945
+ added director gets injected into the live speaker queue. */
4946
+ openCastEditOverlay(anchorBtn) {
4947
+ this.closeCastEditOverlay();
4948
+ if (!anchorBtn || !this.currentRoomId || !this.currentRoom) return;
4949
+ if (this.currentRoom.status === "adjourned") return;
4950
+
4951
+ const dirs = (this.agents || [])
4952
+ .filter((a) => a.roleKind !== "moderator")
4953
+ .slice()
4954
+ .sort((a, b) => (a.name || "").localeCompare(b.name || ""));
4955
+ const currentIds = new Set(
4956
+ (this.currentMembers || [])
4957
+ .filter((m) => (m.roleKind || "director") !== "moderator")
4958
+ .map((m) => m.id),
4959
+ );
4960
+ // State carries the INITIAL set (for diff on confirm) + the
4961
+ // user's working selection (mutated by toggleCastEditDirector).
4962
+ this._castEditState = {
4963
+ initialIds: new Set(currentIds),
4964
+ currentIds: new Set(currentIds),
4965
+ saving: false,
4966
+ };
4967
+
4968
+ const rows = dirs.map((a) => {
4969
+ const checked = currentIds.has(a.id);
4970
+ return `
4971
+ <label class="composer-pick-row${checked ? " on" : ""}" data-cast-edit-pick-id="${this.escape(a.id)}">
4972
+ <input type="checkbox" ${checked ? "checked" : ""}>
4973
+ <img class="composer-pick-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}">
4974
+ <span class="composer-pick-main">
4975
+ <span class="composer-pick-name">${this.escape(a.name || "")}</span>
4976
+ <span class="composer-pick-tag">${this.escape(a.roleTag || "")}</span>
4977
+ </span>
4978
+ </label>
4979
+ `;
4980
+ }).join("");
4981
+
4982
+ const pop = document.createElement("div");
4983
+ pop.id = "cast-edit-pop";
4984
+ pop.className = "cast-edit-pop";
4985
+ pop.innerHTML = `
4986
+ <div class="composer-pick-head">
4987
+ <span class="composer-pick-title">${this.escape(this._t("cast_edit_title"))}</span>
4988
+ </div>
4989
+ <div class="composer-pick-list">${rows || `<div class="composer-pick-empty">${this.escape(this._t("cast_edit_empty"))}</div>`}</div>
4990
+ <div class="cast-edit-foot">
4991
+ <span class="cast-edit-floor-msg" data-cast-edit-floor>${this.escape(this._t("cast_edit_min_floor"))}</span>
4992
+ <button type="button" class="cast-edit-btn" data-cast-edit-close>${this.escape(this._t("cast_edit_cancel"))}</button>
4993
+ <button type="button" class="cast-edit-btn is-primary" data-cast-edit-confirm disabled>${this.escape(this._t("cast_edit_confirm"))}</button>
4994
+ </div>
4995
+ `;
4996
+ document.body.appendChild(pop);
4997
+
4998
+ // Position · mirrors openFollowUpCastPicker · pick the side with
4999
+ // more room (the head icon sits near the top so "below" usually
5000
+ // wins). Cap max-height to the available space so the row list
5001
+ // scrolls within view instead of overflowing.
5002
+ const r = anchorBtn.getBoundingClientRect();
5003
+ const MARGIN = 8;
5004
+ const GAP = 6;
5005
+ const popW = Math.min(340, window.innerWidth - MARGIN * 2);
5006
+ const spaceBelow = window.innerHeight - r.bottom - GAP - MARGIN;
5007
+ const spaceAbove = r.top - GAP - MARGIN;
5008
+ const openAbove = spaceAbove > spaceBelow;
5009
+ // Cap the popover height · without this the list stretches to
5010
+ // every available pixel of `spaceBelow` and visually anchors to
5011
+ // the window's bottom edge (~750px on a 13" laptop). The cap
5012
+ // is `min(420px, 55vh)` — enough rows to scan ~6-7 directors
5013
+ // without scrolling, short enough to read as a compact menu
5014
+ // instead of a full-height panel.
5015
+ const HEIGHT_CAP = Math.min(420, Math.round(window.innerHeight * 0.55));
5016
+ const available = openAbove ? spaceAbove : spaceBelow;
5017
+ const maxHeight = Math.max(180, Math.min(HEIGHT_CAP, available));
5018
+ pop.style.width = popW + "px";
5019
+ // Right-align to the anchor so the popover doesn't jut past the
5020
+ // right edge of the window (head-actions sits flush right).
5021
+ const left = Math.min(
5022
+ Math.max(MARGIN, r.right - popW),
5023
+ window.innerWidth - popW - MARGIN,
5024
+ );
5025
+ pop.style.left = left + "px";
5026
+ pop.style.maxHeight = maxHeight + "px";
5027
+ pop.style.top = openAbove
5028
+ ? (r.top - GAP - Math.min(pop.scrollHeight || maxHeight, maxHeight)) + "px"
5029
+ : (r.bottom + GAP) + "px";
5030
+
5031
+ // Outside-click + Esc dismiss · same dismiss vocabulary as the
5032
+ // follow-up cast picker. We bind the outside-click on the NEXT
5033
+ // tick so this open click doesn't immediately close.
5034
+ this._castEditEsc = (ev) => {
5035
+ if (ev.key === "Escape") {
5036
+ ev.stopImmediatePropagation();
5037
+ this.closeCastEditOverlay();
5038
+ }
5039
+ };
5040
+ this._castEditOutside = (ev) => {
5041
+ if (
5042
+ !pop.contains(ev.target)
5043
+ && !ev.target.closest("[data-cast-edit-trigger]")
5044
+ ) {
5045
+ this.closeCastEditOverlay();
5046
+ }
5047
+ };
5048
+ document.addEventListener("keydown", this._castEditEsc, true);
5049
+ setTimeout(() => document.addEventListener("click", this._castEditOutside, true), 0);
5050
+
5051
+ // Single source of truth for row toggling · listens at the
5052
+ // popover root so both label-click (browser flips the input)
5053
+ // and direct-checkbox-click reach us as one `change` event.
5054
+ pop.addEventListener("change", (ev) => {
5055
+ const cb = ev.target;
5056
+ if (!cb || cb.type !== "checkbox") return;
5057
+ const row = cb.closest("[data-cast-edit-pick-id]");
5058
+ if (!row) return;
5059
+ const id = row.getAttribute("data-cast-edit-pick-id");
5060
+ this.setCastEditDirector(id, !!cb.checked);
5061
+ });
5062
+
5063
+ this.refreshCastEditConfirm();
5064
+ },
5065
+
5066
+ closeCastEditOverlay() {
5067
+ const el = document.getElementById("cast-edit-pop");
5068
+ if (el) el.remove();
5069
+ if (this._castEditEsc) {
5070
+ document.removeEventListener("keydown", this._castEditEsc, true);
5071
+ this._castEditEsc = null;
5072
+ }
5073
+ if (this._castEditOutside) {
5074
+ document.removeEventListener("click", this._castEditOutside, true);
5075
+ this._castEditOutside = null;
5076
+ }
5077
+ this._castEditState = null;
5078
+ },
5079
+
5080
+ /** Mirror the row's checkbox state into _castEditState · driven
5081
+ * by the `change` event in openCastEditOverlay. `on` reflects
5082
+ * the input.checked value AFTER the native flip, so this is
5083
+ * always a set/clear (never a flip) and is idempotent under
5084
+ * duplicate fires. */
5085
+ setCastEditDirector(id, on) {
5086
+ const state = this._castEditState;
5087
+ if (!state || state.saving) return;
5088
+ if (on) state.currentIds.add(id);
5089
+ else state.currentIds.delete(id);
5090
+ const row = document.querySelector(`[data-cast-edit-pick-id="${CSS.escape(id)}"]`);
5091
+ if (row) row.classList.toggle("on", on);
5092
+ this.refreshCastEditConfirm();
5093
+ },
5094
+
5095
+ /** Recompute the confirm-button enabled state · enabled when the
5096
+ * selection differs from the initial AND keeps at least one
5097
+ * director (the server enforces the same floor; we mirror it
5098
+ * here so the button reflects validity). Also flips the floor-
5099
+ * message visibility. */
5100
+ refreshCastEditConfirm() {
5101
+ const state = this._castEditState;
5102
+ const pop = document.getElementById("cast-edit-pop");
5103
+ if (!state || !pop) return;
5104
+ const confirmBtn = pop.querySelector("[data-cast-edit-confirm]");
5105
+ const floorMsg = pop.querySelector("[data-cast-edit-floor]");
5106
+ const sizeOk = state.currentIds.size >= 1;
5107
+ const changed =
5108
+ state.currentIds.size !== state.initialIds.size
5109
+ || [...state.currentIds].some((id) => !state.initialIds.has(id));
5110
+ if (confirmBtn) confirmBtn.disabled = state.saving || !changed || !sizeOk;
5111
+ if (floorMsg) floorMsg.classList.toggle("is-visible", !sizeOk);
5112
+ },
5113
+
5114
+ async submitCastEdit() {
5115
+ const state = this._castEditState;
5116
+ if (!state || state.saving || !this.currentRoomId) return;
5117
+ if (state.currentIds.size < 1) return;
5118
+ state.saving = true;
5119
+ this.refreshCastEditConfirm();
5120
+ const pop = document.getElementById("cast-edit-pop");
5121
+ const confirmBtn = pop ? pop.querySelector("[data-cast-edit-confirm]") : null;
5122
+ const origLabel = confirmBtn ? confirmBtn.textContent : "";
5123
+ if (confirmBtn) confirmBtn.textContent = this._t("cast_edit_saving");
5124
+ try {
5125
+ const res = await fetch(
5126
+ "/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/members",
5127
+ {
5128
+ method: "PATCH",
5129
+ headers: { "content-type": "application/json" },
5130
+ body: JSON.stringify({ agentIds: [...state.currentIds] }),
5131
+ },
5132
+ );
5133
+ if (!res.ok) {
5134
+ const j = await res.json().catch(() => ({}));
5135
+ throw new Error(j.error || "Failed to update cast");
5136
+ }
5137
+ // SSE `members-changed` config-event arrives moments later
5138
+ // and triggers a member re-fetch in the openRoom path's
5139
+ // handler. Close the popover immediately so the user sees the
5140
+ // chair's join/leave announcement land in chat.
5141
+ this.closeCastEditOverlay();
5142
+ } catch (e) {
5143
+ if (confirmBtn) confirmBtn.textContent = origLabel;
5144
+ state.saving = false;
5145
+ this.refreshCastEditConfirm();
5146
+ alert((e && e.message) || "Failed to update cast");
5147
+ }
5148
+ },
5149
+
4801
5150
  async submitFollowUp() {
4802
5151
  const overlay = document.getElementById("followup-overlay");
4803
5152
  if (!overlay) return;
@@ -6707,6 +7056,25 @@
6707
7056
  // when the agent profile takes focus, and otherwise the agent
6708
7057
  // rows shouldn't be highlighted at all.
6709
7058
  }
7059
+ // Collapsed mini-rail · light the "rooms" icon when an actual
7060
+ // room is the main view; clear it for composers (the new-room /
7061
+ // new-agent mini icon carries the highlight there instead).
7062
+ this.setMiniRailContext(roomId !== null ? "rooms" : null);
7063
+ },
7064
+
7065
+ /** Highlight the rooms / agents switcher in the collapsed mini
7066
+ * rail. Mutually exclusive · passing `null` clears both (used by
7067
+ * the reports / notes / composer destinations, which light their
7068
+ * own mini icon via the shared trigger attributes). Mirrors the
7069
+ * full sidebar's tab selection but scoped to the icon rail so the
7070
+ * collapsed sidebar still shows "where you are". */
7071
+ setMiniRailContext(ctx) {
7072
+ document.querySelectorAll('.mini-tab[data-mini-tab="rooms"]').forEach((el) => {
7073
+ el.classList.toggle("active", ctx === "rooms");
7074
+ });
7075
+ document.querySelectorAll('.mini-tab[data-mini-tab="agents"]').forEach((el) => {
7076
+ el.classList.toggle("active", ctx === "agents");
7077
+ });
6710
7078
  },
6711
7079
 
6712
7080
  /** Sidebar focus when an agent profile takes the main view. The
@@ -6736,6 +7104,9 @@
6736
7104
  document.querySelectorAll(".agent-row").forEach((r) => {
6737
7105
  r.classList.toggle("active", r.dataset.agentProfile === slug);
6738
7106
  });
7107
+ // Collapsed mini-rail · an agent profile is the main view → light
7108
+ // the agents switcher icon.
7109
+ this.setMiniRailContext("agents");
6739
7110
  // Reset composer mode so subsequent transitions are clean — the
6740
7111
  // user is no longer "creating" anything.
6741
7112
  this.composerMode = "room";
@@ -6863,13 +7234,9 @@
6863
7234
  const sectionHeader = (label, kind) => {
6864
7235
  const pinned = kind === "pinned";
6865
7236
  const chair = kind === "chair";
6866
- const glyph = pinned
6867
- ? `<svg class="pin-glyph" viewBox="0 0 24 24"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5v6h2v-6h5v-2l-2-2z"/></svg>`
6868
- : "";
6869
7237
  const cls = "agents-section-header" + (pinned ? " pinned" : "") + (chair ? " chair" : "");
6870
7238
  return `
6871
7239
  <div class="${cls}">
6872
- ${glyph}
6873
7240
  <span>${this.escape(label)}</span>
6874
7241
  <span class="line"></span>
6875
7242
  </div>
@@ -7002,17 +7369,23 @@
7002
7369
  // matches what the user picked in settings; otherwise fall back
7003
7370
  // to the initial-letter chip we shipped before AvatarSkill
7004
7371
  // existed.
7005
- const av = document.querySelector("[data-user-avatar]");
7006
- if (av) {
7007
- const seed = this.prefs?.avatarSeed;
7008
- if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
7372
+ // Sync every avatar slot · the full sidebar foot AND the
7373
+ // collapsed mini-rail foot both carry [data-user-avatar], so
7374
+ // querySelectorAll keeps them identical regardless of which is
7375
+ // currently visible.
7376
+ const seed = this.prefs?.avatarSeed;
7377
+ const avHtml = (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function")
7378
+ ? window.AvatarSkill.generate(seed)
7379
+ : null;
7380
+ document.querySelectorAll("[data-user-avatar]").forEach((av) => {
7381
+ if (avHtml) {
7009
7382
  av.classList.add("has-pixel-av");
7010
- av.innerHTML = window.AvatarSkill.generate(seed);
7383
+ av.innerHTML = avHtml;
7011
7384
  } else {
7012
7385
  av.classList.remove("has-pixel-av");
7013
7386
  av.textContent = initial;
7014
7387
  }
7015
- }
7388
+ });
7016
7389
  const nm = document.querySelector("[data-user-name]");
7017
7390
  if (nm) nm.textContent = name;
7018
7391
  const mt = document.querySelector("[data-user-meta]");
@@ -7073,7 +7446,10 @@
7073
7446
  const kids = Array.isArray(this.currentFollowUps) ? this.currentFollowUps : [];
7074
7447
  if (briefCard && kids.length > 0) {
7075
7448
  // System UI · always English (sidebar / brief-card chrome head).
7076
- const headLabel = `Follow-up rooms · ${kids.length}`;
7449
+ // `// ` prefix matches the `.sa-banner-tag` kicker convention
7450
+ // (`// session analytics`) so both post-adjourn cards read as
7451
+ // the same component family.
7452
+ const headLabel = `// Follow-up rooms · ${kids.length}`;
7077
7453
  const block = document.createElement("div");
7078
7454
  block.className = "followup-children";
7079
7455
  block.innerHTML = [
@@ -7868,6 +8244,9 @@
7868
8244
  document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
7869
8245
  document.querySelectorAll("[data-search-trigger].active").forEach((el) => el.classList.remove("active"));
7870
8246
  document.querySelectorAll("[data-reports-trigger]").forEach((el) => el.classList.add("active"));
8247
+ // Reports is its own destination · clear the mini-rail rooms /
8248
+ // agents switchers so only the reports mini icon reads active.
8249
+ this.setMiniRailContext(null);
7871
8250
 
7872
8251
  // Persist the view via URL hash so refresh / back-button restore
7873
8252
  // both the page content AND the sidebar highlight. replaceState
@@ -8354,6 +8733,9 @@
8354
8733
  document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
8355
8734
  document.querySelectorAll("[data-search-trigger].active").forEach((el) => el.classList.remove("active"));
8356
8735
  document.querySelectorAll("[data-notes-trigger]").forEach((el) => el.classList.add("active"));
8736
+ // Notes is its own destination · clear the mini-rail rooms /
8737
+ // agents switchers so only the notes mini icon reads active.
8738
+ this.setMiniRailContext(null);
8357
8739
 
8358
8740
  if (location.hash !== "#/notes") {
8359
8741
  try { history.replaceState(null, "", "#/notes"); } catch { /* ignore */ }
@@ -8387,367 +8769,58 @@
8387
8769
  this.renderNotesPage(notesList);
8388
8770
  },
8389
8771
 
8390
- // ── Search view · cross-room keyword search ────────────────
8391
- /** Open the Search page · same main-view-replacement pattern as
8392
- * openAllReports / openAllNotes. Mounts a search input + an
8393
- * empty results panel; user-typed input triggers debounced
8394
- * /api/search calls (see runSearch). Result rows route back
8395
- * into rooms with `?q=<term>&m=<msgId>` so the message gets
8396
- * scrolled into view + flashed on arrival. */
8772
+ // ── Search overlay · cross-room keyword search ─────────────
8773
+ /** Open the floating search overlay. Layers above whatever
8774
+ * view is mounted (room / agent / reports / notes stay
8775
+ * underneath). Esc / scrim-click / X close — see
8776
+ * closeSearchOverlay + the doc-level handlers in
8777
+ * index.html. Restores the last query on re-open so the
8778
+ * user sees their prior results without retyping. */
8397
8779
  openSearch() {
8398
- document.documentElement.classList.add("no-room");
8399
- if (this.currentRoomId) {
8400
- this.disconnectSSE?.();
8401
- this.currentRoomId = null;
8402
- this.currentRoom = null;
8403
- this.currentMessages = [];
8404
- this.currentMembers = [];
8405
- this.currentHistoricalMembers = [];
8406
- this.currentQueue = [];
8407
- this.currentBrief = null;
8408
- if (/^#\/r\//.test(location.hash)) {
8409
- history.replaceState(null, "", location.pathname + location.search);
8410
- }
8411
- }
8412
- if (typeof window.closeAgentProfile === "function") {
8413
- try { window.closeAgentProfile(); } catch { /* ignore */ }
8414
- }
8415
- const room = document.querySelector('[data-main-view="room"]');
8416
- const agent = document.querySelector('[data-main-view="agent"]');
8417
- const reports = document.querySelector('[data-main-view="reports"]');
8418
- const notes = document.querySelector('[data-main-view="notes"]');
8419
- const search = document.querySelector('[data-main-view="search"]');
8420
- if (room) room.setAttribute("hidden", "");
8421
- if (agent) agent.setAttribute("hidden", "");
8422
- if (reports) reports.setAttribute("hidden", "");
8423
- if (notes) notes.setAttribute("hidden", "");
8424
- if (search) search.removeAttribute("hidden");
8425
-
8426
- this.composerMode = "room";
8427
- document.querySelectorAll("[data-convene-trigger], [data-agent-composer-trigger]").forEach((el) => el.classList.remove("active"));
8428
- document.querySelectorAll(".session-row-shell.active, .agent-row.active").forEach((el) => el.classList.remove("active"));
8429
- document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
8430
- document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
8780
+ const overlay = document.querySelector("[data-search-overlay]");
8781
+ if (!overlay) return;
8782
+ overlay.removeAttribute("hidden");
8783
+ overlay.setAttribute("aria-hidden", "false");
8784
+ document.documentElement.classList.add("has-search-overlay");
8431
8785
  document.querySelectorAll("[data-search-trigger]").forEach((el) => el.classList.add("active"));
8432
-
8433
- if (location.hash !== "#/search") {
8434
- try { history.replaceState(null, "", "#/search"); } catch { /* ignore */ }
8435
- }
8436
-
8437
- const page = document.querySelector("[data-search-page]");
8438
- if (!page) return;
8439
- // Initial paint · Google-style two-state markup. Both the
8440
- // hero (kicker / wordmark / caption) and the head row (input
8441
- // + result-count meta) live in the DOM together so the
8442
- // `.is-initial` ↔ `.has-results` flip is purely a CSS class
8443
- // change — the input element stays put so its value / focus
8444
- // / cursor survive the swap. The doc-level `[data-search-
8445
- // input]` listener (~line 15486) calls `runSearch(value)` on
8446
- // every input event; the clear button (~line 14577) routes
8447
- // back through `runSearch("")`.
8448
- const lastQuery = this._searchLastQuery || "";
8449
- const startsInResults = lastQuery.length > 0;
8450
- page.className = "search-page " + (startsInResults ? "has-results" : "is-initial");
8451
- // Perplexity-style hero · calm conversational tagline above
8452
- // a substantial card-style input with an internal footer
8453
- // toolbar (mono hint left, lime send button right). Starter
8454
- // chips below give one-click into common queries. The same
8455
- // input element serves both states; `.is-initial` styles
8456
- // make the wrapping `.search-card` look like a standalone
8457
- // card, while `.has-results` strips the card chrome and
8458
- // collapses input + meta into a compact head row.
8459
- // 8-bit ambient deco · scattered pixel constellation that
8460
- // gives the page texture without competing with the hero.
8461
- // All coordinates picked by hand to avoid the "regular
8462
- // grid" feel · varied densities + 3 lime accents land
8463
- // the eye softly. CSS masks the bottom edge to a fade so
8464
- // it never crowds the card. Static · no animation,
8465
- // pointer-events: none, aria-hidden.
8466
- const BG_DECO_SVG = `
8467
- <svg viewBox="0 0 800 280" preserveAspectRatio="xMidYMin slice"
8468
- shape-rendering="crispEdges" aria-hidden="true">
8469
- <!-- Faint pixel dots · scattered, mostly 2×2. -->
8470
- <g fill="var(--line-bright, #2A2A26)">
8471
- <rect x="32" y="38" width="2" height="2"/>
8472
- <rect x="78" y="22" width="2" height="2"/>
8473
- <rect x="118" y="62" width="2" height="2"/>
8474
- <rect x="156" y="30" width="2" height="2"/>
8475
- <rect x="206" y="78" width="2" height="2"/>
8476
- <rect x="254" y="44" width="2" height="2"/>
8477
- <rect x="298" y="92" width="2" height="2"/>
8478
- <rect x="342" y="26" width="2" height="2"/>
8479
- <rect x="388" y="68" width="2" height="2"/>
8480
- <rect x="436" y="38" width="2" height="2"/>
8481
- <rect x="486" y="86" width="2" height="2"/>
8482
- <rect x="528" y="50" width="2" height="2"/>
8483
- <rect x="572" y="22" width="2" height="2"/>
8484
- <rect x="618" y="74" width="2" height="2"/>
8485
- <rect x="664" y="42" width="2" height="2"/>
8486
- <rect x="708" y="88" width="2" height="2"/>
8487
- <rect x="752" y="32" width="2" height="2"/>
8488
- <rect x="60" y="118" width="2" height="2"/>
8489
- <rect x="146" y="142" width="2" height="2"/>
8490
- <rect x="222" y="116" width="2" height="2"/>
8491
- <rect x="316" y="148" width="2" height="2"/>
8492
- <rect x="402" y="124" width="2" height="2"/>
8493
- <rect x="488" y="158" width="2" height="2"/>
8494
- <rect x="572" y="118" width="2" height="2"/>
8495
- <rect x="654" y="146" width="2" height="2"/>
8496
- <rect x="734" y="124" width="2" height="2"/>
8497
- <rect x="92" y="180" width="2" height="2"/>
8498
- <rect x="186" y="206" width="2" height="2"/>
8499
- <rect x="278" y="186" width="2" height="2"/>
8500
- <rect x="370" y="216" width="2" height="2"/>
8501
- <rect x="462" y="194" width="2" height="2"/>
8502
- <rect x="554" y="222" width="2" height="2"/>
8503
- <rect x="646" y="198" width="2" height="2"/>
8504
- <rect x="724" y="226" width="2" height="2"/>
8505
- </g>
8506
- <!-- Mid accents · 4×4 squares, lime-dim. -->
8507
- <g fill="var(--lime-dim, #2D5532)">
8508
- <rect x="226" y="46" width="4" height="4"/>
8509
- <rect x="514" y="100" width="4" height="4"/>
8510
- <rect x="694" y="170" width="4" height="4"/>
8511
- </g>
8512
- <!-- Pixel "plus" accents · evoke the 8-bit
8513
- star/sparkle vocabulary. Lime-dim. -->
8514
- <g fill="var(--lime-dim, #2D5532)">
8515
- <!-- top-left plus -->
8516
- <rect x="98" y="78" width="6" height="2"/>
8517
- <rect x="100" y="76" width="2" height="6"/>
8518
- <!-- mid-right plus -->
8519
- <rect x="640" y="62" width="6" height="2"/>
8520
- <rect x="642" y="60" width="2" height="6"/>
8521
- <!-- lower-left plus -->
8522
- <rect x="354" y="178" width="6" height="2"/>
8523
- <rect x="356" y="176" width="2" height="6"/>
8524
- </g>
8525
- <!-- Bright accents · sparse, lime. Catch-the-eye
8526
- points scattered at the visual rule-of-thirds. -->
8527
- <g fill="var(--lime, #6FB572)">
8528
- <rect x="170" y="98" width="3" height="3"/>
8529
- <rect x="540" y="38" width="3" height="3"/>
8530
- <rect x="416" y="170" width="3" height="3"/>
8531
- </g>
8532
- <!-- Pixel "frame" segments · short hairline brackets at
8533
- the very top corners · evoke a CRT scanlines feel
8534
- without a full grid. -->
8535
- <g fill="var(--line-bright, #2A2A26)">
8536
- <rect x="14" y="14" width="20" height="2"/>
8537
- <rect x="14" y="14" width="2" height="20"/>
8538
- <rect x="766" y="14" width="20" height="2"/>
8539
- <rect x="784" y="14" width="2" height="20"/>
8540
- </g>
8541
- </svg>
8542
- `;
8543
- // Results-only deco · second 8-bit layer that mounts on
8544
- // top of the constellation when .has-results is active.
8545
- // Adds proper "search station" character: pixel antennas
8546
- // anchoring the corners, scattered "+" sparkles between
8547
- // them, denser dot field, and a horizon-tick floor at
8548
- // the bottom of the band. CSS scanlines (declared in
8549
- // index.html) sit underneath via background-image.
8550
- const RESULTS_DECO_SVG = `
8551
- <svg viewBox="0 0 800 130" preserveAspectRatio="xMidYMin slice"
8552
- shape-rendering="crispEdges" aria-hidden="true">
8553
- <!-- Left antenna · vertical mast + 2 crossbars +
8554
- base tile. Reads as a pixel radio tower. -->
8555
- <g fill="var(--line-bright, #2A2A26)">
8556
- <rect x="22" y="40" width="2" height="74"/>
8557
- <rect x="18" y="56" width="10" height="2"/>
8558
- <rect x="20" y="74" width="6" height="2"/>
8559
- <rect x="14" y="114" width="18" height="3"/>
8560
- </g>
8561
- <!-- Left antenna blink tip · static lime accent. -->
8562
- <rect x="22" y="36" width="2" height="2" fill="var(--lime, #6FB572)"/>
8563
- <!-- Right antenna · mirror of the left. -->
8564
- <g fill="var(--line-bright, #2A2A26)">
8565
- <rect x="776" y="44" width="2" height="70"/>
8566
- <rect x="772" y="60" width="10" height="2"/>
8567
- <rect x="774" y="78" width="6" height="2"/>
8568
- <rect x="768" y="114" width="18" height="3"/>
8569
- </g>
8570
- <rect x="776" y="40" width="2" height="2" fill="var(--lime, #6FB572)"/>
8571
- <!-- Pixel "+" sparkles · scattered in the central
8572
- band between the antennas. Reads as scanner
8573
- pings. -->
8574
- <g fill="var(--lime-dim, #2D5532)">
8575
- <rect x="138" y="44" width="6" height="2"/>
8576
- <rect x="140" y="42" width="2" height="6"/>
8577
- <rect x="324" y="68" width="6" height="2"/>
8578
- <rect x="326" y="66" width="2" height="6"/>
8579
- <rect x="498" y="34" width="6" height="2"/>
8580
- <rect x="500" y="32" width="2" height="6"/>
8581
- <rect x="612" y="86" width="6" height="2"/>
8582
- <rect x="614" y="84" width="2" height="6"/>
8583
- <rect x="220" y="92" width="6" height="2"/>
8584
- <rect x="222" y="90" width="2" height="6"/>
8585
- <rect x="700" y="50" width="6" height="2"/>
8586
- <rect x="702" y="48" width="2" height="6"/>
8587
- </g>
8588
- <!-- Dense pixel-dot field · layered on top of the
8589
- existing constellation to thicken the header. -->
8590
- <g fill="var(--line-bright, #2A2A26)">
8591
- <rect x="62" y="28" width="2" height="2"/>
8592
- <rect x="106" y="78" width="2" height="2"/>
8593
- <rect x="170" y="34" width="2" height="2"/>
8594
- <rect x="248" y="56" width="2" height="2"/>
8595
- <rect x="288" y="22" width="2" height="2"/>
8596
- <rect x="370" y="50" width="2" height="2"/>
8597
- <rect x="412" y="92" width="2" height="2"/>
8598
- <rect x="468" y="68" width="2" height="2"/>
8599
- <rect x="544" y="84" width="2" height="2"/>
8600
- <rect x="588" y="44" width="2" height="2"/>
8601
- <rect x="668" y="76" width="2" height="2"/>
8602
- <rect x="722" y="32" width="2" height="2"/>
8603
- <rect x="84" y="98" width="2" height="2"/>
8604
- <rect x="262" y="100" width="2" height="2"/>
8605
- </g>
8606
- <!-- Bright lime accent dots · 2 only, at rule-of-
8607
- thirds positions. Catches the eye. -->
8608
- <g fill="var(--lime, #6FB572)">
8609
- <rect x="266" y="40" width="3" height="3"/>
8610
- <rect x="566" y="74" width="3" height="3"/>
8611
- </g>
8612
- <!-- Horizon ticks · faint dashed pixel line near
8613
- the bottom edge of the band. Acts as visual
8614
- "floor" the antennas plant on. -->
8615
- <g fill="var(--line, #1A1A18)">
8616
- <rect x="40" y="122" width="6" height="1"/>
8617
- <rect x="60" y="122" width="2" height="1"/>
8618
- <rect x="76" y="122" width="6" height="1"/>
8619
- <rect x="96" y="122" width="2" height="1"/>
8620
- <rect x="112" y="122" width="6" height="1"/>
8621
- <rect x="132" y="122" width="2" height="1"/>
8622
- <rect x="148" y="122" width="6" height="1"/>
8623
- <rect x="168" y="122" width="2" height="1"/>
8624
- <rect x="184" y="122" width="6" height="1"/>
8625
- <rect x="204" y="122" width="2" height="1"/>
8626
- <rect x="220" y="122" width="6" height="1"/>
8627
- <rect x="240" y="122" width="2" height="1"/>
8628
- <rect x="256" y="122" width="6" height="1"/>
8629
- <rect x="276" y="122" width="2" height="1"/>
8630
- <rect x="292" y="122" width="6" height="1"/>
8631
- <rect x="312" y="122" width="2" height="1"/>
8632
- <rect x="328" y="122" width="6" height="1"/>
8633
- <rect x="348" y="122" width="2" height="1"/>
8634
- <rect x="364" y="122" width="6" height="1"/>
8635
- <rect x="384" y="122" width="2" height="1"/>
8636
- <rect x="400" y="122" width="6" height="1"/>
8637
- <rect x="420" y="122" width="2" height="1"/>
8638
- <rect x="436" y="122" width="6" height="1"/>
8639
- <rect x="456" y="122" width="2" height="1"/>
8640
- <rect x="472" y="122" width="6" height="1"/>
8641
- <rect x="492" y="122" width="2" height="1"/>
8642
- <rect x="508" y="122" width="6" height="1"/>
8643
- <rect x="528" y="122" width="2" height="1"/>
8644
- <rect x="544" y="122" width="6" height="1"/>
8645
- <rect x="564" y="122" width="2" height="1"/>
8646
- <rect x="580" y="122" width="6" height="1"/>
8647
- <rect x="600" y="122" width="2" height="1"/>
8648
- <rect x="616" y="122" width="6" height="1"/>
8649
- <rect x="636" y="122" width="2" height="1"/>
8650
- <rect x="652" y="122" width="6" height="1"/>
8651
- <rect x="672" y="122" width="2" height="1"/>
8652
- <rect x="688" y="122" width="6" height="1"/>
8653
- <rect x="708" y="122" width="2" height="1"/>
8654
- <rect x="724" y="122" width="6" height="1"/>
8655
- <rect x="744" y="122" width="2" height="1"/>
8656
- <rect x="760" y="122" width="6" height="1"/>
8657
- </g>
8658
- </svg>
8659
- `;
8660
- page.innerHTML = `
8661
- <!-- 8-bit ambient deco · top-of-page background overlay
8662
- for the is-initial state. Stays visible (dimmer)
8663
- in has-results as the base atmosphere layer. -->
8664
- <div class="search-bg-deco">${BG_DECO_SVG}</div>
8665
- <!-- Results-only deco · second layer with antennas +
8666
- sparkles + horizon ticks + CSS scanlines (in CSS).
8667
- Hidden in is-initial via opacity. -->
8668
- <div class="search-results-deco">${RESULTS_DECO_SVG}</div>
8669
- <!-- Hero · matches the new-room composer's two-row header ·
8670
- a mono caps greeting (cmp-greet · "Good afternoon, Kay")
8671
- above the headline. The old "across every room ..." sub-
8672
- line moved INSIDE the search-card's topbar so the card
8673
- itself surfaces its scope, matching .cmp-input-frame. -->
8674
- <div class="search-hero">
8675
- <div class="cmp-greet">${this.escape(this.composerGreeting(this.composerLanguage(), (this.prefs?.name || "you").trim() || "you"))}</div>
8676
- <h1 class="search-hero-title">Search every conversation</h1>
8677
- </div>
8678
- <!-- Card · is-initial = framed input with a brand-tinted
8679
- topbar hosting the scope hint, then the input below;
8680
- has-results = flex row with the meta beside it, topbar
8681
- collapsed via CSS. Mirrors new-room .cmp-input-frame. -->
8682
- <div class="search-card${lastQuery ? "" : " is-empty"}" data-search-card>
8683
- <div class="search-topbar">
8684
- <span class="search-topbar-hint">across every room · keyword · message body · room name</span>
8685
- </div>
8686
- <div class="search-input-wrap">
8687
- <span class="search-input-icon" aria-hidden="true">
8688
- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
8689
- <circle cx="7" cy="7" r="4.5"/>
8690
- <line x1="10.3" y1="10.3" x2="13.5" y2="13.5"/>
8691
- </svg>
8692
- </span>
8693
- <input type="text" class="search-input" data-search-input
8694
- placeholder="Find a keyword, decision, or name across rooms"
8695
- value="${this.escape(lastQuery)}"
8696
- spellcheck="false"
8697
- autocomplete="off">
8698
- <button type="button" class="search-input-clear" data-search-clear aria-label="Clear" title="Clear">✕</button>
8699
- </div>
8700
- <!-- Sort filter · only visible in .has-results. Toggle
8701
- between newest-first (default) and oldest-first.
8702
- Click handler in the doc-level delegate updates
8703
- app._searchSort and re-renders from the cached
8704
- result list (no re-fetch). -->
8705
- <div class="search-results-sort" data-search-sort>
8706
- <span class="srs-label">sort</span>
8707
- <button type="button" data-search-sort-by="newest" class="active">Newest</button>
8708
- <button type="button" data-search-sort-by="oldest">Oldest</button>
8709
- </div>
8710
- <span class="search-results-meta" data-search-results-meta></span>
8711
- </div>
8712
- <!-- Static starter chips · one click pre-fills the input
8713
- and triggers the search. Hidden once the user types
8714
- anything (.has-results). System UI English-only. -->
8715
- <div class="search-starters" data-search-starters>
8716
- <span class="search-starters-label">try</span>
8717
- <button type="button" class="search-starter" data-search-starter="decision">decision</button>
8718
- <button type="button" class="search-starter" data-search-starter="next step">next step</button>
8719
- <button type="button" class="search-starter" data-search-starter="risk">risk</button>
8720
- <button type="button" class="search-starter" data-search-starter="ship">ship</button>
8721
- </div>
8722
- <div class="search-results" data-search-results></div>
8723
- `;
8724
- // Initialise the sort default · defaults to "newest"
8725
- // (most recent matches first). The chip group reads from
8726
- // this on every render to set the active state.
8727
- if (this._searchSort !== "oldest") this._searchSort = "newest";
8728
- this._refreshSortChips();
8729
- const inputEl = page.querySelector("[data-search-input]");
8730
- if (inputEl) {
8731
- // Defer focus so the layout settles + view is visible.
8732
- setTimeout(() => inputEl.focus(), 50);
8733
- if (lastQuery) {
8734
- // Re-run last search on re-open so the user sees their
8735
- // prior results without retyping.
8736
- this.runSearch(lastQuery);
8737
- }
8786
+ const input = overlay.querySelector("[data-search-input]");
8787
+ if (input) {
8788
+ const last = this._searchLastQuery || "";
8789
+ if (input.value !== last) input.value = last;
8790
+ setTimeout(() => { input.focus(); input.select(); }, 30);
8791
+ if (last) this.runSearch(last);
8738
8792
  }
8739
8793
  },
8740
8794
 
8741
- /** Flip the page-level state class. `is-initial` mounts the
8742
- * vertical-centre hero; `has-results` collapses the hero and
8743
- * shrinks the input into a head row beside the meta. Called
8744
- * from runSearch whenever the query crosses the empty
8745
- * threshold (and on cold mount in renderSearchPage). */
8746
- _setSearchPageState(state) {
8747
- const page = document.querySelector("[data-search-page]");
8748
- if (!page) return;
8749
- page.classList.toggle("is-initial", state === "initial");
8750
- page.classList.toggle("has-results", state !== "initial");
8795
+ /** Close the floating search overlay. Leaves the underlying
8796
+ * view exactly as it was. Doesn't clear the input or cached
8797
+ * query re-open restores them via openSearch. */
8798
+ closeSearchOverlay() {
8799
+ const overlay = document.querySelector("[data-search-overlay]");
8800
+ if (!overlay) return;
8801
+ overlay.setAttribute("hidden", "");
8802
+ overlay.setAttribute("aria-hidden", "true");
8803
+ document.documentElement.classList.remove("has-search-overlay");
8804
+ document.querySelectorAll("[data-search-trigger]").forEach((el) => el.classList.remove("active"));
8805
+ },
8806
+
8807
+ /** Static HTML for the empty-state placeholder · captured
8808
+ * here so runSearch("") can restore the same markup that
8809
+ * index.html mounts initially. */
8810
+ _searchOverlayEmptyHtml() {
8811
+ const title = (this._t && this._t("search_overlay_empty_title")) || "Search across rooms and messages";
8812
+ const hint = (this._t && this._t("search_overlay_empty_hint")) || "Type to filter live · click a result to jump";
8813
+ return `<div class="search-overlay-empty">
8814
+ <svg class="search-overlay-empty-icon" viewBox="0 0 64 64" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
8815
+ <circle cx="27" cy="27" r="17"/>
8816
+ <line x1="50" y1="50" x2="39" y2="39"/>
8817
+ <line x1="21" y1="27" x2="33" y2="27" opacity="0.45"/>
8818
+ <line x1="21" y1="22" x2="28" y2="22" opacity="0.3"/>
8819
+ <line x1="21" y1="32" x2="30" y2="32" opacity="0.3"/>
8820
+ </svg>
8821
+ <div class="search-overlay-empty-text">${this.escape(title)}</div>
8822
+ <div class="search-overlay-empty-hint">${this.escape(hint)}</div>
8823
+ </div>`;
8751
8824
  },
8752
8825
 
8753
8826
  /** Debounced search · 200ms after last keystroke we hit
@@ -8763,118 +8836,55 @@
8763
8836
  }
8764
8837
  const seq = (this._searchSeq = (this._searchSeq || 0) + 1);
8765
8838
  const target = document.querySelector("[data-search-results]");
8766
- const metaEl = document.querySelector("[data-search-results-meta]");
8767
- // Keep the card's empty-state class in sync with the live
8768
- // query · the CSS class hides the trailing ✕ when the
8769
- // input is genuinely empty so the hero's right edge stays
8770
- // clean before the user has typed anything.
8771
- const card = document.querySelector("[data-search-card]");
8772
- if (card) card.classList.toggle("is-empty", q.length === 0);
8773
8839
  if (!target) return;
8774
8840
  if (q.length === 0) {
8775
- // Empty query · snap back to the hero (initial state),
8776
- // clear the results list + meta. The CSS hides both via
8777
- // `.is-initial`; we still wipe innerHTML so a later
8778
- // .has-results flip doesn't briefly show stale rows.
8779
- this._setSearchPageState("initial");
8780
- target.innerHTML = "";
8781
- if (metaEl) metaEl.textContent = "";
8841
+ target.innerHTML = this._searchOverlayEmptyHtml();
8782
8842
  return;
8783
8843
  }
8784
- // Non-empty query · flip to results state (CSS fades the
8785
- // hero out, shrinks the input into the head row) and show
8786
- // a small "searching…" placeholder while the fetch is in
8787
- // flight. The meta line takes a beat to populate.
8788
- this._setSearchPageState("results");
8789
- target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// searching</span><div class="search-empty-msg">…</div></div>`;
8790
- if (metaEl) metaEl.textContent = "";
8844
+ target.innerHTML = `<div class="search-overlay-status"><span class="search-overlay-status-kicker">// searching</span><div class="search-overlay-status-msg">…</div></div>`;
8791
8845
  this._searchDebounceTimer = setTimeout(async () => {
8792
8846
  try {
8793
8847
  const r = await fetch("/api/search?q=" + encodeURIComponent(q));
8794
8848
  if (!r.ok) throw new Error("HTTP " + r.status);
8795
8849
  const j = await r.json();
8796
- if (seq !== this._searchSeq) return; // stale
8850
+ if (seq !== this._searchSeq) return;
8797
8851
  this.renderSearchResults(j.results || [], q);
8798
8852
  } catch (e) {
8799
8853
  if (seq !== this._searchSeq) return;
8800
- target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// error</span><div class="search-empty-msg">${this.escape(String(e && e.message ? e.message : e))}</div></div>`;
8854
+ target.innerHTML = `<div class="search-overlay-status"><span class="search-overlay-status-kicker">// error</span><div class="search-overlay-status-msg">${this.escape(String(e && e.message ? e.message : e))}</div></div>`;
8801
8855
  }
8802
8856
  }, 200);
8803
8857
  },
8804
8858
 
8805
- /** Render the search results list · flat ordered list with
8806
- * Google-style 3-line rows (mono source breadcrumb sans
8807
- * link title sans snippet body). Per-row chrome is defined
8808
- * in `renderSearchResultRow`. The sort order ("newest" /
8809
- * "oldest") is read from `app._searchSort` (default
8810
- * "newest") and re-applied client-side by `_applySearchSort`
8811
- * whenever the user clicks a sort chip — no re-fetch. */
8859
+ /** Render the result list · flat `<ul.search-overlay-list>` of
8860
+ * `.so-row` rows, sorted newest-first by createdAt. 0-match
8861
+ * shows a status block in place of the list. */
8812
8862
  renderSearchResults(results, query) {
8813
8863
  const target = document.querySelector("[data-search-results]");
8814
- const metaEl = document.querySelector("[data-search-results-meta]");
8815
8864
  if (!target) return;
8816
- // Cache the raw response so the sort chip click can re-
8817
- // render without hitting the server again. Also store the
8818
- // query so the row builder knows what to highlight.
8819
8865
  this._searchLastResults = Array.isArray(results) ? results.slice() : [];
8820
8866
  this._searchLastQueryRendered = query || "";
8821
8867
  if (!results || results.length === 0) {
8822
- if (metaEl) metaEl.textContent = `0 matches`;
8823
- target.innerHTML = `<div class="search-empty"><span class="search-empty-kicker">// no matches</span><div class="search-empty-msg">No messages match "${this.escape(query)}". Try a shorter or different term.</div></div>`;
8868
+ target.innerHTML = `<div class="search-overlay-status"><span class="search-overlay-status-kicker">// no matches</span><div class="search-overlay-status-msg">No messages match "${this.escape(query)}". Try a shorter or different term.</div></div>`;
8824
8869
  return;
8825
8870
  }
8826
- const sorted = this._applySearchSort(results);
8827
- const roomSet = new Set(sorted.map((r) => r.roomId));
8828
- if (metaEl) {
8829
- metaEl.textContent =
8830
- `${sorted.length} match${sorted.length === 1 ? "" : "es"} · ` +
8831
- `${roomSet.size} room${roomSet.size === 1 ? "" : "s"}`;
8832
- }
8871
+ const sorted = results.slice().sort((a, b) => ((b && b.createdAt) || 0) - ((a && a.createdAt) || 0));
8833
8872
  const rows = sorted.map((hit) => this.renderSearchResultRow(hit, query)).join("");
8834
- target.innerHTML = `<ul class="search-results-list">${rows}</ul>`;
8835
- },
8836
-
8837
- /** Sort a results array by createdAt according to the current
8838
- * `app._searchSort` setting. Returns a NEW array so the
8839
- * cached `_searchLastResults` stays in original order. */
8840
- _applySearchSort(results) {
8841
- const order = this._searchSort === "oldest" ? "oldest" : "newest";
8842
- const sorted = (results || []).slice();
8843
- sorted.sort((a, b) => {
8844
- const aT = (a && a.createdAt) || 0;
8845
- const bT = (b && b.createdAt) || 0;
8846
- return order === "oldest" ? aT - bT : bT - aT;
8847
- });
8848
- return sorted;
8873
+ target.innerHTML = `<ul class="search-overlay-list">${rows}</ul>`;
8849
8874
  },
8850
8875
 
8851
- /** Sync the active state on the sort chips so the lit button
8852
- * reflects `app._searchSort`. Called from the chip click
8853
- * handler + on initial mount. */
8854
- _refreshSortChips() {
8855
- const order = this._searchSort === "oldest" ? "oldest" : "newest";
8856
- document.querySelectorAll("[data-search-sort-by]").forEach((btn) => {
8857
- btn.classList.toggle("active", btn.getAttribute("data-search-sort-by") === order);
8858
- });
8859
- },
8860
-
8861
- /** Build one Google-style result row · three stacked text
8862
- * lines:
8863
- * 1. Source breadcrumb (mono caption, faint) · author name
8864
- * + time-ago — the "URL row" equivalent.
8865
- * 2. Title (sans link, lime on hover) · the room subject
8866
- * (truncated). Clicking jumps to `#/r/<id>?m=<mid>&q=
8867
- * <q>` exactly like the previous flat-list row.
8868
- * 3. Snippet (sans body, soft tone) · 2-line clamp of the
8869
- * message context with `<mark>` highlight on the
8870
- * matched keyword.
8871
- * Snippet window (~110 lead, ~180 trail) is unchanged from
8872
- * the prior implementation — the change is purely visual. */
8876
+ /** Build one overlay result row · grid layout (icon | title |
8877
+ * meta · snippet spans cols 2-3). `<mark>` wraps the matched
8878
+ * fragment inside the snippet so the keyword stays visible
8879
+ * inside the truncated context. Click → `#/r/{roomId}?m=
8880
+ * {msgId}&q={q}` same hash-router path the prior page used,
8881
+ * so message-jump + keyword-flash on the destination is
8882
+ * unchanged. */
8873
8883
  renderSearchResultRow(hit, query) {
8874
8884
  const body = hit.body || "";
8875
8885
  const offset = Math.max(0, hit.matchOffset || 0);
8876
8886
  const ql = (query || "").length;
8877
- const LEAD = 110, TRAIL = 180;
8887
+ const LEAD = 60, TRAIL = 120;
8878
8888
  const start = Math.max(0, offset - LEAD);
8879
8889
  const end = Math.min(body.length, offset + ql + TRAIL);
8880
8890
  const prefix = start > 0 ? "…" : "";
@@ -8888,31 +8898,12 @@
8888
8898
  `<mark>${this.escape(flat(matched))}</mark>` +
8889
8899
  this.escape(flat(after) + suffix);
8890
8900
  const roomTitle = (hit.roomTitle || "Untitled room").trim() || "Untitled room";
8891
- const author = (hit.authorName || "").trim() || "Director";
8892
8901
  const timeAgo = hit.createdAt ? this.relTime(hit.createdAt) : "";
8893
8902
  const qParam = query ? `&q=${encodeURIComponent(query)}` : "";
8894
- // Source breadcrumb · author + relative time. No "from"
8895
- // label · room title is now the prominent title line (where
8896
- // the user navigates to), so the breadcrumb only needs to
8897
- // identify the speaker + when.
8898
- const sourceLine =
8899
- `<span class="sr-source-author">${this.escape(author)}</span>` +
8900
- (timeAgo
8901
- ? `<span class="sr-source-sep">·</span><span class="sr-source-time">${this.escape(timeAgo)}</span>`
8902
- : "");
8903
- return `
8904
- <li>
8905
- <a href="#/r/${this.escape(hit.roomId)}?m=${this.escape(hit.messageId)}${qParam}"
8906
- class="sr-row"
8907
- data-search-jump-room="${this.escape(hit.roomId)}"
8908
- data-search-jump-msg="${this.escape(hit.messageId)}"
8909
- data-search-jump-q="${this.escape(query || "")}">
8910
- <div class="sr-source">${sourceLine}</div>
8911
- <div class="sr-title">${this.escape(roomTitle)}</div>
8912
- <div class="sr-snippet">${snippet}</div>
8913
- </a>
8914
- </li>
8915
- `;
8903
+ // Lucide MessageSquare · chat-bubble icon matching the
8904
+ // Claude search.png reference. Inherit currentColor.
8905
+ const icon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>`;
8906
+ return `<li><a href="#/r/${this.escape(hit.roomId)}?m=${this.escape(hit.messageId)}${qParam}" class="so-row" data-search-jump-room="${this.escape(hit.roomId)}" data-search-jump-msg="${this.escape(hit.messageId)}" data-search-jump-q="${this.escape(query || "")}"><span class="so-row-icon">${icon}</span><span class="so-row-title">${this.escape(roomTitle)}</span><span class="so-row-meta">${this.escape(timeAgo)}</span><span class="so-row-snippet">${snippet}</span></a></li>`;
8916
8907
  },
8917
8908
 
8918
8909
  /** Render the All Notes timeline · same filter-strip + date-
@@ -10241,182 +10232,6 @@
10241
10232
  * tune live as a slim toolbar inside its bottom edge so the page
10242
10233
  * has one clear gravitational centre, not three competing form
10243
10234
  * fields. */
10244
- /** 8-bit ambient backdrop · same vocabulary as the Search page's
10245
- * `.search-bg-deco` (crispEdges pixel motifs, lime accents) but
10246
- * scene-tuned for each composer:
10247
- * · room → mini boardroom (pixel table + 4 chair silhouettes)
10248
- * · agent → row of pixel character heads w/ speech bubbles
10249
- * Constellation dots + corner brackets are shared. Returns plain
10250
- * SVG markup ready to drop into `.cmp-bg-deco`. */
10251
- composerBgDecoSvg(scene) {
10252
- // Helper · stagger animation-delays so siblings don't pulse in
10253
- // lockstep. Returns a string like `style="animation-delay: 1.2s"`.
10254
- // The pseudo-random offset is index-based so it's deterministic
10255
- // (no flicker between renders).
10256
- const _delay = (i, base) => `style="animation-delay: ${(((i * 137) % 100) / 100 * base).toFixed(2)}s"`;
10257
- // Constellation dots + lime accents + corner brackets · shared
10258
- // ambient "8-bit sky" the user singled out as the visual goal.
10259
- // Each dot animates with `deco-twinkle` keyframes for a slow
10260
- // opacity blink, staggered by index so the field shimmers
10261
- // organically instead of pulsing as a single beat.
10262
- const scatterDots = [
10263
- [32, 38], [78, 22], [118, 62], [156, 30], [206, 78], [254, 44],
10264
- [298, 92], [342, 26], [388, 68], [436, 38], [486, 86], [528, 50],
10265
- [572, 22], [618, 74], [664, 42], [708, 88], [752, 32], [60, 200],
10266
- [186, 216], [278, 196], [554, 222], [646, 198], [734, 226],
10267
- ];
10268
- const limeAccents = [
10269
- // Dropped the centre-lower lime dot (416, 170) — it sat
10270
- // directly behind the H1 prompt and read as a typo.
10271
- [170, 98], [540, 38],
10272
- ];
10273
- const scatter = `
10274
- <g fill="var(--line-bright, #2A2A26)">
10275
- ${scatterDots.map(([x, y], i) =>
10276
- `<rect class="deco-twinkle" ${_delay(i, 4.5)} x="${x}" y="${y}" width="2" height="2"/>`
10277
- ).join("")}
10278
- </g>
10279
- <g fill="var(--lime, #6FB572)">
10280
- ${limeAccents.map(([x, y], i) =>
10281
- `<rect class="deco-shine" ${_delay(i + 7, 2.8)} x="${x}" y="${y}" width="3" height="3"/>`
10282
- ).join("")}
10283
- </g>
10284
- <g fill="var(--line-bright, #2A2A26)">
10285
- <rect x="14" y="14" width="20" height="2"/>
10286
- <rect x="14" y="14" width="2" height="20"/>
10287
- <rect x="766" y="14" width="20" height="2"/>
10288
- <rect x="784" y="14" width="2" height="20"/>
10289
- </g>
10290
- `;
10291
- // Scene-specific MINI motifs · small scattered 8-bit glyphs that
10292
- // theme the constellation without occupying the centre stage.
10293
- // Replaces the previous big centred tableau (table + chairs /
10294
- // row of character heads) — the user wanted ambient dots /
10295
- // sparkles tinted with each composer's flavour, not a literal
10296
- // scene in the hero band. Each motif is ≤ 14×14 px so the band
10297
- // still reads as scatter, not a feature illustration.
10298
- let motif = "";
10299
- if (scene === "room") {
10300
- // Room scene · tiny pixel chairs + mics + plus-sparkles + a
10301
- // few pixel "speech-mark" pairs (boardroom vocabulary). All
10302
- // in the warm wood / cyan moderator palette so the scatter
10303
- // tints toward "meeting" without ever forming a tableau.
10304
- // Each group carries an animation class · `deco-bob` for
10305
- // chairs, `deco-spark` for "+" glyphs, `deco-twinkle` for
10306
- // quote dots. Inline animation-delays stagger across siblings
10307
- // so the field never pulses in lockstep.
10308
- // Center-lower zone (roughly x ∈ [280, 520], y > 140) is
10309
- // where the H1 "What's on your mind today?" prompt lands.
10310
- // Cleared of motif elements so the chairs / mics / sparks /
10311
- // quote dots never collide with the title text · the deco
10312
- // stays visible only at the periphery + along the top band.
10313
- // Same hygiene pass as the agent composer's motif.
10314
- // All motif fills below pull from theme tokens (`--accent-line`
10315
- // is the brand-tinted hairline = very subtle in both themes)
10316
- // so the backdrop adopts the page's palette instead of staying
10317
- // a fixed wood-brown patch that looks "dirty" on a light bg.
10318
- const chairs = [
10319
- { x: 108, y: 138 },
10320
- { x: 372, y: 46 },
10321
- { x: 678, y: 148 },
10322
- ];
10323
- const sparks = [
10324
- [98, 78], [640, 62], [588, 156],
10325
- ];
10326
- const quotes = [
10327
- [148, 110], [152, 110], [716, 98], [720, 98],
10328
- ];
10329
- motif = `
10330
- <g shape-rendering="crispEdges">
10331
- <!-- Mini chair silhouettes — fill pulled from theme token -->
10332
- ${chairs.map((c, i) => `
10333
- <g class="deco-bob" ${_delay(i + 3, 3.2)} fill="var(--accent-line)">
10334
- <rect x="${c.x}" y="${c.y + 10}" width="6" height="2"/>
10335
- <rect x="${c.x}" y="${c.y}" width="2" height="10"/>
10336
- <rect x="${c.x + 6}" y="${c.y}" width="2" height="10"/>
10337
- </g>
10338
- `).join("")}
10339
- <!-- Tiny microphones · static (small, would feel busy). -->
10340
- <g fill="var(--accent-line)">
10341
- <rect x="226" y="46" width="3" height="2"/>
10342
- <rect x="227" y="42" width="1" height="4"/>
10343
- <rect x="514" y="100" width="3" height="2"/>
10344
- <rect x="515" y="96" width="1" height="4"/>
10345
- </g>
10346
- <!-- Wood-tone "+" sparkles · scale-pulse animation -->
10347
- ${sparks.map(([x, y], i) => `
10348
- <g class="deco-spark" ${_delay(i + 11, 2.4)} fill="var(--amber-dim, #5C3A1F)">
10349
- <rect x="${x}" y="${y + 2}" width="6" height="2"/>
10350
- <rect x="${x + 2}" y="${y}" width="2" height="6"/>
10351
- </g>
10352
- `).join("")}
10353
- <!-- Pixel "quote marks" · 2-dot pairs, twinkle in sync -->
10354
- ${quotes.map(([x, y], i) =>
10355
- `<rect class="deco-twinkle" ${_delay(i + 17, 3.5)} x="${x}" y="${y}" width="2" height="2" fill="var(--lime-dim, #2D5532)"/>`
10356
- ).join("")}
10357
- </g>
10358
- `;
10359
- } else if (scene === "agent") {
10360
- // Agent scene · tiny pixel character heads (4×4) + mini
10361
- // speech bubbles (5×3) + lime sparkles · evokes "cast / new
10362
- // persona" without ever forming a centre tableau. Heads are
10363
- // small enough to read as constellation, not as portraits.
10364
- // Heads bob, bubbles blink in / out, sparkles pulse.
10365
- // Center-lower zone (roughly x ∈ [280, 520], y > 140) is
10366
- // where the H1 "What do you want to build?" prompt lands.
10367
- // Cleared of motif elements so the heads / bubbles / sparks
10368
- // never collide with the title text · the deco stays
10369
- // visible only at the periphery + along the top band.
10370
- const heads = [
10371
- [124, 48], [498, 64], [676, 178], [218, 200],
10372
- ];
10373
- const bubbles = [
10374
- [170, 68], [362, 92], [552, 46], [612, 206],
10375
- ];
10376
- const ideaSparks = [
10377
- [68, 118], [724, 138], [84, 186],
10378
- ];
10379
- motif = `
10380
- <g shape-rendering="crispEdges">
10381
- <!-- Mini character heads · fills pulled from theme tokens
10382
- so the heads stop reading as out-of-place skin-tan +
10383
- dark-brown patches against a white bg. -->
10384
- ${heads.map(([x, y], i) => `
10385
- <g class="deco-bob" ${_delay(i + 21, 3.0)}>
10386
- <rect x="${x}" y="${y}" width="4" height="4" fill="var(--accent-line)"/>
10387
- <rect x="${x}" y="${y}" width="4" height="1" fill="var(--lime-dim)"/>
10388
- </g>
10389
- `).join("")}
10390
- <!-- Tiny speech bubbles · 10×6 pill with 1-pixel tail · blink -->
10391
- ${bubbles.map(([x, y], i) => `
10392
- <g class="deco-blink" ${_delay(i + 27, 4.0)}>
10393
- <rect x="${x}" y="${y}" width="10" height="6" fill="var(--panel-3, #1A1A18)"/>
10394
- <rect x="${x}" y="${y}" width="10" height="1" fill="var(--lime-dim, #2D5532)"/>
10395
- <rect x="${x}" y="${y + 5}" width="10" height="1" fill="var(--lime-dim, #2D5532)"/>
10396
- <rect x="${x}" y="${y}" width="1" height="6" fill="var(--lime-dim, #2D5532)"/>
10397
- <rect x="${x + 9}" y="${y}" width="1" height="6" fill="var(--lime-dim, #2D5532)"/>
10398
- <rect x="${x + 2}" y="${y + 6}" width="2" height="1" fill="var(--lime-dim, #2D5532)"/>
10399
- </g>
10400
- `).join("")}
10401
- <!-- Idea sparkles · "+" glyphs in lime-dim · scale pulse -->
10402
- ${ideaSparks.map(([x, y], i) => `
10403
- <g class="deco-spark" ${_delay(i + 33, 2.6)} fill="var(--lime-dim, #2D5532)">
10404
- <rect x="${x}" y="${y + 2}" width="6" height="2"/>
10405
- <rect x="${x + 2}" y="${y}" width="2" height="6"/>
10406
- </g>
10407
- `).join("")}
10408
- </g>
10409
- `;
10410
- }
10411
- return `
10412
- <svg viewBox="0 0 800 280" preserveAspectRatio="xMidYMin slice"
10413
- shape-rendering="crispEdges" aria-hidden="true">
10414
- ${scatter}
10415
- ${motif}
10416
- </svg>
10417
- `;
10418
- },
10419
-
10420
10235
  renderComposerHtml(state) {
10421
10236
  const userName = (this.prefs?.name || "you").trim() || "you";
10422
10237
  const lang = this.composerLanguage();
@@ -10508,7 +10323,6 @@
10508
10323
 
10509
10324
  return `
10510
10325
  <section class="cmp">
10511
- <div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("room")}</div>
10512
10326
  <header class="cmp-hero">
10513
10327
  <div class="cmp-greet">${this.escape(t.greet)}</div>
10514
10328
  <h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
@@ -10679,6 +10493,18 @@
10679
10493
  if (!ta) return;
10680
10494
  const MIN = 24;
10681
10495
  const MAX = 200;
10496
+ // Empty value · clear the inline `height` entirely so CSS
10497
+ // `min-height: 24px` reasserts the single-line baseline.
10498
+ // Without this, a `style.height = "auto"` + scrollHeight cycle
10499
+ // can leave the field at the prior tall value · Chromium
10500
+ // sometimes returns the OLD scrollHeight when value was just
10501
+ // cleared synchronously (layout invalidation doesn't always
10502
+ // flush by the next read), so the post-send "shrink back"
10503
+ // visually fails. Explicit unset bypasses the read entirely.
10504
+ if (!ta.value) {
10505
+ ta.style.removeProperty("height");
10506
+ return;
10507
+ }
10682
10508
  ta.style.height = "auto";
10683
10509
  const h = Math.min(MAX, Math.max(MIN, ta.scrollHeight));
10684
10510
  ta.style.height = h + "px";
@@ -11406,15 +11232,14 @@
11406
11232
  const ctaHint = builderMode === "full"
11407
11233
  ? "5–10 min · ReAct research + 7-phase build"
11408
11234
  : t.ctaHint;
11409
- // Celebrity seed cards · 6 random portraits from the pool of
11235
+ // Celebrity seed cards · 4 random portraits from the pool of
11410
11236
  // (CELEBRITY_SEEDS_V1 ∪ generated) − consumed. New random
11411
11237
  // pick on every render so users browse a rotating set.
11412
- const celebrityCards = this._pickRandomCelebrities(6)
11238
+ const celebrityCards = this._pickRandomCelebrities(4)
11413
11239
  .map((seed) => this.celebrityCardHtml(seed))
11414
11240
  .join("");
11415
11241
  return `
11416
11242
  <section class="cmp ag-cmp">
11417
- <div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("agent")}</div>
11418
11243
  <header class="cmp-hero">
11419
11244
  <div class="cmp-greet">${this.escape(t.greet)}</div>
11420
11245
  <h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
@@ -13787,13 +13612,13 @@
13787
13612
  return pool.slice(0, take);
13788
13613
  },
13789
13614
 
13790
- /** Build the markup for the 4 visible scenario cards. Compact
13615
+ /** Build the markup for the 2 visible scenario cards. Compact
13791
13616
  * 2-col grid layout — matches the celebrity seed cards on the
13792
13617
  * new-agent page (mono kicker → serif italic headline → italic
13793
13618
  * intro). No pills, no avatars, no CTA arrow — the entire card
13794
13619
  * is one click target. */
13795
13620
  scenarioAdCardsHtml() {
13796
- return this._pickRandomScenarios(4)
13621
+ return this._pickRandomScenarios(2)
13797
13622
  .map((c) => this.scenarioCardHtml(c))
13798
13623
  .join("");
13799
13624
  },
@@ -13961,7 +13786,6 @@
13961
13786
  // tokens are not routed through `_t()`.
13962
13787
  const statusWord = r.status !== "live" ? String(r.status).toUpperCase() : "";
13963
13788
  head.innerHTML = `
13964
- <button type="button" class="room-head-expand" data-sidebar-expand title="${this.escape(this._t("sidebar_expand"))}" aria-label="${this.escape(this._t("sidebar_expand"))}" data-tip="${this.escape(this._t("sidebar_expand"))}"></button>
13965
13789
  <div class="room-info">
13966
13790
  <div class="room-kicker">
13967
13791
  <span class="kicker-num">// ROOM #${r.number}</span>
@@ -13974,9 +13798,10 @@
13974
13798
  <h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(this._truncateRoomSubject(r.name || r.subject, 60))}</h1>
13975
13799
  </div>
13976
13800
  <div class="head-actions">
13801
+ <a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
13977
13802
  <div class="head-cast">${castHtml}</div>
13803
+ <a href="#" class="head-icon-btn head-add-cast" data-cast-edit-trigger data-tip="${this.escape(this._t("head_add_cast_tip"))}" aria-label="${this.escape(this._t("head_add_cast_label"))}"></a>
13978
13804
  <a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
13979
- <a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
13980
13805
  <a href="#" class="head-icon-btn head-divergence" data-divergence-open data-tip="See how widely the room has explored your question" aria-label="Coverage check"></a>
13981
13806
  ${this.currentBrief
13982
13807
  ? (() => {
@@ -15830,14 +15655,36 @@
15830
15655
  const isChairCard =
15831
15656
  article.classList.contains("chair-direct") ||
15832
15657
  article.classList.contains("chair-intervention");
15833
- if (empty && streaming) {
15658
+ // Single-active-speaker gate · the streaming animation belongs
15659
+ // only to the director whose turn is currently visible per the
15660
+ // server's queue-update activeMessageId field. In text mode this
15661
+ // erases the brief same-frame flash where A's finalize and B's
15662
+ // appended SSE events both run their DOM mutations in the same
15663
+ // rAF — without the gate, both bubbles render `.streaming` and
15664
+ // the user reads "two directors speaking at once." Chair cards
15665
+ // and non-agent messages (user, system) keep their own
15666
+ // streaming semantics; the gate only applies to director
15667
+ // (agent.roleKind === "director") bubbles. We can't read
15668
+ // roleKind here without a getter, so use the same heuristic
15669
+ // updateMessageBodyDom already trusts: a regular .msg article
15670
+ // that is NOT a chair-card is a director or user; user
15671
+ // messages don't carry the streaming class anyway, so this
15672
+ // narrows safely.
15673
+ const isAgentArticle =
15674
+ article.classList.contains("msg") &&
15675
+ !article.classList.contains("user") &&
15676
+ !isChairCard;
15677
+ const activeId = this.currentActiveMessageId || null;
15678
+ const speakingGate = !isAgentArticle || !activeId || messageId === activeId;
15679
+ const wantStreaming = !!streaming && speakingGate;
15680
+ if (empty && wantStreaming) {
15834
15681
  bubble.innerHTML = this.thinkingHtml();
15835
15682
  article.classList.add("thinking");
15836
15683
  article.classList.add(isChairCard ? "is-streaming" : "streaming");
15837
15684
  } else {
15838
15685
  bubble.innerHTML = this.renderBody(display);
15839
15686
  article.classList.toggle("thinking", false);
15840
- article.classList.toggle(isChairCard ? "is-streaming" : "streaming", !!streaming);
15687
+ article.classList.toggle(isChairCard ? "is-streaming" : "streaming", wantStreaming);
15841
15688
  // Re-apply chairman's-notes highlights · the bubble's
15842
15689
  // innerHTML rewrite above wipes any previously-injected
15843
15690
  // .note-highlight spans. Skip mid-stream (offsets won't match
@@ -15848,6 +15695,21 @@
15848
15695
  }
15849
15696
  },
15850
15697
 
15698
+ /** Re-evaluate the streaming class on a single bubble using its
15699
+ * current `meta.streaming` and the current activeMessageId. Used
15700
+ * by the queue-update SSE handler to flip the speaking animation
15701
+ * between A (finalized) and B (newly active) without waiting
15702
+ * for a token tick. No-op when the message isn't streaming or
15703
+ * the article isn't in the DOM yet. */
15704
+ _refreshStreamingClassForId(messageId) {
15705
+ if (!messageId) return;
15706
+ const msg = this.currentMessages.find((m) => m.id === messageId);
15707
+ if (!msg) return;
15708
+ const streaming = !!(msg.meta && msg.meta.streaming);
15709
+ if (!streaming) return;
15710
+ this.updateMessageBodyDom(messageId, msg.body || "", true);
15711
+ },
15712
+
15851
15713
  /** Count of prior non-empty messages this speaker would see as context. */
15852
15714
  contextCountAt(messageId) {
15853
15715
  const idx = this.currentMessages.findIndex((m) => m.id === messageId);
@@ -16512,12 +16374,22 @@
16512
16374
 
16513
16375
  const empty = !displayBody;
16514
16376
  const streaming = m.meta && m.meta.streaming === true;
16377
+ // Single-active-speaker gate · mirrors `updateMessageBodyDom`.
16378
+ // For director (non-chair, non-user, agent) messages, only the
16379
+ // bubble whose id matches the server's activeMessageId may
16380
+ // wear the .streaming + .thinking classes. This keeps the
16381
+ // first-paint and the live-update class state consistent so
16382
+ // the text-mode "two directors flashing" race vanishes.
16383
+ const isDirectorMsg = !isUser && !isChair;
16384
+ const activeId = this.currentActiveMessageId || null;
16385
+ const speakingGate = !isDirectorMsg || !activeId || m.id === activeId;
16386
+ const wantStreaming = !!streaming && speakingGate;
16515
16387
  const stateCls = [];
16516
- if (streaming) stateCls.push("streaming");
16517
- if (empty && streaming && !isUser) stateCls.push("thinking");
16388
+ if (wantStreaming) stateCls.push("streaming");
16389
+ if (empty && wantStreaming) stateCls.push("thinking");
16518
16390
  if (metaKind) stateCls.push(`kind-${metaKind}`);
16519
16391
 
16520
- const bubbleHtml = (empty && streaming && !isUser)
16392
+ const bubbleHtml = (empty && wantStreaming)
16521
16393
  ? this.thinkingHtml()
16522
16394
  : this.renderBody(displayBody);
16523
16395
 
@@ -18169,6 +18041,184 @@
18169
18041
  * `.roundtable-stage`, so it's automatically hidden by the
18170
18042
  * stage's `[hidden]` attribute when in chat view — no separate
18171
18043
  * visibility gate needed. */
18044
+ /** TTS sentence-highlight regex · matches one sentence including
18045
+ * the trailing punctuation. CJK punctuation (。!?;) + English
18046
+ * punctuation (.!?;) + newline boundaries. Mirrors the same regex
18047
+ * the subtitle picker uses in `renderRtSubtitle` so the chat
18048
+ * highlight and the stage subtitle agree on sentence boundaries. */
18049
+ _TTS_SENT_RE: /[^。!?.!?;;\n]+[。!?.!?;;\n]?/g,
18050
+
18051
+ /** Wrap each sentence inside a message's bubble in a `.tts-sentence`
18052
+ * span so the replay-tick listener can light up one at a time. No-op
18053
+ * when already wrapped, when there's no recognisable bubble, or
18054
+ * when the message only contains one sentence (no benefit to
18055
+ * wrapping a single-sentence body). Idempotent · safe to call
18056
+ * on every tick. */
18057
+ _wrapMessageSentencesForTts(article) {
18058
+ if (!article) return false;
18059
+ if (article.dataset.ttsWrapped === "1") return true;
18060
+ // Mid-stream bodies change as tokens arrive · wrapping now would
18061
+ // be torn down by the next innerHTML rewrite. Skip until the
18062
+ // streaming flag clears.
18063
+ if (article.classList.contains("streaming") || article.classList.contains("is-streaming")) {
18064
+ return false;
18065
+ }
18066
+ const bubble =
18067
+ article.querySelector(".cd-body") ||
18068
+ article.querySelector(".ci-body") ||
18069
+ article.querySelector(".msg-bubble");
18070
+ if (!bubble) return false;
18071
+ // Walk text nodes IN ORDER · sentence boundaries cut across
18072
+ // inline formatting (a sentence can include <strong>, <em>,
18073
+ // <a>...) so we have to operate on the concatenated plain text.
18074
+ const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT, null);
18075
+ const textNodes = [];
18076
+ let n;
18077
+ while ((n = walker.nextNode())) textNodes.push(n);
18078
+ if (textNodes.length === 0) return false;
18079
+ let combined = "";
18080
+ const nodeRanges = [];
18081
+ for (const tn of textNodes) {
18082
+ const start = combined.length;
18083
+ combined += tn.textContent;
18084
+ nodeRanges.push({ node: tn, start, end: combined.length });
18085
+ }
18086
+ // Find sentence boundaries in the combined text.
18087
+ const sentences = [];
18088
+ const re = new RegExp(this._TTS_SENT_RE.source, "g");
18089
+ let mtch;
18090
+ while ((mtch = re.exec(combined)) !== null) {
18091
+ const sStart = mtch.index;
18092
+ const sEnd = mtch.index + mtch[0].length;
18093
+ if (mtch[0].trim()) sentences.push({ start: sStart, end: sEnd });
18094
+ }
18095
+ if (sentences.length <= 1) return false;
18096
+ // Bucket per-node ranges by which sentence each character belongs
18097
+ // to · one text node may carry pieces of multiple sentences,
18098
+ // one sentence may span multiple text nodes.
18099
+ const nodeSplits = new Map();
18100
+ for (let sIdx = 0; sIdx < sentences.length; sIdx++) {
18101
+ const { start, end } = sentences[sIdx];
18102
+ for (const nr of nodeRanges) {
18103
+ const overlapStart = Math.max(start, nr.start);
18104
+ const overlapEnd = Math.min(end, nr.end);
18105
+ if (overlapStart >= overlapEnd) continue;
18106
+ if (!nodeSplits.has(nr.node)) nodeSplits.set(nr.node, []);
18107
+ nodeSplits.get(nr.node).push({
18108
+ idx: sIdx,
18109
+ ls: overlapStart - nr.start,
18110
+ le: overlapEnd - nr.start,
18111
+ });
18112
+ }
18113
+ }
18114
+ // Replace each text node with a fragment of (raw text gaps +
18115
+ // sentence spans). Gaps cover whitespace/leading characters
18116
+ // that fell between matched sentences.
18117
+ for (const [tn, splits] of nodeSplits) {
18118
+ const text = tn.textContent;
18119
+ const frag = document.createDocumentFragment();
18120
+ splits.sort((a, b) => a.ls - b.ls);
18121
+ let cursor = 0;
18122
+ for (const s of splits) {
18123
+ if (s.ls > cursor) {
18124
+ frag.appendChild(document.createTextNode(text.slice(cursor, s.ls)));
18125
+ }
18126
+ const span = document.createElement("span");
18127
+ span.className = "tts-sentence";
18128
+ span.dataset.ttsIdx = String(s.idx);
18129
+ span.textContent = text.slice(s.ls, s.le);
18130
+ frag.appendChild(span);
18131
+ cursor = s.le;
18132
+ }
18133
+ if (cursor < text.length) {
18134
+ frag.appendChild(document.createTextNode(text.slice(cursor)));
18135
+ }
18136
+ tn.parentNode.replaceChild(frag, tn);
18137
+ }
18138
+ bubble.dataset.ttsSentenceCount = String(sentences.length);
18139
+ // Store per-sentence character bounds so the tick picker can do
18140
+ // character-cursor-based matching (more accurate than even-split
18141
+ // sentence count division because sentences vary in length).
18142
+ bubble.dataset.ttsSentenceEnds = sentences.map((s) => s.end).join(",");
18143
+ bubble.dataset.ttsTotalChars = String(combined.length);
18144
+ article.dataset.ttsWrapped = "1";
18145
+ return true;
18146
+ },
18147
+
18148
+ _clearTtsSentenceHighlights() {
18149
+ const prior = document.querySelectorAll(".tts-sentence.is-current");
18150
+ prior.forEach((el) => el.classList.remove("is-current"));
18151
+ },
18152
+
18153
+ /** Drive the karaoke-style highlight · reads the live replay
18154
+ * cursor (currentTime / duration) and turns it into the matching
18155
+ * sentence index, then toggles `.is-current` on the right span.
18156
+ * Called from the `boardroom:replay-tick` event listener; no-op
18157
+ * when replay isn't active or the message isn't in the visible
18158
+ * chat (e.g. user scrolled it off, or the room is in stage view). */
18159
+ updateTtsSentenceHighlight() {
18160
+ const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
18161
+ if (!vr || typeof vr.getActive !== "function") return;
18162
+ const active = vr.getActive();
18163
+ if (!active || !active.messageId) {
18164
+ this._clearTtsSentenceHighlights();
18165
+ return;
18166
+ }
18167
+ const audio = (typeof vr.getActiveAudio === "function") ? vr.getActiveAudio() : null;
18168
+ if (!audio || !isFinite(audio.duration) || audio.duration <= 0) return;
18169
+ const article = document.querySelector(`[data-message-id="${active.messageId}"]`);
18170
+ if (!article) return;
18171
+ if (!this._wrapMessageSentencesForTts(article)) {
18172
+ // Single-sentence message · no per-sentence highlight needed.
18173
+ return;
18174
+ }
18175
+ const bubble =
18176
+ article.querySelector(".cd-body") ||
18177
+ article.querySelector(".ci-body") ||
18178
+ article.querySelector(".msg-bubble");
18179
+ if (!bubble) return;
18180
+ const totalChars = parseInt(bubble.dataset.ttsTotalChars || "0", 10);
18181
+ const endsRaw = bubble.dataset.ttsSentenceEnds || "";
18182
+ if (!totalChars || !endsRaw) return;
18183
+ const ends = endsRaw.split(",").map((s) => parseInt(s, 10)).filter((n) => Number.isFinite(n));
18184
+ if (ends.length === 0) return;
18185
+ // Same picker as the subtitle's replay branch · character-cursor
18186
+ // based, so a long sentence holds the highlight for the right
18187
+ // proportion of audio time even when sentence lengths vary.
18188
+ const progress = Math.max(0, Math.min(1, audio.currentTime / audio.duration));
18189
+ const cursor = Math.floor(totalChars * progress);
18190
+ let pickIdx = ends.length - 1;
18191
+ for (let i = 0; i < ends.length; i++) {
18192
+ if (ends[i] >= cursor) { pickIdx = i; break; }
18193
+ }
18194
+ // Diff-apply · only update DOM when the picked sentence
18195
+ // changed since the last tick (avoids reflows ~4×/sec).
18196
+ if (article.dataset.ttsCurrentIdx === String(pickIdx)) return;
18197
+ article.dataset.ttsCurrentIdx = String(pickIdx);
18198
+ // Clear any prior current spans inside THIS article first; spans
18199
+ // in other articles are dropped via the `getActive` change path.
18200
+ const prior = article.querySelectorAll(".tts-sentence.is-current");
18201
+ prior.forEach((el) => el.classList.remove("is-current"));
18202
+ // Also clear highlights in any other articles · the active
18203
+ // message changed (handled below).
18204
+ const stale = document.querySelectorAll(`.tts-sentence.is-current`);
18205
+ stale.forEach((el) => {
18206
+ if (!article.contains(el)) el.classList.remove("is-current");
18207
+ });
18208
+ const spans = article.querySelectorAll(`.tts-sentence[data-tts-idx="${pickIdx}"]`);
18209
+ spans.forEach((el) => el.classList.add("is-current"));
18210
+ // Auto-scroll the current sentence into view · long messages
18211
+ // are the whole reason for this feature, so we shouldn't make
18212
+ // the user scroll manually to follow the cursor. `block: "nearest"`
18213
+ // only scrolls when the sentence is outside the visible area;
18214
+ // no-op when it's already on screen.
18215
+ if (spans.length > 0) {
18216
+ try {
18217
+ spans[0].scrollIntoView({ block: "nearest", behavior: "smooth" });
18218
+ } catch (_) { /* old browsers · noop */ }
18219
+ }
18220
+ },
18221
+
18172
18222
  /** Live subtitle · paints the speaker's name + the tail of their
18173
18223
  * body text into the `[data-rt-subtitle]` panel pinned to the
18174
18224
  * bottom of the round-table stage. Cheap DOM update only — no
@@ -19736,15 +19786,55 @@
19736
19786
  CHAT_STICK_THRESHOLD: 96,
19737
19787
 
19738
19788
  /** Bind a scroll listener once · the listener flips chatStuckToBottom
19739
- * based on how far from the bottom the user has scrolled. Idempotent —
19740
- * re-bind is fine since we tag the element. */
19789
+ * based on direction-aware reasoning (scroll-up = unstick, near-
19790
+ * bottom = stick). Idempotent — re-bind is fine since we tag the
19791
+ * element.
19792
+ *
19793
+ * Earlier implementation used pure distance-from-bottom: any time
19794
+ * `scrollHeight - clientHeight - scrollTop > 96 px` the user was
19795
+ * treated as "reading history" and auto-scroll stopped. That
19796
+ * broke during fast director token streams · content grew faster
19797
+ * than the rAF-batched scroll could catch up, dist temporarily
19798
+ * exceeded the threshold (sometimes by hundreds of pixels mid-
19799
+ * paragraph), the watcher unstuck the user, and from that moment
19800
+ * on no further auto-scroll fired even though the user hadn't
19801
+ * touched the wheel. Net effect: director content rolled off the
19802
+ * bottom and the user had to scroll manually.
19803
+ *
19804
+ * Direction-aware logic instead:
19805
+ * · User scrolled UP (scrollTop decreased ≥ 4 px) → unstick.
19806
+ * The 4 px hysteresis tolerates browser-level micro-jitter so
19807
+ * a smooth-scroll animation overshooting by 1-2 px doesn't
19808
+ * look like a user scroll-up.
19809
+ * · scrollTop landed within CHAT_STICK_THRESHOLD of the bottom
19810
+ * → stick. Restores the bottom-feed after a manual scroll
19811
+ * back to the tail.
19812
+ * · Neither (scroll-down but not at bottom, or content-growth
19813
+ * pushed dist > threshold without user action) → keep current
19814
+ * state. This is the critical clause that fixes the bug —
19815
+ * auto-content-growth alone never unsticks. */
19741
19816
  bindChatScrollWatch() {
19742
19817
  const chat = document.querySelector(".chat");
19743
19818
  if (!chat || chat.dataset.scrollWatch === "1") return;
19744
19819
  chat.dataset.scrollWatch = "1";
19820
+ let prevScrollTop = chat.scrollTop;
19745
19821
  const update = () => {
19746
- const dist = chat.scrollHeight - chat.clientHeight - chat.scrollTop;
19747
- this.chatStuckToBottom = dist <= this.CHAT_STICK_THRESHOLD;
19822
+ const cur = chat.scrollTop;
19823
+ const dist = chat.scrollHeight - chat.clientHeight - cur;
19824
+ if (cur < prevScrollTop - 4) {
19825
+ // User-initiated scroll-up · leave the stick now so token
19826
+ // streaming doesn't keep snapping them back down.
19827
+ this.chatStuckToBottom = false;
19828
+ } else if (dist <= this.CHAT_STICK_THRESHOLD) {
19829
+ // Near the bottom (auto-scroll just landed there, or the
19830
+ // user scrolled back down to the tail) · re-engage stick.
19831
+ this.chatStuckToBottom = true;
19832
+ }
19833
+ // The "else" branch · scroll-down but not yet at bottom, OR
19834
+ // content-growth pushed dist > threshold without the user
19835
+ // touching anything · DO NOTHING. Keeps the prior state
19836
+ // intact so a stuck user stays stuck through fast streams.
19837
+ prevScrollTop = cur;
19748
19838
  };
19749
19839
  chat.addEventListener("scroll", update, { passive: true });
19750
19840
  update();
@@ -20027,6 +20117,30 @@
20027
20117
  app.openDivergenceOverlay();
20028
20118
  return;
20029
20119
  }
20120
+ // Cast-edit · header "Add director" icon → popover with
20121
+ // checkboxes + explicit Confirm. Anchor is the icon button.
20122
+ const castEditTrigger = e.target.closest("[data-cast-edit-trigger]");
20123
+ if (castEditTrigger) {
20124
+ e.preventDefault();
20125
+ app.openCastEditOverlay(castEditTrigger);
20126
+ return;
20127
+ }
20128
+ // Row toggle is wired via a `change` listener inside
20129
+ // openCastEditOverlay · not the global click delegation. Reason:
20130
+ // <label> wraps the <input>, so a single click fires both a label
20131
+ // click AND a synthetic input click — both bubble through any
20132
+ // `[data-cast-edit-pick-id]` `closest()` match → double toggle.
20133
+ // The `change` event fires exactly once per state flip.
20134
+ if (e.target.closest("[data-cast-edit-confirm]")) {
20135
+ e.preventDefault();
20136
+ app.submitCastEdit();
20137
+ return;
20138
+ }
20139
+ if (e.target.closest("[data-cast-edit-close]")) {
20140
+ e.preventDefault();
20141
+ app.closeCastEditOverlay();
20142
+ return;
20143
+ }
20030
20144
  // Jump-to-opener · scroll the chat back to the room's first
20031
20145
  // user message (the convene question). Useful when a long room
20032
20146
  // has scrolled the opener many screens up.
@@ -20115,67 +20229,27 @@
20115
20229
  app.downloadRoomVoiceMp3s().catch((err) => alert(String(err && err.message ? err.message : err)));
20116
20230
  return;
20117
20231
  }
20118
- // Search view · clear-input button (X) inside the input wrap.
20119
- if (e.target.closest("[data-search-clear]")) {
20232
+ // Search overlay · close button (X) or scrim click. Either
20233
+ // element carries `[data-search-overlay-close]`. Closes the
20234
+ // overlay without touching the underlying view.
20235
+ if (e.target.closest("[data-search-overlay-close]")) {
20120
20236
  e.preventDefault();
20121
- const input = document.querySelector("[data-search-input]");
20122
- if (input) {
20123
- input.value = "";
20124
- input.focus();
20125
- app.runSearch("");
20126
- }
20237
+ if (typeof app.closeSearchOverlay === "function") app.closeSearchOverlay();
20127
20238
  return;
20128
20239
  }
20129
- // Search view · starter chip click. Pre-fills the input with
20130
- // the chip's keyword and triggers a search. Dispatching an
20131
- // input event ensures the existing doc-level input listener
20132
- // (which calls runSearch) fires too, so the debounced fetch
20133
- // path stays canonical.
20134
- const starter = e.target.closest("[data-search-starter]");
20135
- if (starter) {
20136
- e.preventDefault();
20137
- const term = starter.getAttribute("data-search-starter") || "";
20138
- const input = document.querySelector("[data-search-input]");
20139
- if (input && term) {
20140
- input.value = term;
20141
- input.focus();
20142
- input.dispatchEvent(new Event("input", { bubbles: true }));
20143
- }
20144
- return;
20145
- }
20146
- // Search view · sort chip ("Newest" / "Oldest"). Updates the
20147
- // sort key + re-renders the cached result list client-side.
20148
- // No re-fetch · the API doesn't expose a sort param and the
20149
- // dataset is small (≤ 200 hits per query) so an in-memory
20150
- // sort is the right call.
20151
- const sortChip = e.target.closest("[data-search-sort-by]");
20152
- if (sortChip) {
20153
- e.preventDefault();
20154
- const next = sortChip.getAttribute("data-search-sort-by") === "oldest" ? "oldest" : "newest";
20155
- if (app._searchSort === next) return; // already active
20156
- app._searchSort = next;
20157
- app._refreshSortChips();
20158
- // Re-render from the cached results · skip the no-cache
20159
- // path (search not yet run) since the chip group is hidden
20160
- // in is-initial.
20161
- const cached = Array.isArray(app._searchLastResults) ? app._searchLastResults : null;
20162
- const cachedQuery = app._searchLastQueryRendered || app._searchLastQuery || "";
20163
- if (cached && cached.length > 0) {
20164
- app.renderSearchResults(cached, cachedQuery);
20165
- }
20166
- return;
20167
- }
20168
- // Search view · result row click. Anchor's href is
20240
+ // Search overlay · result row click. Anchor's href is
20169
20241
  // `#/r/<id>?m=<mid>&q=<query>`, which the hashchange route
20170
20242
  // handler picks up. We stash the pending message id + query
20171
20243
  // here too as a belt-and-braces in case the hash is consumed
20172
- // by another listener before handleRoute runs.
20244
+ // by another listener before handleRoute runs. Also closes
20245
+ // the overlay so the destination room is visible immediately.
20173
20246
  const searchJumpMsg = e.target.closest("[data-search-jump-msg]");
20174
20247
  if (searchJumpMsg) {
20175
20248
  const mid = searchJumpMsg.getAttribute("data-search-jump-msg");
20176
20249
  const qry = searchJumpMsg.getAttribute("data-search-jump-q") || "";
20177
20250
  if (mid) app._pendingMessageScroll = mid;
20178
20251
  app._pendingMessageQuery = qry;
20252
+ if (typeof app.closeSearchOverlay === "function") app.closeSearchOverlay();
20179
20253
  // Let the anchor navigate naturally (hash change → handleRoute
20180
20254
  // → openRoom). No `return` since the link's default action
20181
20255
  // does the navigation.
@@ -21140,7 +21214,15 @@
21140
21214
  // Restore the text into the composer so the user can edit/resend.
21141
21215
  const input = document.querySelector('.input-bar input, [data-send-input]');
21142
21216
  if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
21143
- if (!input.value.trim()) input.value = app.pendingUserMessage;
21217
+ if (!input.value.trim()) {
21218
+ input.value = app.pendingUserMessage;
21219
+ // Re-grow the textarea to fit the restored body · without
21220
+ // this a multi-line queued message reappears clipped to
21221
+ // the single-line baseline.
21222
+ if (input.matches?.(".ib-textarea[data-send-input]")) {
21223
+ app.autosizeRoomInputTextarea();
21224
+ }
21225
+ }
21144
21226
  }
21145
21227
  app.pendingUserMessage = null;
21146
21228
  app.pendingForSpeakerId = null;