viberoom 0.4.2 → 0.5.1
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/README.md +1 -1
- package/dist/acp-client.js +3 -0
- package/dist/commands.js +31 -0
- package/dist/edit.js +10 -1
- package/dist/files.js +60 -0
- package/dist/hub.js +73 -2
- package/dist/persona.js +82 -12
- package/dist/room.js +213 -25
- package/dist/server.js +102 -5
- package/dist/skills.js +4 -0
- package/dist/templates.js +76 -0
- package/package.json +2 -1
- package/templates/design-critique/template.json +24 -0
- package/templates/explainer/template.json +17 -0
- package/templates/forge-and-lumen/template.json +28 -0
- package/templates/pair-programmer/template.json +17 -0
- package/ui/app.css +175 -20
- package/ui/app.js +868 -116
- package/ui/icons.js +13 -10
- package/ui/index.html +31 -3
- package/ui/theme.css +2 -2
package/ui/app.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
selection: { kind: "room" },
|
|
15
15
|
detailsOpen: false,
|
|
16
16
|
unread: new Map(),
|
|
17
|
+
openRooms: [],
|
|
17
18
|
search: "",
|
|
18
19
|
roomSearch: "",
|
|
19
20
|
expanded: new Set(),
|
|
@@ -30,9 +31,8 @@
|
|
|
30
31
|
fvBody: $("#fv-body"),
|
|
31
32
|
fvOpen: $("#fv-open"),
|
|
32
33
|
rail: $("#rail"),
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
railUnread: $("#rail-unread"),
|
|
34
|
+
railRooms: $("#rail-rooms"),
|
|
35
|
+
railRoomsWrap: $("#rail-rooms-wrap"),
|
|
36
36
|
railMe: $("#rail-me"),
|
|
37
37
|
railMeAvatar: $("#rail-me-avatar"),
|
|
38
38
|
railMeLabel: $("#rail-me-label"),
|
|
@@ -61,12 +61,14 @@
|
|
|
61
61
|
search: $("#search"),
|
|
62
62
|
messages: $("#messages"),
|
|
63
63
|
jumpLatest: $("#jump-latest"),
|
|
64
|
-
|
|
64
|
+
doneNotes: $("#done-notes"),
|
|
65
65
|
mentionMenu: $("#mention-menu"),
|
|
66
66
|
emojiMenu: $("#emoji-menu"),
|
|
67
67
|
emojiBtn: $("#emoji-btn"),
|
|
68
68
|
sideRoomEmoji: $("#side-room-emoji"),
|
|
69
69
|
composer: $("#composer"),
|
|
70
|
+
shotsTray: $("#shots-tray"),
|
|
71
|
+
lightbox: $("#lightbox"),
|
|
70
72
|
input: $("#input"),
|
|
71
73
|
pageView: $("#page-view"),
|
|
72
74
|
pageInner: $("#page-inner"),
|
|
@@ -126,7 +128,7 @@
|
|
|
126
128
|
eraseSubmit: $("#erase-submit"),
|
|
127
129
|
};
|
|
128
130
|
|
|
129
|
-
const STATUS_LABEL = { starting: "starting…", idle: "ready", queued: "waiting…", thinking: "thinking…", error: "error", offline: "offline", left: "left" };
|
|
131
|
+
const STATUS_LABEL = { unstaffed: "needs a coding agent", starting: "starting…", idle: "ready", queued: "waiting…", thinking: "thinking…", error: "error", offline: "offline", left: "left" };
|
|
130
132
|
const CHAT_EMOJI = ["😀", "😄", "😂", "🙂", "😉", "😍", "🤔", "😎", "🥳", "😅", "😢", "😡", "👍", "👎", "👋", "🙏", "👏", "💪", "🔥", "✨", "🎉", "❤️", "💜", "✅", "❌", "⚠️", "💡", "🚀", "🐛", "🤖", "🤫", "☕"];
|
|
131
133
|
const ROOM_EMOJI = ["🎭", "🚀", "🧪", "🛠️", "🎨", "📚", "🧠", "💬", "🔬", "🎯", "🐙", "☕", "🌈", "🏗️", "🎮", "🔥", "🧩", "📈", "🗺️", "🎧", "🌱", "🏠", "🛸", "🧭"];
|
|
132
134
|
function emojiGrid(list, current, onPick) {
|
|
@@ -275,6 +277,7 @@
|
|
|
275
277
|
}
|
|
276
278
|
function mentions(room, html) {
|
|
277
279
|
return html.replace(/(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu, (m, name) => {
|
|
280
|
+
if (name.toLowerCase() === "all") return `<span class="mention all">@${esc(name)}</span>`;
|
|
278
281
|
const p = findByName(room, name);
|
|
279
282
|
return p ? `<span class="mention" style="color:${p.color}">@${esc(name)}</span>` : m;
|
|
280
283
|
});
|
|
@@ -317,10 +320,17 @@
|
|
|
317
320
|
}
|
|
318
321
|
return out;
|
|
319
322
|
}
|
|
320
|
-
function renderText(room, text) {
|
|
321
|
-
|
|
322
|
-
return
|
|
323
|
+
function renderText(room, text, images) {
|
|
324
|
+
const html = md ? decorate(room, md.parse(String(text == null ? "" : text))) : renderTextLight(room, text);
|
|
325
|
+
return images && images.length ? imageRefs(html, images) : html;
|
|
323
326
|
}
|
|
327
|
+
function imageRefs(html, images) {
|
|
328
|
+
const numbers = new Set(images.map((image, i) => image.n || i + 1));
|
|
329
|
+
return html.replace(/\[img (\d+)\]/gi, (whole, n) =>
|
|
330
|
+
numbers.has(Number(n)) ? `<button type="button" class="img-ref" data-n="${n}" title="Image ${n}">${IMG_GLYPH}<span>${n}</span></button>` : whole,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const IMG_GLYPH = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.5" y="2.5" width="13" height="11" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="5.6" cy="6.4" r="1.4" fill="currentColor"/><path d="M2.6 12.2l3.4-3.4 2.6 2.6 2.2-2.2 3 3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>';
|
|
324
334
|
function renderTextLight(room, text) {
|
|
325
335
|
let html = esc(text);
|
|
326
336
|
const blocks = [];
|
|
@@ -522,9 +532,22 @@
|
|
|
522
532
|
const el = els.messages;
|
|
523
533
|
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
524
534
|
}
|
|
535
|
+
let stuck = true;
|
|
536
|
+
let settling = 0;
|
|
525
537
|
function scrollToBottom() {
|
|
526
538
|
els.messages.scrollTop = els.messages.scrollHeight;
|
|
527
539
|
els.jumpLatest.hidden = true;
|
|
540
|
+
stuck = true;
|
|
541
|
+
if (!settling) settleBottom(20);
|
|
542
|
+
}
|
|
543
|
+
function settleBottom(frames) {
|
|
544
|
+
settling = frames;
|
|
545
|
+
requestAnimationFrame(() => {
|
|
546
|
+
const el = els.messages;
|
|
547
|
+
if (stuck && el.scrollTop + el.clientHeight < el.scrollHeight - 1) el.scrollTop = el.scrollHeight;
|
|
548
|
+
settling = frames - 1;
|
|
549
|
+
if (settling > 0) settleBottom(settling);
|
|
550
|
+
});
|
|
528
551
|
}
|
|
529
552
|
function avatar(p, size, opts) {
|
|
530
553
|
return window.Avatars.avatarHtml(p, size, Object.assign({ recipes: state.recipes }, opts || {}));
|
|
@@ -750,10 +773,19 @@
|
|
|
750
773
|
|
|
751
774
|
function selectRoom(id, opts) {
|
|
752
775
|
if (!state.rooms.has(id)) return;
|
|
776
|
+
if (state.currentRoomId !== id) {
|
|
777
|
+
clearShots();
|
|
778
|
+
doneNotes.length = 0;
|
|
779
|
+
renderDoneNotes();
|
|
780
|
+
}
|
|
753
781
|
state.currentRoomId = id;
|
|
754
782
|
state.unread.delete(id);
|
|
755
783
|
state.selection = { kind: "room" };
|
|
756
784
|
remember("room", id);
|
|
785
|
+
if (!state.openRooms.includes(id)) {
|
|
786
|
+
state.openRooms.push(id);
|
|
787
|
+
post(`/api/rooms/${encodeURIComponent(id)}/open`).catch(() => {});
|
|
788
|
+
}
|
|
757
789
|
setView("room");
|
|
758
790
|
if (!(opts && opts.keepDetails)) closeDetails();
|
|
759
791
|
maybeOfferReconnect();
|
|
@@ -765,12 +797,7 @@
|
|
|
765
797
|
const active = nav === state.view || (nav === "me" && state.selection.kind === "me" && state.detailsOpen);
|
|
766
798
|
b.classList.toggle("active", active);
|
|
767
799
|
});
|
|
768
|
-
|
|
769
|
-
els.railRoom.hidden = !room;
|
|
770
|
-
if (room) els.railRoomLabel.textContent = room.name;
|
|
771
|
-
const unread = [...state.unread.values()].reduce((a, b) => a + b, 0);
|
|
772
|
-
els.railUnread.hidden = !unread;
|
|
773
|
-
els.railUnread.textContent = unread > 99 ? "99+" : String(unread);
|
|
800
|
+
renderRailRooms();
|
|
774
801
|
const s = state.settings;
|
|
775
802
|
if (s) {
|
|
776
803
|
els.railMeAvatar.innerHTML = avatar(meAvatarData(), 44, {});
|
|
@@ -781,6 +808,54 @@
|
|
|
781
808
|
els.railToggle.innerHTML = ic(open ? "collapse" : "expand");
|
|
782
809
|
}
|
|
783
810
|
|
|
811
|
+
function activeRooms() {
|
|
812
|
+
const ids = state.openRooms.filter((id) => state.rooms.has(id));
|
|
813
|
+
if (state.currentRoomId && state.rooms.has(state.currentRoomId) && !ids.includes(state.currentRoomId)) ids.push(state.currentRoomId);
|
|
814
|
+
return ids.map((id) => state.rooms.get(id));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function renderRailRooms() {
|
|
818
|
+
const rooms = activeRooms();
|
|
819
|
+
const box = els.railRooms;
|
|
820
|
+
els.railRoomsWrap.hidden = !rooms.length;
|
|
821
|
+
const seen = new Set();
|
|
822
|
+
let anchor = null;
|
|
823
|
+
for (const room of rooms) {
|
|
824
|
+
seen.add(room.id);
|
|
825
|
+
let b = box.querySelector(`.rail-room[data-room="${CSS.escape(room.id)}"]`);
|
|
826
|
+
if (!b) {
|
|
827
|
+
b = document.createElement("button");
|
|
828
|
+
b.className = "rail-item rail-room";
|
|
829
|
+
b.dataset.room = room.id;
|
|
830
|
+
b.innerHTML = `<span class="ico"></span><span class="label"></span><span class="rail-count" hidden></span>`;
|
|
831
|
+
}
|
|
832
|
+
if (!b.isConnected || b.previousElementSibling !== anchor) box.insertBefore(b, anchor ? anchor.nextSibling : box.firstChild);
|
|
833
|
+
anchor = b;
|
|
834
|
+
const mark = roomMark(room);
|
|
835
|
+
const ico = b.querySelector(".ico");
|
|
836
|
+
if (ico.dataset.mark !== mark) {
|
|
837
|
+
ico.innerHTML = mark;
|
|
838
|
+
ico.dataset.mark = mark;
|
|
839
|
+
}
|
|
840
|
+
b.querySelector(".label").textContent = room.name;
|
|
841
|
+
b.title = roomTitle(room);
|
|
842
|
+
b.classList.toggle("active", room.id === state.currentRoomId && state.view === "room");
|
|
843
|
+
const unread = state.unread.get(room.id) || 0;
|
|
844
|
+
const count = b.querySelector(".rail-count");
|
|
845
|
+
const text = unread ? (unread > 99 ? "99+" : String(unread)) : "";
|
|
846
|
+
if (count.textContent !== text) {
|
|
847
|
+
count.textContent = text;
|
|
848
|
+
count.hidden = !unread;
|
|
849
|
+
if (unread) {
|
|
850
|
+
count.classList.remove("bump");
|
|
851
|
+
void count.offsetWidth;
|
|
852
|
+
count.classList.add("bump");
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
for (const b of [...box.children]) if (!seen.has(b.dataset.room)) b.remove();
|
|
857
|
+
}
|
|
858
|
+
|
|
784
859
|
function setRailOpen(open) {
|
|
785
860
|
els.app.classList.toggle("rail-open", open);
|
|
786
861
|
remember("railOpen", open ? "1" : "0");
|
|
@@ -790,11 +865,12 @@
|
|
|
790
865
|
|
|
791
866
|
function roomStats(room) {
|
|
792
867
|
const agents = room.participants.filter((p) => p.kind === "agent");
|
|
793
|
-
const online = agents.filter((p) => p.status !== "offline" && p.status !== "left").length;
|
|
868
|
+
const online = agents.filter((p) => p.status !== "offline" && p.status !== "left" && p.status !== "unstaffed").length;
|
|
869
|
+
const waiting = agents.filter((p) => p.status === "unstaffed").length;
|
|
794
870
|
const chats = room.messages.filter((m) => m.kind === "chat");
|
|
795
871
|
const last = chats.length ? chats[chats.length - 1].ts : room.createdAt;
|
|
796
872
|
const thinking = agents.some((p) => p.status === "thinking");
|
|
797
|
-
return { agents, online, chats, last, thinking, unread: state.unread.get(room.id) || 0 };
|
|
873
|
+
return { agents, online, waiting, chats, last, thinking, unread: state.unread.get(room.id) || 0 };
|
|
798
874
|
}
|
|
799
875
|
|
|
800
876
|
function sortedRooms() {
|
|
@@ -885,8 +961,7 @@
|
|
|
885
961
|
<h1>${state.freshVibe ? "Welcome" : "Welcome back"}, ${esc(name)} 👋</h1>
|
|
886
962
|
<p>One chat, many coding agents. Summon your vibemates into a room, talk to all of them at once, and let them talk to each other.</p>
|
|
887
963
|
<div class="hero-actions">
|
|
888
|
-
<button class="btn cta hero-cta" id="home-open-room">${ic("
|
|
889
|
-
<button class="btn hero-ghost" id="home-rooms">${ic("rooms")}Your rooms</button>
|
|
964
|
+
<button class="btn cta hero-cta" id="home-open-room">${ic("rooms")}Open room</button>
|
|
890
965
|
</div>
|
|
891
966
|
</div>
|
|
892
967
|
<div class="hero-art" aria-hidden="true">
|
|
@@ -934,8 +1009,10 @@
|
|
|
934
1009
|
</div>
|
|
935
1010
|
</section>
|
|
936
1011
|
</div>`;
|
|
937
|
-
$("#home-open-room").addEventListener("click",
|
|
938
|
-
|
|
1012
|
+
$("#home-open-room").addEventListener("click", () => {
|
|
1013
|
+
setView("rooms");
|
|
1014
|
+
remember("view", "rooms");
|
|
1015
|
+
});
|
|
939
1016
|
const all = $("#home-all-rooms");
|
|
940
1017
|
if (all) all.addEventListener("click", () => setView("rooms"));
|
|
941
1018
|
els.homeView.querySelectorAll(".home-room[data-room]").forEach((b) => b.addEventListener("click", () => selectRoom(b.dataset.room)));
|
|
@@ -951,6 +1028,11 @@
|
|
|
951
1028
|
cta.innerHTML = `<div class="plus">${ic("plus")}</div><div>Open a room</div><div class="hint">a space for you and some vibemates</div>`;
|
|
952
1029
|
cta.addEventListener("click", openRoomDialog);
|
|
953
1030
|
grid.appendChild(cta);
|
|
1031
|
+
const tpl = document.createElement("div");
|
|
1032
|
+
tpl.className = "card cta hover room-card";
|
|
1033
|
+
tpl.innerHTML = `<div class="plus">${ic("rooms")}</div><div>Start from a template</div><div class="hint">rules and vibemates, ready to summon</div>`;
|
|
1034
|
+
tpl.addEventListener("click", openTemplateDialog);
|
|
1035
|
+
grid.appendChild(tpl);
|
|
954
1036
|
const rooms = sortedRooms();
|
|
955
1037
|
els.roomsSub.textContent = rooms.length ? `${rooms.length} room${rooms.length === 1 ? "" : "s"}. Pick one, or open a new one.` : "No rooms yet. Open one and summon some vibemates.";
|
|
956
1038
|
for (const room of rooms) {
|
|
@@ -974,22 +1056,26 @@
|
|
|
974
1056
|
}
|
|
975
1057
|
|
|
976
1058
|
function renderSideRoom() {
|
|
1059
|
+
updateCastGate(currentRoom());
|
|
977
1060
|
const room = currentRoom();
|
|
978
1061
|
if (!room) return;
|
|
979
1062
|
const st = roomStats(room);
|
|
980
1063
|
els.sideRoomName.textContent = room.name;
|
|
981
1064
|
els.sideRoomEmoji.textContent = room.settings.emoji || "";
|
|
982
|
-
els.sideRoomSub.textContent = room.settings.topic || `${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}`;
|
|
1065
|
+
els.sideRoomSub.textContent = room.settings.topic || `${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}${st.waiting ? ` · ${st.waiting} waiting` : ""}`;
|
|
983
1066
|
const ordered = [...room.participants].sort((a, b) => (a.kind === "human" ? -1 : b.kind === "human" ? 1 : 0));
|
|
984
1067
|
const rows = new Map([...els.participants.children].map((li) => [li.dataset.id, li]));
|
|
985
1068
|
for (const p of ordered) {
|
|
986
1069
|
let li = rows.get(p.id);
|
|
987
1070
|
const selected = (state.selection.kind === "participant" && state.selection.id === p.id) || (p.kind === "human" && state.selection.kind === "me" && state.detailsOpen);
|
|
988
1071
|
const asleep = p.kind === "agent" && (p.status === "offline" || p.status === "left");
|
|
989
|
-
const
|
|
1072
|
+
const unstaffed = p.kind === "agent" && p.status === "unstaffed";
|
|
1073
|
+
const className = (p.kind === "human" ? "me" : "") + (selected ? " selected" : "") + (asleep ? " offline" : "") + (unstaffed ? " unstaffed" : "");
|
|
990
1074
|
const sub = p.kind === "human" ? "you, the human" : [p.tagline ? `"${p.tagline}"` : "", p.agentVendor || p.agentLabel, p.model].filter(Boolean).join(" · ");
|
|
991
1075
|
const warn = p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<div class="p-warn" title="${esc(p.statusDetail)}">${esc(p.statusDetail)}</div>` : "";
|
|
992
|
-
const status =
|
|
1076
|
+
const status = unstaffed
|
|
1077
|
+
? `<span class="badge status-unstaffed" title="Click to summon this vibemate: pick the coding agent that runs it">summon</span>`
|
|
1078
|
+
: asleep
|
|
993
1079
|
? `<span class="zzz" title="${esc(STATUS_LABEL[p.status] || p.status)}">zzz</span>`
|
|
994
1080
|
: p.kind === "agent" && p.status !== "idle" ? `<span class="badge status-${p.status}">${p.status === "thinking" ? '<span class="dot"></span>' : ""}${STATUS_LABEL[p.status] || p.status}</span>` : "";
|
|
995
1081
|
const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true });
|
|
@@ -1065,6 +1151,7 @@
|
|
|
1065
1151
|
|
|
1066
1152
|
function renderChatHead() {
|
|
1067
1153
|
const room = currentRoom();
|
|
1154
|
+
renderWorkingNow();
|
|
1068
1155
|
if (!room) {
|
|
1069
1156
|
els.chatRoomName.textContent = "No room";
|
|
1070
1157
|
els.chatRoomSub.textContent = "";
|
|
@@ -1073,7 +1160,7 @@
|
|
|
1073
1160
|
const st = roomStats(room);
|
|
1074
1161
|
els.chatRoomName.textContent = roomTitle(room);
|
|
1075
1162
|
els.chatRoomSub.innerHTML =
|
|
1076
|
-
`<span>${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}</span>` +
|
|
1163
|
+
`<span>${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}${st.waiting ? ` · ${st.waiting} waiting` : ""}</span>` +
|
|
1077
1164
|
(room.settings.topic ? `<span>· ${esc(room.settings.topic)}</span>` : "") +
|
|
1078
1165
|
`<span class="chip dir-chip" title="working directory of the vibemates: ${esc(room.dir)}">${ic("folder")}${esc(room.dir.split(/[\\/]/).filter(Boolean).slice(-1)[0] || room.dir)}</span>`;
|
|
1079
1166
|
}
|
|
@@ -1115,6 +1202,7 @@
|
|
|
1115
1202
|
const el = document.createElement("div");
|
|
1116
1203
|
el.dataset.id = m.id;
|
|
1117
1204
|
el.dataset.seq = m.seq;
|
|
1205
|
+
el.dataset.from = m.from;
|
|
1118
1206
|
if (m.kind === "hidden") {
|
|
1119
1207
|
el.className = "msg hidden";
|
|
1120
1208
|
renderHidden(el, m);
|
|
@@ -1122,6 +1210,12 @@
|
|
|
1122
1210
|
}
|
|
1123
1211
|
if (m.kind === "system") {
|
|
1124
1212
|
el.className = "msg system";
|
|
1213
|
+
if (m.audience === "human") {
|
|
1214
|
+
el.className = "msg system done";
|
|
1215
|
+
const who = m.details && m.details.agentId ? findById(room, m.details.agentId) : null;
|
|
1216
|
+
el.innerHTML = `<button type="button" class="done-row" data-ref="${esc((m.details && m.details.refId) || "")}" title="${esc(fullTime(m.ts))} · go to the reply">${who ? avatar(who, 20, {}) : ""}<span>${esc(m.text)}</span></button>`;
|
|
1217
|
+
return el;
|
|
1218
|
+
}
|
|
1125
1219
|
if (m.audience === "agents") {
|
|
1126
1220
|
el.className = "msg hidden";
|
|
1127
1221
|
el.innerHTML = `<details class="hidden-turn"><summary>${ic("info")} hub → vibemates · ${esc(m.text.split(":")[0])}</summary><div class="hidden-body"><div class="hidden-label">What the vibemates were told</div><div class="hidden-text">${esc(m.text)}</div></div></details>`;
|
|
@@ -1138,9 +1232,8 @@
|
|
|
1138
1232
|
const mine = m.from === "human";
|
|
1139
1233
|
el.className = "msg " + (mine ? "mine" : "agent");
|
|
1140
1234
|
el.innerHTML = `
|
|
1141
|
-
${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 36, { vendor: true })}
|
|
1142
1235
|
<div class="bubble-col">
|
|
1143
|
-
<div class="head"><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? `<button type="button" class="edit-btn" title="Edit this message">${ic("pencil")}</button>` : ""}<span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
|
|
1236
|
+
<div class="head"><span class="head-av">${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 32, { vendor: true })}</span><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? `<button type="button" class="edit-btn" title="Edit this message">${ic("pencil")}</button>` : ""}<button type="button" class="pin-btn" title="Pin this message">${ic("pin")}</button><span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
|
|
1144
1237
|
<div class="bubble">
|
|
1145
1238
|
${m.skill ? `<div class="skill-invoke" title="skill invocation: the vibemates that have this skill got its instructions with this message">${ic("skills")} skill <b>${esc(m.skill.name)}</b></div>` : ""}
|
|
1146
1239
|
<div class="edit-box" hidden></div>
|
|
@@ -1149,8 +1242,10 @@
|
|
|
1149
1242
|
<div class="tools"></div>
|
|
1150
1243
|
<div class="plan" hidden></div>
|
|
1151
1244
|
<div class="text"></div>
|
|
1245
|
+
<div class="shots"></div>
|
|
1152
1246
|
<button class="more" hidden></button>
|
|
1153
1247
|
<div class="perms"></div>
|
|
1248
|
+
<div class="waiting" hidden></div>
|
|
1154
1249
|
</div>
|
|
1155
1250
|
<div class="meta"></div>
|
|
1156
1251
|
</div>`;
|
|
@@ -1161,12 +1256,108 @@
|
|
|
1161
1256
|
});
|
|
1162
1257
|
const editBtn = el.querySelector(".edit-btn");
|
|
1163
1258
|
if (editBtn) editBtn.addEventListener("click", () => openInlineEditor(el, room, m));
|
|
1259
|
+
el.querySelector(".pin-btn").addEventListener("click", async () => {
|
|
1260
|
+
if (m.pending) return;
|
|
1261
|
+
try {
|
|
1262
|
+
await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(m.id)}/pin`, { pinned: !m.pinned });
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
showError(error);
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1164
1267
|
updateMessageElement(el, room, m);
|
|
1165
1268
|
return el;
|
|
1166
1269
|
}
|
|
1167
1270
|
|
|
1271
|
+
function shotUrl(roomId, image) {
|
|
1272
|
+
if (image.url) return image.url;
|
|
1273
|
+
return `/api/rooms/${encodeURIComponent(roomId)}/files/${encodeURIComponent(image.file)}`;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function renderShots(box, room, m) {
|
|
1277
|
+
if (!box) return;
|
|
1278
|
+
const images = m.images || [];
|
|
1279
|
+
box.hidden = !images.length;
|
|
1280
|
+
if (!images.length) return void (box.innerHTML = "");
|
|
1281
|
+
box.innerHTML = images
|
|
1282
|
+
.map((image, i) => `<button type="button" class="shot" data-n="${image.n || i + 1}" data-src="${esc(shotUrl(room.id, image))}" title="${esc(image.name)}"><img src="${esc(shotUrl(room.id, image))}" alt="${esc(image.name)}"><span class="shot-n">${image.n || i + 1}</span></button>`)
|
|
1283
|
+
.join("");
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function openLightbox(src, title) {
|
|
1287
|
+
els.lightbox.querySelector("img").src = src;
|
|
1288
|
+
els.lightbox.querySelector("img").alt = title || "";
|
|
1289
|
+
els.lightbox.hidden = false;
|
|
1290
|
+
}
|
|
1291
|
+
function closeLightbox() {
|
|
1292
|
+
els.lightbox.hidden = true;
|
|
1293
|
+
els.lightbox.querySelector("img").src = "";
|
|
1294
|
+
}
|
|
1295
|
+
els.lightbox.addEventListener("click", closeLightbox);
|
|
1296
|
+
document.addEventListener("keydown", (e) => {
|
|
1297
|
+
if (e.key === "Escape" && !els.lightbox.hidden) closeLightbox();
|
|
1298
|
+
});
|
|
1299
|
+
els.messages.addEventListener("click", (e) => {
|
|
1300
|
+
const done = e.target.closest(".done-row");
|
|
1301
|
+
if (done) return void jumpToMessage(els.messages.querySelector(`.msg[data-id="${done.dataset.ref}"]`));
|
|
1302
|
+
const shot = e.target.closest(".shot");
|
|
1303
|
+
if (shot) return void openLightbox(shot.dataset.src, shot.title);
|
|
1304
|
+
const ref = e.target.closest(".img-ref");
|
|
1305
|
+
if (!ref) return;
|
|
1306
|
+
const msg = ref.closest(".msg");
|
|
1307
|
+
const target = msg && msg.querySelector(`.shot[data-n="${ref.dataset.n}"]`);
|
|
1308
|
+
if (target) openLightbox(target.dataset.src, target.title);
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
function waitingFor(room, m) {
|
|
1312
|
+
if (m.from !== "human" || m.kind !== "chat" || !m.to || !m.to.length || m.pending) return [];
|
|
1313
|
+
return m.to.map((id) => findById(room, id)).filter((p) => p && p.kind === "agent" && p.status === "thinking" && p.lastSeenSeq != null && p.lastSeenSeq < m.seq);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function renderWaiting(el, room, m) {
|
|
1317
|
+
const box = el.querySelector(".waiting");
|
|
1318
|
+
if (!box) return;
|
|
1319
|
+
const agents = waitingFor(room, m);
|
|
1320
|
+
el.classList.toggle("waiting", agents.length > 0);
|
|
1321
|
+
box.hidden = !agents.length;
|
|
1322
|
+
if (!agents.length) return void (box.innerHTML = "");
|
|
1323
|
+
const names = agents.map((p) => p.name);
|
|
1324
|
+
const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
1325
|
+
const verb = names.length === 1 ? "is" : "are";
|
|
1326
|
+
const stopLabel = names.length === 1 ? `Stop ${names[0]} and send now` : "Stop them and send now";
|
|
1327
|
+
box.innerHTML = `<span class="waiting-text">${ic("clock")} ${esc(who)} ${verb} still working — this arrives when the current turn ends.</span><button type="button" class="waiting-stop">${esc(stopLabel)}</button>`;
|
|
1328
|
+
box.querySelector(".waiting-stop").addEventListener("click", async (e) => {
|
|
1329
|
+
const btn = e.currentTarget;
|
|
1330
|
+
btn.disabled = true;
|
|
1331
|
+
btn.textContent = "Stopping…";
|
|
1332
|
+
try {
|
|
1333
|
+
await Promise.all(agents.map((p) => post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`))));
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
showError(error);
|
|
1336
|
+
btn.disabled = false;
|
|
1337
|
+
btn.textContent = stopLabel;
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const openTools = new Set();
|
|
1343
|
+
els.messages.addEventListener("click", (e) => {
|
|
1344
|
+
const chip = e.target.closest(".tool > .chip");
|
|
1345
|
+
if (!chip) return;
|
|
1346
|
+
const msgEl = chip.closest(".msg");
|
|
1347
|
+
const room = currentRoom();
|
|
1348
|
+
const m = room && msgEl && room.messages.find((x) => x.id === msgEl.dataset.id);
|
|
1349
|
+
if (!m) return;
|
|
1350
|
+
if (openTools.has(chip.dataset.tool)) openTools.delete(chip.dataset.tool);
|
|
1351
|
+
else openTools.add(chip.dataset.tool);
|
|
1352
|
+
updateMessageElement(msgEl, room, m);
|
|
1353
|
+
});
|
|
1168
1354
|
function updateMessageElement(el, room, m) {
|
|
1169
1355
|
el.classList.toggle("hidden-by-search", !messageMatches(m));
|
|
1356
|
+
const wasPinned = el.classList.contains("pinned");
|
|
1357
|
+
el.classList.toggle("pinned", !!m.pinned);
|
|
1358
|
+
const pinBtn = el.querySelector(".pin-btn");
|
|
1359
|
+
if (pinBtn) pinBtn.title = m.pinned ? "Unpin this message" : "Pin this message";
|
|
1360
|
+
if (wasPinned !== !!m.pinned) renderTimeline();
|
|
1170
1361
|
const editedEl = el.querySelector(".edited");
|
|
1171
1362
|
if (editedEl) {
|
|
1172
1363
|
editedEl.hidden = !m.edited;
|
|
@@ -1186,12 +1377,14 @@
|
|
|
1186
1377
|
const more = el.querySelector(".more");
|
|
1187
1378
|
const long = !m.streaming && m.text.length > CLAMP_CHARS;
|
|
1188
1379
|
const expanded = state.expanded.has(m.id);
|
|
1189
|
-
text.innerHTML = renderText(room, m.text) + (m.streaming ? '<span class="caret"></span>' : "");
|
|
1380
|
+
text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? '<span class="caret"></span>' : "");
|
|
1190
1381
|
if (!m.streaming) renderDiagrams(text);
|
|
1191
1382
|
if (m.streaming && !m.text) text.innerHTML = '<span class="pending">…</span>';
|
|
1192
1383
|
text.classList.toggle("clamped", long && !expanded);
|
|
1193
1384
|
more.hidden = !long;
|
|
1194
1385
|
more.textContent = expanded ? "Show less" : "Show more";
|
|
1386
|
+
renderShots(el.querySelector(".shots"), room, m);
|
|
1387
|
+
renderWaiting(el, room, m);
|
|
1195
1388
|
const thought = el.querySelector(".thought");
|
|
1196
1389
|
if (m.thought) {
|
|
1197
1390
|
thought.hidden = false;
|
|
@@ -1201,11 +1394,16 @@
|
|
|
1201
1394
|
const tools = el.querySelector(".tools");
|
|
1202
1395
|
tools.innerHTML = "";
|
|
1203
1396
|
for (const call of m.toolCalls || []) {
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1397
|
+
const open = openTools.has(call.toolCallId);
|
|
1398
|
+
const box = document.createElement("div");
|
|
1399
|
+
box.className = `tool${open ? " open" : ""}`;
|
|
1400
|
+
const input = call.rawInput === undefined ? "" : typeof call.rawInput === "string" ? call.rawInput : JSON.stringify(call.rawInput, null, 1);
|
|
1401
|
+
box.innerHTML =
|
|
1402
|
+
`<button type="button" class="chip chip-${esc(call.status || "pending")}" data-tool="${esc(call.toolCallId)}" title="${open ? "Collapse" : "Expand"}">${ic("tool")}${esc(`${call.title}${call.kind ? ` · ${call.kind}` : ""} · ${call.status || "pending"}`)}</button>` +
|
|
1403
|
+
(open
|
|
1404
|
+
? `<div class="tool-body"><div class="tool-sec"><b>call</b><pre>${esc(call.title)}</pre></div>${input ? `<div class="tool-sec"><b>input</b><pre>${esc(input.slice(0, 4000))}</pre></div>` : ""}${call.output ? `<div class="tool-sec"><b>output</b><pre>${esc(call.output)}</pre></div>` : `<div class="tool-sec muted">no output recorded</div>`}</div>`
|
|
1405
|
+
: "");
|
|
1406
|
+
tools.appendChild(box);
|
|
1209
1407
|
}
|
|
1210
1408
|
const plan = el.querySelector(".plan");
|
|
1211
1409
|
if (m.plan && m.plan.length) {
|
|
@@ -1231,18 +1429,23 @@
|
|
|
1231
1429
|
return null;
|
|
1232
1430
|
}
|
|
1233
1431
|
function seenHtml(room, m) {
|
|
1234
|
-
const
|
|
1432
|
+
const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline");
|
|
1433
|
+
const seen = present.filter((p) => p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
|
|
1235
1434
|
if (!seen.length) return `<span class="ticks">✓</span> sent`;
|
|
1435
|
+
if (present.length > 1 && seen.length === present.length) return `<span class="ticks">✓✓</span> seen by all`;
|
|
1236
1436
|
return `<span class="ticks">✓✓</span> seen by ${esc(seen.map((p) => p.name).join(", "))}`;
|
|
1237
1437
|
}
|
|
1238
1438
|
function refreshSeen(room) {
|
|
1239
1439
|
const last = lastHumanMessage(room);
|
|
1440
|
+
const byId = new Map(room.messages.map((m) => [m.id, m]));
|
|
1240
1441
|
for (const el of els.messages.querySelectorAll(".msg.mine")) {
|
|
1241
1442
|
const meta = el.querySelector(".meta");
|
|
1242
1443
|
if (!meta) continue;
|
|
1243
1444
|
const isLast = last && el.dataset.id === last.id;
|
|
1244
1445
|
meta.innerHTML = isLast ? seenHtml(room, last) : "";
|
|
1245
1446
|
meta.classList.toggle("seen", !!isLast);
|
|
1447
|
+
const m = byId.get(el.dataset.id);
|
|
1448
|
+
if (m && (isLast || el.classList.contains("waiting"))) renderWaiting(el, room, m);
|
|
1246
1449
|
}
|
|
1247
1450
|
}
|
|
1248
1451
|
|
|
@@ -1310,11 +1513,14 @@
|
|
|
1310
1513
|
const room = state.rooms.get(roomId);
|
|
1311
1514
|
if (!room) return;
|
|
1312
1515
|
const idx = room.messages.findIndex((x) => x.id === m.id);
|
|
1313
|
-
|
|
1314
|
-
|
|
1516
|
+
const wasFinal = idx >= 0 && !room.messages[idx].streaming;
|
|
1517
|
+
if (idx >= 0) {
|
|
1518
|
+
if (!("pinned" in m)) delete room.messages[idx].pinned;
|
|
1519
|
+
Object.assign(room.messages[idx], m);
|
|
1520
|
+
} else room.messages.push(m);
|
|
1315
1521
|
const showing = roomId === state.currentRoomId && state.view === "room";
|
|
1316
1522
|
if (!showing) {
|
|
1317
|
-
if (
|
|
1523
|
+
if (!wasFinal && m.kind === "chat" && !m.streaming && m.from !== "human" && roomId !== state.currentRoomId) state.unread.set(roomId, (state.unread.get(roomId) || 0) + 1);
|
|
1318
1524
|
if ((state.view === "rooms" || state.view === "home")) {
|
|
1319
1525
|
renderSideRooms();
|
|
1320
1526
|
renderRoomsGrid();
|
|
@@ -1322,7 +1528,7 @@
|
|
|
1322
1528
|
renderRail();
|
|
1323
1529
|
return;
|
|
1324
1530
|
}
|
|
1325
|
-
const stick =
|
|
1531
|
+
const stick = stuck;
|
|
1326
1532
|
const existing = els.messages.querySelector(`.msg[data-id="${m.id}"]`);
|
|
1327
1533
|
if (existing) updateMessageElement(existing, room, room.messages[idx]);
|
|
1328
1534
|
else {
|
|
@@ -1330,8 +1536,11 @@
|
|
|
1330
1536
|
if (empty) empty.remove();
|
|
1331
1537
|
els.messages.appendChild(messageElement(room, m));
|
|
1332
1538
|
if (m.from === "human") refreshSeen(room);
|
|
1539
|
+
else if (!stick && m.kind === "chat") noteNew(room, m);
|
|
1333
1540
|
}
|
|
1334
1541
|
if (stick) scrollToBottom();
|
|
1542
|
+
if (m.streaming) updateWorkingNow();
|
|
1543
|
+
if (!wasFinal && !m.streaming && m.kind === "chat" && m.from !== "human") noteFinished(room, m);
|
|
1335
1544
|
if (m.from === "human") renderTimeline();
|
|
1336
1545
|
}
|
|
1337
1546
|
|
|
@@ -1344,6 +1553,8 @@
|
|
|
1344
1553
|
if (el) el.remove();
|
|
1345
1554
|
}
|
|
1346
1555
|
|
|
1556
|
+
const dirty = new Map();
|
|
1557
|
+
let flushScheduled = false;
|
|
1347
1558
|
function patchMessage(roomId, id, fn) {
|
|
1348
1559
|
const room = state.rooms.get(roomId);
|
|
1349
1560
|
if (!room) return;
|
|
@@ -1351,10 +1562,26 @@
|
|
|
1351
1562
|
if (!m) return;
|
|
1352
1563
|
fn(m);
|
|
1353
1564
|
if (roomId !== state.currentRoomId || state.view !== "room") return;
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1565
|
+
dirty.set(id, room);
|
|
1566
|
+
if (flushScheduled) return;
|
|
1567
|
+
flushScheduled = true;
|
|
1568
|
+
requestAnimationFrame(flushPatches);
|
|
1569
|
+
}
|
|
1570
|
+
function flushPatches() {
|
|
1571
|
+
flushScheduled = false;
|
|
1572
|
+
const batch = [...dirty];
|
|
1573
|
+
dirty.clear();
|
|
1574
|
+
let touched = false;
|
|
1575
|
+
for (const [id, room] of batch) {
|
|
1576
|
+
if (room.id !== state.currentRoomId || state.view !== "room") continue;
|
|
1577
|
+
const m = room.messages.find((x) => x.id === id);
|
|
1578
|
+
const el = m && els.messages.querySelector(`.msg[data-id="${id}"]`);
|
|
1579
|
+
if (!el) continue;
|
|
1580
|
+
updateMessageElement(el, room, m);
|
|
1581
|
+
touched = true;
|
|
1582
|
+
}
|
|
1583
|
+
if (touched && stuck) scrollToBottom();
|
|
1584
|
+
if (touched) updateWorkingNow();
|
|
1358
1585
|
}
|
|
1359
1586
|
|
|
1360
1587
|
|
|
@@ -1582,6 +1809,11 @@
|
|
|
1582
1809
|
${sectionTitle("link", "Session")}
|
|
1583
1810
|
<div id="pp-config"></div>
|
|
1584
1811
|
</div>
|
|
1812
|
+
<div class="section danger">
|
|
1813
|
+
${sectionTitle("bolt", "Respawn")}
|
|
1814
|
+
<p class="hint">${esc(p.name)} comes back with an empty head: it forgets this conversation entirely. The room's history stays and you still see everything.${geekTip("A session's context cannot be erased, so the vibemate's process and session are closed and it starts a new one with no replay. Its stored session is dropped too, or a later reconnect would bring the old context back. Same thing as typing /respawn @Name in the composer.")}</p>
|
|
1815
|
+
<div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button></div>
|
|
1816
|
+
</div>
|
|
1585
1817
|
<div class="section">
|
|
1586
1818
|
${sectionTitle("info", "Stats")}
|
|
1587
1819
|
<div class="kv">
|
|
@@ -1599,6 +1831,18 @@
|
|
|
1599
1831
|
"skills, timing, session, stats",
|
|
1600
1832
|
)}`;
|
|
1601
1833
|
wireDetailsClose();
|
|
1834
|
+
const respawnBtn = els.detailsInner.querySelector('button[data-act="respawn"]');
|
|
1835
|
+
if (respawnBtn) {
|
|
1836
|
+
respawnBtn.addEventListener("click", async () => {
|
|
1837
|
+
const ok = await confirmDialog(`${p.name} forgets this whole conversation and starts over. You keep the history; it does not.`, { title: `Respawn ${p.name}?`, okLabel: "Respawn", danger: true });
|
|
1838
|
+
if (!ok) return;
|
|
1839
|
+
try {
|
|
1840
|
+
await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`));
|
|
1841
|
+
} catch (e) {
|
|
1842
|
+
showError(e);
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1602
1846
|
els.detailsInner.querySelectorAll(".action").forEach((btn) => {
|
|
1603
1847
|
btn.addEventListener("click", async () => {
|
|
1604
1848
|
const act = btn.dataset.act;
|
|
@@ -1772,7 +2016,7 @@
|
|
|
1772
2016
|
</div>
|
|
1773
2017
|
<div class="section danger" style="margin-top:12px">
|
|
1774
2018
|
${sectionTitle("alert", "Danger zone")}
|
|
1775
|
-
<p class="field-note">Closes every vibemate in this room and removes it from the list.
|
|
2019
|
+
<p class="field-note">Closes every vibemate in this room and removes it from the list. Its history and files move to the trash folder of your viberoom data; a new room with the same name starts empty.</p>
|
|
1776
2020
|
<div class="row-btns start"><button class="btn danger sm" id="rp-delete">${ic("trash")}Close this room for good</button></div>
|
|
1777
2021
|
</div>`;
|
|
1778
2022
|
wireDetailsClose();
|
|
@@ -2379,9 +2623,30 @@
|
|
|
2379
2623
|
els.invAvatarPicker.innerHTML = "";
|
|
2380
2624
|
els.invAvatarPicker.appendChild(window.Avatars.pickerElement("", (emoji) => (els.invAvatar.value = emoji)));
|
|
2381
2625
|
els.invGeek.open = false;
|
|
2626
|
+
setStaffing(null);
|
|
2382
2627
|
openDialog(els.dialog);
|
|
2383
2628
|
els.invName.focus();
|
|
2384
2629
|
}
|
|
2630
|
+
const staffing = { id: null };
|
|
2631
|
+
const PERSONA_FIELDS = () => [els.invName, els.invTagline, els.invRole, els.invAvatar];
|
|
2632
|
+
function setStaffing(p) {
|
|
2633
|
+
staffing.id = p ? p.id : null;
|
|
2634
|
+
for (const f of PERSONA_FIELDS()) f.disabled = false;
|
|
2635
|
+
els.invSkills.classList.remove("locked");
|
|
2636
|
+
const lead = els.dialog.querySelector(".lead");
|
|
2637
|
+
lead.textContent = p
|
|
2638
|
+
? `${p.name} comes from the room's template. Pick the coding agent that runs it; change the vibename or the character if you like.`
|
|
2639
|
+
: "Pick a vibemate, give it a vibename and a character, and it joins the room.";
|
|
2640
|
+
}
|
|
2641
|
+
function openStaffDialog(p) {
|
|
2642
|
+
openInvite();
|
|
2643
|
+
els.invName.value = p.name;
|
|
2644
|
+
els.invTagline.value = p.tagline || "";
|
|
2645
|
+
els.invRole.value = p.role || "";
|
|
2646
|
+
els.invAvatar.value = p.avatar || "";
|
|
2647
|
+
renderSkillChecks(els.invSkills, p.skills || []);
|
|
2648
|
+
setStaffing(p);
|
|
2649
|
+
}
|
|
2385
2650
|
async function submitInvite(event) {
|
|
2386
2651
|
event.preventDefault();
|
|
2387
2652
|
if (!els.invType.value) {
|
|
@@ -2393,6 +2658,21 @@
|
|
|
2393
2658
|
els.invSubmit.classList.add("loading");
|
|
2394
2659
|
els.invError.hidden = true;
|
|
2395
2660
|
try {
|
|
2661
|
+
if (staffing.id) {
|
|
2662
|
+
await post(roomApi(`/participants/${encodeURIComponent(staffing.id)}/staff`), {
|
|
2663
|
+
agentType: els.invType.value,
|
|
2664
|
+
model: els.invModelCustom.value.trim() || els.invModel.value || null,
|
|
2665
|
+
effort: els.invEffort.value || null,
|
|
2666
|
+
mode: els.invMode.value || null,
|
|
2667
|
+
name: els.invName.value.trim(),
|
|
2668
|
+
tagline: els.invTagline.value.trim(),
|
|
2669
|
+
role: els.invRole.value.trim(),
|
|
2670
|
+
avatar: els.invAvatar.value.trim(),
|
|
2671
|
+
skills: checkedSkills(els.invSkills),
|
|
2672
|
+
});
|
|
2673
|
+
closeDialog(els.dialog);
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2396
2676
|
await post(roomApi("/invite"), {
|
|
2397
2677
|
agentType: els.invType.value,
|
|
2398
2678
|
name: els.invName.value.trim(),
|
|
@@ -2471,6 +2751,88 @@
|
|
|
2471
2751
|
}
|
|
2472
2752
|
|
|
2473
2753
|
|
|
2754
|
+
const tplEls = { dialog: $("#template-dialog"), form: $("#template-form"), list: $("#tpl-list"), detail: $("#tpl-detail"), name: $("#tpl-room-name"), dir: $("#tpl-room-dir"), error: $("#tpl-error"), create: $("#tpl-create") };
|
|
2755
|
+
const tpl = { items: [], current: null, autoName: "" };
|
|
2756
|
+
async function openTemplateDialog() {
|
|
2757
|
+
tplEls.error.hidden = true;
|
|
2758
|
+
tplEls.name.value = "";
|
|
2759
|
+
tplEls.dir.value = "";
|
|
2760
|
+
tplEls.list.innerHTML = '<span class="hint">loading…</span>';
|
|
2761
|
+
tplEls.detail.innerHTML = "";
|
|
2762
|
+
openDialog(tplEls.dialog);
|
|
2763
|
+
try {
|
|
2764
|
+
tpl.items = (await (await fetch("/api/templates")).json()).templates || [];
|
|
2765
|
+
} catch (error) {
|
|
2766
|
+
tplEls.error.textContent = error.message;
|
|
2767
|
+
tplEls.error.hidden = false;
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
renderTemplateList(tpl.items[0] ? tpl.items[0].id : null);
|
|
2771
|
+
}
|
|
2772
|
+
function renderTemplateList(currentId) {
|
|
2773
|
+
tpl.current = tpl.items.find((t) => t.id === currentId) || null;
|
|
2774
|
+
tplEls.list.innerHTML = tpl.items
|
|
2775
|
+
.map((t) => {
|
|
2776
|
+
const on = tpl.current && t.id === tpl.current.id;
|
|
2777
|
+
const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: "#9ca3af" }, 20, {})).join("");
|
|
2778
|
+
return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
|
|
2779
|
+
<span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
|
|
2780
|
+
<span class="tpl-body"><b>${esc(t.name)}${t.recommended ? '<span class="badge tpl-rec">recommended</span>' : ""}</b><span class="tpl-meta">${t.vibemates.length} vibemate${t.vibemates.length === 1 ? "" : "s"}${t.builtin ? " · built in" : " · yours"}</span><span class="avatar-stack">${faces}</span></span>
|
|
2781
|
+
<span class="tpl-check">${ic("check")}</span>
|
|
2782
|
+
</button>`;
|
|
2783
|
+
})
|
|
2784
|
+
.join("") || '<span class="hint">No templates yet.</span>';
|
|
2785
|
+
renderTemplateDetail();
|
|
2786
|
+
}
|
|
2787
|
+
tplEls.list.addEventListener("click", (e) => {
|
|
2788
|
+
const b = e.target.closest(".tpl-item");
|
|
2789
|
+
if (b) renderTemplateList(b.dataset.id);
|
|
2790
|
+
});
|
|
2791
|
+
function renderTemplateDetail() {
|
|
2792
|
+
const t = tpl.current;
|
|
2793
|
+
if (!t) return void (tplEls.detail.innerHTML = "");
|
|
2794
|
+
const rules = String((t.settings || {}).customRules || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
2795
|
+
if (!tplEls.name.value || tplEls.name.value === tpl.autoName) tplEls.name.value = t.name;
|
|
2796
|
+
tpl.autoName = t.name;
|
|
2797
|
+
tplEls.detail.innerHTML = `
|
|
2798
|
+
<p class="tpl-desc">${esc(t.description)}</p>
|
|
2799
|
+
${rules.length ? `<div class="tpl-rules"><div class="label">Room rules</div><ul>${rules.map((r) => `<li>${esc(r)}</li>`).join("")}</ul></div>` : ""}
|
|
2800
|
+
<div class="label">Vibemates</div>
|
|
2801
|
+
${t.vibemates
|
|
2802
|
+
.map((v, i) => {
|
|
2803
|
+
return `<div class="tpl-vm" data-i="${i}">
|
|
2804
|
+
<div class="tpl-vm-head"><b>${esc(v.name)}</b>${v.tagline ? `<span class="hint">"${esc(v.tagline)}"</span>` : ""}</div>
|
|
2805
|
+
${v.role ? `<div class="tpl-vm-role">${esc(v.role)}</div>` : ""}
|
|
2806
|
+
</div>`;
|
|
2807
|
+
})
|
|
2808
|
+
.join("")}`;
|
|
2809
|
+
tplEls.detail.insertAdjacentHTML("beforeend", '<p class="hint">You pick the coding agent for each vibemate in the room, right after it opens.</p>');
|
|
2810
|
+
}
|
|
2811
|
+
$("#tpl-dir-browse").addEventListener("click", () => openFolderPicker(tplEls.dir.value, (dir) => (tplEls.dir.value = dir)));
|
|
2812
|
+
tplEls.form.addEventListener("submit", async (event) => {
|
|
2813
|
+
event.preventDefault();
|
|
2814
|
+
const t = tpl.current;
|
|
2815
|
+
if (!t) return;
|
|
2816
|
+
const vibemates = t.vibemates.map((v) => ({ name: v.name, agentType: "" }));
|
|
2817
|
+
tplEls.create.disabled = true;
|
|
2818
|
+
tplEls.create.textContent = "Summoning…";
|
|
2819
|
+
try {
|
|
2820
|
+
const res = await post("/api/rooms/from-template", { template: t.id, name: tplEls.name.value, dir: tplEls.dir.value.trim() || null, vibemates });
|
|
2821
|
+
closeDialog(tplEls.dialog);
|
|
2822
|
+
if (res.room && res.room.id) {
|
|
2823
|
+
state.rooms.set(res.room.id, res.room);
|
|
2824
|
+
selectRoom(res.room.id);
|
|
2825
|
+
}
|
|
2826
|
+
for (const n of res.notices || []) toast(n, "info");
|
|
2827
|
+
} catch (error) {
|
|
2828
|
+
tplEls.error.textContent = error.message;
|
|
2829
|
+
tplEls.error.hidden = false;
|
|
2830
|
+
} finally {
|
|
2831
|
+
tplEls.create.disabled = false;
|
|
2832
|
+
tplEls.create.textContent = "Create the room";
|
|
2833
|
+
}
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2474
2836
|
function openRoomDialog() {
|
|
2475
2837
|
els.roomError.hidden = true;
|
|
2476
2838
|
els.roomName.value = "";
|
|
@@ -2573,6 +2935,7 @@
|
|
|
2573
2935
|
state.recipes = snapshot.recipes || [];
|
|
2574
2936
|
state.roomDefaults = snapshot.roomDefaults || null;
|
|
2575
2937
|
state.rooms = new Map((snapshot.rooms || []).map((r) => [r.id, r]));
|
|
2938
|
+
state.openRooms = [...(snapshot.openRooms || [])];
|
|
2576
2939
|
const params = new URLSearchParams(location.search);
|
|
2577
2940
|
const wanted = params.get("room");
|
|
2578
2941
|
const remembered = recall("room");
|
|
@@ -2717,9 +3080,14 @@
|
|
|
2717
3080
|
}
|
|
2718
3081
|
renderRail();
|
|
2719
3082
|
});
|
|
3083
|
+
es.addEventListener("rooms.opened", (e) => {
|
|
3084
|
+
state.openRooms = JSON.parse(e.data).roomIds || [];
|
|
3085
|
+
renderRail();
|
|
3086
|
+
});
|
|
2720
3087
|
es.addEventListener("room.removed", (e) => {
|
|
2721
3088
|
const { roomId } = JSON.parse(e.data);
|
|
2722
3089
|
state.rooms.delete(roomId);
|
|
3090
|
+
state.openRooms = state.openRooms.filter((id) => id !== roomId);
|
|
2723
3091
|
if (state.currentRoomId === roomId) {
|
|
2724
3092
|
state.currentRoomId = null;
|
|
2725
3093
|
state.selection = { kind: "room" };
|
|
@@ -2763,9 +3131,168 @@
|
|
|
2763
3131
|
});
|
|
2764
3132
|
|
|
2765
3133
|
|
|
3134
|
+
let composerMin = Number(recall("composerH")) || 0;
|
|
3135
|
+
const composerCeiling = () => Math.max(120, els.app.clientHeight - 260);
|
|
3136
|
+
let autosizeQueued = false;
|
|
3137
|
+
function autosizeSoon() {
|
|
3138
|
+
if (autosizeQueued) return;
|
|
3139
|
+
autosizeQueued = true;
|
|
3140
|
+
requestAnimationFrame(() => {
|
|
3141
|
+
autosizeQueued = false;
|
|
3142
|
+
autosize();
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
2766
3145
|
function autosize() {
|
|
3146
|
+
const min = Math.max(36, composerMin);
|
|
3147
|
+
const cap = Math.max(180, min);
|
|
2767
3148
|
els.input.style.height = "auto";
|
|
2768
|
-
els.input.style.height = Math.min(
|
|
3149
|
+
els.input.style.height = Math.min(composerCeiling(), Math.max(min, Math.min(cap, els.input.scrollHeight))) + "px";
|
|
3150
|
+
}
|
|
3151
|
+
{
|
|
3152
|
+
const grip = $("#composer-grip");
|
|
3153
|
+
let drag = null;
|
|
3154
|
+
grip.addEventListener("pointerdown", (e) => {
|
|
3155
|
+
if (e.button !== 0) return;
|
|
3156
|
+
drag = { y: e.clientY, h: els.input.offsetHeight };
|
|
3157
|
+
grip.setPointerCapture(e.pointerId);
|
|
3158
|
+
els.composer.classList.add("resizing");
|
|
3159
|
+
e.preventDefault();
|
|
3160
|
+
});
|
|
3161
|
+
grip.addEventListener("pointermove", (e) => {
|
|
3162
|
+
if (!drag) return;
|
|
3163
|
+
composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY)));
|
|
3164
|
+
autosize();
|
|
3165
|
+
});
|
|
3166
|
+
const stop = () => {
|
|
3167
|
+
if (!drag) return;
|
|
3168
|
+
drag = null;
|
|
3169
|
+
els.composer.classList.remove("resizing");
|
|
3170
|
+
remember("composerH", composerMin);
|
|
3171
|
+
};
|
|
3172
|
+
grip.addEventListener("pointerup", stop);
|
|
3173
|
+
grip.addEventListener("pointercancel", stop);
|
|
3174
|
+
grip.addEventListener("dblclick", () => {
|
|
3175
|
+
composerMin = 0;
|
|
3176
|
+
remember("composerH", 0);
|
|
3177
|
+
autosize();
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
const SHOT_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
3182
|
+
const SHOTS_MAX = 6;
|
|
3183
|
+
let pendingShots = [];
|
|
3184
|
+
let shotSeq = 0;
|
|
3185
|
+
const shotMarker = (n) => `[img ${n}]`;
|
|
3186
|
+
|
|
3187
|
+
const composerClear = $("#composer-clear");
|
|
3188
|
+
function updateComposerClear() {
|
|
3189
|
+
composerClear.hidden = !(els.input.value.trim() || pendingShots.length);
|
|
3190
|
+
}
|
|
3191
|
+
composerClear.addEventListener("click", () => {
|
|
3192
|
+
els.input.value = "";
|
|
3193
|
+
clearShots();
|
|
3194
|
+
autosize();
|
|
3195
|
+
updateComposerClear();
|
|
3196
|
+
els.input.focus();
|
|
3197
|
+
});
|
|
3198
|
+
els.input.addEventListener("input", updateComposerClear);
|
|
3199
|
+
function renderShotsTray() {
|
|
3200
|
+
updateComposerClear();
|
|
3201
|
+
els.shotsTray.hidden = !pendingShots.length;
|
|
3202
|
+
els.shotsTray.innerHTML = pendingShots
|
|
3203
|
+
.map((shot, i) => `<span class="shot-chip"><img src="${esc(shot.data)}" alt=""><span class="shot-n">${shot.n}</span><button type="button" class="shot-drop" data-i="${i}" title="Remove ${esc(shot.name)}">×</button></span>`)
|
|
3204
|
+
.join("");
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function clearShots() {
|
|
3208
|
+
pendingShots = [];
|
|
3209
|
+
shotSeq = 0;
|
|
3210
|
+
renderShotsTray();
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
function insertShotMarker(n) {
|
|
3214
|
+
const el = els.input;
|
|
3215
|
+
const value = el.value;
|
|
3216
|
+
const start = el.selectionStart ?? value.length;
|
|
3217
|
+
const end = el.selectionEnd ?? start;
|
|
3218
|
+
const before = value.slice(0, start);
|
|
3219
|
+
const after = value.slice(end);
|
|
3220
|
+
const lead = before && !/\s$/.test(before) ? " " : "";
|
|
3221
|
+
const tail = after && !/^\s/.test(after) ? " " : "";
|
|
3222
|
+
const marker = `${lead}${shotMarker(n)}${tail}`;
|
|
3223
|
+
el.value = before + marker + after;
|
|
3224
|
+
const caret = before.length + marker.length;
|
|
3225
|
+
el.setSelectionRange(caret, caret);
|
|
3226
|
+
autosize();
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
function removeShotMarker(n) {
|
|
3230
|
+
const el = els.input;
|
|
3231
|
+
const pattern = new RegExp(` ?\\[img ${n}\\]`, "gi");
|
|
3232
|
+
const caret = el.selectionStart ?? el.value.length;
|
|
3233
|
+
const removedBefore = (el.value.slice(0, caret).match(pattern) || []).join("").length;
|
|
3234
|
+
el.value = el.value.replace(pattern, "");
|
|
3235
|
+
const at = Math.max(0, caret - removedBefore);
|
|
3236
|
+
el.setSelectionRange(at, at);
|
|
3237
|
+
autosize();
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
function addShotFiles(files) {
|
|
3241
|
+
const room = currentRoom();
|
|
3242
|
+
if (!room) return;
|
|
3243
|
+
for (const file of files) {
|
|
3244
|
+
if (!SHOT_TYPES.includes(file.type)) {
|
|
3245
|
+
showError(new Error(`${file.name || "that image"} is a ${file.type || "kind"} the room cannot show (png, jpeg, webp and gif only)`));
|
|
3246
|
+
continue;
|
|
3247
|
+
}
|
|
3248
|
+
if (pendingShots.length >= SHOTS_MAX) return void showError(new Error(`up to ${SHOTS_MAX} images per message`));
|
|
3249
|
+
const reader = new FileReader();
|
|
3250
|
+
const name = file.name || "";
|
|
3251
|
+
reader.onload = () => {
|
|
3252
|
+
if (pendingShots.length >= SHOTS_MAX) return void showError(new Error(`up to ${SHOTS_MAX} images per message`));
|
|
3253
|
+
const n = ++shotSeq;
|
|
3254
|
+
pendingShots.push({ n, name, mimeType: file.type, data: String(reader.result) });
|
|
3255
|
+
renderShotsTray();
|
|
3256
|
+
insertShotMarker(n);
|
|
3257
|
+
};
|
|
3258
|
+
reader.onerror = () => showError(new Error(`could not read ${name || "the image"}`));
|
|
3259
|
+
reader.readAsDataURL(file);
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
function imageFilesFrom(transfer) {
|
|
3264
|
+
if (!transfer) return [];
|
|
3265
|
+
return Array.from(transfer.files || []).filter((f) => f && f.type && f.type.startsWith("image/"));
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
els.shotsTray.addEventListener("click", (e) => {
|
|
3269
|
+
const drop = e.target.closest(".shot-drop");
|
|
3270
|
+
if (!drop) return;
|
|
3271
|
+
const [shot] = pendingShots.splice(Number(drop.dataset.i), 1);
|
|
3272
|
+
if (shot) removeShotMarker(shot.n);
|
|
3273
|
+
renderShotsTray();
|
|
3274
|
+
});
|
|
3275
|
+
els.input.addEventListener("paste", (e) => {
|
|
3276
|
+
const files = imageFilesFrom(e.clipboardData);
|
|
3277
|
+
if (!files.length) return;
|
|
3278
|
+
e.preventDefault();
|
|
3279
|
+
addShotFiles(files);
|
|
3280
|
+
});
|
|
3281
|
+
for (const target of [els.composer, els.messages]) {
|
|
3282
|
+
target.addEventListener("dragover", (e) => {
|
|
3283
|
+
if (!imageFilesFrom(e.dataTransfer).length && !(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files"))) return;
|
|
3284
|
+
e.preventDefault();
|
|
3285
|
+
els.composer.classList.add("drop-target");
|
|
3286
|
+
});
|
|
3287
|
+
target.addEventListener("dragleave", () => els.composer.classList.remove("drop-target"));
|
|
3288
|
+
target.addEventListener("drop", (e) => {
|
|
3289
|
+
const files = imageFilesFrom(e.dataTransfer);
|
|
3290
|
+
els.composer.classList.remove("drop-target");
|
|
3291
|
+
if (!files.length) return;
|
|
3292
|
+
e.preventDefault();
|
|
3293
|
+
addShotFiles(files);
|
|
3294
|
+
els.input.focus();
|
|
3295
|
+
});
|
|
2769
3296
|
}
|
|
2770
3297
|
let typingSentAt = 0;
|
|
2771
3298
|
els.input.addEventListener("input", () => {
|
|
@@ -2785,8 +3312,26 @@
|
|
|
2785
3312
|
if (e.target.closest(".reconnect-btn")) return openReconnectDialog(room);
|
|
2786
3313
|
if (e.target.closest("button")) return;
|
|
2787
3314
|
if (p.kind === "human") openDetails({ kind: "me" });
|
|
3315
|
+
else if (p.status === "unstaffed") openStaffDialog(p);
|
|
2788
3316
|
else openDetails({ kind: "participant", id: p.id });
|
|
2789
3317
|
});
|
|
3318
|
+
const castBanner = $("#cast-banner");
|
|
3319
|
+
castBanner.addEventListener("click", (e) => {
|
|
3320
|
+
const card = e.target.closest(".cast-card");
|
|
3321
|
+
const room = card && currentRoom();
|
|
3322
|
+
const p = room && findById(room, card.dataset.id);
|
|
3323
|
+
if (p && p.status === "unstaffed") openStaffDialog(p);
|
|
3324
|
+
});
|
|
3325
|
+
function updateCastGate(room) {
|
|
3326
|
+
const waiting = room ? room.participants.filter((p) => p.kind === "agent" && p.status === "unstaffed") : [];
|
|
3327
|
+
castBanner.hidden = waiting.length === 0;
|
|
3328
|
+
castBanner.innerHTML = waiting.length
|
|
3329
|
+
? `<div class="cast-lead"><strong>Summon ${waiting.map((p) => esc(p.name)).join(" and ")} to begin.</strong> ${waiting.length === 1 ? "It comes" : "They come"} from the template with the character set; pick the coding agent that runs ${waiting.length === 1 ? "it" : "each"}.</div><div class="cast-list">${waiting.map((p) => `<button type="button" class="cast-card" data-id="${esc(p.id)}">${avatar(p, 44, {})}<b>${esc(p.name)}</b>${p.tagline ? `<span>"${esc(p.tagline)}"</span>` : ""}<em>Summon ${esc(p.name)}</em></button>`).join("")}</div>`
|
|
3330
|
+
: "";
|
|
3331
|
+
els.input.disabled = waiting.length > 0;
|
|
3332
|
+
els.input.placeholder = waiting.length ? `Summon ${waiting.map((p) => p.name).join(" and ")} to start the conversation` : "Message the room… @Name or /skill";
|
|
3333
|
+
els.composer.classList.toggle("gated", waiting.length > 0);
|
|
3334
|
+
}
|
|
2790
3335
|
els.participants.addEventListener("dblclick", (e) => {
|
|
2791
3336
|
const li = e.target.closest("li[data-id]");
|
|
2792
3337
|
const p = li && findById(currentRoom(), li.dataset.id);
|
|
@@ -2796,19 +3341,25 @@
|
|
|
2796
3341
|
event.preventDefault();
|
|
2797
3342
|
const text = els.input.value.trim();
|
|
2798
3343
|
const room = currentRoom();
|
|
2799
|
-
if (!text || !room) return;
|
|
3344
|
+
if ((!text && !pendingShots.length) || !room) return;
|
|
3345
|
+
const shots = pendingShots;
|
|
2800
3346
|
els.input.value = "";
|
|
3347
|
+
clearShots();
|
|
2801
3348
|
typingSentAt = 0;
|
|
2802
3349
|
autosize();
|
|
2803
3350
|
const local = { id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, seq: 0, from: "human", fromName: (state.settings || {}).humanName || "You", to: [], toNames: [], text, ts: Date.now(), kind: "chat", pending: true };
|
|
3351
|
+
if (shots.length) local.images = shots.map((shot) => ({ file: "", name: shot.name, mimeType: shot.mimeType, bytes: 0, n: shot.n, url: shot.data }));
|
|
2804
3352
|
upsertMessage(room.id, local);
|
|
2805
3353
|
try {
|
|
2806
|
-
const r = await post(roomApi("/send"), { text });
|
|
2807
|
-
|
|
3354
|
+
const r = await post(roomApi("/send"), { text, images: shots });
|
|
3355
|
+
if (r.command) removeMessage(room.id, local.id);
|
|
3356
|
+
else adoptLocalMessage(room.id, local.id, r.id);
|
|
2808
3357
|
} catch (error) {
|
|
2809
3358
|
removeMessage(room.id, local.id);
|
|
2810
3359
|
showError(error);
|
|
2811
3360
|
els.input.value = text;
|
|
3361
|
+
pendingShots = shots;
|
|
3362
|
+
renderShotsTray();
|
|
2812
3363
|
}
|
|
2813
3364
|
});
|
|
2814
3365
|
function adoptLocalMessage(roomId, localId, realId) {
|
|
@@ -2979,6 +3530,7 @@
|
|
|
2979
3530
|
});
|
|
2980
3531
|
}
|
|
2981
3532
|
|
|
3533
|
+
const ALL_MENTION = { id: "all", name: "All", kind: "all", tagline: "every vibemate in the room" };
|
|
2982
3534
|
function attachMentions(textarea, menuEl, options) {
|
|
2983
3535
|
const opts = options || {};
|
|
2984
3536
|
const m = { open: false, items: [], index: 0, start: -1 };
|
|
@@ -3001,6 +3553,8 @@
|
|
|
3001
3553
|
if (!ctx || !room) return close();
|
|
3002
3554
|
const q = ctx.prefix.toLowerCase();
|
|
3003
3555
|
const items = room.participants.filter((p) => (opts.includeHuman || p.id !== "human") && p.name.toLowerCase().startsWith(q));
|
|
3556
|
+
const agents = room.participants.filter((p) => p.kind === "agent" && p.status !== "left").length;
|
|
3557
|
+
if (!opts.includeHuman && agents > 1 && "all".startsWith(q)) items.unshift(ALL_MENTION);
|
|
3004
3558
|
if (!items.length) return close();
|
|
3005
3559
|
m.open = true;
|
|
3006
3560
|
m.items = items;
|
|
@@ -3011,7 +3565,7 @@
|
|
|
3011
3565
|
const b = document.createElement("button");
|
|
3012
3566
|
b.type = "button";
|
|
3013
3567
|
b.className = i === m.index ? "active" : "";
|
|
3014
|
-
b.innerHTML = `${avatar(p, 24, { vendor: true })}<span>${esc(p.name)}</span><span class="mm-sub">${esc(p.kind === "human" ? "you" : p.tagline || p.agentVendor || "")}${p.status === "offline" ? " · offline" : ""}</span>`;
|
|
3568
|
+
b.innerHTML = `${p === ALL_MENTION ? `<span class="mm-all">${ic("rooms")}</span>` : avatar(p, 24, { vendor: true })}<span>${esc(p.name)}</span><span class="mm-sub">${esc(p.kind === "human" ? "you" : p.tagline || p.agentVendor || "")}${p.status === "offline" ? " · offline" : ""}</span>`;
|
|
3015
3569
|
b.addEventListener("mousedown", (e) => {
|
|
3016
3570
|
e.preventDefault();
|
|
3017
3571
|
pick(i);
|
|
@@ -3053,7 +3607,7 @@
|
|
|
3053
3607
|
}
|
|
3054
3608
|
});
|
|
3055
3609
|
}
|
|
3056
|
-
attachMentions(els.input, els.mentionMenu, { onChange:
|
|
3610
|
+
attachMentions(els.input, els.mentionMenu, { onChange: autosizeSoon });
|
|
3057
3611
|
attachSlashMenu(els.input, els.mentionMenu);
|
|
3058
3612
|
els.input.addEventListener("keydown", (event) => {
|
|
3059
3613
|
if (event.defaultPrevented) return;
|
|
@@ -3094,15 +3648,16 @@
|
|
|
3094
3648
|
else openDetails({ kind: "me" });
|
|
3095
3649
|
return;
|
|
3096
3650
|
}
|
|
3097
|
-
if (nav === "room") {
|
|
3098
|
-
if (currentRoom()) selectRoom(state.currentRoomId, { keepDetails: true });
|
|
3099
|
-
return;
|
|
3100
|
-
}
|
|
3101
3651
|
setView(nav);
|
|
3102
3652
|
remember("view", nav);
|
|
3103
3653
|
});
|
|
3104
3654
|
});
|
|
3105
3655
|
els.railToggle.addEventListener("click", () => setRailOpen(!els.app.classList.contains("rail-open")));
|
|
3656
|
+
els.railRooms.addEventListener("click", (e) => {
|
|
3657
|
+
const b = e.target.closest(".rail-room");
|
|
3658
|
+
if (b) selectRoom(b.dataset.room, { keepDetails: true });
|
|
3659
|
+
});
|
|
3660
|
+
els.railRooms.addEventListener("animationend", (e) => e.target.classList.remove("bump"));
|
|
3106
3661
|
function setSideOpen(open) {
|
|
3107
3662
|
els.app.classList.toggle("side-collapsed", !open);
|
|
3108
3663
|
remember("sideOpen", open ? "1" : "0");
|
|
@@ -3110,12 +3665,24 @@
|
|
|
3110
3665
|
els.sideToggle.innerHTML = ic(open ? "collapse" : "expand");
|
|
3111
3666
|
}
|
|
3112
3667
|
els.sideToggle.addEventListener("click", () => setSideOpen(els.app.classList.contains("side-collapsed")));
|
|
3668
|
+
let lastScrollTop = 0;
|
|
3113
3669
|
els.messages.addEventListener("scroll", () => {
|
|
3114
|
-
els.
|
|
3670
|
+
const top = els.messages.scrollTop;
|
|
3671
|
+
if (nearBottom()) stuck = true;
|
|
3672
|
+
else if (top < lastScrollTop - 1) stuck = false;
|
|
3673
|
+
lastScrollTop = top;
|
|
3674
|
+
els.jumpLatest.hidden = stuck;
|
|
3115
3675
|
updateTimelineView();
|
|
3676
|
+
updateWorkingNow();
|
|
3677
|
+
if (stuck) clearNotes();
|
|
3678
|
+
else pruneDoneNotes();
|
|
3679
|
+
});
|
|
3680
|
+
els.jumpLatest.addEventListener("click", scrollToBottom);
|
|
3681
|
+
document.addEventListener("mousedown", (e) => {
|
|
3682
|
+
if (!state.detailsOpen) return;
|
|
3683
|
+
if (e.target.closest("#details, #side, .rail, .chat-actions, dialog, .mention-menu, .lightbox, .tl-pop, #pins-panel")) return;
|
|
3684
|
+
closeDetails();
|
|
3116
3685
|
});
|
|
3117
|
-
els.jumpLatest.addEventListener("click", () => els.messages.scrollTo({ top: els.messages.scrollHeight, behavior: "smooth" }));
|
|
3118
|
-
els.detailsHandle.addEventListener("click", closeDetails);
|
|
3119
3686
|
setSideOpen(recall("sideOpen") !== "0");
|
|
3120
3687
|
$("#rail-logo").addEventListener("click", () => setView("home"));
|
|
3121
3688
|
$("#rail-new-room").addEventListener("click", openRoomDialog);
|
|
@@ -3229,83 +3796,268 @@
|
|
|
3229
3796
|
});
|
|
3230
3797
|
window.addEventListener("beforeunload", () => remember("view", state.view));
|
|
3231
3798
|
|
|
3232
|
-
const tl = { el: $("#timeline"), ticks: $("#timeline .tl-ticks"), view: $("#timeline .tl-view"), pop: $("#timeline .tl-pop"), items: [] };
|
|
3233
3799
|
const TICK_H = 5;
|
|
3234
|
-
function
|
|
3235
|
-
const
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
const
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3800
|
+
function createTimeline(root, pick, opts) {
|
|
3801
|
+
const t = { el: root, ticks: root.querySelector(".tl-ticks"), view: root.querySelector(".tl-view"), pop: root.querySelector(".tl-pop"), items: [] };
|
|
3802
|
+
function render() {
|
|
3803
|
+
const room = currentRoom();
|
|
3804
|
+
const nodes = room && state.view === "room" ? pick(room) : [];
|
|
3805
|
+
t.items = nodes;
|
|
3806
|
+
t.el.hidden = nodes.length === 0;
|
|
3807
|
+
t.pop.hidden = true;
|
|
3808
|
+
if (!nodes.length) return;
|
|
3809
|
+
const total = els.messages.scrollHeight || 1;
|
|
3810
|
+
const h = Math.max(0, t.ticks.clientHeight - TICK_H);
|
|
3811
|
+
const tops = nodes.map((el) => el.offsetTop);
|
|
3812
|
+
const frag = document.createDocumentFragment();
|
|
3813
|
+
nodes.forEach((el, i) => {
|
|
3814
|
+
const tick = document.createElement("div");
|
|
3815
|
+
const pinned = el.classList.contains("pinned");
|
|
3816
|
+
tick.className = `tl-tick${pinned ? " pinned i i-pin" : ""}`;
|
|
3817
|
+
tick.dataset.i = i;
|
|
3818
|
+
if (opts.colorOf) tick.style.setProperty("--tick", opts.colorOf(room, el));
|
|
3819
|
+
tick.style.top = `${Math.round((tops[i] / total) * h)}px`;
|
|
3820
|
+
frag.appendChild(tick);
|
|
3821
|
+
});
|
|
3822
|
+
t.ticks.replaceChildren(frag);
|
|
3823
|
+
updateView();
|
|
3824
|
+
}
|
|
3825
|
+
function updateView() {
|
|
3826
|
+
if (t.el.hidden) return;
|
|
3827
|
+
const m = els.messages;
|
|
3828
|
+
const total = m.scrollHeight || 1;
|
|
3829
|
+
const h = t.ticks.clientHeight;
|
|
3830
|
+
t.view.style.top = `${(m.scrollTop / total) * h}px`;
|
|
3831
|
+
t.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
|
|
3832
|
+
const top = m.scrollTop;
|
|
3833
|
+
const bottom = m.scrollTop + m.clientHeight;
|
|
3834
|
+
const inView = t.items.map((el) => el.offsetTop + el.offsetHeight > top && el.offsetTop < bottom);
|
|
3835
|
+
inView.forEach((on, i) => {
|
|
3836
|
+
const tick = t.ticks.children[i];
|
|
3837
|
+
if (tick) tick.classList.toggle("in-view", on);
|
|
3838
|
+
});
|
|
3839
|
+
}
|
|
3840
|
+
function rowHtml(k, cls) {
|
|
3841
|
+
const el = t.items[k];
|
|
3842
|
+
const av = opts.avatarOf ? `<span class="tl-av">${opts.avatarOf(currentRoom(), el)}</span>` : "";
|
|
3843
|
+
const pinned = el.classList.contains("pinned");
|
|
3844
|
+
return `<div class="tl-row ${cls}${pinned ? " pinned" : ""}" data-i="${k}">${av}<span class="tl-text">${esc(timelineText(el))}</span>${pinned ? `<span class="tl-pin" title="Pinned">${ic("pin")}</span>` : ""}</div>`;
|
|
3845
|
+
}
|
|
3846
|
+
function showPop(i) {
|
|
3847
|
+
const rows = [[i - 2, "faded far"], [i - 1, "faded"], [i, "current"], [i + 1, "faded"], [i + 2, "faded far"]].filter(([k]) => t.items[k]);
|
|
3848
|
+
t.pop.innerHTML = rows.map(([k, c]) => rowHtml(k, c)).join("");
|
|
3849
|
+
t.pop.hidden = false;
|
|
3850
|
+
t.ticks.querySelectorAll(".tl-tick.active").forEach((x) => x.classList.remove("active"));
|
|
3851
|
+
const tick = t.ticks.children[i];
|
|
3852
|
+
if (tick) tick.classList.add("active");
|
|
3853
|
+
const current = t.pop.querySelector(".tl-row.current");
|
|
3854
|
+
let top = (tick ? tick.offsetTop : 0) - (current ? current.offsetTop + current.offsetHeight / 2 : 20) + TICK_H / 2;
|
|
3855
|
+
top = Math.max(0, Math.min(top, t.el.clientHeight - t.pop.offsetHeight));
|
|
3856
|
+
t.pop.style.top = `${top}px`;
|
|
3857
|
+
}
|
|
3858
|
+
function hidePop() {
|
|
3859
|
+
t.pop.hidden = true;
|
|
3860
|
+
t.ticks.querySelectorAll(".tl-tick.active").forEach((x) => x.classList.remove("active"));
|
|
3861
|
+
}
|
|
3862
|
+
t.ticks.addEventListener("mouseover", (e) => {
|
|
3863
|
+
const tick = e.target.closest(".tl-tick");
|
|
3864
|
+
if (tick) showPop(Number(tick.dataset.i));
|
|
3251
3865
|
});
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
const top = m.scrollTop;
|
|
3262
|
-
const bottom = m.scrollTop + m.clientHeight;
|
|
3263
|
-
tl.items.forEach((el, i) => {
|
|
3264
|
-
const tick = tl.ticks.children[i];
|
|
3265
|
-
if (tick) tick.classList.toggle("in-view", el.offsetTop + el.offsetHeight > top && el.offsetTop < bottom);
|
|
3866
|
+
let hideTimer = 0;
|
|
3867
|
+
t.el.addEventListener("mouseleave", () => {
|
|
3868
|
+
clearTimeout(hideTimer);
|
|
3869
|
+
hideTimer = setTimeout(hidePop, 320);
|
|
3870
|
+
});
|
|
3871
|
+
t.el.addEventListener("mouseenter", () => clearTimeout(hideTimer));
|
|
3872
|
+
t.ticks.addEventListener("click", (e) => {
|
|
3873
|
+
const tick = e.target.closest(".tl-tick");
|
|
3874
|
+
if (tick) jumpToMessage(t.items[Number(tick.dataset.i)]);
|
|
3266
3875
|
});
|
|
3876
|
+
t.pop.addEventListener("click", (e) => {
|
|
3877
|
+
const row = e.target.closest(".tl-row");
|
|
3878
|
+
if (row) jumpToMessage(t.items[Number(row.dataset.i)]);
|
|
3879
|
+
});
|
|
3880
|
+
return { render, updateView };
|
|
3267
3881
|
}
|
|
3268
3882
|
function timelineText(el) {
|
|
3269
3883
|
const t = el.querySelector(".text");
|
|
3270
|
-
return (t ? t.
|
|
3271
|
-
}
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
const
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3884
|
+
return (t ? t.textContent : el.textContent).trim().replace(/\s+/g, " ").slice(0, 240);
|
|
3885
|
+
}
|
|
3886
|
+
const doneNotes = [];
|
|
3887
|
+
const NOTE_TTL_MS = 4000;
|
|
3888
|
+
function bubbleInView(id) {
|
|
3889
|
+
const head = els.messages.querySelector(`.msg[data-id="${id}"] .head`);
|
|
3890
|
+
if (!head) return false;
|
|
3891
|
+
const box = els.messages.getBoundingClientRect();
|
|
3892
|
+
const r = head.getBoundingClientRect();
|
|
3893
|
+
return r.bottom > box.top && r.top < box.bottom;
|
|
3894
|
+
}
|
|
3895
|
+
function pushNote(note) {
|
|
3896
|
+
const i = doneNotes.findIndex((n) => n.id === note.id);
|
|
3897
|
+
if (i >= 0) {
|
|
3898
|
+
clearTimeout(doneNotes[i].timer);
|
|
3899
|
+
doneNotes.splice(i, 1);
|
|
3900
|
+
}
|
|
3901
|
+
note.timer = setTimeout(() => dropNote(note.id), NOTE_TTL_MS);
|
|
3902
|
+
doneNotes.push(note);
|
|
3903
|
+
while (doneNotes.length > 4) dropNote(doneNotes[0].id);
|
|
3904
|
+
renderDoneNotes();
|
|
3905
|
+
}
|
|
3906
|
+
function dropNote(id) {
|
|
3907
|
+
const i = doneNotes.findIndex((n) => n.id === id);
|
|
3908
|
+
if (i < 0) return;
|
|
3909
|
+
clearTimeout(doneNotes[i].timer);
|
|
3910
|
+
doneNotes.splice(i, 1);
|
|
3911
|
+
renderDoneNotes();
|
|
3912
|
+
}
|
|
3913
|
+
function clearNotes() {
|
|
3914
|
+
for (const n of doneNotes) clearTimeout(n.timer);
|
|
3915
|
+
doneNotes.length = 0;
|
|
3916
|
+
renderDoneNotes();
|
|
3917
|
+
}
|
|
3918
|
+
function noteFinished(room, m) {
|
|
3919
|
+
if (room.id !== state.currentRoomId || state.view !== "room") return;
|
|
3920
|
+
requestAnimationFrame(() => {
|
|
3921
|
+
if (bubbleInView(m.id)) return;
|
|
3922
|
+
const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: "agent" };
|
|
3923
|
+
pushNote({ id: m.id, kind: "done", p, text: `${p.name} finished`, sub: `started ${time(m.ts)}${m.durationMs ? ` · ${spanText(m.durationMs)}` : ""}` });
|
|
3924
|
+
});
|
|
3287
3925
|
}
|
|
3926
|
+
function noteNew(room, m) {
|
|
3927
|
+
if (room.id !== state.currentRoomId || state.view !== "room") return;
|
|
3928
|
+
requestAnimationFrame(() => {
|
|
3929
|
+
if (bubbleInView(m.id)) return;
|
|
3930
|
+
const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: "agent" };
|
|
3931
|
+
pushNote({ id: m.id, kind: "new", p, text: `${p.name} wrote below`, sub: time(m.ts) });
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3934
|
+
function spanText(ms) {
|
|
3935
|
+
if (!ms) return "";
|
|
3936
|
+
const s = Math.round(ms / 1000);
|
|
3937
|
+
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, "0")} s`;
|
|
3938
|
+
}
|
|
3939
|
+
function renderDoneNotes() {
|
|
3940
|
+
els.doneNotes.hidden = doneNotes.length === 0;
|
|
3941
|
+
els.doneNotes.innerHTML = doneNotes
|
|
3942
|
+
.map((n) => `<button type="button" class="done-note ${n.kind}" data-id="${esc(n.id)}" title="Go to the message">${avatar(n.p, 18, {})}<span class="done-text"><b>${esc(n.text)}</b><small>${esc(n.sub)}</small></span></button>`)
|
|
3943
|
+
.join("");
|
|
3944
|
+
}
|
|
3945
|
+
function pruneDoneNotes() {
|
|
3946
|
+
for (const n of [...doneNotes]) if (bubbleInView(n.id)) dropNote(n.id);
|
|
3947
|
+
}
|
|
3948
|
+
els.doneNotes.addEventListener("click", (e) => {
|
|
3949
|
+
const note = e.target.closest(".done-note");
|
|
3950
|
+
if (!note) return;
|
|
3951
|
+
const id = note.dataset.id;
|
|
3952
|
+
dropNote(id);
|
|
3953
|
+
jumpToMessage(els.messages.querySelector(`.msg[data-id="${id}"]`));
|
|
3954
|
+
});
|
|
3288
3955
|
function jumpToMessage(el) {
|
|
3289
3956
|
if (!el) return;
|
|
3290
|
-
el.scrollIntoView({ block: "center"
|
|
3957
|
+
el.scrollIntoView({ block: "center" });
|
|
3958
|
+
requestAnimationFrame(() => el.scrollIntoView({ block: "center" }));
|
|
3291
3959
|
el.classList.remove("flash");
|
|
3292
3960
|
void el.offsetWidth;
|
|
3293
3961
|
el.classList.add("flash");
|
|
3294
3962
|
}
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3963
|
+
const authorOf = (room, el) => findById(room, el.dataset.from);
|
|
3964
|
+
const timelines = [
|
|
3965
|
+
createTimeline($("#timeline"), () => [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")], {}),
|
|
3966
|
+
createTimeline($("#timeline-left"), () => [...els.messages.querySelectorAll(".msg.agent:not(.hidden-by-search)")], {
|
|
3967
|
+
colorOf: (room, el) => (authorOf(room, el) || {}).color || "#9ca3af",
|
|
3968
|
+
avatarOf: (room, el) => { const p = authorOf(room, el); return p ? avatar(p, 16, {}) : ""; },
|
|
3969
|
+
}),
|
|
3970
|
+
];
|
|
3971
|
+
function renderTimeline() {
|
|
3972
|
+
for (const t of timelines) t.render();
|
|
3973
|
+
renderPins();
|
|
3974
|
+
}
|
|
3975
|
+
function updateTimelineView() { for (const t of timelines) t.updateView(); }
|
|
3976
|
+
new ResizeObserver(() => {
|
|
3977
|
+
els.app.style.setProperty("--composer-h", `${els.composer.offsetHeight}px`);
|
|
3978
|
+
renderTimeline();
|
|
3979
|
+
}).observe(els.composer);
|
|
3980
|
+
new ResizeObserver(() => renderTimeline()).observe(els.messages);
|
|
3981
|
+
|
|
3982
|
+
const workingNow = $("#working-now");
|
|
3983
|
+
function renderWorkingNow() {
|
|
3984
|
+
const room = currentRoom();
|
|
3985
|
+
const working = room && state.view === "room" ? room.participants.filter((p) => p.kind === "agent" && p.status === "thinking") : [];
|
|
3986
|
+
workingNow.innerHTML = working.map((p, i) => `<button type="button" class="wn-av" data-id="${esc(p.id)}" style="--i:${i}" title="${esc(p.name)} is writing — click to go to the reply">${avatar(p, 22, {})}</button>`).join("");
|
|
3987
|
+
updateWorkingNow();
|
|
3988
|
+
}
|
|
3989
|
+
function updateWorkingNow() {
|
|
3990
|
+
const room = currentRoom();
|
|
3991
|
+
const buttons = [...workingNow.querySelectorAll(".wn-av")];
|
|
3992
|
+
if (!room || !buttons.length) return void (workingNow.hidden = true);
|
|
3993
|
+
const box = els.messages.getBoundingClientRect();
|
|
3994
|
+
let shown = 0;
|
|
3995
|
+
for (const b of buttons) {
|
|
3996
|
+
const draft = room.messages.find((x) => x.from === b.dataset.id && x.streaming);
|
|
3997
|
+
const head = draft && els.messages.querySelector(`.msg[data-id="${draft.id}"] .head`);
|
|
3998
|
+
let show = false;
|
|
3999
|
+
if (head) {
|
|
4000
|
+
const r = head.getBoundingClientRect();
|
|
4001
|
+
show = !(r.bottom > box.top && r.top < box.bottom);
|
|
4002
|
+
}
|
|
4003
|
+
b.hidden = !show;
|
|
4004
|
+
if (show) shown++;
|
|
4005
|
+
}
|
|
4006
|
+
workingNow.hidden = shown === 0;
|
|
4007
|
+
}
|
|
4008
|
+
workingNow.addEventListener("click", (e) => {
|
|
4009
|
+
const b = e.target.closest(".wn-av");
|
|
4010
|
+
const room = b && currentRoom();
|
|
4011
|
+
if (!room) return;
|
|
4012
|
+
const m = [...room.messages].reverse().find((x) => x.from === b.dataset.id && x.kind === "chat");
|
|
4013
|
+
const el = m && els.messages.querySelector(`.msg[data-id="${m.id}"]`);
|
|
4014
|
+
if (el && m.streaming) jumpToMessage(el);
|
|
4015
|
+
else scrollToBottom();
|
|
3298
4016
|
});
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
4017
|
+
|
|
4018
|
+
const pinsBtn = $("#pins-btn");
|
|
4019
|
+
const pinsPanel = $("#pins-panel");
|
|
4020
|
+
function pinnedMessages(room) {
|
|
4021
|
+
return room.messages.filter((m) => m.pinned && m.kind === "chat").sort((a, b) => a.seq - b.seq);
|
|
4022
|
+
}
|
|
4023
|
+
function renderPins() {
|
|
4024
|
+
const room = currentRoom();
|
|
4025
|
+
const pins = room && state.view === "room" ? pinnedMessages(room) : [];
|
|
4026
|
+
pinsBtn.hidden = pins.length === 0;
|
|
4027
|
+
pinsBtn.innerHTML = `${ic("pin")} Pinned · ${pins.length}`;
|
|
4028
|
+
if (!pins.length) pinsPanel.hidden = true;
|
|
4029
|
+
if (pinsPanel.hidden) return;
|
|
4030
|
+
pinsPanel.innerHTML = pins
|
|
4031
|
+
.map((m) => {
|
|
4032
|
+
const p = findById(room, m.from);
|
|
4033
|
+
const who = m.from === "human" ? Object.assign(meAvatarData(), { color: (p || {}).color }) : p;
|
|
4034
|
+
const text = String(m.text || "").replace(/\s+/g, " ").trim().slice(0, 160) || (m.images && m.images.length ? `[${m.images.length} image${m.images.length === 1 ? "" : "s"}]` : "");
|
|
4035
|
+
return `<div class="tl-row pin-row${m.from === "human" ? " mine" : ""}" data-id="${esc(m.id)}"><span class="tl-av">${who ? avatar(who, 16, {}) : ""}</span><span class="tl-text">${esc(text)}</span><span class="pin-time">${time(m.ts)}</span><button type="button" class="pin-x" title="Unpin">×</button></div>`;
|
|
4036
|
+
})
|
|
4037
|
+
.join("");
|
|
4038
|
+
}
|
|
4039
|
+
pinsBtn.addEventListener("click", () => {
|
|
4040
|
+
pinsPanel.hidden = !pinsPanel.hidden;
|
|
4041
|
+
renderPins();
|
|
3303
4042
|
});
|
|
3304
|
-
|
|
3305
|
-
const row = e.target.closest(".
|
|
3306
|
-
|
|
4043
|
+
pinsPanel.addEventListener("click", async (e) => {
|
|
4044
|
+
const row = e.target.closest(".pin-row");
|
|
4045
|
+
const room = currentRoom();
|
|
4046
|
+
if (!row || !room) return;
|
|
4047
|
+
if (e.target.closest(".pin-x")) {
|
|
4048
|
+
try {
|
|
4049
|
+
await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(row.dataset.id)}/pin`, { pinned: false });
|
|
4050
|
+
} catch (error) {
|
|
4051
|
+
showError(error);
|
|
4052
|
+
}
|
|
4053
|
+
return;
|
|
4054
|
+
}
|
|
4055
|
+
jumpToMessage(els.messages.querySelector(`.msg[data-id="${row.dataset.id}"]`));
|
|
4056
|
+
pinsPanel.hidden = true;
|
|
4057
|
+
});
|
|
4058
|
+
document.addEventListener("click", (e) => {
|
|
4059
|
+
if (!pinsPanel.hidden && !e.target.closest("#pins-panel, #pins-btn")) pinsPanel.hidden = true;
|
|
3307
4060
|
});
|
|
3308
|
-
new ResizeObserver(() => renderTimeline()).observe(els.messages);
|
|
3309
4061
|
|
|
3310
4062
|
const fp = { onChoose: null, selected: "", roots: [], home: "" };
|
|
3311
4063
|
const fpEls = { dialog: $("#folder-dialog"), path: $("#fp-path"), tree: $("#fp-tree"), recent: $("#fp-recent"), error: $("#fp-error"), selected: $("#fp-selected"), choose: $("#fp-choose"), home: $("#fp-home"), newBtn: $("#fp-new") };
|