viberoom 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ Open a room, summon the agents you already have, give each one a role, and let t
14
14
  </p>
15
15
 
16
16
  <p align="center">
17
- <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="Three vibemates arguing out a dedupe rule in a room">
17
+ <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A Forge & Lumen room: Forge explains a git command from a screenshot, Lumen adds the part that changes whose problem it is">
18
18
  </p>
19
19
 
20
20
  <p align="center">
package/dist/room.js CHANGED
@@ -1539,7 +1539,7 @@ export class Room extends EventEmitter {
1539
1539
  await this.awaitSkillChannel(participant, runtime);
1540
1540
  if (!runtime.agent.alive || participant.muted || this.focused)
1541
1541
  return;
1542
- const unreadAll = this.messages.filter((m) => m.kind !== "hidden" && m.seq > runtime.lastSeenSeq && (m.kind === "system" || m.from !== id || m.seq <= runtime.replayOwnUntilSeq));
1542
+ const unreadAll = this.messages.filter((m) => m.kind !== "hidden" && m.audience !== "human" && m.seq > runtime.lastSeenSeq && (m.kind === "system" || m.from !== id || m.seq <= runtime.replayOwnUntilSeq));
1543
1543
  if (!unreadAll.some((m) => m.kind === "chat" || m.wakes))
1544
1544
  return;
1545
1545
  const cap = this.settings.backlogCap;
@@ -1660,6 +1660,7 @@ export class Room extends EventEmitter {
1660
1660
  }
1661
1661
  const startedAt = runtime.turn.startedAt;
1662
1662
  const published = runtime.turn.published;
1663
+ const publishedAt = runtime.turn.publishedAt ?? null;
1663
1664
  runtime.turn = null;
1664
1665
  runtime.turnActive = false;
1665
1666
  this.drafts.delete(draft.id);
@@ -1689,9 +1690,9 @@ export class Room extends EventEmitter {
1689
1690
  this.closeRetry(retry, "the correction turn failed; nothing was posted");
1690
1691
  return null;
1691
1692
  }
1692
- return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published);
1693
+ return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published, publishedAt);
1693
1694
  }
1694
- finalizeTurn(participant, runtime, draft, result, durationMs, retry, published) {
1695
+ finalizeTurn(participant, runtime, draft, result, durationMs, retry, published, publishedAt = null) {
1695
1696
  participant.status = "idle";
1696
1697
  this.push({ type: "participant", participant });
1697
1698
  const text = draft.text.trim();
@@ -1787,6 +1788,11 @@ export class Room extends EventEmitter {
1787
1788
  durationMs,
1788
1789
  };
1789
1790
  this.commit(message);
1791
+ if (publishedAt !== null && this.messages.some((x) => x.kind === "chat" && x.id !== message.id && x.ts > publishedAt)) {
1792
+ const at = new Date(draft.ts);
1793
+ const hhmm = `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
1794
+ this.postSystem(`${participant.name} finished the reply started at ${hhmm} · ${Math.round(durationMs / 1000)} s`, "human", false, { refId: message.id, agentId: participant.id });
1795
+ }
1790
1796
  if (retry) {
1791
1797
  this.closeRetry(retry, corrections.length ? `corrected reply posted, but it still breaks: ${corrections.map((c) => c.replace(/^reminder:\s*/i, "")).join("; ")}` : "corrected reply posted");
1792
1798
  }
@@ -1869,6 +1875,7 @@ export class Room extends EventEmitter {
1869
1875
  if (turn.published)
1870
1876
  return;
1871
1877
  turn.published = true;
1878
+ turn.publishedAt = Date.now();
1872
1879
  this.push({ type: "message", message: turn.message });
1873
1880
  }
1874
1881
  onSessionUpdate(id, update) {
@@ -2184,7 +2191,7 @@ export class Room extends EventEmitter {
2184
2191
  return p;
2185
2192
  return undefined;
2186
2193
  }
2187
- postSystem(text, audience, wakes) {
2194
+ postSystem(text, audience, wakes, details) {
2188
2195
  const message = {
2189
2196
  id: randomUUID(),
2190
2197
  seq: ++this.seq,
@@ -2200,6 +2207,8 @@ export class Room extends EventEmitter {
2200
2207
  message.audience = audience;
2201
2208
  if (wakes)
2202
2209
  message.wakes = true;
2210
+ if (details)
2211
+ message.details = details;
2203
2212
  this.commit(message);
2204
2213
  }
2205
2214
  async setDir(dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,18 +11,18 @@
11
11
  "turnTaking": "parallel",
12
12
  "agentsWakeEachOther": false,
13
13
  "hopLimit": 12,
14
- "customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to Forge and a question to Lumen.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case. The builder reports to the human, never to the other vibemate.\nMeasure before diagnosing; say no with a reason and a cheaper alternative; keep replies short.\nWork only on your own task, never on the other's; do not touch what the other is working on.\nReply only when addressed. On a message to everyone, only the one it concerns answers (a task: Forge; a question: Lumen); the other stays silent. Never add to, correct or comment on the other's reply unless the human asks.\nFollow the conventions of the project you work in (its instructions, notes, tests). Where it keeps decision notes, write the decision there before reporting it here.\nRead every proposal and analyse it; agree or reject only with a real argument. Never concede to be agreeable, never object to seem rigorous.\nReply in the human's language. Code identifiers, commands, file paths, protocol and tool names stay exactly as in the code; established technical terms (kernel, thread, commit, pull request, layout) stay in English."
14
+ "customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to Forge and a question to Lumen.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case. The builder reports to the human, never to the other vibemate. A build is reported as: what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified, what is left.\nMeasure before diagnosing; say no with a reason and a cheaper alternative; keep replies short.\nWork only on your own task, never on the other's; do not touch what the other is working on.\nReply only when addressed. On a message to everyone, only the one it concerns answers (a task: Forge; a question: Lumen); the other stays silent. Never add to, correct or comment on the other's reply unless the human asks.\nFollow the conventions of the project you work in (its instructions, notes, tests). Where it keeps decision notes, write the decision there before reporting it here.\nRead every proposal and analyse it; agree or reject only with a real argument. Never concede to be agreeable, never object to seem rigorous.\nReply in the human's language. Code identifiers, commands, file paths, protocol and tool names stay exactly as in the code; established technical terms (kernel, thread, commit, pull request, layout) stay in English."
15
15
  },
16
16
  "vibemates": [
17
17
  {
18
18
  "name": "Forge",
19
19
  "tagline": "builds by default; explains and reviews when asked",
20
- "role": "You are Forge, one of two equal vibemates, Forge and Lumen. Either of you builds, explains or reviews, whatever the human asks; without an address, a task goes to Forge and a question to Lumen. The human decides when work is reviewed; do not review, comment on or extend the other's work unless asked, and do not answer the other's report unless it asks you something. When you build, read the code first, keep the change small, and report to the human — never to the other vibemate — what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified and what is left. When asked to review, first say in one line what you will check, then review by that criterion, not an impression: a number, a live check, the risky case. Say no with a reason and a cheaper alternative; never concede to be agreeable, never object to seem rigorous. Work only on your own task, never on the other's; do not touch what the other is working on. On a message to everyone, answer only if it concerns you; never add to, correct or comment on the other's reply unless the human asks. Before building something, check whether the other already did it."
20
+ "role": "You are Forge, one of two equal vibemates, Forge and Lumen. You lean to building: a task without an address is yours; read the code before you change it and keep each change small. Everything else is in the room rules."
21
21
  },
22
22
  {
23
23
  "name": "Lumen",
24
24
  "tagline": "explains by default; builds and reviews when asked",
25
- "role": "You are Lumen, one of two equal vibemates, Forge and Lumen. Either of you builds, explains or reviews, whatever the human asks; without an address, a task goes to Forge and a question to Lumen. The human decides when work is reviewed; do not review, comment on or extend the other's work unless asked, and do not answer the other's report unless it asks you something. When you build, read the code first, keep the change small, and report to the human — never to the other vibemate — what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified and what is left. When asked to review, first say in one line what you will check, then review by that criterion, not an impression: a number, a live check, the risky case. Say no with a reason and a cheaper alternative; never concede to be agreeable, never object to seem rigorous. Work only on your own task, never on the other's; do not touch what the other is working on. On a message to everyone, answer only if it concerns you; never add to, correct or comment on the other's reply unless the human asks. Before building something, check whether the other already did it."
25
+ "role": "You are Lumen, one of two equal vibemates, Forge and Lumen. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules."
26
26
  }
27
27
  ]
28
28
  }
package/ui/app.css CHANGED
@@ -264,7 +264,9 @@
264
264
  .jump-latest { position: absolute; right: 30px; bottom: 96px; z-index: 5; display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px 0 10px; border: 0; border-radius: var(--r-pill); background: var(--card); color: var(--primary); font: inherit; font-size: 12px; font-weight: 800; box-shadow: var(--shadow-pop); cursor: pointer; animation: rise var(--t-base) var(--ease-out); }
265
265
  .jump-latest:hover { background: var(--soft); }
266
266
  .done-notes { position: absolute; left: 30px; bottom: 96px; z-index: 5; display: flex; flex-direction: column-reverse; gap: 6px; }
267
- .done-note { display: inline-flex; align-items: stretch; max-width: 230px; border-radius: 14px; background: var(--warm); color: var(--warm-ink); box-shadow: var(--shadow-pop); animation: rise var(--t-base) var(--ease-out); overflow: hidden; }
267
+ .done-note { display: inline-flex; align-items: center; gap: 8px; max-width: 230px; padding: 6px 12px 6px 8px; border: 0; border-radius: 14px; background: var(--warm); color: var(--warm-ink); font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow-pop); animation: rise var(--t-base) var(--ease-out); overflow: hidden; }
268
+ .done-note:hover { background: #ffeaa8; }
269
+ .done-note .avatar { flex: none; }
268
270
  .done-go { display: inline-flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 4px 6px 8px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
269
271
  .done-go:hover { background: #ffeaa8; }
270
272
  .done-go .avatar { flex: none; }
@@ -372,7 +374,20 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
372
374
  .tool { display: inline-flex; flex-direction: column; max-width: 100%; min-width: 0; }
373
375
  .tool.open { flex-basis: 100%; }
374
376
  .tool > .chip { font: inherit; font-size: 11px; font-weight: 800; cursor: pointer; text-align: left; }
375
- .tool-body { margin: 4px 0 2px; padding: 8px 10px; background: var(--soft); border-radius: 10px; font-size: 12px; max-height: 340px; overflow: auto; }
377
+ .tool > .chip-completed { --tool-bg: var(--mint); --tool-ink: var(--mint-ink); }
378
+ .tool > .chip-failed { --tool-bg: var(--rose); --tool-ink: var(--rose-ink); }
379
+ .tool > .chip-in_progress { --tool-bg: var(--warm); --tool-ink: var(--warm-ink); }
380
+ .tool > .chip-pending { --tool-bg: var(--lav); --tool-ink: var(--primary); }
381
+ .tool > .chip::after { content: "▾"; margin-left: auto; padding-left: 8px; opacity: 0.55; transition: transform var(--t-fast); }
382
+ .tool.open > .chip { width: 100%; border-radius: 12px 12px 0 0; padding: 6px 12px; white-space: normal; }
383
+ .tool.open > .chip::after { transform: rotate(180deg); }
384
+ .tool.open { --tool-bg: var(--lav); --tool-ink: var(--primary); }
385
+ .tool.open > .chip-completed ~ .tool-body, .tool.open > .chip-completed + .tool-body { --tool-bg: var(--mint); --tool-ink: var(--mint-ink); }
386
+ .tool.open > .chip-failed + .tool-body { --tool-bg: var(--rose); --tool-ink: var(--rose-ink); }
387
+ .tool.open > .chip-in_progress + .tool-body { --tool-bg: var(--warm); --tool-ink: var(--warm-ink); }
388
+ .tool-body { margin: 0; padding: 8px 10px 10px; background: var(--card); border: 2px solid var(--tool-bg, var(--lav)); border-top: 0; border-radius: 0 0 12px 12px; font-size: 12px; max-height: 340px; overflow: auto; }
389
+ .tool-body pre { background: var(--soft); color: var(--ink); padding: 6px 8px; border-radius: 8px; }
390
+ .tool-sec b { color: var(--tool-ink, var(--muted)); }
376
391
  .tool-sec + .tool-sec { margin-top: 8px; }
377
392
  .tool-sec b { display: block; font-size: 10.5px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
378
393
  .tool-sec.muted { color: var(--muted); font-style: italic; }
@@ -466,6 +481,8 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
466
481
  .composer textarea::placeholder { color: var(--placeholder); font-weight: 600; }
467
482
  .send-btn { width: 44px; height: 44px; border-radius: 14px; border: 0; background: var(--grad-primary); color: #fff; display: grid; place-content: center; box-shadow: var(--shadow-primary); flex: none; transition: transform var(--t-fast) var(--ease-out), filter var(--t-fast); }
468
483
  .send-btn .i { width: 18px; height: 18px; display: block; }
484
+ .composer-clear { width: 28px; height: 28px; border-radius: 50%; border: 0; background: transparent; color: var(--placeholder); font-size: 20px; line-height: 1; cursor: pointer; flex: none; margin-right: 2px; transition: background var(--t-fast), color var(--t-fast); }
485
+ .composer-clear:hover { background: var(--soft); color: var(--danger); }
469
486
  .send-btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
470
487
  .send-btn:active { transform: translateY(1px); }
471
488
  .chat .mention-menu { left: 24px; bottom: calc(var(--composer-h, 60px) + 18px); }
@@ -572,3 +589,10 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
572
589
  .composer.gated { opacity: 0.7; }
573
590
  .composer.gated textarea { cursor: not-allowed; }
574
591
  .check-list.locked { pointer-events: none; opacity: 0.6; }
592
+ .msg.system.done { justify-content: center; }
593
+ .done-row { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 8px; border: 0; border-radius: var(--r-pill); background: var(--warm); color: var(--warm-ink); font: inherit; font-size: 12px; font-weight: 800; cursor: pointer; box-shadow: var(--edge); }
594
+ .done-row:hover { background: #ffeaa8; }
595
+ .done-notes { flex-direction: column; }
596
+ .done-note.new { background: var(--card); color: var(--ink-2); }
597
+ .done-note.new:hover { background: var(--soft); }
598
+ .meta .seen-by { color: var(--primary); font-weight: 800; margin-left: 6px; }
package/ui/app.js CHANGED
@@ -1210,6 +1210,12 @@
1210
1210
  }
1211
1211
  if (m.kind === "system") {
1212
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
+ }
1213
1219
  if (m.audience === "agents") {
1214
1220
  el.className = "msg hidden";
1215
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>`;
@@ -1291,6 +1297,8 @@
1291
1297
  if (e.key === "Escape" && !els.lightbox.hidden) closeLightbox();
1292
1298
  });
1293
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}"]`));
1294
1302
  const shot = e.target.closest(".shot");
1295
1303
  if (shot) return void openLightbox(shot.dataset.src, shot.title);
1296
1304
  const ref = e.target.closest(".img-ref");
@@ -1409,7 +1417,8 @@
1409
1417
  if (m.durationMs) parts.push(`<span title="how long the reply took">${ic("clock")} ${(m.durationMs / 1000).toFixed(1)} s</span>`);
1410
1418
  if (m.usage) parts.push(`<span title="tokens in">${ic("arrow-down")} ${fmtTokens(m.usage.inputTokens)}</span><span title="tokens out">${ic("arrow-up")} ${fmtTokens(m.usage.outputTokens)}</span>${m.usage.cachedWriteTokens ? `<span title="tokens written to the cache">${ic("database")} ${fmtTokens(m.usage.cachedWriteTokens)}</span>` : ""}`);
1411
1419
  meta.innerHTML = parts.join("<span class=\"sep\">·</span>");
1412
- } else if (m.from === "human") refreshSeen(room);
1420
+ fillSeen(el, room, m);
1421
+ } else if (m.from === "human") fillSeen(el, room, m);
1413
1422
  else meta.textContent = "";
1414
1423
  }
1415
1424
 
@@ -1421,23 +1430,47 @@
1421
1430
  return null;
1422
1431
  }
1423
1432
  function seenHtml(room, m) {
1424
- const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline");
1433
+ const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline" && p.id !== m.from);
1434
+ if (!present.length) return "";
1425
1435
  const seen = present.filter((p) => p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
1436
+ if (seen.length === present.length) return "";
1426
1437
  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`;
1428
1438
  return `<span class="ticks">✓✓</span> seen by ${esc(seen.map((p) => p.name).join(", "))}`;
1429
1439
  }
1440
+ function fillSeen(el, room, m) {
1441
+ if (m.kind !== "chat" || m.streaming) return;
1442
+ const html = seenHtml(room, m);
1443
+ if (m.from === "human") {
1444
+ const meta = el.querySelector(".meta");
1445
+ if (!meta) return;
1446
+ meta.innerHTML = html;
1447
+ meta.classList.toggle("seen", !!html);
1448
+ } else {
1449
+ const meta = el.querySelector(".meta");
1450
+ let span = meta && meta.querySelector(".seen-by");
1451
+ if (!span && meta && html) {
1452
+ span = document.createElement("span");
1453
+ span.className = "seen-by";
1454
+ meta.appendChild(span);
1455
+ }
1456
+ if (span) {
1457
+ span.innerHTML = html;
1458
+ span.hidden = !html;
1459
+ }
1460
+ }
1461
+ }
1430
1462
  function refreshSeen(room) {
1431
- const last = lastHumanMessage(room);
1463
+ const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline");
1464
+ const floor = present.length ? Math.min(...present.map((p) => (p.lastSeenSeq == null ? 0 : p.lastSeenSeq))) : Infinity;
1432
1465
  const byId = new Map(room.messages.map((m) => [m.id, m]));
1433
- for (const el of els.messages.querySelectorAll(".msg.mine")) {
1434
- const meta = el.querySelector(".meta");
1435
- if (!meta) continue;
1436
- const isLast = last && el.dataset.id === last.id;
1437
- meta.innerHTML = isLast ? seenHtml(room, last) : "";
1438
- meta.classList.toggle("seen", !!isLast);
1466
+ for (const el of els.messages.querySelectorAll(".msg.mine, .msg.agent")) {
1467
+ const seq = Number(el.dataset.seq);
1468
+ const hasLabel = el.querySelector(".meta.seen, .seen-by:not([hidden])");
1469
+ if (seq <= floor && !hasLabel) continue;
1439
1470
  const m = byId.get(el.dataset.id);
1440
- if (m && (isLast || el.classList.contains("waiting"))) renderWaiting(el, room, m);
1471
+ if (!m) continue;
1472
+ fillSeen(el, room, m);
1473
+ if (m.from === "human" && (seq > floor || el.classList.contains("waiting"))) renderWaiting(el, room, m);
1441
1474
  }
1442
1475
  }
1443
1476
 
@@ -1528,6 +1561,7 @@
1528
1561
  if (empty) empty.remove();
1529
1562
  els.messages.appendChild(messageElement(room, m));
1530
1563
  if (m.from === "human") refreshSeen(room);
1564
+ else if (!stick && m.kind === "chat") noteNew(room, m);
1531
1565
  }
1532
1566
  if (stick) scrollToBottom();
1533
1567
  if (m.streaming) updateWorkingNow();
@@ -3175,7 +3209,20 @@
3175
3209
  let shotSeq = 0;
3176
3210
  const shotMarker = (n) => `[img ${n}]`;
3177
3211
 
3212
+ const composerClear = $("#composer-clear");
3213
+ function updateComposerClear() {
3214
+ composerClear.hidden = !(els.input.value.trim() || pendingShots.length);
3215
+ }
3216
+ composerClear.addEventListener("click", () => {
3217
+ els.input.value = "";
3218
+ clearShots();
3219
+ autosize();
3220
+ updateComposerClear();
3221
+ els.input.focus();
3222
+ });
3223
+ els.input.addEventListener("input", updateComposerClear);
3178
3224
  function renderShotsTray() {
3225
+ updateComposerClear();
3179
3226
  els.shotsTray.hidden = !pendingShots.length;
3180
3227
  els.shotsTray.innerHTML = pendingShots
3181
3228
  .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>`)
@@ -3652,6 +3699,8 @@
3652
3699
  els.jumpLatest.hidden = stuck;
3653
3700
  updateTimelineView();
3654
3701
  updateWorkingNow();
3702
+ if (stuck) clearNotes();
3703
+ else pruneDoneNotes();
3655
3704
  });
3656
3705
  els.jumpLatest.addEventListener("click", scrollToBottom);
3657
3706
  document.addEventListener("mousedown", (e) => {
@@ -3860,6 +3909,7 @@
3860
3909
  return (t ? t.textContent : el.textContent).trim().replace(/\s+/g, " ").slice(0, 240);
3861
3910
  }
3862
3911
  const doneNotes = [];
3912
+ const NOTE_TTL_MS = 4000;
3863
3913
  function bubbleInView(id) {
3864
3914
  const head = els.messages.querySelector(`.msg[data-id="${id}"] .head`);
3865
3915
  if (!head) return false;
@@ -3867,14 +3917,43 @@
3867
3917
  const r = head.getBoundingClientRect();
3868
3918
  return r.bottom > box.top && r.top < box.bottom;
3869
3919
  }
3920
+ function pushNote(note) {
3921
+ const i = doneNotes.findIndex((n) => n.id === note.id);
3922
+ if (i >= 0) {
3923
+ clearTimeout(doneNotes[i].timer);
3924
+ doneNotes.splice(i, 1);
3925
+ }
3926
+ note.timer = setTimeout(() => dropNote(note.id), NOTE_TTL_MS);
3927
+ doneNotes.push(note);
3928
+ while (doneNotes.length > 4) dropNote(doneNotes[0].id);
3929
+ renderDoneNotes();
3930
+ }
3931
+ function dropNote(id) {
3932
+ const i = doneNotes.findIndex((n) => n.id === id);
3933
+ if (i < 0) return;
3934
+ clearTimeout(doneNotes[i].timer);
3935
+ doneNotes.splice(i, 1);
3936
+ renderDoneNotes();
3937
+ }
3938
+ function clearNotes() {
3939
+ for (const n of doneNotes) clearTimeout(n.timer);
3940
+ doneNotes.length = 0;
3941
+ renderDoneNotes();
3942
+ }
3870
3943
  function noteFinished(room, m) {
3871
3944
  if (room.id !== state.currentRoomId || state.view !== "room") return;
3872
3945
  requestAnimationFrame(() => {
3873
- if (bubbleInView(m.id) || doneNotes.some((n) => n.id === m.id)) return;
3946
+ if (bubbleInView(m.id)) return;
3874
3947
  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();
3948
+ pushNote({ id: m.id, kind: "done", p, text: `${p.name} finished`, sub: `started ${time(m.ts)}${m.durationMs ? ` · ${spanText(m.durationMs)}` : ""}` });
3949
+ });
3950
+ }
3951
+ function noteNew(room, m) {
3952
+ if (room.id !== state.currentRoomId || state.view !== "room") return;
3953
+ requestAnimationFrame(() => {
3954
+ if (bubbleInView(m.id)) return;
3955
+ const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: "agent" };
3956
+ pushNote({ id: m.id, kind: "new", p, text: `${p.name} wrote below`, sub: time(m.ts) });
3878
3957
  });
3879
3958
  }
3880
3959
  function spanText(ms) {
@@ -3885,19 +3964,18 @@
3885
3964
  function renderDoneNotes() {
3886
3965
  els.doneNotes.hidden = doneNotes.length === 0;
3887
3966
  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>`)
3967
+ .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>`)
3889
3968
  .join("");
3890
3969
  }
3970
+ function pruneDoneNotes() {
3971
+ for (const n of [...doneNotes]) if (bubbleInView(n.id)) dropNote(n.id);
3972
+ }
3891
3973
  els.doneNotes.addEventListener("click", (e) => {
3892
3974
  const note = e.target.closest(".done-note");
3893
3975
  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}"]`));
3976
+ const id = note.dataset.id;
3977
+ dropNote(id);
3978
+ jumpToMessage(els.messages.querySelector(`.msg[data-id="${id}"]`));
3901
3979
  });
3902
3980
  function jumpToMessage(el) {
3903
3981
  if (!el) return;
package/ui/index.html CHANGED
@@ -103,6 +103,7 @@
103
103
  <div class="shots-tray" id="shots-tray" hidden></div>
104
104
  <textarea id="input" rows="1" placeholder="Message the room… @Name or /skill" title="Enter sends, Shift+Enter is a new line; paste or drop an image"></textarea>
105
105
  </div>
106
+ <button type="button" id="composer-clear" class="composer-clear" title="Clear the message" hidden>×</button>
106
107
  <button type="submit" class="send-btn" title="Send"><span data-icon="send"></span></button>
107
108
  </form>
108
109
  </section>