privateboard 0.1.36 → 0.1.38
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 +1142 -50
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1142 -50
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1121 -50
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -2
- package/public/adjourn-overlay.css +2 -2
- package/public/agent-overlay.css +27 -15
- package/public/agent-overlay.js +3 -1
- package/public/agent-profile.css +328 -32
- package/public/agent-profile.js +414 -43
- package/public/app-updater.css +1 -1
- package/public/app.js +1807 -35
- package/public/avatars/chair-blink.svg +1 -0
- package/public/home-3d-loader.js +6 -0
- package/public/home.html +3 -3
- package/public/i18n.js +279 -0
- package/public/icons/folded-sidebar.png +0 -0
- package/public/index.html +410 -147
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/report.html +27 -7
- package/public/room-settings.css +24 -9
- package/public/thread.css +1211 -0
- package/public/user-settings.css +6 -6
- package/public/user-settings.js +37 -20
- package/public/voice-3d.js +167 -3
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/icons/search.png +0 -0
package/public/app.js
CHANGED
|
@@ -148,6 +148,15 @@
|
|
|
148
148
|
* appendMessageDom, drained in _revealPrewarmedBubble, baked into
|
|
149
149
|
* messageHtml so every render preserves the hidden state. */
|
|
150
150
|
_prewarmedHidden: new Set(),
|
|
151
|
+
/** Server-authoritative "currently-visible speaker" messageId.
|
|
152
|
+
* Pushed via queue-update SSE (orchestrator/room.ts pumpQueue).
|
|
153
|
+
* Drives the .streaming class gate in `updateMessageBodyDom` so
|
|
154
|
+
* only the one director the server marks as the active turn
|
|
155
|
+
* shows the typing-dots / pulse animation, even in the brief
|
|
156
|
+
* same-frame window where A's finalize and B's appended SSE
|
|
157
|
+
* events both reach the DOM. Null between turns / before first
|
|
158
|
+
* turn. */
|
|
159
|
+
currentActiveMessageId: null,
|
|
151
160
|
/** Mini-player anchor · when set, the cross-room bar persists
|
|
152
161
|
* for this voice room throughout its whole live/paused life
|
|
153
162
|
* (between speakers, during votes / clarify, idle phases) —
|
|
@@ -334,6 +343,34 @@
|
|
|
334
343
|
: (btn.getAttribute("data-more") || this._t("convene_show_more"));
|
|
335
344
|
btn.textContent = label;
|
|
336
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
|
+
});
|
|
337
374
|
// Live-subtitle minimize / restore · attached in CAPTURE phase
|
|
338
375
|
// so it wins ahead of any outside-click handlers (picker dismiss
|
|
339
376
|
// / overlay close) that might `stopPropagation` and swallow the
|
|
@@ -1080,6 +1117,30 @@
|
|
|
1080
1117
|
return;
|
|
1081
1118
|
}
|
|
1082
1119
|
|
|
1120
|
+
// Thread guard · the main room view is not the right surface
|
|
1121
|
+
// for a thread (it expects a chair, multiple directors, the
|
|
1122
|
+
// full round / vote / brief pipeline). If the URL hash routed
|
|
1123
|
+
// to a thread (manual paste, stale link), redirect to the
|
|
1124
|
+
// parent and surface the thread as a float window so the user
|
|
1125
|
+
// lands somewhere coherent.
|
|
1126
|
+
if (data.room && data.room.kind === "thread") {
|
|
1127
|
+
const parentId = data.room.parentRoomId;
|
|
1128
|
+
if (parentId) {
|
|
1129
|
+
// Navigate to parent and re-mount the thread window.
|
|
1130
|
+
location.hash = "#/r/" + encodeURIComponent(parentId);
|
|
1131
|
+
if (data.room.threadDirectorId) {
|
|
1132
|
+
// Defer until after the route handler settles into the
|
|
1133
|
+
// parent so currentRoomId / agentsById are populated.
|
|
1134
|
+
setTimeout(() => {
|
|
1135
|
+
this.openThreadWith(data.room.threadDirectorId);
|
|
1136
|
+
}, 200);
|
|
1137
|
+
}
|
|
1138
|
+
} else {
|
|
1139
|
+
this.closeRoom();
|
|
1140
|
+
}
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1083
1144
|
// We may be coming from the All Reports view OR an agent profile
|
|
1084
1145
|
// — make sure the room main view is the visible one and the
|
|
1085
1146
|
// others are hidden. The agent-view hide is what stops a stale
|
|
@@ -1164,6 +1225,12 @@
|
|
|
1164
1225
|
: (data.members || []).map((m) => ({ ...m, removedAt: null }));
|
|
1165
1226
|
this.currentChair = data.chair || null;
|
|
1166
1227
|
this.currentQueue = data.queue || [];
|
|
1228
|
+
// Reset to null on room open · the queue-update SSE will push
|
|
1229
|
+
// the real value if a director is currently speaking. The
|
|
1230
|
+
// initial GET /api/rooms/:id snapshot doesn't carry this field
|
|
1231
|
+
// (it's a transient pump state, not persisted to messages), so
|
|
1232
|
+
// we wait for the first SSE tick.
|
|
1233
|
+
this.currentActiveMessageId = null;
|
|
1167
1234
|
this.currentRound = data.round || { spoken: 0, total: 0 };
|
|
1168
1235
|
this.currentKeyPoints = data.keyPoints || [];
|
|
1169
1236
|
// Reset the round-table HUD log when (re)opening a room. Past
|
|
@@ -1281,6 +1348,12 @@
|
|
|
1281
1348
|
console.error("[openRoom] renderRoom failed:", err);
|
|
1282
1349
|
}
|
|
1283
1350
|
this.markActiveRoom(roomId);
|
|
1351
|
+
// Restore any thread float windows the user had open for this
|
|
1352
|
+
// room before the last page reload · the threads themselves
|
|
1353
|
+
// live in the DB; this just rebuilds the floating UI for the
|
|
1354
|
+
// ones the user had visible. Fire-and-forget · failure here
|
|
1355
|
+
// never blocks room load.
|
|
1356
|
+
this.restoreThreadsForRoom(roomId);
|
|
1284
1357
|
// Round-table stage · paint + decide whether the .chat or
|
|
1285
1358
|
// .roundtable-stage should be visible for this room. Runs
|
|
1286
1359
|
// AFTER renderRoom so the DOM exists and currentRoom /
|
|
@@ -1354,6 +1427,19 @@
|
|
|
1354
1427
|
},
|
|
1355
1428
|
|
|
1356
1429
|
async closeRoom() {
|
|
1430
|
+
// Thread cleanup · route through `closeThreadWindow` so the
|
|
1431
|
+
// localStorage persistence entry is dropped too. Per the
|
|
1432
|
+
// updated UX rule, leaving a room means the thread chat is
|
|
1433
|
+
// CLOSED — coming back to the same room later should land
|
|
1434
|
+
// on a clean main view, not auto-restore an old thread.
|
|
1435
|
+
// closeThreadWindow handles DOM, SSE, body.has-thread-dock,
|
|
1436
|
+
// and `pb.thread.open.<parentRoomId>` cleanup in one place.
|
|
1437
|
+
if (this._threads) {
|
|
1438
|
+
for (const tid of Object.keys(this._threads)) {
|
|
1439
|
+
this.closeThreadWindow(tid);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
document.body.classList.remove("has-thread-dock");
|
|
1357
1443
|
// Recording guard · if a meeting capture is in progress for
|
|
1358
1444
|
// the current room, intercept and let the user choose:
|
|
1359
1445
|
// stop-and-save before leaving or cancel and stay. We re-route
|
|
@@ -1402,6 +1488,7 @@
|
|
|
1402
1488
|
// re-opened (since `openRoom` re-sets it from data.chair),
|
|
1403
1489
|
// but the empty + composer states looked broken.
|
|
1404
1490
|
this.currentQueue = [];
|
|
1491
|
+
this.currentActiveMessageId = null;
|
|
1405
1492
|
this.currentRound = { spoken: 0, total: 0 };
|
|
1406
1493
|
this.currentKeyPoints = [];
|
|
1407
1494
|
this.rtChairLog = [];
|
|
@@ -1978,6 +2065,15 @@
|
|
|
1978
2065
|
msg.body = data.body;
|
|
1979
2066
|
msg.meta = data.meta || {};
|
|
1980
2067
|
}
|
|
2068
|
+
// Server cleared meta.preWarmed (pump just consumed this
|
|
2069
|
+
// speaker · it is now the visible active turn). Drain from
|
|
2070
|
+
// the hidden set BEFORE renderChat so the article re-paints
|
|
2071
|
+
// without `data-prewarmed`. Otherwise the CSS `display:none`
|
|
2072
|
+
// would persist across this re-render until the next event.
|
|
2073
|
+
if (data.meta && data.meta.preWarmed === false
|
|
2074
|
+
&& this._prewarmedHidden && this._prewarmedHidden.has(data.messageId)) {
|
|
2075
|
+
this._revealPrewarmedBubble(data.messageId);
|
|
2076
|
+
}
|
|
1981
2077
|
// Re-render via a chat repaint · the tool-use renderer is
|
|
1982
2078
|
// selected by meta.kind so it rebuilds with the new status.
|
|
1983
2079
|
this.renderChat();
|
|
@@ -1987,6 +2083,32 @@
|
|
|
1987
2083
|
const data = JSON.parse(e.data);
|
|
1988
2084
|
this.currentQueue = data.queue || [];
|
|
1989
2085
|
if (data.round) this.currentRound = data.round;
|
|
2086
|
+
// activeMessageId · server tells us which director's bubble
|
|
2087
|
+
// is the currently-visible "speaking" turn. Two consumers:
|
|
2088
|
+
// (1) `updateMessageBodyDom` gates the streaming animation
|
|
2089
|
+
// on this id (suppresses the brief text-mode flash where
|
|
2090
|
+
// A's finalize and B's append cross paths in the same rAF),
|
|
2091
|
+
// (2) a hidden pre-warmed bubble whose id matches gets
|
|
2092
|
+
// revealed here (defense in depth alongside the message-
|
|
2093
|
+
// updated meta.preWarmed:false signal — whichever arrives
|
|
2094
|
+
// first wins).
|
|
2095
|
+
const prevActive = this.currentActiveMessageId || null;
|
|
2096
|
+
this.currentActiveMessageId = (data && typeof data.activeMessageId === "string")
|
|
2097
|
+
? data.activeMessageId
|
|
2098
|
+
: (data && data.activeMessageId === null ? null : prevActive);
|
|
2099
|
+
if (this.currentActiveMessageId
|
|
2100
|
+
&& this._prewarmedHidden
|
|
2101
|
+
&& this._prewarmedHidden.has(this.currentActiveMessageId)) {
|
|
2102
|
+
this._revealPrewarmedBubble(this.currentActiveMessageId);
|
|
2103
|
+
}
|
|
2104
|
+
// If the active id changed AND the streaming class is now on
|
|
2105
|
+
// the wrong bubble, refresh both. Only affects bubbles that
|
|
2106
|
+
// are still in streaming state; finalized bubbles never carry
|
|
2107
|
+
// .streaming so they're not re-touched.
|
|
2108
|
+
if (prevActive !== this.currentActiveMessageId) {
|
|
2109
|
+
this._refreshStreamingClassForId(prevActive);
|
|
2110
|
+
this._refreshStreamingClassForId(this.currentActiveMessageId);
|
|
2111
|
+
}
|
|
1990
2112
|
this.renderQueue();
|
|
1991
2113
|
});
|
|
1992
2114
|
|
|
@@ -2048,6 +2170,26 @@
|
|
|
2048
2170
|
this._pendingAdjournAfterRecordStop = false;
|
|
2049
2171
|
try { this.adjournRoom({ skipBrief: true }); }
|
|
2050
2172
|
catch (e) { console.warn("[recorder] pending adjourn failed", e); }
|
|
2173
|
+
} else if (
|
|
2174
|
+
// Vanilla pause-after-current path · the user clicked
|
|
2175
|
+
// "Pause after current director" while a recording was
|
|
2176
|
+
// running. Without an explicit prompt the dialog would
|
|
2177
|
+
// never appear and the recording would keep capturing
|
|
2178
|
+
// a silent room. Surface a save dialog so the user can
|
|
2179
|
+
// commit the clip + optionally adjourn before audio
|
|
2180
|
+
// chunks of dead air pile up. Skip when:
|
|
2181
|
+
// · pauseMode === "hard" · interrupt path (user
|
|
2182
|
+
// already saw the choice modal)
|
|
2183
|
+
// · _pendingAdjournAfterRecordStop · handled above
|
|
2184
|
+
// · _pendingReloadOnPause · user picked reload
|
|
2185
|
+
// · recording-save modal already on screen (defensive)
|
|
2186
|
+
payload.mode === "soft"
|
|
2187
|
+
&& !this._pendingReloadOnPause
|
|
2188
|
+
&& window.BoardroomRecorder
|
|
2189
|
+
&& window.BoardroomRecorder.isRecording()
|
|
2190
|
+
&& !document.getElementById("rec-pause-save-overlay")
|
|
2191
|
+
) {
|
|
2192
|
+
this.openRecordingPauseSaveModal();
|
|
2051
2193
|
}
|
|
2052
2194
|
} else if (kind === "room-resumed") {
|
|
2053
2195
|
if (this.currentRoom) {
|
|
@@ -3542,12 +3684,53 @@
|
|
|
3542
3684
|
`;
|
|
3543
3685
|
}).join("");
|
|
3544
3686
|
document.body.appendChild(pop);
|
|
3545
|
-
// Anchor
|
|
3546
|
-
// `position: fixed` so we set top/left
|
|
3687
|
+
// Anchor relative to the wrapper · `.ap-model-picker` is
|
|
3688
|
+
// `position: fixed` so we set top/left in viewport coords.
|
|
3689
|
+
// Flip-above logic · the house-style dropdown sits in the
|
|
3690
|
+
// SECOND row of the modal and the modal is centred in the
|
|
3691
|
+
// viewport, so the trigger often lands close to the lower
|
|
3692
|
+
// half of the screen. A naive "always below" placement gets
|
|
3693
|
+
// clipped at the viewport bottom (visually: half the list
|
|
3694
|
+
// hidden under the chrome). We measure the rendered popover
|
|
3695
|
+
// height and choose the side with more room — preferring
|
|
3696
|
+
// below by default, flipping above when below is tight and
|
|
3697
|
+
// above has more space. The base `.ap-model-picker` rule
|
|
3698
|
+
// caps height at 60vh + scrolls inside, so a long menu still
|
|
3699
|
+
// fits even after flip.
|
|
3547
3700
|
const r = wrap.getBoundingClientRect();
|
|
3548
|
-
pop.style.top = `${Math.round(r.bottom + 4)}px`;
|
|
3549
3701
|
pop.style.left = `${Math.round(r.left)}px`;
|
|
3550
3702
|
pop.style.width = `${Math.round(r.width)}px`;
|
|
3703
|
+
// First paint to measure; the visible top is intentionally
|
|
3704
|
+
// off-screen until the position decision lands (avoids a
|
|
3705
|
+
// one-frame flash at the wrong location).
|
|
3706
|
+
pop.style.top = "-9999px";
|
|
3707
|
+
pop.style.visibility = "hidden";
|
|
3708
|
+
const PAD = 8;
|
|
3709
|
+
const GAP = 4;
|
|
3710
|
+
const popH = pop.getBoundingClientRect().height;
|
|
3711
|
+
const spaceBelow = Math.max(0, window.innerHeight - r.bottom - PAD);
|
|
3712
|
+
const spaceAbove = Math.max(0, r.top - PAD);
|
|
3713
|
+
let topPx;
|
|
3714
|
+
if (popH + GAP <= spaceBelow) {
|
|
3715
|
+
// Fits below comfortably.
|
|
3716
|
+
topPx = Math.round(r.bottom + GAP);
|
|
3717
|
+
} else if (popH + GAP <= spaceAbove) {
|
|
3718
|
+
// Fits above comfortably · flip.
|
|
3719
|
+
topPx = Math.round(r.top - popH - GAP);
|
|
3720
|
+
} else if (spaceAbove > spaceBelow) {
|
|
3721
|
+
// Neither side fits the full list — pick the bigger side
|
|
3722
|
+
// (above) and cap max-height so the popover's own internal
|
|
3723
|
+
// scroll (`.ap-model-picker { overflow-y: auto }`) handles
|
|
3724
|
+
// the overflow rather than the viewport edge clipping us.
|
|
3725
|
+
topPx = PAD;
|
|
3726
|
+
pop.style.maxHeight = `${spaceAbove}px`;
|
|
3727
|
+
} else {
|
|
3728
|
+
// Stay below · same cap-and-scroll trick.
|
|
3729
|
+
topPx = Math.round(r.bottom + GAP);
|
|
3730
|
+
pop.style.maxHeight = `${spaceBelow}px`;
|
|
3731
|
+
}
|
|
3732
|
+
pop.style.top = `${topPx}px`;
|
|
3733
|
+
pop.style.visibility = "";
|
|
3551
3734
|
// Dismiss handlers · attached synchronously, but the trigger's
|
|
3552
3735
|
// own wrap is whitelisted so the click that OPENED the popover
|
|
3553
3736
|
// (which fires AFTER mousedown · same user gesture) doesn't
|
|
@@ -5055,6 +5238,1296 @@
|
|
|
5055
5238
|
}
|
|
5056
5239
|
},
|
|
5057
5240
|
|
|
5241
|
+
/* ─── Private thread · 1:1 director aside (Phase 1) ───────
|
|
5242
|
+
The user pulls a single director into a Slack-DM-style floating
|
|
5243
|
+
window for a private conversation. Persists as its own `rooms`
|
|
5244
|
+
row (kind="thread") with parent_room_id pointing here, so:
|
|
5245
|
+
· subscribe to its own SSE stream
|
|
5246
|
+
· messages flow through the existing POST /messages route
|
|
5247
|
+
· the orchestrator's buildDirectorContext detects kind==="thread"
|
|
5248
|
+
and seeds the LLM with the parent room's snapshot
|
|
5249
|
+
Storage / drag-window mounting / SSE plumbing all live in
|
|
5250
|
+
these methods. State map below tracks the active windows by
|
|
5251
|
+
threadId so subsequent opens reuse existing windows. */
|
|
5252
|
+
|
|
5253
|
+
/** SVG icon string · shared by the per-bubble trigger and the
|
|
5254
|
+
* head-cast badge. Lucide-style "reply" curve (arrow back to
|
|
5255
|
+
* the speaker) reads as IM-grammar for "respond privately". */
|
|
5256
|
+
_threadTriggerIconSvg() {
|
|
5257
|
+
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>`;
|
|
5258
|
+
},
|
|
5259
|
+
|
|
5260
|
+
/** Discord-style floating message toolbar · appears on article
|
|
5261
|
+
* hover at the top-right of the bubble. Pure icons (no labels)
|
|
5262
|
+
* to keep the toolbar compact; the icon vocabulary is small and
|
|
5263
|
+
* reads instantly on hover. Future per-message actions (quote,
|
|
5264
|
+
* react, pin) slot as additional buttons inside the same
|
|
5265
|
+
* toolbar chrome. Returns "" when no actions apply (user / chair
|
|
5266
|
+
* / excused / thread room / adjourned). */
|
|
5267
|
+
_messageToolbarHtml(m, isUser, isChair, excused, author) {
|
|
5268
|
+
const trigger = this._threadTriggerHtml(m, isUser, isChair, excused, author);
|
|
5269
|
+
if (!trigger) return "";
|
|
5270
|
+
return `<div class="msg-toolbar">${trigger}</div>`;
|
|
5271
|
+
},
|
|
5272
|
+
|
|
5273
|
+
/** Reply chip · icon + "Thread" label as a compact pill. Single
|
|
5274
|
+
* slot inside the Discord-style hover toolbar. Returns "" when
|
|
5275
|
+
* threading doesn't apply (see `_messageToolbarHtml`'s wrapper). */
|
|
5276
|
+
_threadTriggerHtml(m, isUser, isChair, excused, author) {
|
|
5277
|
+
if (isUser || isChair || excused) return "";
|
|
5278
|
+
if (!author || !author.id) return "";
|
|
5279
|
+
// Skip when rendering inside a thread window · `_threadMessageHtml`
|
|
5280
|
+
// flips this flag for the duration of its messageHtml call so
|
|
5281
|
+
// thread bubbles don't ship a "open another thread with this
|
|
5282
|
+
// same director" trigger (which already exists · the user is
|
|
5283
|
+
// looking at that thread right now).
|
|
5284
|
+
if (this._renderingThread) return "";
|
|
5285
|
+
const room = this.currentRoom;
|
|
5286
|
+
// Threads can be spawned from a room in any status · live,
|
|
5287
|
+
// paused, OR adjourned. Re-reading an adjourned session and
|
|
5288
|
+
// wanting to follow up with one director privately is a real
|
|
5289
|
+
// use case ("I want to go deeper on what Socrates said in that
|
|
5290
|
+
// meeting from last week"). The thread itself is its own
|
|
5291
|
+
// independent room — the parent's status doesn't constrain it.
|
|
5292
|
+
// Only suppress when there's no room loaded OR we're already
|
|
5293
|
+
// inside a thread (no nested threads).
|
|
5294
|
+
if (!room) return "";
|
|
5295
|
+
if (room.kind === "thread") return "";
|
|
5296
|
+
const label = this._t("thread_trigger_label") || "Thread";
|
|
5297
|
+
const tip = this._t("thread_trigger_tip", { name: author.name || "" })
|
|
5298
|
+
|| `Pull ${author.name || "this director"} aside privately`;
|
|
5299
|
+
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>`;
|
|
5300
|
+
},
|
|
5301
|
+
|
|
5302
|
+
async openThreadWith(directorId) {
|
|
5303
|
+
if (!this.currentRoomId || !directorId) return;
|
|
5304
|
+
// De-dupe · if a thread window for (this room, this director)
|
|
5305
|
+
// already exists, just restore it. Avoids spawning a duplicate
|
|
5306
|
+
// DB row + duplicate SSE stream when the user clicks the
|
|
5307
|
+
// "thread" trigger repeatedly.
|
|
5308
|
+
this._threads = this._threads || {};
|
|
5309
|
+
for (const [tid, t] of Object.entries(this._threads)) {
|
|
5310
|
+
if (t.director && t.director.id === directorId) {
|
|
5311
|
+
this.restoreThreadWindow(tid);
|
|
5312
|
+
return;
|
|
5313
|
+
}
|
|
5314
|
+
}
|
|
5315
|
+
// In-flight create guard · the de-dupe above misses the race
|
|
5316
|
+
// where the user double-clicks (or two trigger buttons for the
|
|
5317
|
+
// same director fire concurrently): both clicks see _threads
|
|
5318
|
+
// empty, both POST, server creates TWO thread rows, both windows
|
|
5319
|
+
// mount, the user perceives "the app keeps spawning new rooms."
|
|
5320
|
+
// Track creates per-director with a Set so the second click
|
|
5321
|
+
// becomes a no-op until the first POST resolves.
|
|
5322
|
+
this._threadsCreating = this._threadsCreating || new Set();
|
|
5323
|
+
if (this._threadsCreating.has(directorId)) return;
|
|
5324
|
+
this._threadsCreating.add(directorId);
|
|
5325
|
+
// Capture the parent roomId at click time · if the user
|
|
5326
|
+
// navigates to a different room while the POST is in flight,
|
|
5327
|
+
// we still want the thread to attach to the room the click
|
|
5328
|
+
// originated from, not the room currently showing.
|
|
5329
|
+
const parentRoomId = this.currentRoomId;
|
|
5330
|
+
try {
|
|
5331
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(parentRoomId)}/threads`, {
|
|
5332
|
+
method: "POST",
|
|
5333
|
+
headers: { "content-type": "application/json" },
|
|
5334
|
+
body: JSON.stringify({ directorId }),
|
|
5335
|
+
});
|
|
5336
|
+
if (!res.ok) {
|
|
5337
|
+
const err = await res.json().catch(() => ({}));
|
|
5338
|
+
alert((err && err.error) || "Failed to open thread");
|
|
5339
|
+
return;
|
|
5340
|
+
}
|
|
5341
|
+
const data = await res.json();
|
|
5342
|
+
const director = (this.agentsById && this.agentsById[directorId])
|
|
5343
|
+
|| (data.members || []).find((m) => m.id === directorId)
|
|
5344
|
+
|| { id: directorId, name: directorId, avatarPath: "" };
|
|
5345
|
+
// Default to docked mode · the user explicitly asked for the
|
|
5346
|
+
// side-panel as the primary thread surface. Narrow viewports
|
|
5347
|
+
// (`_canDockHere` returns false below 1200px) silently fall
|
|
5348
|
+
// back to the floating mode since the chat-col would
|
|
5349
|
+
// otherwise be squeezed below readable width.
|
|
5350
|
+
this.mountThreadWindow(data.room, director, { docked: this._canDockHere() });
|
|
5351
|
+
} catch (e) {
|
|
5352
|
+
alert((e && e.message) || "Failed to open thread");
|
|
5353
|
+
} finally {
|
|
5354
|
+
this._threadsCreating.delete(directorId);
|
|
5355
|
+
}
|
|
5356
|
+
},
|
|
5357
|
+
|
|
5358
|
+
mountThreadWindow(threadRoom, director, opts) {
|
|
5359
|
+
this._threads = this._threads || {};
|
|
5360
|
+
// Idempotent · if the user already has this thread mounted
|
|
5361
|
+
// (e.g. localStorage restore racing with an active session),
|
|
5362
|
+
// just restore the existing window instead of building a
|
|
5363
|
+
// duplicate DOM.
|
|
5364
|
+
if (this._threads[threadRoom.id]) {
|
|
5365
|
+
this.restoreThreadWindow(threadRoom.id);
|
|
5366
|
+
return;
|
|
5367
|
+
}
|
|
5368
|
+
// Single-thread-per-room rule · the room may only have ONE
|
|
5369
|
+
// thread surfaced at a time (float OR docked). Opening a new
|
|
5370
|
+
// one closes any existing thread first. This sidesteps the
|
|
5371
|
+
// "main chat squeezed by stacked docked panels" problem and
|
|
5372
|
+
// keeps the maximize/restore toggle's mental model simple.
|
|
5373
|
+
const existingIds = Object.keys(this._threads);
|
|
5374
|
+
for (const oldId of existingIds) {
|
|
5375
|
+
if (oldId !== threadRoom.id) this.closeThreadWindow(oldId);
|
|
5376
|
+
}
|
|
5377
|
+
// Threads are PANEL-ONLY · always dock into the active room view
|
|
5378
|
+
// (the floating-window mode was removed). When no room view is
|
|
5379
|
+
// mounted (defensive · the trigger only fires from inside a room)
|
|
5380
|
+
// we fall back to a body-level overlay. On viewports < 1200px the
|
|
5381
|
+
// `.is-docked` CSS @media renders the panel as a fixed overlay,
|
|
5382
|
+
// which is the narrow-screen substitute for a full side panel.
|
|
5383
|
+
void opts;
|
|
5384
|
+
const dockHost = this._threadDockHost();
|
|
5385
|
+
const startDocked = !!dockHost;
|
|
5386
|
+
|
|
5387
|
+
const root = document.createElement("div");
|
|
5388
|
+
root.className = "thread-float" + (startDocked ? " is-docked" : "");
|
|
5389
|
+
root.setAttribute("data-thread-id", threadRoom.id);
|
|
5390
|
+
root.setAttribute("data-director-id", director.id);
|
|
5391
|
+
if (!startDocked) {
|
|
5392
|
+
root.style.right = "24px";
|
|
5393
|
+
root.style.bottom = "24px";
|
|
5394
|
+
}
|
|
5395
|
+
|
|
5396
|
+
const escape = (s) => String(s == null ? "" : s)
|
|
5397
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
5398
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
5399
|
+
const avatarSrc = director.avatarPath ? escape(director.avatarPath) : "";
|
|
5400
|
+
// Title / subtitle resolution · same priority as the popover
|
|
5401
|
+
// row: LLM-distilled `room.name` > truncated `room.subject` >
|
|
5402
|
+
// director's name as last-resort fallback. Director's name
|
|
5403
|
+
// ALWAYS shows in the subtitle so the user reads "what we're
|
|
5404
|
+
// talking about · with whom".
|
|
5405
|
+
const headerTitle = this._resolveThreadTitle(threadRoom, director);
|
|
5406
|
+
const headerSubtitle = director.name || director.id || "";
|
|
5407
|
+
// Head buttons are icon-only (Lucide mask via CSS · see
|
|
5408
|
+
// .thread-float-head-btn rules in thread.css). aria-label
|
|
5409
|
+
// carries the action name for screen readers + the `title`
|
|
5410
|
+
// attribute provides the hover tooltip. No textContent.
|
|
5411
|
+
const minTip = this._t("thread_minimize_tip") || "Minimize";
|
|
5412
|
+
const closeTip = this._t("thread_close_tip") || "Close";
|
|
5413
|
+
const emptyTitle = this._t("thread_empty_title") || "Pull them aside";
|
|
5414
|
+
const emptyDeck = this._t("thread_empty_deck", { name: director.name || "" })
|
|
5415
|
+
|| "What does {name} know that you want to dig into? They have the full room context up to this point.";
|
|
5416
|
+
const placeholder = this._t("thread_composer_placeholder", { name: director.name || "" })
|
|
5417
|
+
|| `Ask ${director.name || "them"} anything…`;
|
|
5418
|
+
const sendLabel = this._t("thread_send") || "Send";
|
|
5419
|
+
|
|
5420
|
+
root.innerHTML = `
|
|
5421
|
+
<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>
|
|
5422
|
+
<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>
|
|
5423
|
+
<header class="thread-float-head" data-thread-drag>
|
|
5424
|
+
${avatarSrc
|
|
5425
|
+
? `<img class="thread-float-head-avatar" src="${avatarSrc}" alt="" data-agent="${escape(director.id)}" title="${escape(director.name || "")}">`
|
|
5426
|
+
: `<span class="thread-float-head-avatar" data-agent="${escape(director.id)}" title="${escape(director.name || "")}"></span>`}
|
|
5427
|
+
<div class="thread-float-meta-wrap thread-float-head-meta">
|
|
5428
|
+
<span class="thread-float-head-title">${escape(headerTitle)}</span>
|
|
5429
|
+
<span class="thread-float-head-subtitle">${escape(headerSubtitle)}</span>
|
|
5430
|
+
</div>
|
|
5431
|
+
<div class="thread-float-head-controls">
|
|
5432
|
+
<button type="button" class="thread-float-head-btn" data-thread-minimize title="${escape(minTip)}" aria-label="${escape(minTip)}"></button>
|
|
5433
|
+
<button type="button" class="thread-float-head-btn" data-thread-close title="${escape(closeTip)}" aria-label="${escape(closeTip)}"></button>
|
|
5434
|
+
</div>
|
|
5435
|
+
</header>
|
|
5436
|
+
<div class="thread-float-messages" data-thread-messages>
|
|
5437
|
+
<div class="thread-float-empty" data-thread-empty>
|
|
5438
|
+
<span class="thread-float-empty-tag">// ${escape(emptyTitle)}</span>
|
|
5439
|
+
<span>${escape(emptyDeck)}</span>
|
|
5440
|
+
</div>
|
|
5441
|
+
</div>
|
|
5442
|
+
<div class="thread-float-composer" data-thread-composer>
|
|
5443
|
+
<div class="thread-float-composer-text-row">
|
|
5444
|
+
<textarea class="thread-float-textarea" data-thread-input placeholder="${escape(placeholder)}" rows="1"></textarea>
|
|
5445
|
+
</div>
|
|
5446
|
+
<div class="thread-float-composer-controls-row">
|
|
5447
|
+
<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>
|
|
5448
|
+
<button type="button" class="thread-float-send" data-thread-send title="${escape(sendLabel)}" aria-label="${escape(sendLabel)}">${escape(sendLabel)}</button>
|
|
5449
|
+
<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>
|
|
5450
|
+
</div>
|
|
5451
|
+
</div>
|
|
5452
|
+
<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>
|
|
5453
|
+
`;
|
|
5454
|
+
// Mount target depends on docked vs float mode.
|
|
5455
|
+
// · Float (default): body-level overlay (fixed positioning).
|
|
5456
|
+
// · Docked: side-by-side with chat-col inside the active
|
|
5457
|
+
// room main-view, so the chat is genuinely beside the
|
|
5458
|
+
// thread and not under a floating chip.
|
|
5459
|
+
if (startDocked && dockHost) {
|
|
5460
|
+
dockHost.appendChild(root);
|
|
5461
|
+
document.body.classList.add("has-thread-dock");
|
|
5462
|
+
} else {
|
|
5463
|
+
document.body.appendChild(root);
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
// Per-thread state · SSE + drag-offset bookkeeping. Stored on
|
|
5467
|
+
// app so the SSE callbacks + drag handlers can find their
|
|
5468
|
+
// window even when many threads are open.
|
|
5469
|
+
const state = {
|
|
5470
|
+
threadId: threadRoom.id,
|
|
5471
|
+
director,
|
|
5472
|
+
room: threadRoom,
|
|
5473
|
+
windowEl: root,
|
|
5474
|
+
sse: null,
|
|
5475
|
+
messages: [],
|
|
5476
|
+
minimized: false,
|
|
5477
|
+
// Threads are panel-only · `docked` is true whenever a room
|
|
5478
|
+
// view exists to host the side panel (the float-window mode +
|
|
5479
|
+
// its maximize/restore toggle were removed). The only time
|
|
5480
|
+
// this is false is the defensive no-room-view fallback, where
|
|
5481
|
+
// the panel renders as a body-level overlay.
|
|
5482
|
+
docked: startDocked,
|
|
5483
|
+
// Drag translate offset · still honoured for the narrow-
|
|
5484
|
+
// viewport fallback where `.is-docked` renders as a fixed,
|
|
5485
|
+
// draggable overlay (see thread.css @media max-width:1200px).
|
|
5486
|
+
dragX: 0,
|
|
5487
|
+
dragY: 0,
|
|
5488
|
+
// User-resized height (px) · null until the user drags the
|
|
5489
|
+
// top edge. Persisted across minimize/restore so the chosen
|
|
5490
|
+
// height survives toggling the chip. The window is bottom-
|
|
5491
|
+
// anchored, so a taller height grows upward.
|
|
5492
|
+
height: null,
|
|
5493
|
+
};
|
|
5494
|
+
this._threads[threadRoom.id] = state;
|
|
5495
|
+
// Persist open-window membership across page reloads · the
|
|
5496
|
+
// thread row itself lives in the DB regardless, but the user's
|
|
5497
|
+
// CHOICE to have a window open for it is client-only state.
|
|
5498
|
+
this._persistThreadOpen(threadRoom.parentRoomId, threadRoom.id, true);
|
|
5499
|
+
|
|
5500
|
+
// Wire interactions
|
|
5501
|
+
root.querySelector("[data-thread-minimize]").addEventListener("click", () => {
|
|
5502
|
+
this.minimizeThreadWindow(threadRoom.id);
|
|
5503
|
+
});
|
|
5504
|
+
root.querySelector("[data-thread-close]").addEventListener("click", () => {
|
|
5505
|
+
this.closeThreadWindow(threadRoom.id);
|
|
5506
|
+
});
|
|
5507
|
+
// Left-edge resize handle · only matters in docked mode (CSS
|
|
5508
|
+
// hides it in float). Wires up unconditionally so the toggle
|
|
5509
|
+
// between float/dock doesn't need to re-bind.
|
|
5510
|
+
const dockResizeHandle = root.querySelector("[data-thread-dock-resize]");
|
|
5511
|
+
if (dockResizeHandle) this._wireThreadDockResize(dockResizeHandle);
|
|
5512
|
+
const dragHead = root.querySelector("[data-thread-drag]");
|
|
5513
|
+
this._wireThreadDrag(dragHead, state);
|
|
5514
|
+
root.querySelectorAll("[data-thread-resize]").forEach((h) => {
|
|
5515
|
+
this._wireThreadResize(h, state, h.getAttribute("data-thread-resize") === "bottom" ? "bottom" : "top");
|
|
5516
|
+
});
|
|
5517
|
+
const sendBtn = root.querySelector("[data-thread-send]");
|
|
5518
|
+
const stopBtn = root.querySelector("[data-thread-stop]");
|
|
5519
|
+
const jumpTopBtn = root.querySelector("[data-thread-jump-top]");
|
|
5520
|
+
const input = root.querySelector("[data-thread-input]");
|
|
5521
|
+
sendBtn.addEventListener("click", () => this.sendThreadMessage(threadRoom.id));
|
|
5522
|
+
stopBtn.addEventListener("click", () => this.stopThreadDirector(threadRoom.id));
|
|
5523
|
+
// Jump-to-top · mirror of the room input-bar's `[data-jump-to-opener]`
|
|
5524
|
+
// affordance · scroll the thread message list back to its first
|
|
5525
|
+
// message. Smooth-scrolling the messages container itself rather
|
|
5526
|
+
// than the viewport · the thread panel has its own scroll context
|
|
5527
|
+
// (absolute-positioned `.thread-float-messages`).
|
|
5528
|
+
if (jumpTopBtn) {
|
|
5529
|
+
jumpTopBtn.addEventListener("click", () => {
|
|
5530
|
+
const list = root.querySelector("[data-thread-messages]");
|
|
5531
|
+
if (list) list.scrollTop = 0;
|
|
5532
|
+
});
|
|
5533
|
+
}
|
|
5534
|
+
input.addEventListener("keydown", (e) => {
|
|
5535
|
+
if (e.key !== "Enter" || e.shiftKey) return;
|
|
5536
|
+
// IME composition guard · while a Chinese / Japanese / Korean
|
|
5537
|
+
// input method is open (pinyin candidate row visible, kana
|
|
5538
|
+
// pre-conversion buffer, etc.) the Enter key is the user
|
|
5539
|
+
// committing their candidate selection, NOT submitting the
|
|
5540
|
+
// message. Firing send here would dispatch the half-typed
|
|
5541
|
+
// raw romanisation. `isComposing` is the standardised
|
|
5542
|
+
// property; `keyCode === 229` is the legacy Chrome/Edge
|
|
5543
|
+
// signal that fires before isComposing flips in some IME
|
|
5544
|
+
// engines. Both checks together cover the matrix.
|
|
5545
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
5546
|
+
e.preventDefault();
|
|
5547
|
+
this.sendThreadMessage(threadRoom.id);
|
|
5548
|
+
});
|
|
5549
|
+
// Auto-grow textarea up to max-height · 200px ceiling matches
|
|
5550
|
+
// the room's `.ib-textarea` so a long compose feels identical
|
|
5551
|
+
// across both surfaces (CSS cap is also 200px).
|
|
5552
|
+
input.addEventListener("input", () => {
|
|
5553
|
+
input.style.height = "auto";
|
|
5554
|
+
input.style.height = Math.min(200, input.scrollHeight) + "px";
|
|
5555
|
+
});
|
|
5556
|
+
// Quote seed · the quote-cta bar's Thread button stashes the
|
|
5557
|
+
// selected director text on `_threadPendingQuote[directorId]`
|
|
5558
|
+
// BEFORE calling openThreadWith. Consume it here so the new
|
|
5559
|
+
// thread's composer opens with a markdown blockquote ready —
|
|
5560
|
+
// the user just types their follow-up question and hits send.
|
|
5561
|
+
// Cleared after consumption so subsequent opens don't re-paste.
|
|
5562
|
+
const pending = this._threadPendingQuote && this._threadPendingQuote[director.id];
|
|
5563
|
+
if (pending && pending.text) {
|
|
5564
|
+
const quoteLines = String(pending.text).split(/\r?\n/).map((l) => "> " + l);
|
|
5565
|
+
if (pending.directorName) quoteLines.push("> — @" + pending.directorName);
|
|
5566
|
+
input.value = quoteLines.join("\n") + "\n\n";
|
|
5567
|
+
// Trigger autosize after the seed lands.
|
|
5568
|
+
input.style.height = "auto";
|
|
5569
|
+
input.style.height = Math.min(200, input.scrollHeight) + "px";
|
|
5570
|
+
// Cursor goes after the blank line so the user types
|
|
5571
|
+
// immediately at the follow-up position.
|
|
5572
|
+
const pos = input.value.length;
|
|
5573
|
+
try { input.setSelectionRange(pos, pos); } catch (_) {}
|
|
5574
|
+
delete this._threadPendingQuote[director.id];
|
|
5575
|
+
}
|
|
5576
|
+
setTimeout(() => { try { input.focus(); } catch (_) {} }, 30);
|
|
5577
|
+
|
|
5578
|
+
// SSE subscription · the thread's own per-room stream. We get
|
|
5579
|
+
// message-appended / message-token / message-final / message-updated
|
|
5580
|
+
// for the thread's messages; main room events fan out via the
|
|
5581
|
+
// main app.sse and don't reach here.
|
|
5582
|
+
try {
|
|
5583
|
+
const es = new EventSource(`/api/rooms/${encodeURIComponent(threadRoom.id)}/stream`);
|
|
5584
|
+
state.sse = es;
|
|
5585
|
+
es.addEventListener("message-appended", (ev) => {
|
|
5586
|
+
const data = JSON.parse(ev.data);
|
|
5587
|
+
this._threadAppendMessage(threadRoom.id, data);
|
|
5588
|
+
});
|
|
5589
|
+
es.addEventListener("message-token", (ev) => {
|
|
5590
|
+
const data = JSON.parse(ev.data);
|
|
5591
|
+
this._threadApplyToken(threadRoom.id, data);
|
|
5592
|
+
});
|
|
5593
|
+
es.addEventListener("message-updated", (ev) => {
|
|
5594
|
+
const data = JSON.parse(ev.data);
|
|
5595
|
+
this._threadUpdateMessage(threadRoom.id, data);
|
|
5596
|
+
});
|
|
5597
|
+
es.addEventListener("message-final", (ev) => {
|
|
5598
|
+
const data = JSON.parse(ev.data);
|
|
5599
|
+
this._threadFinalizeMessage(threadRoom.id, data);
|
|
5600
|
+
});
|
|
5601
|
+
es.addEventListener("message-removed", (ev) => {
|
|
5602
|
+
const data = JSON.parse(ev.data);
|
|
5603
|
+
this._threadRemoveMessage(threadRoom.id, data);
|
|
5604
|
+
});
|
|
5605
|
+
} catch (e) {
|
|
5606
|
+
console.warn("[thread] SSE attach failed:", e);
|
|
5607
|
+
}
|
|
5608
|
+
},
|
|
5609
|
+
|
|
5610
|
+
closeThreadWindow(threadId) {
|
|
5611
|
+
const state = this._threads && this._threads[threadId];
|
|
5612
|
+
if (!state) return;
|
|
5613
|
+
if (state.sse) {
|
|
5614
|
+
try { state.sse.close(); } catch (_) {}
|
|
5615
|
+
state.sse = null;
|
|
5616
|
+
}
|
|
5617
|
+
if (state.windowEl && state.windowEl.parentNode) {
|
|
5618
|
+
state.windowEl.parentNode.removeChild(state.windowEl);
|
|
5619
|
+
}
|
|
5620
|
+
const parentId = state.room && state.room.parentRoomId;
|
|
5621
|
+
delete this._threads[threadId];
|
|
5622
|
+
if (parentId) this._persistThreadOpen(parentId, threadId, false);
|
|
5623
|
+
// If no docked thread remains, drop the body class that
|
|
5624
|
+
// squeezed the chat-col into its left grid column.
|
|
5625
|
+
const anyDocked = Object.values(this._threads || {}).some((s) => s && s.docked);
|
|
5626
|
+
if (!anyDocked) document.body.classList.remove("has-thread-dock");
|
|
5627
|
+
},
|
|
5628
|
+
|
|
5629
|
+
/** Title shown in the panel header AND in the popover row · same
|
|
5630
|
+
* priority chain as `openThreadsListPopover` so both surfaces
|
|
5631
|
+
* read the same string. LLM-distilled `room.name` wins; until
|
|
5632
|
+
* the title-gen pipeline completes (or for legacy threads
|
|
5633
|
+
* with name_auto=0), the truncated subject stands in; the
|
|
5634
|
+
* director's name is a last-resort fallback for fresh threads
|
|
5635
|
+
* with no user message yet. */
|
|
5636
|
+
_resolveThreadTitle(threadRoom, director) {
|
|
5637
|
+
if (!threadRoom) return (director && director.name) || "";
|
|
5638
|
+
const isPlaceholder = typeof threadRoom.name === "string"
|
|
5639
|
+
&& /^thread:/.test(threadRoom.name);
|
|
5640
|
+
if (!isPlaceholder && typeof threadRoom.name === "string" && threadRoom.name.trim()) {
|
|
5641
|
+
return threadRoom.name.trim();
|
|
5642
|
+
}
|
|
5643
|
+
if (typeof threadRoom.subject === "string" && threadRoom.subject.trim()) {
|
|
5644
|
+
const subj = threadRoom.subject.trim();
|
|
5645
|
+
return subj.length > 60 ? subj.slice(0, 60) + "…" : subj;
|
|
5646
|
+
}
|
|
5647
|
+
return (director && director.name) || "";
|
|
5648
|
+
},
|
|
5649
|
+
|
|
5650
|
+
/** Is the current viewport wide enough to honour docked mode?
|
|
5651
|
+
* Below ~1200px the 420px panel would squeeze the chat to an
|
|
5652
|
+
* uncomfortable width; in that case we keep the thread float-
|
|
5653
|
+
* only regardless of the user's maximize click. */
|
|
5654
|
+
_canDockHere() {
|
|
5655
|
+
try {
|
|
5656
|
+
const w = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
5657
|
+
return w >= 1200;
|
|
5658
|
+
} catch (_) { return false; }
|
|
5659
|
+
},
|
|
5660
|
+
|
|
5661
|
+
/** Where to mount a docked thread panel · the active room
|
|
5662
|
+
* main-view, so the panel becomes a flex/grid sibling of the
|
|
5663
|
+
* chat-col. Returns null if no room view is active (defensive
|
|
5664
|
+
* · should never fire because the trigger is only reachable
|
|
5665
|
+
* from inside a room). */
|
|
5666
|
+
_threadDockHost() {
|
|
5667
|
+
const view = document.querySelector('.main-view[data-main-view="room"]');
|
|
5668
|
+
return view && !view.hidden ? view : null;
|
|
5669
|
+
},
|
|
5670
|
+
|
|
5671
|
+
/** Wire the left-edge resize handle so the user can drag-widen
|
|
5672
|
+
* the docked panel. Reads pointer-x on move, computes the new
|
|
5673
|
+
* width as `viewport_width - pointer_x` (panel is anchored to
|
|
5674
|
+
* the right edge), and writes the value to `--thread-dock-w`
|
|
5675
|
+
* on document body. CSS clamps the variable into a sane range
|
|
5676
|
+
* (320-720) so the chat-col never collapses below input-bar
|
|
5677
|
+
* content width. Persists to localStorage so the user's chosen
|
|
5678
|
+
* width survives page reload. */
|
|
5679
|
+
_wireThreadDockResize(handle) {
|
|
5680
|
+
// Hydrate any saved width on first wire so the dock opens at
|
|
5681
|
+
// the user's last-chosen size.
|
|
5682
|
+
const restorePersistedWidth = () => {
|
|
5683
|
+
try {
|
|
5684
|
+
const raw = localStorage.getItem("pb.thread.dock.width");
|
|
5685
|
+
const n = raw ? parseInt(raw, 10) : 0;
|
|
5686
|
+
if (n >= 320 && n <= 720) {
|
|
5687
|
+
document.body.style.setProperty("--thread-dock-w", `${n}px`);
|
|
5688
|
+
}
|
|
5689
|
+
} catch (_) { /* */ }
|
|
5690
|
+
};
|
|
5691
|
+
restorePersistedWidth();
|
|
5692
|
+
|
|
5693
|
+
let dragging = false;
|
|
5694
|
+
let rafId = 0;
|
|
5695
|
+
let pendingX = 0;
|
|
5696
|
+
const flush = () => {
|
|
5697
|
+
rafId = 0;
|
|
5698
|
+
if (!dragging) return;
|
|
5699
|
+
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
5700
|
+
// Panel sits on the right; new width = distance from
|
|
5701
|
+
// viewport right edge to pointer. Clamp to handle range so
|
|
5702
|
+
// mid-drag the CSS clamp doesn't lag behind the variable.
|
|
5703
|
+
const next = Math.max(320, Math.min(720, vw - pendingX));
|
|
5704
|
+
document.body.style.setProperty("--thread-dock-w", `${next}px`);
|
|
5705
|
+
};
|
|
5706
|
+
const onMove = (e) => {
|
|
5707
|
+
if (!dragging) return;
|
|
5708
|
+
if (e.cancelable) e.preventDefault();
|
|
5709
|
+
pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
|
|
5710
|
+
if (!rafId) rafId = requestAnimationFrame(flush);
|
|
5711
|
+
};
|
|
5712
|
+
const onUp = () => {
|
|
5713
|
+
if (!dragging) return;
|
|
5714
|
+
dragging = false;
|
|
5715
|
+
document.body.classList.remove("is-thread-dock-resizing");
|
|
5716
|
+
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
5717
|
+
document.removeEventListener("mousemove", onMove);
|
|
5718
|
+
document.removeEventListener("mouseup", onUp);
|
|
5719
|
+
document.removeEventListener("touchmove", onMove);
|
|
5720
|
+
document.removeEventListener("touchend", onUp);
|
|
5721
|
+
// Persist whatever value we landed on. Read back the
|
|
5722
|
+
// computed style rather than the inline string so any
|
|
5723
|
+
// clamp-by-CSS adjustment is honoured.
|
|
5724
|
+
try {
|
|
5725
|
+
const v = document.body.style.getPropertyValue("--thread-dock-w");
|
|
5726
|
+
const n = parseInt(v, 10);
|
|
5727
|
+
if (n >= 320 && n <= 720) {
|
|
5728
|
+
localStorage.setItem("pb.thread.dock.width", String(n));
|
|
5729
|
+
}
|
|
5730
|
+
} catch (_) { /* private mode · ignore */ }
|
|
5731
|
+
};
|
|
5732
|
+
const onDown = (e) => {
|
|
5733
|
+
// Only honour the drag when the panel is actually docked ·
|
|
5734
|
+
// CSS already hides the handle off-screen otherwise, but
|
|
5735
|
+
// defensive guard for keyboard / programmatic invocations.
|
|
5736
|
+
const panel = handle.closest(".thread-float");
|
|
5737
|
+
if (!panel || !panel.classList.contains("is-docked")) return;
|
|
5738
|
+
dragging = true;
|
|
5739
|
+
document.body.classList.add("is-thread-dock-resizing");
|
|
5740
|
+
pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
|
|
5741
|
+
document.addEventListener("mousemove", onMove);
|
|
5742
|
+
document.addEventListener("mouseup", onUp);
|
|
5743
|
+
document.addEventListener("touchmove", onMove, { passive: false });
|
|
5744
|
+
document.addEventListener("touchend", onUp);
|
|
5745
|
+
};
|
|
5746
|
+
handle.addEventListener("mousedown", onDown);
|
|
5747
|
+
handle.addEventListener("touchstart", onDown, { passive: true });
|
|
5748
|
+
},
|
|
5749
|
+
|
|
5750
|
+
/** Client-side membership · which thread IDs the user has a
|
|
5751
|
+
* window open for, keyed by parent roomId. localStorage so the
|
|
5752
|
+
* set survives page reload. Stored as a JSON array per room
|
|
5753
|
+
* under `pb.thread.open.<roomId>` (mirrors the rest of the
|
|
5754
|
+
* app's `pb.*` localStorage namespace). */
|
|
5755
|
+
_persistThreadOpen(parentRoomId, threadId, isOpen) {
|
|
5756
|
+
if (!parentRoomId || !threadId) return;
|
|
5757
|
+
const key = `pb.thread.open.${parentRoomId}`;
|
|
5758
|
+
try {
|
|
5759
|
+
const raw = localStorage.getItem(key);
|
|
5760
|
+
const ids = raw ? JSON.parse(raw) : [];
|
|
5761
|
+
const next = isOpen
|
|
5762
|
+
? Array.from(new Set([...ids, threadId]))
|
|
5763
|
+
: ids.filter((id) => id !== threadId);
|
|
5764
|
+
if (next.length) localStorage.setItem(key, JSON.stringify(next));
|
|
5765
|
+
else localStorage.removeItem(key);
|
|
5766
|
+
} catch (_) { /* quota / private mode · silently degrade */ }
|
|
5767
|
+
},
|
|
5768
|
+
|
|
5769
|
+
_persistedThreadOpen(parentRoomId) {
|
|
5770
|
+
if (!parentRoomId) return [];
|
|
5771
|
+
try {
|
|
5772
|
+
const raw = localStorage.getItem(`pb.thread.open.${parentRoomId}`);
|
|
5773
|
+
const arr = raw ? JSON.parse(raw) : [];
|
|
5774
|
+
return Array.isArray(arr) ? arr : [];
|
|
5775
|
+
} catch { return []; }
|
|
5776
|
+
},
|
|
5777
|
+
|
|
5778
|
+
/** Re-mount any thread windows the user previously had open for
|
|
5779
|
+
* the current room. Called from openRoom AFTER members /
|
|
5780
|
+
* agentsById are populated so the director resolution works.
|
|
5781
|
+
* Threads whose IDs no longer exist (deleted out from under
|
|
5782
|
+
* the user) are quietly dropped from the persisted list. */
|
|
5783
|
+
async restoreThreadsForRoom(parentRoomId) {
|
|
5784
|
+
if (!parentRoomId) return;
|
|
5785
|
+
const wanted = this._persistedThreadOpen(parentRoomId);
|
|
5786
|
+
if (wanted.length === 0) return;
|
|
5787
|
+
// Pull the canonical list from the server so we know which
|
|
5788
|
+
// threads still exist. The persisted IDs are advisory.
|
|
5789
|
+
let threads = [];
|
|
5790
|
+
try {
|
|
5791
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(parentRoomId)}/threads`);
|
|
5792
|
+
if (res.ok) {
|
|
5793
|
+
const data = await res.json();
|
|
5794
|
+
threads = Array.isArray(data.threads) ? data.threads : [];
|
|
5795
|
+
}
|
|
5796
|
+
} catch { return; }
|
|
5797
|
+
const live = new Map(threads.map((t) => [t.id, t]));
|
|
5798
|
+
const stillOpen = [];
|
|
5799
|
+
for (const id of wanted) {
|
|
5800
|
+
const room = live.get(id);
|
|
5801
|
+
if (!room) continue;
|
|
5802
|
+
const director = (this.agentsById && this.agentsById[room.threadDirectorId])
|
|
5803
|
+
|| { id: room.threadDirectorId, name: room.threadDirectorId, avatarPath: "" };
|
|
5804
|
+
// Restore in the same mode the user opens NEW threads with.
|
|
5805
|
+
// localStorage doesn't track float-vs-dock yet; default to
|
|
5806
|
+
// dock when the viewport allows so the user's last session's
|
|
5807
|
+
// dock preference is honoured implicitly.
|
|
5808
|
+
this.mountThreadWindow(room, director, { docked: this._canDockHere() });
|
|
5809
|
+
stillOpen.push(id);
|
|
5810
|
+
// Seed the transcript · /threads list endpoint only returns
|
|
5811
|
+
// room metadata, so we fetch the per-thread state to get
|
|
5812
|
+
// messages and paint them into the freshly-mounted window.
|
|
5813
|
+
// Fire-and-forget · each thread loads independently and the
|
|
5814
|
+
// SSE stream picks up new messages from this point on.
|
|
5815
|
+
fetch(`/api/rooms/${encodeURIComponent(id)}`)
|
|
5816
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
5817
|
+
.then((data) => {
|
|
5818
|
+
if (!data || !Array.isArray(data.messages) || data.messages.length === 0) return;
|
|
5819
|
+
this._seedThreadHistory(id, data.messages);
|
|
5820
|
+
})
|
|
5821
|
+
.catch(() => { /* per-thread network blip · log via the SSE error path */ });
|
|
5822
|
+
}
|
|
5823
|
+
// Prune the persisted list to what actually exists · keeps the
|
|
5824
|
+
// localStorage entry from accumulating stale IDs forever.
|
|
5825
|
+
try {
|
|
5826
|
+
const key = `pb.thread.open.${parentRoomId}`;
|
|
5827
|
+
if (stillOpen.length) localStorage.setItem(key, JSON.stringify(stillOpen));
|
|
5828
|
+
else localStorage.removeItem(key);
|
|
5829
|
+
} catch (_) { /* */ }
|
|
5830
|
+
},
|
|
5831
|
+
|
|
5832
|
+
/** Open a popover anchored under the head-threads icon listing
|
|
5833
|
+
* every thread (active or dormant) attached to this main room.
|
|
5834
|
+
* Each row is a click target that mounts / restores the thread
|
|
5835
|
+
* window. ESC + outside-click dismiss. */
|
|
5836
|
+
async openThreadsListPopover(anchorBtn) {
|
|
5837
|
+
this.closeThreadsListPopover();
|
|
5838
|
+
if (!this.currentRoomId) return;
|
|
5839
|
+
const pop = document.createElement("div");
|
|
5840
|
+
pop.id = "thread-list-pop";
|
|
5841
|
+
pop.className = "thread-list-pop";
|
|
5842
|
+
const title = this._t("threads_list_title") || "Private threads";
|
|
5843
|
+
const loading = this._t("threads_list_loading") || "Loading…";
|
|
5844
|
+
pop.innerHTML = `
|
|
5845
|
+
<div class="thread-list-head">${this.escape(title)}</div>
|
|
5846
|
+
<div class="thread-list-body" data-threads-body>
|
|
5847
|
+
<div class="thread-list-empty">${this.escape(loading)}</div>
|
|
5848
|
+
</div>
|
|
5849
|
+
`;
|
|
5850
|
+
document.body.appendChild(pop);
|
|
5851
|
+
// Anchor under the trigger button · `position: fixed` so values
|
|
5852
|
+
// are viewport coords. Right-align so the popover stays under
|
|
5853
|
+
// the icon when the head row contracts on narrow viewports.
|
|
5854
|
+
const r = anchorBtn.getBoundingClientRect();
|
|
5855
|
+
const POP_WIDTH = 320;
|
|
5856
|
+
pop.style.top = `${Math.round(r.bottom + 6)}px`;
|
|
5857
|
+
const rightSpace = window.innerWidth - r.right;
|
|
5858
|
+
pop.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - POP_WIDTH - 8, r.right - POP_WIDTH)))}px`;
|
|
5859
|
+
void rightSpace;
|
|
5860
|
+
|
|
5861
|
+
// Dismissal · outside click + ESC. Same pattern as
|
|
5862
|
+
// closeCastEditOverlay's wiring.
|
|
5863
|
+
this._threadsPopDocClick = (e) => {
|
|
5864
|
+
if (e.target.closest("#thread-list-pop")) return;
|
|
5865
|
+
if (e.target.closest("[data-threads-trigger]")) return;
|
|
5866
|
+
this.closeThreadsListPopover();
|
|
5867
|
+
};
|
|
5868
|
+
this._threadsPopEsc = (e) => {
|
|
5869
|
+
if (e.key === "Escape") {
|
|
5870
|
+
e.preventDefault();
|
|
5871
|
+
this.closeThreadsListPopover();
|
|
5872
|
+
}
|
|
5873
|
+
};
|
|
5874
|
+
setTimeout(() => document.addEventListener("click", this._threadsPopDocClick, true), 0);
|
|
5875
|
+
document.addEventListener("keydown", this._threadsPopEsc, true);
|
|
5876
|
+
|
|
5877
|
+
// Fetch threads · canonical source is the server's
|
|
5878
|
+
// /api/rooms/:id/threads endpoint we already shipped.
|
|
5879
|
+
let threads = [];
|
|
5880
|
+
try {
|
|
5881
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(this.currentRoomId)}/threads`);
|
|
5882
|
+
if (res.ok) {
|
|
5883
|
+
const data = await res.json();
|
|
5884
|
+
threads = Array.isArray(data.threads) ? data.threads : [];
|
|
5885
|
+
}
|
|
5886
|
+
} catch (_) { /* network · render empty state */ }
|
|
5887
|
+
|
|
5888
|
+
const body = pop.querySelector("[data-threads-body]");
|
|
5889
|
+
if (!body) return;
|
|
5890
|
+
|
|
5891
|
+
// Filter rules:
|
|
5892
|
+
// - messageCount === 0 · empty threads (user opened a thread
|
|
5893
|
+
// from the trigger but never sent a message) are clutter,
|
|
5894
|
+
// hide them.
|
|
5895
|
+
// - Dedupe by director id · keep ONLY the newest thread per
|
|
5896
|
+
// director. Older duplicates (created by past race bugs,
|
|
5897
|
+
// or by manual API hits) collapse so the user sees one row
|
|
5898
|
+
// per director — which is the mental model the rest of the
|
|
5899
|
+
// UI assumes (open-thread-with-director, not per-message).
|
|
5900
|
+
// `threads` arrives newest-first from the server, so the
|
|
5901
|
+
// first occurrence per directorId is the winner.
|
|
5902
|
+
const seenDirectors = new Set();
|
|
5903
|
+
const visible = [];
|
|
5904
|
+
for (const t of threads) {
|
|
5905
|
+
if (typeof t.messageCount === "number" && t.messageCount === 0) continue;
|
|
5906
|
+
if (t.threadDirectorId && seenDirectors.has(t.threadDirectorId)) continue;
|
|
5907
|
+
if (t.threadDirectorId) seenDirectors.add(t.threadDirectorId);
|
|
5908
|
+
visible.push(t);
|
|
5909
|
+
}
|
|
5910
|
+
|
|
5911
|
+
if (visible.length === 0) {
|
|
5912
|
+
const emptyMsg = this._t("threads_list_empty")
|
|
5913
|
+
|| "No private threads yet · open one from the // Thread button on any director's message.";
|
|
5914
|
+
// Placeholder icon · Lucide MessagesSquare (two chat bubbles,
|
|
5915
|
+
// matching the head-threads trigger glyph) so the empty
|
|
5916
|
+
// state reads as "this is where your private threads
|
|
5917
|
+
// would appear" rather than as a bare line of text.
|
|
5918
|
+
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>`;
|
|
5919
|
+
body.innerHTML = `
|
|
5920
|
+
<div class="thread-list-empty">
|
|
5921
|
+
<span class="thread-list-empty-icon" aria-hidden="true">${placeholderIcon}</span>
|
|
5922
|
+
<span>${this.escape(emptyMsg)}</span>
|
|
5923
|
+
</div>
|
|
5924
|
+
`;
|
|
5925
|
+
return;
|
|
5926
|
+
}
|
|
5927
|
+
const activeLabel = this._t("threads_list_active") || "open";
|
|
5928
|
+
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>`;
|
|
5929
|
+
const deleteTip = this._t("threads_list_delete_tip") || "Delete this thread";
|
|
5930
|
+
|
|
5931
|
+
body.innerHTML = visible.map((t) => {
|
|
5932
|
+
const director = (this.agentsById && this.agentsById[t.threadDirectorId])
|
|
5933
|
+
|| { name: t.threadDirectorId || "?", avatarPath: "" };
|
|
5934
|
+
const avatarSrc = director.avatarPath ? this.escape(director.avatarPath) : "";
|
|
5935
|
+
const ts = this.timeFmt ? this.timeFmt(t.createdAt) : "";
|
|
5936
|
+
const isOpen = !!(this._threads && this._threads[t.id]);
|
|
5937
|
+
// Title resolution · ALWAYS surface the topic phrase (same
|
|
5938
|
+
// convention as the sidebar's room titles). Priority:
|
|
5939
|
+
// 1. LLM-distilled `room.name` (set by generateRoomTitle
|
|
5940
|
+
// after the first user message · the canonical short
|
|
5941
|
+
// phrase mode)
|
|
5942
|
+
// 2. `room.subject` truncated (the raw first user message
|
|
5943
|
+
// · interim state while the LLM call is in flight or
|
|
5944
|
+
// if it failed to land a phrase)
|
|
5945
|
+
// 3. Director name (last-resort fallback for threads with
|
|
5946
|
+
// no user message yet — shouldn't appear in practice
|
|
5947
|
+
// since we filter messageCount === 0 above)
|
|
5948
|
+
// The director's name + timestamp ALWAYS live in the
|
|
5949
|
+
// subtitle, regardless of which title path was picked.
|
|
5950
|
+
const isPlaceholderName = typeof t.name === "string" && /^thread:/.test(t.name);
|
|
5951
|
+
let title = "";
|
|
5952
|
+
if (!isPlaceholderName && typeof t.name === "string" && t.name.trim()) {
|
|
5953
|
+
title = t.name.trim();
|
|
5954
|
+
} else if (typeof t.subject === "string" && t.subject.trim()) {
|
|
5955
|
+
const subj = t.subject.trim();
|
|
5956
|
+
title = subj.length > 60 ? subj.slice(0, 60) + "…" : subj;
|
|
5957
|
+
} else {
|
|
5958
|
+
title = director.name || "";
|
|
5959
|
+
}
|
|
5960
|
+
const subtitleParts = [];
|
|
5961
|
+
if (director.name) subtitleParts.push(director.name);
|
|
5962
|
+
if (ts) subtitleParts.push(ts);
|
|
5963
|
+
const subtitle = subtitleParts.join(" · ");
|
|
5964
|
+
return `
|
|
5965
|
+
<div class="thread-list-row">
|
|
5966
|
+
<button type="button" class="thread-list-row-body" data-thread-open="${this.escape(t.id)}">
|
|
5967
|
+
${avatarSrc ? `<img class="thread-list-row-av" src="${avatarSrc}" alt="">` : `<span class="thread-list-row-av"></span>`}
|
|
5968
|
+
<span class="thread-list-row-meta">
|
|
5969
|
+
<span class="thread-list-row-name">${this.escape(title)}</span>
|
|
5970
|
+
<span class="thread-list-row-time">${this.escape(subtitle)}</span>
|
|
5971
|
+
</span>
|
|
5972
|
+
${isOpen ? `<span class="thread-list-row-active">// ${this.escape(activeLabel)}</span>` : ""}
|
|
5973
|
+
</button>
|
|
5974
|
+
<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)}">
|
|
5975
|
+
${trashIcon}
|
|
5976
|
+
</button>
|
|
5977
|
+
</div>
|
|
5978
|
+
`;
|
|
5979
|
+
}).join("");
|
|
5980
|
+
body.querySelectorAll("[data-thread-open]").forEach((row) => {
|
|
5981
|
+
row.addEventListener("click", (e) => {
|
|
5982
|
+
e.stopPropagation();
|
|
5983
|
+
const id = row.getAttribute("data-thread-open");
|
|
5984
|
+
this.closeThreadsListPopover();
|
|
5985
|
+
if (id) this.openExistingThread(id);
|
|
5986
|
+
});
|
|
5987
|
+
});
|
|
5988
|
+
body.querySelectorAll("[data-thread-delete]").forEach((btn) => {
|
|
5989
|
+
btn.addEventListener("click", (e) => {
|
|
5990
|
+
e.preventDefault();
|
|
5991
|
+
e.stopPropagation();
|
|
5992
|
+
const id = btn.getAttribute("data-thread-delete");
|
|
5993
|
+
const dirName = btn.getAttribute("data-director-name") || "";
|
|
5994
|
+
if (id) this.deleteThread(id, dirName, anchorBtn);
|
|
5995
|
+
});
|
|
5996
|
+
});
|
|
5997
|
+
},
|
|
5998
|
+
|
|
5999
|
+
/** Permanently delete a thread room · routes through the standard
|
|
6000
|
+
* DELETE /api/rooms/:id which cascades messages + members.
|
|
6001
|
+
* Confirms via `window.confirm` since the action is destructive
|
|
6002
|
+
* and not undoable. Closes any open window for the same thread,
|
|
6003
|
+
* prunes localStorage, and re-renders the popover. */
|
|
6004
|
+
async deleteThread(threadId, directorName, anchorBtn) {
|
|
6005
|
+
if (!threadId) return;
|
|
6006
|
+
const tmpl = this._t("threads_list_delete_confirm")
|
|
6007
|
+
|| "Delete the private thread with {name}? This can't be undone.";
|
|
6008
|
+
const msg = tmpl.replace("{name}", directorName || "this director");
|
|
6009
|
+
if (!window.confirm(msg)) return;
|
|
6010
|
+
try {
|
|
6011
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}`, { method: "DELETE" });
|
|
6012
|
+
if (!res.ok) {
|
|
6013
|
+
const err = await res.json().catch(() => ({}));
|
|
6014
|
+
alert((err && err.error) || "Delete failed");
|
|
6015
|
+
return;
|
|
6016
|
+
}
|
|
6017
|
+
} catch (e) {
|
|
6018
|
+
alert((e && e.message) || "Delete failed");
|
|
6019
|
+
return;
|
|
6020
|
+
}
|
|
6021
|
+
// Tear down any open window for this thread + drop from
|
|
6022
|
+
// persistence. closeThreadWindow does both.
|
|
6023
|
+
if (this._threads && this._threads[threadId]) {
|
|
6024
|
+
this.closeThreadWindow(threadId);
|
|
6025
|
+
}
|
|
6026
|
+
// Refresh the popover so the row disappears. Reuse the
|
|
6027
|
+
// original trigger as the anchor — it's still on screen since
|
|
6028
|
+
// we never navigated away.
|
|
6029
|
+
const trigger = anchorBtn || document.querySelector("[data-threads-trigger]");
|
|
6030
|
+
this.closeThreadsListPopover();
|
|
6031
|
+
if (trigger) this.openThreadsListPopover(trigger);
|
|
6032
|
+
},
|
|
6033
|
+
|
|
6034
|
+
closeThreadsListPopover() {
|
|
6035
|
+
const el = document.getElementById("thread-list-pop");
|
|
6036
|
+
if (el) el.remove();
|
|
6037
|
+
if (this._threadsPopDocClick) {
|
|
6038
|
+
document.removeEventListener("click", this._threadsPopDocClick, true);
|
|
6039
|
+
this._threadsPopDocClick = null;
|
|
6040
|
+
}
|
|
6041
|
+
if (this._threadsPopEsc) {
|
|
6042
|
+
document.removeEventListener("keydown", this._threadsPopEsc, true);
|
|
6043
|
+
this._threadsPopEsc = null;
|
|
6044
|
+
}
|
|
6045
|
+
},
|
|
6046
|
+
|
|
6047
|
+
/** Mount a window for an EXISTING thread (one the user hasn't
|
|
6048
|
+
* got open right now but exists in the DB). Used by the
|
|
6049
|
+
* "Threads" header popover entries · the user clicks a row to
|
|
6050
|
+
* resume an old aside without spawning a new one. */
|
|
6051
|
+
async openExistingThread(threadId) {
|
|
6052
|
+
if (!threadId) return;
|
|
6053
|
+
this._threads = this._threads || {};
|
|
6054
|
+
if (this._threads[threadId]) {
|
|
6055
|
+
this.restoreThreadWindow(threadId);
|
|
6056
|
+
return;
|
|
6057
|
+
}
|
|
6058
|
+
try {
|
|
6059
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}`);
|
|
6060
|
+
if (!res.ok) return;
|
|
6061
|
+
const data = await res.json();
|
|
6062
|
+
const room = data.room;
|
|
6063
|
+
if (!room || room.kind !== "thread") return;
|
|
6064
|
+
const director = (this.agentsById && this.agentsById[room.threadDirectorId])
|
|
6065
|
+
|| { id: room.threadDirectorId, name: room.threadDirectorId, avatarPath: "" };
|
|
6066
|
+
// Default docked · same as the trigger-button entry path.
|
|
6067
|
+
this.mountThreadWindow(room, director, { docked: this._canDockHere() });
|
|
6068
|
+
// Seed the historical transcript · without this the window
|
|
6069
|
+
// mounts empty and only catches messages arriving via SSE
|
|
6070
|
+
// FROM NOW ON, so reopening an old thread looked like a
|
|
6071
|
+
// brand-new conversation. The /api/rooms/:id state response
|
|
6072
|
+
// already carries the full message list — paint it now.
|
|
6073
|
+
if (Array.isArray(data.messages) && data.messages.length > 0) {
|
|
6074
|
+
this._seedThreadHistory(threadId, data.messages);
|
|
6075
|
+
}
|
|
6076
|
+
} catch (e) {
|
|
6077
|
+
try { console.warn("[thread] openExisting failed:", e); } catch (_) {}
|
|
6078
|
+
}
|
|
6079
|
+
},
|
|
6080
|
+
|
|
6081
|
+
/** Paint the thread room's stored messages into the window on
|
|
6082
|
+
* first mount. State + DOM both populated so subsequent SSE
|
|
6083
|
+
* token / update / final events can find the message refs and
|
|
6084
|
+
* patch the bubbles in place. */
|
|
6085
|
+
_seedThreadHistory(threadId, messages) {
|
|
6086
|
+
const state = this._threads && this._threads[threadId];
|
|
6087
|
+
if (!state || !state.windowEl) return;
|
|
6088
|
+
const list = state.windowEl.querySelector("[data-thread-messages]");
|
|
6089
|
+
if (!list) return;
|
|
6090
|
+
// Hide the empty-state placeholder if any messages exist.
|
|
6091
|
+
this._threadEmptyState(threadId, true);
|
|
6092
|
+
// Dedupe against anything already streamed in via SSE that
|
|
6093
|
+
// raced the seed (rare but possible). Skip if already known.
|
|
6094
|
+
const known = new Set(state.messages.map((m) => m.id));
|
|
6095
|
+
const html = [];
|
|
6096
|
+
for (const data of messages) {
|
|
6097
|
+
if (!data || !data.id || known.has(data.id)) continue;
|
|
6098
|
+
const msg = {
|
|
6099
|
+
id: data.id,
|
|
6100
|
+
authorKind: data.authorKind,
|
|
6101
|
+
authorId: data.authorId,
|
|
6102
|
+
body: data.body || "",
|
|
6103
|
+
meta: data.meta || {},
|
|
6104
|
+
roundNum: data.roundNum,
|
|
6105
|
+
createdAt: data.createdAt,
|
|
6106
|
+
};
|
|
6107
|
+
state.messages.push(msg);
|
|
6108
|
+
html.push(this._threadMessageHtml(state, msg));
|
|
6109
|
+
}
|
|
6110
|
+
if (html.length) {
|
|
6111
|
+
list.insertAdjacentHTML("beforeend", html.join(""));
|
|
6112
|
+
list.scrollTop = list.scrollHeight;
|
|
6113
|
+
}
|
|
6114
|
+
},
|
|
6115
|
+
|
|
6116
|
+
minimizeThreadWindow(threadId) {
|
|
6117
|
+
const state = this._threads && this._threads[threadId];
|
|
6118
|
+
if (!state || !state.windowEl) return;
|
|
6119
|
+
state.minimized = true;
|
|
6120
|
+
state.windowEl.classList.add("is-minimized");
|
|
6121
|
+
// Drop the user-resized inline height · the minimized pill sizes
|
|
6122
|
+
// to its content (CSS `height: auto`), and inline height would
|
|
6123
|
+
// win over that class rule and stretch the chip. The chosen
|
|
6124
|
+
// height is preserved on state.height and reapplied on restore.
|
|
6125
|
+
state.windowEl.style.height = "";
|
|
6126
|
+
// Reset transform so the minimized chip lives in its dock
|
|
6127
|
+
// anchor at the bottom-right rather than at the user's drag
|
|
6128
|
+
// location. The dragX/dragY values stay on state so restore
|
|
6129
|
+
// can put it back where they had it.
|
|
6130
|
+
state.windowEl.style.transform = "";
|
|
6131
|
+
// Pull into a shared dock so multiple minimized chips line up
|
|
6132
|
+
// along the bottom-right edge.
|
|
6133
|
+
const dock = this._ensureThreadDock();
|
|
6134
|
+
dock.appendChild(state.windowEl);
|
|
6135
|
+
state.windowEl.style.right = "auto";
|
|
6136
|
+
state.windowEl.style.bottom = "auto";
|
|
6137
|
+
// Click-to-restore on the whole chip (header is hidden,
|
|
6138
|
+
// controls are hidden, so the chip itself is the click target).
|
|
6139
|
+
const restoreHandler = (e) => {
|
|
6140
|
+
e.preventDefault();
|
|
6141
|
+
e.stopPropagation();
|
|
6142
|
+
this.restoreThreadWindow(threadId);
|
|
6143
|
+
};
|
|
6144
|
+
state.windowEl.addEventListener("click", restoreHandler, { once: true });
|
|
6145
|
+
},
|
|
6146
|
+
|
|
6147
|
+
restoreThreadWindow(threadId) {
|
|
6148
|
+
const state = this._threads && this._threads[threadId];
|
|
6149
|
+
if (!state || !state.windowEl) return;
|
|
6150
|
+
state.minimized = false;
|
|
6151
|
+
const el = state.windowEl;
|
|
6152
|
+
el.classList.remove("is-minimized");
|
|
6153
|
+
// Panel-only · restore re-docks into the active room view rather
|
|
6154
|
+
// than reviving a floating window (float mode was removed). Clear
|
|
6155
|
+
// any leftover float inline anchors first, then re-parent into the
|
|
6156
|
+
// dock host. Only when no room view is mounted (defensive) do we
|
|
6157
|
+
// fall back to a body-level overlay.
|
|
6158
|
+
el.style.right = "";
|
|
6159
|
+
el.style.bottom = "";
|
|
6160
|
+
el.style.transform = "";
|
|
6161
|
+
el.style.height = "";
|
|
6162
|
+
const dockHost = this._threadDockHost();
|
|
6163
|
+
if (dockHost) {
|
|
6164
|
+
el.classList.add("is-docked");
|
|
6165
|
+
state.docked = true;
|
|
6166
|
+
if (el.parentNode !== dockHost) dockHost.appendChild(el);
|
|
6167
|
+
document.body.classList.add("has-thread-dock");
|
|
6168
|
+
} else {
|
|
6169
|
+
el.classList.remove("is-docked");
|
|
6170
|
+
state.docked = false;
|
|
6171
|
+
if (el.parentNode !== document.body) document.body.appendChild(el);
|
|
6172
|
+
el.style.right = "24px";
|
|
6173
|
+
el.style.bottom = "24px";
|
|
6174
|
+
}
|
|
6175
|
+
},
|
|
6176
|
+
|
|
6177
|
+
_ensureThreadDock() {
|
|
6178
|
+
let dock = document.getElementById("thread-dock");
|
|
6179
|
+
if (!dock) {
|
|
6180
|
+
dock = document.createElement("div");
|
|
6181
|
+
dock.id = "thread-dock";
|
|
6182
|
+
dock.className = "thread-dock";
|
|
6183
|
+
document.body.appendChild(dock);
|
|
6184
|
+
}
|
|
6185
|
+
return dock;
|
|
6186
|
+
},
|
|
6187
|
+
|
|
6188
|
+
_wireThreadDrag(handle, state) {
|
|
6189
|
+
// Per-drag scratch + rAF coalescing · pointermove fires faster
|
|
6190
|
+
// than the browser can paint on most setups; without throttling
|
|
6191
|
+
// we'd issue multiple style writes per frame. Reading the
|
|
6192
|
+
// pending coords in a single rAF and writing transform once
|
|
6193
|
+
// keeps the window pinned to the cursor with no perceived
|
|
6194
|
+
// lag. The earlier implementation wrote transform on every
|
|
6195
|
+
// mousemove AND had a 0.15s transition (since removed from
|
|
6196
|
+
// .thread-float CSS), which compounded into visible drag jitter.
|
|
6197
|
+
let startX = 0, startY = 0, baseX = 0, baseY = 0;
|
|
6198
|
+
let dragging = false;
|
|
6199
|
+
let pendingX = 0, pendingY = 0;
|
|
6200
|
+
let rafId = 0;
|
|
6201
|
+
const flush = () => {
|
|
6202
|
+
rafId = 0;
|
|
6203
|
+
if (!dragging) return;
|
|
6204
|
+
state.dragX = baseX + (pendingX - startX);
|
|
6205
|
+
state.dragY = baseY + (pendingY - startY);
|
|
6206
|
+
state.windowEl.style.transform = `translate(${state.dragX}px, ${state.dragY}px)`;
|
|
6207
|
+
};
|
|
6208
|
+
const onMove = (e) => {
|
|
6209
|
+
if (!dragging) return;
|
|
6210
|
+
// touchmove may need preventDefault to suppress page scroll
|
|
6211
|
+
// while the user drags the window across the viewport.
|
|
6212
|
+
if (e.cancelable && e.type === "touchmove") e.preventDefault();
|
|
6213
|
+
pendingX = ("touches" in e ? e.touches[0].clientX : e.clientX);
|
|
6214
|
+
pendingY = ("touches" in e ? e.touches[0].clientY : e.clientY);
|
|
6215
|
+
if (!rafId) rafId = requestAnimationFrame(flush);
|
|
6216
|
+
};
|
|
6217
|
+
const onUp = () => {
|
|
6218
|
+
dragging = false;
|
|
6219
|
+
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
6220
|
+
// Reset the compositor hint once dragging ends so the layer
|
|
6221
|
+
// is freed and the window doesn't keep paying the
|
|
6222
|
+
// promote-to-its-own-layer tax forever.
|
|
6223
|
+
if (state.windowEl) state.windowEl.style.willChange = "";
|
|
6224
|
+
document.removeEventListener("mousemove", onMove);
|
|
6225
|
+
document.removeEventListener("mouseup", onUp);
|
|
6226
|
+
document.removeEventListener("touchmove", onMove);
|
|
6227
|
+
document.removeEventListener("touchend", onUp);
|
|
6228
|
+
};
|
|
6229
|
+
const onDown = (e) => {
|
|
6230
|
+
// Ignore drags that originate on interactive children of the
|
|
6231
|
+
// header — buttons (close / min / max) AND the director
|
|
6232
|
+
// avatar, which carries `[data-agent]` and routes its click
|
|
6233
|
+
// to the lightweight intro overlay (agent-overlay.js).
|
|
6234
|
+
if (e.target && e.target.closest && e.target.closest("button, [data-agent]")) return;
|
|
6235
|
+
dragging = true;
|
|
6236
|
+
startX = ("touches" in e ? e.touches[0].clientX : e.clientX);
|
|
6237
|
+
startY = ("touches" in e ? e.touches[0].clientY : e.clientY);
|
|
6238
|
+
pendingX = startX;
|
|
6239
|
+
pendingY = startY;
|
|
6240
|
+
baseX = state.dragX || 0;
|
|
6241
|
+
baseY = state.dragY || 0;
|
|
6242
|
+
// Promote the window to its own compositor layer for the
|
|
6243
|
+
// drag duration so transform updates can be GPU-accelerated.
|
|
6244
|
+
// Cleared on mouseup so we don't permanently inflate
|
|
6245
|
+
// memory.
|
|
6246
|
+
if (state.windowEl) state.windowEl.style.willChange = "transform";
|
|
6247
|
+
document.addEventListener("mousemove", onMove);
|
|
6248
|
+
document.addEventListener("mouseup", onUp);
|
|
6249
|
+
// touchmove · passive:false so we can preventDefault inside
|
|
6250
|
+
// onMove (matters on iOS Safari where the browser otherwise
|
|
6251
|
+
// hijacks vertical drags for page scroll).
|
|
6252
|
+
document.addEventListener("touchmove", onMove, { passive: false });
|
|
6253
|
+
document.addEventListener("touchend", onUp);
|
|
6254
|
+
};
|
|
6255
|
+
handle.addEventListener("mousedown", onDown);
|
|
6256
|
+
handle.addEventListener("touchstart", onDown, { passive: true });
|
|
6257
|
+
},
|
|
6258
|
+
|
|
6259
|
+
/** Edge resize · the float is bottom-anchored.
|
|
6260
|
+
* · "top" handle · drag UP grows / DOWN shrinks; the bottom
|
|
6261
|
+
* edge stays pinned (height only).
|
|
6262
|
+
* · "bottom" handle · the bottom edge follows the cursor (drag
|
|
6263
|
+
* DOWN grows / UP shrinks) while the top edge stays pinned —
|
|
6264
|
+
* achieved by moving the drag-translate `dragY` by the same
|
|
6265
|
+
* delta the height grows, so the box's top doesn't shift.
|
|
6266
|
+
* Mirrors `_wireThreadDrag`'s rAF-coalesced pointer bookkeeping.
|
|
6267
|
+
* Height is clamped to [240, viewport-96] — the upper cap matches
|
|
6268
|
+
* `.thread-float`'s CSS `max-height` so the inline height never
|
|
6269
|
+
* fights it. Persisted on `state.height` (+ `dragY` for the
|
|
6270
|
+
* bottom edge) so minimize/restore keeps the chosen size + spot. */
|
|
6271
|
+
_wireThreadResize(handle, state, edge) {
|
|
6272
|
+
const MIN_H = 240;
|
|
6273
|
+
const maxH = () => Math.max(MIN_H, window.innerHeight - 96);
|
|
6274
|
+
const isBottom = edge === "bottom";
|
|
6275
|
+
let startY = 0, baseH = 0, baseDragY = 0;
|
|
6276
|
+
let resizing = false;
|
|
6277
|
+
let pendingY = 0;
|
|
6278
|
+
let rafId = 0;
|
|
6279
|
+
const flush = () => {
|
|
6280
|
+
rafId = 0;
|
|
6281
|
+
if (!resizing || !state.windowEl) return;
|
|
6282
|
+
// Drag delta along Y · top edge grows when the cursor moves UP
|
|
6283
|
+
// (startY - cur); bottom edge grows when it moves DOWN.
|
|
6284
|
+
const rawDelta = isBottom ? (pendingY - startY) : (startY - pendingY);
|
|
6285
|
+
const next = Math.min(maxH(), Math.max(MIN_H, baseH + rawDelta));
|
|
6286
|
+
state.height = next;
|
|
6287
|
+
state.windowEl.style.height = `${next}px`;
|
|
6288
|
+
if (isBottom) {
|
|
6289
|
+
// Shift the box down by however much it actually grew so the
|
|
6290
|
+
// top edge stays put (height was clamped, so use the applied
|
|
6291
|
+
// delta, not the raw cursor delta).
|
|
6292
|
+
state.dragY = baseDragY + (next - baseH);
|
|
6293
|
+
state.windowEl.style.transform = `translate(${state.dragX || 0}px, ${state.dragY}px)`;
|
|
6294
|
+
}
|
|
6295
|
+
};
|
|
6296
|
+
const onMove = (e) => {
|
|
6297
|
+
if (!resizing) return;
|
|
6298
|
+
if (e.cancelable && e.type === "touchmove") e.preventDefault();
|
|
6299
|
+
pendingY = ("touches" in e ? e.touches[0].clientY : e.clientY);
|
|
6300
|
+
if (!rafId) rafId = requestAnimationFrame(flush);
|
|
6301
|
+
};
|
|
6302
|
+
const onUp = () => {
|
|
6303
|
+
resizing = false;
|
|
6304
|
+
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
6305
|
+
// Re-enable text selection in the rest of the app.
|
|
6306
|
+
document.body.style.userSelect = "";
|
|
6307
|
+
document.removeEventListener("mousemove", onMove);
|
|
6308
|
+
document.removeEventListener("mouseup", onUp);
|
|
6309
|
+
document.removeEventListener("touchmove", onMove);
|
|
6310
|
+
document.removeEventListener("touchend", onUp);
|
|
6311
|
+
};
|
|
6312
|
+
const onDown = (e) => {
|
|
6313
|
+
// Don't start a resize while minimized · the handle is hidden
|
|
6314
|
+
// then anyway, but guard against stray events.
|
|
6315
|
+
if (state.minimized || !state.windowEl) return;
|
|
6316
|
+
// Swallow the event so it doesn't also reach the header's
|
|
6317
|
+
// drag-to-move handler underneath.
|
|
6318
|
+
if (e.stopPropagation) e.stopPropagation();
|
|
6319
|
+
// Suppress text selection for the whole drag · without this the
|
|
6320
|
+
// mousemove sweep selects whatever room text sits under the
|
|
6321
|
+
// cursor, which reads as a glitch while resizing the window.
|
|
6322
|
+
if (e.type === "mousedown" && e.preventDefault) e.preventDefault();
|
|
6323
|
+
document.body.style.userSelect = "none";
|
|
6324
|
+
resizing = true;
|
|
6325
|
+
startY = ("touches" in e ? e.touches[0].clientY : e.clientY);
|
|
6326
|
+
pendingY = startY;
|
|
6327
|
+
baseH = state.windowEl.getBoundingClientRect().height;
|
|
6328
|
+
baseDragY = state.dragY || 0;
|
|
6329
|
+
document.addEventListener("mousemove", onMove);
|
|
6330
|
+
document.addEventListener("mouseup", onUp);
|
|
6331
|
+
document.addEventListener("touchmove", onMove, { passive: false });
|
|
6332
|
+
document.addEventListener("touchend", onUp);
|
|
6333
|
+
};
|
|
6334
|
+
handle.addEventListener("mousedown", onDown);
|
|
6335
|
+
handle.addEventListener("touchstart", onDown, { passive: true });
|
|
6336
|
+
},
|
|
6337
|
+
|
|
6338
|
+
async sendThreadMessage(threadId) {
|
|
6339
|
+
const state = this._threads && this._threads[threadId];
|
|
6340
|
+
if (!state || !state.windowEl) return;
|
|
6341
|
+
const input = state.windowEl.querySelector("[data-thread-input]");
|
|
6342
|
+
if (!input) return;
|
|
6343
|
+
const text = (input.value || "").trim();
|
|
6344
|
+
if (!text) return;
|
|
6345
|
+
input.value = "";
|
|
6346
|
+
input.style.height = "auto";
|
|
6347
|
+
try {
|
|
6348
|
+
const res = await fetch(`/api/rooms/${encodeURIComponent(threadId)}/messages`, {
|
|
6349
|
+
method: "POST",
|
|
6350
|
+
headers: { "content-type": "application/json" },
|
|
6351
|
+
body: JSON.stringify({ body: text, mode: "now" }),
|
|
6352
|
+
});
|
|
6353
|
+
if (!res.ok) {
|
|
6354
|
+
const err = await res.json().catch(() => ({}));
|
|
6355
|
+
alert((err && err.error) || "Failed to send thread message");
|
|
6356
|
+
}
|
|
6357
|
+
// No need to optimistically render · the SSE message-appended
|
|
6358
|
+
// for the user message will fan out and our handler renders it.
|
|
6359
|
+
} catch (e) {
|
|
6360
|
+
alert((e && e.message) || "Failed to send thread message");
|
|
6361
|
+
}
|
|
6362
|
+
},
|
|
6363
|
+
|
|
6364
|
+
_threadEmptyState(threadId, hide) {
|
|
6365
|
+
const state = this._threads && this._threads[threadId];
|
|
6366
|
+
if (!state || !state.windowEl) return;
|
|
6367
|
+
const empty = state.windowEl.querySelector("[data-thread-empty]");
|
|
6368
|
+
if (empty) empty.style.display = hide ? "none" : "";
|
|
6369
|
+
},
|
|
6370
|
+
|
|
6371
|
+
/** Render a thread bubble using the main chat's full `messageHtml`
|
|
6372
|
+
* pipeline — keeps user avatars, director avatars, model badges,
|
|
6373
|
+
* the streaming dots animation, name+tag chips, etc. all
|
|
6374
|
+
* consistent with the main room. Earlier custom-mini renderer
|
|
6375
|
+
* produced visually broken bubbles (no user avatar, missing
|
|
6376
|
+
* meta chips) because it duplicated only a fraction of the
|
|
6377
|
+
* main path. Now reuses ONE source of truth.
|
|
6378
|
+
*
|
|
6379
|
+
* Side effect protection · sets `this._renderingThread = true`
|
|
6380
|
+
* for the duration of the call so the per-message thread
|
|
6381
|
+
* toolbar (`_threadTriggerHtml`) skips rendering inside thread
|
|
6382
|
+
* windows — there's no "thread the speaker again" inside an
|
|
6383
|
+
* already-open thread. The flag flips back in `finally`. */
|
|
6384
|
+
_threadMessageHtml(state, msg) {
|
|
6385
|
+
void state;
|
|
6386
|
+
this._renderingThread = true;
|
|
6387
|
+
try {
|
|
6388
|
+
return this.messageHtml(msg, false);
|
|
6389
|
+
} finally {
|
|
6390
|
+
this._renderingThread = false;
|
|
6391
|
+
}
|
|
6392
|
+
},
|
|
6393
|
+
|
|
6394
|
+
_threadAppendMessage(threadId, data) {
|
|
6395
|
+
const state = this._threads && this._threads[threadId];
|
|
6396
|
+
if (!state || !state.windowEl) return;
|
|
6397
|
+
const list = state.windowEl.querySelector("[data-thread-messages]");
|
|
6398
|
+
if (!list) return;
|
|
6399
|
+
this._threadEmptyState(threadId, true);
|
|
6400
|
+
// Track msg in state so streaming token appends find a stable
|
|
6401
|
+
// reference even if the article gets re-rendered.
|
|
6402
|
+
const msg = {
|
|
6403
|
+
id: data.messageId,
|
|
6404
|
+
authorKind: data.authorKind,
|
|
6405
|
+
authorId: data.authorId,
|
|
6406
|
+
body: data.body || "",
|
|
6407
|
+
meta: data.meta || {},
|
|
6408
|
+
roundNum: data.roundNum,
|
|
6409
|
+
createdAt: data.createdAt,
|
|
6410
|
+
};
|
|
6411
|
+
state.messages.push(msg);
|
|
6412
|
+
list.insertAdjacentHTML("beforeend", this._threadMessageHtml(state, msg));
|
|
6413
|
+
list.scrollTop = list.scrollHeight;
|
|
6414
|
+
// Director just started streaming · swap composer Send → Stop
|
|
6415
|
+
// so the user can interrupt the response in flight. The flag
|
|
6416
|
+
// stays on the message; later finalize / abort clears it.
|
|
6417
|
+
if (msg.authorKind === "agent" && msg.meta && msg.meta.streaming) {
|
|
6418
|
+
this._setThreadStreamingState(threadId, msg.id);
|
|
6419
|
+
}
|
|
6420
|
+
},
|
|
6421
|
+
|
|
6422
|
+
_threadApplyToken(threadId, data) {
|
|
6423
|
+
const state = this._threads && this._threads[threadId];
|
|
6424
|
+
if (!state) return;
|
|
6425
|
+
const msg = state.messages.find((m) => m.id === data.messageId);
|
|
6426
|
+
if (!msg) return;
|
|
6427
|
+
msg.body = (msg.body || "") + (data.delta || "");
|
|
6428
|
+
const article = state.windowEl.querySelector(
|
|
6429
|
+
`[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
|
|
6430
|
+
);
|
|
6431
|
+
if (!article) return;
|
|
6432
|
+
const bubble = article.querySelector(".msg-bubble");
|
|
6433
|
+
if (!bubble) return;
|
|
6434
|
+
article.classList.remove("thinking");
|
|
6435
|
+
bubble.innerHTML = this.renderBody ? this.renderBody(msg.body) : msg.body;
|
|
6436
|
+
const list = state.windowEl.querySelector("[data-thread-messages]");
|
|
6437
|
+
if (list) list.scrollTop = list.scrollHeight;
|
|
6438
|
+
},
|
|
6439
|
+
|
|
6440
|
+
_threadUpdateMessage(threadId, data) {
|
|
6441
|
+
const state = this._threads && this._threads[threadId];
|
|
6442
|
+
if (!state) return;
|
|
6443
|
+
const msg = state.messages.find((m) => m.id === data.messageId);
|
|
6444
|
+
if (msg) {
|
|
6445
|
+
msg.body = data.body || "";
|
|
6446
|
+
msg.meta = data.meta || {};
|
|
6447
|
+
}
|
|
6448
|
+
const article = state.windowEl.querySelector(
|
|
6449
|
+
`[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
|
|
6450
|
+
);
|
|
6451
|
+
if (!article) return;
|
|
6452
|
+
const bubble = article.querySelector(".msg-bubble");
|
|
6453
|
+
if (bubble) {
|
|
6454
|
+
bubble.innerHTML = this.renderBody ? this.renderBody(data.body || "") : (data.body || "");
|
|
6455
|
+
}
|
|
6456
|
+
const streaming = !!(data.meta && data.meta.streaming);
|
|
6457
|
+
article.classList.toggle("streaming", streaming);
|
|
6458
|
+
if (!streaming) {
|
|
6459
|
+
article.classList.remove("thinking");
|
|
6460
|
+
// Server flipped streaming:false on this director's message ·
|
|
6461
|
+
// restore the composer to Send mode if this was the message
|
|
6462
|
+
// we were tracking. Catches the abort + natural-finish paths
|
|
6463
|
+
// uniformly without needing a separate handler.
|
|
6464
|
+
if (state.streamingMessageId === data.messageId) {
|
|
6465
|
+
this._setThreadStreamingState(threadId, null);
|
|
6466
|
+
}
|
|
6467
|
+
}
|
|
6468
|
+
},
|
|
6469
|
+
|
|
6470
|
+
_threadFinalizeMessage(threadId, data) {
|
|
6471
|
+
const state = this._threads && this._threads[threadId];
|
|
6472
|
+
if (!state) return;
|
|
6473
|
+
const article = state.windowEl.querySelector(
|
|
6474
|
+
`[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
|
|
6475
|
+
);
|
|
6476
|
+
if (article) {
|
|
6477
|
+
article.classList.remove("streaming");
|
|
6478
|
+
article.classList.remove("thinking");
|
|
6479
|
+
}
|
|
6480
|
+
// Director's stream finished · flip composer back to Send.
|
|
6481
|
+
if (state.streamingMessageId === data.messageId) {
|
|
6482
|
+
this._setThreadStreamingState(threadId, null);
|
|
6483
|
+
}
|
|
6484
|
+
},
|
|
6485
|
+
|
|
6486
|
+
/** Flip the composer's Send/Stop visibility on the thread window
|
|
6487
|
+
* via a state class on `.thread-float-composer`. Tracks which
|
|
6488
|
+
* message we believe is currently streaming so duplicate
|
|
6489
|
+
* message-updated events (e.g. multiple token batches before
|
|
6490
|
+
* the streaming:false flag flips) don't toggle prematurely. */
|
|
6491
|
+
_setThreadStreamingState(threadId, messageId) {
|
|
6492
|
+
const state = this._threads && this._threads[threadId];
|
|
6493
|
+
if (!state || !state.windowEl) return;
|
|
6494
|
+
state.streamingMessageId = messageId;
|
|
6495
|
+
const composer = state.windowEl.querySelector("[data-thread-composer]");
|
|
6496
|
+
if (composer) composer.classList.toggle("is-streaming", !!messageId);
|
|
6497
|
+
},
|
|
6498
|
+
|
|
6499
|
+
/** Interrupt the in-flight director response in a thread. Hits
|
|
6500
|
+
* POST /api/rooms/:threadId/abort (the same endpoint the main
|
|
6501
|
+
* room uses for chair-interrupt + skip-current-speaker), which
|
|
6502
|
+
* aborts the LLM stream and drains voice waiters. The director's
|
|
6503
|
+
* partial body stays in chat; the composer flips back to Send
|
|
6504
|
+
* when the server's resulting message-updated (streaming:false)
|
|
6505
|
+
* arrives via SSE. */
|
|
6506
|
+
async stopThreadDirector(threadId) {
|
|
6507
|
+
const state = this._threads && this._threads[threadId];
|
|
6508
|
+
if (!state) return;
|
|
6509
|
+
try {
|
|
6510
|
+
await fetch(`/api/rooms/${encodeURIComponent(threadId)}/abort`, { method: "POST" });
|
|
6511
|
+
} catch (e) {
|
|
6512
|
+
try { console.warn("[thread] abort failed:", e); } catch (_) {}
|
|
6513
|
+
}
|
|
6514
|
+
// Optimistic UX · flip the composer back immediately rather
|
|
6515
|
+
// than waiting for the SSE round-trip. The streaming:false
|
|
6516
|
+
// update + message-final will arrive shortly and the toggle
|
|
6517
|
+
// is idempotent.
|
|
6518
|
+
this._setThreadStreamingState(threadId, null);
|
|
6519
|
+
},
|
|
6520
|
+
|
|
6521
|
+
_threadRemoveMessage(threadId, data) {
|
|
6522
|
+
const state = this._threads && this._threads[threadId];
|
|
6523
|
+
if (!state) return;
|
|
6524
|
+
state.messages = state.messages.filter((m) => m.id !== data.messageId);
|
|
6525
|
+
const article = state.windowEl.querySelector(
|
|
6526
|
+
`[data-message-id="${(window.CSS && CSS.escape) ? CSS.escape(data.messageId) : data.messageId}"]`,
|
|
6527
|
+
);
|
|
6528
|
+
if (article && article.parentNode) article.parentNode.removeChild(article);
|
|
6529
|
+
},
|
|
6530
|
+
|
|
5058
6531
|
async submitFollowUp() {
|
|
5059
6532
|
const overlay = document.getElementById("followup-overlay");
|
|
5060
6533
|
if (!overlay) return;
|
|
@@ -5938,6 +7411,81 @@
|
|
|
5938
7411
|
if (el) el.remove();
|
|
5939
7412
|
},
|
|
5940
7413
|
|
|
7414
|
+
/** Pause-after-current save modal · the user soft-paused the room
|
|
7415
|
+
* while a recording was running. The recording itself doesn't
|
|
7416
|
+
* stop automatically (room can resume), so we ask the user
|
|
7417
|
+
* whether to save it now. Three choices:
|
|
7418
|
+
* · primary "Save & adjourn" — stopAndDownload + adjourn(skipBrief)
|
|
7419
|
+
* · secondary "Save & keep paused" — stopAndDownload, room stays paused
|
|
7420
|
+
* · ghost "Keep recording" — close modal, room paused, recorder
|
|
7421
|
+
* continues capturing the silence
|
|
7422
|
+
* Modal reuses the .pc-overlay / .pc-modal / .pc-choice chrome
|
|
7423
|
+
* for visual consistency with other pause-/reload-choice flows. */
|
|
7424
|
+
openRecordingPauseSaveModal() {
|
|
7425
|
+
this.closeRecordingPauseSaveModal();
|
|
7426
|
+
const html = `
|
|
7427
|
+
<div id="rec-pause-save-overlay" class="pc-overlay">
|
|
7428
|
+
<div class="pc-modal">
|
|
7429
|
+
<div class="pc-classification">
|
|
7430
|
+
<span><span class="dot">●</span> ${this.escape(this._t("rec_pause_save_class"))}</span>
|
|
7431
|
+
<span class="right">${this.escape(this._t("rec_pause_save_right"))}</span>
|
|
7432
|
+
</div>
|
|
7433
|
+
<div class="pc-head">
|
|
7434
|
+
<div class="pc-tag">${this.escape(this._t("rec_pause_save_tag"))}</div>
|
|
7435
|
+
<h2 class="pc-title">${this.escape(this._t("rec_pause_save_title"))}</h2>
|
|
7436
|
+
<p class="pc-deck">${this.escape(this._t("rec_pause_save_deck"))}</p>
|
|
7437
|
+
</div>
|
|
7438
|
+
<div class="pc-body">
|
|
7439
|
+
<button type="button" class="pc-choice primary" data-rec-pause-save-choice="save-adjourn">
|
|
7440
|
+
<div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_adjourn_mark"))}</div>
|
|
7441
|
+
<div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_adjourn_deck"))}</div>
|
|
7442
|
+
</button>
|
|
7443
|
+
<button type="button" class="pc-choice" data-rec-pause-save-choice="save-keep">
|
|
7444
|
+
<div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_keep_mark"))}</div>
|
|
7445
|
+
<div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_keep_deck"))}</div>
|
|
7446
|
+
</button>
|
|
7447
|
+
<button type="button" class="pc-choice ghost" data-rec-pause-save-choice="continue">
|
|
7448
|
+
<div class="pc-choice-mark">${this.escape(this._t("rec_pause_save_continue_mark"))}</div>
|
|
7449
|
+
<div class="pc-choice-deck">${this.escape(this._t("rec_pause_save_continue_deck"))}</div>
|
|
7450
|
+
</button>
|
|
7451
|
+
</div>
|
|
7452
|
+
</div>
|
|
7453
|
+
</div>
|
|
7454
|
+
`;
|
|
7455
|
+
document.body.insertAdjacentHTML("beforeend", html);
|
|
7456
|
+
},
|
|
7457
|
+
|
|
7458
|
+
closeRecordingPauseSaveModal() {
|
|
7459
|
+
const el = document.getElementById("rec-pause-save-overlay");
|
|
7460
|
+
if (el) el.remove();
|
|
7461
|
+
},
|
|
7462
|
+
|
|
7463
|
+
async handleRecordingPauseSaveChoice(mode) {
|
|
7464
|
+
this.closeRecordingPauseSaveModal();
|
|
7465
|
+
if (mode === "continue") return; // keep recording, room stays paused
|
|
7466
|
+
// Both save paths stop + download the clip first.
|
|
7467
|
+
let blob = null;
|
|
7468
|
+
try { blob = await window.BoardroomRecorder.stopAndDownload(); }
|
|
7469
|
+
catch (e) { console.error("[recorder] stopAndDownload failed", e); }
|
|
7470
|
+
if (blob && blob.size > 0) {
|
|
7471
|
+
try {
|
|
7472
|
+
this.showRoundTableToast({
|
|
7473
|
+
kind: "settings",
|
|
7474
|
+
glyph: "↓",
|
|
7475
|
+
htmlText: this.escape(this._t("rec_saved_toast")),
|
|
7476
|
+
lifetimeMs: 5200,
|
|
7477
|
+
});
|
|
7478
|
+
} catch (_) { /* toast best-effort */ }
|
|
7479
|
+
}
|
|
7480
|
+
if (mode === "save-adjourn") {
|
|
7481
|
+
// skipBrief · recording-driven adjourns never auto-generate
|
|
7482
|
+
// a report; the user files one manually.
|
|
7483
|
+
try { await this.adjournRoom({ skipBrief: true }); }
|
|
7484
|
+
catch (e) { console.warn("[recorder] adjournRoom failed", e); }
|
|
7485
|
+
}
|
|
7486
|
+
// save-keep · nothing else to do; room is already paused.
|
|
7487
|
+
},
|
|
7488
|
+
|
|
5941
7489
|
async handleRecordingStopChoice(mode) {
|
|
5942
7490
|
this.closeRecordingStopModal();
|
|
5943
7491
|
if (mode === "cancel") return;
|
|
@@ -6828,9 +8376,17 @@
|
|
|
6828
8376
|
timeFmt(ms) {
|
|
6829
8377
|
if (!ms) return "";
|
|
6830
8378
|
const d = new Date(ms);
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
8379
|
+
// Absolute timestamp · "May 24, 3:45 PM". When the year differs
|
|
8380
|
+
// from the current one (last year or earlier) the year is
|
|
8381
|
+
// prepended so e.g. "2024 May 24, 3:45 PM" disambiguates old
|
|
8382
|
+
// messages. en-US locale pins the short month + 12-hour "PM"
|
|
8383
|
+
// meridiem regardless of UI language (matches the notification
|
|
8384
|
+
// date format elsewhere in the app).
|
|
8385
|
+
const datePart = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
8386
|
+
const timePart = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
|
8387
|
+
const base = `${datePart}, ${timePart}`;
|
|
8388
|
+
const yr = d.getFullYear();
|
|
8389
|
+
return yr === new Date().getFullYear() ? base : `${yr} ${base}`;
|
|
6834
8390
|
},
|
|
6835
8391
|
|
|
6836
8392
|
relTime(ms) {
|
|
@@ -6964,6 +8520,25 @@
|
|
|
6964
8520
|
// when the agent profile takes focus, and otherwise the agent
|
|
6965
8521
|
// rows shouldn't be highlighted at all.
|
|
6966
8522
|
}
|
|
8523
|
+
// Collapsed mini-rail · light the "rooms" icon when an actual
|
|
8524
|
+
// room is the main view; clear it for composers (the new-room /
|
|
8525
|
+
// new-agent mini icon carries the highlight there instead).
|
|
8526
|
+
this.setMiniRailContext(roomId !== null ? "rooms" : null);
|
|
8527
|
+
},
|
|
8528
|
+
|
|
8529
|
+
/** Highlight the rooms / agents switcher in the collapsed mini
|
|
8530
|
+
* rail. Mutually exclusive · passing `null` clears both (used by
|
|
8531
|
+
* the reports / notes / composer destinations, which light their
|
|
8532
|
+
* own mini icon via the shared trigger attributes). Mirrors the
|
|
8533
|
+
* full sidebar's tab selection but scoped to the icon rail so the
|
|
8534
|
+
* collapsed sidebar still shows "where you are". */
|
|
8535
|
+
setMiniRailContext(ctx) {
|
|
8536
|
+
document.querySelectorAll('.mini-tab[data-mini-tab="rooms"]').forEach((el) => {
|
|
8537
|
+
el.classList.toggle("active", ctx === "rooms");
|
|
8538
|
+
});
|
|
8539
|
+
document.querySelectorAll('.mini-tab[data-mini-tab="agents"]').forEach((el) => {
|
|
8540
|
+
el.classList.toggle("active", ctx === "agents");
|
|
8541
|
+
});
|
|
6967
8542
|
},
|
|
6968
8543
|
|
|
6969
8544
|
/** Sidebar focus when an agent profile takes the main view. The
|
|
@@ -6993,6 +8568,9 @@
|
|
|
6993
8568
|
document.querySelectorAll(".agent-row").forEach((r) => {
|
|
6994
8569
|
r.classList.toggle("active", r.dataset.agentProfile === slug);
|
|
6995
8570
|
});
|
|
8571
|
+
// Collapsed mini-rail · an agent profile is the main view → light
|
|
8572
|
+
// the agents switcher icon.
|
|
8573
|
+
this.setMiniRailContext("agents");
|
|
6996
8574
|
// Reset composer mode so subsequent transitions are clean — the
|
|
6997
8575
|
// user is no longer "creating" anything.
|
|
6998
8576
|
this.composerMode = "room";
|
|
@@ -7255,17 +8833,23 @@
|
|
|
7255
8833
|
// matches what the user picked in settings; otherwise fall back
|
|
7256
8834
|
// to the initial-letter chip we shipped before AvatarSkill
|
|
7257
8835
|
// existed.
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
8836
|
+
// Sync every avatar slot · the full sidebar foot AND the
|
|
8837
|
+
// collapsed mini-rail foot both carry [data-user-avatar], so
|
|
8838
|
+
// querySelectorAll keeps them identical regardless of which is
|
|
8839
|
+
// currently visible.
|
|
8840
|
+
const seed = this.prefs?.avatarSeed;
|
|
8841
|
+
const avHtml = (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function")
|
|
8842
|
+
? window.AvatarSkill.generate(seed)
|
|
8843
|
+
: null;
|
|
8844
|
+
document.querySelectorAll("[data-user-avatar]").forEach((av) => {
|
|
8845
|
+
if (avHtml) {
|
|
7262
8846
|
av.classList.add("has-pixel-av");
|
|
7263
|
-
av.innerHTML =
|
|
8847
|
+
av.innerHTML = avHtml;
|
|
7264
8848
|
} else {
|
|
7265
8849
|
av.classList.remove("has-pixel-av");
|
|
7266
8850
|
av.textContent = initial;
|
|
7267
8851
|
}
|
|
7268
|
-
}
|
|
8852
|
+
});
|
|
7269
8853
|
const nm = document.querySelector("[data-user-name]");
|
|
7270
8854
|
if (nm) nm.textContent = name;
|
|
7271
8855
|
const mt = document.querySelector("[data-user-meta]");
|
|
@@ -8124,6 +9708,9 @@
|
|
|
8124
9708
|
document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
8125
9709
|
document.querySelectorAll("[data-search-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
8126
9710
|
document.querySelectorAll("[data-reports-trigger]").forEach((el) => el.classList.add("active"));
|
|
9711
|
+
// Reports is its own destination · clear the mini-rail rooms /
|
|
9712
|
+
// agents switchers so only the reports mini icon reads active.
|
|
9713
|
+
this.setMiniRailContext(null);
|
|
8127
9714
|
|
|
8128
9715
|
// Persist the view via URL hash so refresh / back-button restore
|
|
8129
9716
|
// both the page content AND the sidebar highlight. replaceState
|
|
@@ -8610,6 +10197,9 @@
|
|
|
8610
10197
|
document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
8611
10198
|
document.querySelectorAll("[data-search-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
8612
10199
|
document.querySelectorAll("[data-notes-trigger]").forEach((el) => el.classList.add("active"));
|
|
10200
|
+
// Notes is its own destination · clear the mini-rail rooms /
|
|
10201
|
+
// agents switchers so only the notes mini icon reads active.
|
|
10202
|
+
this.setMiniRailContext(null);
|
|
8613
10203
|
|
|
8614
10204
|
if (location.hash !== "#/notes") {
|
|
8615
10205
|
try { history.replaceState(null, "", "#/notes"); } catch { /* ignore */ }
|
|
@@ -9586,7 +11176,11 @@
|
|
|
9586
11176
|
const article = document.querySelector(`article[data-message-id="${openerId}"]`);
|
|
9587
11177
|
if (!article) return;
|
|
9588
11178
|
try {
|
|
9589
|
-
|
|
11179
|
+
// Instant · matches the thread panel's jump-top affordance.
|
|
11180
|
+
// User reads the result, not the animation; smooth scrolling
|
|
11181
|
+
// on long rooms (10+ screens of history) takes ≥800ms which
|
|
11182
|
+
// reads as sluggish.
|
|
11183
|
+
article.scrollIntoView({ behavior: "auto", block: "start" });
|
|
9590
11184
|
} catch { /* */ }
|
|
9591
11185
|
this.chatStuckToBottom = false;
|
|
9592
11186
|
this._suppressBottomScrollUntil = Date.now() + 2000;
|
|
@@ -9868,6 +11462,15 @@
|
|
|
9868
11462
|
|
|
9869
11463
|
showNoteTooltip(span) {
|
|
9870
11464
|
if (!span) return;
|
|
11465
|
+
// Suppress while a text selection is active · the selection CTA
|
|
11466
|
+
// bar (quote-cta.js) already sits in this slot above the
|
|
11467
|
+
// selection. Selecting text that overlaps an already-saved
|
|
11468
|
+
// highlight would otherwise stack the "✓ Saved" hover tip on
|
|
11469
|
+
// top of the CTA bar — the user sees two toolbars. Once the
|
|
11470
|
+
// selection collapses (click elsewhere), hovering the highlight
|
|
11471
|
+
// shows the tip again as normal.
|
|
11472
|
+
const sel = window.getSelection ? window.getSelection() : null;
|
|
11473
|
+
if (sel && !sel.isCollapsed) return;
|
|
9871
11474
|
const tip = this._ensureNoteTip();
|
|
9872
11475
|
const noteId = span.dataset.noteId;
|
|
9873
11476
|
// Resolve note metadata from currentNotes · used for the time
|
|
@@ -12691,9 +14294,8 @@
|
|
|
12691
14294
|
<span class="brief-picker-num">${this.escape(num)}</span>
|
|
12692
14295
|
<span class="brief-picker-main">
|
|
12693
14296
|
<span class="brief-picker-title">${this.escape(b.title || "(untitled)")}</span>
|
|
12694
|
-
${subtitle ? `<span class="brief-picker-sub">${this.escape(subtitle)}</span>` : ""}
|
|
14297
|
+
${(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>` : ""}
|
|
12695
14298
|
</span>
|
|
12696
|
-
${filedLabel ? `<span class="brief-picker-time">${this.escape(filedLabel)}</span>` : ""}
|
|
12697
14299
|
<span class="brief-picker-arrow">↗</span>
|
|
12698
14300
|
</a>
|
|
12699
14301
|
`;
|
|
@@ -13597,10 +15199,27 @@
|
|
|
13597
15199
|
// (instead of relying on agent-overlay's autoTagAvatars regex) is
|
|
13598
15200
|
// required for custom agents whose avatarPath is a data: URL —
|
|
13599
15201
|
// the regex only matches `/avatars/*.svg`.
|
|
15202
|
+
// Head-cast avatars · each director wrapped in a span so a
|
|
15203
|
+
// small thread-trigger badge can pin to the avatar's bottom-
|
|
15204
|
+
// right. Avatar click stays as the agent-overlay open (existing
|
|
15205
|
+
// data-agent behavior); the badge gets its own click target
|
|
15206
|
+
// (data-thread-trigger) so the two affordances don't fight.
|
|
15207
|
+
// Chair is never threadable — filter on roleKind. Allow every
|
|
15208
|
+
// status (live, paused, adjourned) — threads are independent
|
|
15209
|
+
// rooms; the parent's status doesn't gate them. Only block
|
|
15210
|
+
// when this room itself is a thread (no nested threads).
|
|
15211
|
+
const canThreadHere = r.kind !== "thread";
|
|
15212
|
+
const threadTipBase = this._t("thread_trigger") || "// thread";
|
|
15213
|
+
const threadIcon = this._threadTriggerIconSvg();
|
|
13600
15214
|
const castImgs = this.currentMembers
|
|
13601
15215
|
.map((a) => {
|
|
13602
15216
|
const id = this.escape(a.id);
|
|
13603
|
-
|
|
15217
|
+
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)}">`;
|
|
15218
|
+
const showBadge = canThreadHere && a.roleKind !== "moderator";
|
|
15219
|
+
const badge = showBadge
|
|
15220
|
+
? `<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>`
|
|
15221
|
+
: "";
|
|
15222
|
+
return `<span class="head-cast-wrap">${img}${badge}</span>`;
|
|
13604
15223
|
})
|
|
13605
15224
|
.join("");
|
|
13606
15225
|
const castCount = this.currentMembers.length;
|
|
@@ -13660,7 +15279,6 @@
|
|
|
13660
15279
|
// tokens are not routed through `_t()`.
|
|
13661
15280
|
const statusWord = r.status !== "live" ? String(r.status).toUpperCase() : "";
|
|
13662
15281
|
head.innerHTML = `
|
|
13663
|
-
<button type="button" class="room-head-expand" data-sidebar-expand title="${this.escape(this._t("sidebar_expand"))}" aria-label="${this.escape(this._t("sidebar_expand"))}" data-tip="${this.escape(this._t("sidebar_expand"))}"></button>
|
|
13664
15282
|
<div class="room-info">
|
|
13665
15283
|
<div class="room-kicker">
|
|
13666
15284
|
<span class="kicker-num">// ROOM #${r.number}</span>
|
|
@@ -13674,9 +15292,10 @@
|
|
|
13674
15292
|
</div>
|
|
13675
15293
|
<div class="head-actions">
|
|
13676
15294
|
<a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
|
|
15295
|
+
<a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
|
|
13677
15296
|
<div class="head-cast">${castHtml}</div>
|
|
13678
15297
|
<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>
|
|
13679
|
-
<a href="#" class="
|
|
15298
|
+
<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>
|
|
13680
15299
|
<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>
|
|
13681
15300
|
${this.currentBrief
|
|
13682
15301
|
? (() => {
|
|
@@ -15530,14 +17149,36 @@
|
|
|
15530
17149
|
const isChairCard =
|
|
15531
17150
|
article.classList.contains("chair-direct") ||
|
|
15532
17151
|
article.classList.contains("chair-intervention");
|
|
15533
|
-
|
|
17152
|
+
// Single-active-speaker gate · the streaming animation belongs
|
|
17153
|
+
// only to the director whose turn is currently visible per the
|
|
17154
|
+
// server's queue-update activeMessageId field. In text mode this
|
|
17155
|
+
// erases the brief same-frame flash where A's finalize and B's
|
|
17156
|
+
// appended SSE events both run their DOM mutations in the same
|
|
17157
|
+
// rAF — without the gate, both bubbles render `.streaming` and
|
|
17158
|
+
// the user reads "two directors speaking at once." Chair cards
|
|
17159
|
+
// and non-agent messages (user, system) keep their own
|
|
17160
|
+
// streaming semantics; the gate only applies to director
|
|
17161
|
+
// (agent.roleKind === "director") bubbles. We can't read
|
|
17162
|
+
// roleKind here without a getter, so use the same heuristic
|
|
17163
|
+
// updateMessageBodyDom already trusts: a regular .msg article
|
|
17164
|
+
// that is NOT a chair-card is a director or user; user
|
|
17165
|
+
// messages don't carry the streaming class anyway, so this
|
|
17166
|
+
// narrows safely.
|
|
17167
|
+
const isAgentArticle =
|
|
17168
|
+
article.classList.contains("msg") &&
|
|
17169
|
+
!article.classList.contains("user") &&
|
|
17170
|
+
!isChairCard;
|
|
17171
|
+
const activeId = this.currentActiveMessageId || null;
|
|
17172
|
+
const speakingGate = !isAgentArticle || !activeId || messageId === activeId;
|
|
17173
|
+
const wantStreaming = !!streaming && speakingGate;
|
|
17174
|
+
if (empty && wantStreaming) {
|
|
15534
17175
|
bubble.innerHTML = this.thinkingHtml();
|
|
15535
17176
|
article.classList.add("thinking");
|
|
15536
17177
|
article.classList.add(isChairCard ? "is-streaming" : "streaming");
|
|
15537
17178
|
} else {
|
|
15538
17179
|
bubble.innerHTML = this.renderBody(display);
|
|
15539
17180
|
article.classList.toggle("thinking", false);
|
|
15540
|
-
article.classList.toggle(isChairCard ? "is-streaming" : "streaming",
|
|
17181
|
+
article.classList.toggle(isChairCard ? "is-streaming" : "streaming", wantStreaming);
|
|
15541
17182
|
// Re-apply chairman's-notes highlights · the bubble's
|
|
15542
17183
|
// innerHTML rewrite above wipes any previously-injected
|
|
15543
17184
|
// .note-highlight spans. Skip mid-stream (offsets won't match
|
|
@@ -15548,6 +17189,21 @@
|
|
|
15548
17189
|
}
|
|
15549
17190
|
},
|
|
15550
17191
|
|
|
17192
|
+
/** Re-evaluate the streaming class on a single bubble using its
|
|
17193
|
+
* current `meta.streaming` and the current activeMessageId. Used
|
|
17194
|
+
* by the queue-update SSE handler to flip the speaking animation
|
|
17195
|
+
* between A (finalized) and B (newly active) without waiting
|
|
17196
|
+
* for a token tick. No-op when the message isn't streaming or
|
|
17197
|
+
* the article isn't in the DOM yet. */
|
|
17198
|
+
_refreshStreamingClassForId(messageId) {
|
|
17199
|
+
if (!messageId) return;
|
|
17200
|
+
const msg = this.currentMessages.find((m) => m.id === messageId);
|
|
17201
|
+
if (!msg) return;
|
|
17202
|
+
const streaming = !!(msg.meta && msg.meta.streaming);
|
|
17203
|
+
if (!streaming) return;
|
|
17204
|
+
this.updateMessageBodyDom(messageId, msg.body || "", true);
|
|
17205
|
+
},
|
|
17206
|
+
|
|
15551
17207
|
/** Count of prior non-empty messages this speaker would see as context. */
|
|
15552
17208
|
contextCountAt(messageId) {
|
|
15553
17209
|
const idx = this.currentMessages.findIndex((m) => m.id === messageId);
|
|
@@ -16212,12 +17868,28 @@
|
|
|
16212
17868
|
|
|
16213
17869
|
const empty = !displayBody;
|
|
16214
17870
|
const streaming = m.meta && m.meta.streaming === true;
|
|
17871
|
+
// Single-active-speaker gate · mirrors `updateMessageBodyDom`.
|
|
17872
|
+
// For director (non-chair, non-user, agent) messages, only the
|
|
17873
|
+
// bubble whose id matches the server's activeMessageId may
|
|
17874
|
+
// wear the .streaming + .thinking classes. This keeps the
|
|
17875
|
+
// first-paint and the live-update class state consistent so
|
|
17876
|
+
// the text-mode "two directors flashing" race vanishes.
|
|
17877
|
+
const isDirectorMsg = !isUser && !isChair;
|
|
17878
|
+
const activeId = this.currentActiveMessageId || null;
|
|
17879
|
+
// `currentActiveMessageId` tracks the MAIN room's currently-
|
|
17880
|
+
// visible speaker · doesn't apply to thread windows (which run
|
|
17881
|
+
// their own SSE / pump with a different message id pool). When
|
|
17882
|
+
// rendering inside a thread, skip the gate so the thread's own
|
|
17883
|
+
// streaming bubble shows the dots + animation normally.
|
|
17884
|
+
const speakingGate = this._renderingThread
|
|
17885
|
+
|| !isDirectorMsg || !activeId || m.id === activeId;
|
|
17886
|
+
const wantStreaming = !!streaming && speakingGate;
|
|
16215
17887
|
const stateCls = [];
|
|
16216
|
-
if (
|
|
16217
|
-
if (empty &&
|
|
17888
|
+
if (wantStreaming) stateCls.push("streaming");
|
|
17889
|
+
if (empty && wantStreaming) stateCls.push("thinking");
|
|
16218
17890
|
if (metaKind) stateCls.push(`kind-${metaKind}`);
|
|
16219
17891
|
|
|
16220
|
-
const bubbleHtml = (empty &&
|
|
17892
|
+
const bubbleHtml = (empty && wantStreaming)
|
|
16221
17893
|
? this.thinkingHtml()
|
|
16222
17894
|
: this.renderBody(displayBody);
|
|
16223
17895
|
|
|
@@ -16245,7 +17917,18 @@
|
|
|
16245
17917
|
const webSearchUsed = !!(m.meta && m.meta.webSearchUsed);
|
|
16246
17918
|
const webSearchQuery = (m.meta && typeof m.meta.webSearchQuery === "string") ? m.meta.webSearchQuery : "";
|
|
16247
17919
|
const webSearchSources = (m.meta && Array.isArray(m.meta.webSearchSources)) ? m.meta.webSearchSources : [];
|
|
16248
|
-
|
|
17920
|
+
// Thread mode promotes the director's web-search into a
|
|
17921
|
+
// standalone `.msg-tool-card` rendered ABOVE the bubble,
|
|
17922
|
+
// mirroring the chair's tool-use card in main rooms (chair.ts
|
|
17923
|
+
// emits a tool-use message; here the director's webSearch
|
|
17924
|
+
// lives on the same message's meta, so we synthesise the same
|
|
17925
|
+
// visual treatment in-place). In the main room layout we
|
|
17926
|
+
// keep the original inline-badge-in-meta + sources-panel-
|
|
17927
|
+
// under-bubble pattern (matches every other director bubble
|
|
17928
|
+
// in the room and doesn't compete with the chair's tool-use
|
|
17929
|
+
// cards above).
|
|
17930
|
+
const renderWsAsCard = this._renderingThread === true && webSearchUsed;
|
|
17931
|
+
const webSearchBadge = (!renderWsAsCard && webSearchUsed)
|
|
16249
17932
|
? (() => {
|
|
16250
17933
|
const n = webSearchSources.length;
|
|
16251
17934
|
const title = this.escape(this._t("msg_ws_title", { query: webSearchQuery, n }));
|
|
@@ -16255,7 +17938,7 @@
|
|
|
16255
17938
|
return `<button type="button" class="msg-web-search" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" title="${title}">🔍 ${label}</button>`;
|
|
16256
17939
|
})()
|
|
16257
17940
|
: "";
|
|
16258
|
-
const webSearchSourcesPanel = webSearchUsed && webSearchSources.length > 0
|
|
17941
|
+
const webSearchSourcesPanel = (!renderWsAsCard && webSearchUsed && webSearchSources.length > 0)
|
|
16259
17942
|
? `<div class="msg-web-search-sources" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
|
|
16260
17943
|
<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>
|
|
16261
17944
|
<ol class="msg-web-search-list">
|
|
@@ -16270,6 +17953,76 @@
|
|
|
16270
17953
|
</ol>
|
|
16271
17954
|
</div>`
|
|
16272
17955
|
: "";
|
|
17956
|
+
// Thread mode · synthesise a `.msg-tool-card` for the
|
|
17957
|
+
// director's web-search and surface it ABOVE the bubble.
|
|
17958
|
+
// Visual shape mirrors `chair.ts`'s standalone tool-use card:
|
|
17959
|
+
// banner kicker + body row (status mark + "Searched 'query'"
|
|
17960
|
+
// text + caret) + collapsible sources list + expand button.
|
|
17961
|
+
// Same `data-msg-ws-toggle` / `data-msg-ws-sources` attribute
|
|
17962
|
+
// pair the chair card uses → the existing toggle handler at
|
|
17963
|
+
// app.js:21376 works without modification. Always status=done
|
|
17964
|
+
// since the message meta is set after the search completed.
|
|
17965
|
+
const threadWebSearchCard = renderWsAsCard
|
|
17966
|
+
? (() => {
|
|
17967
|
+
const n = webSearchSources.length;
|
|
17968
|
+
const hasSources = n > 0;
|
|
17969
|
+
const tail = "";
|
|
17970
|
+
const stamp = !hasSources
|
|
17971
|
+
? this.escape(this._t("msg_ws_failed"))
|
|
17972
|
+
: n === 1
|
|
17973
|
+
? this.escape(this._t("msg_ws_done_one", { tail }))
|
|
17974
|
+
: this.escape(this._t("msg_ws_done", { n, tail }));
|
|
17975
|
+
const stampClass = hasSources ? "status-done" : "status-failed";
|
|
17976
|
+
const mark = hasSources ? "✓" : "⚠";
|
|
17977
|
+
const bodyText = this.escape(this._t("msg_ws_card_body", { query: webSearchQuery }));
|
|
17978
|
+
const caret = hasSources ? `<span class="msg-tool-caret" aria-hidden="true">▸</span>` : "";
|
|
17979
|
+
const bodyToggleAttrs = hasSources
|
|
17980
|
+
? ` data-msg-ws-toggle data-message-id="${this.escape(m.id)}" role="button" tabindex="0" aria-label="${this.escape(this._t("msg_ws_toggle"))}"`
|
|
17981
|
+
: "";
|
|
17982
|
+
const sourcesList = hasSources
|
|
17983
|
+
? `
|
|
17984
|
+
<ol class="msg-tool-sources-list" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
|
|
17985
|
+
${webSearchSources.map((s, i) => {
|
|
17986
|
+
const url = s && typeof s.url === "string" ? s.url : "";
|
|
17987
|
+
const title = s && typeof s.title === "string" ? s.title : url;
|
|
17988
|
+
const desc = s && typeof s.description === "string" ? s.description : "";
|
|
17989
|
+
const host = this.hostnameOf(url);
|
|
17990
|
+
const numStr = String(i + 1).padStart(2, "0");
|
|
17991
|
+
return `
|
|
17992
|
+
<li>
|
|
17993
|
+
<span class="msg-tool-sources-num">${numStr}</span>
|
|
17994
|
+
<a href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" class="msg-tool-sources-title">
|
|
17995
|
+
<span class="msg-tool-sources-title-text">${this.escape(title)}</span>
|
|
17996
|
+
<span class="msg-tool-sources-ext" aria-hidden="true">↗</span>
|
|
17997
|
+
</a>
|
|
17998
|
+
<span class="msg-tool-sources-host">${this.escape(host)}</span>
|
|
17999
|
+
${desc ? `<span class="msg-tool-sources-desc">${this.escape(desc)}</span>` : ""}
|
|
18000
|
+
</li>
|
|
18001
|
+
`;
|
|
18002
|
+
}).join("")}
|
|
18003
|
+
</ol>
|
|
18004
|
+
<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"))}">
|
|
18005
|
+
<span class="msg-tool-sources-expand-icon" aria-hidden="true">▾</span>
|
|
18006
|
+
<span class="msg-tool-sources-expand-show">${this.escape(this._t("msg_ws_expand_show", { n }))}</span>
|
|
18007
|
+
<span class="msg-tool-sources-expand-hide">${this.escape(this._t("msg_ws_expand_hide"))}</span>
|
|
18008
|
+
</button>`
|
|
18009
|
+
: "";
|
|
18010
|
+
return `
|
|
18011
|
+
<div class="msg-tool-card ${stampClass} thread-tool-card" data-message-id="${this.escape(m.id)}">
|
|
18012
|
+
<div class="msg-tool-banner">
|
|
18013
|
+
<span class="msg-tool-banner-tag">${this.escape(this._t("msg_ws_banner"))}</span>
|
|
18014
|
+
<span class="msg-tool-banner-stamp">${stamp}</span>
|
|
18015
|
+
</div>
|
|
18016
|
+
<div class="msg-tool-card-body${hasSources ? " is-toggle" : ""}"${bodyToggleAttrs}>
|
|
18017
|
+
<span class="msg-tool-mark" aria-hidden="true">${mark}</span>
|
|
18018
|
+
<span class="msg-tool-card-text">${bodyText}</span>
|
|
18019
|
+
${caret}
|
|
18020
|
+
</div>
|
|
18021
|
+
${sourcesList}
|
|
18022
|
+
</div>
|
|
18023
|
+
`;
|
|
18024
|
+
})()
|
|
18025
|
+
: "";
|
|
16273
18026
|
|
|
16274
18027
|
// Round-end card · render even DURING streaming so the user sees
|
|
16275
18028
|
// a skeleton placeholder immediately after the chair's ping
|
|
@@ -16339,9 +18092,11 @@
|
|
|
16339
18092
|
${ctxBadge}
|
|
16340
18093
|
<span class="msg-time">${this.timeFmt(m.createdAt)}</span>
|
|
16341
18094
|
</div>
|
|
18095
|
+
${threadWebSearchCard}
|
|
16342
18096
|
<div class="msg-bubble">${bubbleHtml}</div>
|
|
16343
18097
|
${webSearchSourcesPanel}
|
|
16344
18098
|
</div>
|
|
18099
|
+
${this._messageToolbarHtml(m, isUser, isChair, excusedMember, author)}
|
|
16345
18100
|
</article>
|
|
16346
18101
|
${roundEndCard}
|
|
16347
18102
|
${roundPromptCard}
|
|
@@ -19889,6 +21644,21 @@
|
|
|
19889
21644
|
app.closeRecordingStopModal();
|
|
19890
21645
|
return;
|
|
19891
21646
|
}
|
|
21647
|
+
// Pause-after-current save modal · three-option grammar
|
|
21648
|
+
// (save-adjourn / save-keep / continue). Fired by SSE
|
|
21649
|
+
// room-paused when payload.mode==="soft" + recording active.
|
|
21650
|
+
const recPauseSaveChoice = e.target.closest("[data-rec-pause-save-choice]");
|
|
21651
|
+
if (recPauseSaveChoice) {
|
|
21652
|
+
e.preventDefault();
|
|
21653
|
+
app.handleRecordingPauseSaveChoice(recPauseSaveChoice.getAttribute("data-rec-pause-save-choice"));
|
|
21654
|
+
return;
|
|
21655
|
+
}
|
|
21656
|
+
if (e.target.id === "rec-pause-save-overlay") {
|
|
21657
|
+
// Backdrop click · keep recording (don't accidentally end
|
|
21658
|
+
// a recording the user explicitly hasn't decided to stop).
|
|
21659
|
+
app.closeRecordingPauseSaveModal();
|
|
21660
|
+
return;
|
|
21661
|
+
}
|
|
19892
21662
|
// Record button in the room header · toggle on/off.
|
|
19893
21663
|
if (e.target.closest("[data-record-toggle]")) {
|
|
19894
21664
|
e.preventDefault();
|
|
@@ -21148,19 +22918,21 @@
|
|
|
21148
22918
|
}
|
|
21149
22919
|
});
|
|
21150
22920
|
|
|
21151
|
-
//
|
|
21152
|
-
//
|
|
21153
|
-
//
|
|
21154
|
-
// We toggle
|
|
21155
|
-
//
|
|
21156
|
-
// after the last scroll
|
|
21157
|
-
//
|
|
21158
|
-
// anywhere in the document.
|
|
22921
|
+
// Scrollbar auto-reveal · `.ib-textarea` (room input bar) and
|
|
22922
|
+
// `.thread-float-messages` (thread chat) both render with
|
|
22923
|
+
// `scrollbar-width: none` by default and only flip visible
|
|
22924
|
+
// while the user is actively scrolling. We toggle `.is-scrolling`
|
|
22925
|
+
// with an 800 ms debounce so the scrollbar fades back out
|
|
22926
|
+
// shortly after the last scroll. Scroll events don't bubble,
|
|
22927
|
+
// hence the capture-phase listener.
|
|
21159
22928
|
const _scrollDecayTimers = new WeakMap();
|
|
21160
22929
|
document.addEventListener("scroll", (ev) => {
|
|
21161
22930
|
const t = ev.target;
|
|
21162
|
-
if (!t || !(t instanceof
|
|
21163
|
-
|
|
22931
|
+
if (!t || !(t instanceof HTMLElement)) return;
|
|
22932
|
+
const eligible =
|
|
22933
|
+
(t instanceof HTMLTextAreaElement && t.classList.contains("ib-textarea"))
|
|
22934
|
+
|| t.classList.contains("thread-float-messages");
|
|
22935
|
+
if (!eligible) return;
|
|
21164
22936
|
t.classList.add("is-scrolling");
|
|
21165
22937
|
const prev = _scrollDecayTimers.get(t);
|
|
21166
22938
|
if (prev) clearTimeout(prev);
|