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/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
- railRoom: $("#rail-room"),
34
- railRoomLabel: $("#rail-room-label"),
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
- detailsHandle: $("#details-handle"),
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
- if (!md) return renderTextLight(room, text);
322
- return decorate(room, md.parse(String(text == null ? "" : text)));
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
- const room = currentRoom();
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("plus")}Open a room</button>
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", openRoomDialog);
938
- $("#home-rooms").addEventListener("click", () => setView("rooms"));
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 className = (p.kind === "human" ? "me" : "") + (selected ? " selected" : "") + (asleep ? " offline" : "");
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 = asleep
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 chip = document.createElement("span");
1205
- chip.className = `chip chip-${call.status || "pending"}`;
1206
- chip.title = call.rawInput ? JSON.stringify(call.rawInput, null, 1).slice(0, 800) : "";
1207
- chip.innerHTML = `${ic("tool")}${esc(`${call.title}${call.kind ? ` · ${call.kind}` : ""} · ${call.status || "pending"}`)}`;
1208
- tools.appendChild(chip);
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 seen = room.participants.filter((p) => p.kind === "agent" && p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
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
- if (idx >= 0) Object.assign(room.messages[idx], m);
1314
- else room.messages.push(m);
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 (idx < 0 && m.kind === "chat" && !m.streaming && m.from !== "human" && roomId !== state.currentRoomId) state.unread.set(roomId, (state.unread.get(roomId) || 0) + 1);
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 = nearBottom();
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
- const el = els.messages.querySelector(`.msg[data-id="${id}"]`);
1355
- const stick = nearBottom();
1356
- if (el) updateMessageElement(el, room, m);
1357
- if (stick) scrollToBottom();
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. The history file stays on disk.</p>
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(180, els.input.scrollHeight) + "px";
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
- adoptLocalMessage(room.id, local.id, r.id);
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: autosize });
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.jumpLatest.hidden = nearBottom();
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 renderTimeline() {
3235
- const room = currentRoom();
3236
- const nodes = room && state.view === "room" ? [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")] : [];
3237
- tl.items = nodes;
3238
- tl.el.hidden = nodes.length === 0;
3239
- tl.pop.hidden = true;
3240
- if (!nodes.length) return;
3241
- const total = els.messages.scrollHeight || 1;
3242
- const h = Math.max(0, tl.ticks.clientHeight - TICK_H);
3243
- tl.ticks.innerHTML = "";
3244
- nodes.forEach((el, i) => {
3245
- const t = document.createElement("div");
3246
- t.className = "tl-tick";
3247
- t.dataset.i = i;
3248
- t.title = "";
3249
- t.style.top = `${Math.round((el.offsetTop / total) * h)}px`;
3250
- tl.ticks.appendChild(t);
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
- updateTimelineView();
3253
- }
3254
- function updateTimelineView() {
3255
- if (tl.el.hidden) return;
3256
- const m = els.messages;
3257
- const total = m.scrollHeight || 1;
3258
- const h = tl.ticks.clientHeight;
3259
- tl.view.style.top = `${(m.scrollTop / total) * h}px`;
3260
- tl.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
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.innerText : el.innerText).trim().replace(/\s+/g, " ").slice(0, 240);
3271
- }
3272
- function showTimelinePop(i) {
3273
- const rows = [[i - 2, "faded far"], [i - 1, "faded"], [i, "current"], [i + 1, "faded"], [i + 2, "faded far"]].filter(([k]) => tl.items[k]);
3274
- tl.pop.innerHTML = rows.map(([k, c]) => `<div class="tl-row ${c}" data-i="${k}">${esc(timelineText(tl.items[k]))}</div>`).join("");
3275
- tl.pop.hidden = false;
3276
- tl.ticks.querySelectorAll(".tl-tick.active").forEach((t) => t.classList.remove("active"));
3277
- const tick = tl.ticks.children[i];
3278
- if (tick) tick.classList.add("active");
3279
- const current = tl.pop.querySelector(".tl-row.current");
3280
- let top = (tick ? tick.offsetTop : 0) - (current ? current.offsetTop + current.offsetHeight / 2 : 20) + TICK_H / 2;
3281
- top = Math.max(0, Math.min(top, tl.el.clientHeight - tl.pop.offsetHeight));
3282
- tl.pop.style.top = `${top}px`;
3283
- }
3284
- function hideTimelinePop() {
3285
- tl.pop.hidden = true;
3286
- tl.ticks.querySelectorAll(".tl-tick.active").forEach((t) => t.classList.remove("active"));
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", behavior: "smooth" });
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
- tl.ticks.addEventListener("mouseover", (e) => {
3296
- const tick = e.target.closest(".tl-tick");
3297
- if (tick) showTimelinePop(Number(tick.dataset.i));
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
- tl.el.addEventListener("mouseleave", hideTimelinePop);
3300
- tl.ticks.addEventListener("click", (e) => {
3301
- const tick = e.target.closest(".tl-tick");
3302
- if (tick) jumpToMessage(tl.items[Number(tick.dataset.i)]);
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
- tl.pop.addEventListener("click", (e) => {
3305
- const row = e.target.closest(".tl-row");
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") };