viberoom 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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);
@@ -1122,6 +1210,12 @@
1122
1210
  }
1123
1211
  if (m.kind === "system") {
1124
1212
  el.className = "msg system";
1213
+ if (m.audience === "human") {
1214
+ el.className = "msg system done";
1215
+ const who = m.details && m.details.agentId ? findById(room, m.details.agentId) : null;
1216
+ el.innerHTML = `<button type="button" class="done-row" data-ref="${esc((m.details && m.details.refId) || "")}" title="${esc(fullTime(m.ts))} · go to the reply">${who ? avatar(who, 20, {}) : ""}<span>${esc(m.text)}</span></button>`;
1217
+ return el;
1218
+ }
1125
1219
  if (m.audience === "agents") {
1126
1220
  el.className = "msg hidden";
1127
1221
  el.innerHTML = `<details class="hidden-turn"><summary>${ic("info")} hub → vibemates · ${esc(m.text.split(":")[0])}</summary><div class="hidden-body"><div class="hidden-label">What the vibemates were told</div><div class="hidden-text">${esc(m.text)}</div></div></details>`;
@@ -1138,9 +1232,8 @@
1138
1232
  const mine = m.from === "human";
1139
1233
  el.className = "msg " + (mine ? "mine" : "agent");
1140
1234
  el.innerHTML = `
1141
- ${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 36, { vendor: true })}
1142
1235
  <div class="bubble-col">
1143
- <div class="head"><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? `<button type="button" class="edit-btn" title="Edit this message">${ic("pencil")}</button>` : ""}<span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
1236
+ <div class="head"><span class="head-av">${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 32, { vendor: true })}</span><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? `<button type="button" class="edit-btn" title="Edit this message">${ic("pencil")}</button>` : ""}<button type="button" class="pin-btn" title="Pin this message">${ic("pin")}</button><span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
1144
1237
  <div class="bubble">
1145
1238
  ${m.skill ? `<div class="skill-invoke" title="skill invocation: the vibemates that have this skill got its instructions with this message">${ic("skills")} skill <b>${esc(m.skill.name)}</b></div>` : ""}
1146
1239
  <div class="edit-box" hidden></div>
@@ -1149,8 +1242,10 @@
1149
1242
  <div class="tools"></div>
1150
1243
  <div class="plan" hidden></div>
1151
1244
  <div class="text"></div>
1245
+ <div class="shots"></div>
1152
1246
  <button class="more" hidden></button>
1153
1247
  <div class="perms"></div>
1248
+ <div class="waiting" hidden></div>
1154
1249
  </div>
1155
1250
  <div class="meta"></div>
1156
1251
  </div>`;
@@ -1161,12 +1256,108 @@
1161
1256
  });
1162
1257
  const editBtn = el.querySelector(".edit-btn");
1163
1258
  if (editBtn) editBtn.addEventListener("click", () => openInlineEditor(el, room, m));
1259
+ el.querySelector(".pin-btn").addEventListener("click", async () => {
1260
+ if (m.pending) return;
1261
+ try {
1262
+ await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(m.id)}/pin`, { pinned: !m.pinned });
1263
+ } catch (error) {
1264
+ showError(error);
1265
+ }
1266
+ });
1164
1267
  updateMessageElement(el, room, m);
1165
1268
  return el;
1166
1269
  }
1167
1270
 
1271
+ function shotUrl(roomId, image) {
1272
+ if (image.url) return image.url;
1273
+ return `/api/rooms/${encodeURIComponent(roomId)}/files/${encodeURIComponent(image.file)}`;
1274
+ }
1275
+
1276
+ function renderShots(box, room, m) {
1277
+ if (!box) return;
1278
+ const images = m.images || [];
1279
+ box.hidden = !images.length;
1280
+ if (!images.length) return void (box.innerHTML = "");
1281
+ box.innerHTML = images
1282
+ .map((image, i) => `<button type="button" class="shot" data-n="${image.n || i + 1}" data-src="${esc(shotUrl(room.id, image))}" title="${esc(image.name)}"><img src="${esc(shotUrl(room.id, image))}" alt="${esc(image.name)}"><span class="shot-n">${image.n || i + 1}</span></button>`)
1283
+ .join("");
1284
+ }
1285
+
1286
+ function openLightbox(src, title) {
1287
+ els.lightbox.querySelector("img").src = src;
1288
+ els.lightbox.querySelector("img").alt = title || "";
1289
+ els.lightbox.hidden = false;
1290
+ }
1291
+ function closeLightbox() {
1292
+ els.lightbox.hidden = true;
1293
+ els.lightbox.querySelector("img").src = "";
1294
+ }
1295
+ els.lightbox.addEventListener("click", closeLightbox);
1296
+ document.addEventListener("keydown", (e) => {
1297
+ if (e.key === "Escape" && !els.lightbox.hidden) closeLightbox();
1298
+ });
1299
+ els.messages.addEventListener("click", (e) => {
1300
+ const done = e.target.closest(".done-row");
1301
+ if (done) return void jumpToMessage(els.messages.querySelector(`.msg[data-id="${done.dataset.ref}"]`));
1302
+ const shot = e.target.closest(".shot");
1303
+ if (shot) return void openLightbox(shot.dataset.src, shot.title);
1304
+ const ref = e.target.closest(".img-ref");
1305
+ if (!ref) return;
1306
+ const msg = ref.closest(".msg");
1307
+ const target = msg && msg.querySelector(`.shot[data-n="${ref.dataset.n}"]`);
1308
+ if (target) openLightbox(target.dataset.src, target.title);
1309
+ });
1310
+
1311
+ function waitingFor(room, m) {
1312
+ if (m.from !== "human" || m.kind !== "chat" || !m.to || !m.to.length || m.pending) return [];
1313
+ return m.to.map((id) => findById(room, id)).filter((p) => p && p.kind === "agent" && p.status === "thinking" && p.lastSeenSeq != null && p.lastSeenSeq < m.seq);
1314
+ }
1315
+
1316
+ function renderWaiting(el, room, m) {
1317
+ const box = el.querySelector(".waiting");
1318
+ if (!box) return;
1319
+ const agents = waitingFor(room, m);
1320
+ el.classList.toggle("waiting", agents.length > 0);
1321
+ box.hidden = !agents.length;
1322
+ if (!agents.length) return void (box.innerHTML = "");
1323
+ const names = agents.map((p) => p.name);
1324
+ const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
1325
+ const verb = names.length === 1 ? "is" : "are";
1326
+ const stopLabel = names.length === 1 ? `Stop ${names[0]} and send now` : "Stop them and send now";
1327
+ box.innerHTML = `<span class="waiting-text">${ic("clock")} ${esc(who)} ${verb} still working — this arrives when the current turn ends.</span><button type="button" class="waiting-stop">${esc(stopLabel)}</button>`;
1328
+ box.querySelector(".waiting-stop").addEventListener("click", async (e) => {
1329
+ const btn = e.currentTarget;
1330
+ btn.disabled = true;
1331
+ btn.textContent = "Stopping…";
1332
+ try {
1333
+ await Promise.all(agents.map((p) => post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`))));
1334
+ } catch (error) {
1335
+ showError(error);
1336
+ btn.disabled = false;
1337
+ btn.textContent = stopLabel;
1338
+ }
1339
+ });
1340
+ }
1341
+
1342
+ const openTools = new Set();
1343
+ els.messages.addEventListener("click", (e) => {
1344
+ const chip = e.target.closest(".tool > .chip");
1345
+ if (!chip) return;
1346
+ const msgEl = chip.closest(".msg");
1347
+ const room = currentRoom();
1348
+ const m = room && msgEl && room.messages.find((x) => x.id === msgEl.dataset.id);
1349
+ if (!m) return;
1350
+ if (openTools.has(chip.dataset.tool)) openTools.delete(chip.dataset.tool);
1351
+ else openTools.add(chip.dataset.tool);
1352
+ updateMessageElement(msgEl, room, m);
1353
+ });
1168
1354
  function updateMessageElement(el, room, m) {
1169
1355
  el.classList.toggle("hidden-by-search", !messageMatches(m));
1356
+ const wasPinned = el.classList.contains("pinned");
1357
+ el.classList.toggle("pinned", !!m.pinned);
1358
+ const pinBtn = el.querySelector(".pin-btn");
1359
+ if (pinBtn) pinBtn.title = m.pinned ? "Unpin this message" : "Pin this message";
1360
+ if (wasPinned !== !!m.pinned) renderTimeline();
1170
1361
  const editedEl = el.querySelector(".edited");
1171
1362
  if (editedEl) {
1172
1363
  editedEl.hidden = !m.edited;
@@ -1186,12 +1377,14 @@
1186
1377
  const more = el.querySelector(".more");
1187
1378
  const long = !m.streaming && m.text.length > CLAMP_CHARS;
1188
1379
  const expanded = state.expanded.has(m.id);
1189
- text.innerHTML = renderText(room, m.text) + (m.streaming ? '<span class="caret"></span>' : "");
1380
+ text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? '<span class="caret"></span>' : "");
1190
1381
  if (!m.streaming) renderDiagrams(text);
1191
1382
  if (m.streaming && !m.text) text.innerHTML = '<span class="pending">…</span>';
1192
1383
  text.classList.toggle("clamped", long && !expanded);
1193
1384
  more.hidden = !long;
1194
1385
  more.textContent = expanded ? "Show less" : "Show more";
1386
+ renderShots(el.querySelector(".shots"), room, m);
1387
+ renderWaiting(el, room, m);
1195
1388
  const thought = el.querySelector(".thought");
1196
1389
  if (m.thought) {
1197
1390
  thought.hidden = false;
@@ -1201,11 +1394,16 @@
1201
1394
  const tools = el.querySelector(".tools");
1202
1395
  tools.innerHTML = "";
1203
1396
  for (const call of m.toolCalls || []) {
1204
- const 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);
1397
+ const open = openTools.has(call.toolCallId);
1398
+ const box = document.createElement("div");
1399
+ box.className = `tool${open ? " open" : ""}`;
1400
+ const input = call.rawInput === undefined ? "" : typeof call.rawInput === "string" ? call.rawInput : JSON.stringify(call.rawInput, null, 1);
1401
+ box.innerHTML =
1402
+ `<button type="button" class="chip chip-${esc(call.status || "pending")}" data-tool="${esc(call.toolCallId)}" title="${open ? "Collapse" : "Expand"}">${ic("tool")}${esc(`${call.title}${call.kind ? ` · ${call.kind}` : ""} · ${call.status || "pending"}`)}</button>` +
1403
+ (open
1404
+ ? `<div class="tool-body"><div class="tool-sec"><b>call</b><pre>${esc(call.title)}</pre></div>${input ? `<div class="tool-sec"><b>input</b><pre>${esc(input.slice(0, 4000))}</pre></div>` : ""}${call.output ? `<div class="tool-sec"><b>output</b><pre>${esc(call.output)}</pre></div>` : `<div class="tool-sec muted">no output recorded</div>`}</div>`
1405
+ : "");
1406
+ tools.appendChild(box);
1209
1407
  }
1210
1408
  const plan = el.querySelector(".plan");
1211
1409
  if (m.plan && m.plan.length) {
@@ -1231,18 +1429,23 @@
1231
1429
  return null;
1232
1430
  }
1233
1431
  function seenHtml(room, m) {
1234
- const seen = room.participants.filter((p) => p.kind === "agent" && p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
1432
+ const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline");
1433
+ const seen = present.filter((p) => p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
1235
1434
  if (!seen.length) return `<span class="ticks">✓</span> sent`;
1435
+ if (present.length > 1 && seen.length === present.length) return `<span class="ticks">✓✓</span> seen by all`;
1236
1436
  return `<span class="ticks">✓✓</span> seen by ${esc(seen.map((p) => p.name).join(", "))}`;
1237
1437
  }
1238
1438
  function refreshSeen(room) {
1239
1439
  const last = lastHumanMessage(room);
1440
+ const byId = new Map(room.messages.map((m) => [m.id, m]));
1240
1441
  for (const el of els.messages.querySelectorAll(".msg.mine")) {
1241
1442
  const meta = el.querySelector(".meta");
1242
1443
  if (!meta) continue;
1243
1444
  const isLast = last && el.dataset.id === last.id;
1244
1445
  meta.innerHTML = isLast ? seenHtml(room, last) : "";
1245
1446
  meta.classList.toggle("seen", !!isLast);
1447
+ const m = byId.get(el.dataset.id);
1448
+ if (m && (isLast || el.classList.contains("waiting"))) renderWaiting(el, room, m);
1246
1449
  }
1247
1450
  }
1248
1451
 
@@ -1310,11 +1513,14 @@
1310
1513
  const room = state.rooms.get(roomId);
1311
1514
  if (!room) return;
1312
1515
  const idx = room.messages.findIndex((x) => x.id === m.id);
1313
- if (idx >= 0) Object.assign(room.messages[idx], m);
1314
- else room.messages.push(m);
1516
+ const wasFinal = idx >= 0 && !room.messages[idx].streaming;
1517
+ if (idx >= 0) {
1518
+ if (!("pinned" in m)) delete room.messages[idx].pinned;
1519
+ Object.assign(room.messages[idx], m);
1520
+ } else room.messages.push(m);
1315
1521
  const showing = roomId === state.currentRoomId && state.view === "room";
1316
1522
  if (!showing) {
1317
- if (idx < 0 && m.kind === "chat" && !m.streaming && m.from !== "human" && roomId !== state.currentRoomId) state.unread.set(roomId, (state.unread.get(roomId) || 0) + 1);
1523
+ if (!wasFinal && m.kind === "chat" && !m.streaming && m.from !== "human" && roomId !== state.currentRoomId) state.unread.set(roomId, (state.unread.get(roomId) || 0) + 1);
1318
1524
  if ((state.view === "rooms" || state.view === "home")) {
1319
1525
  renderSideRooms();
1320
1526
  renderRoomsGrid();
@@ -1322,7 +1528,7 @@
1322
1528
  renderRail();
1323
1529
  return;
1324
1530
  }
1325
- const stick = nearBottom();
1531
+ const stick = stuck;
1326
1532
  const existing = els.messages.querySelector(`.msg[data-id="${m.id}"]`);
1327
1533
  if (existing) updateMessageElement(existing, room, room.messages[idx]);
1328
1534
  else {
@@ -1330,8 +1536,11 @@
1330
1536
  if (empty) empty.remove();
1331
1537
  els.messages.appendChild(messageElement(room, m));
1332
1538
  if (m.from === "human") refreshSeen(room);
1539
+ else if (!stick && m.kind === "chat") noteNew(room, m);
1333
1540
  }
1334
1541
  if (stick) scrollToBottom();
1542
+ if (m.streaming) updateWorkingNow();
1543
+ if (!wasFinal && !m.streaming && m.kind === "chat" && m.from !== "human") noteFinished(room, m);
1335
1544
  if (m.from === "human") renderTimeline();
1336
1545
  }
1337
1546
 
@@ -1344,6 +1553,8 @@
1344
1553
  if (el) el.remove();
1345
1554
  }
1346
1555
 
1556
+ const dirty = new Map();
1557
+ let flushScheduled = false;
1347
1558
  function patchMessage(roomId, id, fn) {
1348
1559
  const room = state.rooms.get(roomId);
1349
1560
  if (!room) return;
@@ -1351,10 +1562,26 @@
1351
1562
  if (!m) return;
1352
1563
  fn(m);
1353
1564
  if (roomId !== state.currentRoomId || state.view !== "room") return;
1354
- const el = els.messages.querySelector(`.msg[data-id="${id}"]`);
1355
- const stick = nearBottom();
1356
- if (el) updateMessageElement(el, room, m);
1357
- if (stick) scrollToBottom();
1565
+ dirty.set(id, room);
1566
+ if (flushScheduled) return;
1567
+ flushScheduled = true;
1568
+ requestAnimationFrame(flushPatches);
1569
+ }
1570
+ function flushPatches() {
1571
+ flushScheduled = false;
1572
+ const batch = [...dirty];
1573
+ dirty.clear();
1574
+ let touched = false;
1575
+ for (const [id, room] of batch) {
1576
+ if (room.id !== state.currentRoomId || state.view !== "room") continue;
1577
+ const m = room.messages.find((x) => x.id === id);
1578
+ const el = m && els.messages.querySelector(`.msg[data-id="${id}"]`);
1579
+ if (!el) continue;
1580
+ updateMessageElement(el, room, m);
1581
+ touched = true;
1582
+ }
1583
+ if (touched && stuck) scrollToBottom();
1584
+ if (touched) updateWorkingNow();
1358
1585
  }
1359
1586
 
1360
1587
 
@@ -1582,6 +1809,11 @@
1582
1809
  ${sectionTitle("link", "Session")}
1583
1810
  <div id="pp-config"></div>
1584
1811
  </div>
1812
+ <div class="section danger">
1813
+ ${sectionTitle("bolt", "Respawn")}
1814
+ <p class="hint">${esc(p.name)} comes back with an empty head: it forgets this conversation entirely. The room's history stays and you still see everything.${geekTip("A session's context cannot be erased, so the vibemate's process and session are closed and it starts a new one with no replay. Its stored session is dropped too, or a later reconnect would bring the old context back. Same thing as typing /respawn @Name in the composer.")}</p>
1815
+ <div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button></div>
1816
+ </div>
1585
1817
  <div class="section">
1586
1818
  ${sectionTitle("info", "Stats")}
1587
1819
  <div class="kv">
@@ -1599,6 +1831,18 @@
1599
1831
  "skills, timing, session, stats",
1600
1832
  )}`;
1601
1833
  wireDetailsClose();
1834
+ const respawnBtn = els.detailsInner.querySelector('button[data-act="respawn"]');
1835
+ if (respawnBtn) {
1836
+ respawnBtn.addEventListener("click", async () => {
1837
+ const ok = await confirmDialog(`${p.name} forgets this whole conversation and starts over. You keep the history; it does not.`, { title: `Respawn ${p.name}?`, okLabel: "Respawn", danger: true });
1838
+ if (!ok) return;
1839
+ try {
1840
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`));
1841
+ } catch (e) {
1842
+ showError(e);
1843
+ }
1844
+ });
1845
+ }
1602
1846
  els.detailsInner.querySelectorAll(".action").forEach((btn) => {
1603
1847
  btn.addEventListener("click", async () => {
1604
1848
  const act = btn.dataset.act;
@@ -1772,7 +2016,7 @@
1772
2016
  </div>
1773
2017
  <div class="section danger" style="margin-top:12px">
1774
2018
  ${sectionTitle("alert", "Danger zone")}
1775
- <p class="field-note">Closes every vibemate in this room and removes it from the list. The history file stays on disk.</p>
2019
+ <p class="field-note">Closes every vibemate in this room and removes it from the list. Its history and files move to the trash folder of your viberoom data; a new room with the same name starts empty.</p>
1776
2020
  <div class="row-btns start"><button class="btn danger sm" id="rp-delete">${ic("trash")}Close this room for good</button></div>
1777
2021
  </div>`;
1778
2022
  wireDetailsClose();
@@ -2379,9 +2623,30 @@
2379
2623
  els.invAvatarPicker.innerHTML = "";
2380
2624
  els.invAvatarPicker.appendChild(window.Avatars.pickerElement("", (emoji) => (els.invAvatar.value = emoji)));
2381
2625
  els.invGeek.open = false;
2626
+ setStaffing(null);
2382
2627
  openDialog(els.dialog);
2383
2628
  els.invName.focus();
2384
2629
  }
2630
+ const staffing = { id: null };
2631
+ const PERSONA_FIELDS = () => [els.invName, els.invTagline, els.invRole, els.invAvatar];
2632
+ function setStaffing(p) {
2633
+ staffing.id = p ? p.id : null;
2634
+ for (const f of PERSONA_FIELDS()) f.disabled = false;
2635
+ els.invSkills.classList.remove("locked");
2636
+ const lead = els.dialog.querySelector(".lead");
2637
+ lead.textContent = p
2638
+ ? `${p.name} comes from the room's template. Pick the coding agent that runs it; change the vibename or the character if you like.`
2639
+ : "Pick a vibemate, give it a vibename and a character, and it joins the room.";
2640
+ }
2641
+ function openStaffDialog(p) {
2642
+ openInvite();
2643
+ els.invName.value = p.name;
2644
+ els.invTagline.value = p.tagline || "";
2645
+ els.invRole.value = p.role || "";
2646
+ els.invAvatar.value = p.avatar || "";
2647
+ renderSkillChecks(els.invSkills, p.skills || []);
2648
+ setStaffing(p);
2649
+ }
2385
2650
  async function submitInvite(event) {
2386
2651
  event.preventDefault();
2387
2652
  if (!els.invType.value) {
@@ -2393,6 +2658,21 @@
2393
2658
  els.invSubmit.classList.add("loading");
2394
2659
  els.invError.hidden = true;
2395
2660
  try {
2661
+ if (staffing.id) {
2662
+ await post(roomApi(`/participants/${encodeURIComponent(staffing.id)}/staff`), {
2663
+ agentType: els.invType.value,
2664
+ model: els.invModelCustom.value.trim() || els.invModel.value || null,
2665
+ effort: els.invEffort.value || null,
2666
+ mode: els.invMode.value || null,
2667
+ name: els.invName.value.trim(),
2668
+ tagline: els.invTagline.value.trim(),
2669
+ role: els.invRole.value.trim(),
2670
+ avatar: els.invAvatar.value.trim(),
2671
+ skills: checkedSkills(els.invSkills),
2672
+ });
2673
+ closeDialog(els.dialog);
2674
+ return;
2675
+ }
2396
2676
  await post(roomApi("/invite"), {
2397
2677
  agentType: els.invType.value,
2398
2678
  name: els.invName.value.trim(),
@@ -2471,6 +2751,88 @@
2471
2751
  }
2472
2752
 
2473
2753
 
2754
+ const tplEls = { dialog: $("#template-dialog"), form: $("#template-form"), list: $("#tpl-list"), detail: $("#tpl-detail"), name: $("#tpl-room-name"), dir: $("#tpl-room-dir"), error: $("#tpl-error"), create: $("#tpl-create") };
2755
+ const tpl = { items: [], current: null, autoName: "" };
2756
+ async function openTemplateDialog() {
2757
+ tplEls.error.hidden = true;
2758
+ tplEls.name.value = "";
2759
+ tplEls.dir.value = "";
2760
+ tplEls.list.innerHTML = '<span class="hint">loading…</span>';
2761
+ tplEls.detail.innerHTML = "";
2762
+ openDialog(tplEls.dialog);
2763
+ try {
2764
+ tpl.items = (await (await fetch("/api/templates")).json()).templates || [];
2765
+ } catch (error) {
2766
+ tplEls.error.textContent = error.message;
2767
+ tplEls.error.hidden = false;
2768
+ return;
2769
+ }
2770
+ renderTemplateList(tpl.items[0] ? tpl.items[0].id : null);
2771
+ }
2772
+ function renderTemplateList(currentId) {
2773
+ tpl.current = tpl.items.find((t) => t.id === currentId) || null;
2774
+ tplEls.list.innerHTML = tpl.items
2775
+ .map((t) => {
2776
+ const on = tpl.current && t.id === tpl.current.id;
2777
+ const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: "#9ca3af" }, 20, {})).join("");
2778
+ return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
2779
+ <span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
2780
+ <span class="tpl-body"><b>${esc(t.name)}${t.recommended ? '<span class="badge tpl-rec">recommended</span>' : ""}</b><span class="tpl-meta">${t.vibemates.length} vibemate${t.vibemates.length === 1 ? "" : "s"}${t.builtin ? " · built in" : " · yours"}</span><span class="avatar-stack">${faces}</span></span>
2781
+ <span class="tpl-check">${ic("check")}</span>
2782
+ </button>`;
2783
+ })
2784
+ .join("") || '<span class="hint">No templates yet.</span>';
2785
+ renderTemplateDetail();
2786
+ }
2787
+ tplEls.list.addEventListener("click", (e) => {
2788
+ const b = e.target.closest(".tpl-item");
2789
+ if (b) renderTemplateList(b.dataset.id);
2790
+ });
2791
+ function renderTemplateDetail() {
2792
+ const t = tpl.current;
2793
+ if (!t) return void (tplEls.detail.innerHTML = "");
2794
+ const rules = String((t.settings || {}).customRules || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
2795
+ if (!tplEls.name.value || tplEls.name.value === tpl.autoName) tplEls.name.value = t.name;
2796
+ tpl.autoName = t.name;
2797
+ tplEls.detail.innerHTML = `
2798
+ <p class="tpl-desc">${esc(t.description)}</p>
2799
+ ${rules.length ? `<div class="tpl-rules"><div class="label">Room rules</div><ul>${rules.map((r) => `<li>${esc(r)}</li>`).join("")}</ul></div>` : ""}
2800
+ <div class="label">Vibemates</div>
2801
+ ${t.vibemates
2802
+ .map((v, i) => {
2803
+ return `<div class="tpl-vm" data-i="${i}">
2804
+ <div class="tpl-vm-head"><b>${esc(v.name)}</b>${v.tagline ? `<span class="hint">"${esc(v.tagline)}"</span>` : ""}</div>
2805
+ ${v.role ? `<div class="tpl-vm-role">${esc(v.role)}</div>` : ""}
2806
+ </div>`;
2807
+ })
2808
+ .join("")}`;
2809
+ tplEls.detail.insertAdjacentHTML("beforeend", '<p class="hint">You pick the coding agent for each vibemate in the room, right after it opens.</p>');
2810
+ }
2811
+ $("#tpl-dir-browse").addEventListener("click", () => openFolderPicker(tplEls.dir.value, (dir) => (tplEls.dir.value = dir)));
2812
+ tplEls.form.addEventListener("submit", async (event) => {
2813
+ event.preventDefault();
2814
+ const t = tpl.current;
2815
+ if (!t) return;
2816
+ const vibemates = t.vibemates.map((v) => ({ name: v.name, agentType: "" }));
2817
+ tplEls.create.disabled = true;
2818
+ tplEls.create.textContent = "Summoning…";
2819
+ try {
2820
+ const res = await post("/api/rooms/from-template", { template: t.id, name: tplEls.name.value, dir: tplEls.dir.value.trim() || null, vibemates });
2821
+ closeDialog(tplEls.dialog);
2822
+ if (res.room && res.room.id) {
2823
+ state.rooms.set(res.room.id, res.room);
2824
+ selectRoom(res.room.id);
2825
+ }
2826
+ for (const n of res.notices || []) toast(n, "info");
2827
+ } catch (error) {
2828
+ tplEls.error.textContent = error.message;
2829
+ tplEls.error.hidden = false;
2830
+ } finally {
2831
+ tplEls.create.disabled = false;
2832
+ tplEls.create.textContent = "Create the room";
2833
+ }
2834
+ });
2835
+
2474
2836
  function openRoomDialog() {
2475
2837
  els.roomError.hidden = true;
2476
2838
  els.roomName.value = "";
@@ -2573,6 +2935,7 @@
2573
2935
  state.recipes = snapshot.recipes || [];
2574
2936
  state.roomDefaults = snapshot.roomDefaults || null;
2575
2937
  state.rooms = new Map((snapshot.rooms || []).map((r) => [r.id, r]));
2938
+ state.openRooms = [...(snapshot.openRooms || [])];
2576
2939
  const params = new URLSearchParams(location.search);
2577
2940
  const wanted = params.get("room");
2578
2941
  const remembered = recall("room");
@@ -2717,9 +3080,14 @@
2717
3080
  }
2718
3081
  renderRail();
2719
3082
  });
3083
+ es.addEventListener("rooms.opened", (e) => {
3084
+ state.openRooms = JSON.parse(e.data).roomIds || [];
3085
+ renderRail();
3086
+ });
2720
3087
  es.addEventListener("room.removed", (e) => {
2721
3088
  const { roomId } = JSON.parse(e.data);
2722
3089
  state.rooms.delete(roomId);
3090
+ state.openRooms = state.openRooms.filter((id) => id !== roomId);
2723
3091
  if (state.currentRoomId === roomId) {
2724
3092
  state.currentRoomId = null;
2725
3093
  state.selection = { kind: "room" };
@@ -2763,9 +3131,168 @@
2763
3131
  });
2764
3132
 
2765
3133
 
3134
+ let composerMin = Number(recall("composerH")) || 0;
3135
+ const composerCeiling = () => Math.max(120, els.app.clientHeight - 260);
3136
+ let autosizeQueued = false;
3137
+ function autosizeSoon() {
3138
+ if (autosizeQueued) return;
3139
+ autosizeQueued = true;
3140
+ requestAnimationFrame(() => {
3141
+ autosizeQueued = false;
3142
+ autosize();
3143
+ });
3144
+ }
2766
3145
  function autosize() {
3146
+ const min = Math.max(36, composerMin);
3147
+ const cap = Math.max(180, min);
2767
3148
  els.input.style.height = "auto";
2768
- els.input.style.height = Math.min(180, els.input.scrollHeight) + "px";
3149
+ els.input.style.height = Math.min(composerCeiling(), Math.max(min, Math.min(cap, els.input.scrollHeight))) + "px";
3150
+ }
3151
+ {
3152
+ const grip = $("#composer-grip");
3153
+ let drag = null;
3154
+ grip.addEventListener("pointerdown", (e) => {
3155
+ if (e.button !== 0) return;
3156
+ drag = { y: e.clientY, h: els.input.offsetHeight };
3157
+ grip.setPointerCapture(e.pointerId);
3158
+ els.composer.classList.add("resizing");
3159
+ e.preventDefault();
3160
+ });
3161
+ grip.addEventListener("pointermove", (e) => {
3162
+ if (!drag) return;
3163
+ composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY)));
3164
+ autosize();
3165
+ });
3166
+ const stop = () => {
3167
+ if (!drag) return;
3168
+ drag = null;
3169
+ els.composer.classList.remove("resizing");
3170
+ remember("composerH", composerMin);
3171
+ };
3172
+ grip.addEventListener("pointerup", stop);
3173
+ grip.addEventListener("pointercancel", stop);
3174
+ grip.addEventListener("dblclick", () => {
3175
+ composerMin = 0;
3176
+ remember("composerH", 0);
3177
+ autosize();
3178
+ });
3179
+ }
3180
+
3181
+ const SHOT_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
3182
+ const SHOTS_MAX = 6;
3183
+ let pendingShots = [];
3184
+ let shotSeq = 0;
3185
+ const shotMarker = (n) => `[img ${n}]`;
3186
+
3187
+ const composerClear = $("#composer-clear");
3188
+ function updateComposerClear() {
3189
+ composerClear.hidden = !(els.input.value.trim() || pendingShots.length);
3190
+ }
3191
+ composerClear.addEventListener("click", () => {
3192
+ els.input.value = "";
3193
+ clearShots();
3194
+ autosize();
3195
+ updateComposerClear();
3196
+ els.input.focus();
3197
+ });
3198
+ els.input.addEventListener("input", updateComposerClear);
3199
+ function renderShotsTray() {
3200
+ updateComposerClear();
3201
+ els.shotsTray.hidden = !pendingShots.length;
3202
+ els.shotsTray.innerHTML = pendingShots
3203
+ .map((shot, i) => `<span class="shot-chip"><img src="${esc(shot.data)}" alt=""><span class="shot-n">${shot.n}</span><button type="button" class="shot-drop" data-i="${i}" title="Remove ${esc(shot.name)}">×</button></span>`)
3204
+ .join("");
3205
+ }
3206
+
3207
+ function clearShots() {
3208
+ pendingShots = [];
3209
+ shotSeq = 0;
3210
+ renderShotsTray();
3211
+ }
3212
+
3213
+ function insertShotMarker(n) {
3214
+ const el = els.input;
3215
+ const value = el.value;
3216
+ const start = el.selectionStart ?? value.length;
3217
+ const end = el.selectionEnd ?? start;
3218
+ const before = value.slice(0, start);
3219
+ const after = value.slice(end);
3220
+ const lead = before && !/\s$/.test(before) ? " " : "";
3221
+ const tail = after && !/^\s/.test(after) ? " " : "";
3222
+ const marker = `${lead}${shotMarker(n)}${tail}`;
3223
+ el.value = before + marker + after;
3224
+ const caret = before.length + marker.length;
3225
+ el.setSelectionRange(caret, caret);
3226
+ autosize();
3227
+ }
3228
+
3229
+ function removeShotMarker(n) {
3230
+ const el = els.input;
3231
+ const pattern = new RegExp(` ?\\[img ${n}\\]`, "gi");
3232
+ const caret = el.selectionStart ?? el.value.length;
3233
+ const removedBefore = (el.value.slice(0, caret).match(pattern) || []).join("").length;
3234
+ el.value = el.value.replace(pattern, "");
3235
+ const at = Math.max(0, caret - removedBefore);
3236
+ el.setSelectionRange(at, at);
3237
+ autosize();
3238
+ }
3239
+
3240
+ function addShotFiles(files) {
3241
+ const room = currentRoom();
3242
+ if (!room) return;
3243
+ for (const file of files) {
3244
+ if (!SHOT_TYPES.includes(file.type)) {
3245
+ showError(new Error(`${file.name || "that image"} is a ${file.type || "kind"} the room cannot show (png, jpeg, webp and gif only)`));
3246
+ continue;
3247
+ }
3248
+ if (pendingShots.length >= SHOTS_MAX) return void showError(new Error(`up to ${SHOTS_MAX} images per message`));
3249
+ const reader = new FileReader();
3250
+ const name = file.name || "";
3251
+ reader.onload = () => {
3252
+ if (pendingShots.length >= SHOTS_MAX) return void showError(new Error(`up to ${SHOTS_MAX} images per message`));
3253
+ const n = ++shotSeq;
3254
+ pendingShots.push({ n, name, mimeType: file.type, data: String(reader.result) });
3255
+ renderShotsTray();
3256
+ insertShotMarker(n);
3257
+ };
3258
+ reader.onerror = () => showError(new Error(`could not read ${name || "the image"}`));
3259
+ reader.readAsDataURL(file);
3260
+ }
3261
+ }
3262
+
3263
+ function imageFilesFrom(transfer) {
3264
+ if (!transfer) return [];
3265
+ return Array.from(transfer.files || []).filter((f) => f && f.type && f.type.startsWith("image/"));
3266
+ }
3267
+
3268
+ els.shotsTray.addEventListener("click", (e) => {
3269
+ const drop = e.target.closest(".shot-drop");
3270
+ if (!drop) return;
3271
+ const [shot] = pendingShots.splice(Number(drop.dataset.i), 1);
3272
+ if (shot) removeShotMarker(shot.n);
3273
+ renderShotsTray();
3274
+ });
3275
+ els.input.addEventListener("paste", (e) => {
3276
+ const files = imageFilesFrom(e.clipboardData);
3277
+ if (!files.length) return;
3278
+ e.preventDefault();
3279
+ addShotFiles(files);
3280
+ });
3281
+ for (const target of [els.composer, els.messages]) {
3282
+ target.addEventListener("dragover", (e) => {
3283
+ if (!imageFilesFrom(e.dataTransfer).length && !(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files"))) return;
3284
+ e.preventDefault();
3285
+ els.composer.classList.add("drop-target");
3286
+ });
3287
+ target.addEventListener("dragleave", () => els.composer.classList.remove("drop-target"));
3288
+ target.addEventListener("drop", (e) => {
3289
+ const files = imageFilesFrom(e.dataTransfer);
3290
+ els.composer.classList.remove("drop-target");
3291
+ if (!files.length) return;
3292
+ e.preventDefault();
3293
+ addShotFiles(files);
3294
+ els.input.focus();
3295
+ });
2769
3296
  }
2770
3297
  let typingSentAt = 0;
2771
3298
  els.input.addEventListener("input", () => {
@@ -2785,8 +3312,26 @@
2785
3312
  if (e.target.closest(".reconnect-btn")) return openReconnectDialog(room);
2786
3313
  if (e.target.closest("button")) return;
2787
3314
  if (p.kind === "human") openDetails({ kind: "me" });
3315
+ else if (p.status === "unstaffed") openStaffDialog(p);
2788
3316
  else openDetails({ kind: "participant", id: p.id });
2789
3317
  });
3318
+ const castBanner = $("#cast-banner");
3319
+ castBanner.addEventListener("click", (e) => {
3320
+ const card = e.target.closest(".cast-card");
3321
+ const room = card && currentRoom();
3322
+ const p = room && findById(room, card.dataset.id);
3323
+ if (p && p.status === "unstaffed") openStaffDialog(p);
3324
+ });
3325
+ function updateCastGate(room) {
3326
+ const waiting = room ? room.participants.filter((p) => p.kind === "agent" && p.status === "unstaffed") : [];
3327
+ castBanner.hidden = waiting.length === 0;
3328
+ castBanner.innerHTML = waiting.length
3329
+ ? `<div class="cast-lead"><strong>Summon ${waiting.map((p) => esc(p.name)).join(" and ")} to begin.</strong> ${waiting.length === 1 ? "It comes" : "They come"} from the template with the character set; pick the coding agent that runs ${waiting.length === 1 ? "it" : "each"}.</div><div class="cast-list">${waiting.map((p) => `<button type="button" class="cast-card" data-id="${esc(p.id)}">${avatar(p, 44, {})}<b>${esc(p.name)}</b>${p.tagline ? `<span>"${esc(p.tagline)}"</span>` : ""}<em>Summon ${esc(p.name)}</em></button>`).join("")}</div>`
3330
+ : "";
3331
+ els.input.disabled = waiting.length > 0;
3332
+ els.input.placeholder = waiting.length ? `Summon ${waiting.map((p) => p.name).join(" and ")} to start the conversation` : "Message the room… @Name or /skill";
3333
+ els.composer.classList.toggle("gated", waiting.length > 0);
3334
+ }
2790
3335
  els.participants.addEventListener("dblclick", (e) => {
2791
3336
  const li = e.target.closest("li[data-id]");
2792
3337
  const p = li && findById(currentRoom(), li.dataset.id);
@@ -2796,19 +3341,25 @@
2796
3341
  event.preventDefault();
2797
3342
  const text = els.input.value.trim();
2798
3343
  const room = currentRoom();
2799
- if (!text || !room) return;
3344
+ if ((!text && !pendingShots.length) || !room) return;
3345
+ const shots = pendingShots;
2800
3346
  els.input.value = "";
3347
+ clearShots();
2801
3348
  typingSentAt = 0;
2802
3349
  autosize();
2803
3350
  const local = { id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, seq: 0, from: "human", fromName: (state.settings || {}).humanName || "You", to: [], toNames: [], text, ts: Date.now(), kind: "chat", pending: true };
3351
+ if (shots.length) local.images = shots.map((shot) => ({ file: "", name: shot.name, mimeType: shot.mimeType, bytes: 0, n: shot.n, url: shot.data }));
2804
3352
  upsertMessage(room.id, local);
2805
3353
  try {
2806
- const r = await post(roomApi("/send"), { text });
2807
- adoptLocalMessage(room.id, local.id, r.id);
3354
+ const r = await post(roomApi("/send"), { text, images: shots });
3355
+ if (r.command) removeMessage(room.id, local.id);
3356
+ else adoptLocalMessage(room.id, local.id, r.id);
2808
3357
  } catch (error) {
2809
3358
  removeMessage(room.id, local.id);
2810
3359
  showError(error);
2811
3360
  els.input.value = text;
3361
+ pendingShots = shots;
3362
+ renderShotsTray();
2812
3363
  }
2813
3364
  });
2814
3365
  function adoptLocalMessage(roomId, localId, realId) {
@@ -2979,6 +3530,7 @@
2979
3530
  });
2980
3531
  }
2981
3532
 
3533
+ const ALL_MENTION = { id: "all", name: "All", kind: "all", tagline: "every vibemate in the room" };
2982
3534
  function attachMentions(textarea, menuEl, options) {
2983
3535
  const opts = options || {};
2984
3536
  const m = { open: false, items: [], index: 0, start: -1 };
@@ -3001,6 +3553,8 @@
3001
3553
  if (!ctx || !room) return close();
3002
3554
  const q = ctx.prefix.toLowerCase();
3003
3555
  const items = room.participants.filter((p) => (opts.includeHuman || p.id !== "human") && p.name.toLowerCase().startsWith(q));
3556
+ const agents = room.participants.filter((p) => p.kind === "agent" && p.status !== "left").length;
3557
+ if (!opts.includeHuman && agents > 1 && "all".startsWith(q)) items.unshift(ALL_MENTION);
3004
3558
  if (!items.length) return close();
3005
3559
  m.open = true;
3006
3560
  m.items = items;
@@ -3011,7 +3565,7 @@
3011
3565
  const b = document.createElement("button");
3012
3566
  b.type = "button";
3013
3567
  b.className = i === m.index ? "active" : "";
3014
- b.innerHTML = `${avatar(p, 24, { vendor: true })}<span>${esc(p.name)}</span><span class="mm-sub">${esc(p.kind === "human" ? "you" : p.tagline || p.agentVendor || "")}${p.status === "offline" ? " · offline" : ""}</span>`;
3568
+ b.innerHTML = `${p === ALL_MENTION ? `<span class="mm-all">${ic("rooms")}</span>` : avatar(p, 24, { vendor: true })}<span>${esc(p.name)}</span><span class="mm-sub">${esc(p.kind === "human" ? "you" : p.tagline || p.agentVendor || "")}${p.status === "offline" ? " · offline" : ""}</span>`;
3015
3569
  b.addEventListener("mousedown", (e) => {
3016
3570
  e.preventDefault();
3017
3571
  pick(i);
@@ -3053,7 +3607,7 @@
3053
3607
  }
3054
3608
  });
3055
3609
  }
3056
- attachMentions(els.input, els.mentionMenu, { onChange: autosize });
3610
+ attachMentions(els.input, els.mentionMenu, { onChange: autosizeSoon });
3057
3611
  attachSlashMenu(els.input, els.mentionMenu);
3058
3612
  els.input.addEventListener("keydown", (event) => {
3059
3613
  if (event.defaultPrevented) return;
@@ -3094,15 +3648,16 @@
3094
3648
  else openDetails({ kind: "me" });
3095
3649
  return;
3096
3650
  }
3097
- if (nav === "room") {
3098
- if (currentRoom()) selectRoom(state.currentRoomId, { keepDetails: true });
3099
- return;
3100
- }
3101
3651
  setView(nav);
3102
3652
  remember("view", nav);
3103
3653
  });
3104
3654
  });
3105
3655
  els.railToggle.addEventListener("click", () => setRailOpen(!els.app.classList.contains("rail-open")));
3656
+ els.railRooms.addEventListener("click", (e) => {
3657
+ const b = e.target.closest(".rail-room");
3658
+ if (b) selectRoom(b.dataset.room, { keepDetails: true });
3659
+ });
3660
+ els.railRooms.addEventListener("animationend", (e) => e.target.classList.remove("bump"));
3106
3661
  function setSideOpen(open) {
3107
3662
  els.app.classList.toggle("side-collapsed", !open);
3108
3663
  remember("sideOpen", open ? "1" : "0");
@@ -3110,12 +3665,24 @@
3110
3665
  els.sideToggle.innerHTML = ic(open ? "collapse" : "expand");
3111
3666
  }
3112
3667
  els.sideToggle.addEventListener("click", () => setSideOpen(els.app.classList.contains("side-collapsed")));
3668
+ let lastScrollTop = 0;
3113
3669
  els.messages.addEventListener("scroll", () => {
3114
- els.jumpLatest.hidden = nearBottom();
3670
+ const top = els.messages.scrollTop;
3671
+ if (nearBottom()) stuck = true;
3672
+ else if (top < lastScrollTop - 1) stuck = false;
3673
+ lastScrollTop = top;
3674
+ els.jumpLatest.hidden = stuck;
3115
3675
  updateTimelineView();
3676
+ updateWorkingNow();
3677
+ if (stuck) clearNotes();
3678
+ else pruneDoneNotes();
3679
+ });
3680
+ els.jumpLatest.addEventListener("click", scrollToBottom);
3681
+ document.addEventListener("mousedown", (e) => {
3682
+ if (!state.detailsOpen) return;
3683
+ if (e.target.closest("#details, #side, .rail, .chat-actions, dialog, .mention-menu, .lightbox, .tl-pop, #pins-panel")) return;
3684
+ closeDetails();
3116
3685
  });
3117
- els.jumpLatest.addEventListener("click", () => els.messages.scrollTo({ top: els.messages.scrollHeight, behavior: "smooth" }));
3118
- els.detailsHandle.addEventListener("click", closeDetails);
3119
3686
  setSideOpen(recall("sideOpen") !== "0");
3120
3687
  $("#rail-logo").addEventListener("click", () => setView("home"));
3121
3688
  $("#rail-new-room").addEventListener("click", openRoomDialog);
@@ -3229,83 +3796,268 @@
3229
3796
  });
3230
3797
  window.addEventListener("beforeunload", () => remember("view", state.view));
3231
3798
 
3232
- const tl = { el: $("#timeline"), ticks: $("#timeline .tl-ticks"), view: $("#timeline .tl-view"), pop: $("#timeline .tl-pop"), items: [] };
3233
3799
  const TICK_H = 5;
3234
- function 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);
3800
+ function createTimeline(root, pick, opts) {
3801
+ const t = { el: root, ticks: root.querySelector(".tl-ticks"), view: root.querySelector(".tl-view"), pop: root.querySelector(".tl-pop"), items: [] };
3802
+ function render() {
3803
+ const room = currentRoom();
3804
+ const nodes = room && state.view === "room" ? pick(room) : [];
3805
+ t.items = nodes;
3806
+ t.el.hidden = nodes.length === 0;
3807
+ t.pop.hidden = true;
3808
+ if (!nodes.length) return;
3809
+ const total = els.messages.scrollHeight || 1;
3810
+ const h = Math.max(0, t.ticks.clientHeight - TICK_H);
3811
+ const tops = nodes.map((el) => el.offsetTop);
3812
+ const frag = document.createDocumentFragment();
3813
+ nodes.forEach((el, i) => {
3814
+ const tick = document.createElement("div");
3815
+ const pinned = el.classList.contains("pinned");
3816
+ tick.className = `tl-tick${pinned ? " pinned i i-pin" : ""}`;
3817
+ tick.dataset.i = i;
3818
+ if (opts.colorOf) tick.style.setProperty("--tick", opts.colorOf(room, el));
3819
+ tick.style.top = `${Math.round((tops[i] / total) * h)}px`;
3820
+ frag.appendChild(tick);
3821
+ });
3822
+ t.ticks.replaceChildren(frag);
3823
+ updateView();
3824
+ }
3825
+ function updateView() {
3826
+ if (t.el.hidden) return;
3827
+ const m = els.messages;
3828
+ const total = m.scrollHeight || 1;
3829
+ const h = t.ticks.clientHeight;
3830
+ t.view.style.top = `${(m.scrollTop / total) * h}px`;
3831
+ t.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
3832
+ const top = m.scrollTop;
3833
+ const bottom = m.scrollTop + m.clientHeight;
3834
+ const inView = t.items.map((el) => el.offsetTop + el.offsetHeight > top && el.offsetTop < bottom);
3835
+ inView.forEach((on, i) => {
3836
+ const tick = t.ticks.children[i];
3837
+ if (tick) tick.classList.toggle("in-view", on);
3838
+ });
3839
+ }
3840
+ function rowHtml(k, cls) {
3841
+ const el = t.items[k];
3842
+ const av = opts.avatarOf ? `<span class="tl-av">${opts.avatarOf(currentRoom(), el)}</span>` : "";
3843
+ const pinned = el.classList.contains("pinned");
3844
+ return `<div class="tl-row ${cls}${pinned ? " pinned" : ""}" data-i="${k}">${av}<span class="tl-text">${esc(timelineText(el))}</span>${pinned ? `<span class="tl-pin" title="Pinned">${ic("pin")}</span>` : ""}</div>`;
3845
+ }
3846
+ function showPop(i) {
3847
+ const rows = [[i - 2, "faded far"], [i - 1, "faded"], [i, "current"], [i + 1, "faded"], [i + 2, "faded far"]].filter(([k]) => t.items[k]);
3848
+ t.pop.innerHTML = rows.map(([k, c]) => rowHtml(k, c)).join("");
3849
+ t.pop.hidden = false;
3850
+ t.ticks.querySelectorAll(".tl-tick.active").forEach((x) => x.classList.remove("active"));
3851
+ const tick = t.ticks.children[i];
3852
+ if (tick) tick.classList.add("active");
3853
+ const current = t.pop.querySelector(".tl-row.current");
3854
+ let top = (tick ? tick.offsetTop : 0) - (current ? current.offsetTop + current.offsetHeight / 2 : 20) + TICK_H / 2;
3855
+ top = Math.max(0, Math.min(top, t.el.clientHeight - t.pop.offsetHeight));
3856
+ t.pop.style.top = `${top}px`;
3857
+ }
3858
+ function hidePop() {
3859
+ t.pop.hidden = true;
3860
+ t.ticks.querySelectorAll(".tl-tick.active").forEach((x) => x.classList.remove("active"));
3861
+ }
3862
+ t.ticks.addEventListener("mouseover", (e) => {
3863
+ const tick = e.target.closest(".tl-tick");
3864
+ if (tick) showPop(Number(tick.dataset.i));
3251
3865
  });
3252
- 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);
3866
+ let hideTimer = 0;
3867
+ t.el.addEventListener("mouseleave", () => {
3868
+ clearTimeout(hideTimer);
3869
+ hideTimer = setTimeout(hidePop, 320);
3870
+ });
3871
+ t.el.addEventListener("mouseenter", () => clearTimeout(hideTimer));
3872
+ t.ticks.addEventListener("click", (e) => {
3873
+ const tick = e.target.closest(".tl-tick");
3874
+ if (tick) jumpToMessage(t.items[Number(tick.dataset.i)]);
3266
3875
  });
3876
+ t.pop.addEventListener("click", (e) => {
3877
+ const row = e.target.closest(".tl-row");
3878
+ if (row) jumpToMessage(t.items[Number(row.dataset.i)]);
3879
+ });
3880
+ return { render, updateView };
3267
3881
  }
3268
3882
  function timelineText(el) {
3269
3883
  const t = el.querySelector(".text");
3270
- return (t ? t.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"));
3884
+ return (t ? t.textContent : el.textContent).trim().replace(/\s+/g, " ").slice(0, 240);
3885
+ }
3886
+ const doneNotes = [];
3887
+ const NOTE_TTL_MS = 4000;
3888
+ function bubbleInView(id) {
3889
+ const head = els.messages.querySelector(`.msg[data-id="${id}"] .head`);
3890
+ if (!head) return false;
3891
+ const box = els.messages.getBoundingClientRect();
3892
+ const r = head.getBoundingClientRect();
3893
+ return r.bottom > box.top && r.top < box.bottom;
3894
+ }
3895
+ function pushNote(note) {
3896
+ const i = doneNotes.findIndex((n) => n.id === note.id);
3897
+ if (i >= 0) {
3898
+ clearTimeout(doneNotes[i].timer);
3899
+ doneNotes.splice(i, 1);
3900
+ }
3901
+ note.timer = setTimeout(() => dropNote(note.id), NOTE_TTL_MS);
3902
+ doneNotes.push(note);
3903
+ while (doneNotes.length > 4) dropNote(doneNotes[0].id);
3904
+ renderDoneNotes();
3905
+ }
3906
+ function dropNote(id) {
3907
+ const i = doneNotes.findIndex((n) => n.id === id);
3908
+ if (i < 0) return;
3909
+ clearTimeout(doneNotes[i].timer);
3910
+ doneNotes.splice(i, 1);
3911
+ renderDoneNotes();
3912
+ }
3913
+ function clearNotes() {
3914
+ for (const n of doneNotes) clearTimeout(n.timer);
3915
+ doneNotes.length = 0;
3916
+ renderDoneNotes();
3917
+ }
3918
+ function noteFinished(room, m) {
3919
+ if (room.id !== state.currentRoomId || state.view !== "room") return;
3920
+ requestAnimationFrame(() => {
3921
+ if (bubbleInView(m.id)) return;
3922
+ const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: "agent" };
3923
+ pushNote({ id: m.id, kind: "done", p, text: `${p.name} finished`, sub: `started ${time(m.ts)}${m.durationMs ? ` · ${spanText(m.durationMs)}` : ""}` });
3924
+ });
3287
3925
  }
3926
+ function noteNew(room, m) {
3927
+ if (room.id !== state.currentRoomId || state.view !== "room") return;
3928
+ requestAnimationFrame(() => {
3929
+ if (bubbleInView(m.id)) return;
3930
+ const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: "agent" };
3931
+ pushNote({ id: m.id, kind: "new", p, text: `${p.name} wrote below`, sub: time(m.ts) });
3932
+ });
3933
+ }
3934
+ function spanText(ms) {
3935
+ if (!ms) return "";
3936
+ const s = Math.round(ms / 1000);
3937
+ return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, "0")} s`;
3938
+ }
3939
+ function renderDoneNotes() {
3940
+ els.doneNotes.hidden = doneNotes.length === 0;
3941
+ els.doneNotes.innerHTML = doneNotes
3942
+ .map((n) => `<button type="button" class="done-note ${n.kind}" data-id="${esc(n.id)}" title="Go to the message">${avatar(n.p, 18, {})}<span class="done-text"><b>${esc(n.text)}</b><small>${esc(n.sub)}</small></span></button>`)
3943
+ .join("");
3944
+ }
3945
+ function pruneDoneNotes() {
3946
+ for (const n of [...doneNotes]) if (bubbleInView(n.id)) dropNote(n.id);
3947
+ }
3948
+ els.doneNotes.addEventListener("click", (e) => {
3949
+ const note = e.target.closest(".done-note");
3950
+ if (!note) return;
3951
+ const id = note.dataset.id;
3952
+ dropNote(id);
3953
+ jumpToMessage(els.messages.querySelector(`.msg[data-id="${id}"]`));
3954
+ });
3288
3955
  function jumpToMessage(el) {
3289
3956
  if (!el) return;
3290
- el.scrollIntoView({ block: "center", behavior: "smooth" });
3957
+ el.scrollIntoView({ block: "center" });
3958
+ requestAnimationFrame(() => el.scrollIntoView({ block: "center" }));
3291
3959
  el.classList.remove("flash");
3292
3960
  void el.offsetWidth;
3293
3961
  el.classList.add("flash");
3294
3962
  }
3295
- tl.ticks.addEventListener("mouseover", (e) => {
3296
- const tick = e.target.closest(".tl-tick");
3297
- if (tick) showTimelinePop(Number(tick.dataset.i));
3963
+ const authorOf = (room, el) => findById(room, el.dataset.from);
3964
+ const timelines = [
3965
+ createTimeline($("#timeline"), () => [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")], {}),
3966
+ createTimeline($("#timeline-left"), () => [...els.messages.querySelectorAll(".msg.agent:not(.hidden-by-search)")], {
3967
+ colorOf: (room, el) => (authorOf(room, el) || {}).color || "#9ca3af",
3968
+ avatarOf: (room, el) => { const p = authorOf(room, el); return p ? avatar(p, 16, {}) : ""; },
3969
+ }),
3970
+ ];
3971
+ function renderTimeline() {
3972
+ for (const t of timelines) t.render();
3973
+ renderPins();
3974
+ }
3975
+ function updateTimelineView() { for (const t of timelines) t.updateView(); }
3976
+ new ResizeObserver(() => {
3977
+ els.app.style.setProperty("--composer-h", `${els.composer.offsetHeight}px`);
3978
+ renderTimeline();
3979
+ }).observe(els.composer);
3980
+ new ResizeObserver(() => renderTimeline()).observe(els.messages);
3981
+
3982
+ const workingNow = $("#working-now");
3983
+ function renderWorkingNow() {
3984
+ const room = currentRoom();
3985
+ const working = room && state.view === "room" ? room.participants.filter((p) => p.kind === "agent" && p.status === "thinking") : [];
3986
+ workingNow.innerHTML = working.map((p, i) => `<button type="button" class="wn-av" data-id="${esc(p.id)}" style="--i:${i}" title="${esc(p.name)} is writing — click to go to the reply">${avatar(p, 22, {})}</button>`).join("");
3987
+ updateWorkingNow();
3988
+ }
3989
+ function updateWorkingNow() {
3990
+ const room = currentRoom();
3991
+ const buttons = [...workingNow.querySelectorAll(".wn-av")];
3992
+ if (!room || !buttons.length) return void (workingNow.hidden = true);
3993
+ const box = els.messages.getBoundingClientRect();
3994
+ let shown = 0;
3995
+ for (const b of buttons) {
3996
+ const draft = room.messages.find((x) => x.from === b.dataset.id && x.streaming);
3997
+ const head = draft && els.messages.querySelector(`.msg[data-id="${draft.id}"] .head`);
3998
+ let show = false;
3999
+ if (head) {
4000
+ const r = head.getBoundingClientRect();
4001
+ show = !(r.bottom > box.top && r.top < box.bottom);
4002
+ }
4003
+ b.hidden = !show;
4004
+ if (show) shown++;
4005
+ }
4006
+ workingNow.hidden = shown === 0;
4007
+ }
4008
+ workingNow.addEventListener("click", (e) => {
4009
+ const b = e.target.closest(".wn-av");
4010
+ const room = b && currentRoom();
4011
+ if (!room) return;
4012
+ const m = [...room.messages].reverse().find((x) => x.from === b.dataset.id && x.kind === "chat");
4013
+ const el = m && els.messages.querySelector(`.msg[data-id="${m.id}"]`);
4014
+ if (el && m.streaming) jumpToMessage(el);
4015
+ else scrollToBottom();
3298
4016
  });
3299
- 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)]);
4017
+
4018
+ const pinsBtn = $("#pins-btn");
4019
+ const pinsPanel = $("#pins-panel");
4020
+ function pinnedMessages(room) {
4021
+ return room.messages.filter((m) => m.pinned && m.kind === "chat").sort((a, b) => a.seq - b.seq);
4022
+ }
4023
+ function renderPins() {
4024
+ const room = currentRoom();
4025
+ const pins = room && state.view === "room" ? pinnedMessages(room) : [];
4026
+ pinsBtn.hidden = pins.length === 0;
4027
+ pinsBtn.innerHTML = `${ic("pin")} Pinned · ${pins.length}`;
4028
+ if (!pins.length) pinsPanel.hidden = true;
4029
+ if (pinsPanel.hidden) return;
4030
+ pinsPanel.innerHTML = pins
4031
+ .map((m) => {
4032
+ const p = findById(room, m.from);
4033
+ const who = m.from === "human" ? Object.assign(meAvatarData(), { color: (p || {}).color }) : p;
4034
+ const text = String(m.text || "").replace(/\s+/g, " ").trim().slice(0, 160) || (m.images && m.images.length ? `[${m.images.length} image${m.images.length === 1 ? "" : "s"}]` : "");
4035
+ return `<div class="tl-row pin-row${m.from === "human" ? " mine" : ""}" data-id="${esc(m.id)}"><span class="tl-av">${who ? avatar(who, 16, {}) : ""}</span><span class="tl-text">${esc(text)}</span><span class="pin-time">${time(m.ts)}</span><button type="button" class="pin-x" title="Unpin">×</button></div>`;
4036
+ })
4037
+ .join("");
4038
+ }
4039
+ pinsBtn.addEventListener("click", () => {
4040
+ pinsPanel.hidden = !pinsPanel.hidden;
4041
+ renderPins();
3303
4042
  });
3304
- tl.pop.addEventListener("click", (e) => {
3305
- const row = e.target.closest(".tl-row");
3306
- if (row) jumpToMessage(tl.items[Number(row.dataset.i)]);
4043
+ pinsPanel.addEventListener("click", async (e) => {
4044
+ const row = e.target.closest(".pin-row");
4045
+ const room = currentRoom();
4046
+ if (!row || !room) return;
4047
+ if (e.target.closest(".pin-x")) {
4048
+ try {
4049
+ await post(`/api/rooms/${encodeURIComponent(room.id)}/messages/${encodeURIComponent(row.dataset.id)}/pin`, { pinned: false });
4050
+ } catch (error) {
4051
+ showError(error);
4052
+ }
4053
+ return;
4054
+ }
4055
+ jumpToMessage(els.messages.querySelector(`.msg[data-id="${row.dataset.id}"]`));
4056
+ pinsPanel.hidden = true;
4057
+ });
4058
+ document.addEventListener("click", (e) => {
4059
+ if (!pinsPanel.hidden && !e.target.closest("#pins-panel, #pins-btn")) pinsPanel.hidden = true;
3307
4060
  });
3308
- new ResizeObserver(() => renderTimeline()).observe(els.messages);
3309
4061
 
3310
4062
  const fp = { onChoose: null, selected: "", roots: [], home: "" };
3311
4063
  const fpEls = { dialog: $("#folder-dialog"), path: $("#fp-path"), tree: $("#fp-tree"), recent: $("#fp-recent"), error: $("#fp-error"), selected: $("#fp-selected"), choose: $("#fp-choose"), home: $("#fp-home"), newBtn: $("#fp-new") };