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/dist/boot.js +482 -208
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +482 -208
- package/dist/cli.js.map +1 -1
- package/dist/server.js +482 -208
- 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 +3 -2
- 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 +799 -717
- package/public/avatars/chair-blink.svg +1 -0
- package/public/home-3d-loader.js +6 -0
- package/public/home.html +1 -1
- package/public/i18n.js +122 -0
- package/public/icons/folded-sidebar.png +0 -0
- package/public/index.html +898 -990
- package/public/report.html +27 -7
- package/public/room-settings.css +18 -0
- package/public/themes.css +11 -0
- package/public/user-settings.js +37 -20
- package/public/voice-3d-banner.js +110 -36
- package/public/voice-3d.js +206 -6
- package/public/icons/share.png +0 -0
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
|
|
3508
|
-
// `position: fixed` so we set top/left
|
|
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
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
8391
|
-
/** Open the
|
|
8392
|
-
*
|
|
8393
|
-
*
|
|
8394
|
-
*
|
|
8395
|
-
*
|
|
8396
|
-
*
|
|
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.
|
|
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"));
|
|
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 (
|
|
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
|
-
}
|
|
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
|
-
/**
|
|
8742
|
-
*
|
|
8743
|
-
*
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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-
|
|
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
|
|
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. */
|
|
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
|
-
|
|
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 =
|
|
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-
|
|
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
|
-
/**
|
|
8852
|
-
*
|
|
8853
|
-
*
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
`;
|
|
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 ·
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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",
|
|
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 (
|
|
16517
|
-
if (empty &&
|
|
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 &&
|
|
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
|
|
19740
|
-
* re-bind is fine since we tag the
|
|
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
|
|
19747
|
-
|
|
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
|
|
20119
|
-
|
|
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
|
-
|
|
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
|
|
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())
|
|
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;
|