viberoom 0.4.2 → 0.5.0
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/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 +205 -24
- 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 +165 -20
- package/ui/app.js +815 -116
- package/ui/icons.js +13 -10
- package/ui/index.html +30 -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);
|
|
@@ -1138,9 +1226,8 @@
|
|
|
1138
1226
|
const mine = m.from === "human";
|
|
1139
1227
|
el.className = "msg " + (mine ? "mine" : "agent");
|
|
1140
1228
|
el.innerHTML = `
|
|
1141
|
-
${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 36, { vendor: true })}
|
|
1142
1229
|
<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>
|
|
1230
|
+
<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
1231
|
<div class="bubble">
|
|
1145
1232
|
${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
1233
|
<div class="edit-box" hidden></div>
|
|
@@ -1149,8 +1236,10 @@
|
|
|
1149
1236
|
<div class="tools"></div>
|
|
1150
1237
|
<div class="plan" hidden></div>
|
|
1151
1238
|
<div class="text"></div>
|
|
1239
|
+
<div class="shots"></div>
|
|
1152
1240
|
<button class="more" hidden></button>
|
|
1153
1241
|
<div class="perms"></div>
|
|
1242
|
+
<div class="waiting" hidden></div>
|
|
1154
1243
|
</div>
|
|
1155
1244
|
<div class="meta"></div>
|
|
1156
1245
|
</div>`;
|
|
@@ -1161,12 +1250,106 @@
|
|
|
1161
1250
|
});
|
|
1162
1251
|
const editBtn = el.querySelector(".edit-btn");
|
|
1163
1252
|
if (editBtn) editBtn.addEventListener("click", () => openInlineEditor(el, room, m));
|
|
1253
|
+
el.querySelector(".pin-btn").addEventListener("click", async () => {
|
|
1254
|
+
if (m.pending) return;
|
|
1255
|
+
try {
|
|
1256
|
+
await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(m.id)}/pin`, { pinned: !m.pinned });
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
showError(error);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1164
1261
|
updateMessageElement(el, room, m);
|
|
1165
1262
|
return el;
|
|
1166
1263
|
}
|
|
1167
1264
|
|
|
1265
|
+
function shotUrl(roomId, image) {
|
|
1266
|
+
if (image.url) return image.url;
|
|
1267
|
+
return `/api/rooms/${encodeURIComponent(roomId)}/files/${encodeURIComponent(image.file)}`;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function renderShots(box, room, m) {
|
|
1271
|
+
if (!box) return;
|
|
1272
|
+
const images = m.images || [];
|
|
1273
|
+
box.hidden = !images.length;
|
|
1274
|
+
if (!images.length) return void (box.innerHTML = "");
|
|
1275
|
+
box.innerHTML = images
|
|
1276
|
+
.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>`)
|
|
1277
|
+
.join("");
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function openLightbox(src, title) {
|
|
1281
|
+
els.lightbox.querySelector("img").src = src;
|
|
1282
|
+
els.lightbox.querySelector("img").alt = title || "";
|
|
1283
|
+
els.lightbox.hidden = false;
|
|
1284
|
+
}
|
|
1285
|
+
function closeLightbox() {
|
|
1286
|
+
els.lightbox.hidden = true;
|
|
1287
|
+
els.lightbox.querySelector("img").src = "";
|
|
1288
|
+
}
|
|
1289
|
+
els.lightbox.addEventListener("click", closeLightbox);
|
|
1290
|
+
document.addEventListener("keydown", (e) => {
|
|
1291
|
+
if (e.key === "Escape" && !els.lightbox.hidden) closeLightbox();
|
|
1292
|
+
});
|
|
1293
|
+
els.messages.addEventListener("click", (e) => {
|
|
1294
|
+
const shot = e.target.closest(".shot");
|
|
1295
|
+
if (shot) return void openLightbox(shot.dataset.src, shot.title);
|
|
1296
|
+
const ref = e.target.closest(".img-ref");
|
|
1297
|
+
if (!ref) return;
|
|
1298
|
+
const msg = ref.closest(".msg");
|
|
1299
|
+
const target = msg && msg.querySelector(`.shot[data-n="${ref.dataset.n}"]`);
|
|
1300
|
+
if (target) openLightbox(target.dataset.src, target.title);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
function waitingFor(room, m) {
|
|
1304
|
+
if (m.from !== "human" || m.kind !== "chat" || !m.to || !m.to.length || m.pending) return [];
|
|
1305
|
+
return m.to.map((id) => findById(room, id)).filter((p) => p && p.kind === "agent" && p.status === "thinking" && p.lastSeenSeq != null && p.lastSeenSeq < m.seq);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function renderWaiting(el, room, m) {
|
|
1309
|
+
const box = el.querySelector(".waiting");
|
|
1310
|
+
if (!box) return;
|
|
1311
|
+
const agents = waitingFor(room, m);
|
|
1312
|
+
el.classList.toggle("waiting", agents.length > 0);
|
|
1313
|
+
box.hidden = !agents.length;
|
|
1314
|
+
if (!agents.length) return void (box.innerHTML = "");
|
|
1315
|
+
const names = agents.map((p) => p.name);
|
|
1316
|
+
const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
1317
|
+
const verb = names.length === 1 ? "is" : "are";
|
|
1318
|
+
const stopLabel = names.length === 1 ? `Stop ${names[0]} and send now` : "Stop them and send now";
|
|
1319
|
+
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>`;
|
|
1320
|
+
box.querySelector(".waiting-stop").addEventListener("click", async (e) => {
|
|
1321
|
+
const btn = e.currentTarget;
|
|
1322
|
+
btn.disabled = true;
|
|
1323
|
+
btn.textContent = "Stopping…";
|
|
1324
|
+
try {
|
|
1325
|
+
await Promise.all(agents.map((p) => post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`))));
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
showError(error);
|
|
1328
|
+
btn.disabled = false;
|
|
1329
|
+
btn.textContent = stopLabel;
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const openTools = new Set();
|
|
1335
|
+
els.messages.addEventListener("click", (e) => {
|
|
1336
|
+
const chip = e.target.closest(".tool > .chip");
|
|
1337
|
+
if (!chip) return;
|
|
1338
|
+
const msgEl = chip.closest(".msg");
|
|
1339
|
+
const room = currentRoom();
|
|
1340
|
+
const m = room && msgEl && room.messages.find((x) => x.id === msgEl.dataset.id);
|
|
1341
|
+
if (!m) return;
|
|
1342
|
+
if (openTools.has(chip.dataset.tool)) openTools.delete(chip.dataset.tool);
|
|
1343
|
+
else openTools.add(chip.dataset.tool);
|
|
1344
|
+
updateMessageElement(msgEl, room, m);
|
|
1345
|
+
});
|
|
1168
1346
|
function updateMessageElement(el, room, m) {
|
|
1169
1347
|
el.classList.toggle("hidden-by-search", !messageMatches(m));
|
|
1348
|
+
const wasPinned = el.classList.contains("pinned");
|
|
1349
|
+
el.classList.toggle("pinned", !!m.pinned);
|
|
1350
|
+
const pinBtn = el.querySelector(".pin-btn");
|
|
1351
|
+
if (pinBtn) pinBtn.title = m.pinned ? "Unpin this message" : "Pin this message";
|
|
1352
|
+
if (wasPinned !== !!m.pinned) renderTimeline();
|
|
1170
1353
|
const editedEl = el.querySelector(".edited");
|
|
1171
1354
|
if (editedEl) {
|
|
1172
1355
|
editedEl.hidden = !m.edited;
|
|
@@ -1186,12 +1369,14 @@
|
|
|
1186
1369
|
const more = el.querySelector(".more");
|
|
1187
1370
|
const long = !m.streaming && m.text.length > CLAMP_CHARS;
|
|
1188
1371
|
const expanded = state.expanded.has(m.id);
|
|
1189
|
-
text.innerHTML = renderText(room, m.text) + (m.streaming ? '<span class="caret"></span>' : "");
|
|
1372
|
+
text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? '<span class="caret"></span>' : "");
|
|
1190
1373
|
if (!m.streaming) renderDiagrams(text);
|
|
1191
1374
|
if (m.streaming && !m.text) text.innerHTML = '<span class="pending">…</span>';
|
|
1192
1375
|
text.classList.toggle("clamped", long && !expanded);
|
|
1193
1376
|
more.hidden = !long;
|
|
1194
1377
|
more.textContent = expanded ? "Show less" : "Show more";
|
|
1378
|
+
renderShots(el.querySelector(".shots"), room, m);
|
|
1379
|
+
renderWaiting(el, room, m);
|
|
1195
1380
|
const thought = el.querySelector(".thought");
|
|
1196
1381
|
if (m.thought) {
|
|
1197
1382
|
thought.hidden = false;
|
|
@@ -1201,11 +1386,16 @@
|
|
|
1201
1386
|
const tools = el.querySelector(".tools");
|
|
1202
1387
|
tools.innerHTML = "";
|
|
1203
1388
|
for (const call of m.toolCalls || []) {
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1389
|
+
const open = openTools.has(call.toolCallId);
|
|
1390
|
+
const box = document.createElement("div");
|
|
1391
|
+
box.className = `tool${open ? " open" : ""}`;
|
|
1392
|
+
const input = call.rawInput === undefined ? "" : typeof call.rawInput === "string" ? call.rawInput : JSON.stringify(call.rawInput, null, 1);
|
|
1393
|
+
box.innerHTML =
|
|
1394
|
+
`<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>` +
|
|
1395
|
+
(open
|
|
1396
|
+
? `<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>`
|
|
1397
|
+
: "");
|
|
1398
|
+
tools.appendChild(box);
|
|
1209
1399
|
}
|
|
1210
1400
|
const plan = el.querySelector(".plan");
|
|
1211
1401
|
if (m.plan && m.plan.length) {
|
|
@@ -1231,18 +1421,23 @@
|
|
|
1231
1421
|
return null;
|
|
1232
1422
|
}
|
|
1233
1423
|
function seenHtml(room, m) {
|
|
1234
|
-
const
|
|
1424
|
+
const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline");
|
|
1425
|
+
const seen = present.filter((p) => p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
|
|
1235
1426
|
if (!seen.length) return `<span class="ticks">✓</span> sent`;
|
|
1427
|
+
if (present.length > 1 && seen.length === present.length) return `<span class="ticks">✓✓</span> seen by all`;
|
|
1236
1428
|
return `<span class="ticks">✓✓</span> seen by ${esc(seen.map((p) => p.name).join(", "))}`;
|
|
1237
1429
|
}
|
|
1238
1430
|
function refreshSeen(room) {
|
|
1239
1431
|
const last = lastHumanMessage(room);
|
|
1432
|
+
const byId = new Map(room.messages.map((m) => [m.id, m]));
|
|
1240
1433
|
for (const el of els.messages.querySelectorAll(".msg.mine")) {
|
|
1241
1434
|
const meta = el.querySelector(".meta");
|
|
1242
1435
|
if (!meta) continue;
|
|
1243
1436
|
const isLast = last && el.dataset.id === last.id;
|
|
1244
1437
|
meta.innerHTML = isLast ? seenHtml(room, last) : "";
|
|
1245
1438
|
meta.classList.toggle("seen", !!isLast);
|
|
1439
|
+
const m = byId.get(el.dataset.id);
|
|
1440
|
+
if (m && (isLast || el.classList.contains("waiting"))) renderWaiting(el, room, m);
|
|
1246
1441
|
}
|
|
1247
1442
|
}
|
|
1248
1443
|
|
|
@@ -1310,11 +1505,14 @@
|
|
|
1310
1505
|
const room = state.rooms.get(roomId);
|
|
1311
1506
|
if (!room) return;
|
|
1312
1507
|
const idx = room.messages.findIndex((x) => x.id === m.id);
|
|
1313
|
-
|
|
1314
|
-
|
|
1508
|
+
const wasFinal = idx >= 0 && !room.messages[idx].streaming;
|
|
1509
|
+
if (idx >= 0) {
|
|
1510
|
+
if (!("pinned" in m)) delete room.messages[idx].pinned;
|
|
1511
|
+
Object.assign(room.messages[idx], m);
|
|
1512
|
+
} else room.messages.push(m);
|
|
1315
1513
|
const showing = roomId === state.currentRoomId && state.view === "room";
|
|
1316
1514
|
if (!showing) {
|
|
1317
|
-
if (
|
|
1515
|
+
if (!wasFinal && m.kind === "chat" && !m.streaming && m.from !== "human" && roomId !== state.currentRoomId) state.unread.set(roomId, (state.unread.get(roomId) || 0) + 1);
|
|
1318
1516
|
if ((state.view === "rooms" || state.view === "home")) {
|
|
1319
1517
|
renderSideRooms();
|
|
1320
1518
|
renderRoomsGrid();
|
|
@@ -1322,7 +1520,7 @@
|
|
|
1322
1520
|
renderRail();
|
|
1323
1521
|
return;
|
|
1324
1522
|
}
|
|
1325
|
-
const stick =
|
|
1523
|
+
const stick = stuck;
|
|
1326
1524
|
const existing = els.messages.querySelector(`.msg[data-id="${m.id}"]`);
|
|
1327
1525
|
if (existing) updateMessageElement(existing, room, room.messages[idx]);
|
|
1328
1526
|
else {
|
|
@@ -1332,6 +1530,8 @@
|
|
|
1332
1530
|
if (m.from === "human") refreshSeen(room);
|
|
1333
1531
|
}
|
|
1334
1532
|
if (stick) scrollToBottom();
|
|
1533
|
+
if (m.streaming) updateWorkingNow();
|
|
1534
|
+
if (!wasFinal && !m.streaming && m.kind === "chat" && m.from !== "human") noteFinished(room, m);
|
|
1335
1535
|
if (m.from === "human") renderTimeline();
|
|
1336
1536
|
}
|
|
1337
1537
|
|
|
@@ -1344,6 +1544,8 @@
|
|
|
1344
1544
|
if (el) el.remove();
|
|
1345
1545
|
}
|
|
1346
1546
|
|
|
1547
|
+
const dirty = new Map();
|
|
1548
|
+
let flushScheduled = false;
|
|
1347
1549
|
function patchMessage(roomId, id, fn) {
|
|
1348
1550
|
const room = state.rooms.get(roomId);
|
|
1349
1551
|
if (!room) return;
|
|
@@ -1351,10 +1553,26 @@
|
|
|
1351
1553
|
if (!m) return;
|
|
1352
1554
|
fn(m);
|
|
1353
1555
|
if (roomId !== state.currentRoomId || state.view !== "room") return;
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1556
|
+
dirty.set(id, room);
|
|
1557
|
+
if (flushScheduled) return;
|
|
1558
|
+
flushScheduled = true;
|
|
1559
|
+
requestAnimationFrame(flushPatches);
|
|
1560
|
+
}
|
|
1561
|
+
function flushPatches() {
|
|
1562
|
+
flushScheduled = false;
|
|
1563
|
+
const batch = [...dirty];
|
|
1564
|
+
dirty.clear();
|
|
1565
|
+
let touched = false;
|
|
1566
|
+
for (const [id, room] of batch) {
|
|
1567
|
+
if (room.id !== state.currentRoomId || state.view !== "room") continue;
|
|
1568
|
+
const m = room.messages.find((x) => x.id === id);
|
|
1569
|
+
const el = m && els.messages.querySelector(`.msg[data-id="${id}"]`);
|
|
1570
|
+
if (!el) continue;
|
|
1571
|
+
updateMessageElement(el, room, m);
|
|
1572
|
+
touched = true;
|
|
1573
|
+
}
|
|
1574
|
+
if (touched && stuck) scrollToBottom();
|
|
1575
|
+
if (touched) updateWorkingNow();
|
|
1358
1576
|
}
|
|
1359
1577
|
|
|
1360
1578
|
|
|
@@ -1582,6 +1800,11 @@
|
|
|
1582
1800
|
${sectionTitle("link", "Session")}
|
|
1583
1801
|
<div id="pp-config"></div>
|
|
1584
1802
|
</div>
|
|
1803
|
+
<div class="section danger">
|
|
1804
|
+
${sectionTitle("bolt", "Respawn")}
|
|
1805
|
+
<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>
|
|
1806
|
+
<div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button></div>
|
|
1807
|
+
</div>
|
|
1585
1808
|
<div class="section">
|
|
1586
1809
|
${sectionTitle("info", "Stats")}
|
|
1587
1810
|
<div class="kv">
|
|
@@ -1599,6 +1822,18 @@
|
|
|
1599
1822
|
"skills, timing, session, stats",
|
|
1600
1823
|
)}`;
|
|
1601
1824
|
wireDetailsClose();
|
|
1825
|
+
const respawnBtn = els.detailsInner.querySelector('button[data-act="respawn"]');
|
|
1826
|
+
if (respawnBtn) {
|
|
1827
|
+
respawnBtn.addEventListener("click", async () => {
|
|
1828
|
+
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 });
|
|
1829
|
+
if (!ok) return;
|
|
1830
|
+
try {
|
|
1831
|
+
await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`));
|
|
1832
|
+
} catch (e) {
|
|
1833
|
+
showError(e);
|
|
1834
|
+
}
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1602
1837
|
els.detailsInner.querySelectorAll(".action").forEach((btn) => {
|
|
1603
1838
|
btn.addEventListener("click", async () => {
|
|
1604
1839
|
const act = btn.dataset.act;
|
|
@@ -1772,7 +2007,7 @@
|
|
|
1772
2007
|
</div>
|
|
1773
2008
|
<div class="section danger" style="margin-top:12px">
|
|
1774
2009
|
${sectionTitle("alert", "Danger zone")}
|
|
1775
|
-
<p class="field-note">Closes every vibemate in this room and removes it from the list.
|
|
2010
|
+
<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
2011
|
<div class="row-btns start"><button class="btn danger sm" id="rp-delete">${ic("trash")}Close this room for good</button></div>
|
|
1777
2012
|
</div>`;
|
|
1778
2013
|
wireDetailsClose();
|
|
@@ -2379,9 +2614,30 @@
|
|
|
2379
2614
|
els.invAvatarPicker.innerHTML = "";
|
|
2380
2615
|
els.invAvatarPicker.appendChild(window.Avatars.pickerElement("", (emoji) => (els.invAvatar.value = emoji)));
|
|
2381
2616
|
els.invGeek.open = false;
|
|
2617
|
+
setStaffing(null);
|
|
2382
2618
|
openDialog(els.dialog);
|
|
2383
2619
|
els.invName.focus();
|
|
2384
2620
|
}
|
|
2621
|
+
const staffing = { id: null };
|
|
2622
|
+
const PERSONA_FIELDS = () => [els.invName, els.invTagline, els.invRole, els.invAvatar];
|
|
2623
|
+
function setStaffing(p) {
|
|
2624
|
+
staffing.id = p ? p.id : null;
|
|
2625
|
+
for (const f of PERSONA_FIELDS()) f.disabled = false;
|
|
2626
|
+
els.invSkills.classList.remove("locked");
|
|
2627
|
+
const lead = els.dialog.querySelector(".lead");
|
|
2628
|
+
lead.textContent = p
|
|
2629
|
+
? `${p.name} comes from the room's template. Pick the coding agent that runs it; change the vibename or the character if you like.`
|
|
2630
|
+
: "Pick a vibemate, give it a vibename and a character, and it joins the room.";
|
|
2631
|
+
}
|
|
2632
|
+
function openStaffDialog(p) {
|
|
2633
|
+
openInvite();
|
|
2634
|
+
els.invName.value = p.name;
|
|
2635
|
+
els.invTagline.value = p.tagline || "";
|
|
2636
|
+
els.invRole.value = p.role || "";
|
|
2637
|
+
els.invAvatar.value = p.avatar || "";
|
|
2638
|
+
renderSkillChecks(els.invSkills, p.skills || []);
|
|
2639
|
+
setStaffing(p);
|
|
2640
|
+
}
|
|
2385
2641
|
async function submitInvite(event) {
|
|
2386
2642
|
event.preventDefault();
|
|
2387
2643
|
if (!els.invType.value) {
|
|
@@ -2393,6 +2649,21 @@
|
|
|
2393
2649
|
els.invSubmit.classList.add("loading");
|
|
2394
2650
|
els.invError.hidden = true;
|
|
2395
2651
|
try {
|
|
2652
|
+
if (staffing.id) {
|
|
2653
|
+
await post(roomApi(`/participants/${encodeURIComponent(staffing.id)}/staff`), {
|
|
2654
|
+
agentType: els.invType.value,
|
|
2655
|
+
model: els.invModelCustom.value.trim() || els.invModel.value || null,
|
|
2656
|
+
effort: els.invEffort.value || null,
|
|
2657
|
+
mode: els.invMode.value || null,
|
|
2658
|
+
name: els.invName.value.trim(),
|
|
2659
|
+
tagline: els.invTagline.value.trim(),
|
|
2660
|
+
role: els.invRole.value.trim(),
|
|
2661
|
+
avatar: els.invAvatar.value.trim(),
|
|
2662
|
+
skills: checkedSkills(els.invSkills),
|
|
2663
|
+
});
|
|
2664
|
+
closeDialog(els.dialog);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2396
2667
|
await post(roomApi("/invite"), {
|
|
2397
2668
|
agentType: els.invType.value,
|
|
2398
2669
|
name: els.invName.value.trim(),
|
|
@@ -2471,6 +2742,88 @@
|
|
|
2471
2742
|
}
|
|
2472
2743
|
|
|
2473
2744
|
|
|
2745
|
+
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") };
|
|
2746
|
+
const tpl = { items: [], current: null, autoName: "" };
|
|
2747
|
+
async function openTemplateDialog() {
|
|
2748
|
+
tplEls.error.hidden = true;
|
|
2749
|
+
tplEls.name.value = "";
|
|
2750
|
+
tplEls.dir.value = "";
|
|
2751
|
+
tplEls.list.innerHTML = '<span class="hint">loading…</span>';
|
|
2752
|
+
tplEls.detail.innerHTML = "";
|
|
2753
|
+
openDialog(tplEls.dialog);
|
|
2754
|
+
try {
|
|
2755
|
+
tpl.items = (await (await fetch("/api/templates")).json()).templates || [];
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
tplEls.error.textContent = error.message;
|
|
2758
|
+
tplEls.error.hidden = false;
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
renderTemplateList(tpl.items[0] ? tpl.items[0].id : null);
|
|
2762
|
+
}
|
|
2763
|
+
function renderTemplateList(currentId) {
|
|
2764
|
+
tpl.current = tpl.items.find((t) => t.id === currentId) || null;
|
|
2765
|
+
tplEls.list.innerHTML = tpl.items
|
|
2766
|
+
.map((t) => {
|
|
2767
|
+
const on = tpl.current && t.id === tpl.current.id;
|
|
2768
|
+
const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: "#9ca3af" }, 20, {})).join("");
|
|
2769
|
+
return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
|
|
2770
|
+
<span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
|
|
2771
|
+
<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>
|
|
2772
|
+
<span class="tpl-check">${ic("check")}</span>
|
|
2773
|
+
</button>`;
|
|
2774
|
+
})
|
|
2775
|
+
.join("") || '<span class="hint">No templates yet.</span>';
|
|
2776
|
+
renderTemplateDetail();
|
|
2777
|
+
}
|
|
2778
|
+
tplEls.list.addEventListener("click", (e) => {
|
|
2779
|
+
const b = e.target.closest(".tpl-item");
|
|
2780
|
+
if (b) renderTemplateList(b.dataset.id);
|
|
2781
|
+
});
|
|
2782
|
+
function renderTemplateDetail() {
|
|
2783
|
+
const t = tpl.current;
|
|
2784
|
+
if (!t) return void (tplEls.detail.innerHTML = "");
|
|
2785
|
+
const rules = String((t.settings || {}).customRules || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
2786
|
+
if (!tplEls.name.value || tplEls.name.value === tpl.autoName) tplEls.name.value = t.name;
|
|
2787
|
+
tpl.autoName = t.name;
|
|
2788
|
+
tplEls.detail.innerHTML = `
|
|
2789
|
+
<p class="tpl-desc">${esc(t.description)}</p>
|
|
2790
|
+
${rules.length ? `<div class="tpl-rules"><div class="label">Room rules</div><ul>${rules.map((r) => `<li>${esc(r)}</li>`).join("")}</ul></div>` : ""}
|
|
2791
|
+
<div class="label">Vibemates</div>
|
|
2792
|
+
${t.vibemates
|
|
2793
|
+
.map((v, i) => {
|
|
2794
|
+
return `<div class="tpl-vm" data-i="${i}">
|
|
2795
|
+
<div class="tpl-vm-head"><b>${esc(v.name)}</b>${v.tagline ? `<span class="hint">"${esc(v.tagline)}"</span>` : ""}</div>
|
|
2796
|
+
${v.role ? `<div class="tpl-vm-role">${esc(v.role)}</div>` : ""}
|
|
2797
|
+
</div>`;
|
|
2798
|
+
})
|
|
2799
|
+
.join("")}`;
|
|
2800
|
+
tplEls.detail.insertAdjacentHTML("beforeend", '<p class="hint">You pick the coding agent for each vibemate in the room, right after it opens.</p>');
|
|
2801
|
+
}
|
|
2802
|
+
$("#tpl-dir-browse").addEventListener("click", () => openFolderPicker(tplEls.dir.value, (dir) => (tplEls.dir.value = dir)));
|
|
2803
|
+
tplEls.form.addEventListener("submit", async (event) => {
|
|
2804
|
+
event.preventDefault();
|
|
2805
|
+
const t = tpl.current;
|
|
2806
|
+
if (!t) return;
|
|
2807
|
+
const vibemates = t.vibemates.map((v) => ({ name: v.name, agentType: "" }));
|
|
2808
|
+
tplEls.create.disabled = true;
|
|
2809
|
+
tplEls.create.textContent = "Summoning…";
|
|
2810
|
+
try {
|
|
2811
|
+
const res = await post("/api/rooms/from-template", { template: t.id, name: tplEls.name.value, dir: tplEls.dir.value.trim() || null, vibemates });
|
|
2812
|
+
closeDialog(tplEls.dialog);
|
|
2813
|
+
if (res.room && res.room.id) {
|
|
2814
|
+
state.rooms.set(res.room.id, res.room);
|
|
2815
|
+
selectRoom(res.room.id);
|
|
2816
|
+
}
|
|
2817
|
+
for (const n of res.notices || []) toast(n, "info");
|
|
2818
|
+
} catch (error) {
|
|
2819
|
+
tplEls.error.textContent = error.message;
|
|
2820
|
+
tplEls.error.hidden = false;
|
|
2821
|
+
} finally {
|
|
2822
|
+
tplEls.create.disabled = false;
|
|
2823
|
+
tplEls.create.textContent = "Create the room";
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2474
2827
|
function openRoomDialog() {
|
|
2475
2828
|
els.roomError.hidden = true;
|
|
2476
2829
|
els.roomName.value = "";
|
|
@@ -2573,6 +2926,7 @@
|
|
|
2573
2926
|
state.recipes = snapshot.recipes || [];
|
|
2574
2927
|
state.roomDefaults = snapshot.roomDefaults || null;
|
|
2575
2928
|
state.rooms = new Map((snapshot.rooms || []).map((r) => [r.id, r]));
|
|
2929
|
+
state.openRooms = [...(snapshot.openRooms || [])];
|
|
2576
2930
|
const params = new URLSearchParams(location.search);
|
|
2577
2931
|
const wanted = params.get("room");
|
|
2578
2932
|
const remembered = recall("room");
|
|
@@ -2717,9 +3071,14 @@
|
|
|
2717
3071
|
}
|
|
2718
3072
|
renderRail();
|
|
2719
3073
|
});
|
|
3074
|
+
es.addEventListener("rooms.opened", (e) => {
|
|
3075
|
+
state.openRooms = JSON.parse(e.data).roomIds || [];
|
|
3076
|
+
renderRail();
|
|
3077
|
+
});
|
|
2720
3078
|
es.addEventListener("room.removed", (e) => {
|
|
2721
3079
|
const { roomId } = JSON.parse(e.data);
|
|
2722
3080
|
state.rooms.delete(roomId);
|
|
3081
|
+
state.openRooms = state.openRooms.filter((id) => id !== roomId);
|
|
2723
3082
|
if (state.currentRoomId === roomId) {
|
|
2724
3083
|
state.currentRoomId = null;
|
|
2725
3084
|
state.selection = { kind: "room" };
|
|
@@ -2763,9 +3122,155 @@
|
|
|
2763
3122
|
});
|
|
2764
3123
|
|
|
2765
3124
|
|
|
3125
|
+
let composerMin = Number(recall("composerH")) || 0;
|
|
3126
|
+
const composerCeiling = () => Math.max(120, els.app.clientHeight - 260);
|
|
3127
|
+
let autosizeQueued = false;
|
|
3128
|
+
function autosizeSoon() {
|
|
3129
|
+
if (autosizeQueued) return;
|
|
3130
|
+
autosizeQueued = true;
|
|
3131
|
+
requestAnimationFrame(() => {
|
|
3132
|
+
autosizeQueued = false;
|
|
3133
|
+
autosize();
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
2766
3136
|
function autosize() {
|
|
3137
|
+
const min = Math.max(36, composerMin);
|
|
3138
|
+
const cap = Math.max(180, min);
|
|
2767
3139
|
els.input.style.height = "auto";
|
|
2768
|
-
els.input.style.height = Math.min(
|
|
3140
|
+
els.input.style.height = Math.min(composerCeiling(), Math.max(min, Math.min(cap, els.input.scrollHeight))) + "px";
|
|
3141
|
+
}
|
|
3142
|
+
{
|
|
3143
|
+
const grip = $("#composer-grip");
|
|
3144
|
+
let drag = null;
|
|
3145
|
+
grip.addEventListener("pointerdown", (e) => {
|
|
3146
|
+
if (e.button !== 0) return;
|
|
3147
|
+
drag = { y: e.clientY, h: els.input.offsetHeight };
|
|
3148
|
+
grip.setPointerCapture(e.pointerId);
|
|
3149
|
+
els.composer.classList.add("resizing");
|
|
3150
|
+
e.preventDefault();
|
|
3151
|
+
});
|
|
3152
|
+
grip.addEventListener("pointermove", (e) => {
|
|
3153
|
+
if (!drag) return;
|
|
3154
|
+
composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY)));
|
|
3155
|
+
autosize();
|
|
3156
|
+
});
|
|
3157
|
+
const stop = () => {
|
|
3158
|
+
if (!drag) return;
|
|
3159
|
+
drag = null;
|
|
3160
|
+
els.composer.classList.remove("resizing");
|
|
3161
|
+
remember("composerH", composerMin);
|
|
3162
|
+
};
|
|
3163
|
+
grip.addEventListener("pointerup", stop);
|
|
3164
|
+
grip.addEventListener("pointercancel", stop);
|
|
3165
|
+
grip.addEventListener("dblclick", () => {
|
|
3166
|
+
composerMin = 0;
|
|
3167
|
+
remember("composerH", 0);
|
|
3168
|
+
autosize();
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
const SHOT_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
3173
|
+
const SHOTS_MAX = 6;
|
|
3174
|
+
let pendingShots = [];
|
|
3175
|
+
let shotSeq = 0;
|
|
3176
|
+
const shotMarker = (n) => `[img ${n}]`;
|
|
3177
|
+
|
|
3178
|
+
function renderShotsTray() {
|
|
3179
|
+
els.shotsTray.hidden = !pendingShots.length;
|
|
3180
|
+
els.shotsTray.innerHTML = pendingShots
|
|
3181
|
+
.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>`)
|
|
3182
|
+
.join("");
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
function clearShots() {
|
|
3186
|
+
pendingShots = [];
|
|
3187
|
+
shotSeq = 0;
|
|
3188
|
+
renderShotsTray();
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
function insertShotMarker(n) {
|
|
3192
|
+
const el = els.input;
|
|
3193
|
+
const value = el.value;
|
|
3194
|
+
const start = el.selectionStart ?? value.length;
|
|
3195
|
+
const end = el.selectionEnd ?? start;
|
|
3196
|
+
const before = value.slice(0, start);
|
|
3197
|
+
const after = value.slice(end);
|
|
3198
|
+
const lead = before && !/\s$/.test(before) ? " " : "";
|
|
3199
|
+
const tail = after && !/^\s/.test(after) ? " " : "";
|
|
3200
|
+
const marker = `${lead}${shotMarker(n)}${tail}`;
|
|
3201
|
+
el.value = before + marker + after;
|
|
3202
|
+
const caret = before.length + marker.length;
|
|
3203
|
+
el.setSelectionRange(caret, caret);
|
|
3204
|
+
autosize();
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function removeShotMarker(n) {
|
|
3208
|
+
const el = els.input;
|
|
3209
|
+
const pattern = new RegExp(` ?\\[img ${n}\\]`, "gi");
|
|
3210
|
+
const caret = el.selectionStart ?? el.value.length;
|
|
3211
|
+
const removedBefore = (el.value.slice(0, caret).match(pattern) || []).join("").length;
|
|
3212
|
+
el.value = el.value.replace(pattern, "");
|
|
3213
|
+
const at = Math.max(0, caret - removedBefore);
|
|
3214
|
+
el.setSelectionRange(at, at);
|
|
3215
|
+
autosize();
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
function addShotFiles(files) {
|
|
3219
|
+
const room = currentRoom();
|
|
3220
|
+
if (!room) return;
|
|
3221
|
+
for (const file of files) {
|
|
3222
|
+
if (!SHOT_TYPES.includes(file.type)) {
|
|
3223
|
+
showError(new Error(`${file.name || "that image"} is a ${file.type || "kind"} the room cannot show (png, jpeg, webp and gif only)`));
|
|
3224
|
+
continue;
|
|
3225
|
+
}
|
|
3226
|
+
if (pendingShots.length >= SHOTS_MAX) return void showError(new Error(`up to ${SHOTS_MAX} images per message`));
|
|
3227
|
+
const reader = new FileReader();
|
|
3228
|
+
const name = file.name || "";
|
|
3229
|
+
reader.onload = () => {
|
|
3230
|
+
if (pendingShots.length >= SHOTS_MAX) return void showError(new Error(`up to ${SHOTS_MAX} images per message`));
|
|
3231
|
+
const n = ++shotSeq;
|
|
3232
|
+
pendingShots.push({ n, name, mimeType: file.type, data: String(reader.result) });
|
|
3233
|
+
renderShotsTray();
|
|
3234
|
+
insertShotMarker(n);
|
|
3235
|
+
};
|
|
3236
|
+
reader.onerror = () => showError(new Error(`could not read ${name || "the image"}`));
|
|
3237
|
+
reader.readAsDataURL(file);
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
function imageFilesFrom(transfer) {
|
|
3242
|
+
if (!transfer) return [];
|
|
3243
|
+
return Array.from(transfer.files || []).filter((f) => f && f.type && f.type.startsWith("image/"));
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
els.shotsTray.addEventListener("click", (e) => {
|
|
3247
|
+
const drop = e.target.closest(".shot-drop");
|
|
3248
|
+
if (!drop) return;
|
|
3249
|
+
const [shot] = pendingShots.splice(Number(drop.dataset.i), 1);
|
|
3250
|
+
if (shot) removeShotMarker(shot.n);
|
|
3251
|
+
renderShotsTray();
|
|
3252
|
+
});
|
|
3253
|
+
els.input.addEventListener("paste", (e) => {
|
|
3254
|
+
const files = imageFilesFrom(e.clipboardData);
|
|
3255
|
+
if (!files.length) return;
|
|
3256
|
+
e.preventDefault();
|
|
3257
|
+
addShotFiles(files);
|
|
3258
|
+
});
|
|
3259
|
+
for (const target of [els.composer, els.messages]) {
|
|
3260
|
+
target.addEventListener("dragover", (e) => {
|
|
3261
|
+
if (!imageFilesFrom(e.dataTransfer).length && !(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files"))) return;
|
|
3262
|
+
e.preventDefault();
|
|
3263
|
+
els.composer.classList.add("drop-target");
|
|
3264
|
+
});
|
|
3265
|
+
target.addEventListener("dragleave", () => els.composer.classList.remove("drop-target"));
|
|
3266
|
+
target.addEventListener("drop", (e) => {
|
|
3267
|
+
const files = imageFilesFrom(e.dataTransfer);
|
|
3268
|
+
els.composer.classList.remove("drop-target");
|
|
3269
|
+
if (!files.length) return;
|
|
3270
|
+
e.preventDefault();
|
|
3271
|
+
addShotFiles(files);
|
|
3272
|
+
els.input.focus();
|
|
3273
|
+
});
|
|
2769
3274
|
}
|
|
2770
3275
|
let typingSentAt = 0;
|
|
2771
3276
|
els.input.addEventListener("input", () => {
|
|
@@ -2785,8 +3290,26 @@
|
|
|
2785
3290
|
if (e.target.closest(".reconnect-btn")) return openReconnectDialog(room);
|
|
2786
3291
|
if (e.target.closest("button")) return;
|
|
2787
3292
|
if (p.kind === "human") openDetails({ kind: "me" });
|
|
3293
|
+
else if (p.status === "unstaffed") openStaffDialog(p);
|
|
2788
3294
|
else openDetails({ kind: "participant", id: p.id });
|
|
2789
3295
|
});
|
|
3296
|
+
const castBanner = $("#cast-banner");
|
|
3297
|
+
castBanner.addEventListener("click", (e) => {
|
|
3298
|
+
const card = e.target.closest(".cast-card");
|
|
3299
|
+
const room = card && currentRoom();
|
|
3300
|
+
const p = room && findById(room, card.dataset.id);
|
|
3301
|
+
if (p && p.status === "unstaffed") openStaffDialog(p);
|
|
3302
|
+
});
|
|
3303
|
+
function updateCastGate(room) {
|
|
3304
|
+
const waiting = room ? room.participants.filter((p) => p.kind === "agent" && p.status === "unstaffed") : [];
|
|
3305
|
+
castBanner.hidden = waiting.length === 0;
|
|
3306
|
+
castBanner.innerHTML = waiting.length
|
|
3307
|
+
? `<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>`
|
|
3308
|
+
: "";
|
|
3309
|
+
els.input.disabled = waiting.length > 0;
|
|
3310
|
+
els.input.placeholder = waiting.length ? `Summon ${waiting.map((p) => p.name).join(" and ")} to start the conversation` : "Message the room… @Name or /skill";
|
|
3311
|
+
els.composer.classList.toggle("gated", waiting.length > 0);
|
|
3312
|
+
}
|
|
2790
3313
|
els.participants.addEventListener("dblclick", (e) => {
|
|
2791
3314
|
const li = e.target.closest("li[data-id]");
|
|
2792
3315
|
const p = li && findById(currentRoom(), li.dataset.id);
|
|
@@ -2796,19 +3319,25 @@
|
|
|
2796
3319
|
event.preventDefault();
|
|
2797
3320
|
const text = els.input.value.trim();
|
|
2798
3321
|
const room = currentRoom();
|
|
2799
|
-
if (!text || !room) return;
|
|
3322
|
+
if ((!text && !pendingShots.length) || !room) return;
|
|
3323
|
+
const shots = pendingShots;
|
|
2800
3324
|
els.input.value = "";
|
|
3325
|
+
clearShots();
|
|
2801
3326
|
typingSentAt = 0;
|
|
2802
3327
|
autosize();
|
|
2803
3328
|
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 };
|
|
3329
|
+
if (shots.length) local.images = shots.map((shot) => ({ file: "", name: shot.name, mimeType: shot.mimeType, bytes: 0, n: shot.n, url: shot.data }));
|
|
2804
3330
|
upsertMessage(room.id, local);
|
|
2805
3331
|
try {
|
|
2806
|
-
const r = await post(roomApi("/send"), { text });
|
|
2807
|
-
|
|
3332
|
+
const r = await post(roomApi("/send"), { text, images: shots });
|
|
3333
|
+
if (r.command) removeMessage(room.id, local.id);
|
|
3334
|
+
else adoptLocalMessage(room.id, local.id, r.id);
|
|
2808
3335
|
} catch (error) {
|
|
2809
3336
|
removeMessage(room.id, local.id);
|
|
2810
3337
|
showError(error);
|
|
2811
3338
|
els.input.value = text;
|
|
3339
|
+
pendingShots = shots;
|
|
3340
|
+
renderShotsTray();
|
|
2812
3341
|
}
|
|
2813
3342
|
});
|
|
2814
3343
|
function adoptLocalMessage(roomId, localId, realId) {
|
|
@@ -2979,6 +3508,7 @@
|
|
|
2979
3508
|
});
|
|
2980
3509
|
}
|
|
2981
3510
|
|
|
3511
|
+
const ALL_MENTION = { id: "all", name: "All", kind: "all", tagline: "every vibemate in the room" };
|
|
2982
3512
|
function attachMentions(textarea, menuEl, options) {
|
|
2983
3513
|
const opts = options || {};
|
|
2984
3514
|
const m = { open: false, items: [], index: 0, start: -1 };
|
|
@@ -3001,6 +3531,8 @@
|
|
|
3001
3531
|
if (!ctx || !room) return close();
|
|
3002
3532
|
const q = ctx.prefix.toLowerCase();
|
|
3003
3533
|
const items = room.participants.filter((p) => (opts.includeHuman || p.id !== "human") && p.name.toLowerCase().startsWith(q));
|
|
3534
|
+
const agents = room.participants.filter((p) => p.kind === "agent" && p.status !== "left").length;
|
|
3535
|
+
if (!opts.includeHuman && agents > 1 && "all".startsWith(q)) items.unshift(ALL_MENTION);
|
|
3004
3536
|
if (!items.length) return close();
|
|
3005
3537
|
m.open = true;
|
|
3006
3538
|
m.items = items;
|
|
@@ -3011,7 +3543,7 @@
|
|
|
3011
3543
|
const b = document.createElement("button");
|
|
3012
3544
|
b.type = "button";
|
|
3013
3545
|
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>`;
|
|
3546
|
+
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
3547
|
b.addEventListener("mousedown", (e) => {
|
|
3016
3548
|
e.preventDefault();
|
|
3017
3549
|
pick(i);
|
|
@@ -3053,7 +3585,7 @@
|
|
|
3053
3585
|
}
|
|
3054
3586
|
});
|
|
3055
3587
|
}
|
|
3056
|
-
attachMentions(els.input, els.mentionMenu, { onChange:
|
|
3588
|
+
attachMentions(els.input, els.mentionMenu, { onChange: autosizeSoon });
|
|
3057
3589
|
attachSlashMenu(els.input, els.mentionMenu);
|
|
3058
3590
|
els.input.addEventListener("keydown", (event) => {
|
|
3059
3591
|
if (event.defaultPrevented) return;
|
|
@@ -3094,15 +3626,16 @@
|
|
|
3094
3626
|
else openDetails({ kind: "me" });
|
|
3095
3627
|
return;
|
|
3096
3628
|
}
|
|
3097
|
-
if (nav === "room") {
|
|
3098
|
-
if (currentRoom()) selectRoom(state.currentRoomId, { keepDetails: true });
|
|
3099
|
-
return;
|
|
3100
|
-
}
|
|
3101
3629
|
setView(nav);
|
|
3102
3630
|
remember("view", nav);
|
|
3103
3631
|
});
|
|
3104
3632
|
});
|
|
3105
3633
|
els.railToggle.addEventListener("click", () => setRailOpen(!els.app.classList.contains("rail-open")));
|
|
3634
|
+
els.railRooms.addEventListener("click", (e) => {
|
|
3635
|
+
const b = e.target.closest(".rail-room");
|
|
3636
|
+
if (b) selectRoom(b.dataset.room, { keepDetails: true });
|
|
3637
|
+
});
|
|
3638
|
+
els.railRooms.addEventListener("animationend", (e) => e.target.classList.remove("bump"));
|
|
3106
3639
|
function setSideOpen(open) {
|
|
3107
3640
|
els.app.classList.toggle("side-collapsed", !open);
|
|
3108
3641
|
remember("sideOpen", open ? "1" : "0");
|
|
@@ -3110,12 +3643,22 @@
|
|
|
3110
3643
|
els.sideToggle.innerHTML = ic(open ? "collapse" : "expand");
|
|
3111
3644
|
}
|
|
3112
3645
|
els.sideToggle.addEventListener("click", () => setSideOpen(els.app.classList.contains("side-collapsed")));
|
|
3646
|
+
let lastScrollTop = 0;
|
|
3113
3647
|
els.messages.addEventListener("scroll", () => {
|
|
3114
|
-
els.
|
|
3648
|
+
const top = els.messages.scrollTop;
|
|
3649
|
+
if (nearBottom()) stuck = true;
|
|
3650
|
+
else if (top < lastScrollTop - 1) stuck = false;
|
|
3651
|
+
lastScrollTop = top;
|
|
3652
|
+
els.jumpLatest.hidden = stuck;
|
|
3115
3653
|
updateTimelineView();
|
|
3654
|
+
updateWorkingNow();
|
|
3655
|
+
});
|
|
3656
|
+
els.jumpLatest.addEventListener("click", scrollToBottom);
|
|
3657
|
+
document.addEventListener("mousedown", (e) => {
|
|
3658
|
+
if (!state.detailsOpen) return;
|
|
3659
|
+
if (e.target.closest("#details, #side, .rail, .chat-actions, dialog, .mention-menu, .lightbox, .tl-pop, #pins-panel")) return;
|
|
3660
|
+
closeDetails();
|
|
3116
3661
|
});
|
|
3117
|
-
els.jumpLatest.addEventListener("click", () => els.messages.scrollTo({ top: els.messages.scrollHeight, behavior: "smooth" }));
|
|
3118
|
-
els.detailsHandle.addEventListener("click", closeDetails);
|
|
3119
3662
|
setSideOpen(recall("sideOpen") !== "0");
|
|
3120
3663
|
$("#rail-logo").addEventListener("click", () => setView("home"));
|
|
3121
3664
|
$("#rail-new-room").addEventListener("click", openRoomDialog);
|
|
@@ -3229,83 +3772,239 @@
|
|
|
3229
3772
|
});
|
|
3230
3773
|
window.addEventListener("beforeunload", () => remember("view", state.view));
|
|
3231
3774
|
|
|
3232
|
-
const tl = { el: $("#timeline"), ticks: $("#timeline .tl-ticks"), view: $("#timeline .tl-view"), pop: $("#timeline .tl-pop"), items: [] };
|
|
3233
3775
|
const TICK_H = 5;
|
|
3234
|
-
function
|
|
3235
|
-
const
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
const
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3776
|
+
function createTimeline(root, pick, opts) {
|
|
3777
|
+
const t = { el: root, ticks: root.querySelector(".tl-ticks"), view: root.querySelector(".tl-view"), pop: root.querySelector(".tl-pop"), items: [] };
|
|
3778
|
+
function render() {
|
|
3779
|
+
const room = currentRoom();
|
|
3780
|
+
const nodes = room && state.view === "room" ? pick(room) : [];
|
|
3781
|
+
t.items = nodes;
|
|
3782
|
+
t.el.hidden = nodes.length === 0;
|
|
3783
|
+
t.pop.hidden = true;
|
|
3784
|
+
if (!nodes.length) return;
|
|
3785
|
+
const total = els.messages.scrollHeight || 1;
|
|
3786
|
+
const h = Math.max(0, t.ticks.clientHeight - TICK_H);
|
|
3787
|
+
const tops = nodes.map((el) => el.offsetTop);
|
|
3788
|
+
const frag = document.createDocumentFragment();
|
|
3789
|
+
nodes.forEach((el, i) => {
|
|
3790
|
+
const tick = document.createElement("div");
|
|
3791
|
+
const pinned = el.classList.contains("pinned");
|
|
3792
|
+
tick.className = `tl-tick${pinned ? " pinned i i-pin" : ""}`;
|
|
3793
|
+
tick.dataset.i = i;
|
|
3794
|
+
if (opts.colorOf) tick.style.setProperty("--tick", opts.colorOf(room, el));
|
|
3795
|
+
tick.style.top = `${Math.round((tops[i] / total) * h)}px`;
|
|
3796
|
+
frag.appendChild(tick);
|
|
3797
|
+
});
|
|
3798
|
+
t.ticks.replaceChildren(frag);
|
|
3799
|
+
updateView();
|
|
3800
|
+
}
|
|
3801
|
+
function updateView() {
|
|
3802
|
+
if (t.el.hidden) return;
|
|
3803
|
+
const m = els.messages;
|
|
3804
|
+
const total = m.scrollHeight || 1;
|
|
3805
|
+
const h = t.ticks.clientHeight;
|
|
3806
|
+
t.view.style.top = `${(m.scrollTop / total) * h}px`;
|
|
3807
|
+
t.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
|
|
3808
|
+
const top = m.scrollTop;
|
|
3809
|
+
const bottom = m.scrollTop + m.clientHeight;
|
|
3810
|
+
const inView = t.items.map((el) => el.offsetTop + el.offsetHeight > top && el.offsetTop < bottom);
|
|
3811
|
+
inView.forEach((on, i) => {
|
|
3812
|
+
const tick = t.ticks.children[i];
|
|
3813
|
+
if (tick) tick.classList.toggle("in-view", on);
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3816
|
+
function rowHtml(k, cls) {
|
|
3817
|
+
const el = t.items[k];
|
|
3818
|
+
const av = opts.avatarOf ? `<span class="tl-av">${opts.avatarOf(currentRoom(), el)}</span>` : "";
|
|
3819
|
+
const pinned = el.classList.contains("pinned");
|
|
3820
|
+
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>`;
|
|
3821
|
+
}
|
|
3822
|
+
function showPop(i) {
|
|
3823
|
+
const rows = [[i - 2, "faded far"], [i - 1, "faded"], [i, "current"], [i + 1, "faded"], [i + 2, "faded far"]].filter(([k]) => t.items[k]);
|
|
3824
|
+
t.pop.innerHTML = rows.map(([k, c]) => rowHtml(k, c)).join("");
|
|
3825
|
+
t.pop.hidden = false;
|
|
3826
|
+
t.ticks.querySelectorAll(".tl-tick.active").forEach((x) => x.classList.remove("active"));
|
|
3827
|
+
const tick = t.ticks.children[i];
|
|
3828
|
+
if (tick) tick.classList.add("active");
|
|
3829
|
+
const current = t.pop.querySelector(".tl-row.current");
|
|
3830
|
+
let top = (tick ? tick.offsetTop : 0) - (current ? current.offsetTop + current.offsetHeight / 2 : 20) + TICK_H / 2;
|
|
3831
|
+
top = Math.max(0, Math.min(top, t.el.clientHeight - t.pop.offsetHeight));
|
|
3832
|
+
t.pop.style.top = `${top}px`;
|
|
3833
|
+
}
|
|
3834
|
+
function hidePop() {
|
|
3835
|
+
t.pop.hidden = true;
|
|
3836
|
+
t.ticks.querySelectorAll(".tl-tick.active").forEach((x) => x.classList.remove("active"));
|
|
3837
|
+
}
|
|
3838
|
+
t.ticks.addEventListener("mouseover", (e) => {
|
|
3839
|
+
const tick = e.target.closest(".tl-tick");
|
|
3840
|
+
if (tick) showPop(Number(tick.dataset.i));
|
|
3251
3841
|
});
|
|
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);
|
|
3842
|
+
let hideTimer = 0;
|
|
3843
|
+
t.el.addEventListener("mouseleave", () => {
|
|
3844
|
+
clearTimeout(hideTimer);
|
|
3845
|
+
hideTimer = setTimeout(hidePop, 320);
|
|
3846
|
+
});
|
|
3847
|
+
t.el.addEventListener("mouseenter", () => clearTimeout(hideTimer));
|
|
3848
|
+
t.ticks.addEventListener("click", (e) => {
|
|
3849
|
+
const tick = e.target.closest(".tl-tick");
|
|
3850
|
+
if (tick) jumpToMessage(t.items[Number(tick.dataset.i)]);
|
|
3266
3851
|
});
|
|
3852
|
+
t.pop.addEventListener("click", (e) => {
|
|
3853
|
+
const row = e.target.closest(".tl-row");
|
|
3854
|
+
if (row) jumpToMessage(t.items[Number(row.dataset.i)]);
|
|
3855
|
+
});
|
|
3856
|
+
return { render, updateView };
|
|
3267
3857
|
}
|
|
3268
3858
|
function timelineText(el) {
|
|
3269
3859
|
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
|
-
|
|
3860
|
+
return (t ? t.textContent : el.textContent).trim().replace(/\s+/g, " ").slice(0, 240);
|
|
3861
|
+
}
|
|
3862
|
+
const doneNotes = [];
|
|
3863
|
+
function bubbleInView(id) {
|
|
3864
|
+
const head = els.messages.querySelector(`.msg[data-id="${id}"] .head`);
|
|
3865
|
+
if (!head) return false;
|
|
3866
|
+
const box = els.messages.getBoundingClientRect();
|
|
3867
|
+
const r = head.getBoundingClientRect();
|
|
3868
|
+
return r.bottom > box.top && r.top < box.bottom;
|
|
3869
|
+
}
|
|
3870
|
+
function noteFinished(room, m) {
|
|
3871
|
+
if (room.id !== state.currentRoomId || state.view !== "room") return;
|
|
3872
|
+
requestAnimationFrame(() => {
|
|
3873
|
+
if (bubbleInView(m.id) || doneNotes.some((n) => n.id === m.id)) return;
|
|
3874
|
+
const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: "agent" };
|
|
3875
|
+
doneNotes.push({ id: m.id, name: p.name, p, startedAt: m.ts, durationMs: m.durationMs || 0 });
|
|
3876
|
+
while (doneNotes.length > 3) doneNotes.shift();
|
|
3877
|
+
renderDoneNotes();
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
function spanText(ms) {
|
|
3881
|
+
if (!ms) return "";
|
|
3882
|
+
const s = Math.round(ms / 1000);
|
|
3883
|
+
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, "0")} s`;
|
|
3287
3884
|
}
|
|
3885
|
+
function renderDoneNotes() {
|
|
3886
|
+
els.doneNotes.hidden = doneNotes.length === 0;
|
|
3887
|
+
els.doneNotes.innerHTML = doneNotes
|
|
3888
|
+
.map((n) => `<div class="done-note" data-id="${esc(n.id)}"><button type="button" class="done-go" title="Go to the reply">${avatar(n.p, 18, {})}<span class="done-text"><b>${esc(n.name)} finished</b><small>started ${esc(time(n.startedAt))}${n.durationMs ? ` · ${esc(spanText(n.durationMs))}` : ""}</small></span></button><button type="button" class="done-x" title="Dismiss">×</button></div>`)
|
|
3889
|
+
.join("");
|
|
3890
|
+
}
|
|
3891
|
+
els.doneNotes.addEventListener("click", (e) => {
|
|
3892
|
+
const note = e.target.closest(".done-note");
|
|
3893
|
+
if (!note) return;
|
|
3894
|
+
if (e.target.closest(".done-x")) {
|
|
3895
|
+
const i = doneNotes.findIndex((n) => n.id === note.dataset.id);
|
|
3896
|
+
if (i >= 0) doneNotes.splice(i, 1);
|
|
3897
|
+
renderDoneNotes();
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
jumpToMessage(els.messages.querySelector(`.msg[data-id="${note.dataset.id}"]`));
|
|
3901
|
+
});
|
|
3288
3902
|
function jumpToMessage(el) {
|
|
3289
3903
|
if (!el) return;
|
|
3290
|
-
el.scrollIntoView({ block: "center"
|
|
3904
|
+
el.scrollIntoView({ block: "center" });
|
|
3905
|
+
requestAnimationFrame(() => el.scrollIntoView({ block: "center" }));
|
|
3291
3906
|
el.classList.remove("flash");
|
|
3292
3907
|
void el.offsetWidth;
|
|
3293
3908
|
el.classList.add("flash");
|
|
3294
3909
|
}
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3910
|
+
const authorOf = (room, el) => findById(room, el.dataset.from);
|
|
3911
|
+
const timelines = [
|
|
3912
|
+
createTimeline($("#timeline"), () => [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")], {}),
|
|
3913
|
+
createTimeline($("#timeline-left"), () => [...els.messages.querySelectorAll(".msg.agent:not(.hidden-by-search)")], {
|
|
3914
|
+
colorOf: (room, el) => (authorOf(room, el) || {}).color || "#9ca3af",
|
|
3915
|
+
avatarOf: (room, el) => { const p = authorOf(room, el); return p ? avatar(p, 16, {}) : ""; },
|
|
3916
|
+
}),
|
|
3917
|
+
];
|
|
3918
|
+
function renderTimeline() {
|
|
3919
|
+
for (const t of timelines) t.render();
|
|
3920
|
+
renderPins();
|
|
3921
|
+
}
|
|
3922
|
+
function updateTimelineView() { for (const t of timelines) t.updateView(); }
|
|
3923
|
+
new ResizeObserver(() => {
|
|
3924
|
+
els.app.style.setProperty("--composer-h", `${els.composer.offsetHeight}px`);
|
|
3925
|
+
renderTimeline();
|
|
3926
|
+
}).observe(els.composer);
|
|
3927
|
+
new ResizeObserver(() => renderTimeline()).observe(els.messages);
|
|
3928
|
+
|
|
3929
|
+
const workingNow = $("#working-now");
|
|
3930
|
+
function renderWorkingNow() {
|
|
3931
|
+
const room = currentRoom();
|
|
3932
|
+
const working = room && state.view === "room" ? room.participants.filter((p) => p.kind === "agent" && p.status === "thinking") : [];
|
|
3933
|
+
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("");
|
|
3934
|
+
updateWorkingNow();
|
|
3935
|
+
}
|
|
3936
|
+
function updateWorkingNow() {
|
|
3937
|
+
const room = currentRoom();
|
|
3938
|
+
const buttons = [...workingNow.querySelectorAll(".wn-av")];
|
|
3939
|
+
if (!room || !buttons.length) return void (workingNow.hidden = true);
|
|
3940
|
+
const box = els.messages.getBoundingClientRect();
|
|
3941
|
+
let shown = 0;
|
|
3942
|
+
for (const b of buttons) {
|
|
3943
|
+
const draft = room.messages.find((x) => x.from === b.dataset.id && x.streaming);
|
|
3944
|
+
const head = draft && els.messages.querySelector(`.msg[data-id="${draft.id}"] .head`);
|
|
3945
|
+
let show = false;
|
|
3946
|
+
if (head) {
|
|
3947
|
+
const r = head.getBoundingClientRect();
|
|
3948
|
+
show = !(r.bottom > box.top && r.top < box.bottom);
|
|
3949
|
+
}
|
|
3950
|
+
b.hidden = !show;
|
|
3951
|
+
if (show) shown++;
|
|
3952
|
+
}
|
|
3953
|
+
workingNow.hidden = shown === 0;
|
|
3954
|
+
}
|
|
3955
|
+
workingNow.addEventListener("click", (e) => {
|
|
3956
|
+
const b = e.target.closest(".wn-av");
|
|
3957
|
+
const room = b && currentRoom();
|
|
3958
|
+
if (!room) return;
|
|
3959
|
+
const m = [...room.messages].reverse().find((x) => x.from === b.dataset.id && x.kind === "chat");
|
|
3960
|
+
const el = m && els.messages.querySelector(`.msg[data-id="${m.id}"]`);
|
|
3961
|
+
if (el && m.streaming) jumpToMessage(el);
|
|
3962
|
+
else scrollToBottom();
|
|
3963
|
+
});
|
|
3964
|
+
|
|
3965
|
+
const pinsBtn = $("#pins-btn");
|
|
3966
|
+
const pinsPanel = $("#pins-panel");
|
|
3967
|
+
function pinnedMessages(room) {
|
|
3968
|
+
return room.messages.filter((m) => m.pinned && m.kind === "chat").sort((a, b) => a.seq - b.seq);
|
|
3969
|
+
}
|
|
3970
|
+
function renderPins() {
|
|
3971
|
+
const room = currentRoom();
|
|
3972
|
+
const pins = room && state.view === "room" ? pinnedMessages(room) : [];
|
|
3973
|
+
pinsBtn.hidden = pins.length === 0;
|
|
3974
|
+
pinsBtn.innerHTML = `${ic("pin")} Pinned · ${pins.length}`;
|
|
3975
|
+
if (!pins.length) pinsPanel.hidden = true;
|
|
3976
|
+
if (pinsPanel.hidden) return;
|
|
3977
|
+
pinsPanel.innerHTML = pins
|
|
3978
|
+
.map((m) => {
|
|
3979
|
+
const p = findById(room, m.from);
|
|
3980
|
+
const who = m.from === "human" ? Object.assign(meAvatarData(), { color: (p || {}).color }) : p;
|
|
3981
|
+
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"}]` : "");
|
|
3982
|
+
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>`;
|
|
3983
|
+
})
|
|
3984
|
+
.join("");
|
|
3985
|
+
}
|
|
3986
|
+
pinsBtn.addEventListener("click", () => {
|
|
3987
|
+
pinsPanel.hidden = !pinsPanel.hidden;
|
|
3988
|
+
renderPins();
|
|
3298
3989
|
});
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
const
|
|
3302
|
-
if (
|
|
3990
|
+
pinsPanel.addEventListener("click", async (e) => {
|
|
3991
|
+
const row = e.target.closest(".pin-row");
|
|
3992
|
+
const room = currentRoom();
|
|
3993
|
+
if (!row || !room) return;
|
|
3994
|
+
if (e.target.closest(".pin-x")) {
|
|
3995
|
+
try {
|
|
3996
|
+
await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(row.dataset.id)}/pin`, { pinned: false });
|
|
3997
|
+
} catch (error) {
|
|
3998
|
+
showError(error);
|
|
3999
|
+
}
|
|
4000
|
+
return;
|
|
4001
|
+
}
|
|
4002
|
+
jumpToMessage(els.messages.querySelector(`.msg[data-id="${row.dataset.id}"]`));
|
|
4003
|
+
pinsPanel.hidden = true;
|
|
3303
4004
|
});
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
if (row) jumpToMessage(tl.items[Number(row.dataset.i)]);
|
|
4005
|
+
document.addEventListener("click", (e) => {
|
|
4006
|
+
if (!pinsPanel.hidden && !e.target.closest("#pins-panel, #pins-btn")) pinsPanel.hidden = true;
|
|
3307
4007
|
});
|
|
3308
|
-
new ResizeObserver(() => renderTimeline()).observe(els.messages);
|
|
3309
4008
|
|
|
3310
4009
|
const fp = { onChoose: null, selected: "", roots: [], home: "" };
|
|
3311
4010
|
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") };
|