roger-roger 0.1.4 → 0.1.13

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.
@@ -535,8 +535,63 @@ export function slackText({ kind = "info", message, project, mention }) {
535
535
 
536
536
  /** What gets spoken: the agent's `--say` line, unless the user wants the message read verbatim. */
537
537
  export function speechText({ mode = "auto", message, say }) {
538
- if (mode === "same") return message;
539
- return say || message;
538
+ return speakable(mode === "same" ? message : say || message);
539
+ }
540
+
541
+ // A word that only makes sense on a screen: shell punctuation, a flag, a path, a quote, a template.
542
+ const CODEISH = /[{}$|;<>=\\`"']|^--?[a-z]|[\w.~-]\/[\w.-]|^https?:|^[\w-]+\.[a-z]{2,4}[,.]?$|^\w+\(|\)$/i;
543
+
544
+ /**
545
+ * Does this stretch read as code rather than as a sentence? `quoted` is for text already marked as
546
+ * code (in backticks): there one flag or path is enough. Loose text needs two, and a fair share.
547
+ */
548
+ export function looksLikeCode(text, { quoted = false } = {}) {
549
+ const words = String(text).trim().split(/\s+/).filter(Boolean);
550
+ if (!words.length) return false;
551
+ const codeish = words.filter((w) => CODEISH.test(w)).length;
552
+ if (words.length === 1) return words[0].length > 24 || /[{}$|;<>=\\/]/.test(words[0]);
553
+ if (quoted) return codeish >= 1;
554
+ return codeish >= 2 && codeish / words.length >= 0.3;
555
+ }
556
+
557
+ /** What a piece of code is, in words: "an ssh command", "a file", or just "a command". */
558
+ function nameCode(code) {
559
+ const s = String(code).trim();
560
+ if (!/\s/.test(s) && /[\/\\]/.test(s) && !/^https?:/i.test(s)) return "a file";
561
+ const first = s.split(/\s+/)[0].replace(/^[^\w]+/, "");
562
+ // "an ssh", "an npm", "a git": a word with no vowel is spelt out, and f, h, l, m, n, r, s, x start with a vowel sound.
563
+ const an = /^[aeiou]/i.test(first) || (!/[aeiouy]/i.test(first) && /^[fhlmnrsx]/i.test(first));
564
+ if (/^[a-z][\w-]{1,15}$/i.test(first) && !/^(the|a|an|and|or|to|then)$/i.test(first)) return `${an ? "an" : "a"} ${first} command`;
565
+ return "a command";
566
+ }
567
+
568
+ /**
569
+ * The line as it should be heard. Nobody wants a voice reading out `ssh -o BatchMode=yes …` or a
570
+ * URL, so code is said as what it is: a fenced block becomes "the details are in Slack", inline
571
+ * code that is really code becomes "an lsblk command" or "a file", a link becomes "a link", and a
572
+ * stretch of bare command line becomes what it is too. Markdown emphasis goes; tone tags stay.
573
+ */
574
+ export function speakable(text) {
575
+ let s = String(text ?? "");
576
+ s = s.replace(/```[\s\S]*?(```|$)/g, " (the details are in Slack) ");
577
+ s = s.replace(/`([^`\n]+)`/g, (_, code) => (looksLikeCode(code, { quoted: true }) ? nameCode(code) : code));
578
+ s = s.replace(/<?https?:\/\/[^\s>|]+(\|[^>]*)?>?/gi, "a link");
579
+ s = s
580
+ .split(/\n+|(?<=[.!?])\s+(?=[A-Z[])/)
581
+ .map((part) => {
582
+ // A sentence that ends in a command ("Can I run ssh -o … 'df -h'") keeps its words and names the command.
583
+ const words = part.trim().split(/\s+/);
584
+ const start = words.findIndex((w, i) => looksLikeCode(words.slice(i).join(" ")) && CODEISH.test(words[Math.min(i + 1, words.length - 1)]));
585
+ if (start < 0) return part;
586
+ return [...words.slice(0, start), nameCode(words.slice(start).join(" "))].join(" ");
587
+ })
588
+ .join(" ");
589
+ return s
590
+ .replace(/(^|\s)[*_~]+(?=\S)|(?<=\S)[*_~]+(?=\s|[,.!?;:]|$)/g, "$1")
591
+ .replace(/^\s*[>•-]\s+/gm, "")
592
+ .replace(/\s+([,.!?;:])/g, "$1")
593
+ .replace(/\s{2,}/g, " ")
594
+ .trim();
540
595
  }
541
596
 
542
597
  // Several agents share one pair of speakers, and a voice from the next room with no name on it is
@@ -68,10 +68,22 @@ export function isNickname(value) {
68
68
  * A free nickname from the pool, chosen at random so consecutive sessions don't look alike. Falls
69
69
  * back to numbering (`sage2`) only once every name in the pool is in use.
70
70
  */
71
- export function pickName(taken = [], pool = NAMES, random = Math.random) {
71
+ /**
72
+ * A name for a new session. `taken` are names in use right now and are never given out; `avoid`
73
+ * are names a quiet session was using lately, given out only once nothing else is left, so a
74
+ * session that goes quiet for an hour comes back to its own name rather than someone else's.
75
+ */
76
+ export function pickName(taken = [], pool = NAMES, random = Math.random, { avoid = [] } = {}) {
72
77
  const used = new Set(taken.map((n) => String(n).toLowerCase()));
73
78
  const names = (pool.length ? pool : NAMES).map((n) => String(n).toLowerCase()).filter(isNickname);
74
- const free = names.filter((n) => !used.has(n));
79
+ // `avoid` runs from the most recently quiet to the longest quiet. Names are freed from the end
80
+ // of it one at a time, so the name lent out is the one whose owner has been gone the longest.
81
+ const held = avoid.map((n) => String(n).toLowerCase());
82
+ let free = [];
83
+ for (let keep = held.length; keep >= 0 && !free.length; keep--) {
84
+ const reserved = new Set(held.slice(0, keep));
85
+ free = names.filter((n) => !used.has(n) && !reserved.has(n));
86
+ }
75
87
  // Two of a kind side by side defeat the point, so prefer an emoji nobody is wearing.
76
88
  const swatches = new Set([...used].map((n) => colourOf(n).swatch));
77
89
  const distinct = free.filter((n) => !swatches.has(colourOf(n).swatch));
@@ -465,6 +465,7 @@ async function main() {
465
465
  case "control":
466
466
  case "listen":
467
467
  case "snooze":
468
+ case "hush":
468
469
  case "tray":
469
470
  case "ask":
470
471
  case "wait":
@@ -16,7 +16,8 @@ import { NAMES, colourOf, pickName } from "./names.mjs";
16
16
  const QUIET_MS = 30 * 60 * 1000;
17
17
  // Even with a live pid, a session that hasn't said anything all day is no longer a sensible target.
18
18
  const MAX_IDLE_MS = 12 * 3600 * 1000;
19
- const FORGET_MS = 7 * 24 * 3600 * 1000;
19
+ // A conversation can be picked up again weeks later, and should come back under the name it had.
20
+ const FORGET_MS = 30 * 24 * 3600 * 1000;
20
21
  const MAX_QUEUE = 50;
21
22
 
22
23
  export const STATES = ["working", "waiting", "paused", "done"];
@@ -87,15 +88,20 @@ export function register(identity, about = {}, pool = NAMES) {
87
88
  : identity.id;
88
89
  if (fingerprint) state.aliases[fingerprint] = id;
89
90
  const existing = state.sessions[id];
90
- const taken = Object.values(state.sessions)
91
- .filter((s) => s.id !== id && isLive(s))
91
+ const others = Object.values(state.sessions).filter((s) => s.id !== id && !s.endedAt);
92
+ const taken = others.filter((s) => isLive(s)).map((s) => s.nickname);
93
+ // Names quiet sessions are still remembered by, the most recently quiet first: a newcomer gets
94
+ // one only when nothing else is free, and then the one whose owner has been gone longest.
95
+ const avoid = others
96
+ .filter((s) => !isLive(s))
97
+ .sort((a, b) => Date.parse(b.lastSeen ?? 0) - Date.parse(a.lastSeen ?? 0))
92
98
  .map((s) => s.nickname);
93
99
  const session = {
94
100
  ...existing,
95
101
  id,
96
102
  // A session keeps its name, unless someone else was given it while this one was quiet — two
97
103
  // live agents answering to "jade" is exactly what the names are there to prevent.
98
- nickname: existing?.nickname && !taken.includes(existing.nickname) ? existing.nickname : pickName(taken, pool),
104
+ nickname: existing?.nickname && !taken.includes(existing.nickname) ? existing.nickname : pickName(taken, pool, Math.random, { avoid }),
99
105
  pid: identity.pid || existing?.pid || 0,
100
106
  cwd: identity.cwd ?? existing?.cwd ?? "",
101
107
  project: identity.project || existing?.project || "",
@@ -11,6 +11,7 @@ import {
11
11
  rogerRogerHome, inQuietHours, listSounds, snoozeUntil,
12
12
  } from "./lib.mjs";
13
13
  import * as decisions from "./decisions.mjs";
14
+ import * as activity from "./activity.mjs";
14
15
  import * as progressStore from "./progress.mjs";
15
16
  import * as sessions from "./sessions.mjs";
16
17
  import { PROVIDERS, catalog, keyFor, providerOf } from "./tts.mjs";
@@ -46,6 +47,8 @@ export function snapshot({ config = null, daemon = null, now = Date.now() } = {}
46
47
  return {
47
48
  updatedAt: new Date(now).toISOString(),
48
49
  daemon: daemon ? { pid: daemon.pid, slackConnected: Boolean(daemon.slackConnected) } : null,
50
+ // Who is speaking right now ({ who, since }), so the panel can offer to stop it.
51
+ speaking: daemon?.speaking ?? null,
49
52
  muted: {
50
53
  snoozeUntil: snoozeUntil(config, new Date(now)),
51
54
  quietHours: config?.quietHours ?? null,
@@ -61,6 +64,8 @@ export function snapshot({ config = null, daemon = null, now = Date.now() } = {}
61
64
  expiresAt: d.expiresAt,
62
65
  })),
63
66
  working: progressStore.active(now).map((p) => ({ key: p.key, headline: p.headline, nickname: p.nickname ?? null })),
67
+ // Who said what lately, newest first: the Log view.
68
+ log: activity.recent(60),
64
69
  // Everything `setup` can change, so the panel can offer it without asking anyone. Tokens live
65
70
  // in slack.json and the environment and are deliberately not here.
66
71
  settings: config
@@ -191,6 +191,8 @@ function tooltip(state, stale) {
191
191
  */
192
192
  function menu(state, spot) {
193
193
  const items = [];
194
+ // While a voice plays, the first thing on the menu stops it.
195
+ if (state.speaking) items.push({ id: "hush", label: state.speaking.who ? `Stop ${state.speaking.who} speaking` : "Stop the voice" }, { id: "sep0", label: "-" });
194
196
  if (state.muted?.snoozeUntil) {
195
197
  items.push({ id: "snooze:off", label: "Stop snoozing" });
196
198
  } else {
@@ -203,9 +205,10 @@ function menu(state, spot) {
203
205
  return { items };
204
206
  }
205
207
 
206
- const menuKey = (state, spot) => `${state.muted?.snoozeUntil ? "snoozed" : "awake"}:${spot ? "spot" : "free"}`;
208
+ const menuKey = (state, spot) => `${state.muted?.snoozeUntil ? "snoozed" : "awake"}:${spot ? "spot" : "free"}:${state.speaking ? `speaking-${state.speaking.who ?? ""}` : "silent"}`;
207
209
 
208
210
  function onMenu(id) {
211
+ if (id === "hush") return run("hush");
209
212
  if (id === "snooze:off") return run("snooze", "off");
210
213
  if (id?.startsWith("snooze:")) return run("snooze", id.slice("snooze:".length));
211
214
  return null;
@@ -463,6 +466,7 @@ async function main() {
463
466
  return log(`the panel said something I could not read: ${message?.body ?? message}`);
464
467
  }
465
468
  if (ask.do === "snooze") return run("snooze", ask.value || "1h");
469
+ if (ask.do === "hush") return run("hush");
466
470
  if (ask.do === "hide") return hidePanel();
467
471
  if (ask.do === "keep-open") {
468
472
  keepOpen = Boolean(ask.value);
@@ -141,6 +141,40 @@
141
141
  .slider input[type="range"] { flex: 1; accent-color: var(--accent); padding: 0; border: 0; background: transparent; }
142
142
  .slider output { width: 40px; text-align: right; font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
143
143
  .flash.is-ok { border-color: #7fd1a0; }
144
+
145
+ /* The log: who said what, newest first. The agent's colour runs down the left edge. */
146
+ .row.is-agent { cursor: pointer; }
147
+ .filters { display: flex; gap: 4px; flex-wrap: wrap; padding: 6px 4px 8px; }
148
+ .entry { position: relative; padding: 8px 8px 8px 12px; border-bottom: 1px solid var(--border-subtle); cursor: pointer; }
149
+ .entry::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px; background: var(--edge, var(--text-muted)); }
150
+ .entry.from-user::before { background: var(--accent); }
151
+ .entry:hover { background: color-mix(in srgb, var(--bg-section) 60%, transparent); }
152
+ .entry .top { gap: 6px; font-size: 11px; color: var(--text-dim); }
153
+ .entry .name { font-weight: 600; color: var(--text); }
154
+ .entry .arrow { color: var(--text-muted); }
155
+ .entry .kind { padding: 0 5px; border: 1px solid var(--border-subtle); font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-muted); }
156
+ .entry .kind.k-blocked, .entry .kind.k-error, .entry .kind.k-question { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); }
157
+ .entry .kind.k-done, .entry .kind.k-answer { color: #7fd1a0; border-color: #7fd1a055; }
158
+ .entry .text { margin-top: 4px; line-height: 1.4; color: var(--text); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
159
+ .entry.is-open .text { display: block; }
160
+ .entry .detail { margin-top: 3px; font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
161
+ .entry.is-open .detail { white-space: normal; }
162
+ .day { padding: 12px 8px 4px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
163
+
164
+ /* On air: shown only while a voice or sound plays, with the one thing you might want — stop it. */
165
+ .onair { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-bottom: 1px solid var(--border-subtle); background: color-mix(in srgb, var(--accent) 12%, transparent); }
166
+ .onair[hidden] { display: none; }
167
+ .onair .bars { display: flex; align-items: flex-end; gap: 2px; height: 14px; }
168
+ .onair .bars i { width: 3px; background: var(--accent); animation: level 900ms ease-in-out infinite; }
169
+ .onair .bars i:nth-child(2) { animation-delay: -300ms; }
170
+ .onair .bars i:nth-child(3) { animation-delay: -600ms; }
171
+ @keyframes level { 0%, 100% { height: 4px; } 50% { height: 14px; } }
172
+ @media (prefers-reduced-motion: reduce) { .onair .bars i { animation: none; height: 10px; } }
173
+ .onair .label { flex: 1; min-width: 0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
174
+ .onair .label strong { font-weight: 600; }
175
+ .onair button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; font-weight: 600; color: #101317; background: var(--accent); border-color: var(--accent); }
176
+ .onair button:hover { filter: brightness(1.08); }
177
+ .onair button svg { width: 11px; height: 11px; fill: currentColor; stroke: none; }
144
178
  svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
145
179
  </style>
146
180
  </head>
@@ -151,6 +185,9 @@
151
185
  <span class="count" id="count"></span>
152
186
  <span class="spacer"></span>
153
187
  <span class="badge" id="badge" hidden></span>
188
+ <button class="icon-btn icon-btn-sm" id="log" title="Log: who said what">
189
+ <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path><path d="M8 9h8"></path><path d="M8 13h5"></path></svg>
190
+ </button>
154
191
  <button class="icon-btn icon-btn-sm" id="pin" title="Keep open when I click elsewhere">
155
192
  <svg viewBox="0 0 24 24"><path d="M12 17v5"></path><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"></path></svg>
156
193
  </button>
@@ -158,6 +195,13 @@
158
195
  <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6h.09A1.65 1.65 0 0 0 10 3.09V3a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
159
196
  </button>
160
197
  </header>
198
+ <div class="onair" id="onair" hidden>
199
+ <span class="bars" aria-hidden="true"><i></i><i></i><i></i></span>
200
+ <span class="label" id="onair-label"></span>
201
+ <button data-do="hush" title="Stop this announcement (the Slack message stays)">
202
+ <svg viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14"></rect></svg>Stop
203
+ </button>
204
+ </div>
161
205
  <main id="body"></main>
162
206
  <div class="flash" id="flash" hidden></div>
163
207
  <footer id="actions"></footer>
@@ -168,9 +212,15 @@
168
212
  const plural = (n, one, many) => n + " " + (n === 1 ? one : many);
169
213
  const send = (body) => window.ipc.postMessage(JSON.stringify(body));
170
214
 
171
- let latest = { agents: [], questions: [], working: [] };
215
+ let latest = { agents: [], questions: [], working: [], log: [] };
172
216
  let settingsOpen = false;
173
217
  let advancedOpen = false;
218
+ // The log view, which agent it is narrowed to (null: everyone), and which entries are opened out.
219
+ let logOpen = false;
220
+ let logFilter = null;
221
+ // Stop was pressed: hide the strip at once rather than waiting for the daemon to say it stopped.
222
+ let hushedAt = 0;
223
+ const openEntries = new Set();
174
224
 
175
225
  function ago(ms) {
176
226
  const s = Math.round((ms ?? 0) / 1000);
@@ -197,7 +247,7 @@
197
247
  if (a.queued) bits.push(plural(a.queued, "message", "messages") + " unread");
198
248
  if (a.owes) bits.push(plural(a.owes, "answer", "answers") + " to collect");
199
249
  return (
200
- '<div class="row"><div class="top">' +
250
+ '<div class="row is-agent" data-agent="' + esc(a.nickname) + '" title="Show what ' + esc(a.nickname) + ' said"><div class="top">' +
201
251
  '<span class="status-dot"' + colour + "></span>" +
202
252
  '<span class="who">' + esc(a.nickname) + "</span>" +
203
253
  '<span class="state">' + esc(a.state) + "</span>" +
@@ -217,6 +267,70 @@
217
267
  );
218
268
  }
219
269
 
270
+ // ---------------------------------------------------------------- log
271
+
272
+ const KIND_LABEL = { done: "done", blocked: "blocked", input: "needs you", milestone: "milestone", error: "error", info: "info", question: "question", answer: "answer", message: "message", cancelled: "cancelled", expired: "expired" };
273
+
274
+ function clock(iso) {
275
+ const d = new Date(iso);
276
+ return isFinite(d) ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
277
+ }
278
+
279
+ function dayOf(iso) {
280
+ const d = new Date(iso);
281
+ if (!isFinite(d)) return "";
282
+ const today = new Date();
283
+ const yesterday = new Date(today.getTime() - 864e5);
284
+ if (d.toDateString() === today.toDateString()) return "Today";
285
+ if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
286
+ return d.toLocaleDateString([], { weekday: "long", day: "numeric", month: "short" });
287
+ }
288
+
289
+ function entryRow(e, i) {
290
+ const name = (a) => '<span class="name">' + esc(a ? (a.swatch ? a.swatch + " " : "") + a.nickname : "an agent") + "</span>";
291
+ const who = e.from === "user"
292
+ ? '<span class="name">you</span>' + (e.agent ? '<span class="arrow">→</span>' + name(e.agent) : "")
293
+ : name(e.agent) + '<span class="arrow">→</span><span class="name">you</span>';
294
+ const key = e.at + "|" + i;
295
+ const edge = e.agent && e.agent.colour ? ' style="--edge:' + esc(e.agent.colour) + '"' : "";
296
+ const detail = e.kind === "answer" && e.detail ? "to: " + e.detail : e.detail;
297
+ return (
298
+ '<div class="entry' + (e.from === "user" ? " from-user" : "") + (openEntries.has(key) ? " is-open" : "") + '" data-entry="' + esc(key) + '"' + edge + ">" +
299
+ '<div class="top">' + who +
300
+ '<span class="kind k-' + esc(e.kind) + '">' + esc(KIND_LABEL[e.kind] || e.kind) + "</span>" +
301
+ '<span class="age" title="' + esc(new Date(e.at).toLocaleString()) + '">' + clock(e.at) + "</span></div>" +
302
+ (e.text ? '<div class="text">' + esc(e.text) + "</div>" : "") +
303
+ (detail ? '<div class="detail">' + esc(detail) + "</div>" : "") +
304
+ "</div>"
305
+ );
306
+ }
307
+
308
+ function logView(log) {
309
+ const names = [...new Set(log.map((e) => e.agent && e.agent.nickname).filter(Boolean))];
310
+ const shown = logFilter ? log.filter((e) => e.agent && e.agent.nickname === logFilter) : log;
311
+ const parts = [];
312
+ if (names.length > 1 || logFilter) {
313
+ parts.push('<div class="filters">' +
314
+ '<button class="chip' + (logFilter ? "" : " is-active") + '" data-filter="">Everyone</button>' +
315
+ names.map((n) => '<button class="chip' + (logFilter === n ? " is-active" : "") + '" data-filter="' + esc(n) + '">' + esc(n) + "</button>").join("") +
316
+ "</div>");
317
+ }
318
+ if (!shown.length) {
319
+ parts.push('<p class="empty">' + (logFilter ? "Nothing from " + esc(logFilter) + " yet." : "Nothing said yet. Notifications, questions, your answers and your messages show up here.") + "</p>");
320
+ return parts.join("");
321
+ }
322
+ let day = "";
323
+ shown.forEach((e, i) => {
324
+ const d = dayOf(e.at);
325
+ if (d !== day) {
326
+ day = d;
327
+ parts.push('<p class="day">' + esc(d) + "</p>");
328
+ }
329
+ parts.push(entryRow(e, i));
330
+ });
331
+ return parts.join("");
332
+ }
333
+
220
334
  // ---------------------------------------------------------------- settings
221
335
 
222
336
  const setting = (label, control, hint) =>
@@ -363,6 +477,8 @@
363
477
 
364
478
  document.getElementById("count").textContent = settingsOpen
365
479
  ? "settings"
480
+ : logOpen
481
+ ? "log" + (logFilter ? " · " + logFilter : "")
366
482
  : agents.length
367
483
  ? plural(agents.length, "agent", "agents") + (questions.length ? " · " + plural(questions.length, "question", "questions") : "")
368
484
  : "nothing running";
@@ -371,12 +487,27 @@
371
487
  badge.hidden = !snoozed && !quiet;
372
488
  badge.textContent = snoozed ? "snoozed" : "quiet hours";
373
489
  document.getElementById("gear").classList.toggle("is-active", settingsOpen);
490
+ document.getElementById("log").classList.toggle("is-active", logOpen && !settingsOpen);
491
+
492
+ // Who is speaking, and the Stop button, only while something is.
493
+ const speaking = state.speaking;
494
+ const onair = document.getElementById("onair");
495
+ onair.hidden = !speaking || hushedAt > Date.now() - 2500;
496
+ if (speaking) {
497
+ const who = speaking.who;
498
+ const agent = agents.find((a) => a.nickname === who);
499
+ document.getElementById("onair-label").innerHTML = who && agent
500
+ ? "<strong>" + esc((agent.swatch ? agent.swatch + " " : "") + who) + "</strong> is speaking"
501
+ : who ? "Playing a " + esc(who) : "Speaking";
502
+ }
374
503
 
375
504
  const parts = [];
376
505
  // The tray says so when the file it reads has stopped being kept up to date.
377
506
  if (state.stale) parts.push('<p class="stale">Daemon not running — state may be old.</p>');
378
507
  if (settingsOpen) {
379
508
  parts.push(settingsView(state.settings, state.options || {}));
509
+ } else if (logOpen) {
510
+ parts.push(logView(state.log ?? []));
380
511
  } else if (!agents.length && !questions.length && !working.length) {
381
512
  parts.push('<p class="empty">Nothing is running.</p>');
382
513
  } else {
@@ -455,10 +586,40 @@
455
586
 
456
587
  document.getElementById("gear").addEventListener("click", () => {
457
588
  settingsOpen = !settingsOpen;
589
+ if (settingsOpen) logOpen = false;
590
+ draw();
591
+ });
592
+
593
+ document.getElementById("log").addEventListener("click", () => {
594
+ logOpen = settingsOpen ? true : !logOpen;
595
+ settingsOpen = false;
596
+ logFilter = null;
597
+ document.getElementById("body").scrollTop = 0;
458
598
  draw();
459
599
  });
460
600
 
461
601
  document.addEventListener("click", (e) => {
602
+ // An agent's row opens its log; a filter narrows the log; an entry opens out to its full text.
603
+ const agent = e.target.closest("[data-agent]");
604
+ if (agent) {
605
+ logOpen = true;
606
+ logFilter = agent.dataset.agent;
607
+ document.getElementById("body").scrollTop = 0;
608
+ return draw();
609
+ }
610
+ const filter = e.target.closest("[data-filter]");
611
+ if (filter) {
612
+ logFilter = filter.dataset.filter || null;
613
+ return draw();
614
+ }
615
+ const entry = e.target.closest("[data-entry]");
616
+ if (entry) {
617
+ const key = entry.dataset.entry;
618
+ if (openEntries.has(key)) openEntries.delete(key);
619
+ else openEntries.add(key);
620
+ entry.classList.toggle("is-open");
621
+ return;
622
+ }
462
623
  const action = e.target.closest("[data-do]");
463
624
  if (action) {
464
625
  e.preventDefault();
@@ -466,6 +627,10 @@
466
627
  action.disabled = true;
467
628
  action.textContent = "Speaking…";
468
629
  }
630
+ if (action.dataset.do === "hush") {
631
+ hushedAt = Date.now();
632
+ document.getElementById("onair").hidden = true;
633
+ }
469
634
  return send({ do: action.dataset.do, value: action.dataset.value });
470
635
  }
471
636
  const chip = e.target.closest("[data-chip]");