privateboard 0.1.37 → 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 +1098 -47
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1098 -47
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1077 -47
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/public/adjourn-overlay.css +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 +1621 -21
- package/public/home.html +3 -3
- package/public/i18n.js +267 -0
- package/public/index.html +55 -61
- 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/room-settings.css +24 -9
- package/public/thread.css +1211 -0
- package/public/user-settings.css +6 -6
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
package/public/app.js
CHANGED
|
@@ -343,6 +343,34 @@
|
|
|
343
343
|
: (btn.getAttribute("data-more") || this._t("convene_show_more"));
|
|
344
344
|
btn.textContent = label;
|
|
345
345
|
});
|
|
346
|
+
|
|
347
|
+
// Thread trigger · per-bubble "// thread" link on each director
|
|
348
|
+
// message. Doc-level delegate so it picks up bubbles rendered
|
|
349
|
+
// both at chat first paint AND via every message-appended /
|
|
350
|
+
// renderChat invalidation.
|
|
351
|
+
document.addEventListener("click", (e) => {
|
|
352
|
+
const btn = e.target.closest("[data-thread-trigger]");
|
|
353
|
+
if (!btn) return;
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
e.stopPropagation();
|
|
356
|
+
const directorId = btn.getAttribute("data-thread-trigger");
|
|
357
|
+
if (directorId) this.openThreadWith(directorId);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Threads-list popover trigger · the room head's "all threads"
|
|
361
|
+
// icon. Click toggles the popover open/closed; click outside
|
|
362
|
+
// dismisses (handled inside openThreadsListPopover).
|
|
363
|
+
document.addEventListener("click", (e) => {
|
|
364
|
+
const btn = e.target.closest("[data-threads-trigger]");
|
|
365
|
+
if (!btn) return;
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
e.stopPropagation();
|
|
368
|
+
if (document.getElementById("thread-list-pop")) {
|
|
369
|
+
this.closeThreadsListPopover();
|
|
370
|
+
} else {
|
|
371
|
+
this.openThreadsListPopover(btn);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
346
374
|
// Live-subtitle minimize / restore · attached in CAPTURE phase
|
|
347
375
|
// so it wins ahead of any outside-click handlers (picker dismiss
|
|
348
376
|
// / overlay close) that might `stopPropagation` and swallow the
|
|
@@ -1089,6 +1117,30 @@
|
|
|
1089
1117
|
return;
|
|
1090
1118
|
}
|
|
1091
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
|
+
|
|
1092
1144
|
// We may be coming from the All Reports view OR an agent profile
|
|
1093
1145
|
// — make sure the room main view is the visible one and the
|
|
1094
1146
|
// others are hidden. The agent-view hide is what stops a stale
|
|
@@ -1296,6 +1348,12 @@
|
|
|
1296
1348
|
console.error("[openRoom] renderRoom failed:", err);
|
|
1297
1349
|
}
|
|
1298
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);
|
|
1299
1357
|
// Round-table stage · paint + decide whether the .chat or
|
|
1300
1358
|
// .roundtable-stage should be visible for this room. Runs
|
|
1301
1359
|
// AFTER renderRoom so the DOM exists and currentRoom /
|
|
@@ -1369,6 +1427,19 @@
|
|
|
1369
1427
|
},
|
|
1370
1428
|
|
|
1371
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");
|
|
1372
1443
|
// Recording guard · if a meeting capture is in progress for
|
|
1373
1444
|
// the current room, intercept and let the user choose:
|
|
1374
1445
|
// stop-and-save before leaving or cancel and stay. We re-route
|
|
@@ -2099,6 +2170,26 @@
|
|
|
2099
2170
|
this._pendingAdjournAfterRecordStop = false;
|
|
2100
2171
|
try { this.adjournRoom({ skipBrief: true }); }
|
|
2101
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();
|
|
2102
2193
|
}
|
|
2103
2194
|
} else if (kind === "room-resumed") {
|
|
2104
2195
|
if (this.currentRoom) {
|
|
@@ -5147,6 +5238,1296 @@
|
|
|
5147
5238
|
}
|
|
5148
5239
|
},
|
|
5149
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
|
+
|
|
5150
6531
|
async submitFollowUp() {
|
|
5151
6532
|
const overlay = document.getElementById("followup-overlay");
|
|
5152
6533
|
if (!overlay) return;
|
|
@@ -6030,6 +7411,81 @@
|
|
|
6030
7411
|
if (el) el.remove();
|
|
6031
7412
|
},
|
|
6032
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
|
+
|
|
6033
7489
|
async handleRecordingStopChoice(mode) {
|
|
6034
7490
|
this.closeRecordingStopModal();
|
|
6035
7491
|
if (mode === "cancel") return;
|
|
@@ -6920,9 +8376,17 @@
|
|
|
6920
8376
|
timeFmt(ms) {
|
|
6921
8377
|
if (!ms) return "";
|
|
6922
8378
|
const d = new Date(ms);
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
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}`;
|
|
6926
8390
|
},
|
|
6927
8391
|
|
|
6928
8392
|
relTime(ms) {
|
|
@@ -9712,7 +11176,11 @@
|
|
|
9712
11176
|
const article = document.querySelector(`article[data-message-id="${openerId}"]`);
|
|
9713
11177
|
if (!article) return;
|
|
9714
11178
|
try {
|
|
9715
|
-
|
|
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" });
|
|
9716
11184
|
} catch { /* */ }
|
|
9717
11185
|
this.chatStuckToBottom = false;
|
|
9718
11186
|
this._suppressBottomScrollUntil = Date.now() + 2000;
|
|
@@ -9994,6 +11462,15 @@
|
|
|
9994
11462
|
|
|
9995
11463
|
showNoteTooltip(span) {
|
|
9996
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;
|
|
9997
11474
|
const tip = this._ensureNoteTip();
|
|
9998
11475
|
const noteId = span.dataset.noteId;
|
|
9999
11476
|
// Resolve note metadata from currentNotes · used for the time
|
|
@@ -12817,9 +14294,8 @@
|
|
|
12817
14294
|
<span class="brief-picker-num">${this.escape(num)}</span>
|
|
12818
14295
|
<span class="brief-picker-main">
|
|
12819
14296
|
<span class="brief-picker-title">${this.escape(b.title || "(untitled)")}</span>
|
|
12820
|
-
${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>` : ""}
|
|
12821
14298
|
</span>
|
|
12822
|
-
${filedLabel ? `<span class="brief-picker-time">${this.escape(filedLabel)}</span>` : ""}
|
|
12823
14299
|
<span class="brief-picker-arrow">↗</span>
|
|
12824
14300
|
</a>
|
|
12825
14301
|
`;
|
|
@@ -13723,10 +15199,27 @@
|
|
|
13723
15199
|
// (instead of relying on agent-overlay's autoTagAvatars regex) is
|
|
13724
15200
|
// required for custom agents whose avatarPath is a data: URL —
|
|
13725
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();
|
|
13726
15214
|
const castImgs = this.currentMembers
|
|
13727
15215
|
.map((a) => {
|
|
13728
15216
|
const id = this.escape(a.id);
|
|
13729
|
-
|
|
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>`;
|
|
13730
15223
|
})
|
|
13731
15224
|
.join("");
|
|
13732
15225
|
const castCount = this.currentMembers.length;
|
|
@@ -13799,9 +15292,10 @@
|
|
|
13799
15292
|
</div>
|
|
13800
15293
|
<div class="head-actions">
|
|
13801
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>
|
|
13802
15296
|
<div class="head-cast">${castHtml}</div>
|
|
13803
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>
|
|
13804
|
-
<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>
|
|
13805
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>
|
|
13806
15300
|
${this.currentBrief
|
|
13807
15301
|
? (() => {
|
|
@@ -16382,7 +17876,13 @@
|
|
|
16382
17876
|
// the text-mode "two directors flashing" race vanishes.
|
|
16383
17877
|
const isDirectorMsg = !isUser && !isChair;
|
|
16384
17878
|
const activeId = this.currentActiveMessageId || null;
|
|
16385
|
-
|
|
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;
|
|
16386
17886
|
const wantStreaming = !!streaming && speakingGate;
|
|
16387
17887
|
const stateCls = [];
|
|
16388
17888
|
if (wantStreaming) stateCls.push("streaming");
|
|
@@ -16417,7 +17917,18 @@
|
|
|
16417
17917
|
const webSearchUsed = !!(m.meta && m.meta.webSearchUsed);
|
|
16418
17918
|
const webSearchQuery = (m.meta && typeof m.meta.webSearchQuery === "string") ? m.meta.webSearchQuery : "";
|
|
16419
17919
|
const webSearchSources = (m.meta && Array.isArray(m.meta.webSearchSources)) ? m.meta.webSearchSources : [];
|
|
16420
|
-
|
|
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)
|
|
16421
17932
|
? (() => {
|
|
16422
17933
|
const n = webSearchSources.length;
|
|
16423
17934
|
const title = this.escape(this._t("msg_ws_title", { query: webSearchQuery, n }));
|
|
@@ -16427,7 +17938,7 @@
|
|
|
16427
17938
|
return `<button type="button" class="msg-web-search" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" title="${title}">🔍 ${label}</button>`;
|
|
16428
17939
|
})()
|
|
16429
17940
|
: "";
|
|
16430
|
-
const webSearchSourcesPanel = webSearchUsed && webSearchSources.length > 0
|
|
17941
|
+
const webSearchSourcesPanel = (!renderWsAsCard && webSearchUsed && webSearchSources.length > 0)
|
|
16431
17942
|
? `<div class="msg-web-search-sources" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
|
|
16432
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>
|
|
16433
17944
|
<ol class="msg-web-search-list">
|
|
@@ -16442,6 +17953,76 @@
|
|
|
16442
17953
|
</ol>
|
|
16443
17954
|
</div>`
|
|
16444
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
|
+
: "";
|
|
16445
18026
|
|
|
16446
18027
|
// Round-end card · render even DURING streaming so the user sees
|
|
16447
18028
|
// a skeleton placeholder immediately after the chair's ping
|
|
@@ -16511,9 +18092,11 @@
|
|
|
16511
18092
|
${ctxBadge}
|
|
16512
18093
|
<span class="msg-time">${this.timeFmt(m.createdAt)}</span>
|
|
16513
18094
|
</div>
|
|
18095
|
+
${threadWebSearchCard}
|
|
16514
18096
|
<div class="msg-bubble">${bubbleHtml}</div>
|
|
16515
18097
|
${webSearchSourcesPanel}
|
|
16516
18098
|
</div>
|
|
18099
|
+
${this._messageToolbarHtml(m, isUser, isChair, excusedMember, author)}
|
|
16517
18100
|
</article>
|
|
16518
18101
|
${roundEndCard}
|
|
16519
18102
|
${roundPromptCard}
|
|
@@ -20061,6 +21644,21 @@
|
|
|
20061
21644
|
app.closeRecordingStopModal();
|
|
20062
21645
|
return;
|
|
20063
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
|
+
}
|
|
20064
21662
|
// Record button in the room header · toggle on/off.
|
|
20065
21663
|
if (e.target.closest("[data-record-toggle]")) {
|
|
20066
21664
|
e.preventDefault();
|
|
@@ -21320,19 +22918,21 @@
|
|
|
21320
22918
|
}
|
|
21321
22919
|
});
|
|
21322
22920
|
|
|
21323
|
-
//
|
|
21324
|
-
//
|
|
21325
|
-
//
|
|
21326
|
-
// We toggle
|
|
21327
|
-
//
|
|
21328
|
-
// after the last scroll
|
|
21329
|
-
//
|
|
21330
|
-
// 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.
|
|
21331
22928
|
const _scrollDecayTimers = new WeakMap();
|
|
21332
22929
|
document.addEventListener("scroll", (ev) => {
|
|
21333
22930
|
const t = ev.target;
|
|
21334
|
-
if (!t || !(t instanceof
|
|
21335
|
-
|
|
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;
|
|
21336
22936
|
t.classList.add("is-scrolling");
|
|
21337
22937
|
const prev = _scrollDecayTimers.get(t);
|
|
21338
22938
|
if (prev) clearTimeout(prev);
|