privateboard 0.1.37 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot.js +1415 -91
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1415 -91
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1271 -81
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/public/__avatar3d_test.html +156 -0
- package/public/adjourn-overlay.css +2 -2
- package/public/agent-overlay.css +27 -15
- package/public/agent-overlay.js +3 -1
- package/public/agent-profile.css +331 -41
- package/public/agent-profile.js +499 -75
- package/public/app-updater.css +1 -1
- package/public/app.js +2090 -547
- package/public/avatar-3d-snap.js +205 -0
- package/public/avatar-3d.js +792 -0
- package/public/avatar-customizer.html +274 -0
- package/public/avatar3d-editor.css +240 -0
- package/public/avatar3d-editor.js +481 -0
- package/public/avatars/3d/chair.png +0 -0
- package/public/avatars/3d/first-principles.png +0 -0
- package/public/avatars/3d/historian.png +0 -0
- package/public/avatars/3d/long-horizon.png +0 -0
- package/public/avatars/3d/phenomenologist.png +0 -0
- package/public/avatars/3d/socrates.png +0 -0
- package/public/avatars/3d/user-empathy.png +0 -0
- package/public/avatars/3d/value-investor.png +0 -0
- package/public/core-avatars.js +86 -0
- package/public/home-3d-loader.js +15 -4
- package/public/home-3d-mock.js +18 -7
- package/public/home.html +80 -18
- package/public/i18n.js +279 -4
- package/public/icons/avatar_1779855104027.glb +0 -0
- package/public/icons/logo.png +0 -0
- package/public/icons/new-style.glb +0 -0
- package/public/icons/new-style2.glb +0 -0
- package/public/icons/new-style3.glb +0 -0
- package/public/icons/new-style4.glb +0 -0
- package/public/icons/new-style5.glb +0 -0
- package/public/icons/office.glb +0 -0
- package/public/icons/stuff.glb +0 -0
- package/public/index.html +203 -182
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/new-agent.js +46 -20
- package/public/office-viewer.html +340 -0
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/room-settings.css +24 -9
- package/public/stuff-viewer.html +330 -0
- package/public/thread.css +1211 -0
- package/public/user-settings.css +16 -19
- package/public/user-settings.js +86 -78
- package/public/vendor/BufferGeometryUtils.js +1434 -0
- package/public/vendor/DRACOLoader.js +739 -0
- package/public/vendor/GLTFLoader.js +4860 -0
- package/public/vendor/RoomEnvironment.js +185 -0
- package/public/vendor/SkeletonUtils.js +496 -0
- package/public/vendor/draco/draco_decoder.js +34 -0
- package/public/vendor/draco/draco_decoder.wasm +0 -0
- package/public/vendor/draco/draco_encoder.js +33 -0
- package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
- package/public/vendor/meshopt_decoder.module.js +196 -0
- package/public/voice-3d-banner.js +12 -0
- package/public/voice-3d.js +1407 -432
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/voice-replay.js +21 -0
- package/public/avatar-skill.js +0 -629
- package/public/icons/folded-sidebar.png +0 -0
package/public/app.js
CHANGED
|
@@ -343,6 +343,34 @@
|
|
|
343
343
|
: (btn.getAttribute("data-more") || this._t("convene_show_more"));
|
|
344
344
|
btn.textContent = label;
|
|
345
345
|
});
|
|
346
|
+
|
|
347
|
+
// Thread trigger · per-bubble "// thread" link on each director
|
|
348
|
+
// message. Doc-level delegate so it picks up bubbles rendered
|
|
349
|
+
// both at chat first paint AND via every message-appended /
|
|
350
|
+
// renderChat invalidation.
|
|
351
|
+
document.addEventListener("click", (e) => {
|
|
352
|
+
const btn = e.target.closest("[data-thread-trigger]");
|
|
353
|
+
if (!btn) return;
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
e.stopPropagation();
|
|
356
|
+
const directorId = btn.getAttribute("data-thread-trigger");
|
|
357
|
+
if (directorId) this.openThreadWith(directorId);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Threads-list popover trigger · the room head's "all threads"
|
|
361
|
+
// icon. Click toggles the popover open/closed; click outside
|
|
362
|
+
// dismisses (handled inside openThreadsListPopover).
|
|
363
|
+
document.addEventListener("click", (e) => {
|
|
364
|
+
const btn = e.target.closest("[data-threads-trigger]");
|
|
365
|
+
if (!btn) return;
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
e.stopPropagation();
|
|
368
|
+
if (document.getElementById("thread-list-pop")) {
|
|
369
|
+
this.closeThreadsListPopover();
|
|
370
|
+
} else {
|
|
371
|
+
this.openThreadsListPopover(btn);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
346
374
|
// Live-subtitle minimize / restore · attached in CAPTURE phase
|
|
347
375
|
// so it wins ahead of any outside-click handlers (picker dismiss
|
|
348
376
|
// / overlay close) that might `stopPropagation` and swallow the
|
|
@@ -538,6 +566,17 @@
|
|
|
538
566
|
// currentRoomId). Stage repaint still bails out when no
|
|
539
567
|
// room is open, since the stage DOM isn't mounted there.
|
|
540
568
|
this.refreshMiniPlayer();
|
|
569
|
+
// The Record button is offered while a replay runs (adjourned
|
|
570
|
+
// rooms), so re-render the header when the replay open-state
|
|
571
|
+
// flips. Guarded so we don't rebuild the header on every
|
|
572
|
+
// per-message item transition — only on open ↔ close.
|
|
573
|
+
const replOpen = this._isReplayActiveForRoom(this.currentRoomId);
|
|
574
|
+
if (replOpen !== this._lastReplayHeaderState) {
|
|
575
|
+
this._lastReplayHeaderState = replOpen;
|
|
576
|
+
if (this.currentRoomId && typeof this.renderHeader === "function") {
|
|
577
|
+
this.renderHeader();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
541
580
|
if (!this.currentRoomId) return;
|
|
542
581
|
this.renderRoundTable();
|
|
543
582
|
});
|
|
@@ -1089,6 +1128,30 @@
|
|
|
1089
1128
|
return;
|
|
1090
1129
|
}
|
|
1091
1130
|
|
|
1131
|
+
// Thread guard · the main room view is not the right surface
|
|
1132
|
+
// for a thread (it expects a chair, multiple directors, the
|
|
1133
|
+
// full round / vote / brief pipeline). If the URL hash routed
|
|
1134
|
+
// to a thread (manual paste, stale link), redirect to the
|
|
1135
|
+
// parent and surface the thread as a float window so the user
|
|
1136
|
+
// lands somewhere coherent.
|
|
1137
|
+
if (data.room && data.room.kind === "thread") {
|
|
1138
|
+
const parentId = data.room.parentRoomId;
|
|
1139
|
+
if (parentId) {
|
|
1140
|
+
// Navigate to parent and re-mount the thread window.
|
|
1141
|
+
location.hash = "#/r/" + encodeURIComponent(parentId);
|
|
1142
|
+
if (data.room.threadDirectorId) {
|
|
1143
|
+
// Defer until after the route handler settles into the
|
|
1144
|
+
// parent so currentRoomId / agentsById are populated.
|
|
1145
|
+
setTimeout(() => {
|
|
1146
|
+
this.openThreadWith(data.room.threadDirectorId);
|
|
1147
|
+
}, 200);
|
|
1148
|
+
}
|
|
1149
|
+
} else {
|
|
1150
|
+
this.closeRoom();
|
|
1151
|
+
}
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1092
1155
|
// We may be coming from the All Reports view OR an agent profile
|
|
1093
1156
|
// — make sure the room main view is the visible one and the
|
|
1094
1157
|
// others are hidden. The agent-view hide is what stops a stale
|
|
@@ -1296,6 +1359,12 @@
|
|
|
1296
1359
|
console.error("[openRoom] renderRoom failed:", err);
|
|
1297
1360
|
}
|
|
1298
1361
|
this.markActiveRoom(roomId);
|
|
1362
|
+
// Restore any thread float windows the user had open for this
|
|
1363
|
+
// room before the last page reload · the threads themselves
|
|
1364
|
+
// live in the DB; this just rebuilds the floating UI for the
|
|
1365
|
+
// ones the user had visible. Fire-and-forget · failure here
|
|
1366
|
+
// never blocks room load.
|
|
1367
|
+
this.restoreThreadsForRoom(roomId);
|
|
1299
1368
|
// Round-table stage · paint + decide whether the .chat or
|
|
1300
1369
|
// .roundtable-stage should be visible for this room. Runs
|
|
1301
1370
|
// AFTER renderRoom so the DOM exists and currentRoom /
|
|
@@ -1369,6 +1438,19 @@
|
|
|
1369
1438
|
},
|
|
1370
1439
|
|
|
1371
1440
|
async closeRoom() {
|
|
1441
|
+
// Thread cleanup · route through `closeThreadWindow` so the
|
|
1442
|
+
// localStorage persistence entry is dropped too. Per the
|
|
1443
|
+
// updated UX rule, leaving a room means the thread chat is
|
|
1444
|
+
// CLOSED — coming back to the same room later should land
|
|
1445
|
+
// on a clean main view, not auto-restore an old thread.
|
|
1446
|
+
// closeThreadWindow handles DOM, SSE, body.has-thread-dock,
|
|
1447
|
+
// and `pb.thread.open.<parentRoomId>` cleanup in one place.
|
|
1448
|
+
if (this._threads) {
|
|
1449
|
+
for (const tid of Object.keys(this._threads)) {
|
|
1450
|
+
this.closeThreadWindow(tid);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
document.body.classList.remove("has-thread-dock");
|
|
1372
1454
|
// Recording guard · if a meeting capture is in progress for
|
|
1373
1455
|
// the current room, intercept and let the user choose:
|
|
1374
1456
|
// stop-and-save before leaving or cancel and stay. We re-route
|
|
@@ -2099,6 +2181,26 @@
|
|
|
2099
2181
|
this._pendingAdjournAfterRecordStop = false;
|
|
2100
2182
|
try { this.adjournRoom({ skipBrief: true }); }
|
|
2101
2183
|
catch (e) { console.warn("[recorder] pending adjourn failed", e); }
|
|
2184
|
+
} else if (
|
|
2185
|
+
// Vanilla pause-after-current path · the user clicked
|
|
2186
|
+
// "Pause after current director" while a recording was
|
|
2187
|
+
// running. Without an explicit prompt the dialog would
|
|
2188
|
+
// never appear and the recording would keep capturing
|
|
2189
|
+
// a silent room. Surface a save dialog so the user can
|
|
2190
|
+
// commit the clip + optionally adjourn before audio
|
|
2191
|
+
// chunks of dead air pile up. Skip when:
|
|
2192
|
+
// · pauseMode === "hard" · interrupt path (user
|
|
2193
|
+
// already saw the choice modal)
|
|
2194
|
+
// · _pendingAdjournAfterRecordStop · handled above
|
|
2195
|
+
// · _pendingReloadOnPause · user picked reload
|
|
2196
|
+
// · recording-save modal already on screen (defensive)
|
|
2197
|
+
payload.mode === "soft"
|
|
2198
|
+
&& !this._pendingReloadOnPause
|
|
2199
|
+
&& window.BoardroomRecorder
|
|
2200
|
+
&& window.BoardroomRecorder.isRecording()
|
|
2201
|
+
&& !document.getElementById("rec-pause-save-overlay")
|
|
2202
|
+
) {
|
|
2203
|
+
this.openRecordingPauseSaveModal();
|
|
2102
2204
|
}
|
|
2103
2205
|
} else if (kind === "room-resumed") {
|
|
2104
2206
|
if (this.currentRoom) {
|
|
@@ -5147,6 +5249,1296 @@
|
|
|
5147
5249
|
}
|
|
5148
5250
|
},
|
|
5149
5251
|
|
|
5252
|
+
/* ─── Private thread · 1:1 director aside (Phase 1) ───────
|
|
5253
|
+
The user pulls a single director into a Slack-DM-style floating
|
|
5254
|
+
window for a private conversation. Persists as its own `rooms`
|
|
5255
|
+
row (kind="thread") with parent_room_id pointing here, so:
|
|
5256
|
+
· subscribe to its own SSE stream
|
|
5257
|
+
· messages flow through the existing POST /messages route
|
|
5258
|
+
· the orchestrator's buildDirectorContext detects kind==="thread"
|
|
5259
|
+
and seeds the LLM with the parent room's snapshot
|
|
5260
|
+
Storage / drag-window mounting / SSE plumbing all live in
|
|
5261
|
+
these methods. State map below tracks the active windows by
|
|
5262
|
+
threadId so subsequent opens reuse existing windows. */
|
|
5263
|
+
|
|
5264
|
+
/** SVG icon string · shared by the per-bubble trigger and the
|
|
5265
|
+
* head-cast badge. Lucide-style "reply" curve (arrow back to
|
|
5266
|
+
* the speaker) reads as IM-grammar for "respond privately". */
|
|
5267
|
+
_threadTriggerIconSvg() {
|
|
5268
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 17l-5-5 5-5"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>`;
|
|
5269
|
+
},
|
|
5270
|
+
|
|
5271
|
+
/** Discord-style floating message toolbar · appears on article
|
|
5272
|
+
* hover at the top-right of the bubble. Pure icons (no labels)
|
|
5273
|
+
* to keep the toolbar compact; the icon vocabulary is small and
|
|
5274
|
+
* reads instantly on hover. Future per-message actions (quote,
|
|
5275
|
+
* react, pin) slot as additional buttons inside the same
|
|
5276
|
+
* toolbar chrome. Returns "" when no actions apply (user / chair
|
|
5277
|
+
* / excused / thread room / adjourned). */
|
|
5278
|
+
_messageToolbarHtml(m, isUser, isChair, excused, author) {
|
|
5279
|
+
const trigger = this._threadTriggerHtml(m, isUser, isChair, excused, author);
|
|
5280
|
+
if (!trigger) return "";
|
|
5281
|
+
return `<div class="msg-toolbar">${trigger}</div>`;
|
|
5282
|
+
},
|
|
5283
|
+
|
|
5284
|
+
/** Reply chip · icon + "Thread" label as a compact pill. Single
|
|
5285
|
+
* slot inside the Discord-style hover toolbar. Returns "" when
|
|
5286
|
+
* threading doesn't apply (see `_messageToolbarHtml`'s wrapper). */
|
|
5287
|
+
_threadTriggerHtml(m, isUser, isChair, excused, author) {
|
|
5288
|
+
if (isUser || isChair || excused) return "";
|
|
5289
|
+
if (!author || !author.id) return "";
|
|
5290
|
+
// Skip when rendering inside a thread window · `_threadMessageHtml`
|
|
5291
|
+
// flips this flag for the duration of its messageHtml call so
|
|
5292
|
+
// thread bubbles don't ship a "open another thread with this
|
|
5293
|
+
// same director" trigger (which already exists · the user is
|
|
5294
|
+
// looking at that thread right now).
|
|
5295
|
+
if (this._renderingThread) return "";
|
|
5296
|
+
const room = this.currentRoom;
|
|
5297
|
+
// Threads can be spawned from a room in any status · live,
|
|
5298
|
+
// paused, OR adjourned. Re-reading an adjourned session and
|
|
5299
|
+
// wanting to follow up with one director privately is a real
|
|
5300
|
+
// use case ("I want to go deeper on what Socrates said in that
|
|
5301
|
+
// meeting from last week"). The thread itself is its own
|
|
5302
|
+
// independent room — the parent's status doesn't constrain it.
|
|
5303
|
+
// Only suppress when there's no room loaded OR we're already
|
|
5304
|
+
// inside a thread (no nested threads).
|
|
5305
|
+
if (!room) return "";
|
|
5306
|
+
if (room.kind === "thread") return "";
|
|
5307
|
+
const label = this._t("thread_trigger_label") || "Thread";
|
|
5308
|
+
const tip = this._t("thread_trigger_tip", { name: author.name || "" })
|
|
5309
|
+
|| `Pull ${author.name || "this director"} aside privately`;
|
|
5310
|
+
return `<button type="button" class="msg-thread-trigger" data-thread-trigger="${this.escape(author.id)}" title="${this.escape(tip)}" aria-label="${this.escape(tip)}">${this._threadTriggerIconSvg()}<span>${this.escape(label)}</span></button>`;
|
|
5311
|
+
},
|
|
5312
|
+
|
|
5313
|
+
async openThreadWith(directorId) {
|
|
5314
|
+
if (!this.currentRoomId || !directorId) return;
|
|
5315
|
+
// De-dupe · if a thread window for (this room, this director)
|
|
5316
|
+
// already exists, just restore it. Avoids spawning a duplicate
|
|
5317
|
+
// DB row + duplicate SSE stream when the user clicks the
|
|
5318
|
+
// "thread" trigger repeatedly.
|
|
5319
|
+
this._threads = this._threads || {};
|
|
5320
|
+
for (const [tid, t] of Object.entries(this._threads)) {
|
|
5321
|
+
if (t.director && t.director.id === directorId) {
|
|
5322
|
+
this.restoreThreadWindow(tid);
|
|
5323
|
+
return;
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
5326
|
+
// In-flight create guard · the de-dupe above misses the race
|
|
5327
|
+
// where the user double-clicks (or two trigger buttons for the
|
|
5328
|
+
// same director fire concurrently): both clicks see _threads
|
|
5329
|
+
// empty, both POST, server creates TWO thread rows, both windows
|
|
5330
|
+
// mount, the user perceives "the app keeps spawning new rooms."
|
|
5331
|
+
// Track creates per-director with a Set so the second click
|
|
5332
|
+
// becomes a no-op until the first POST resolves.
|
|
5333
|
+
this._threadsCreating = this._threadsCreating || new Set();
|
|
5334
|
+
if (this._threadsCreating.has(directorId)) return;
|
|
5335
|
+
this._threadsCreating.add(directorId);
|
|
5336
|
+
// Capture the parent roomId at click time · if the user
|
|
5337
|
+
// navigates to a different room while the POST is in flight,
|
|
5338
|
+
// we still want the thread to attach to the room the click
|
|
5339
|
+
// originated from, not the room currently showing.
|
|
5340
|
+
const parentRoomId = this.currentRoomId;
|
|
5341
|
+
try {
|
|
5342
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(parentRoomId)}/threads`, {
|
|
5343
|
+
method: "POST",
|
|
5344
|
+
headers: { "content-type": "application/json" },
|
|
5345
|
+
body: JSON.stringify({ directorId }),
|
|
5346
|
+
});
|
|
5347
|
+
if (!res.ok) {
|
|
5348
|
+
const err = await res.json().catch(() => ({}));
|
|
5349
|
+
alert((err && err.error) || "Failed to open thread");
|
|
5350
|
+
return;
|
|
5351
|
+
}
|
|
5352
|
+
const data = await res.json();
|
|
5353
|
+
const director = (this.agentsById && this.agentsById[directorId])
|
|
5354
|
+
|| (data.members || []).find((m) => m.id === directorId)
|
|
5355
|
+
|| { id: directorId, name: directorId, avatarPath: "" };
|
|
5356
|
+
// Default to docked mode · the user explicitly asked for the
|
|
5357
|
+
// side-panel as the primary thread surface. Narrow viewports
|
|
5358
|
+
// (`_canDockHere` returns false below 1200px) silently fall
|
|
5359
|
+
// back to the floating mode since the chat-col would
|
|
5360
|
+
// otherwise be squeezed below readable width.
|
|
5361
|
+
this.mountThreadWindow(data.room, director, { docked: this._canDockHere() });
|
|
5362
|
+
} catch (e) {
|
|
5363
|
+
alert((e && e.message) || "Failed to open thread");
|
|
5364
|
+
} finally {
|
|
5365
|
+
this._threadsCreating.delete(directorId);
|
|
5366
|
+
}
|
|
5367
|
+
},
|
|
5368
|
+
|
|
5369
|
+
mountThreadWindow(threadRoom, director, opts) {
|
|
5370
|
+
this._threads = this._threads || {};
|
|
5371
|
+
// Idempotent · if the user already has this thread mounted
|
|
5372
|
+
// (e.g. localStorage restore racing with an active session),
|
|
5373
|
+
// just restore the existing window instead of building a
|
|
5374
|
+
// duplicate DOM.
|
|
5375
|
+
if (this._threads[threadRoom.id]) {
|
|
5376
|
+
this.restoreThreadWindow(threadRoom.id);
|
|
5377
|
+
return;
|
|
5378
|
+
}
|
|
5379
|
+
// Single-thread-per-room rule · the room may only have ONE
|
|
5380
|
+
// thread surfaced at a time (float OR docked). Opening a new
|
|
5381
|
+
// one closes any existing thread first. This sidesteps the
|
|
5382
|
+
// "main chat squeezed by stacked docked panels" problem and
|
|
5383
|
+
// keeps the maximize/restore toggle's mental model simple.
|
|
5384
|
+
const existingIds = Object.keys(this._threads);
|
|
5385
|
+
for (const oldId of existingIds) {
|
|
5386
|
+
if (oldId !== threadRoom.id) this.closeThreadWindow(oldId);
|
|
5387
|
+
}
|
|
5388
|
+
// Threads are PANEL-ONLY · always dock into the active room view
|
|
5389
|
+
// (the floating-window mode was removed). When no room view is
|
|
5390
|
+
// mounted (defensive · the trigger only fires from inside a room)
|
|
5391
|
+
// we fall back to a body-level overlay. On viewports < 1200px the
|
|
5392
|
+
// `.is-docked` CSS @media renders the panel as a fixed overlay,
|
|
5393
|
+
// which is the narrow-screen substitute for a full side panel.
|
|
5394
|
+
void opts;
|
|
5395
|
+
const dockHost = this._threadDockHost();
|
|
5396
|
+
const startDocked = !!dockHost;
|
|
5397
|
+
|
|
5398
|
+
const root = document.createElement("div");
|
|
5399
|
+
root.className = "thread-float" + (startDocked ? " is-docked" : "");
|
|
5400
|
+
root.setAttribute("data-thread-id", threadRoom.id);
|
|
5401
|
+
root.setAttribute("data-director-id", director.id);
|
|
5402
|
+
if (!startDocked) {
|
|
5403
|
+
root.style.right = "24px";
|
|
5404
|
+
root.style.bottom = "24px";
|
|
5405
|
+
}
|
|
5406
|
+
|
|
5407
|
+
const escape = (s) => String(s == null ? "" : s)
|
|
5408
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
5409
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
5410
|
+
const avatarSrc = director.avatarPath ? escape(director.avatarPath) : "";
|
|
5411
|
+
// Title / subtitle resolution · same priority as the popover
|
|
5412
|
+
// row: LLM-distilled `room.name` > truncated `room.subject` >
|
|
5413
|
+
// director's name as last-resort fallback. Director's name
|
|
5414
|
+
// ALWAYS shows in the subtitle so the user reads "what we're
|
|
5415
|
+
// talking about · with whom".
|
|
5416
|
+
const headerTitle = this._resolveThreadTitle(threadRoom, director);
|
|
5417
|
+
const headerSubtitle = director.name || director.id || "";
|
|
5418
|
+
// Head buttons are icon-only (Lucide mask via CSS · see
|
|
5419
|
+
// .thread-float-head-btn rules in thread.css). aria-label
|
|
5420
|
+
// carries the action name for screen readers + the `title`
|
|
5421
|
+
// attribute provides the hover tooltip. No textContent.
|
|
5422
|
+
const minTip = this._t("thread_minimize_tip") || "Minimize";
|
|
5423
|
+
const closeTip = this._t("thread_close_tip") || "Close";
|
|
5424
|
+
const emptyTitle = this._t("thread_empty_title") || "Pull them aside";
|
|
5425
|
+
const emptyDeck = this._t("thread_empty_deck", { name: director.name || "" })
|
|
5426
|
+
|| "What does {name} know that you want to dig into? They have the full room context up to this point.";
|
|
5427
|
+
const placeholder = this._t("thread_composer_placeholder", { name: director.name || "" })
|
|
5428
|
+
|| `Ask ${director.name || "them"} anything…`;
|
|
5429
|
+
const sendLabel = this._t("thread_send") || "Send";
|
|
5430
|
+
|
|
5431
|
+
root.innerHTML = `
|
|
5432
|
+
<div class="thread-dock-resize-handle" data-thread-dock-resize aria-hidden="true" title="${escape(this._t("thread_dock_resize_tip") || "Drag to resize panel")}"></div>
|
|
5433
|
+
<div class="thread-float-resize thread-float-resize-top" data-thread-resize="top" aria-hidden="true" title="${escape(this._t("thread_resize_tip") || "Drag to resize")}"></div>
|
|
5434
|
+
<header class="thread-float-head" data-thread-drag>
|
|
5435
|
+
${avatarSrc
|
|
5436
|
+
? `<img class="thread-float-head-avatar" src="${avatarSrc}" alt="" data-agent="${escape(director.id)}" title="${escape(director.name || "")}">`
|
|
5437
|
+
: `<span class="thread-float-head-avatar" data-agent="${escape(director.id)}" title="${escape(director.name || "")}"></span>`}
|
|
5438
|
+
<div class="thread-float-meta-wrap thread-float-head-meta">
|
|
5439
|
+
<span class="thread-float-head-title">${escape(headerTitle)}</span>
|
|
5440
|
+
<span class="thread-float-head-subtitle">${escape(headerSubtitle)}</span>
|
|
5441
|
+
</div>
|
|
5442
|
+
<div class="thread-float-head-controls">
|
|
5443
|
+
<button type="button" class="thread-float-head-btn" data-thread-minimize title="${escape(minTip)}" aria-label="${escape(minTip)}"></button>
|
|
5444
|
+
<button type="button" class="thread-float-head-btn" data-thread-close title="${escape(closeTip)}" aria-label="${escape(closeTip)}"></button>
|
|
5445
|
+
</div>
|
|
5446
|
+
</header>
|
|
5447
|
+
<div class="thread-float-messages" data-thread-messages>
|
|
5448
|
+
<div class="thread-float-empty" data-thread-empty>
|
|
5449
|
+
<span class="thread-float-empty-tag">// ${escape(emptyTitle)}</span>
|
|
5450
|
+
<span>${escape(emptyDeck)}</span>
|
|
5451
|
+
</div>
|
|
5452
|
+
</div>
|
|
5453
|
+
<div class="thread-float-composer" data-thread-composer>
|
|
5454
|
+
<div class="thread-float-composer-text-row">
|
|
5455
|
+
<textarea class="thread-float-textarea" data-thread-input placeholder="${escape(placeholder)}" rows="1"></textarea>
|
|
5456
|
+
</div>
|
|
5457
|
+
<div class="thread-float-composer-controls-row">
|
|
5458
|
+
<button type="button" class="ib-action ib-jump-top" data-thread-jump-top title="${escape(this._t("ib_jump_top_tip") || "Jump to top")}" aria-label="${escape(this._t("ib_jump_top_label") || "Jump to top")}" data-tip="${escape(this._t("ib_jump_top_tip") || "Jump to top")}"></button>
|
|
5459
|
+
<button type="button" class="thread-float-send" data-thread-send title="${escape(sendLabel)}" aria-label="${escape(sendLabel)}">${escape(sendLabel)}</button>
|
|
5460
|
+
<button type="button" class="thread-float-stop" data-thread-stop title="${escape(this._t("thread_stop_tip") || "Stop director")}" aria-label="${escape(this._t("thread_stop") || "Stop")}">${escape(this._t("thread_stop") || "Stop")}</button>
|
|
5461
|
+
</div>
|
|
5462
|
+
</div>
|
|
5463
|
+
<div class="thread-float-resize thread-float-resize-bottom" data-thread-resize="bottom" aria-hidden="true" title="${escape(this._t("thread_resize_tip") || "Drag to resize")}"></div>
|
|
5464
|
+
`;
|
|
5465
|
+
// Mount target depends on docked vs float mode.
|
|
5466
|
+
// · Float (default): body-level overlay (fixed positioning).
|
|
5467
|
+
// · Docked: side-by-side with chat-col inside the active
|
|
5468
|
+
// room main-view, so the chat is genuinely beside the
|
|
5469
|
+
// thread and not under a floating chip.
|
|
5470
|
+
if (startDocked && dockHost) {
|
|
5471
|
+
dockHost.appendChild(root);
|
|
5472
|
+
document.body.classList.add("has-thread-dock");
|
|
5473
|
+
} else {
|
|
5474
|
+
document.body.appendChild(root);
|
|
5475
|
+
}
|
|
5476
|
+
|
|
5477
|
+
// Per-thread state · SSE + drag-offset bookkeeping. Stored on
|
|
5478
|
+
// app so the SSE callbacks + drag handlers can find their
|
|
5479
|
+
// window even when many threads are open.
|
|
5480
|
+
const state = {
|
|
5481
|
+
threadId: threadRoom.id,
|
|
5482
|
+
director,
|
|
5483
|
+
room: threadRoom,
|
|
5484
|
+
windowEl: root,
|
|
5485
|
+
sse: null,
|
|
5486
|
+
messages: [],
|
|
5487
|
+
minimized: false,
|
|
5488
|
+
// Threads are panel-only · `docked` is true whenever a room
|
|
5489
|
+
// view exists to host the side panel (the float-window mode +
|
|
5490
|
+
// its maximize/restore toggle were removed). The only time
|
|
5491
|
+
// this is false is the defensive no-room-view fallback, where
|
|
5492
|
+
// the panel renders as a body-level overlay.
|
|
5493
|
+
docked: startDocked,
|
|
5494
|
+
// Drag translate offset · still honoured for the narrow-
|
|
5495
|
+
// viewport fallback where `.is-docked` renders as a fixed,
|
|
5496
|
+
// draggable overlay (see thread.css @media max-width:1200px).
|
|
5497
|
+
dragX: 0,
|
|
5498
|
+
dragY: 0,
|
|
5499
|
+
// User-resized height (px) · null until the user drags the
|
|
5500
|
+
// top edge. Persisted across minimize/restore so the chosen
|
|
5501
|
+
// height survives toggling the chip. The window is bottom-
|
|
5502
|
+
// anchored, so a taller height grows upward.
|
|
5503
|
+
height: null,
|
|
5504
|
+
};
|
|
5505
|
+
this._threads[threadRoom.id] = state;
|
|
5506
|
+
// Persist open-window membership across page reloads · the
|
|
5507
|
+
// thread row itself lives in the DB regardless, but the user's
|
|
5508
|
+
// CHOICE to have a window open for it is client-only state.
|
|
5509
|
+
this._persistThreadOpen(threadRoom.parentRoomId, threadRoom.id, true);
|
|
5510
|
+
|
|
5511
|
+
// Wire interactions
|
|
5512
|
+
root.querySelector("[data-thread-minimize]").addEventListener("click", () => {
|
|
5513
|
+
this.minimizeThreadWindow(threadRoom.id);
|
|
5514
|
+
});
|
|
5515
|
+
root.querySelector("[data-thread-close]").addEventListener("click", () => {
|
|
5516
|
+
this.closeThreadWindow(threadRoom.id);
|
|
5517
|
+
});
|
|
5518
|
+
// Left-edge resize handle · only matters in docked mode (CSS
|
|
5519
|
+
// hides it in float). Wires up unconditionally so the toggle
|
|
5520
|
+
// between float/dock doesn't need to re-bind.
|
|
5521
|
+
const dockResizeHandle = root.querySelector("[data-thread-dock-resize]");
|
|
5522
|
+
if (dockResizeHandle) this._wireThreadDockResize(dockResizeHandle);
|
|
5523
|
+
const dragHead = root.querySelector("[data-thread-drag]");
|
|
5524
|
+
this._wireThreadDrag(dragHead, state);
|
|
5525
|
+
root.querySelectorAll("[data-thread-resize]").forEach((h) => {
|
|
5526
|
+
this._wireThreadResize(h, state, h.getAttribute("data-thread-resize") === "bottom" ? "bottom" : "top");
|
|
5527
|
+
});
|
|
5528
|
+
const sendBtn = root.querySelector("[data-thread-send]");
|
|
5529
|
+
const stopBtn = root.querySelector("[data-thread-stop]");
|
|
5530
|
+
const jumpTopBtn = root.querySelector("[data-thread-jump-top]");
|
|
5531
|
+
const input = root.querySelector("[data-thread-input]");
|
|
5532
|
+
sendBtn.addEventListener("click", () => this.sendThreadMessage(threadRoom.id));
|
|
5533
|
+
stopBtn.addEventListener("click", () => this.stopThreadDirector(threadRoom.id));
|
|
5534
|
+
// Jump-to-top · mirror of the room input-bar's `[data-jump-to-opener]`
|
|
5535
|
+
// affordance · scroll the thread message list back to its first
|
|
5536
|
+
// message. Smooth-scrolling the messages container itself rather
|
|
5537
|
+
// than the viewport · the thread panel has its own scroll context
|
|
5538
|
+
// (absolute-positioned `.thread-float-messages`).
|
|
5539
|
+
if (jumpTopBtn) {
|
|
5540
|
+
jumpTopBtn.addEventListener("click", () => {
|
|
5541
|
+
const list = root.querySelector("[data-thread-messages]");
|
|
5542
|
+
if (list) list.scrollTop = 0;
|
|
5543
|
+
});
|
|
5544
|
+
}
|
|
5545
|
+
input.addEventListener("keydown", (e) => {
|
|
5546
|
+
if (e.key !== "Enter" || e.shiftKey) return;
|
|
5547
|
+
// IME composition guard · while a Chinese / Japanese / Korean
|
|
5548
|
+
// input method is open (pinyin candidate row visible, kana
|
|
5549
|
+
// pre-conversion buffer, etc.) the Enter key is the user
|
|
5550
|
+
// committing their candidate selection, NOT submitting the
|
|
5551
|
+
// message. Firing send here would dispatch the half-typed
|
|
5552
|
+
// raw romanisation. `isComposing` is the standardised
|
|
5553
|
+
// property; `keyCode === 229` is the legacy Chrome/Edge
|
|
5554
|
+
// signal that fires before isComposing flips in some IME
|
|
5555
|
+
// engines. Both checks together cover the matrix.
|
|
5556
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
5557
|
+
e.preventDefault();
|
|
5558
|
+
this.sendThreadMessage(threadRoom.id);
|
|
5559
|
+
});
|
|
5560
|
+
// Auto-grow textarea up to max-height · 200px ceiling matches
|
|
5561
|
+
// the room's `.ib-textarea` so a long compose feels identical
|
|
5562
|
+
// across both surfaces (CSS cap is also 200px).
|
|
5563
|
+
input.addEventListener("input", () => {
|
|
5564
|
+
input.style.height = "auto";
|
|
5565
|
+
input.style.height = Math.min(200, input.scrollHeight) + "px";
|
|
5566
|
+
});
|
|
5567
|
+
// Quote seed · the quote-cta bar's Thread button stashes the
|
|
5568
|
+
// selected director text on `_threadPendingQuote[directorId]`
|
|
5569
|
+
// BEFORE calling openThreadWith. Consume it here so the new
|
|
5570
|
+
// thread's composer opens with a markdown blockquote ready —
|
|
5571
|
+
// the user just types their follow-up question and hits send.
|
|
5572
|
+
// Cleared after consumption so subsequent opens don't re-paste.
|
|
5573
|
+
const pending = this._threadPendingQuote && this._threadPendingQuote[director.id];
|
|
5574
|
+
if (pending && pending.text) {
|
|
5575
|
+
const quoteLines = String(pending.text).split(/\r?\n/).map((l) => "> " + l);
|
|
5576
|
+
if (pending.directorName) quoteLines.push("> — @" + pending.directorName);
|
|
5577
|
+
input.value = quoteLines.join("\n") + "\n\n";
|
|
5578
|
+
// Trigger autosize after the seed lands.
|
|
5579
|
+
input.style.height = "auto";
|
|
5580
|
+
input.style.height = Math.min(200, input.scrollHeight) + "px";
|
|
5581
|
+
// Cursor goes after the blank line so the user types
|
|
5582
|
+
// immediately at the follow-up position.
|
|
5583
|
+
const pos = input.value.length;
|
|
5584
|
+
try { input.setSelectionRange(pos, pos); } catch (_) {}
|
|
5585
|
+
delete this._threadPendingQuote[director.id];
|
|
5586
|
+
}
|
|
5587
|
+
setTimeout(() => { try { input.focus(); } catch (_) {} }, 30);
|
|
5588
|
+
|
|
5589
|
+
// SSE subscription · the thread's own per-room stream. We get
|
|
5590
|
+
// message-appended / message-token / message-final / message-updated
|
|
5591
|
+
// for the thread's messages; main room events fan out via the
|
|
5592
|
+
// main app.sse and don't reach here.
|
|
5593
|
+
try {
|
|
5594
|
+
const es = new EventSource(`/api/rooms/${encodeURIComponent(threadRoom.id)}/stream`);
|
|
5595
|
+
state.sse = es;
|
|
5596
|
+
es.addEventListener("message-appended", (ev) => {
|
|
5597
|
+
const data = JSON.parse(ev.data);
|
|
5598
|
+
this._threadAppendMessage(threadRoom.id, data);
|
|
5599
|
+
});
|
|
5600
|
+
es.addEventListener("message-token", (ev) => {
|
|
5601
|
+
const data = JSON.parse(ev.data);
|
|
5602
|
+
this._threadApplyToken(threadRoom.id, data);
|
|
5603
|
+
});
|
|
5604
|
+
es.addEventListener("message-updated", (ev) => {
|
|
5605
|
+
const data = JSON.parse(ev.data);
|
|
5606
|
+
this._threadUpdateMessage(threadRoom.id, data);
|
|
5607
|
+
});
|
|
5608
|
+
es.addEventListener("message-final", (ev) => {
|
|
5609
|
+
const data = JSON.parse(ev.data);
|
|
5610
|
+
this._threadFinalizeMessage(threadRoom.id, data);
|
|
5611
|
+
});
|
|
5612
|
+
es.addEventListener("message-removed", (ev) => {
|
|
5613
|
+
const data = JSON.parse(ev.data);
|
|
5614
|
+
this._threadRemoveMessage(threadRoom.id, data);
|
|
5615
|
+
});
|
|
5616
|
+
} catch (e) {
|
|
5617
|
+
console.warn("[thread] SSE attach failed:", e);
|
|
5618
|
+
}
|
|
5619
|
+
},
|
|
5620
|
+
|
|
5621
|
+
closeThreadWindow(threadId) {
|
|
5622
|
+
const state = this._threads && this._threads[threadId];
|
|
5623
|
+
if (!state) return;
|
|
5624
|
+
if (state.sse) {
|
|
5625
|
+
try { state.sse.close(); } catch (_) {}
|
|
5626
|
+
state.sse = null;
|
|
5627
|
+
}
|
|
5628
|
+
if (state.windowEl && state.windowEl.parentNode) {
|
|
5629
|
+
state.windowEl.parentNode.removeChild(state.windowEl);
|
|
5630
|
+
}
|
|
5631
|
+
const parentId = state.room && state.room.parentRoomId;
|
|
5632
|
+
delete this._threads[threadId];
|
|
5633
|
+
if (parentId) this._persistThreadOpen(parentId, threadId, false);
|
|
5634
|
+
// If no docked thread remains, drop the body class that
|
|
5635
|
+
// squeezed the chat-col into its left grid column.
|
|
5636
|
+
const anyDocked = Object.values(this._threads || {}).some((s) => s && s.docked);
|
|
5637
|
+
if (!anyDocked) document.body.classList.remove("has-thread-dock");
|
|
5638
|
+
},
|
|
5639
|
+
|
|
5640
|
+
/** Title shown in the panel header AND in the popover row · same
|
|
5641
|
+
* priority chain as `openThreadsListPopover` so both surfaces
|
|
5642
|
+
* read the same string. LLM-distilled `room.name` wins; until
|
|
5643
|
+
* the title-gen pipeline completes (or for legacy threads
|
|
5644
|
+
* with name_auto=0), the truncated subject stands in; the
|
|
5645
|
+
* director's name is a last-resort fallback for fresh threads
|
|
5646
|
+
* with no user message yet. */
|
|
5647
|
+
_resolveThreadTitle(threadRoom, director) {
|
|
5648
|
+
if (!threadRoom) return (director && director.name) || "";
|
|
5649
|
+
const isPlaceholder = typeof threadRoom.name === "string"
|
|
5650
|
+
&& /^thread:/.test(threadRoom.name);
|
|
5651
|
+
if (!isPlaceholder && typeof threadRoom.name === "string" && threadRoom.name.trim()) {
|
|
5652
|
+
return threadRoom.name.trim();
|
|
5653
|
+
}
|
|
5654
|
+
if (typeof threadRoom.subject === "string" && threadRoom.subject.trim()) {
|
|
5655
|
+
const subj = threadRoom.subject.trim();
|
|
5656
|
+
return subj.length > 60 ? subj.slice(0, 60) + "…" : subj;
|
|
5657
|
+
}
|
|
5658
|
+
return (director && director.name) || "";
|
|
5659
|
+
},
|
|
5660
|
+
|
|
5661
|
+
/** Is the current viewport wide enough to honour docked mode?
|
|
5662
|
+
* Below ~1200px the 420px panel would squeeze the chat to an
|
|
5663
|
+
* uncomfortable width; in that case we keep the thread float-
|
|
5664
|
+
* only regardless of the user's maximize click. */
|
|
5665
|
+
_canDockHere() {
|
|
5666
|
+
try {
|
|
5667
|
+
const w = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
5668
|
+
return w >= 1200;
|
|
5669
|
+
} catch (_) { return false; }
|
|
5670
|
+
},
|
|
5671
|
+
|
|
5672
|
+
/** Where to mount a docked thread panel · the active room
|
|
5673
|
+
* main-view, so the panel becomes a flex/grid sibling of the
|
|
5674
|
+
* chat-col. Returns null if no room view is active (defensive
|
|
5675
|
+
* · should never fire because the trigger is only reachable
|
|
5676
|
+
* from inside a room). */
|
|
5677
|
+
_threadDockHost() {
|
|
5678
|
+
const view = document.querySelector('.main-view[data-main-view="room"]');
|
|
5679
|
+
return view && !view.hidden ? view : null;
|
|
5680
|
+
},
|
|
5681
|
+
|
|
5682
|
+
/** Wire the left-edge resize handle so the user can drag-widen
|
|
5683
|
+
* the docked panel. Reads pointer-x on move, computes the new
|
|
5684
|
+
* width as `viewport_width - pointer_x` (panel is anchored to
|
|
5685
|
+
* the right edge), and writes the value to `--thread-dock-w`
|
|
5686
|
+
* on document body. CSS clamps the variable into a sane range
|
|
5687
|
+
* (320-720) so the chat-col never collapses below input-bar
|
|
5688
|
+
* content width. Persists to localStorage so the user's chosen
|
|
5689
|
+
* width survives page reload. */
|
|
5690
|
+
_wireThreadDockResize(handle) {
|
|
5691
|
+
// Hydrate any saved width on first wire so the dock opens at
|
|
5692
|
+
// the user's last-chosen size.
|
|
5693
|
+
const restorePersistedWidth = () => {
|
|
5694
|
+
try {
|
|
5695
|
+
const raw = localStorage.getItem("pb.thread.dock.width");
|
|
5696
|
+
const n = raw ? parseInt(raw, 10) : 0;
|
|
5697
|
+
if (n >= 320 && n <= 720) {
|
|
5698
|
+
document.body.style.setProperty("--thread-dock-w", `${n}px`);
|
|
5699
|
+
}
|
|
5700
|
+
} catch (_) { /* */ }
|
|
5701
|
+
};
|
|
5702
|
+
restorePersistedWidth();
|
|
5703
|
+
|
|
5704
|
+
let dragging = false;
|
|
5705
|
+
let rafId = 0;
|
|
5706
|
+
let pendingX = 0;
|
|
5707
|
+
const flush = () => {
|
|
5708
|
+
rafId = 0;
|
|
5709
|
+
if (!dragging) return;
|
|
5710
|
+
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
5711
|
+
// Panel sits on the right; new width = distance from
|
|
5712
|
+
// viewport right edge to pointer. Clamp to handle range so
|
|
5713
|
+
// mid-drag the CSS clamp doesn't lag behind the variable.
|
|
5714
|
+
const next = Math.max(320, Math.min(720, vw - pendingX));
|
|
5715
|
+
document.body.style.setProperty("--thread-dock-w", `${next}px`);
|
|
5716
|
+
};
|
|
5717
|
+
const onMove = (e) => {
|
|
5718
|
+
if (!dragging) return;
|
|
5719
|
+
if (e.cancelable) e.preventDefault();
|
|
5720
|
+
pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
|
|
5721
|
+
if (!rafId) rafId = requestAnimationFrame(flush);
|
|
5722
|
+
};
|
|
5723
|
+
const onUp = () => {
|
|
5724
|
+
if (!dragging) return;
|
|
5725
|
+
dragging = false;
|
|
5726
|
+
document.body.classList.remove("is-thread-dock-resizing");
|
|
5727
|
+
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
5728
|
+
document.removeEventListener("mousemove", onMove);
|
|
5729
|
+
document.removeEventListener("mouseup", onUp);
|
|
5730
|
+
document.removeEventListener("touchmove", onMove);
|
|
5731
|
+
document.removeEventListener("touchend", onUp);
|
|
5732
|
+
// Persist whatever value we landed on. Read back the
|
|
5733
|
+
// computed style rather than the inline string so any
|
|
5734
|
+
// clamp-by-CSS adjustment is honoured.
|
|
5735
|
+
try {
|
|
5736
|
+
const v = document.body.style.getPropertyValue("--thread-dock-w");
|
|
5737
|
+
const n = parseInt(v, 10);
|
|
5738
|
+
if (n >= 320 && n <= 720) {
|
|
5739
|
+
localStorage.setItem("pb.thread.dock.width", String(n));
|
|
5740
|
+
}
|
|
5741
|
+
} catch (_) { /* private mode · ignore */ }
|
|
5742
|
+
};
|
|
5743
|
+
const onDown = (e) => {
|
|
5744
|
+
// Only honour the drag when the panel is actually docked ·
|
|
5745
|
+
// CSS already hides the handle off-screen otherwise, but
|
|
5746
|
+
// defensive guard for keyboard / programmatic invocations.
|
|
5747
|
+
const panel = handle.closest(".thread-float");
|
|
5748
|
+
if (!panel || !panel.classList.contains("is-docked")) return;
|
|
5749
|
+
dragging = true;
|
|
5750
|
+
document.body.classList.add("is-thread-dock-resizing");
|
|
5751
|
+
pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
|
|
5752
|
+
document.addEventListener("mousemove", onMove);
|
|
5753
|
+
document.addEventListener("mouseup", onUp);
|
|
5754
|
+
document.addEventListener("touchmove", onMove, { passive: false });
|
|
5755
|
+
document.addEventListener("touchend", onUp);
|
|
5756
|
+
};
|
|
5757
|
+
handle.addEventListener("mousedown", onDown);
|
|
5758
|
+
handle.addEventListener("touchstart", onDown, { passive: true });
|
|
5759
|
+
},
|
|
5760
|
+
|
|
5761
|
+
/** Client-side membership · which thread IDs the user has a
|
|
5762
|
+
* window open for, keyed by parent roomId. localStorage so the
|
|
5763
|
+
* set survives page reload. Stored as a JSON array per room
|
|
5764
|
+
* under `pb.thread.open.<roomId>` (mirrors the rest of the
|
|
5765
|
+
* app's `pb.*` localStorage namespace). */
|
|
5766
|
+
_persistThreadOpen(parentRoomId, threadId, isOpen) {
|
|
5767
|
+
if (!parentRoomId || !threadId) return;
|
|
5768
|
+
const key = `pb.thread.open.${parentRoomId}`;
|
|
5769
|
+
try {
|
|
5770
|
+
const raw = localStorage.getItem(key);
|
|
5771
|
+
const ids = raw ? JSON.parse(raw) : [];
|
|
5772
|
+
const next = isOpen
|
|
5773
|
+
? Array.from(new Set([...ids, threadId]))
|
|
5774
|
+
: ids.filter((id) => id !== threadId);
|
|
5775
|
+
if (next.length) localStorage.setItem(key, JSON.stringify(next));
|
|
5776
|
+
else localStorage.removeItem(key);
|
|
5777
|
+
} catch (_) { /* quota / private mode · silently degrade */ }
|
|
5778
|
+
},
|
|
5779
|
+
|
|
5780
|
+
_persistedThreadOpen(parentRoomId) {
|
|
5781
|
+
if (!parentRoomId) return [];
|
|
5782
|
+
try {
|
|
5783
|
+
const raw = localStorage.getItem(`pb.thread.open.${parentRoomId}`);
|
|
5784
|
+
const arr = raw ? JSON.parse(raw) : [];
|
|
5785
|
+
return Array.isArray(arr) ? arr : [];
|
|
5786
|
+
} catch { return []; }
|
|
5787
|
+
},
|
|
5788
|
+
|
|
5789
|
+
/** Re-mount any thread windows the user previously had open for
|
|
5790
|
+
* the current room. Called from openRoom AFTER members /
|
|
5791
|
+
* agentsById are populated so the director resolution works.
|
|
5792
|
+
* Threads whose IDs no longer exist (deleted out from under
|
|
5793
|
+
* the user) are quietly dropped from the persisted list. */
|
|
5794
|
+
async restoreThreadsForRoom(parentRoomId) {
|
|
5795
|
+
if (!parentRoomId) return;
|
|
5796
|
+
const wanted = this._persistedThreadOpen(parentRoomId);
|
|
5797
|
+
if (wanted.length === 0) return;
|
|
5798
|
+
// Pull the canonical list from the server so we know which
|
|
5799
|
+
// threads still exist. The persisted IDs are advisory.
|
|
5800
|
+
let threads = [];
|
|
5801
|
+
try {
|
|
5802
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(parentRoomId)}/threads`);
|
|
5803
|
+
if (res.ok) {
|
|
5804
|
+
const data = await res.json();
|
|
5805
|
+
threads = Array.isArray(data.threads) ? data.threads : [];
|
|
5806
|
+
}
|
|
5807
|
+
} catch { return; }
|
|
5808
|
+
const live = new Map(threads.map((t) => [t.id, t]));
|
|
5809
|
+
const stillOpen = [];
|
|
5810
|
+
for (const id of wanted) {
|
|
5811
|
+
const room = live.get(id);
|
|
5812
|
+
if (!room) continue;
|
|
5813
|
+
const director = (this.agentsById && this.agentsById[room.threadDirectorId])
|
|
5814
|
+
|| { id: room.threadDirectorId, name: room.threadDirectorId, avatarPath: "" };
|
|
5815
|
+
// Restore in the same mode the user opens NEW threads with.
|
|
5816
|
+
// localStorage doesn't track float-vs-dock yet; default to
|
|
5817
|
+
// dock when the viewport allows so the user's last session's
|
|
5818
|
+
// dock preference is honoured implicitly.
|
|
5819
|
+
this.mountThreadWindow(room, director, { docked: this._canDockHere() });
|
|
5820
|
+
stillOpen.push(id);
|
|
5821
|
+
// Seed the transcript · /threads list endpoint only returns
|
|
5822
|
+
// room metadata, so we fetch the per-thread state to get
|
|
5823
|
+
// messages and paint them into the freshly-mounted window.
|
|
5824
|
+
// Fire-and-forget · each thread loads independently and the
|
|
5825
|
+
// SSE stream picks up new messages from this point on.
|
|
5826
|
+
fetch(`/api/rooms/${encodeURIComponent(id)}`)
|
|
5827
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
5828
|
+
.then((data) => {
|
|
5829
|
+
if (!data || !Array.isArray(data.messages) || data.messages.length === 0) return;
|
|
5830
|
+
this._seedThreadHistory(id, data.messages);
|
|
5831
|
+
})
|
|
5832
|
+
.catch(() => { /* per-thread network blip · log via the SSE error path */ });
|
|
5833
|
+
}
|
|
5834
|
+
// Prune the persisted list to what actually exists · keeps the
|
|
5835
|
+
// localStorage entry from accumulating stale IDs forever.
|
|
5836
|
+
try {
|
|
5837
|
+
const key = `pb.thread.open.${parentRoomId}`;
|
|
5838
|
+
if (stillOpen.length) localStorage.setItem(key, JSON.stringify(stillOpen));
|
|
5839
|
+
else localStorage.removeItem(key);
|
|
5840
|
+
} catch (_) { /* */ }
|
|
5841
|
+
},
|
|
5842
|
+
|
|
5843
|
+
/** Open a popover anchored under the head-threads icon listing
|
|
5844
|
+
* every thread (active or dormant) attached to this main room.
|
|
5845
|
+
* Each row is a click target that mounts / restores the thread
|
|
5846
|
+
* window. ESC + outside-click dismiss. */
|
|
5847
|
+
async openThreadsListPopover(anchorBtn) {
|
|
5848
|
+
this.closeThreadsListPopover();
|
|
5849
|
+
if (!this.currentRoomId) return;
|
|
5850
|
+
const pop = document.createElement("div");
|
|
5851
|
+
pop.id = "thread-list-pop";
|
|
5852
|
+
pop.className = "thread-list-pop";
|
|
5853
|
+
const title = this._t("threads_list_title") || "Private threads";
|
|
5854
|
+
const loading = this._t("threads_list_loading") || "Loading…";
|
|
5855
|
+
pop.innerHTML = `
|
|
5856
|
+
<div class="thread-list-head">${this.escape(title)}</div>
|
|
5857
|
+
<div class="thread-list-body" data-threads-body>
|
|
5858
|
+
<div class="thread-list-empty">${this.escape(loading)}</div>
|
|
5859
|
+
</div>
|
|
5860
|
+
`;
|
|
5861
|
+
document.body.appendChild(pop);
|
|
5862
|
+
// Anchor under the trigger button · `position: fixed` so values
|
|
5863
|
+
// are viewport coords. Right-align so the popover stays under
|
|
5864
|
+
// the icon when the head row contracts on narrow viewports.
|
|
5865
|
+
const r = anchorBtn.getBoundingClientRect();
|
|
5866
|
+
const POP_WIDTH = 320;
|
|
5867
|
+
pop.style.top = `${Math.round(r.bottom + 6)}px`;
|
|
5868
|
+
const rightSpace = window.innerWidth - r.right;
|
|
5869
|
+
pop.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - POP_WIDTH - 8, r.right - POP_WIDTH)))}px`;
|
|
5870
|
+
void rightSpace;
|
|
5871
|
+
|
|
5872
|
+
// Dismissal · outside click + ESC. Same pattern as
|
|
5873
|
+
// closeCastEditOverlay's wiring.
|
|
5874
|
+
this._threadsPopDocClick = (e) => {
|
|
5875
|
+
if (e.target.closest("#thread-list-pop")) return;
|
|
5876
|
+
if (e.target.closest("[data-threads-trigger]")) return;
|
|
5877
|
+
this.closeThreadsListPopover();
|
|
5878
|
+
};
|
|
5879
|
+
this._threadsPopEsc = (e) => {
|
|
5880
|
+
if (e.key === "Escape") {
|
|
5881
|
+
e.preventDefault();
|
|
5882
|
+
this.closeThreadsListPopover();
|
|
5883
|
+
}
|
|
5884
|
+
};
|
|
5885
|
+
setTimeout(() => document.addEventListener("click", this._threadsPopDocClick, true), 0);
|
|
5886
|
+
document.addEventListener("keydown", this._threadsPopEsc, true);
|
|
5887
|
+
|
|
5888
|
+
// Fetch threads · canonical source is the server's
|
|
5889
|
+
// /api/rooms/:id/threads endpoint we already shipped.
|
|
5890
|
+
let threads = [];
|
|
5891
|
+
try {
|
|
5892
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(this.currentRoomId)}/threads`);
|
|
5893
|
+
if (res.ok) {
|
|
5894
|
+
const data = await res.json();
|
|
5895
|
+
threads = Array.isArray(data.threads) ? data.threads : [];
|
|
5896
|
+
}
|
|
5897
|
+
} catch (_) { /* network · render empty state */ }
|
|
5898
|
+
|
|
5899
|
+
const body = pop.querySelector("[data-threads-body]");
|
|
5900
|
+
if (!body) return;
|
|
5901
|
+
|
|
5902
|
+
// Filter rules:
|
|
5903
|
+
// - messageCount === 0 · empty threads (user opened a thread
|
|
5904
|
+
// from the trigger but never sent a message) are clutter,
|
|
5905
|
+
// hide them.
|
|
5906
|
+
// - Dedupe by director id · keep ONLY the newest thread per
|
|
5907
|
+
// director. Older duplicates (created by past race bugs,
|
|
5908
|
+
// or by manual API hits) collapse so the user sees one row
|
|
5909
|
+
// per director — which is the mental model the rest of the
|
|
5910
|
+
// UI assumes (open-thread-with-director, not per-message).
|
|
5911
|
+
// `threads` arrives newest-first from the server, so the
|
|
5912
|
+
// first occurrence per directorId is the winner.
|
|
5913
|
+
const seenDirectors = new Set();
|
|
5914
|
+
const visible = [];
|
|
5915
|
+
for (const t of threads) {
|
|
5916
|
+
if (typeof t.messageCount === "number" && t.messageCount === 0) continue;
|
|
5917
|
+
if (t.threadDirectorId && seenDirectors.has(t.threadDirectorId)) continue;
|
|
5918
|
+
if (t.threadDirectorId) seenDirectors.add(t.threadDirectorId);
|
|
5919
|
+
visible.push(t);
|
|
5920
|
+
}
|
|
5921
|
+
|
|
5922
|
+
if (visible.length === 0) {
|
|
5923
|
+
const emptyMsg = this._t("threads_list_empty")
|
|
5924
|
+
|| "No private threads yet · open one from the // Thread button on any director's message.";
|
|
5925
|
+
// Placeholder icon · Lucide MessagesSquare (two chat bubbles,
|
|
5926
|
+
// matching the head-threads trigger glyph) so the empty
|
|
5927
|
+
// state reads as "this is where your private threads
|
|
5928
|
+
// would appear" rather than as a bare line of text.
|
|
5929
|
+
const placeholderIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>`;
|
|
5930
|
+
body.innerHTML = `
|
|
5931
|
+
<div class="thread-list-empty">
|
|
5932
|
+
<span class="thread-list-empty-icon" aria-hidden="true">${placeholderIcon}</span>
|
|
5933
|
+
<span>${this.escape(emptyMsg)}</span>
|
|
5934
|
+
</div>
|
|
5935
|
+
`;
|
|
5936
|
+
return;
|
|
5937
|
+
}
|
|
5938
|
+
const activeLabel = this._t("threads_list_active") || "open";
|
|
5939
|
+
const trashIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
|
|
5940
|
+
const deleteTip = this._t("threads_list_delete_tip") || "Delete this thread";
|
|
5941
|
+
|
|
5942
|
+
body.innerHTML = visible.map((t) => {
|
|
5943
|
+
const director = (this.agentsById && this.agentsById[t.threadDirectorId])
|
|
5944
|
+
|| { name: t.threadDirectorId || "?", avatarPath: "" };
|
|
5945
|
+
const avatarSrc = director.avatarPath ? this.escape(director.avatarPath) : "";
|
|
5946
|
+
const ts = this.timeFmt ? this.timeFmt(t.createdAt) : "";
|
|
5947
|
+
const isOpen = !!(this._threads && this._threads[t.id]);
|
|
5948
|
+
// Title resolution · ALWAYS surface the topic phrase (same
|
|
5949
|
+
// convention as the sidebar's room titles). Priority:
|
|
5950
|
+
// 1. LLM-distilled `room.name` (set by generateRoomTitle
|
|
5951
|
+
// after the first user message · the canonical short
|
|
5952
|
+
// phrase mode)
|
|
5953
|
+
// 2. `room.subject` truncated (the raw first user message
|
|
5954
|
+
// · interim state while the LLM call is in flight or
|
|
5955
|
+
// if it failed to land a phrase)
|
|
5956
|
+
// 3. Director name (last-resort fallback for threads with
|
|
5957
|
+
// no user message yet — shouldn't appear in practice
|
|
5958
|
+
// since we filter messageCount === 0 above)
|
|
5959
|
+
// The director's name + timestamp ALWAYS live in the
|
|
5960
|
+
// subtitle, regardless of which title path was picked.
|
|
5961
|
+
const isPlaceholderName = typeof t.name === "string" && /^thread:/.test(t.name);
|
|
5962
|
+
let title = "";
|
|
5963
|
+
if (!isPlaceholderName && typeof t.name === "string" && t.name.trim()) {
|
|
5964
|
+
title = t.name.trim();
|
|
5965
|
+
} else if (typeof t.subject === "string" && t.subject.trim()) {
|
|
5966
|
+
const subj = t.subject.trim();
|
|
5967
|
+
title = subj.length > 60 ? subj.slice(0, 60) + "…" : subj;
|
|
5968
|
+
} else {
|
|
5969
|
+
title = director.name || "";
|
|
5970
|
+
}
|
|
5971
|
+
const subtitleParts = [];
|
|
5972
|
+
if (director.name) subtitleParts.push(director.name);
|
|
5973
|
+
if (ts) subtitleParts.push(ts);
|
|
5974
|
+
const subtitle = subtitleParts.join(" · ");
|
|
5975
|
+
return `
|
|
5976
|
+
<div class="thread-list-row">
|
|
5977
|
+
<button type="button" class="thread-list-row-body" data-thread-open="${this.escape(t.id)}">
|
|
5978
|
+
${avatarSrc ? `<img class="thread-list-row-av" src="${avatarSrc}" alt="">` : `<span class="thread-list-row-av"></span>`}
|
|
5979
|
+
<span class="thread-list-row-meta">
|
|
5980
|
+
<span class="thread-list-row-name">${this.escape(title)}</span>
|
|
5981
|
+
<span class="thread-list-row-time">${this.escape(subtitle)}</span>
|
|
5982
|
+
</span>
|
|
5983
|
+
${isOpen ? `<span class="thread-list-row-active">// ${this.escape(activeLabel)}</span>` : ""}
|
|
5984
|
+
</button>
|
|
5985
|
+
<button type="button" class="thread-list-row-delete" data-thread-delete="${this.escape(t.id)}" data-director-name="${this.escape(director.name || "")}" title="${this.escape(deleteTip)}" aria-label="${this.escape(deleteTip)}">
|
|
5986
|
+
${trashIcon}
|
|
5987
|
+
</button>
|
|
5988
|
+
</div>
|
|
5989
|
+
`;
|
|
5990
|
+
}).join("");
|
|
5991
|
+
body.querySelectorAll("[data-thread-open]").forEach((row) => {
|
|
5992
|
+
row.addEventListener("click", (e) => {
|
|
5993
|
+
e.stopPropagation();
|
|
5994
|
+
const id = row.getAttribute("data-thread-open");
|
|
5995
|
+
this.closeThreadsListPopover();
|
|
5996
|
+
if (id) this.openExistingThread(id);
|
|
5997
|
+
});
|
|
5998
|
+
});
|
|
5999
|
+
body.querySelectorAll("[data-thread-delete]").forEach((btn) => {
|
|
6000
|
+
btn.addEventListener("click", (e) => {
|
|
6001
|
+
e.preventDefault();
|
|
6002
|
+
e.stopPropagation();
|
|
6003
|
+
const id = btn.getAttribute("data-thread-delete");
|
|
6004
|
+
const dirName = btn.getAttribute("data-director-name") || "";
|
|
6005
|
+
if (id) this.deleteThread(id, dirName, anchorBtn);
|
|
6006
|
+
});
|
|
6007
|
+
});
|
|
6008
|
+
},
|
|
6009
|
+
|
|
6010
|
+
/** Permanently delete a thread room · routes through the standard
|
|
6011
|
+
* DELETE /api/rooms/:id which cascades messages + members.
|
|
6012
|
+
* Confirms via `window.confirm` since the action is destructive
|
|
6013
|
+
* and not undoable. Closes any open window for the same thread,
|
|
6014
|
+
* prunes localStorage, and re-renders the popover. */
|
|
6015
|
+
async deleteThread(threadId, directorName, anchorBtn) {
|
|
6016
|
+
if (!threadId) return;
|
|
6017
|
+
const tmpl = this._t("threads_list_delete_confirm")
|
|
6018
|
+
|| "Delete the private thread with {name}? This can't be undone.";
|
|
6019
|
+
const msg = tmpl.replace("{name}", directorName || "this director");
|
|
6020
|
+
if (!window.confirm(msg)) return;
|
|
6021
|
+
try {
|
|
6022
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}`, { method: "DELETE" });
|
|
6023
|
+
if (!res.ok) {
|
|
6024
|
+
const err = await res.json().catch(() => ({}));
|
|
6025
|
+
alert((err && err.error) || "Delete failed");
|
|
6026
|
+
return;
|
|
6027
|
+
}
|
|
6028
|
+
} catch (e) {
|
|
6029
|
+
alert((e && e.message) || "Delete failed");
|
|
6030
|
+
return;
|
|
6031
|
+
}
|
|
6032
|
+
// Tear down any open window for this thread + drop from
|
|
6033
|
+
// persistence. closeThreadWindow does both.
|
|
6034
|
+
if (this._threads && this._threads[threadId]) {
|
|
6035
|
+
this.closeThreadWindow(threadId);
|
|
6036
|
+
}
|
|
6037
|
+
// Refresh the popover so the row disappears. Reuse the
|
|
6038
|
+
// original trigger as the anchor — it's still on screen since
|
|
6039
|
+
// we never navigated away.
|
|
6040
|
+
const trigger = anchorBtn || document.querySelector("[data-threads-trigger]");
|
|
6041
|
+
this.closeThreadsListPopover();
|
|
6042
|
+
if (trigger) this.openThreadsListPopover(trigger);
|
|
6043
|
+
},
|
|
6044
|
+
|
|
6045
|
+
closeThreadsListPopover() {
|
|
6046
|
+
const el = document.getElementById("thread-list-pop");
|
|
6047
|
+
if (el) el.remove();
|
|
6048
|
+
if (this._threadsPopDocClick) {
|
|
6049
|
+
document.removeEventListener("click", this._threadsPopDocClick, true);
|
|
6050
|
+
this._threadsPopDocClick = null;
|
|
6051
|
+
}
|
|
6052
|
+
if (this._threadsPopEsc) {
|
|
6053
|
+
document.removeEventListener("keydown", this._threadsPopEsc, true);
|
|
6054
|
+
this._threadsPopEsc = null;
|
|
6055
|
+
}
|
|
6056
|
+
},
|
|
6057
|
+
|
|
6058
|
+
/** Mount a window for an EXISTING thread (one the user hasn't
|
|
6059
|
+
* got open right now but exists in the DB). Used by the
|
|
6060
|
+
* "Threads" header popover entries · the user clicks a row to
|
|
6061
|
+
* resume an old aside without spawning a new one. */
|
|
6062
|
+
async openExistingThread(threadId) {
|
|
6063
|
+
if (!threadId) return;
|
|
6064
|
+
this._threads = this._threads || {};
|
|
6065
|
+
if (this._threads[threadId]) {
|
|
6066
|
+
this.restoreThreadWindow(threadId);
|
|
6067
|
+
return;
|
|
6068
|
+
}
|
|
6069
|
+
try {
|
|
6070
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}`);
|
|
6071
|
+
if (!res.ok) return;
|
|
6072
|
+
const data = await res.json();
|
|
6073
|
+
const room = data.room;
|
|
6074
|
+
if (!room || room.kind !== "thread") return;
|
|
6075
|
+
const director = (this.agentsById && this.agentsById[room.threadDirectorId])
|
|
6076
|
+
|| { id: room.threadDirectorId, name: room.threadDirectorId, avatarPath: "" };
|
|
6077
|
+
// Default docked · same as the trigger-button entry path.
|
|
6078
|
+
this.mountThreadWindow(room, director, { docked: this._canDockHere() });
|
|
6079
|
+
// Seed the historical transcript · without this the window
|
|
6080
|
+
// mounts empty and only catches messages arriving via SSE
|
|
6081
|
+
// FROM NOW ON, so reopening an old thread looked like a
|
|
6082
|
+
// brand-new conversation. The /api/rooms/:id state response
|
|
6083
|
+
// already carries the full message list — paint it now.
|
|
6084
|
+
if (Array.isArray(data.messages) && data.messages.length > 0) {
|
|
6085
|
+
this._seedThreadHistory(threadId, data.messages);
|
|
6086
|
+
}
|
|
6087
|
+
} catch (e) {
|
|
6088
|
+
try { console.warn("[thread] openExisting failed:", e); } catch (_) {}
|
|
6089
|
+
}
|
|
6090
|
+
},
|
|
6091
|
+
|
|
6092
|
+
/** Paint the thread room's stored messages into the window on
|
|
6093
|
+
* first mount. State + DOM both populated so subsequent SSE
|
|
6094
|
+
* token / update / final events can find the message refs and
|
|
6095
|
+
* patch the bubbles in place. */
|
|
6096
|
+
_seedThreadHistory(threadId, messages) {
|
|
6097
|
+
const state = this._threads && this._threads[threadId];
|
|
6098
|
+
if (!state || !state.windowEl) return;
|
|
6099
|
+
const list = state.windowEl.querySelector("[data-thread-messages]");
|
|
6100
|
+
if (!list) return;
|
|
6101
|
+
// Hide the empty-state placeholder if any messages exist.
|
|
6102
|
+
this._threadEmptyState(threadId, true);
|
|
6103
|
+
// Dedupe against anything already streamed in via SSE that
|
|
6104
|
+
// raced the seed (rare but possible). Skip if already known.
|
|
6105
|
+
const known = new Set(state.messages.map((m) => m.id));
|
|
6106
|
+
const html = [];
|
|
6107
|
+
for (const data of messages) {
|
|
6108
|
+
if (!data || !data.id || known.has(data.id)) continue;
|
|
6109
|
+
const msg = {
|
|
6110
|
+
id: data.id,
|
|
6111
|
+
authorKind: data.authorKind,
|
|
6112
|
+
authorId: data.authorId,
|
|
6113
|
+
body: data.body || "",
|
|
6114
|
+
meta: data.meta || {},
|
|
6115
|
+
roundNum: data.roundNum,
|
|
6116
|
+
createdAt: data.createdAt,
|
|
6117
|
+
};
|
|
6118
|
+
state.messages.push(msg);
|
|
6119
|
+
html.push(this._threadMessageHtml(state, msg));
|
|
6120
|
+
}
|
|
6121
|
+
if (html.length) {
|
|
6122
|
+
list.insertAdjacentHTML("beforeend", html.join(""));
|
|
6123
|
+
list.scrollTop = list.scrollHeight;
|
|
6124
|
+
}
|
|
6125
|
+
},
|
|
6126
|
+
|
|
6127
|
+
minimizeThreadWindow(threadId) {
|
|
6128
|
+
const state = this._threads && this._threads[threadId];
|
|
6129
|
+
if (!state || !state.windowEl) return;
|
|
6130
|
+
state.minimized = true;
|
|
6131
|
+
state.windowEl.classList.add("is-minimized");
|
|
6132
|
+
// Drop the user-resized inline height · the minimized pill sizes
|
|
6133
|
+
// to its content (CSS `height: auto`), and inline height would
|
|
6134
|
+
// win over that class rule and stretch the chip. The chosen
|
|
6135
|
+
// height is preserved on state.height and reapplied on restore.
|
|
6136
|
+
state.windowEl.style.height = "";
|
|
6137
|
+
// Reset transform so the minimized chip lives in its dock
|
|
6138
|
+
// anchor at the bottom-right rather than at the user's drag
|
|
6139
|
+
// location. The dragX/dragY values stay on state so restore
|
|
6140
|
+
// can put it back where they had it.
|
|
6141
|
+
state.windowEl.style.transform = "";
|
|
6142
|
+
// Pull into a shared dock so multiple minimized chips line up
|
|
6143
|
+
// along the bottom-right edge.
|
|
6144
|
+
const dock = this._ensureThreadDock();
|
|
6145
|
+
dock.appendChild(state.windowEl);
|
|
6146
|
+
state.windowEl.style.right = "auto";
|
|
6147
|
+
state.windowEl.style.bottom = "auto";
|
|
6148
|
+
// Click-to-restore on the whole chip (header is hidden,
|
|
6149
|
+
// controls are hidden, so the chip itself is the click target).
|
|
6150
|
+
const restoreHandler = (e) => {
|
|
6151
|
+
e.preventDefault();
|
|
6152
|
+
e.stopPropagation();
|
|
6153
|
+
this.restoreThreadWindow(threadId);
|
|
6154
|
+
};
|
|
6155
|
+
state.windowEl.addEventListener("click", restoreHandler, { once: true });
|
|
6156
|
+
},
|
|
6157
|
+
|
|
6158
|
+
restoreThreadWindow(threadId) {
|
|
6159
|
+
const state = this._threads && this._threads[threadId];
|
|
6160
|
+
if (!state || !state.windowEl) return;
|
|
6161
|
+
state.minimized = false;
|
|
6162
|
+
const el = state.windowEl;
|
|
6163
|
+
el.classList.remove("is-minimized");
|
|
6164
|
+
// Panel-only · restore re-docks into the active room view rather
|
|
6165
|
+
// than reviving a floating window (float mode was removed). Clear
|
|
6166
|
+
// any leftover float inline anchors first, then re-parent into the
|
|
6167
|
+
// dock host. Only when no room view is mounted (defensive) do we
|
|
6168
|
+
// fall back to a body-level overlay.
|
|
6169
|
+
el.style.right = "";
|
|
6170
|
+
el.style.bottom = "";
|
|
6171
|
+
el.style.transform = "";
|
|
6172
|
+
el.style.height = "";
|
|
6173
|
+
const dockHost = this._threadDockHost();
|
|
6174
|
+
if (dockHost) {
|
|
6175
|
+
el.classList.add("is-docked");
|
|
6176
|
+
state.docked = true;
|
|
6177
|
+
if (el.parentNode !== dockHost) dockHost.appendChild(el);
|
|
6178
|
+
document.body.classList.add("has-thread-dock");
|
|
6179
|
+
} else {
|
|
6180
|
+
el.classList.remove("is-docked");
|
|
6181
|
+
state.docked = false;
|
|
6182
|
+
if (el.parentNode !== document.body) document.body.appendChild(el);
|
|
6183
|
+
el.style.right = "24px";
|
|
6184
|
+
el.style.bottom = "24px";
|
|
6185
|
+
}
|
|
6186
|
+
},
|
|
6187
|
+
|
|
6188
|
+
_ensureThreadDock() {
|
|
6189
|
+
let dock = document.getElementById("thread-dock");
|
|
6190
|
+
if (!dock) {
|
|
6191
|
+
dock = document.createElement("div");
|
|
6192
|
+
dock.id = "thread-dock";
|
|
6193
|
+
dock.className = "thread-dock";
|
|
6194
|
+
document.body.appendChild(dock);
|
|
6195
|
+
}
|
|
6196
|
+
return dock;
|
|
6197
|
+
},
|
|
6198
|
+
|
|
6199
|
+
_wireThreadDrag(handle, state) {
|
|
6200
|
+
// Per-drag scratch + rAF coalescing · pointermove fires faster
|
|
6201
|
+
// than the browser can paint on most setups; without throttling
|
|
6202
|
+
// we'd issue multiple style writes per frame. Reading the
|
|
6203
|
+
// pending coords in a single rAF and writing transform once
|
|
6204
|
+
// keeps the window pinned to the cursor with no perceived
|
|
6205
|
+
// lag. The earlier implementation wrote transform on every
|
|
6206
|
+
// mousemove AND had a 0.15s transition (since removed from
|
|
6207
|
+
// .thread-float CSS), which compounded into visible drag jitter.
|
|
6208
|
+
let startX = 0, startY = 0, baseX = 0, baseY = 0;
|
|
6209
|
+
let dragging = false;
|
|
6210
|
+
let pendingX = 0, pendingY = 0;
|
|
6211
|
+
let rafId = 0;
|
|
6212
|
+
const flush = () => {
|
|
6213
|
+
rafId = 0;
|
|
6214
|
+
if (!dragging) return;
|
|
6215
|
+
state.dragX = baseX + (pendingX - startX);
|
|
6216
|
+
state.dragY = baseY + (pendingY - startY);
|
|
6217
|
+
state.windowEl.style.transform = `translate(${state.dragX}px, ${state.dragY}px)`;
|
|
6218
|
+
};
|
|
6219
|
+
const onMove = (e) => {
|
|
6220
|
+
if (!dragging) return;
|
|
6221
|
+
// touchmove may need preventDefault to suppress page scroll
|
|
6222
|
+
// while the user drags the window across the viewport.
|
|
6223
|
+
if (e.cancelable && e.type === "touchmove") e.preventDefault();
|
|
6224
|
+
pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
|
|
6225
|
+
pendingY = ("touches" in e ? e.touches[0].clientY : e.clientY);
|
|
6226
|
+
if (!rafId) rafId = requestAnimationFrame(flush);
|
|
6227
|
+
};
|
|
6228
|
+
const onUp = () => {
|
|
6229
|
+
dragging = false;
|
|
6230
|
+
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
6231
|
+
// Reset the compositor hint once dragging ends so the layer
|
|
6232
|
+
// is freed and the window doesn't keep paying the
|
|
6233
|
+
// promote-to-its-own-layer tax forever.
|
|
6234
|
+
if (state.windowEl) state.windowEl.style.willChange = "";
|
|
6235
|
+
document.removeEventListener("mousemove", onMove);
|
|
6236
|
+
document.removeEventListener("mouseup", onUp);
|
|
6237
|
+
document.removeEventListener("touchmove", onMove);
|
|
6238
|
+
document.removeEventListener("touchend", onUp);
|
|
6239
|
+
};
|
|
6240
|
+
const onDown = (e) => {
|
|
6241
|
+
// Ignore drags that originate on interactive children of the
|
|
6242
|
+
// header — buttons (close / min / max) AND the director
|
|
6243
|
+
// avatar, which carries `[data-agent]` and routes its click
|
|
6244
|
+
// to the lightweight intro overlay (agent-overlay.js).
|
|
6245
|
+
if (e.target && e.target.closest && e.target.closest("button, [data-agent]")) return;
|
|
6246
|
+
dragging = true;
|
|
6247
|
+
startX = ("touches" in e ? e.touches[0].clientX : e.clientX);
|
|
6248
|
+
startY = ("touches" in e ? e.touches[0].clientY : e.clientY);
|
|
6249
|
+
pendingX = startX;
|
|
6250
|
+
pendingY = startY;
|
|
6251
|
+
baseX = state.dragX || 0;
|
|
6252
|
+
baseY = state.dragY || 0;
|
|
6253
|
+
// Promote the window to its own compositor layer for the
|
|
6254
|
+
// drag duration so transform updates can be GPU-accelerated.
|
|
6255
|
+
// Cleared on mouseup so we don't permanently inflate
|
|
6256
|
+
// memory.
|
|
6257
|
+
if (state.windowEl) state.windowEl.style.willChange = "transform";
|
|
6258
|
+
document.addEventListener("mousemove", onMove);
|
|
6259
|
+
document.addEventListener("mouseup", onUp);
|
|
6260
|
+
// touchmove · passive:false so we can preventDefault inside
|
|
6261
|
+
// onMove (matters on iOS Safari where the browser otherwise
|
|
6262
|
+
// hijacks vertical drags for page scroll).
|
|
6263
|
+
document.addEventListener("touchmove", onMove, { passive: false });
|
|
6264
|
+
document.addEventListener("touchend", onUp);
|
|
6265
|
+
};
|
|
6266
|
+
handle.addEventListener("mousedown", onDown);
|
|
6267
|
+
handle.addEventListener("touchstart", onDown, { passive: true });
|
|
6268
|
+
},
|
|
6269
|
+
|
|
6270
|
+
/** Edge resize · the float is bottom-anchored.
|
|
6271
|
+
* · "top" handle · drag UP grows / DOWN shrinks; the bottom
|
|
6272
|
+
* edge stays pinned (height only).
|
|
6273
|
+
* · "bottom" handle · the bottom edge follows the cursor (drag
|
|
6274
|
+
* DOWN grows / UP shrinks) while the top edge stays pinned —
|
|
6275
|
+
* achieved by moving the drag-translate `dragY` by the same
|
|
6276
|
+
* delta the height grows, so the box's top doesn't shift.
|
|
6277
|
+
* Mirrors `_wireThreadDrag`'s rAF-coalesced pointer bookkeeping.
|
|
6278
|
+
* Height is clamped to [240, viewport-96] — the upper cap matches
|
|
6279
|
+
* `.thread-float`'s CSS `max-height` so the inline height never
|
|
6280
|
+
* fights it. Persisted on `state.height` (+ `dragY` for the
|
|
6281
|
+
* bottom edge) so minimize/restore keeps the chosen size + spot. */
|
|
6282
|
+
_wireThreadResize(handle, state, edge) {
|
|
6283
|
+
const MIN_H = 240;
|
|
6284
|
+
const maxH = () => Math.max(MIN_H, window.innerHeight - 96);
|
|
6285
|
+
const isBottom = edge === "bottom";
|
|
6286
|
+
let startY = 0, baseH = 0, baseDragY = 0;
|
|
6287
|
+
let resizing = false;
|
|
6288
|
+
let pendingY = 0;
|
|
6289
|
+
let rafId = 0;
|
|
6290
|
+
const flush = () => {
|
|
6291
|
+
rafId = 0;
|
|
6292
|
+
if (!resizing || !state.windowEl) return;
|
|
6293
|
+
// Drag delta along Y · top edge grows when the cursor moves UP
|
|
6294
|
+
// (startY - cur); bottom edge grows when it moves DOWN.
|
|
6295
|
+
const rawDelta = isBottom ? (pendingY - startY) : (startY - pendingY);
|
|
6296
|
+
const next = Math.min(maxH(), Math.max(MIN_H, baseH + rawDelta));
|
|
6297
|
+
state.height = next;
|
|
6298
|
+
state.windowEl.style.height = `${next}px`;
|
|
6299
|
+
if (isBottom) {
|
|
6300
|
+
// Shift the box down by however much it actually grew so the
|
|
6301
|
+
// top edge stays put (height was clamped, so use the applied
|
|
6302
|
+
// delta, not the raw cursor delta).
|
|
6303
|
+
state.dragY = baseDragY + (next - baseH);
|
|
6304
|
+
state.windowEl.style.transform = `translate(${state.dragX || 0}px, ${state.dragY}px)`;
|
|
6305
|
+
}
|
|
6306
|
+
};
|
|
6307
|
+
const onMove = (e) => {
|
|
6308
|
+
if (!resizing) return;
|
|
6309
|
+
if (e.cancelable && e.type === "touchmove") e.preventDefault();
|
|
6310
|
+
pendingY = ("touches" in e ? e.touches[0].clientY : e.clientY);
|
|
6311
|
+
if (!rafId) rafId = requestAnimationFrame(flush);
|
|
6312
|
+
};
|
|
6313
|
+
const onUp = () => {
|
|
6314
|
+
resizing = false;
|
|
6315
|
+
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
6316
|
+
// Re-enable text selection in the rest of the app.
|
|
6317
|
+
document.body.style.userSelect = "";
|
|
6318
|
+
document.removeEventListener("mousemove", onMove);
|
|
6319
|
+
document.removeEventListener("mouseup", onUp);
|
|
6320
|
+
document.removeEventListener("touchmove", onMove);
|
|
6321
|
+
document.removeEventListener("touchend", onUp);
|
|
6322
|
+
};
|
|
6323
|
+
const onDown = (e) => {
|
|
6324
|
+
// Don't start a resize while minimized · the handle is hidden
|
|
6325
|
+
// then anyway, but guard against stray events.
|
|
6326
|
+
if (state.minimized || !state.windowEl) return;
|
|
6327
|
+
// Swallow the event so it doesn't also reach the header's
|
|
6328
|
+
// drag-to-move handler underneath.
|
|
6329
|
+
if (e.stopPropagation) e.stopPropagation();
|
|
6330
|
+
// Suppress text selection for the whole drag · without this the
|
|
6331
|
+
// mousemove sweep selects whatever room text sits under the
|
|
6332
|
+
// cursor, which reads as a glitch while resizing the window.
|
|
6333
|
+
if (e.type === "mousedown" && e.preventDefault) e.preventDefault();
|
|
6334
|
+
document.body.style.userSelect = "none";
|
|
6335
|
+
resizing = true;
|
|
6336
|
+
startY = ("touches" in e ? e.touches[0].clientY : e.clientY);
|
|
6337
|
+
pendingY = startY;
|
|
6338
|
+
baseH = state.windowEl.getBoundingClientRect().height;
|
|
6339
|
+
baseDragY = state.dragY || 0;
|
|
6340
|
+
document.addEventListener("mousemove", onMove);
|
|
6341
|
+
document.addEventListener("mouseup", onUp);
|
|
6342
|
+
document.addEventListener("touchmove", onMove, { passive: false });
|
|
6343
|
+
document.addEventListener("touchend", onUp);
|
|
6344
|
+
};
|
|
6345
|
+
handle.addEventListener("mousedown", onDown);
|
|
6346
|
+
handle.addEventListener("touchstart", onDown, { passive: true });
|
|
6347
|
+
},
|
|
6348
|
+
|
|
6349
|
+
async sendThreadMessage(threadId) {
|
|
6350
|
+
const state = this._threads && this._threads[threadId];
|
|
6351
|
+
if (!state || !state.windowEl) return;
|
|
6352
|
+
const input = state.windowEl.querySelector("[data-thread-input]");
|
|
6353
|
+
if (!input) return;
|
|
6354
|
+
const text = (input.value || "").trim();
|
|
6355
|
+
if (!text) return;
|
|
6356
|
+
input.value = "";
|
|
6357
|
+
input.style.height = "auto";
|
|
6358
|
+
try {
|
|
6359
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}/messages`, {
|
|
6360
|
+
method: "POST",
|
|
6361
|
+
headers: { "content-type": "application/json" },
|
|
6362
|
+
body: JSON.stringify({ body: text, mode: "now" }),
|
|
6363
|
+
});
|
|
6364
|
+
if (!res.ok) {
|
|
6365
|
+
const err = await res.json().catch(() => ({}));
|
|
6366
|
+
alert((err && err.error) || "Failed to send thread message");
|
|
6367
|
+
}
|
|
6368
|
+
// No need to optimistically render · the SSE message-appended
|
|
6369
|
+
// for the user message will fan out and our handler renders it.
|
|
6370
|
+
} catch (e) {
|
|
6371
|
+
alert((e && e.message) || "Failed to send thread message");
|
|
6372
|
+
}
|
|
6373
|
+
},
|
|
6374
|
+
|
|
6375
|
+
_threadEmptyState(threadId, hide) {
|
|
6376
|
+
const state = this._threads && this._threads[threadId];
|
|
6377
|
+
if (!state || !state.windowEl) return;
|
|
6378
|
+
const empty = state.windowEl.querySelector("[data-thread-empty]");
|
|
6379
|
+
if (empty) empty.style.display = hide ? "none" : "";
|
|
6380
|
+
},
|
|
6381
|
+
|
|
6382
|
+
/** Render a thread bubble using the main chat's full `messageHtml`
|
|
6383
|
+
* pipeline — keeps user avatars, director avatars, model badges,
|
|
6384
|
+
* the streaming dots animation, name+tag chips, etc. all
|
|
6385
|
+
* consistent with the main room. Earlier custom-mini renderer
|
|
6386
|
+
* produced visually broken bubbles (no user avatar, missing
|
|
6387
|
+
* meta chips) because it duplicated only a fraction of the
|
|
6388
|
+
* main path. Now reuses ONE source of truth.
|
|
6389
|
+
*
|
|
6390
|
+
* Side effect protection · sets `this._renderingThread = true`
|
|
6391
|
+
* for the duration of the call so the per-message thread
|
|
6392
|
+
* toolbar (`_threadTriggerHtml`) skips rendering inside thread
|
|
6393
|
+
* windows — there's no "thread the speaker again" inside an
|
|
6394
|
+
* already-open thread. The flag flips back in `finally`. */
|
|
6395
|
+
_threadMessageHtml(state, msg) {
|
|
6396
|
+
void state;
|
|
6397
|
+
this._renderingThread = true;
|
|
6398
|
+
try {
|
|
6399
|
+
return this.messageHtml(msg, false);
|
|
6400
|
+
} finally {
|
|
6401
|
+
this._renderingThread = false;
|
|
6402
|
+
}
|
|
6403
|
+
},
|
|
6404
|
+
|
|
6405
|
+
_threadAppendMessage(threadId, data) {
|
|
6406
|
+
const state = this._threads && this._threads[threadId];
|
|
6407
|
+
if (!state || !state.windowEl) return;
|
|
6408
|
+
const list = state.windowEl.querySelector("[data-thread-messages]");
|
|
6409
|
+
if (!list) return;
|
|
6410
|
+
this._threadEmptyState(threadId, true);
|
|
6411
|
+
// Track msg in state so streaming token appends find a stable
|
|
6412
|
+
// reference even if the article gets re-rendered.
|
|
6413
|
+
const msg = {
|
|
6414
|
+
id: data.messageId,
|
|
6415
|
+
authorKind: data.authorKind,
|
|
6416
|
+
authorId: data.authorId,
|
|
6417
|
+
body: data.body || "",
|
|
6418
|
+
meta: data.meta || {},
|
|
6419
|
+
roundNum: data.roundNum,
|
|
6420
|
+
createdAt: data.createdAt,
|
|
6421
|
+
};
|
|
6422
|
+
state.messages.push(msg);
|
|
6423
|
+
list.insertAdjacentHTML("beforeend", this._threadMessageHtml(state, msg));
|
|
6424
|
+
list.scrollTop = list.scrollHeight;
|
|
6425
|
+
// Director just started streaming · swap composer Send → Stop
|
|
6426
|
+
// so the user can interrupt the response in flight. The flag
|
|
6427
|
+
// stays on the message; later finalize / abort clears it.
|
|
6428
|
+
if (msg.authorKind === "agent" && msg.meta && msg.meta.streaming) {
|
|
6429
|
+
this._setThreadStreamingState(threadId, msg.id);
|
|
6430
|
+
}
|
|
6431
|
+
},
|
|
6432
|
+
|
|
6433
|
+
_threadApplyToken(threadId, data) {
|
|
6434
|
+
const state = this._threads && this._threads[threadId];
|
|
6435
|
+
if (!state) return;
|
|
6436
|
+
const msg = state.messages.find((m) => m.id === data.messageId);
|
|
6437
|
+
if (!msg) return;
|
|
6438
|
+
msg.body = (msg.body || "") + (data.delta || "");
|
|
6439
|
+
const article = state.windowEl.querySelector(
|
|
6440
|
+
`[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
|
|
6441
|
+
);
|
|
6442
|
+
if (!article) return;
|
|
6443
|
+
const bubble = article.querySelector(".msg-bubble");
|
|
6444
|
+
if (!bubble) return;
|
|
6445
|
+
article.classList.remove("thinking");
|
|
6446
|
+
bubble.innerHTML = this.renderBody ? this.renderBody(msg.body) : msg.body;
|
|
6447
|
+
const list = state.windowEl.querySelector("[data-thread-messages]");
|
|
6448
|
+
if (list) list.scrollTop = list.scrollHeight;
|
|
6449
|
+
},
|
|
6450
|
+
|
|
6451
|
+
_threadUpdateMessage(threadId, data) {
|
|
6452
|
+
const state = this._threads && this._threads[threadId];
|
|
6453
|
+
if (!state) return;
|
|
6454
|
+
const msg = state.messages.find((m) => m.id === data.messageId);
|
|
6455
|
+
if (msg) {
|
|
6456
|
+
msg.body = data.body || "";
|
|
6457
|
+
msg.meta = data.meta || {};
|
|
6458
|
+
}
|
|
6459
|
+
const article = state.windowEl.querySelector(
|
|
6460
|
+
`[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
|
|
6461
|
+
);
|
|
6462
|
+
if (!article) return;
|
|
6463
|
+
const bubble = article.querySelector(".msg-bubble");
|
|
6464
|
+
if (bubble) {
|
|
6465
|
+
bubble.innerHTML = this.renderBody ? this.renderBody(data.body || "") : (data.body || "");
|
|
6466
|
+
}
|
|
6467
|
+
const streaming = !!(data.meta && data.meta.streaming);
|
|
6468
|
+
article.classList.toggle("streaming", streaming);
|
|
6469
|
+
if (!streaming) {
|
|
6470
|
+
article.classList.remove("thinking");
|
|
6471
|
+
// Server flipped streaming:false on this director's message ·
|
|
6472
|
+
// restore the composer to Send mode if this was the message
|
|
6473
|
+
// we were tracking. Catches the abort + natural-finish paths
|
|
6474
|
+
// uniformly without needing a separate handler.
|
|
6475
|
+
if (state.streamingMessageId === data.messageId) {
|
|
6476
|
+
this._setThreadStreamingState(threadId, null);
|
|
6477
|
+
}
|
|
6478
|
+
}
|
|
6479
|
+
},
|
|
6480
|
+
|
|
6481
|
+
_threadFinalizeMessage(threadId, data) {
|
|
6482
|
+
const state = this._threads && this._threads[threadId];
|
|
6483
|
+
if (!state) return;
|
|
6484
|
+
const article = state.windowEl.querySelector(
|
|
6485
|
+
`[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
|
|
6486
|
+
);
|
|
6487
|
+
if (article) {
|
|
6488
|
+
article.classList.remove("streaming");
|
|
6489
|
+
article.classList.remove("thinking");
|
|
6490
|
+
}
|
|
6491
|
+
// Director's stream finished · flip composer back to Send.
|
|
6492
|
+
if (state.streamingMessageId === data.messageId) {
|
|
6493
|
+
this._setThreadStreamingState(threadId, null);
|
|
6494
|
+
}
|
|
6495
|
+
},
|
|
6496
|
+
|
|
6497
|
+
/** Flip the composer's Send/Stop visibility on the thread window
|
|
6498
|
+
* via a state class on `.thread-float-composer`. Tracks which
|
|
6499
|
+
* message we believe is currently streaming so duplicate
|
|
6500
|
+
* message-updated events (e.g. multiple token batches before
|
|
6501
|
+
* the streaming:false flag flips) don't toggle prematurely. */
|
|
6502
|
+
_setThreadStreamingState(threadId, messageId) {
|
|
6503
|
+
const state = this._threads && this._threads[threadId];
|
|
6504
|
+
if (!state || !state.windowEl) return;
|
|
6505
|
+
state.streamingMessageId = messageId;
|
|
6506
|
+
const composer = state.windowEl.querySelector("[data-thread-composer]");
|
|
6507
|
+
if (composer) composer.classList.toggle("is-streaming", !!messageId);
|
|
6508
|
+
},
|
|
6509
|
+
|
|
6510
|
+
/** Interrupt the in-flight director response in a thread. Hits
|
|
6511
|
+
* POST /api/rooms/:threadId/abort (the same endpoint the main
|
|
6512
|
+
* room uses for chair-interrupt + skip-current-speaker), which
|
|
6513
|
+
* aborts the LLM stream and drains voice waiters. The director's
|
|
6514
|
+
* partial body stays in chat; the composer flips back to Send
|
|
6515
|
+
* when the server's resulting message-updated (streaming:false)
|
|
6516
|
+
* arrives via SSE. */
|
|
6517
|
+
async stopThreadDirector(threadId) {
|
|
6518
|
+
const state = this._threads && this._threads[threadId];
|
|
6519
|
+
if (!state) return;
|
|
6520
|
+
try {
|
|
6521
|
+
await fetch(`/api/rooms/${encodeURIComponent(threadId)}/abort`, { method: "POST" });
|
|
6522
|
+
} catch (e) {
|
|
6523
|
+
try { console.warn("[thread] abort failed:", e); } catch (_) {}
|
|
6524
|
+
}
|
|
6525
|
+
// Optimistic UX · flip the composer back immediately rather
|
|
6526
|
+
// than waiting for the SSE round-trip. The streaming:false
|
|
6527
|
+
// update + message-final will arrive shortly and the toggle
|
|
6528
|
+
// is idempotent.
|
|
6529
|
+
this._setThreadStreamingState(threadId, null);
|
|
6530
|
+
},
|
|
6531
|
+
|
|
6532
|
+
_threadRemoveMessage(threadId, data) {
|
|
6533
|
+
const state = this._threads && this._threads[threadId];
|
|
6534
|
+
if (!state) return;
|
|
6535
|
+
state.messages = state.messages.filter((m) => m.id !== data.messageId);
|
|
6536
|
+
const article = state.windowEl.querySelector(
|
|
6537
|
+
`[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
|
|
6538
|
+
);
|
|
6539
|
+
if (article && article.parentNode) article.parentNode.removeChild(article);
|
|
6540
|
+
},
|
|
6541
|
+
|
|
5150
6542
|
async submitFollowUp() {
|
|
5151
6543
|
const overlay = document.getElementById("followup-overlay");
|
|
5152
6544
|
if (!overlay) return;
|
|
@@ -5906,6 +7298,19 @@
|
|
|
5906
7298
|
}
|
|
5907
7299
|
},
|
|
5908
7300
|
|
|
7301
|
+
/** True when a voice replay is currently running for `roomId`.
|
|
7302
|
+
* Drives the Record button's visibility in adjourned rooms ·
|
|
7303
|
+
* recording the replay reconstructs a meeting video (3D stage
|
|
7304
|
+
* driven by the replay + the replay's TTS audio) the same way
|
|
7305
|
+
* a live room records. `getRoomId()` is null for legacy replays
|
|
7306
|
+
* that didn't pass a roomId · treat that as "matches". */
|
|
7307
|
+
_isReplayActiveForRoom(roomId) {
|
|
7308
|
+
const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
|
|
7309
|
+
if (!vr || typeof vr.isOpen !== "function" || !vr.isOpen()) return false;
|
|
7310
|
+
const rid = typeof vr.getRoomId === "function" ? vr.getRoomId() : null;
|
|
7311
|
+
return !rid || rid === roomId;
|
|
7312
|
+
},
|
|
7313
|
+
|
|
5909
7314
|
/** Toggle recording for the current voice room. Idempotent · if
|
|
5910
7315
|
* already recording, stops + downloads; otherwise starts. */
|
|
5911
7316
|
async handleRecordToggle() {
|
|
@@ -5914,15 +7319,30 @@
|
|
|
5914
7319
|
console.warn("[recorder] module not loaded");
|
|
5915
7320
|
return;
|
|
5916
7321
|
}
|
|
7322
|
+
const room = this.currentRoom;
|
|
7323
|
+
const isLive = !!(room && room.status === "live");
|
|
5917
7324
|
if (rec.isRecording()) {
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
7325
|
+
if (isLive) {
|
|
7326
|
+
// Live recording · open the stop-choice modal instead of
|
|
7327
|
+
// stopping immediately. The user picks whether to also end
|
|
7328
|
+
// the live room (interrupt / wait for speaker / keep room).
|
|
7329
|
+
this.openRecordingStopModal();
|
|
7330
|
+
} else {
|
|
7331
|
+
// Replay / adjourned recording · there's no live room to
|
|
7332
|
+
// interrupt or pause, so skip the lifecycle modal and just
|
|
7333
|
+
// stop + download the clip.
|
|
7334
|
+
await this._stopRecordingAndToast();
|
|
7335
|
+
}
|
|
5922
7336
|
return;
|
|
5923
7337
|
}
|
|
5924
|
-
const room = this.currentRoom;
|
|
5925
7338
|
if (!room || room.deliveryMode !== "voice") return;
|
|
7339
|
+
// Replay / adjourned · the round-table stage is opt-in there
|
|
7340
|
+
// (hidden by default), but the recorder crops the window capture
|
|
7341
|
+
// to the stage's on-screen rect — so the stage MUST be visible
|
|
7342
|
+
// before start() locks the composite size. Force the stage view
|
|
7343
|
+
// and collapse the floating replay player so it doesn't bleed
|
|
7344
|
+
// into the captured region.
|
|
7345
|
+
if (!isLive) this._ensureStageVisibleForRecording();
|
|
5926
7346
|
try {
|
|
5927
7347
|
await rec.start(room.id, room.subject || room.title || "Meeting");
|
|
5928
7348
|
// Recorder is now capturing chunks · play the cinematic
|
|
@@ -5937,6 +7357,44 @@
|
|
|
5937
7357
|
}
|
|
5938
7358
|
},
|
|
5939
7359
|
|
|
7360
|
+
/** Stop + download the active recording and surface the saved
|
|
7361
|
+
* toast. Used by the replay / adjourned stop path where there's
|
|
7362
|
+
* no room lifecycle to reconcile (cf. handleRecordingStopChoice
|
|
7363
|
+
* for the live-room flow). */
|
|
7364
|
+
async _stopRecordingAndToast() {
|
|
7365
|
+
let blob = null;
|
|
7366
|
+
try { blob = await window.BoardroomRecorder.stopAndDownload(); }
|
|
7367
|
+
catch (e) { console.error("[recorder] stopAndDownload failed", e); }
|
|
7368
|
+
if (blob && blob.size > 0) {
|
|
7369
|
+
try {
|
|
7370
|
+
this.showRoundTableToast({
|
|
7371
|
+
kind: "settings",
|
|
7372
|
+
glyph: "↓",
|
|
7373
|
+
htmlText: this.escape(this._t("rec_saved_toast")),
|
|
7374
|
+
lifetimeMs: 5200,
|
|
7375
|
+
});
|
|
7376
|
+
} catch (_) { /* toast best-effort */ }
|
|
7377
|
+
}
|
|
7378
|
+
},
|
|
7379
|
+
|
|
7380
|
+
/** Reveal the round-table stage (opt-in for adjourned rooms) so
|
|
7381
|
+
* the recorder has a non-empty region to crop, and collapse the
|
|
7382
|
+
* floating voice-replay player so its panel stays out of the
|
|
7383
|
+
* captured frame. */
|
|
7384
|
+
_ensureStageVisibleForRecording() {
|
|
7385
|
+
const roomId = this.currentRoomId;
|
|
7386
|
+
if (!roomId) return;
|
|
7387
|
+
try { localStorage.setItem("rt-view-" + roomId, "stage"); } catch (_) { /* noop */ }
|
|
7388
|
+
if (typeof this.applyRoundTableVisibility === "function") {
|
|
7389
|
+
this.applyRoundTableVisibility(roomId);
|
|
7390
|
+
}
|
|
7391
|
+
const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
|
|
7392
|
+
if (vr && typeof vr.isOpen === "function" && vr.isOpen()
|
|
7393
|
+
&& typeof vr.collapse === "function") {
|
|
7394
|
+
try { vr.collapse(); } catch (_) { /* noop */ }
|
|
7395
|
+
}
|
|
7396
|
+
},
|
|
7397
|
+
|
|
5940
7398
|
/** Movie-trailer countdown · 4 frames (Ready, 3, 2, 1) each
|
|
5941
7399
|
* ~850 ms with a pop+fade animation. Mounts inside the stage
|
|
5942
7400
|
* so the recording captures the overlay as an opening title
|
|
@@ -6030,6 +7488,81 @@
|
|
|
6030
7488
|
if (el) el.remove();
|
|
6031
7489
|
},
|
|
6032
7490
|
|
|
7491
|
+
/** Pause-after-current save modal · the user soft-paused the room
|
|
7492
|
+
* while a recording was running. The recording itself doesn't
|
|
7493
|
+
* stop automatically (room can resume), so we ask the user
|
|
7494
|
+
* whether to save it now. Three choices:
|
|
7495
|
+
* · primary "Save & adjourn" — stopAndDownload + adjourn(skipBrief)
|
|
7496
|
+
* · secondary "Save & keep paused" — stopAndDownload, room stays paused
|
|
7497
|
+
* · ghost "Keep recording" — close modal, room paused, recorder
|
|
7498
|
+
* continues capturing the silence
|
|
7499
|
+
* Modal reuses the .pc-overlay / .pc-modal / .pc-choice chrome
|
|
7500
|
+
* for visual consistency with other pause-/reload-choice flows. */
|
|
7501
|
+
openRecordingPauseSaveModal() {
|
|
7502
|
+
this.closeRecordingPauseSaveModal();
|
|
7503
|
+
const html = `
|
|
7504
|
+
<div id="rec-pause-save-overlay" class="pc-overlay">
|
|
7505
|
+
<div class="pc-modal">
|
|
7506
|
+
<div class="pc-classification">
|
|
7507
|
+
<span><span class="dot">●</span> ${this.escape(this._t("rec_pause_save_class"))}</span>
|
|
7508
|
+
<span class="right">${this.escape(this._t("rec_pause_save_right"))}</span>
|
|
7509
|
+
</div>
|
|
7510
|
+
<div class="pc-head">
|
|
7511
|
+
<div class="pc-tag">${this.escape(this._t("rec_pause_save_tag"))}</div>
|
|
7512
|
+
<h2 class="pc-title">${this.escape(this._t("rec_pause_save_title"))}</h2>
|
|
7513
|
+
<p class="pc-deck">${this.escape(this._t("rec_pause_save_deck"))}</p>
|
|
7514
|
+
</div>
|
|
7515
|
+
<div class="pc-body">
|
|
7516
|
+
<button type="button" class="pc-choice primary" data-rec-pause-save-choice="save-adjourn">
|
|
7517
|
+
<div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_adjourn_mark"))}</div>
|
|
7518
|
+
<div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_adjourn_deck"))}</div>
|
|
7519
|
+
</button>
|
|
7520
|
+
<button type="button" class="pc-choice" data-rec-pause-save-choice="save-keep">
|
|
7521
|
+
<div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_keep_mark"))}</div>
|
|
7522
|
+
<div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_keep_deck"))}</div>
|
|
7523
|
+
</button>
|
|
7524
|
+
<button type="button" class="pc-choice ghost" data-rec-pause-save-choice="continue">
|
|
7525
|
+
<div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_continue_mark"))}</div>
|
|
7526
|
+
<div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_continue_deck"))}</div>
|
|
7527
|
+
</button>
|
|
7528
|
+
</div>
|
|
7529
|
+
</div>
|
|
7530
|
+
</div>
|
|
7531
|
+
`;
|
|
7532
|
+
document.body.insertAdjacentHTML("beforeend", html);
|
|
7533
|
+
},
|
|
7534
|
+
|
|
7535
|
+
closeRecordingPauseSaveModal() {
|
|
7536
|
+
const el = document.getElementById("rec-pause-save-overlay");
|
|
7537
|
+
if (el) el.remove();
|
|
7538
|
+
},
|
|
7539
|
+
|
|
7540
|
+
async handleRecordingPauseSaveChoice(mode) {
|
|
7541
|
+
this.closeRecordingPauseSaveModal();
|
|
7542
|
+
if (mode === "continue") return; // keep recording, room stays paused
|
|
7543
|
+
// Both save paths stop + download the clip first.
|
|
7544
|
+
let blob = null;
|
|
7545
|
+
try { blob = await window.BoardroomRecorder.stopAndDownload(); }
|
|
7546
|
+
catch (e) { console.error("[recorder] stopAndDownload failed", e); }
|
|
7547
|
+
if (blob && blob.size > 0) {
|
|
7548
|
+
try {
|
|
7549
|
+
this.showRoundTableToast({
|
|
7550
|
+
kind: "settings",
|
|
7551
|
+
glyph: "↓",
|
|
7552
|
+
htmlText: this.escape(this._t("rec_saved_toast")),
|
|
7553
|
+
lifetimeMs: 5200,
|
|
7554
|
+
});
|
|
7555
|
+
} catch (_) { /* toast best-effort */ }
|
|
7556
|
+
}
|
|
7557
|
+
if (mode === "save-adjourn") {
|
|
7558
|
+
// skipBrief · recording-driven adjourns never auto-generate
|
|
7559
|
+
// a report; the user files one manually.
|
|
7560
|
+
try { await this.adjournRoom({ skipBrief: true }); }
|
|
7561
|
+
catch (e) { console.warn("[recorder] adjournRoom failed", e); }
|
|
7562
|
+
}
|
|
7563
|
+
// save-keep · nothing else to do; room is already paused.
|
|
7564
|
+
},
|
|
7565
|
+
|
|
6033
7566
|
async handleRecordingStopChoice(mode) {
|
|
6034
7567
|
this.closeRecordingStopModal();
|
|
6035
7568
|
if (mode === "cancel") return;
|
|
@@ -6920,9 +8453,17 @@
|
|
|
6920
8453
|
timeFmt(ms) {
|
|
6921
8454
|
if (!ms) return "";
|
|
6922
8455
|
const d = new Date(ms);
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
8456
|
+
// Absolute timestamp · "May 24, 3:45 PM". When the year differs
|
|
8457
|
+
// from the current one (last year or earlier) the year is
|
|
8458
|
+
// prepended so e.g. "2024 May 24, 3:45 PM" disambiguates old
|
|
8459
|
+
// messages. en-US locale pins the short month + 12-hour "PM"
|
|
8460
|
+
// meridiem regardless of UI language (matches the notification
|
|
8461
|
+
// date format elsewhere in the app).
|
|
8462
|
+
const datePart = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
8463
|
+
const timePart = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
|
8464
|
+
const base = `${datePart}, ${timePart}`;
|
|
8465
|
+
const yr = d.getFullYear();
|
|
8466
|
+
return yr === new Date().getFullYear() ? base : `${yr} ${base}`;
|
|
6926
8467
|
},
|
|
6927
8468
|
|
|
6928
8469
|
relTime(ms) {
|
|
@@ -7363,29 +8904,42 @@
|
|
|
7363
8904
|
meta = this._t("sidebar_host");
|
|
7364
8905
|
}
|
|
7365
8906
|
|
|
7366
|
-
// Avatar source-of-truth · prefs.
|
|
7367
|
-
//
|
|
7368
|
-
//
|
|
7369
|
-
//
|
|
7370
|
-
//
|
|
7371
|
-
// existed.
|
|
8907
|
+
// Avatar source-of-truth · prefs.avatarUrl (rendered 3D PNG
|
|
8908
|
+
// captured by the avatar customizer) wins. Otherwise we paint
|
|
8909
|
+
// the seed-derived 3D snap (Avatar3DSnap.generate) and fall
|
|
8910
|
+
// back to the initial-letter chip while the async render is
|
|
8911
|
+
// in flight / when WebGL isn't available.
|
|
7372
8912
|
// Sync every avatar slot · the full sidebar foot AND the
|
|
7373
8913
|
// collapsed mini-rail foot both carry [data-user-avatar], so
|
|
7374
8914
|
// querySelectorAll keeps them identical regardless of which is
|
|
7375
8915
|
// currently visible.
|
|
8916
|
+
const url = this.prefs?.avatarUrl;
|
|
7376
8917
|
const seed = this.prefs?.avatarSeed;
|
|
7377
|
-
const
|
|
7378
|
-
|
|
7379
|
-
|
|
8918
|
+
const snap = window.Avatar3DSnap;
|
|
8919
|
+
const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
|
|
8920
|
+
const imgSrc = url || cachedSnap || null;
|
|
7380
8921
|
document.querySelectorAll("[data-user-avatar]").forEach((av) => {
|
|
7381
|
-
if (
|
|
8922
|
+
if (imgSrc) {
|
|
7382
8923
|
av.classList.add("has-pixel-av");
|
|
7383
|
-
av.innerHTML =
|
|
8924
|
+
av.innerHTML = `<img src="${imgSrc}" alt="" style="width:100%;height:100%;object-fit:cover;image-rendering:auto">`;
|
|
7384
8925
|
} else {
|
|
7385
8926
|
av.classList.remove("has-pixel-av");
|
|
7386
8927
|
av.textContent = initial;
|
|
7387
8928
|
}
|
|
7388
8929
|
});
|
|
8930
|
+
if (!url && seed && !cachedSnap && snap && typeof snap.generate === "function") {
|
|
8931
|
+
snap.generate(seed).then((dataUrl) => {
|
|
8932
|
+
if (!dataUrl) return;
|
|
8933
|
+
// Only repaint if the same seed is still in play — guard
|
|
8934
|
+
// against fast-fire regenerate clicks landing stale renders.
|
|
8935
|
+
if (this.prefs?.avatarSeed !== seed) return;
|
|
8936
|
+
if (this.prefs?.avatarUrl) return;
|
|
8937
|
+
document.querySelectorAll("[data-user-avatar]").forEach((av) => {
|
|
8938
|
+
av.classList.add("has-pixel-av");
|
|
8939
|
+
av.innerHTML = `<img src="${dataUrl}" alt="" style="width:100%;height:100%;object-fit:cover;image-rendering:auto">`;
|
|
8940
|
+
});
|
|
8941
|
+
}).catch(() => { /* */ });
|
|
8942
|
+
}
|
|
7389
8943
|
const nm = document.querySelector("[data-user-name]");
|
|
7390
8944
|
if (nm) nm.textContent = name;
|
|
7391
8945
|
const mt = document.querySelector("[data-user-meta]");
|
|
@@ -8074,6 +9628,12 @@
|
|
|
8074
9628
|
if (chat) {
|
|
8075
9629
|
if (this.composerMode === "agent") {
|
|
8076
9630
|
chat.innerHTML = this.renderAgentComposerHtml();
|
|
9631
|
+
// Hire-a-known-mind portraits · upgrade the 2D placeholders
|
|
9632
|
+
// to deterministic 3D voxel renders. Fire-and-forget · the
|
|
9633
|
+
// hydrator lazy-loads three.js + avatar-3d.js on first use,
|
|
9634
|
+
// caches per-seed dataURLs, and re-skips already-rendered
|
|
9635
|
+
// cards. Safe against rapid re-renders (idempotent).
|
|
9636
|
+
try { void this._hydrateCelebrityAvatars3D(); } catch (_) { /* */ }
|
|
8077
9637
|
// Focus the description textarea unless we're showing a preview.
|
|
8078
9638
|
setTimeout(() => {
|
|
8079
9639
|
const ta = chat.querySelector("[data-agent-composer-desc]");
|
|
@@ -9712,7 +11272,11 @@
|
|
|
9712
11272
|
const article = document.querySelector(`article[data-message-id="${openerId}"]`);
|
|
9713
11273
|
if (!article) return;
|
|
9714
11274
|
try {
|
|
9715
|
-
|
|
11275
|
+
// Instant · matches the thread panel's jump-top affordance.
|
|
11276
|
+
// User reads the result, not the animation; smooth scrolling
|
|
11277
|
+
// on long rooms (10+ screens of history) takes ≥800ms which
|
|
11278
|
+
// reads as sluggish.
|
|
11279
|
+
article.scrollIntoView({ behavior: "auto", block: "start" });
|
|
9716
11280
|
} catch { /* */ }
|
|
9717
11281
|
this.chatStuckToBottom = false;
|
|
9718
11282
|
this._suppressBottomScrollUntil = Date.now() + 2000;
|
|
@@ -9994,6 +11558,15 @@
|
|
|
9994
11558
|
|
|
9995
11559
|
showNoteTooltip(span) {
|
|
9996
11560
|
if (!span) return;
|
|
11561
|
+
// Suppress while a text selection is active · the selection CTA
|
|
11562
|
+
// bar (quote-cta.js) already sits in this slot above the
|
|
11563
|
+
// selection. Selecting text that overlaps an already-saved
|
|
11564
|
+
// highlight would otherwise stack the "✓ Saved" hover tip on
|
|
11565
|
+
// top of the CTA bar — the user sees two toolbars. Once the
|
|
11566
|
+
// selection collapses (click elsewhere), hovering the highlight
|
|
11567
|
+
// shows the tip again as normal.
|
|
11568
|
+
const sel = window.getSelection ? window.getSelection() : null;
|
|
11569
|
+
if (sel && !sel.isCollapsed) return;
|
|
9997
11570
|
const tip = this._ensureNoteTip();
|
|
9998
11571
|
const noteId = span.dataset.noteId;
|
|
9999
11572
|
// Resolve note metadata from currentNotes · used for the time
|
|
@@ -10672,19 +12245,25 @@
|
|
|
10672
12245
|
});
|
|
10673
12246
|
},
|
|
10674
12247
|
|
|
10675
|
-
/** Card HTML for one celebrity seed. Avatar
|
|
10676
|
-
*
|
|
10677
|
-
*
|
|
10678
|
-
*
|
|
12248
|
+
/** Card HTML for one celebrity seed. Avatar tile mounts empty
|
|
12249
|
+
* (or with a cached 3D snap if one is hot) and gets painted by
|
|
12250
|
+
* the deterministic 3D voxel renderer asynchronously when WebGL
|
|
12251
|
+
* is available — see `_hydrateCelebrityAvatars3D()`. The 3D
|
|
12252
|
+
* config is derived from `seed.id` via
|
|
12253
|
+
* `deriveDefaultAvatarConfig`, so the portrait is stable across
|
|
12254
|
+
* re-renders + matches the random face shipped with the persona
|
|
12255
|
+
* once it's hired. Intro picks the en/zh field that matches the
|
|
12256
|
+
* active locale; ja/es fall back to en. */
|
|
10679
12257
|
celebrityCardHtml(seed) {
|
|
10680
12258
|
const lang = this.composerLanguage();
|
|
10681
12259
|
const intro = (seed.intro && (seed.intro[lang] || seed.intro.en || "")) || "";
|
|
10682
|
-
|
|
10683
|
-
|
|
10684
|
-
|
|
10685
|
-
const
|
|
10686
|
-
|
|
10687
|
-
|
|
12260
|
+
// Prefer the cached 3D render if a previous open already
|
|
12261
|
+
// hydrated this seed (hot-path through the composer should NOT
|
|
12262
|
+
// flash an empty tile on a second open).
|
|
12263
|
+
const cached = (this._celebrityAvatar3dCache && this._celebrityAvatar3dCache.get(seed.id)) || null;
|
|
12264
|
+
const avatarHtml = cached
|
|
12265
|
+
? `<img class="cmp-celeb-img" data-cmp-celeb-img="${this.escape(seed.id)}" src="${this.escape(cached)}" alt="${this.escape(seed.name)}">`
|
|
12266
|
+
: `<span class="cmp-celeb-img-fallback" data-cmp-celeb-img="${this.escape(seed.id)}" aria-hidden="true">·</span>`;
|
|
10688
12267
|
return `
|
|
10689
12268
|
<button type="button" class="cmp-celeb-card" data-celebrity-seed="${this.escape(seed.id)}" title="${this.escape(seed.name)}">
|
|
10690
12269
|
<span class="cmp-celeb-av">${avatarHtml}</span>
|
|
@@ -10697,6 +12276,172 @@
|
|
|
10697
12276
|
`;
|
|
10698
12277
|
},
|
|
10699
12278
|
|
|
12279
|
+
/** Walk every `.cmp-celeb-card` currently mounted and render a
|
|
12280
|
+
* 3D voxel portrait into each one (writes the image dataURL
|
|
12281
|
+
* back to its `<img data-cmp-celeb-img>` src). Idempotent · the
|
|
12282
|
+
* per-seed cache means subsequent calls only render new seeds.
|
|
12283
|
+
* Lazy-loads three.js + avatar-3d.js on the first call so the
|
|
12284
|
+
* composer's first paint isn't blocked. Skips silently when
|
|
12285
|
+
* WebGL isn't available (the 2D fallback already rendered). */
|
|
12286
|
+
async _hydrateCelebrityAvatars3D() {
|
|
12287
|
+
if (!this._celebrityAvatar3dCache) this._celebrityAvatar3dCache = new Map();
|
|
12288
|
+
// Capability gate · skip on no-WebGL so the 2D fallback remains
|
|
12289
|
+
// the visible portrait. Cheap test, runs every call (the user
|
|
12290
|
+
// could plug in a WebGL-capable display between calls).
|
|
12291
|
+
let canWebGL = false;
|
|
12292
|
+
try {
|
|
12293
|
+
const c = document.createElement("canvas");
|
|
12294
|
+
canWebGL = !!(c.getContext("webgl2") || c.getContext("webgl"));
|
|
12295
|
+
} catch (_) { canWebGL = false; }
|
|
12296
|
+
if (!canWebGL) return;
|
|
12297
|
+
const cards = Array.from(document.querySelectorAll("img[data-cmp-celeb-img], span[data-cmp-celeb-img]"));
|
|
12298
|
+
if (cards.length === 0) return;
|
|
12299
|
+
const want = [];
|
|
12300
|
+
for (const el of cards) {
|
|
12301
|
+
const id = el.getAttribute("data-cmp-celeb-img");
|
|
12302
|
+
if (!id) continue;
|
|
12303
|
+
// Already 3D-rendered? Skip (cached value was injected by the
|
|
12304
|
+
// first paint). The check leans on the dataset marker rather
|
|
12305
|
+
// than reading the src so a re-render that fell back to 2D
|
|
12306
|
+
// gets reprocessed.
|
|
12307
|
+
if (el.tagName === "IMG" && el.dataset.cmpCelebRendered === "1") continue;
|
|
12308
|
+
want.push({ id, el });
|
|
12309
|
+
}
|
|
12310
|
+
if (want.length === 0) return;
|
|
12311
|
+
// Cache hot · just paint and bail without loading three.
|
|
12312
|
+
const hot = want.filter((j) => this._celebrityAvatar3dCache.has(j.id));
|
|
12313
|
+
for (const j of hot) {
|
|
12314
|
+
const url = this._celebrityAvatar3dCache.get(j.id);
|
|
12315
|
+
this._paintCelebrityImg(j.el, url);
|
|
12316
|
+
}
|
|
12317
|
+
const cold = want.filter((j) => !this._celebrityAvatar3dCache.has(j.id));
|
|
12318
|
+
if (cold.length === 0) return;
|
|
12319
|
+
|
|
12320
|
+
// Lazy-load three + avatar-3d once per session. The two imports
|
|
12321
|
+
// race in parallel and resolve together.
|
|
12322
|
+
let THREE, av;
|
|
12323
|
+
try {
|
|
12324
|
+
[THREE, av] = await Promise.all([
|
|
12325
|
+
import("/vendor/three.module.min.js"),
|
|
12326
|
+
import("/avatar-3d.js"),
|
|
12327
|
+
]);
|
|
12328
|
+
} catch (e) {
|
|
12329
|
+
try { console.warn("[celebrity-3d] dep load failed", e); } catch (_) {}
|
|
12330
|
+
return;
|
|
12331
|
+
}
|
|
12332
|
+
// Preload every body / hair / outfit referenced by the cold
|
|
12333
|
+
// seeds before rendering · cross-model swaps need the source
|
|
12334
|
+
// GLB cached before buildAvatar3D walks the skeleton.
|
|
12335
|
+
const cfgs = cold.map((j) => ({ id: j.id, cfg: av.deriveDefaultAvatarConfig(j.id) }));
|
|
12336
|
+
const modelIds = new Set();
|
|
12337
|
+
for (const { cfg } of cfgs) {
|
|
12338
|
+
if (cfg.model) modelIds.add(cfg.model);
|
|
12339
|
+
if (cfg.hairStyle && cfg.hairStyle !== "none") modelIds.add(cfg.hairStyle);
|
|
12340
|
+
if (cfg.outfitStyle) modelIds.add(cfg.outfitStyle);
|
|
12341
|
+
}
|
|
12342
|
+
try {
|
|
12343
|
+
await Promise.all(Array.from(modelIds).map((m) => av.loadAvatar3D(m).catch(() => null)));
|
|
12344
|
+
} catch (_) { /* */ }
|
|
12345
|
+
|
|
12346
|
+
// Shared offscreen renderer · same recipe as home.html's
|
|
12347
|
+
// (transparent BG, ACES tone-map, IBL via PMREM + RoomEnv).
|
|
12348
|
+
// 112×112 is 2× retina for the 56-pixel display tile.
|
|
12349
|
+
const SIZE = 112;
|
|
12350
|
+
const off = document.createElement("canvas");
|
|
12351
|
+
off.width = SIZE * 2; off.height = SIZE * 2;
|
|
12352
|
+
let renderer;
|
|
12353
|
+
try {
|
|
12354
|
+
renderer = new THREE.WebGLRenderer({ canvas: off, antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
12355
|
+
} catch (e) {
|
|
12356
|
+
try { console.warn("[celebrity-3d] renderer init failed", e); } catch (_) {}
|
|
12357
|
+
return;
|
|
12358
|
+
}
|
|
12359
|
+
renderer.setSize(SIZE * 2, SIZE * 2, false);
|
|
12360
|
+
renderer.setClearColor(0x000000, 0);
|
|
12361
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
12362
|
+
renderer.toneMappingExposure = 1.0;
|
|
12363
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
12364
|
+
|
|
12365
|
+
const scene = new THREE.Scene();
|
|
12366
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x2a3140, 0.5));
|
|
12367
|
+
const key = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
12368
|
+
key.position.set(2, 3, 2.5);
|
|
12369
|
+
scene.add(key);
|
|
12370
|
+
const rim = new THREE.DirectionalLight(0xbfd4ff, 0.4);
|
|
12371
|
+
rim.position.set(-2, 2, -2);
|
|
12372
|
+
scene.add(rim);
|
|
12373
|
+
// Camera FOV mirrors avatar3d-editor's capturePng so the
|
|
12374
|
+
// head-anchor framing math (`applyFaceFraming`) produces the
|
|
12375
|
+
// SAME crop the agent-profile portrait does. Position is
|
|
12376
|
+
// computed per-figure right before render — face mesh is
|
|
12377
|
+
// present at that point.
|
|
12378
|
+
const camera = new THREE.PerspectiveCamera(35, 1, 0.05, 20);
|
|
12379
|
+
|
|
12380
|
+
for (const { id, cfg } of cfgs) {
|
|
12381
|
+
let figure = null;
|
|
12382
|
+
try {
|
|
12383
|
+
figure = av.buildAvatar3D(id, {
|
|
12384
|
+
model: cfg.model,
|
|
12385
|
+
hairStyle: cfg.hairStyle,
|
|
12386
|
+
outfitStyle: cfg.outfitStyle,
|
|
12387
|
+
accessory: cfg.accessory,
|
|
12388
|
+
height: 1.7,
|
|
12389
|
+
skin: cfg.skin, hair: cfg.hair, brow: cfg.brow, outfit: cfg.outfit,
|
|
12390
|
+
browStyle: cfg.browStyle, tieStyle: cfg.tieStyle,
|
|
12391
|
+
tie: cfg.tie, eye: cfg.eye,
|
|
12392
|
+
});
|
|
12393
|
+
} catch (_) { figure = null; }
|
|
12394
|
+
if (!figure) continue;
|
|
12395
|
+
// Faint 3/4 turn for visual interest, then frame on the
|
|
12396
|
+
// face mesh (same routine the agent-profile capture uses)
|
|
12397
|
+
// so every card shows the same head-and-shoulders crop.
|
|
12398
|
+
figure.rotation.y = -0.18;
|
|
12399
|
+
scene.add(figure);
|
|
12400
|
+
try {
|
|
12401
|
+
if (typeof av.applyFaceFraming === "function") {
|
|
12402
|
+
av.applyFaceFraming(camera, figure);
|
|
12403
|
+
}
|
|
12404
|
+
} catch (_) { /* fallback to constructor defaults */ }
|
|
12405
|
+
renderer.render(scene, camera);
|
|
12406
|
+
const dataUrl = renderer.domElement.toDataURL("image/png");
|
|
12407
|
+
this._celebrityAvatar3dCache.set(id, dataUrl);
|
|
12408
|
+
// Find every mounted card with this id (composer can be open
|
|
12409
|
+
// in two surfaces simultaneously in some flows) and paint.
|
|
12410
|
+
document.querySelectorAll(`[data-cmp-celeb-img="${CSS.escape(id)}"]`)
|
|
12411
|
+
.forEach((el) => this._paintCelebrityImg(el, dataUrl));
|
|
12412
|
+
scene.remove(figure);
|
|
12413
|
+
figure.traverse((n) => {
|
|
12414
|
+
if (n.material) {
|
|
12415
|
+
const ms = Array.isArray(n.material) ? n.material : [n.material];
|
|
12416
|
+
for (const m of ms) { try { m.dispose(); } catch (_) {} }
|
|
12417
|
+
}
|
|
12418
|
+
if (n.geometry) { try { n.geometry.dispose(); } catch (_) {} }
|
|
12419
|
+
});
|
|
12420
|
+
}
|
|
12421
|
+
renderer.dispose();
|
|
12422
|
+
},
|
|
12423
|
+
|
|
12424
|
+
/** Internal · set a celebrity-card image to a dataURL, swapping
|
|
12425
|
+
* the placeholder `<span>` for an `<img>` when the initial
|
|
12426
|
+
* paint used the fallback dot. Marks the node so the next
|
|
12427
|
+
* hydrate pass skips it. */
|
|
12428
|
+
_paintCelebrityImg(el, dataUrl) {
|
|
12429
|
+
if (!el || !dataUrl) return;
|
|
12430
|
+
if (el.tagName === "IMG") {
|
|
12431
|
+
el.src = dataUrl;
|
|
12432
|
+
el.dataset.cmpCelebRendered = "1";
|
|
12433
|
+
return;
|
|
12434
|
+
}
|
|
12435
|
+
// Replace the fallback span with an img inside the same `.cmp-celeb-av` tile.
|
|
12436
|
+
const img = document.createElement("img");
|
|
12437
|
+
img.className = "cmp-celeb-img";
|
|
12438
|
+
img.dataset.cmpCelebImg = el.getAttribute("data-cmp-celeb-img") || "";
|
|
12439
|
+
img.src = dataUrl;
|
|
12440
|
+
img.alt = "";
|
|
12441
|
+
img.dataset.cmpCelebRendered = "1";
|
|
12442
|
+
if (el.parentNode) el.parentNode.replaceChild(img, el);
|
|
12443
|
+
},
|
|
12444
|
+
|
|
10700
12445
|
/** Click handler · kick the full-mode persona builder with the
|
|
10701
12446
|
* celebrity's seed description, tag the live job with the seed
|
|
10702
12447
|
* id so the save-success callback can mark it consumed. */
|
|
@@ -10942,8 +12687,8 @@
|
|
|
10942
12687
|
* topped up via the LLM-driven `_topUpCelebritySeeds` path.
|
|
10943
12688
|
*
|
|
10944
12689
|
* Per-entry shape:
|
|
10945
|
-
* · id · kebab-slug · doubles as `
|
|
10946
|
-
* (deterministic
|
|
12690
|
+
* · id · kebab-slug · doubles as `Avatar3DSnap` seed
|
|
12691
|
+
* (deterministic 3D voxel portrait)
|
|
10947
12692
|
* · name · verbatim, no i18n (proper nouns)
|
|
10948
12693
|
* · roleTag · short mono tag · kept English to match the
|
|
10949
12694
|
* mono kicker register used elsewhere
|
|
@@ -11361,8 +13106,13 @@
|
|
|
11361
13106
|
|
|
11362
13107
|
/** Preview card · all generated fields editable inline. */
|
|
11363
13108
|
renderAgentSpecPreviewHtml(spec) { const seed = this.agentSpecAvatarSeed;
|
|
11364
|
-
|
|
11365
|
-
|
|
13109
|
+
// Avatar paints async via Avatar3DSnap (see the post-render
|
|
13110
|
+
// hydration that follows). The frame mounts with a cached
|
|
13111
|
+
// snap if hot, otherwise empty + a CSS placeholder spinner.
|
|
13112
|
+
const snap = window.Avatar3DSnap;
|
|
13113
|
+
const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
|
|
13114
|
+
const avatarSvg = cachedSnap
|
|
13115
|
+
? `<img src="${cachedSnap}" alt="">`
|
|
11366
13116
|
: `<div class="ag-prev-av-empty">—</div>`;
|
|
11367
13117
|
// Reachable models only · filter by `/api/models` cache so the
|
|
11368
13118
|
// user can't pick a model their active credential can't route
|
|
@@ -11807,9 +13557,14 @@
|
|
|
11807
13557
|
this.personaJob.finalGuessRoleTag = data.guessRoleTag || "director";
|
|
11808
13558
|
// Avatar seed · matches Signal-mode's pattern. Random per
|
|
11809
13559
|
// build, user can re-roll on the save card.
|
|
11810
|
-
this.personaJob.avatarSeed = (window.
|
|
11811
|
-
? window.
|
|
13560
|
+
this.personaJob.avatarSeed = (window.Avatar3DSnap && window.Avatar3DSnap.randomSeed)
|
|
13561
|
+
? window.Avatar3DSnap.randomSeed()
|
|
11812
13562
|
: null;
|
|
13563
|
+
// Warm the 3D snap cache so the persona save overlay's
|
|
13564
|
+
// portrait paints instantly when it opens.
|
|
13565
|
+
if (this.personaJob.avatarSeed && window.Avatar3DSnap && typeof window.Avatar3DSnap.generate === "function") {
|
|
13566
|
+
window.Avatar3DSnap.generate(this.personaJob.avatarSeed).catch(() => { /* */ });
|
|
13567
|
+
}
|
|
11813
13568
|
this._closePersonaSse();
|
|
11814
13569
|
this._stopPersonaTick();
|
|
11815
13570
|
this._personaRender();
|
|
@@ -12436,6 +14191,11 @@
|
|
|
12436
14191
|
const e = await r.json().catch(() => ({}));
|
|
12437
14192
|
throw new Error(e.error || ("HTTP " + r.status));
|
|
12438
14193
|
}
|
|
14194
|
+
// Swap the 8-bit avatar for a 3D screenshot (best-effort) before the
|
|
14195
|
+
// roster refresh below picks the new director up.
|
|
14196
|
+
const saved = await r.json().catch(() => null);
|
|
14197
|
+
const newId = saved && (saved.id || (saved.agent && saved.agent.id));
|
|
14198
|
+
if (newId) await this.apply3dPortrait(newId);
|
|
12439
14199
|
// Capture the celebrity seed id (if any) BEFORE nulling
|
|
12440
14200
|
// personaJob below · `_markCelebritySeedConsumed` needs
|
|
12441
14201
|
// it to update localStorage. Tagging happens in
|
|
@@ -12521,9 +14281,19 @@
|
|
|
12521
14281
|
const userModel = this.loadAgentComposerModel();
|
|
12522
14282
|
if (userModel && MODEL_LABELS[userModel]) this.agentSpec.modelV = userModel;
|
|
12523
14283
|
}
|
|
12524
|
-
this.agentSpecAvatarSeed = (window.
|
|
12525
|
-
? window.
|
|
14284
|
+
this.agentSpecAvatarSeed = (window.Avatar3DSnap && window.Avatar3DSnap.randomSeed)
|
|
14285
|
+
? window.Avatar3DSnap.randomSeed()
|
|
12526
14286
|
: null;
|
|
14287
|
+
// Kick off the 3D portrait render so the cache is hot by the
|
|
14288
|
+
// time the preview card mounts + the save handler reads it.
|
|
14289
|
+
if (this.agentSpecAvatarSeed && window.Avatar3DSnap && typeof window.Avatar3DSnap.generate === "function") {
|
|
14290
|
+
const seed = this.agentSpecAvatarSeed;
|
|
14291
|
+
window.Avatar3DSnap.generate(seed).then((dataUrl) => {
|
|
14292
|
+
if (!dataUrl || this.agentSpecAvatarSeed !== seed) return;
|
|
14293
|
+
const f = document.querySelector(".ag-prev-av-frame");
|
|
14294
|
+
if (f) f.innerHTML = `<img src="${dataUrl}" alt="">`;
|
|
14295
|
+
}).catch(() => { /* */ });
|
|
14296
|
+
}
|
|
12527
14297
|
this.agentSpecError = null;
|
|
12528
14298
|
} catch (e) {
|
|
12529
14299
|
const isAbort = (e && (e.name === "AbortError" || /aborted/i.test(String(e.message))));
|
|
@@ -12614,12 +14384,14 @@
|
|
|
12614
14384
|
throw new Error(e.error || ("HTTP " + r.status));
|
|
12615
14385
|
}
|
|
12616
14386
|
const j = await r.json();
|
|
14387
|
+
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
14388
|
+
// 3D screenshot avatar (before refresh, so the roster shows it).
|
|
14389
|
+
if (newId) await this.apply3dPortrait(newId);
|
|
12617
14390
|
await this.refreshAgents?.();
|
|
12618
14391
|
this.agentSpec = null;
|
|
12619
14392
|
this.agentSpecAvatarSeed = null;
|
|
12620
14393
|
this.clearAgentComposerDraft();
|
|
12621
14394
|
this.composerMode = "room";
|
|
12622
|
-
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
12623
14395
|
// Land on the new agent's full profile (mirrors the prior
|
|
12624
14396
|
// inline-preview save flow's post-success navigation).
|
|
12625
14397
|
if (newId && typeof window.boardroomFocusAgent === "function") {
|
|
@@ -12653,14 +14425,19 @@
|
|
|
12653
14425
|
},
|
|
12654
14426
|
|
|
12655
14427
|
rerollAgentSpecAvatar() {
|
|
12656
|
-
|
|
12657
|
-
|
|
12658
|
-
|
|
12659
|
-
|
|
12660
|
-
// renderSeedSvg helper, hence the previous reroll silently
|
|
12661
|
-
// failed.
|
|
14428
|
+
const snap = window.Avatar3DSnap;
|
|
14429
|
+
if (!snap || !snap.randomSeed || !snap.generate) return;
|
|
14430
|
+
const seed = snap.randomSeed();
|
|
14431
|
+
this.agentSpecAvatarSeed = seed;
|
|
12662
14432
|
const frame = document.querySelector(".ag-prev-av-frame");
|
|
12663
|
-
if (frame)
|
|
14433
|
+
if (!frame) return;
|
|
14434
|
+
frame.innerHTML = '<div class="ag-prev-av-empty">…</div>';
|
|
14435
|
+
snap.generate(seed).then((dataUrl) => {
|
|
14436
|
+
if (!dataUrl) return;
|
|
14437
|
+
if (this.agentSpecAvatarSeed !== seed) return; // user rolled again
|
|
14438
|
+
const f2 = document.querySelector(".ag-prev-av-frame");
|
|
14439
|
+
if (f2) f2.innerHTML = `<img src="${dataUrl}" alt="">`;
|
|
14440
|
+
}).catch(() => { /* */ });
|
|
12664
14441
|
},
|
|
12665
14442
|
|
|
12666
14443
|
discardAgentSpec() {
|
|
@@ -12690,6 +14467,28 @@
|
|
|
12690
14467
|
this._runAgentSpecGeneration(desc);
|
|
12691
14468
|
},
|
|
12692
14469
|
|
|
14470
|
+
/** Give a freshly-created director a 3D screenshot avatar. Renders the
|
|
14471
|
+
* director's deterministic default 3D config (derived from its id — the
|
|
14472
|
+
* same look the room + editor show) to a head-and-shoulders PNG and PATCHes
|
|
14473
|
+
* it as the avatar, plus persists the config. Best-effort: on any failure
|
|
14474
|
+
* the director keeps the 8-bit SVG avatar it was created with. Call AFTER
|
|
14475
|
+
* creation (id known) and BEFORE refreshAgents so the roster picks it up. */
|
|
14476
|
+
async apply3dPortrait(agentId) {
|
|
14477
|
+
try {
|
|
14478
|
+
if (!agentId || !window.Avatar3D || typeof window.renderAvatar3DPortrait !== "function") return;
|
|
14479
|
+
const cfg = window.Avatar3D.deriveDefaultAvatarConfig(agentId);
|
|
14480
|
+
const png = await window.renderAvatar3DPortrait(cfg);
|
|
14481
|
+
if (!png) return;
|
|
14482
|
+
await fetch("/api/agents/" + encodeURIComponent(agentId), {
|
|
14483
|
+
method: "PATCH",
|
|
14484
|
+
headers: { "content-type": "application/json" },
|
|
14485
|
+
body: JSON.stringify({ avatarPath: png, avatar3d: cfg }),
|
|
14486
|
+
});
|
|
14487
|
+
} catch (e) {
|
|
14488
|
+
console.warn("[avatar3d] new-director portrait failed; keeping fallback avatar", e);
|
|
14489
|
+
}
|
|
14490
|
+
},
|
|
14491
|
+
|
|
12693
14492
|
/** Read inline-edited values from the Signal preview card and
|
|
12694
14493
|
* POST to /api/agents. Persona-mode save is a separate path
|
|
12695
14494
|
* (`openPersonaConfirmOverlay` → manual-config overlay → its
|
|
@@ -12710,10 +14509,15 @@
|
|
|
12710
14509
|
instruction: read("instruction").trim(),
|
|
12711
14510
|
modelV: read("modelV").trim(),
|
|
12712
14511
|
};
|
|
12713
|
-
// Avatar —
|
|
14512
|
+
// Avatar — rendered 3D portrait from the current seed, embedded
|
|
14513
|
+
// as a PNG data URL. Render is async (lazy three.js init) but
|
|
14514
|
+
// we already kicked it off when the user landed on the preview
|
|
14515
|
+
// card, so the seed should be hot in the per-seed cache by the
|
|
14516
|
+
// time they click save.
|
|
12714
14517
|
let avatarPath = null;
|
|
12715
|
-
|
|
12716
|
-
|
|
14518
|
+
const snap = window.Avatar3DSnap;
|
|
14519
|
+
if (snap && this.agentSpecAvatarSeed && typeof snap.generate === "function") {
|
|
14520
|
+
try { avatarPath = await snap.generate(this.agentSpecAvatarSeed); } catch (_) { avatarPath = null; }
|
|
12717
14521
|
}
|
|
12718
14522
|
// Ability axes · lifted from the spec produced by /api/agents/generate-spec.
|
|
12719
14523
|
// The server validates + clamps + falls back to a heuristic if missing.
|
|
@@ -12737,6 +14541,11 @@
|
|
|
12737
14541
|
throw new Error(e.error || ("HTTP " + r.status));
|
|
12738
14542
|
}
|
|
12739
14543
|
const j = await r.json();
|
|
14544
|
+
// POST /api/agents returns the agent record directly (not wrapped).
|
|
14545
|
+
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
14546
|
+
// Replace the 8-bit avatar with a 3D screenshot before the roster
|
|
14547
|
+
// refresh so the sidebar + profile show the 3D portrait immediately.
|
|
14548
|
+
if (newId) await this.apply3dPortrait(newId);
|
|
12740
14549
|
// Refresh local agent catalog so the new director shows up
|
|
12741
14550
|
// in pickers + sidebar immediately.
|
|
12742
14551
|
await this.refreshAgents?.();
|
|
@@ -12746,8 +14555,6 @@
|
|
|
12746
14555
|
// a future visit to "+ New Agent" should land on a fresh textarea.
|
|
12747
14556
|
this.clearAgentComposerDraft();
|
|
12748
14557
|
this.composerMode = "room";
|
|
12749
|
-
// POST /api/agents returns the agent record directly (not wrapped).
|
|
12750
|
-
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
12751
14558
|
// Land the user on the new agent's full profile page · also
|
|
12752
14559
|
// switches the sidebar to the Agents tab and persists the
|
|
12753
14560
|
// sub-state so a refresh keeps them on the same agent.
|
|
@@ -12817,9 +14624,8 @@
|
|
|
12817
14624
|
<span class="brief-picker-num">${this.escape(num)}</span>
|
|
12818
14625
|
<span class="brief-picker-main">
|
|
12819
14626
|
<span class="brief-picker-title">${this.escape(b.title || "(untitled)")}</span>
|
|
12820
|
-
${subtitle ? `<span class="brief-picker-sub">${this.escape(subtitle)}</span>` : ""}
|
|
14627
|
+
${(subtitle || filedLabel) ? `<span class="brief-picker-subrow">${subtitle ? `<span class="brief-picker-sub">${this.escape(subtitle)}</span>` : ""}${filedLabel ? `<span class="brief-picker-time">${this.escape(filedLabel)}</span>` : ""}</span>` : ""}
|
|
12821
14628
|
</span>
|
|
12822
|
-
${filedLabel ? `<span class="brief-picker-time">${this.escape(filedLabel)}</span>` : ""}
|
|
12823
14629
|
<span class="brief-picker-arrow">↗</span>
|
|
12824
14630
|
</a>
|
|
12825
14631
|
`;
|
|
@@ -13723,10 +15529,27 @@
|
|
|
13723
15529
|
// (instead of relying on agent-overlay's autoTagAvatars regex) is
|
|
13724
15530
|
// required for custom agents whose avatarPath is a data: URL —
|
|
13725
15531
|
// the regex only matches `/avatars/*.svg`.
|
|
15532
|
+
// Head-cast avatars · each director wrapped in a span so a
|
|
15533
|
+
// small thread-trigger badge can pin to the avatar's bottom-
|
|
15534
|
+
// right. Avatar click stays as the agent-overlay open (existing
|
|
15535
|
+
// data-agent behavior); the badge gets its own click target
|
|
15536
|
+
// (data-thread-trigger) so the two affordances don't fight.
|
|
15537
|
+
// Chair is never threadable — filter on roleKind. Allow every
|
|
15538
|
+
// status (live, paused, adjourned) — threads are independent
|
|
15539
|
+
// rooms; the parent's status doesn't gate them. Only block
|
|
15540
|
+
// when this room itself is a thread (no nested threads).
|
|
15541
|
+
const canThreadHere = r.kind !== "thread";
|
|
15542
|
+
const threadTipBase = this._t("thread_trigger") || "// thread";
|
|
15543
|
+
const threadIcon = this._threadTriggerIconSvg();
|
|
13726
15544
|
const castImgs = this.currentMembers
|
|
13727
15545
|
.map((a) => {
|
|
13728
15546
|
const id = this.escape(a.id);
|
|
13729
|
-
|
|
15547
|
+
const img = `<img class="head-cast-av" data-agent="${id}" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`;
|
|
15548
|
+
const showBadge = canThreadHere && a.roleKind !== "moderator";
|
|
15549
|
+
const badge = showBadge
|
|
15550
|
+
? `<button type="button" class="head-cast-thread" data-thread-trigger="${id}" data-no-agent-overlay title="${this.escape(this._t("thread_trigger_tip", { name: a.name || "" }) || threadTipBase)}" aria-label="${this.escape(this._t("thread_trigger_tip", { name: a.name || "" }) || threadTipBase)}">${threadIcon}</button>`
|
|
15551
|
+
: "";
|
|
15552
|
+
return `<span class="head-cast-wrap">${img}${badge}</span>`;
|
|
13730
15553
|
})
|
|
13731
15554
|
.join("");
|
|
13732
15555
|
const castCount = this.currentMembers.length;
|
|
@@ -13799,9 +15622,13 @@
|
|
|
13799
15622
|
</div>
|
|
13800
15623
|
<div class="head-actions">
|
|
13801
15624
|
<a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
|
|
15625
|
+
<a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
|
|
15626
|
+
${this._isReplayActiveForRoom(r.id)
|
|
15627
|
+
? `<a href="#" class="replay-stop-btn" data-vr-header-stop aria-label="${this.escape(this._t("head_replay_stop_label") || "Stop")}">[ <span class="replay-stop-icon">■</span> ${this.escape(this._t("head_replay_stop_label") || "Stop")} ]</a>`
|
|
15628
|
+
: ""}
|
|
13802
15629
|
<div class="head-cast">${castHtml}</div>
|
|
13803
15630
|
<a href="#" class="head-icon-btn head-add-cast" data-cast-edit-trigger data-tip="${this.escape(this._t("head_add_cast_tip"))}" aria-label="${this.escape(this._t("head_add_cast_label"))}"></a>
|
|
13804
|
-
<a href="#" class="
|
|
15631
|
+
<a href="#" class="head-icon-btn head-threads" data-threads-trigger data-tip="${this.escape(this._t("head_threads_tip") || "All private threads in this room")}" aria-label="${this.escape(this._t("head_threads_label") || "All threads")}"></a>
|
|
13805
15632
|
<a href="#" class="head-icon-btn head-divergence" data-divergence-open data-tip="See how widely the room has explored your question" aria-label="Coverage check"></a>
|
|
13806
15633
|
${this.currentBrief
|
|
13807
15634
|
? (() => {
|
|
@@ -13851,10 +15678,12 @@
|
|
|
13851
15678
|
? `<a href="#" class="head-icon-btn head-followup" data-room-followup data-tip="${this.escape(this._t("adj_followup_label"))}" aria-label="${this.escape(this._t("adj_followup_label"))}"></a>`
|
|
13852
15679
|
: `<a href="#" class="head-icon-btn head-adjourn" data-adjourn data-tip="${this.escape(this._t("ib_adjourn_tip"))}" aria-label="${this.escape(this._t("ib_adjourn_label"))}"></a>`}
|
|
13853
15680
|
${(r.deliveryMode === "voice"
|
|
13854
|
-
&& r.status === "live"
|
|
13855
15681
|
&& window.BoardroomRecorder
|
|
13856
15682
|
&& typeof window.BoardroomRecorder.isAvailable === "function"
|
|
13857
|
-
&& window.BoardroomRecorder.isAvailable()
|
|
15683
|
+
&& window.BoardroomRecorder.isAvailable()
|
|
15684
|
+
&& (r.status === "live"
|
|
15685
|
+
|| this._isReplayActiveForRoom(r.id)
|
|
15686
|
+
|| window.BoardroomRecorder.isRecording())) ? (() => {
|
|
13858
15687
|
const isRec = window.BoardroomRecorder.isRecording();
|
|
13859
15688
|
const tip = isRec ? this._t("head_record_stop_tip") : this._t("head_record_tip");
|
|
13860
15689
|
const cls = "head-icon-btn head-record" + (isRec ? " is-recording" : "");
|
|
@@ -16349,17 +18178,30 @@
|
|
|
16349
18178
|
// profile" kept regressing for custom directors.
|
|
16350
18179
|
const agentTag = !isUser && author ? ` data-agent="${this.escape(author.id)}"` : "";
|
|
16351
18180
|
// User avatar mirrors the preference-overlay setting · when
|
|
16352
|
-
// prefs.
|
|
16353
|
-
//
|
|
16354
|
-
//
|
|
16355
|
-
//
|
|
18181
|
+
// prefs.avatarUrl (a captured 3D PNG) is present we use that
|
|
18182
|
+
// directly. Otherwise, if the avatarSeed has a cached 3D snap
|
|
18183
|
+
// we paint it synchronously; if not we fall back to the
|
|
18184
|
+
// initial-letter chip (the async snap render kicks in on the
|
|
18185
|
+
// next renderUserBlock paint).
|
|
16356
18186
|
let userAvHtml;
|
|
16357
18187
|
if (isUser) {
|
|
18188
|
+
const url = this.prefs?.avatarUrl;
|
|
16358
18189
|
const seed = this.prefs?.avatarSeed;
|
|
16359
|
-
|
|
16360
|
-
|
|
18190
|
+
const snap = window.Avatar3DSnap;
|
|
18191
|
+
const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
|
|
18192
|
+
const src = url || cachedSnap || "";
|
|
18193
|
+
if (src) {
|
|
18194
|
+
userAvHtml = `<img class="msg-av" src="${this.escape(src)}" alt="">`;
|
|
16361
18195
|
} else {
|
|
16362
18196
|
userAvHtml = `<div class="msg-av">${this.escape((this.prefs?.name || "Y").charAt(0).toUpperCase())}</div>`;
|
|
18197
|
+
// Kick off the async render so future paints hit cache.
|
|
18198
|
+
if (seed && snap && typeof snap.generate === "function") {
|
|
18199
|
+
snap.generate(seed).then(() => {
|
|
18200
|
+
if (typeof this.renderChat === "function") {
|
|
18201
|
+
try { this.renderChat(); } catch (_) {}
|
|
18202
|
+
}
|
|
18203
|
+
}).catch(() => { /* */ });
|
|
18204
|
+
}
|
|
16363
18205
|
}
|
|
16364
18206
|
}
|
|
16365
18207
|
const avatarHtml = isUser
|
|
@@ -16382,7 +18224,13 @@
|
|
|
16382
18224
|
// the text-mode "two directors flashing" race vanishes.
|
|
16383
18225
|
const isDirectorMsg = !isUser && !isChair;
|
|
16384
18226
|
const activeId = this.currentActiveMessageId || null;
|
|
16385
|
-
|
|
18227
|
+
// `currentActiveMessageId` tracks the MAIN room's currently-
|
|
18228
|
+
// visible speaker · doesn't apply to thread windows (which run
|
|
18229
|
+
// their own SSE / pump with a different message id pool). When
|
|
18230
|
+
// rendering inside a thread, skip the gate so the thread's own
|
|
18231
|
+
// streaming bubble shows the dots + animation normally.
|
|
18232
|
+
const speakingGate = this._renderingThread
|
|
18233
|
+
|| !isDirectorMsg || !activeId || m.id === activeId;
|
|
16386
18234
|
const wantStreaming = !!streaming && speakingGate;
|
|
16387
18235
|
const stateCls = [];
|
|
16388
18236
|
if (wantStreaming) stateCls.push("streaming");
|
|
@@ -16417,7 +18265,18 @@
|
|
|
16417
18265
|
const webSearchUsed = !!(m.meta && m.meta.webSearchUsed);
|
|
16418
18266
|
const webSearchQuery = (m.meta && typeof m.meta.webSearchQuery === "string") ? m.meta.webSearchQuery : "";
|
|
16419
18267
|
const webSearchSources = (m.meta && Array.isArray(m.meta.webSearchSources)) ? m.meta.webSearchSources : [];
|
|
16420
|
-
|
|
18268
|
+
// Thread mode promotes the director's web-search into a
|
|
18269
|
+
// standalone `.msg-tool-card` rendered ABOVE the bubble,
|
|
18270
|
+
// mirroring the chair's tool-use card in main rooms (chair.ts
|
|
18271
|
+
// emits a tool-use message; here the director's webSearch
|
|
18272
|
+
// lives on the same message's meta, so we synthesise the same
|
|
18273
|
+
// visual treatment in-place). In the main room layout we
|
|
18274
|
+
// keep the original inline-badge-in-meta + sources-panel-
|
|
18275
|
+
// under-bubble pattern (matches every other director bubble
|
|
18276
|
+
// in the room and doesn't compete with the chair's tool-use
|
|
18277
|
+
// cards above).
|
|
18278
|
+
const renderWsAsCard = this._renderingThread === true && webSearchUsed;
|
|
18279
|
+
const webSearchBadge = (!renderWsAsCard && webSearchUsed)
|
|
16421
18280
|
? (() => {
|
|
16422
18281
|
const n = webSearchSources.length;
|
|
16423
18282
|
const title = this.escape(this._t("msg_ws_title", { query: webSearchQuery, n }));
|
|
@@ -16427,7 +18286,7 @@
|
|
|
16427
18286
|
return `<button type="button" class="msg-web-search" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" title="${title}">🔍 ${label}</button>`;
|
|
16428
18287
|
})()
|
|
16429
18288
|
: "";
|
|
16430
|
-
const webSearchSourcesPanel = webSearchUsed && webSearchSources.length > 0
|
|
18289
|
+
const webSearchSourcesPanel = (!renderWsAsCard && webSearchUsed && webSearchSources.length > 0)
|
|
16431
18290
|
? `<div class="msg-web-search-sources" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
|
|
16432
18291
|
<div class="msg-web-search-query"><span class="msg-web-search-query-label">${this.escape(this._t("msg_ws_query_label"))}</span><span class="msg-web-search-query-text">${this.escape(webSearchQuery)}</span></div>
|
|
16433
18292
|
<ol class="msg-web-search-list">
|
|
@@ -16442,6 +18301,76 @@
|
|
|
16442
18301
|
</ol>
|
|
16443
18302
|
</div>`
|
|
16444
18303
|
: "";
|
|
18304
|
+
// Thread mode · synthesise a `.msg-tool-card` for the
|
|
18305
|
+
// director's web-search and surface it ABOVE the bubble.
|
|
18306
|
+
// Visual shape mirrors `chair.ts`'s standalone tool-use card:
|
|
18307
|
+
// banner kicker + body row (status mark + "Searched 'query'"
|
|
18308
|
+
// text + caret) + collapsible sources list + expand button.
|
|
18309
|
+
// Same `data-msg-ws-toggle` / `data-msg-ws-sources` attribute
|
|
18310
|
+
// pair the chair card uses → the existing toggle handler at
|
|
18311
|
+
// app.js:21376 works without modification. Always status=done
|
|
18312
|
+
// since the message meta is set after the search completed.
|
|
18313
|
+
const threadWebSearchCard = renderWsAsCard
|
|
18314
|
+
? (() => {
|
|
18315
|
+
const n = webSearchSources.length;
|
|
18316
|
+
const hasSources = n > 0;
|
|
18317
|
+
const tail = "";
|
|
18318
|
+
const stamp = !hasSources
|
|
18319
|
+
? this.escape(this._t("msg_ws_failed"))
|
|
18320
|
+
: n === 1
|
|
18321
|
+
? this.escape(this._t("msg_ws_done_one", { tail }))
|
|
18322
|
+
: this.escape(this._t("msg_ws_done", { n, tail }));
|
|
18323
|
+
const stampClass = hasSources ? "status-done" : "status-failed";
|
|
18324
|
+
const mark = hasSources ? "✓" : "⚠";
|
|
18325
|
+
const bodyText = this.escape(this._t("msg_ws_card_body", { query: webSearchQuery }));
|
|
18326
|
+
const caret = hasSources ? `<span class="msg-tool-caret" aria-hidden="true">▸</span>` : "";
|
|
18327
|
+
const bodyToggleAttrs = hasSources
|
|
18328
|
+
? ` data-msg-ws-toggle data-message-id="${this.escape(m.id)}" role="button" tabindex="0" aria-label="${this.escape(this._t("msg_ws_toggle"))}"`
|
|
18329
|
+
: "";
|
|
18330
|
+
const sourcesList = hasSources
|
|
18331
|
+
? `
|
|
18332
|
+
<ol class="msg-tool-sources-list" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
|
|
18333
|
+
${webSearchSources.map((s, i) => {
|
|
18334
|
+
const url = s && typeof s.url === "string" ? s.url : "";
|
|
18335
|
+
const title = s && typeof s.title === "string" ? s.title : url;
|
|
18336
|
+
const desc = s && typeof s.description === "string" ? s.description : "";
|
|
18337
|
+
const host = this.hostnameOf(url);
|
|
18338
|
+
const numStr = String(i + 1).padStart(2, "0");
|
|
18339
|
+
return `
|
|
18340
|
+
<li>
|
|
18341
|
+
<span class="msg-tool-sources-num">${numStr}</span>
|
|
18342
|
+
<a href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" class="msg-tool-sources-title">
|
|
18343
|
+
<span class="msg-tool-sources-title-text">${this.escape(title)}</span>
|
|
18344
|
+
<span class="msg-tool-sources-ext" aria-hidden="true">↗</span>
|
|
18345
|
+
</a>
|
|
18346
|
+
<span class="msg-tool-sources-host">${this.escape(host)}</span>
|
|
18347
|
+
${desc ? `<span class="msg-tool-sources-desc">${this.escape(desc)}</span>` : ""}
|
|
18348
|
+
</li>
|
|
18349
|
+
`;
|
|
18350
|
+
}).join("")}
|
|
18351
|
+
</ol>
|
|
18352
|
+
<button type="button" class="msg-tool-sources-expand" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" aria-label="${this.escape(this._t("msg_ws_toggle"))}">
|
|
18353
|
+
<span class="msg-tool-sources-expand-icon" aria-hidden="true">▾</span>
|
|
18354
|
+
<span class="msg-tool-sources-expand-show">${this.escape(this._t("msg_ws_expand_show", { n }))}</span>
|
|
18355
|
+
<span class="msg-tool-sources-expand-hide">${this.escape(this._t("msg_ws_expand_hide"))}</span>
|
|
18356
|
+
</button>`
|
|
18357
|
+
: "";
|
|
18358
|
+
return `
|
|
18359
|
+
<div class="msg-tool-card ${stampClass} thread-tool-card" data-message-id="${this.escape(m.id)}">
|
|
18360
|
+
<div class="msg-tool-banner">
|
|
18361
|
+
<span class="msg-tool-banner-tag">${this.escape(this._t("msg_ws_banner"))}</span>
|
|
18362
|
+
<span class="msg-tool-banner-stamp">${stamp}</span>
|
|
18363
|
+
</div>
|
|
18364
|
+
<div class="msg-tool-card-body${hasSources ? " is-toggle" : ""}"${bodyToggleAttrs}>
|
|
18365
|
+
<span class="msg-tool-mark" aria-hidden="true">${mark}</span>
|
|
18366
|
+
<span class="msg-tool-card-text">${bodyText}</span>
|
|
18367
|
+
${caret}
|
|
18368
|
+
</div>
|
|
18369
|
+
${sourcesList}
|
|
18370
|
+
</div>
|
|
18371
|
+
`;
|
|
18372
|
+
})()
|
|
18373
|
+
: "";
|
|
16445
18374
|
|
|
16446
18375
|
// Round-end card · render even DURING streaming so the user sees
|
|
16447
18376
|
// a skeleton placeholder immediately after the chair's ping
|
|
@@ -16511,9 +18440,11 @@
|
|
|
16511
18440
|
${ctxBadge}
|
|
16512
18441
|
<span class="msg-time">${this.timeFmt(m.createdAt)}</span>
|
|
16513
18442
|
</div>
|
|
18443
|
+
${threadWebSearchCard}
|
|
16514
18444
|
<div class="msg-bubble">${bubbleHtml}</div>
|
|
16515
18445
|
${webSearchSourcesPanel}
|
|
16516
18446
|
</div>
|
|
18447
|
+
${this._messageToolbarHtml(m, isUser, isChair, excusedMember, author)}
|
|
16517
18448
|
</article>
|
|
16518
18449
|
${roundEndCard}
|
|
16519
18450
|
${roundPromptCard}
|
|
@@ -17171,8 +19102,12 @@
|
|
|
17171
19102
|
members.push({
|
|
17172
19103
|
id: "__user__",
|
|
17173
19104
|
name: prefs.name || "You",
|
|
17174
|
-
|
|
19105
|
+
// Prefer the 3D portrait PNG; the seat sprite shows it directly.
|
|
19106
|
+
avatarPath: prefs.avatarUrl || null,
|
|
17175
19107
|
__seed: prefs.avatarSeed || null,
|
|
19108
|
+
// When the user has a saved 3D avatar, the room renders them as a
|
|
19109
|
+
// full 3D figure (like directors) rather than a flat billboard.
|
|
19110
|
+
avatar3d: prefs.avatar3d || null,
|
|
17176
19111
|
__isUser: true,
|
|
17177
19112
|
});
|
|
17178
19113
|
}
|
|
@@ -17465,6 +19400,38 @@
|
|
|
17465
19400
|
return { id: null, state: null };
|
|
17466
19401
|
},
|
|
17467
19402
|
|
|
19403
|
+
/** Is `authorId`'s TTS audio actually PRODUCING SOUND right now? Used by
|
|
19404
|
+
* the 3D room to sync the talking-mouth animation to real audio — the
|
|
19405
|
+
* "speaking" stage state can lead the audio (a streaming-text turn shows
|
|
19406
|
+
* "speaking" before its TTS chunk starts), which made mouths move
|
|
19407
|
+
* silently. This reads the live <audio> element, so it's frame-accurate.
|
|
19408
|
+
* Read each frame from voice-3d's tick (cheap · 0-2 queues). */
|
|
19409
|
+
isSpeakerAudible(authorId) {
|
|
19410
|
+
if (!authorId) return false;
|
|
19411
|
+
// Voice-replay (adjourned playback) drives the seat itself · audible
|
|
19412
|
+
// exactly when its playback state is "speaking".
|
|
19413
|
+
const replay = (typeof window !== "undefined" && window.boardroomVoiceReplay
|
|
19414
|
+
&& typeof window.boardroomVoiceReplay.getActive === "function")
|
|
19415
|
+
? window.boardroomVoiceReplay.getActive() : null;
|
|
19416
|
+
if (replay && replay.authorId) {
|
|
19417
|
+
return replay.authorId === authorId && replay.state === "speaking";
|
|
19418
|
+
}
|
|
19419
|
+
// Live room · a voice queue for this author whose <audio> is playing.
|
|
19420
|
+
const qs = this.voiceQueues;
|
|
19421
|
+
if (qs) {
|
|
19422
|
+
for (const k in qs) {
|
|
19423
|
+
const vq = qs[k];
|
|
19424
|
+
if (!vq || vq.playState !== "playing" || vq.authorId !== authorId) continue;
|
|
19425
|
+
const a = vq.audio;
|
|
19426
|
+
// The "playing" flag is set when audio.play() is invoked; confirm the
|
|
19427
|
+
// element is genuinely running (not paused/ended/buffering at 0).
|
|
19428
|
+
if (!a) return true; // flagged playing, no element to inspect → trust it
|
|
19429
|
+
if (!a.paused && !a.ended && a.currentTime > 0) return true;
|
|
19430
|
+
}
|
|
19431
|
+
}
|
|
19432
|
+
return false;
|
|
19433
|
+
},
|
|
19434
|
+
|
|
17468
19435
|
/** Stage SFX driver · used by BOTH the 3D delegate path and the
|
|
17469
19436
|
* legacy 2D path so the thinking-blip loop + speaker-change
|
|
17470
19437
|
* chime fire regardless of which stage renderer is active.
|
|
@@ -17510,35 +19477,23 @@
|
|
|
17510
19477
|
const floorMode = VALID_FLOORS.includes(tone) ? tone : "constructive";
|
|
17511
19478
|
stage.setAttribute("data-floor", floorMode);
|
|
17512
19479
|
|
|
17513
|
-
// ── 3D stage
|
|
17514
|
-
//
|
|
17515
|
-
//
|
|
17516
|
-
//
|
|
17517
|
-
//
|
|
17518
|
-
//
|
|
17519
|
-
//
|
|
17520
|
-
const
|
|
17521
|
-
|
|
17522
|
-
catch (_) { return true; }
|
|
17523
|
-
})();
|
|
19480
|
+
// ── 3D stage · the only path now ──────────────────────────
|
|
19481
|
+
// The legacy 2D SVG stage was retired · the voice room is
|
|
19482
|
+
// 3D-only. WebGL is required; if it's not available, the stage
|
|
19483
|
+
// simply doesn't paint (caller is expected to gate room entry
|
|
19484
|
+
// on `VS3D.isSupported()` if a no-WebGL fallback ever returns).
|
|
19485
|
+
// The aria-label + HUD + subtitle + SFX tail still runs so
|
|
19486
|
+
// screen readers and the status panels stay in sync regardless.
|
|
19487
|
+
const members = this.roundTableMembers();
|
|
19488
|
+
const positions = this.computeSeatPositions(members);
|
|
17524
19489
|
const VS3D = window.VoiceStage3D;
|
|
17525
|
-
const
|
|
17526
|
-
|
|
17527
|
-
|
|
17528
|
-
if (
|
|
19490
|
+
const sp = this._resolveStageSpeaker();
|
|
19491
|
+
let speakingId = sp && sp.id ? sp.id : null;
|
|
19492
|
+
|
|
19493
|
+
if (VS3D && typeof VS3D.mount === "function" && VS3D.isSupported()) {
|
|
17529
19494
|
try {
|
|
17530
19495
|
VS3D.mount(stage);
|
|
17531
|
-
// Compact speaker resolution · mirrors the longer 2D path
|
|
17532
|
-
// below (priority: voice-replay → audible voice queue →
|
|
17533
|
-
// streaming agent message → chair pending / convene →
|
|
17534
|
-
// queue head). Enough to drive the 3D overlay's bubble +
|
|
17535
|
-
// nameplate-hide while-speaking behaviour.
|
|
17536
|
-
const sp = this._resolveStageSpeaker();
|
|
17537
19496
|
const votePopHtml = this._resolveStageVotePop();
|
|
17538
|
-
// User-spoke bubble · mirrors the 2D `data-rt-user-bubble`
|
|
17539
|
-
// element. Active when the latest user message landed
|
|
17540
|
-
// within the last USER_BUBBLE_TTL_MS, computed once per
|
|
17541
|
-
// render so the bubble's countdown progress is fresh.
|
|
17542
19497
|
const ub = this.userBubble;
|
|
17543
19498
|
const nowMs = Date.now();
|
|
17544
19499
|
const ubActive = !!(ub && ub.text && !ub.dismissed && nowMs < ub.deadline);
|
|
@@ -17547,8 +19502,8 @@
|
|
|
17547
19502
|
(this.USER_BUBBLE_TTL_MS - (ub.deadline - nowMs)) / this.USER_BUBBLE_TTL_MS))
|
|
17548
19503
|
: 0;
|
|
17549
19504
|
VS3D.update({
|
|
17550
|
-
members
|
|
17551
|
-
positions
|
|
19505
|
+
members,
|
|
19506
|
+
positions,
|
|
17552
19507
|
mode: floorMode,
|
|
17553
19508
|
speakerId: sp.id,
|
|
17554
19509
|
speakerState: sp.state,
|
|
@@ -17560,444 +19515,18 @@
|
|
|
17560
19515
|
userWait: !!this.pendingUserMessage,
|
|
17561
19516
|
userBubble: ubActive ? { text: ub.text, progress: ubProgress } : null,
|
|
17562
19517
|
});
|
|
17563
|
-
// The 2D path below ends with calls to renderRoundTableHud
|
|
17564
|
-
// + renderRtSubtitle so the status panel + live subtitle
|
|
17565
|
-
// stay in sync with each renderRoundTable. The 3D delegate
|
|
17566
|
-
// `return`s above the 2D code, so without these two calls
|
|
17567
|
-
// here the HUD div stays empty until some unrelated SSE
|
|
17568
|
-
// (config-event / queue-update) happens to fire them ·
|
|
17569
|
-
// user reported "HUD shows as a thin line, fixed by
|
|
17570
|
-
// toggling the tone" which was exactly that race.
|
|
17571
|
-
this.renderRoundTableHud();
|
|
17572
|
-
this.renderRtSubtitle();
|
|
17573
|
-
// Stage SFX · same call the 2D tail makes (thinking loop +
|
|
17574
|
-
// speaker-change chime). Reuses `sp` resolved above so the
|
|
17575
|
-
// helper sees the exact speaker/state the 3D scene rendered.
|
|
17576
|
-
this._applyStageSfx(sp.id, sp.state);
|
|
17577
|
-
return;
|
|
17578
19518
|
} catch (e) {
|
|
17579
|
-
|
|
17580
|
-
// so the user still gets a working stage. Logged so we can
|
|
17581
|
-
// diagnose later · the toggle stays on (user-flipped only).
|
|
17582
|
-
console.warn("[voice-3d] mount/update failed, falling back to 2D:", e);
|
|
17583
|
-
try { VS3D.unmount(); } catch (_) {}
|
|
17584
|
-
}
|
|
17585
|
-
} else if (VS3D && typeof VS3D.unmount === "function" && stage.classList.contains("is-3d")) {
|
|
17586
|
-
// Toggle just flipped off · tear down the 3D canvas so the
|
|
17587
|
-
// 2D path below paints into a clean stage.
|
|
17588
|
-
try { VS3D.unmount(); } catch (_) {}
|
|
17589
|
-
}
|
|
17590
|
-
|
|
17591
|
-
const seatsHost = stage.querySelector("[data-rt-seats]");
|
|
17592
|
-
if (!seatsHost) return;
|
|
17593
|
-
const members = members3d;
|
|
17594
|
-
const positions = positions3d;
|
|
17595
|
-
|
|
17596
|
-
// Build seat HTML. Z-order via inline style based on the y
|
|
17597
|
-
// coordinate so seats with larger y (front) paint last and
|
|
17598
|
-
// occlude back-row seats / the table edge.
|
|
17599
|
-
const seatsByZ = positions
|
|
17600
|
-
.map((seat, i) => ({ seat, i, zScore: Math.round(seat.y * 10) }))
|
|
17601
|
-
.sort((a, b) => a.zScore - b.zScore);
|
|
17602
|
-
|
|
17603
|
-
// Determine the active speaker AND whether they're thinking
|
|
17604
|
-
// (warming up · no tokens yet) or actively speaking (tokens
|
|
17605
|
-
// flowing). Priority:
|
|
17606
|
-
// 1. Most recent streaming message → that author. State
|
|
17607
|
-
// depends on body content: empty body = thinking,
|
|
17608
|
-
// non-empty = speaking.
|
|
17609
|
-
// 2. Most recent message with an ACTIVE VOICE QUEUE (audio
|
|
17610
|
-
// is being streamed/played for it). Catches chair
|
|
17611
|
-
// templated announcements (announceRoundPrompt +
|
|
17612
|
-
// announceIntervention) that emit voice-chunks without
|
|
17613
|
-
// setting meta.streaming · the user hears them speaking,
|
|
17614
|
-
// so the bubble must surface even though the streaming
|
|
17615
|
-
// flag is false.
|
|
17616
|
-
// 3. currentQueue[0] when status === "speaking" → queue head
|
|
17617
|
-
// just promoted, no message-appended yet → thinking.
|
|
17618
|
-
// 4. Otherwise null (idle).
|
|
17619
|
-
let speakingId = null;
|
|
17620
|
-
let speakerState = null; // "thinking" | "speaking"
|
|
17621
|
-
let replayBody = null; // populated only during voice-replay
|
|
17622
|
-
// (0) Voice-replay override · when an adjourned room is playing
|
|
17623
|
-
// back its transcript via the replay overlay, the live
|
|
17624
|
-
// `streaming` / queue signals are absent (the room is
|
|
17625
|
-
// done). Read the replay's active speaker so the seat
|
|
17626
|
-
// lights up + the bubble + subtitle reflect the playback.
|
|
17627
|
-
// The `body` field powers the subtitle bar at the foot of
|
|
17628
|
-
// the stage (`renderRoundTableSubtitle`). Falls through to
|
|
17629
|
-
// the live-detection paths below when replay isn't active.
|
|
17630
|
-
const replayActive = (typeof window !== "undefined"
|
|
17631
|
-
&& window.boardroomVoiceReplay
|
|
17632
|
-
&& typeof window.boardroomVoiceReplay.getActive === "function")
|
|
17633
|
-
? window.boardroomVoiceReplay.getActive()
|
|
17634
|
-
: null;
|
|
17635
|
-
if (replayActive && replayActive.authorId) {
|
|
17636
|
-
speakingId = replayActive.authorId;
|
|
17637
|
-
speakerState = replayActive.state === "speaking" ? "speaking" : "thinking";
|
|
17638
|
-
replayBody = replayActive.body || "";
|
|
17639
|
-
}
|
|
17640
|
-
const msgs = this.currentMessages || [];
|
|
17641
|
-
// (1) Audible voice queue takes precedence · with cross-director
|
|
17642
|
-
// pipelining, the most-recent streaming message is often the
|
|
17643
|
-
// PRE-WARMED next speaker (B's placeholder lands while A's
|
|
17644
|
-
// audio still plays). The seat that lights up must match
|
|
17645
|
-
// whoever the user is HEARING — not whoever's text is
|
|
17646
|
-
// streaming in the background. Scan voiceQueues for the
|
|
17647
|
-
// playing one and resolve back to its message's author.
|
|
17648
|
-
if (!speakingId && this.voiceQueues) {
|
|
17649
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
17650
|
-
const mm = msgs[i];
|
|
17651
|
-
if (!mm || mm.authorKind !== "agent") continue;
|
|
17652
|
-
const vq = this.voiceQueues[mm.id];
|
|
17653
|
-
if (vq && vq.playState === "playing") {
|
|
17654
|
-
speakingId = mm.authorId;
|
|
17655
|
-
speakerState = "speaking";
|
|
17656
|
-
break;
|
|
17657
|
-
}
|
|
17658
|
-
}
|
|
17659
|
-
}
|
|
17660
|
-
// (2) Streaming message fallback · only relevant when no audible
|
|
17661
|
-
// queue exists (text mode, OR the brief warmup between
|
|
17662
|
-
// message-appended and first voice-chunk). Picks the most
|
|
17663
|
-
// recent streaming agent message that ISN'T pre-warmed —
|
|
17664
|
-
// pre-warmed messages have a voiceQueue with playState
|
|
17665
|
-
// "queued" and should NOT light up a seat.
|
|
17666
|
-
if (!speakingId) {
|
|
17667
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
17668
|
-
const mm = msgs[i];
|
|
17669
|
-
if (!(mm && mm.meta && mm.meta.streaming === true && mm.authorKind === "agent")) continue;
|
|
17670
|
-
// Skip queued (pre-warmed) speakers · their voice waits for
|
|
17671
|
-
// the playing speaker's audio to finish.
|
|
17672
|
-
const vq = this.voiceQueues && this.voiceQueues[mm.id];
|
|
17673
|
-
if (vq && vq.playState === "queued") continue;
|
|
17674
|
-
speakingId = mm.authorId;
|
|
17675
|
-
const body = String(mm.body || "").trim();
|
|
17676
|
-
speakerState = body.length > 0 ? "speaking" : "thinking";
|
|
17677
|
-
break;
|
|
17678
|
-
}
|
|
17679
|
-
}
|
|
17680
|
-
// (2b) Dead branch removed · the old "voice queue exists at all"
|
|
17681
|
-
// path was replaced by the playing-queue priority above.
|
|
17682
|
-
if (false && !speakingId && this.voiceQueues) {
|
|
17683
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
17684
|
-
const mm = msgs[i];
|
|
17685
|
-
if (!mm || mm.authorKind !== "agent") continue;
|
|
17686
|
-
const vq = this.voiceQueues[mm.id];
|
|
17687
|
-
if (vq && vq.playState === "playing") {
|
|
17688
|
-
speakingId = mm.authorId;
|
|
17689
|
-
speakerState = "speaking";
|
|
17690
|
-
break;
|
|
17691
|
-
}
|
|
19519
|
+
console.warn("[voice-3d] mount/update failed:", e);
|
|
17692
19520
|
}
|
|
17693
19521
|
}
|
|
17694
|
-
// (3) Chair preparing (silent prep phase between user input
|
|
17695
|
-
// and the chair's message-appended) · without this hook
|
|
17696
|
-
// the user has no visual signal during the seconds the
|
|
17697
|
-
// chair spends on tools + LLM startup. Show the thinking
|
|
17698
|
-
// bubble on the chair seat so they know the chair is
|
|
17699
|
-
// working. Cleared the moment the chair's real message
|
|
17700
|
-
// appends (hideChairPending fires).
|
|
17701
|
-
if (!speakingId && this.chairPending === true && this.currentChair) {
|
|
17702
|
-
speakingId = this.currentChair.id;
|
|
17703
|
-
speakerState = "thinking";
|
|
17704
|
-
}
|
|
17705
|
-
// (3b) Convening sequence active (auto-pick + chair prep) ·
|
|
17706
|
-
// the chat-side convening card is hidden in voice mode,
|
|
17707
|
-
// so without this the stage shows nothing happening for
|
|
17708
|
-
// the 3-4s the picker LLM + chair tools + LLM startup
|
|
17709
|
-
// take. chairPending above only fires AFTER auto-pick-
|
|
17710
|
-
// complete; conveneState covers the EARLIER picker phase
|
|
17711
|
-
// too. Cleared on the chair's first message-appended.
|
|
17712
|
-
if (!speakingId && this.conveneState && this.currentChair) {
|
|
17713
|
-
speakingId = this.currentChair.id;
|
|
17714
|
-
speakerState = "thinking";
|
|
17715
|
-
}
|
|
17716
|
-
if (!speakingId && Array.isArray(this.currentQueue) && this.currentQueue[0] && this.currentQueue[0].status === "speaking") {
|
|
17717
|
-
speakingId = this.currentQueue[0].agentId;
|
|
17718
|
-
speakerState = "thinking";
|
|
17719
|
-
}
|
|
17720
|
-
|
|
17721
|
-
// Speaker's messageId · derived from the same priority chain
|
|
17722
|
-
// above. Used by the stalled-audio bubble (per-queue watchdog
|
|
17723
|
-
// surfaces on the speaker's bubble + the Skip click needs the
|
|
17724
|
-
// messageId to POST /voice-done). Null when the speaker is
|
|
17725
|
-
// thinking-only (no message id yet) or during replay.
|
|
17726
|
-
let speakingMsgId = null;
|
|
17727
|
-
if (speakingId && this.voiceQueues) {
|
|
17728
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
17729
|
-
const mm = msgs[i];
|
|
17730
|
-
if (!mm || mm.authorKind !== "agent") continue;
|
|
17731
|
-
if (mm.authorId !== speakingId) continue;
|
|
17732
|
-
if (this.voiceQueues[mm.id]) { speakingMsgId = mm.id; break; }
|
|
17733
|
-
}
|
|
17734
|
-
}
|
|
17735
|
-
const stalledQ = (speakingMsgId && this.voiceQueues && this.voiceQueues[speakingMsgId] && this.voiceQueues[speakingMsgId].stalled)
|
|
17736
|
-
? this.voiceQueues[speakingMsgId]
|
|
17737
|
-
: null;
|
|
17738
19522
|
|
|
17739
|
-
// Stage SFX · thinking loop + speaker-change chime.
|
|
17740
|
-
//
|
|
17741
|
-
//
|
|
17742
|
-
|
|
17743
|
-
this._applyStageSfx(speakingId, speakerState);
|
|
17744
|
-
|
|
17745
|
-
const html = seatsByZ.map(({ seat, i }) => {
|
|
17746
|
-
const m = seat.member;
|
|
17747
|
-
const isChair = seat.kind === "chair";
|
|
17748
|
-
const isUser = !!m.__isUser;
|
|
17749
|
-
const qIdx = isUser ? -1 : this.roundTableQueueIndex(m.id);
|
|
17750
|
-
const isSpeaking = !isUser && m.id === speakingId;
|
|
17751
|
-
const isQueued = qIdx >= 1; // anyone after the head
|
|
17752
|
-
|
|
17753
|
-
// Chair sprite · user gets the plain (no-moderator-gem) chair
|
|
17754
|
-
// sprite; the seat reads as "joined the table" not "running it".
|
|
17755
|
-
const chairSvg = this.renderRoundTableChairSvg(isChair);
|
|
17756
|
-
// Avatar · user takes prefs.avatarSeed via AvatarSkill when
|
|
17757
|
-
// available, otherwise falls back to an initial-letter chip
|
|
17758
|
-
// (mirrors the sidebar pattern at app.js:4847). Directors /
|
|
17759
|
-
// chair use their stored avatarPath image.
|
|
17760
|
-
let avatar;
|
|
17761
|
-
if (isUser) {
|
|
17762
|
-
const seed = m.__seed;
|
|
17763
|
-
if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
|
|
17764
|
-
avatar = `<div class="rt-avatar rt-avatar-user has-pixel-av">${window.AvatarSkill.generate(seed)}</div>`;
|
|
17765
|
-
} else {
|
|
17766
|
-
const initial = this.escape(((m.name || "?")[0] || "?").toUpperCase());
|
|
17767
|
-
avatar = `<div class="rt-avatar rt-avatar-user rt-avatar-initial">${initial}</div>`;
|
|
17768
|
-
}
|
|
17769
|
-
} else {
|
|
17770
|
-
// `data-agent` opens the lightweight director overlay (see
|
|
17771
|
-
// public/agent-overlay.js bubble-phase click handler). Tagged
|
|
17772
|
-
// on directors AND the chair so the user can peek at any
|
|
17773
|
-
// seated voice from the stage; user seat is a div, not an
|
|
17774
|
-
// img, and is intentionally not tagged.
|
|
17775
|
-
avatar = `<img class="rt-avatar" data-agent="${this.escape(m.id)}" src="${this.escape(m.avatarPath || "")}" alt="${this.escape(m.name || "")}">`;
|
|
17776
|
-
}
|
|
17777
|
-
// Name plate · adds a small "Chairman / 董事长" title beneath
|
|
17778
|
-
// the user's name so the user seat reads as the room owner /
|
|
17779
|
-
// chairman. Pulled from i18n (`rt_user_title`) so the label
|
|
17780
|
-
// follows the active UI locale: defaults to "Chairman" in
|
|
17781
|
-
// English, "董事长" in Chinese. Directors and the chair
|
|
17782
|
-
// render the plain single-line name.
|
|
17783
|
-
const name = isUser
|
|
17784
|
-
? `<div class="rt-name">${this.escape(m.name || "")}<div class="rt-name-title">${this.escape(this._t("rt_user_title"))}</div></div>`
|
|
17785
|
-
: `<div class="rt-name">${this.escape(m.name || "")}</div>`;
|
|
17786
|
-
const bubbleState = isSpeaking ? speakerState : null;
|
|
17787
|
-
// Bubble carries the speaker's NAME so the user always knows
|
|
17788
|
-
// who's speaking — the name plate beneath/above the seat is
|
|
17789
|
-
// hidden during speaking (display:none in CSS), and without a
|
|
17790
|
-
// name on the bubble itself the user could only guess. Status
|
|
17791
|
-
// word ("thinking" / "speaking") is rendered as a smaller
|
|
17792
|
-
// mono kicker beneath the name. Color + dots animation still
|
|
17793
|
-
// distinguishes thinking (amber) from speaking (lime).
|
|
17794
|
-
// Convene override · while the room is mid-convening, the
|
|
17795
|
-
// chair seat shows the current stage label ("Analyzing topic"
|
|
17796
|
-
// / "Seating directors" / "Preparing remarks") instead of the
|
|
17797
|
-
// generic "Thinking" so the user knows WHICH part of the
|
|
17798
|
-
// setup is in flight. Falls back to the generic label for
|
|
17799
|
-
// every non-chair speaker and for chair turns post-convene.
|
|
17800
|
-
let statusWord = bubbleState === "thinking"
|
|
17801
|
-
? this._t("rt_thinking")
|
|
17802
|
-
: this._t("rt_speaking");
|
|
17803
|
-
if (bubbleState === "thinking" && isChair && this.conveneState) {
|
|
17804
|
-
const stageKey = ({
|
|
17805
|
-
analyzing: "conv_stage_analyzing_title",
|
|
17806
|
-
seating: "conv_stage_seating_title",
|
|
17807
|
-
preparing: "conv_stage_preparing_title",
|
|
17808
|
-
})[this.conveneState.stage];
|
|
17809
|
-
if (stageKey) statusWord = this._t(stageKey);
|
|
17810
|
-
}
|
|
17811
|
-
// Chair-pending phase (server-driven · chair-pending payload.phase).
|
|
17812
|
-
// Maps known phase strings to i18n keys so the bubble label
|
|
17813
|
-
// reflects WHAT silent work is happening — picker LLM, clarify
|
|
17814
|
-
// gate, vote summary, brief stages, etc. Falls back to the
|
|
17815
|
-
// existing convene-stage / "Thinking" label when phase is
|
|
17816
|
-
// empty or unrecognised.
|
|
17817
|
-
if (bubbleState === "thinking" && isChair && this.chairPending && this.chairPendingPhase) {
|
|
17818
|
-
const phaseKey = ({
|
|
17819
|
-
"clarify-deciding": "rt_phase_clarify_deciding",
|
|
17820
|
-
"picker-deciding": "rt_phase_picker_deciding",
|
|
17821
|
-
"next-speaker": "rt_phase_next_speaker",
|
|
17822
|
-
"llm-warming": "rt_phase_llm_warming",
|
|
17823
|
-
"vote-summary": "rt_phase_vote_summary",
|
|
17824
|
-
"brief-extracting": "rt_phase_brief_extracting",
|
|
17825
|
-
"brief-composing": "rt_phase_brief_composing",
|
|
17826
|
-
"brief-writing": "rt_phase_brief_writing",
|
|
17827
|
-
})[this.chairPendingPhase];
|
|
17828
|
-
if (phaseKey) statusWord = this._t(phaseKey);
|
|
17829
|
-
}
|
|
17830
|
-
// Per-message LLM first-token watchdog · client-side belt for
|
|
17831
|
-
// the server's 60s hard cap. When a streaming director message
|
|
17832
|
-
// has gone >8s with no message-token arriving, _localPhase is
|
|
17833
|
-
// flipped to "llm-warming" and the bubble shows that label
|
|
17834
|
-
// until the first token lands (or the server auto-skips).
|
|
17835
|
-
if (bubbleState === "thinking" && !isChair) {
|
|
17836
|
-
const streamingMsg = (this.currentMessages || []).slice().reverse().find(
|
|
17837
|
-
(mm) => mm && mm.authorKind === "agent" && mm.authorId === m.id
|
|
17838
|
-
&& mm.meta && mm.meta.streaming === true,
|
|
17839
|
-
);
|
|
17840
|
-
if (streamingMsg && streamingMsg.meta && streamingMsg.meta._localPhase === "llm-warming") {
|
|
17841
|
-
statusWord = this._t("rt_phase_llm_warming");
|
|
17842
|
-
}
|
|
17843
|
-
}
|
|
17844
|
-
const bubbleCls = bubbleState === "thinking"
|
|
17845
|
-
? "rt-bubble is-thinking"
|
|
17846
|
-
: "rt-bubble";
|
|
17847
|
-
// User bubble · ephemeral, shows latest typed message plus
|
|
17848
|
-
// an `×` close button. Visible only when not dismissed and
|
|
17849
|
-
// the deadline hasn't elapsed. The 10s countdown is
|
|
17850
|
-
// rendered AS the bubble's border via a conic-gradient
|
|
17851
|
-
// driven by `--rt-bubble-user-progress` (0 → 1 over 10s);
|
|
17852
|
-
// the inline-text countdown digit was removed so prose
|
|
17853
|
-
// can use the bubble's full interior width.
|
|
17854
|
-
let bubble = "";
|
|
17855
|
-
if (isUser) {
|
|
17856
|
-
const ub = this.userBubble;
|
|
17857
|
-
if (ub && !ub.dismissed && ub.text && Date.now() < ub.deadline) {
|
|
17858
|
-
const elapsed = this.USER_BUBBLE_TTL_MS - (ub.deadline - Date.now());
|
|
17859
|
-
const progress = Math.min(1, Math.max(0, elapsed / this.USER_BUBBLE_TTL_MS));
|
|
17860
|
-
bubble = `<div class="rt-bubble rt-bubble-user" data-rt-user-bubble style="--rt-bubble-user-progress: ${progress.toFixed(3)}">` +
|
|
17861
|
-
`<span class="rt-bubble-user-text">${this.escape(ub.text)}</span>` +
|
|
17862
|
-
`<button type="button" class="rt-bubble-user-close" data-rt-user-bubble-close aria-label="Dismiss">✕</button>` +
|
|
17863
|
-
`</div>`;
|
|
17864
|
-
}
|
|
17865
|
-
} else if (isChair && this.chairBubble && !this.chairBubble.dismissed
|
|
17866
|
-
&& this.chairBubble.text && Date.now() < this.chairBubble.deadline) {
|
|
17867
|
-
// Chair clarify question · pinned to the chair seat with
|
|
17868
|
-
// border countdown. Takes precedence over the "Speaking"
|
|
17869
|
-
// status bubble since the chair has already finished its
|
|
17870
|
-
// turn at this point (message-final flipped streaming off).
|
|
17871
|
-
const cb = this.chairBubble;
|
|
17872
|
-
const elapsed = this.CHAIR_BUBBLE_TTL_MS - (cb.deadline - Date.now());
|
|
17873
|
-
const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
|
|
17874
|
-
bubble = `<div class="rt-bubble rt-bubble-chair-clarify" data-rt-chair-bubble style="--rt-bubble-chair-progress: ${progress.toFixed(3)}">` +
|
|
17875
|
-
`<span class="rt-bubble-chair-clarify-text">${this.escape(cb.text)}</span>` +
|
|
17876
|
-
`<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
|
|
17877
|
-
`</div>`;
|
|
17878
|
-
} else if (isSpeaking) {
|
|
17879
|
-
// Stalled-audio variant · the chunk-arrival watchdog flipped
|
|
17880
|
-
// q.stalled when no new TTS chunks arrived for ~8s. Repaint
|
|
17881
|
-
// the bubble amber to signal the issue; the 15s auto-skip
|
|
17882
|
-
// inside the watchdog will recover automatically.
|
|
17883
|
-
if (stalledQ && stalledQ.messageId === (this.voiceQueues[speakingMsgId]?.messageId)) {
|
|
17884
|
-
// Audio stalled · the chunk-arrival watchdog flipped
|
|
17885
|
-
// q.stalled when no new TTS chunks arrived for ~8s. Show
|
|
17886
|
-
// an amber state on the bubble so the user understands
|
|
17887
|
-
// why playback paused. The 15s auto-skip in the watchdog
|
|
17888
|
-
// recovers automatically · no manual click affordance.
|
|
17889
|
-
const stalledText = this._t("rt_audio_stalled");
|
|
17890
|
-
bubble = `<div class="${bubbleCls} is-stalled"`
|
|
17891
|
-
+ ` title="${this.escape(stalledText)}">`
|
|
17892
|
-
+ `<span class="rt-bubble-name">${this.escape(m.name || "")}</span>`
|
|
17893
|
-
+ `<span class="rt-bubble-status">${this.escape(stalledText)}</span>`
|
|
17894
|
-
+ `</div>`;
|
|
17895
|
-
} else {
|
|
17896
|
-
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>`;
|
|
17897
|
-
}
|
|
17898
|
-
}
|
|
17899
|
-
const badge = (isQueued && !isSpeaking)
|
|
17900
|
-
? `<div class="rt-badge">${String(qIdx + 1).padStart(2, "0")}</div>`
|
|
17901
|
-
: "";
|
|
19523
|
+
// Stage SFX · thinking loop + speaker-change chime. The 2D
|
|
19524
|
+
// path used to compute speakingId/state itself; we reuse the
|
|
19525
|
+
// resolver above so this stays single-source.
|
|
19526
|
+
this._applyStageSfx(sp.id, sp.state);
|
|
17902
19527
|
|
|
17903
|
-
|
|
17904
|
-
|
|
17905
|
-
// (A) round-prompt phase · chair has just prompted at round
|
|
17906
|
-
// wrap; popover shows [Open vote] [Continue] [Adjourn].
|
|
17907
|
-
// (B) round-end vote phase · awaitingContinue === true;
|
|
17908
|
-
// popover shows the 3 key-points + Continue / Adjourn
|
|
17909
|
-
// AFTER the chair has finished its TTS turn.
|
|
17910
|
-
// The popover is suppressed while the chair is mid-presenting
|
|
17911
|
-
// (preparing, streaming text, or audio still playing). The
|
|
17912
|
-
// user wants to hear the chair speak first, THEN see the
|
|
17913
|
-
// panel — without this gate the panel popped up under the
|
|
17914
|
-
// chair's voice and split the user's attention.
|
|
17915
|
-
const hasActivePrompt = (typeof this.activeRoundPromptId === "function")
|
|
17916
|
-
? !!this.activeRoundPromptId()
|
|
17917
|
-
: false;
|
|
17918
|
-
const isChairBusy = (() => {
|
|
17919
|
-
if (this.chairPending === true) return true;
|
|
17920
|
-
// Chair message landed but voice synthesis hasn't reached
|
|
17921
|
-
// the client yet · the popover would otherwise flash for
|
|
17922
|
-
// 0.5-2s before audio kicks in. See `_chairVoiceAwaiting`
|
|
17923
|
-
// doc + the message-appended hook for the full reasoning.
|
|
17924
|
-
if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.size > 0) return true;
|
|
17925
|
-
const allMsgs = this.currentMessages || [];
|
|
17926
|
-
for (let k = allMsgs.length - 1; k >= 0; k--) {
|
|
17927
|
-
const mm = allMsgs[k];
|
|
17928
|
-
if (!mm || mm.authorKind !== "agent") continue;
|
|
17929
|
-
// Most recent agent turn · streaming text OR active voice
|
|
17930
|
-
// playback counts as "busy". Voice queues stay live for
|
|
17931
|
-
// templated chair messages too (announceRoundPrompt), so
|
|
17932
|
-
// this catches round-prompt voice as well as the LLM
|
|
17933
|
-
// round-end stream.
|
|
17934
|
-
if (mm.meta && mm.meta.streaming === true) return true;
|
|
17935
|
-
if (this.voiceQueues && this.voiceQueues[mm.id]) return true;
|
|
17936
|
-
return false;
|
|
17937
|
-
}
|
|
17938
|
-
return false;
|
|
17939
|
-
})();
|
|
17940
|
-
const showVotePop = isChair && this.currentRoom
|
|
17941
|
-
&& this.currentRoom.status !== "adjourned"
|
|
17942
|
-
&& (this.currentRoom.awaitingContinue === true || hasActivePrompt)
|
|
17943
|
-
&& !isChairBusy;
|
|
17944
|
-
const votePop = showVotePop ? this.renderRoundTableVotePop() : "";
|
|
17945
|
-
|
|
17946
|
-
// Seats whose y is below the stage midline (chair + any
|
|
17947
|
-
// front-row directors) get `rt-seat-below`. CSS flips the
|
|
17948
|
-
// name plate to sit BELOW the chair sprite for these so
|
|
17949
|
-
// names of front-row seats don't land on the table surface
|
|
17950
|
-
// and obscure the props.
|
|
17951
|
-
const isBelow = seat.y > 50;
|
|
17952
|
-
const cls = [
|
|
17953
|
-
"rt-seat",
|
|
17954
|
-
isChair ? "rt-seat-chair" : (isUser ? "rt-seat-user" : "rt-seat-director"),
|
|
17955
|
-
isSpeaking ? "rt-seat-speaking" : "",
|
|
17956
|
-
isSpeaking && speakerState === "thinking" ? "rt-seat-thinking" : "",
|
|
17957
|
-
showVotePop ? "rt-seat-voting" : "",
|
|
17958
|
-
isBelow ? "rt-seat-below" : "",
|
|
17959
|
-
].filter(Boolean).join(" ");
|
|
17960
|
-
|
|
17961
|
-
// Inline style carries position + scaleHint + stagger index.
|
|
17962
|
-
const style = [
|
|
17963
|
-
`left: ${seat.x.toFixed(2)}%`,
|
|
17964
|
-
`top: ${seat.y.toFixed(2)}%`,
|
|
17965
|
-
`--rt-scale: ${seat.scaleHint.toFixed(3)}`,
|
|
17966
|
-
`--seat-i: ${i}`,
|
|
17967
|
-
].join("; ");
|
|
17968
|
-
|
|
17969
|
-
// Wait-marker · only on the user seat, only when the user
|
|
17970
|
-
// has picked "wait — flush after current speaker finishes"
|
|
17971
|
-
// (pendingUserMessage is set). Reads as a small pixel pill
|
|
17972
|
-
// anchored to the seat so a glance at the table answers
|
|
17973
|
-
// "is my queued message still parked?" — clears the moment
|
|
17974
|
-
// the message-appended SSE for the user's body lands.
|
|
17975
|
-
const waitMark = (isUser && this.pendingUserMessage)
|
|
17976
|
-
? `<div class="rt-seat-wait-mark" aria-label="Waiting for current speaker to finish">⌛ WAIT</div>`
|
|
17977
|
-
: "";
|
|
17978
|
-
|
|
17979
|
-
return `
|
|
17980
|
-
<div class="${cls}" data-seat-index="${i}" data-agent-id="${this.escape(m.id)}" style="${style}">
|
|
17981
|
-
${chairSvg}
|
|
17982
|
-
${avatar}
|
|
17983
|
-
${bubble}
|
|
17984
|
-
${badge}
|
|
17985
|
-
${waitMark}
|
|
17986
|
-
${name}
|
|
17987
|
-
${votePop}
|
|
17988
|
-
</div>
|
|
17989
|
-
`;
|
|
17990
|
-
}).join("");
|
|
17991
|
-
|
|
17992
|
-
// Empty state · no directors yet.
|
|
17993
|
-
const empty = members.length <= 1
|
|
17994
|
-
? `<div class="rt-empty"><span>// awaiting directors</span></div>`
|
|
17995
|
-
: "";
|
|
17996
|
-
|
|
17997
|
-
seatsHost.innerHTML = html + empty;
|
|
17998
|
-
|
|
17999
|
-
// Update aria-label to keep screen readers in sync with the
|
|
18000
|
-
// visual state · the speaking-queue strip below remains the
|
|
19528
|
+
// Aria-label keeps screen readers in sync with the visible
|
|
19529
|
+
// speaker · the speaking-queue strip below remains the
|
|
18001
19530
|
// canonical accessible source of truth, this is just a hint.
|
|
18002
19531
|
const speaker = members.find((m) => m.id === speakingId);
|
|
18003
19532
|
const queuedNames = (this.currentQueue || [])
|
|
@@ -18016,18 +19545,15 @@
|
|
|
18016
19545
|
}
|
|
18017
19546
|
stage.setAttribute("aria-label", aria);
|
|
18018
19547
|
|
|
18019
|
-
// Persistent HUD repaint · cheap
|
|
18020
|
-
//
|
|
18021
|
-
//
|
|
18022
|
-
// re-render
|
|
18023
|
-
//
|
|
19548
|
+
// Persistent HUD + live subtitle repaint · cheap and the data
|
|
19549
|
+
// sources are the same room state already consulted above. The
|
|
19550
|
+
// message-token hot path calls renderRtSubtitle directly to
|
|
19551
|
+
// bypass full re-render cost; this call covers queue-update /
|
|
19552
|
+
// config-event / SSE hello cases that flow through here.
|
|
18024
19553
|
this.renderRoundTableHud();
|
|
18025
|
-
// Live subtitle · keep in sync with the same speaker-detection
|
|
18026
|
-
// signals the seats use. Repaints triggered from message-token
|
|
18027
|
-
// already call renderRtSubtitle directly to bypass the full
|
|
18028
|
-
// seat re-render cost; this call covers queue-update / config-
|
|
18029
|
-
// event / SSE hello cases that flow through renderRoundTable.
|
|
18030
19554
|
this.renderRtSubtitle();
|
|
19555
|
+
return;
|
|
19556
|
+
|
|
18031
19557
|
},
|
|
18032
19558
|
|
|
18033
19559
|
/** Paint the top-left HUD console · gamified RPG-style status
|
|
@@ -20061,6 +21587,21 @@
|
|
|
20061
21587
|
app.closeRecordingStopModal();
|
|
20062
21588
|
return;
|
|
20063
21589
|
}
|
|
21590
|
+
// Pause-after-current save modal · three-option grammar
|
|
21591
|
+
// (save-adjourn / save-keep / continue). Fired by SSE
|
|
21592
|
+
// room-paused when payload.mode==="soft" + recording active.
|
|
21593
|
+
const recPauseSaveChoice = e.target.closest("[data-rec-pause-save-choice]");
|
|
21594
|
+
if (recPauseSaveChoice) {
|
|
21595
|
+
e.preventDefault();
|
|
21596
|
+
app.handleRecordingPauseSaveChoice(recPauseSaveChoice.getAttribute("data-rec-pause-save-choice"));
|
|
21597
|
+
return;
|
|
21598
|
+
}
|
|
21599
|
+
if (e.target.id === "rec-pause-save-overlay") {
|
|
21600
|
+
// Backdrop click · keep recording (don't accidentally end
|
|
21601
|
+
// a recording the user explicitly hasn't decided to stop).
|
|
21602
|
+
app.closeRecordingPauseSaveModal();
|
|
21603
|
+
return;
|
|
21604
|
+
}
|
|
20064
21605
|
// Record button in the room header · toggle on/off.
|
|
20065
21606
|
if (e.target.closest("[data-record-toggle]")) {
|
|
20066
21607
|
e.preventDefault();
|
|
@@ -21320,19 +22861,21 @@
|
|
|
21320
22861
|
}
|
|
21321
22862
|
});
|
|
21322
22863
|
|
|
21323
|
-
//
|
|
21324
|
-
//
|
|
21325
|
-
//
|
|
21326
|
-
// We toggle
|
|
21327
|
-
//
|
|
21328
|
-
// after the last scroll
|
|
21329
|
-
//
|
|
21330
|
-
// anywhere in the document.
|
|
22864
|
+
// Scrollbar auto-reveal · `.ib-textarea` (room input bar) and
|
|
22865
|
+
// `.thread-float-messages` (thread chat) both render with
|
|
22866
|
+
// `scrollbar-width: none` by default and only flip visible
|
|
22867
|
+
// while the user is actively scrolling. We toggle `.is-scrolling`
|
|
22868
|
+
// with an 800 ms debounce so the scrollbar fades back out
|
|
22869
|
+
// shortly after the last scroll. Scroll events don't bubble,
|
|
22870
|
+
// hence the capture-phase listener.
|
|
21331
22871
|
const _scrollDecayTimers = new WeakMap();
|
|
21332
22872
|
document.addEventListener("scroll", (ev) => {
|
|
21333
22873
|
const t = ev.target;
|
|
21334
|
-
if (!t || !(t instanceof
|
|
21335
|
-
|
|
22874
|
+
if (!t || !(t instanceof HTMLElement)) return;
|
|
22875
|
+
const eligible =
|
|
22876
|
+
(t instanceof HTMLTextAreaElement && t.classList.contains("ib-textarea"))
|
|
22877
|
+
|| t.classList.contains("thread-float-messages");
|
|
22878
|
+
if (!eligible) return;
|
|
21336
22879
|
t.classList.add("is-scrolling");
|
|
21337
22880
|
const prev = _scrollDecayTimers.get(t);
|
|
21338
22881
|
if (prev) clearTimeout(prev);
|