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