privateboard 0.1.32 → 0.1.36
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/dist/boot.js +438 -205
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +438 -205
- package/dist/cli.js.map +1 -1
- package/dist/server.js +438 -205
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/public/adjourn-overlay.css +8 -0
- package/public/agent-profile.css +46 -0
- package/public/agent-profile.js +247 -48
- package/public/app.js +612 -702
- package/public/home.html +1 -1
- package/public/i18n.js +110 -0
- package/public/icons/search.png +0 -0
- package/public/index.html +546 -907
- package/public/room-settings.css +18 -0
- package/public/themes.css +11 -0
- package/public/voice-3d-banner.js +110 -36
- package/public/voice-3d.js +39 -3
- package/public/icons/share.png +0 -0
package/public/app.js
CHANGED
|
@@ -391,6 +391,31 @@
|
|
|
391
391
|
// page-reload shortcut (it's not a reserved keystroke).
|
|
392
392
|
// Menu reload / URL-bar reload still get caught by the
|
|
393
393
|
// `beforeunload` handler below.
|
|
394
|
+
// Search overlay keyboard · Cmd+K / Ctrl+K toggles the
|
|
395
|
+
// floating search palette; Esc closes it when open. Both
|
|
396
|
+
// pre-empt other handlers — registered with capture so the
|
|
397
|
+
// overlay reliably opens / closes regardless of where focus
|
|
398
|
+
// sits in the document.
|
|
399
|
+
document.addEventListener("keydown", (e) => {
|
|
400
|
+
const isCmdK = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey
|
|
401
|
+
&& (e.key === "k" || e.key === "K" || e.code === "KeyK");
|
|
402
|
+
if (isCmdK) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
const overlay = document.querySelector("[data-search-overlay]");
|
|
405
|
+
if (!overlay) return;
|
|
406
|
+
if (overlay.hasAttribute("hidden")) this.openSearch();
|
|
407
|
+
else this.closeSearchOverlay();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (e.key === "Escape") {
|
|
411
|
+
const overlay = document.querySelector("[data-search-overlay]");
|
|
412
|
+
if (overlay && !overlay.hasAttribute("hidden")) {
|
|
413
|
+
e.preventDefault();
|
|
414
|
+
this.closeSearchOverlay();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}, true);
|
|
418
|
+
|
|
394
419
|
document.addEventListener("keydown", (e) => {
|
|
395
420
|
const isReload = (e.ctrlKey || e.metaKey)
|
|
396
421
|
&& !e.altKey
|
|
@@ -528,6 +553,19 @@
|
|
|
528
553
|
this.refreshMiniPlayer();
|
|
529
554
|
if (!this.currentRoomId) return;
|
|
530
555
|
this.renderRtSubtitle();
|
|
556
|
+
// Karaoke-style sentence highlight inside the chat bubble of
|
|
557
|
+
// the currently-playing message · long agent replies are easy
|
|
558
|
+
// to lose your place in when TTS reads them aloud, so the
|
|
559
|
+
// sentence at the audio cursor gets a theme-color tint.
|
|
560
|
+
this.updateTtsSentenceHighlight();
|
|
561
|
+
});
|
|
562
|
+
// Replay state transitions · when playback stops (manual close,
|
|
563
|
+
// playlist exhausted), clear any lingering sentence highlight
|
|
564
|
+
// so the chat doesn't keep glowing the last-read line.
|
|
565
|
+
document.addEventListener("boardroom:replay-active", () => {
|
|
566
|
+
const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
|
|
567
|
+
const active = (vr && typeof vr.getActive === "function") ? vr.getActive() : null;
|
|
568
|
+
if (!active) this._clearTtsSentenceHighlights();
|
|
531
569
|
});
|
|
532
570
|
window.addEventListener("hashchange", () => this.handleRoute());
|
|
533
571
|
this.handleRoute();
|
|
@@ -3745,6 +3783,14 @@
|
|
|
3745
3783
|
if (choice === "cancel" || !text.trim()) return;
|
|
3746
3784
|
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
|
|
3747
3785
|
input.value = "";
|
|
3786
|
+
// Shrink the room input-bar textarea back to baseline · the
|
|
3787
|
+
// user grew it by typing the message we're now sending, so
|
|
3788
|
+
// the post-clear field would otherwise stay at the prior
|
|
3789
|
+
// tall height. Mirrors the same call submitFromComposer
|
|
3790
|
+
// makes on the direct (no-modal) send path.
|
|
3791
|
+
if (input.matches?.(".ib-textarea[data-send-input]")) {
|
|
3792
|
+
this.autosizeRoomInputTextarea();
|
|
3793
|
+
}
|
|
3748
3794
|
}
|
|
3749
3795
|
if (choice === "interrupt") {
|
|
3750
3796
|
this.sendMessage(text, mentions).catch((err) => alert("Send failed: " + err.message));
|
|
@@ -4798,6 +4844,217 @@
|
|
|
4798
4844
|
this.refreshFollowUpCastButton();
|
|
4799
4845
|
},
|
|
4800
4846
|
|
|
4847
|
+
/* ─── Cast-edit popover · room-head "Add director" button ───
|
|
4848
|
+
Anchored under the head icon. Lists every director with
|
|
4849
|
+
checkboxes pre-checked for current cast members. Diffs against
|
|
4850
|
+
initial state on confirm; PATCH /api/rooms/:id/members applies
|
|
4851
|
+
adds + removes atomically. The orchestrator's announceMember-
|
|
4852
|
+
Change posts a chair welcome / farewell in chat, and any newly-
|
|
4853
|
+
added director gets injected into the live speaker queue. */
|
|
4854
|
+
openCastEditOverlay(anchorBtn) {
|
|
4855
|
+
this.closeCastEditOverlay();
|
|
4856
|
+
if (!anchorBtn || !this.currentRoomId || !this.currentRoom) return;
|
|
4857
|
+
if (this.currentRoom.status === "adjourned") return;
|
|
4858
|
+
|
|
4859
|
+
const dirs = (this.agents || [])
|
|
4860
|
+
.filter((a) => a.roleKind !== "moderator")
|
|
4861
|
+
.slice()
|
|
4862
|
+
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
|
4863
|
+
const currentIds = new Set(
|
|
4864
|
+
(this.currentMembers || [])
|
|
4865
|
+
.filter((m) => (m.roleKind || "director") !== "moderator")
|
|
4866
|
+
.map((m) => m.id),
|
|
4867
|
+
);
|
|
4868
|
+
// State carries the INITIAL set (for diff on confirm) + the
|
|
4869
|
+
// user's working selection (mutated by toggleCastEditDirector).
|
|
4870
|
+
this._castEditState = {
|
|
4871
|
+
initialIds: new Set(currentIds),
|
|
4872
|
+
currentIds: new Set(currentIds),
|
|
4873
|
+
saving: false,
|
|
4874
|
+
};
|
|
4875
|
+
|
|
4876
|
+
const rows = dirs.map((a) => {
|
|
4877
|
+
const checked = currentIds.has(a.id);
|
|
4878
|
+
return `
|
|
4879
|
+
<label class="composer-pick-row${checked ? " on" : ""}" data-cast-edit-pick-id="${this.escape(a.id)}">
|
|
4880
|
+
<input type="checkbox" ${checked ? "checked" : ""}>
|
|
4881
|
+
<img class="composer-pick-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}">
|
|
4882
|
+
<span class="composer-pick-main">
|
|
4883
|
+
<span class="composer-pick-name">${this.escape(a.name || "")}</span>
|
|
4884
|
+
<span class="composer-pick-tag">${this.escape(a.roleTag || "")}</span>
|
|
4885
|
+
</span>
|
|
4886
|
+
</label>
|
|
4887
|
+
`;
|
|
4888
|
+
}).join("");
|
|
4889
|
+
|
|
4890
|
+
const pop = document.createElement("div");
|
|
4891
|
+
pop.id = "cast-edit-pop";
|
|
4892
|
+
pop.className = "cast-edit-pop";
|
|
4893
|
+
pop.innerHTML = `
|
|
4894
|
+
<div class="composer-pick-head">
|
|
4895
|
+
<span class="composer-pick-title">${this.escape(this._t("cast_edit_title"))}</span>
|
|
4896
|
+
</div>
|
|
4897
|
+
<div class="composer-pick-list">${rows || `<div class="composer-pick-empty">${this.escape(this._t("cast_edit_empty"))}</div>`}</div>
|
|
4898
|
+
<div class="cast-edit-foot">
|
|
4899
|
+
<span class="cast-edit-floor-msg" data-cast-edit-floor>${this.escape(this._t("cast_edit_min_floor"))}</span>
|
|
4900
|
+
<button type="button" class="cast-edit-btn" data-cast-edit-close>${this.escape(this._t("cast_edit_cancel"))}</button>
|
|
4901
|
+
<button type="button" class="cast-edit-btn is-primary" data-cast-edit-confirm disabled>${this.escape(this._t("cast_edit_confirm"))}</button>
|
|
4902
|
+
</div>
|
|
4903
|
+
`;
|
|
4904
|
+
document.body.appendChild(pop);
|
|
4905
|
+
|
|
4906
|
+
// Position · mirrors openFollowUpCastPicker · pick the side with
|
|
4907
|
+
// more room (the head icon sits near the top so "below" usually
|
|
4908
|
+
// wins). Cap max-height to the available space so the row list
|
|
4909
|
+
// scrolls within view instead of overflowing.
|
|
4910
|
+
const r = anchorBtn.getBoundingClientRect();
|
|
4911
|
+
const MARGIN = 8;
|
|
4912
|
+
const GAP = 6;
|
|
4913
|
+
const popW = Math.min(340, window.innerWidth - MARGIN * 2);
|
|
4914
|
+
const spaceBelow = window.innerHeight - r.bottom - GAP - MARGIN;
|
|
4915
|
+
const spaceAbove = r.top - GAP - MARGIN;
|
|
4916
|
+
const openAbove = spaceAbove > spaceBelow;
|
|
4917
|
+
// Cap the popover height · without this the list stretches to
|
|
4918
|
+
// every available pixel of `spaceBelow` and visually anchors to
|
|
4919
|
+
// the window's bottom edge (~750px on a 13" laptop). The cap
|
|
4920
|
+
// is `min(420px, 55vh)` — enough rows to scan ~6-7 directors
|
|
4921
|
+
// without scrolling, short enough to read as a compact menu
|
|
4922
|
+
// instead of a full-height panel.
|
|
4923
|
+
const HEIGHT_CAP = Math.min(420, Math.round(window.innerHeight * 0.55));
|
|
4924
|
+
const available = openAbove ? spaceAbove : spaceBelow;
|
|
4925
|
+
const maxHeight = Math.max(180, Math.min(HEIGHT_CAP, available));
|
|
4926
|
+
pop.style.width = popW + "px";
|
|
4927
|
+
// Right-align to the anchor so the popover doesn't jut past the
|
|
4928
|
+
// right edge of the window (head-actions sits flush right).
|
|
4929
|
+
const left = Math.min(
|
|
4930
|
+
Math.max(MARGIN, r.right - popW),
|
|
4931
|
+
window.innerWidth - popW - MARGIN,
|
|
4932
|
+
);
|
|
4933
|
+
pop.style.left = left + "px";
|
|
4934
|
+
pop.style.maxHeight = maxHeight + "px";
|
|
4935
|
+
pop.style.top = openAbove
|
|
4936
|
+
? (r.top - GAP - Math.min(pop.scrollHeight || maxHeight, maxHeight)) + "px"
|
|
4937
|
+
: (r.bottom + GAP) + "px";
|
|
4938
|
+
|
|
4939
|
+
// Outside-click + Esc dismiss · same dismiss vocabulary as the
|
|
4940
|
+
// follow-up cast picker. We bind the outside-click on the NEXT
|
|
4941
|
+
// tick so this open click doesn't immediately close.
|
|
4942
|
+
this._castEditEsc = (ev) => {
|
|
4943
|
+
if (ev.key === "Escape") {
|
|
4944
|
+
ev.stopImmediatePropagation();
|
|
4945
|
+
this.closeCastEditOverlay();
|
|
4946
|
+
}
|
|
4947
|
+
};
|
|
4948
|
+
this._castEditOutside = (ev) => {
|
|
4949
|
+
if (
|
|
4950
|
+
!pop.contains(ev.target)
|
|
4951
|
+
&& !ev.target.closest("[data-cast-edit-trigger]")
|
|
4952
|
+
) {
|
|
4953
|
+
this.closeCastEditOverlay();
|
|
4954
|
+
}
|
|
4955
|
+
};
|
|
4956
|
+
document.addEventListener("keydown", this._castEditEsc, true);
|
|
4957
|
+
setTimeout(() => document.addEventListener("click", this._castEditOutside, true), 0);
|
|
4958
|
+
|
|
4959
|
+
// Single source of truth for row toggling · listens at the
|
|
4960
|
+
// popover root so both label-click (browser flips the input)
|
|
4961
|
+
// and direct-checkbox-click reach us as one `change` event.
|
|
4962
|
+
pop.addEventListener("change", (ev) => {
|
|
4963
|
+
const cb = ev.target;
|
|
4964
|
+
if (!cb || cb.type !== "checkbox") return;
|
|
4965
|
+
const row = cb.closest("[data-cast-edit-pick-id]");
|
|
4966
|
+
if (!row) return;
|
|
4967
|
+
const id = row.getAttribute("data-cast-edit-pick-id");
|
|
4968
|
+
this.setCastEditDirector(id, !!cb.checked);
|
|
4969
|
+
});
|
|
4970
|
+
|
|
4971
|
+
this.refreshCastEditConfirm();
|
|
4972
|
+
},
|
|
4973
|
+
|
|
4974
|
+
closeCastEditOverlay() {
|
|
4975
|
+
const el = document.getElementById("cast-edit-pop");
|
|
4976
|
+
if (el) el.remove();
|
|
4977
|
+
if (this._castEditEsc) {
|
|
4978
|
+
document.removeEventListener("keydown", this._castEditEsc, true);
|
|
4979
|
+
this._castEditEsc = null;
|
|
4980
|
+
}
|
|
4981
|
+
if (this._castEditOutside) {
|
|
4982
|
+
document.removeEventListener("click", this._castEditOutside, true);
|
|
4983
|
+
this._castEditOutside = null;
|
|
4984
|
+
}
|
|
4985
|
+
this._castEditState = null;
|
|
4986
|
+
},
|
|
4987
|
+
|
|
4988
|
+
/** Mirror the row's checkbox state into _castEditState · driven
|
|
4989
|
+
* by the `change` event in openCastEditOverlay. `on` reflects
|
|
4990
|
+
* the input.checked value AFTER the native flip, so this is
|
|
4991
|
+
* always a set/clear (never a flip) and is idempotent under
|
|
4992
|
+
* duplicate fires. */
|
|
4993
|
+
setCastEditDirector(id, on) {
|
|
4994
|
+
const state = this._castEditState;
|
|
4995
|
+
if (!state || state.saving) return;
|
|
4996
|
+
if (on) state.currentIds.add(id);
|
|
4997
|
+
else state.currentIds.delete(id);
|
|
4998
|
+
const row = document.querySelector(`[data-cast-edit-pick-id="${CSS.escape(id)}"]`);
|
|
4999
|
+
if (row) row.classList.toggle("on", on);
|
|
5000
|
+
this.refreshCastEditConfirm();
|
|
5001
|
+
},
|
|
5002
|
+
|
|
5003
|
+
/** Recompute the confirm-button enabled state · enabled when the
|
|
5004
|
+
* selection differs from the initial AND keeps at least one
|
|
5005
|
+
* director (the server enforces the same floor; we mirror it
|
|
5006
|
+
* here so the button reflects validity). Also flips the floor-
|
|
5007
|
+
* message visibility. */
|
|
5008
|
+
refreshCastEditConfirm() {
|
|
5009
|
+
const state = this._castEditState;
|
|
5010
|
+
const pop = document.getElementById("cast-edit-pop");
|
|
5011
|
+
if (!state || !pop) return;
|
|
5012
|
+
const confirmBtn = pop.querySelector("[data-cast-edit-confirm]");
|
|
5013
|
+
const floorMsg = pop.querySelector("[data-cast-edit-floor]");
|
|
5014
|
+
const sizeOk = state.currentIds.size >= 1;
|
|
5015
|
+
const changed =
|
|
5016
|
+
state.currentIds.size !== state.initialIds.size
|
|
5017
|
+
|| [...state.currentIds].some((id) => !state.initialIds.has(id));
|
|
5018
|
+
if (confirmBtn) confirmBtn.disabled = state.saving || !changed || !sizeOk;
|
|
5019
|
+
if (floorMsg) floorMsg.classList.toggle("is-visible", !sizeOk);
|
|
5020
|
+
},
|
|
5021
|
+
|
|
5022
|
+
async submitCastEdit() {
|
|
5023
|
+
const state = this._castEditState;
|
|
5024
|
+
if (!state || state.saving || !this.currentRoomId) return;
|
|
5025
|
+
if (state.currentIds.size < 1) return;
|
|
5026
|
+
state.saving = true;
|
|
5027
|
+
this.refreshCastEditConfirm();
|
|
5028
|
+
const pop = document.getElementById("cast-edit-pop");
|
|
5029
|
+
const confirmBtn = pop ? pop.querySelector("[data-cast-edit-confirm]") : null;
|
|
5030
|
+
const origLabel = confirmBtn ? confirmBtn.textContent : "";
|
|
5031
|
+
if (confirmBtn) confirmBtn.textContent = this._t("cast_edit_saving");
|
|
5032
|
+
try {
|
|
5033
|
+
const res = await fetch(
|
|
5034
|
+
"/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/members",
|
|
5035
|
+
{
|
|
5036
|
+
method: "PATCH",
|
|
5037
|
+
headers: { "content-type": "application/json" },
|
|
5038
|
+
body: JSON.stringify({ agentIds: [...state.currentIds] }),
|
|
5039
|
+
},
|
|
5040
|
+
);
|
|
5041
|
+
if (!res.ok) {
|
|
5042
|
+
const j = await res.json().catch(() => ({}));
|
|
5043
|
+
throw new Error(j.error || "Failed to update cast");
|
|
5044
|
+
}
|
|
5045
|
+
// SSE `members-changed` config-event arrives moments later
|
|
5046
|
+
// and triggers a member re-fetch in the openRoom path's
|
|
5047
|
+
// handler. Close the popover immediately so the user sees the
|
|
5048
|
+
// chair's join/leave announcement land in chat.
|
|
5049
|
+
this.closeCastEditOverlay();
|
|
5050
|
+
} catch (e) {
|
|
5051
|
+
if (confirmBtn) confirmBtn.textContent = origLabel;
|
|
5052
|
+
state.saving = false;
|
|
5053
|
+
this.refreshCastEditConfirm();
|
|
5054
|
+
alert((e && e.message) || "Failed to update cast");
|
|
5055
|
+
}
|
|
5056
|
+
},
|
|
5057
|
+
|
|
4801
5058
|
async submitFollowUp() {
|
|
4802
5059
|
const overlay = document.getElementById("followup-overlay");
|
|
4803
5060
|
if (!overlay) return;
|
|
@@ -6863,13 +7120,9 @@
|
|
|
6863
7120
|
const sectionHeader = (label, kind) => {
|
|
6864
7121
|
const pinned = kind === "pinned";
|
|
6865
7122
|
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
7123
|
const cls = "agents-section-header" + (pinned ? " pinned" : "") + (chair ? " chair" : "");
|
|
6870
7124
|
return `
|
|
6871
7125
|
<div class="${cls}">
|
|
6872
|
-
${glyph}
|
|
6873
7126
|
<span>${this.escape(label)}</span>
|
|
6874
7127
|
<span class="line"></span>
|
|
6875
7128
|
</div>
|
|
@@ -7073,7 +7326,10 @@
|
|
|
7073
7326
|
const kids = Array.isArray(this.currentFollowUps) ? this.currentFollowUps : [];
|
|
7074
7327
|
if (briefCard && kids.length > 0) {
|
|
7075
7328
|
// System UI · always English (sidebar / brief-card chrome head).
|
|
7076
|
-
|
|
7329
|
+
// `// ` prefix matches the `.sa-banner-tag` kicker convention
|
|
7330
|
+
// (`// session analytics`) so both post-adjourn cards read as
|
|
7331
|
+
// the same component family.
|
|
7332
|
+
const headLabel = `// Follow-up rooms · ${kids.length}`;
|
|
7077
7333
|
const block = document.createElement("div");
|
|
7078
7334
|
block.className = "followup-children";
|
|
7079
7335
|
block.innerHTML = [
|
|
@@ -8387,367 +8643,58 @@
|
|
|
8387
8643
|
this.renderNotesPage(notesList);
|
|
8388
8644
|
},
|
|
8389
8645
|
|
|
8390
|
-
// ── Search
|
|
8391
|
-
/** Open the
|
|
8392
|
-
*
|
|
8393
|
-
*
|
|
8394
|
-
*
|
|
8395
|
-
*
|
|
8396
|
-
*
|
|
8646
|
+
// ── Search overlay · cross-room keyword search ─────────────
|
|
8647
|
+
/** Open the floating search overlay. Layers above whatever
|
|
8648
|
+
* view is mounted (room / agent / reports / notes stay
|
|
8649
|
+
* underneath). Esc / scrim-click / X close — see
|
|
8650
|
+
* closeSearchOverlay + the doc-level handlers in
|
|
8651
|
+
* index.html. Restores the last query on re-open so the
|
|
8652
|
+
* user sees their prior results without retyping. */
|
|
8397
8653
|
openSearch() {
|
|
8398
|
-
document.
|
|
8399
|
-
if (
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
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"));
|
|
8654
|
+
const overlay = document.querySelector("[data-search-overlay]");
|
|
8655
|
+
if (!overlay) return;
|
|
8656
|
+
overlay.removeAttribute("hidden");
|
|
8657
|
+
overlay.setAttribute("aria-hidden", "false");
|
|
8658
|
+
document.documentElement.classList.add("has-search-overlay");
|
|
8431
8659
|
document.querySelectorAll("[data-search-trigger]").forEach((el) => el.classList.add("active"));
|
|
8432
|
-
|
|
8433
|
-
if (
|
|
8434
|
-
|
|
8435
|
-
|
|
8436
|
-
|
|
8437
|
-
|
|
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
|
-
}
|
|
8660
|
+
const input = overlay.querySelector("[data-search-input]");
|
|
8661
|
+
if (input) {
|
|
8662
|
+
const last = this._searchLastQuery || "";
|
|
8663
|
+
if (input.value !== last) input.value = last;
|
|
8664
|
+
setTimeout(() => { input.focus(); input.select(); }, 30);
|
|
8665
|
+
if (last) this.runSearch(last);
|
|
8738
8666
|
}
|
|
8739
8667
|
},
|
|
8740
8668
|
|
|
8741
|
-
/**
|
|
8742
|
-
*
|
|
8743
|
-
*
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8669
|
+
/** Close the floating search overlay. Leaves the underlying
|
|
8670
|
+
* view exactly as it was. Doesn't clear the input or cached
|
|
8671
|
+
* query — re-open restores them via openSearch. */
|
|
8672
|
+
closeSearchOverlay() {
|
|
8673
|
+
const overlay = document.querySelector("[data-search-overlay]");
|
|
8674
|
+
if (!overlay) return;
|
|
8675
|
+
overlay.setAttribute("hidden", "");
|
|
8676
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
8677
|
+
document.documentElement.classList.remove("has-search-overlay");
|
|
8678
|
+
document.querySelectorAll("[data-search-trigger]").forEach((el) => el.classList.remove("active"));
|
|
8679
|
+
},
|
|
8680
|
+
|
|
8681
|
+
/** Static HTML for the empty-state placeholder · captured
|
|
8682
|
+
* here so runSearch("") can restore the same markup that
|
|
8683
|
+
* index.html mounts initially. */
|
|
8684
|
+
_searchOverlayEmptyHtml() {
|
|
8685
|
+
const title = (this._t && this._t("search_overlay_empty_title")) || "Search across rooms and messages";
|
|
8686
|
+
const hint = (this._t && this._t("search_overlay_empty_hint")) || "Type to filter live · click a result to jump";
|
|
8687
|
+
return `<div class="search-overlay-empty">
|
|
8688
|
+
<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">
|
|
8689
|
+
<circle cx="27" cy="27" r="17"/>
|
|
8690
|
+
<line x1="50" y1="50" x2="39" y2="39"/>
|
|
8691
|
+
<line x1="21" y1="27" x2="33" y2="27" opacity="0.45"/>
|
|
8692
|
+
<line x1="21" y1="22" x2="28" y2="22" opacity="0.3"/>
|
|
8693
|
+
<line x1="21" y1="32" x2="30" y2="32" opacity="0.3"/>
|
|
8694
|
+
</svg>
|
|
8695
|
+
<div class="search-overlay-empty-text">${this.escape(title)}</div>
|
|
8696
|
+
<div class="search-overlay-empty-hint">${this.escape(hint)}</div>
|
|
8697
|
+
</div>`;
|
|
8751
8698
|
},
|
|
8752
8699
|
|
|
8753
8700
|
/** Debounced search · 200ms after last keystroke we hit
|
|
@@ -8763,118 +8710,55 @@
|
|
|
8763
8710
|
}
|
|
8764
8711
|
const seq = (this._searchSeq = (this._searchSeq || 0) + 1);
|
|
8765
8712
|
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
8713
|
if (!target) return;
|
|
8774
8714
|
if (q.length === 0) {
|
|
8775
|
-
|
|
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 = "";
|
|
8715
|
+
target.innerHTML = this._searchOverlayEmptyHtml();
|
|
8782
8716
|
return;
|
|
8783
8717
|
}
|
|
8784
|
-
|
|
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 = "";
|
|
8718
|
+
target.innerHTML = `<div class="search-overlay-status"><span class="search-overlay-status-kicker">// searching</span><div class="search-overlay-status-msg">…</div></div>`;
|
|
8791
8719
|
this._searchDebounceTimer = setTimeout(async () => {
|
|
8792
8720
|
try {
|
|
8793
8721
|
const r = await fetch("/api/search?q=" + encodeURIComponent(q));
|
|
8794
8722
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
8795
8723
|
const j = await r.json();
|
|
8796
|
-
if (seq !== this._searchSeq) return;
|
|
8724
|
+
if (seq !== this._searchSeq) return;
|
|
8797
8725
|
this.renderSearchResults(j.results || [], q);
|
|
8798
8726
|
} catch (e) {
|
|
8799
8727
|
if (seq !== this._searchSeq) return;
|
|
8800
|
-
target.innerHTML = `<div class="search-
|
|
8728
|
+
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
8729
|
}
|
|
8802
8730
|
}, 200);
|
|
8803
8731
|
},
|
|
8804
8732
|
|
|
8805
|
-
/** Render the
|
|
8806
|
-
*
|
|
8807
|
-
*
|
|
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. */
|
|
8733
|
+
/** Render the result list · flat `<ul.search-overlay-list>` of
|
|
8734
|
+
* `.so-row` rows, sorted newest-first by createdAt. 0-match
|
|
8735
|
+
* shows a status block in place of the list. */
|
|
8812
8736
|
renderSearchResults(results, query) {
|
|
8813
8737
|
const target = document.querySelector("[data-search-results]");
|
|
8814
|
-
const metaEl = document.querySelector("[data-search-results-meta]");
|
|
8815
8738
|
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
8739
|
this._searchLastResults = Array.isArray(results) ? results.slice() : [];
|
|
8820
8740
|
this._searchLastQueryRendered = query || "";
|
|
8821
8741
|
if (!results || results.length === 0) {
|
|
8822
|
-
|
|
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>`;
|
|
8742
|
+
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
8743
|
return;
|
|
8825
8744
|
}
|
|
8826
|
-
const sorted =
|
|
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
|
-
}
|
|
8745
|
+
const sorted = results.slice().sort((a, b) => ((b && b.createdAt) || 0) - ((a && a.createdAt) || 0));
|
|
8833
8746
|
const rows = sorted.map((hit) => this.renderSearchResultRow(hit, query)).join("");
|
|
8834
|
-
target.innerHTML = `<ul class="search-
|
|
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;
|
|
8849
|
-
},
|
|
8850
|
-
|
|
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
|
-
});
|
|
8747
|
+
target.innerHTML = `<ul class="search-overlay-list">${rows}</ul>`;
|
|
8859
8748
|
},
|
|
8860
8749
|
|
|
8861
|
-
/** Build one
|
|
8862
|
-
*
|
|
8863
|
-
*
|
|
8864
|
-
*
|
|
8865
|
-
*
|
|
8866
|
-
*
|
|
8867
|
-
*
|
|
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. */
|
|
8750
|
+
/** Build one overlay result row · grid layout (icon | title |
|
|
8751
|
+
* meta · snippet spans cols 2-3). `<mark>` wraps the matched
|
|
8752
|
+
* fragment inside the snippet so the keyword stays visible
|
|
8753
|
+
* inside the truncated context. Click → `#/r/{roomId}?m=
|
|
8754
|
+
* {msgId}&q={q}` — same hash-router path the prior page used,
|
|
8755
|
+
* so message-jump + keyword-flash on the destination is
|
|
8756
|
+
* unchanged. */
|
|
8873
8757
|
renderSearchResultRow(hit, query) {
|
|
8874
8758
|
const body = hit.body || "";
|
|
8875
8759
|
const offset = Math.max(0, hit.matchOffset || 0);
|
|
8876
8760
|
const ql = (query || "").length;
|
|
8877
|
-
const LEAD =
|
|
8761
|
+
const LEAD = 60, TRAIL = 120;
|
|
8878
8762
|
const start = Math.max(0, offset - LEAD);
|
|
8879
8763
|
const end = Math.min(body.length, offset + ql + TRAIL);
|
|
8880
8764
|
const prefix = start > 0 ? "…" : "";
|
|
@@ -8888,31 +8772,12 @@
|
|
|
8888
8772
|
`<mark>${this.escape(flat(matched))}</mark>` +
|
|
8889
8773
|
this.escape(flat(after) + suffix);
|
|
8890
8774
|
const roomTitle = (hit.roomTitle || "Untitled room").trim() || "Untitled room";
|
|
8891
|
-
const author = (hit.authorName || "").trim() || "Director";
|
|
8892
8775
|
const timeAgo = hit.createdAt ? this.relTime(hit.createdAt) : "";
|
|
8893
8776
|
const qParam = query ? `&q=${encodeURIComponent(query)}` : "";
|
|
8894
|
-
//
|
|
8895
|
-
//
|
|
8896
|
-
|
|
8897
|
-
|
|
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
|
-
`;
|
|
8777
|
+
// Lucide MessageSquare · chat-bubble icon matching the
|
|
8778
|
+
// Claude search.png reference. Inherit currentColor.
|
|
8779
|
+
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>`;
|
|
8780
|
+
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
8781
|
},
|
|
8917
8782
|
|
|
8918
8783
|
/** Render the All Notes timeline · same filter-strip + date-
|
|
@@ -10241,182 +10106,6 @@
|
|
|
10241
10106
|
* tune live as a slim toolbar inside its bottom edge so the page
|
|
10242
10107
|
* has one clear gravitational centre, not three competing form
|
|
10243
10108
|
* 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
10109
|
renderComposerHtml(state) {
|
|
10421
10110
|
const userName = (this.prefs?.name || "you").trim() || "you";
|
|
10422
10111
|
const lang = this.composerLanguage();
|
|
@@ -10508,7 +10197,6 @@
|
|
|
10508
10197
|
|
|
10509
10198
|
return `
|
|
10510
10199
|
<section class="cmp">
|
|
10511
|
-
<div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("room")}</div>
|
|
10512
10200
|
<header class="cmp-hero">
|
|
10513
10201
|
<div class="cmp-greet">${this.escape(t.greet)}</div>
|
|
10514
10202
|
<h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
|
|
@@ -10679,6 +10367,18 @@
|
|
|
10679
10367
|
if (!ta) return;
|
|
10680
10368
|
const MIN = 24;
|
|
10681
10369
|
const MAX = 200;
|
|
10370
|
+
// Empty value · clear the inline `height` entirely so CSS
|
|
10371
|
+
// `min-height: 24px` reasserts the single-line baseline.
|
|
10372
|
+
// Without this, a `style.height = "auto"` + scrollHeight cycle
|
|
10373
|
+
// can leave the field at the prior tall value · Chromium
|
|
10374
|
+
// sometimes returns the OLD scrollHeight when value was just
|
|
10375
|
+
// cleared synchronously (layout invalidation doesn't always
|
|
10376
|
+
// flush by the next read), so the post-send "shrink back"
|
|
10377
|
+
// visually fails. Explicit unset bypasses the read entirely.
|
|
10378
|
+
if (!ta.value) {
|
|
10379
|
+
ta.style.removeProperty("height");
|
|
10380
|
+
return;
|
|
10381
|
+
}
|
|
10682
10382
|
ta.style.height = "auto";
|
|
10683
10383
|
const h = Math.min(MAX, Math.max(MIN, ta.scrollHeight));
|
|
10684
10384
|
ta.style.height = h + "px";
|
|
@@ -11406,15 +11106,14 @@
|
|
|
11406
11106
|
const ctaHint = builderMode === "full"
|
|
11407
11107
|
? "5–10 min · ReAct research + 7-phase build"
|
|
11408
11108
|
: t.ctaHint;
|
|
11409
|
-
// Celebrity seed cards ·
|
|
11109
|
+
// Celebrity seed cards · 4 random portraits from the pool of
|
|
11410
11110
|
// (CELEBRITY_SEEDS_V1 ∪ generated) − consumed. New random
|
|
11411
11111
|
// pick on every render so users browse a rotating set.
|
|
11412
|
-
const celebrityCards = this._pickRandomCelebrities(
|
|
11112
|
+
const celebrityCards = this._pickRandomCelebrities(4)
|
|
11413
11113
|
.map((seed) => this.celebrityCardHtml(seed))
|
|
11414
11114
|
.join("");
|
|
11415
11115
|
return `
|
|
11416
11116
|
<section class="cmp ag-cmp">
|
|
11417
|
-
<div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("agent")}</div>
|
|
11418
11117
|
<header class="cmp-hero">
|
|
11419
11118
|
<div class="cmp-greet">${this.escape(t.greet)}</div>
|
|
11420
11119
|
<h1 class="cmp-prompt">${this.escape(t.prompt)}</h1>
|
|
@@ -13787,13 +13486,13 @@
|
|
|
13787
13486
|
return pool.slice(0, take);
|
|
13788
13487
|
},
|
|
13789
13488
|
|
|
13790
|
-
/** Build the markup for the
|
|
13489
|
+
/** Build the markup for the 2 visible scenario cards. Compact
|
|
13791
13490
|
* 2-col grid layout — matches the celebrity seed cards on the
|
|
13792
13491
|
* new-agent page (mono kicker → serif italic headline → italic
|
|
13793
13492
|
* intro). No pills, no avatars, no CTA arrow — the entire card
|
|
13794
13493
|
* is one click target. */
|
|
13795
13494
|
scenarioAdCardsHtml() {
|
|
13796
|
-
return this._pickRandomScenarios(
|
|
13495
|
+
return this._pickRandomScenarios(2)
|
|
13797
13496
|
.map((c) => this.scenarioCardHtml(c))
|
|
13798
13497
|
.join("");
|
|
13799
13498
|
},
|
|
@@ -13974,9 +13673,10 @@
|
|
|
13974
13673
|
<h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(this._truncateRoomSubject(r.name || r.subject, 60))}</h1>
|
|
13975
13674
|
</div>
|
|
13976
13675
|
<div class="head-actions">
|
|
13676
|
+
<a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
|
|
13977
13677
|
<div class="head-cast">${castHtml}</div>
|
|
13678
|
+
<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
13679
|
<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
13680
|
<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
13681
|
${this.currentBrief
|
|
13982
13682
|
? (() => {
|
|
@@ -18169,6 +17869,184 @@
|
|
|
18169
17869
|
* `.roundtable-stage`, so it's automatically hidden by the
|
|
18170
17870
|
* stage's `[hidden]` attribute when in chat view — no separate
|
|
18171
17871
|
* visibility gate needed. */
|
|
17872
|
+
/** TTS sentence-highlight regex · matches one sentence including
|
|
17873
|
+
* the trailing punctuation. CJK punctuation (。!?;) + English
|
|
17874
|
+
* punctuation (.!?;) + newline boundaries. Mirrors the same regex
|
|
17875
|
+
* the subtitle picker uses in `renderRtSubtitle` so the chat
|
|
17876
|
+
* highlight and the stage subtitle agree on sentence boundaries. */
|
|
17877
|
+
_TTS_SENT_RE: /[^。!?.!?;;\n]+[。!?.!?;;\n]?/g,
|
|
17878
|
+
|
|
17879
|
+
/** Wrap each sentence inside a message's bubble in a `.tts-sentence`
|
|
17880
|
+
* span so the replay-tick listener can light up one at a time. No-op
|
|
17881
|
+
* when already wrapped, when there's no recognisable bubble, or
|
|
17882
|
+
* when the message only contains one sentence (no benefit to
|
|
17883
|
+
* wrapping a single-sentence body). Idempotent · safe to call
|
|
17884
|
+
* on every tick. */
|
|
17885
|
+
_wrapMessageSentencesForTts(article) {
|
|
17886
|
+
if (!article) return false;
|
|
17887
|
+
if (article.dataset.ttsWrapped === "1") return true;
|
|
17888
|
+
// Mid-stream bodies change as tokens arrive · wrapping now would
|
|
17889
|
+
// be torn down by the next innerHTML rewrite. Skip until the
|
|
17890
|
+
// streaming flag clears.
|
|
17891
|
+
if (article.classList.contains("streaming") || article.classList.contains("is-streaming")) {
|
|
17892
|
+
return false;
|
|
17893
|
+
}
|
|
17894
|
+
const bubble =
|
|
17895
|
+
article.querySelector(".cd-body") ||
|
|
17896
|
+
article.querySelector(".ci-body") ||
|
|
17897
|
+
article.querySelector(".msg-bubble");
|
|
17898
|
+
if (!bubble) return false;
|
|
17899
|
+
// Walk text nodes IN ORDER · sentence boundaries cut across
|
|
17900
|
+
// inline formatting (a sentence can include <strong>, <em>,
|
|
17901
|
+
// <a>...) so we have to operate on the concatenated plain text.
|
|
17902
|
+
const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT, null);
|
|
17903
|
+
const textNodes = [];
|
|
17904
|
+
let n;
|
|
17905
|
+
while ((n = walker.nextNode())) textNodes.push(n);
|
|
17906
|
+
if (textNodes.length === 0) return false;
|
|
17907
|
+
let combined = "";
|
|
17908
|
+
const nodeRanges = [];
|
|
17909
|
+
for (const tn of textNodes) {
|
|
17910
|
+
const start = combined.length;
|
|
17911
|
+
combined += tn.textContent;
|
|
17912
|
+
nodeRanges.push({ node: tn, start, end: combined.length });
|
|
17913
|
+
}
|
|
17914
|
+
// Find sentence boundaries in the combined text.
|
|
17915
|
+
const sentences = [];
|
|
17916
|
+
const re = new RegExp(this._TTS_SENT_RE.source, "g");
|
|
17917
|
+
let mtch;
|
|
17918
|
+
while ((mtch = re.exec(combined)) !== null) {
|
|
17919
|
+
const sStart = mtch.index;
|
|
17920
|
+
const sEnd = mtch.index + mtch[0].length;
|
|
17921
|
+
if (mtch[0].trim()) sentences.push({ start: sStart, end: sEnd });
|
|
17922
|
+
}
|
|
17923
|
+
if (sentences.length <= 1) return false;
|
|
17924
|
+
// Bucket per-node ranges by which sentence each character belongs
|
|
17925
|
+
// to · one text node may carry pieces of multiple sentences,
|
|
17926
|
+
// one sentence may span multiple text nodes.
|
|
17927
|
+
const nodeSplits = new Map();
|
|
17928
|
+
for (let sIdx = 0; sIdx < sentences.length; sIdx++) {
|
|
17929
|
+
const { start, end } = sentences[sIdx];
|
|
17930
|
+
for (const nr of nodeRanges) {
|
|
17931
|
+
const overlapStart = Math.max(start, nr.start);
|
|
17932
|
+
const overlapEnd = Math.min(end, nr.end);
|
|
17933
|
+
if (overlapStart >= overlapEnd) continue;
|
|
17934
|
+
if (!nodeSplits.has(nr.node)) nodeSplits.set(nr.node, []);
|
|
17935
|
+
nodeSplits.get(nr.node).push({
|
|
17936
|
+
idx: sIdx,
|
|
17937
|
+
ls: overlapStart - nr.start,
|
|
17938
|
+
le: overlapEnd - nr.start,
|
|
17939
|
+
});
|
|
17940
|
+
}
|
|
17941
|
+
}
|
|
17942
|
+
// Replace each text node with a fragment of (raw text gaps +
|
|
17943
|
+
// sentence spans). Gaps cover whitespace/leading characters
|
|
17944
|
+
// that fell between matched sentences.
|
|
17945
|
+
for (const [tn, splits] of nodeSplits) {
|
|
17946
|
+
const text = tn.textContent;
|
|
17947
|
+
const frag = document.createDocumentFragment();
|
|
17948
|
+
splits.sort((a, b) => a.ls - b.ls);
|
|
17949
|
+
let cursor = 0;
|
|
17950
|
+
for (const s of splits) {
|
|
17951
|
+
if (s.ls > cursor) {
|
|
17952
|
+
frag.appendChild(document.createTextNode(text.slice(cursor, s.ls)));
|
|
17953
|
+
}
|
|
17954
|
+
const span = document.createElement("span");
|
|
17955
|
+
span.className = "tts-sentence";
|
|
17956
|
+
span.dataset.ttsIdx = String(s.idx);
|
|
17957
|
+
span.textContent = text.slice(s.ls, s.le);
|
|
17958
|
+
frag.appendChild(span);
|
|
17959
|
+
cursor = s.le;
|
|
17960
|
+
}
|
|
17961
|
+
if (cursor < text.length) {
|
|
17962
|
+
frag.appendChild(document.createTextNode(text.slice(cursor)));
|
|
17963
|
+
}
|
|
17964
|
+
tn.parentNode.replaceChild(frag, tn);
|
|
17965
|
+
}
|
|
17966
|
+
bubble.dataset.ttsSentenceCount = String(sentences.length);
|
|
17967
|
+
// Store per-sentence character bounds so the tick picker can do
|
|
17968
|
+
// character-cursor-based matching (more accurate than even-split
|
|
17969
|
+
// sentence count division because sentences vary in length).
|
|
17970
|
+
bubble.dataset.ttsSentenceEnds = sentences.map((s) => s.end).join(",");
|
|
17971
|
+
bubble.dataset.ttsTotalChars = String(combined.length);
|
|
17972
|
+
article.dataset.ttsWrapped = "1";
|
|
17973
|
+
return true;
|
|
17974
|
+
},
|
|
17975
|
+
|
|
17976
|
+
_clearTtsSentenceHighlights() {
|
|
17977
|
+
const prior = document.querySelectorAll(".tts-sentence.is-current");
|
|
17978
|
+
prior.forEach((el) => el.classList.remove("is-current"));
|
|
17979
|
+
},
|
|
17980
|
+
|
|
17981
|
+
/** Drive the karaoke-style highlight · reads the live replay
|
|
17982
|
+
* cursor (currentTime / duration) and turns it into the matching
|
|
17983
|
+
* sentence index, then toggles `.is-current` on the right span.
|
|
17984
|
+
* Called from the `boardroom:replay-tick` event listener; no-op
|
|
17985
|
+
* when replay isn't active or the message isn't in the visible
|
|
17986
|
+
* chat (e.g. user scrolled it off, or the room is in stage view). */
|
|
17987
|
+
updateTtsSentenceHighlight() {
|
|
17988
|
+
const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
|
|
17989
|
+
if (!vr || typeof vr.getActive !== "function") return;
|
|
17990
|
+
const active = vr.getActive();
|
|
17991
|
+
if (!active || !active.messageId) {
|
|
17992
|
+
this._clearTtsSentenceHighlights();
|
|
17993
|
+
return;
|
|
17994
|
+
}
|
|
17995
|
+
const audio = (typeof vr.getActiveAudio === "function") ? vr.getActiveAudio() : null;
|
|
17996
|
+
if (!audio || !isFinite(audio.duration) || audio.duration <= 0) return;
|
|
17997
|
+
const article = document.querySelector(`[data-message-id="${active.messageId}"]`);
|
|
17998
|
+
if (!article) return;
|
|
17999
|
+
if (!this._wrapMessageSentencesForTts(article)) {
|
|
18000
|
+
// Single-sentence message · no per-sentence highlight needed.
|
|
18001
|
+
return;
|
|
18002
|
+
}
|
|
18003
|
+
const bubble =
|
|
18004
|
+
article.querySelector(".cd-body") ||
|
|
18005
|
+
article.querySelector(".ci-body") ||
|
|
18006
|
+
article.querySelector(".msg-bubble");
|
|
18007
|
+
if (!bubble) return;
|
|
18008
|
+
const totalChars = parseInt(bubble.dataset.ttsTotalChars || "0", 10);
|
|
18009
|
+
const endsRaw = bubble.dataset.ttsSentenceEnds || "";
|
|
18010
|
+
if (!totalChars || !endsRaw) return;
|
|
18011
|
+
const ends = endsRaw.split(",").map((s) => parseInt(s, 10)).filter((n) => Number.isFinite(n));
|
|
18012
|
+
if (ends.length === 0) return;
|
|
18013
|
+
// Same picker as the subtitle's replay branch · character-cursor
|
|
18014
|
+
// based, so a long sentence holds the highlight for the right
|
|
18015
|
+
// proportion of audio time even when sentence lengths vary.
|
|
18016
|
+
const progress = Math.max(0, Math.min(1, audio.currentTime / audio.duration));
|
|
18017
|
+
const cursor = Math.floor(totalChars * progress);
|
|
18018
|
+
let pickIdx = ends.length - 1;
|
|
18019
|
+
for (let i = 0; i < ends.length; i++) {
|
|
18020
|
+
if (ends[i] >= cursor) { pickIdx = i; break; }
|
|
18021
|
+
}
|
|
18022
|
+
// Diff-apply · only update DOM when the picked sentence
|
|
18023
|
+
// changed since the last tick (avoids reflows ~4×/sec).
|
|
18024
|
+
if (article.dataset.ttsCurrentIdx === String(pickIdx)) return;
|
|
18025
|
+
article.dataset.ttsCurrentIdx = String(pickIdx);
|
|
18026
|
+
// Clear any prior current spans inside THIS article first; spans
|
|
18027
|
+
// in other articles are dropped via the `getActive` change path.
|
|
18028
|
+
const prior = article.querySelectorAll(".tts-sentence.is-current");
|
|
18029
|
+
prior.forEach((el) => el.classList.remove("is-current"));
|
|
18030
|
+
// Also clear highlights in any other articles · the active
|
|
18031
|
+
// message changed (handled below).
|
|
18032
|
+
const stale = document.querySelectorAll(`.tts-sentence.is-current`);
|
|
18033
|
+
stale.forEach((el) => {
|
|
18034
|
+
if (!article.contains(el)) el.classList.remove("is-current");
|
|
18035
|
+
});
|
|
18036
|
+
const spans = article.querySelectorAll(`.tts-sentence[data-tts-idx="${pickIdx}"]`);
|
|
18037
|
+
spans.forEach((el) => el.classList.add("is-current"));
|
|
18038
|
+
// Auto-scroll the current sentence into view · long messages
|
|
18039
|
+
// are the whole reason for this feature, so we shouldn't make
|
|
18040
|
+
// the user scroll manually to follow the cursor. `block: "nearest"`
|
|
18041
|
+
// only scrolls when the sentence is outside the visible area;
|
|
18042
|
+
// no-op when it's already on screen.
|
|
18043
|
+
if (spans.length > 0) {
|
|
18044
|
+
try {
|
|
18045
|
+
spans[0].scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
18046
|
+
} catch (_) { /* old browsers · noop */ }
|
|
18047
|
+
}
|
|
18048
|
+
},
|
|
18049
|
+
|
|
18172
18050
|
/** Live subtitle · paints the speaker's name + the tail of their
|
|
18173
18051
|
* body text into the `[data-rt-subtitle]` panel pinned to the
|
|
18174
18052
|
* bottom of the round-table stage. Cheap DOM update only — no
|
|
@@ -19736,15 +19614,55 @@
|
|
|
19736
19614
|
CHAT_STICK_THRESHOLD: 96,
|
|
19737
19615
|
|
|
19738
19616
|
/** Bind a scroll listener once · the listener flips chatStuckToBottom
|
|
19739
|
-
* based on
|
|
19740
|
-
* re-bind is fine since we tag the
|
|
19617
|
+
* based on direction-aware reasoning (scroll-up = unstick, near-
|
|
19618
|
+
* bottom = stick). Idempotent — re-bind is fine since we tag the
|
|
19619
|
+
* element.
|
|
19620
|
+
*
|
|
19621
|
+
* Earlier implementation used pure distance-from-bottom: any time
|
|
19622
|
+
* `scrollHeight - clientHeight - scrollTop > 96 px` the user was
|
|
19623
|
+
* treated as "reading history" and auto-scroll stopped. That
|
|
19624
|
+
* broke during fast director token streams · content grew faster
|
|
19625
|
+
* than the rAF-batched scroll could catch up, dist temporarily
|
|
19626
|
+
* exceeded the threshold (sometimes by hundreds of pixels mid-
|
|
19627
|
+
* paragraph), the watcher unstuck the user, and from that moment
|
|
19628
|
+
* on no further auto-scroll fired even though the user hadn't
|
|
19629
|
+
* touched the wheel. Net effect: director content rolled off the
|
|
19630
|
+
* bottom and the user had to scroll manually.
|
|
19631
|
+
*
|
|
19632
|
+
* Direction-aware logic instead:
|
|
19633
|
+
* · User scrolled UP (scrollTop decreased ≥ 4 px) → unstick.
|
|
19634
|
+
* The 4 px hysteresis tolerates browser-level micro-jitter so
|
|
19635
|
+
* a smooth-scroll animation overshooting by 1-2 px doesn't
|
|
19636
|
+
* look like a user scroll-up.
|
|
19637
|
+
* · scrollTop landed within CHAT_STICK_THRESHOLD of the bottom
|
|
19638
|
+
* → stick. Restores the bottom-feed after a manual scroll
|
|
19639
|
+
* back to the tail.
|
|
19640
|
+
* · Neither (scroll-down but not at bottom, or content-growth
|
|
19641
|
+
* pushed dist > threshold without user action) → keep current
|
|
19642
|
+
* state. This is the critical clause that fixes the bug —
|
|
19643
|
+
* auto-content-growth alone never unsticks. */
|
|
19741
19644
|
bindChatScrollWatch() {
|
|
19742
19645
|
const chat = document.querySelector(".chat");
|
|
19743
19646
|
if (!chat || chat.dataset.scrollWatch === "1") return;
|
|
19744
19647
|
chat.dataset.scrollWatch = "1";
|
|
19648
|
+
let prevScrollTop = chat.scrollTop;
|
|
19745
19649
|
const update = () => {
|
|
19746
|
-
const
|
|
19747
|
-
|
|
19650
|
+
const cur = chat.scrollTop;
|
|
19651
|
+
const dist = chat.scrollHeight - chat.clientHeight - cur;
|
|
19652
|
+
if (cur < prevScrollTop - 4) {
|
|
19653
|
+
// User-initiated scroll-up · leave the stick now so token
|
|
19654
|
+
// streaming doesn't keep snapping them back down.
|
|
19655
|
+
this.chatStuckToBottom = false;
|
|
19656
|
+
} else if (dist <= this.CHAT_STICK_THRESHOLD) {
|
|
19657
|
+
// Near the bottom (auto-scroll just landed there, or the
|
|
19658
|
+
// user scrolled back down to the tail) · re-engage stick.
|
|
19659
|
+
this.chatStuckToBottom = true;
|
|
19660
|
+
}
|
|
19661
|
+
// The "else" branch · scroll-down but not yet at bottom, OR
|
|
19662
|
+
// content-growth pushed dist > threshold without the user
|
|
19663
|
+
// touching anything · DO NOTHING. Keeps the prior state
|
|
19664
|
+
// intact so a stuck user stays stuck through fast streams.
|
|
19665
|
+
prevScrollTop = cur;
|
|
19748
19666
|
};
|
|
19749
19667
|
chat.addEventListener("scroll", update, { passive: true });
|
|
19750
19668
|
update();
|
|
@@ -20027,6 +19945,30 @@
|
|
|
20027
19945
|
app.openDivergenceOverlay();
|
|
20028
19946
|
return;
|
|
20029
19947
|
}
|
|
19948
|
+
// Cast-edit · header "Add director" icon → popover with
|
|
19949
|
+
// checkboxes + explicit Confirm. Anchor is the icon button.
|
|
19950
|
+
const castEditTrigger = e.target.closest("[data-cast-edit-trigger]");
|
|
19951
|
+
if (castEditTrigger) {
|
|
19952
|
+
e.preventDefault();
|
|
19953
|
+
app.openCastEditOverlay(castEditTrigger);
|
|
19954
|
+
return;
|
|
19955
|
+
}
|
|
19956
|
+
// Row toggle is wired via a `change` listener inside
|
|
19957
|
+
// openCastEditOverlay · not the global click delegation. Reason:
|
|
19958
|
+
// <label> wraps the <input>, so a single click fires both a label
|
|
19959
|
+
// click AND a synthetic input click — both bubble through any
|
|
19960
|
+
// `[data-cast-edit-pick-id]` `closest()` match → double toggle.
|
|
19961
|
+
// The `change` event fires exactly once per state flip.
|
|
19962
|
+
if (e.target.closest("[data-cast-edit-confirm]")) {
|
|
19963
|
+
e.preventDefault();
|
|
19964
|
+
app.submitCastEdit();
|
|
19965
|
+
return;
|
|
19966
|
+
}
|
|
19967
|
+
if (e.target.closest("[data-cast-edit-close]")) {
|
|
19968
|
+
e.preventDefault();
|
|
19969
|
+
app.closeCastEditOverlay();
|
|
19970
|
+
return;
|
|
19971
|
+
}
|
|
20030
19972
|
// Jump-to-opener · scroll the chat back to the room's first
|
|
20031
19973
|
// user message (the convene question). Useful when a long room
|
|
20032
19974
|
// has scrolled the opener many screens up.
|
|
@@ -20115,67 +20057,27 @@
|
|
|
20115
20057
|
app.downloadRoomVoiceMp3s().catch((err) => alert(String(err && err.message ? err.message : err)));
|
|
20116
20058
|
return;
|
|
20117
20059
|
}
|
|
20118
|
-
// Search
|
|
20119
|
-
|
|
20060
|
+
// Search overlay · close button (X) or scrim click. Either
|
|
20061
|
+
// element carries `[data-search-overlay-close]`. Closes the
|
|
20062
|
+
// overlay without touching the underlying view.
|
|
20063
|
+
if (e.target.closest("[data-search-overlay-close]")) {
|
|
20120
20064
|
e.preventDefault();
|
|
20121
|
-
|
|
20122
|
-
if (input) {
|
|
20123
|
-
input.value = "";
|
|
20124
|
-
input.focus();
|
|
20125
|
-
app.runSearch("");
|
|
20126
|
-
}
|
|
20127
|
-
return;
|
|
20128
|
-
}
|
|
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
|
-
}
|
|
20065
|
+
if (typeof app.closeSearchOverlay === "function") app.closeSearchOverlay();
|
|
20166
20066
|
return;
|
|
20167
20067
|
}
|
|
20168
|
-
// Search
|
|
20068
|
+
// Search overlay · result row click. Anchor's href is
|
|
20169
20069
|
// `#/r/<id>?m=<mid>&q=<query>`, which the hashchange route
|
|
20170
20070
|
// handler picks up. We stash the pending message id + query
|
|
20171
20071
|
// here too as a belt-and-braces in case the hash is consumed
|
|
20172
|
-
// by another listener before handleRoute runs.
|
|
20072
|
+
// by another listener before handleRoute runs. Also closes
|
|
20073
|
+
// the overlay so the destination room is visible immediately.
|
|
20173
20074
|
const searchJumpMsg = e.target.closest("[data-search-jump-msg]");
|
|
20174
20075
|
if (searchJumpMsg) {
|
|
20175
20076
|
const mid = searchJumpMsg.getAttribute("data-search-jump-msg");
|
|
20176
20077
|
const qry = searchJumpMsg.getAttribute("data-search-jump-q") || "";
|
|
20177
20078
|
if (mid) app._pendingMessageScroll = mid;
|
|
20178
20079
|
app._pendingMessageQuery = qry;
|
|
20080
|
+
if (typeof app.closeSearchOverlay === "function") app.closeSearchOverlay();
|
|
20179
20081
|
// Let the anchor navigate naturally (hash change → handleRoute
|
|
20180
20082
|
// → openRoom). No `return` since the link's default action
|
|
20181
20083
|
// does the navigation.
|
|
@@ -21140,7 +21042,15 @@
|
|
|
21140
21042
|
// Restore the text into the composer so the user can edit/resend.
|
|
21141
21043
|
const input = document.querySelector('.input-bar input, [data-send-input]');
|
|
21142
21044
|
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
|
|
21143
|
-
if (!input.value.trim())
|
|
21045
|
+
if (!input.value.trim()) {
|
|
21046
|
+
input.value = app.pendingUserMessage;
|
|
21047
|
+
// Re-grow the textarea to fit the restored body · without
|
|
21048
|
+
// this a multi-line queued message reappears clipped to
|
|
21049
|
+
// the single-line baseline.
|
|
21050
|
+
if (input.matches?.(".ib-textarea[data-send-input]")) {
|
|
21051
|
+
app.autosizeRoomInputTextarea();
|
|
21052
|
+
}
|
|
21053
|
+
}
|
|
21144
21054
|
}
|
|
21145
21055
|
app.pendingUserMessage = null;
|
|
21146
21056
|
app.pendingForSpeakerId = null;
|