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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +19 -3
- package/skills/roger-roger/herdr-plugin.toml +1 -1
- package/skills/roger-roger/scripts/activity.mjs +92 -0
- package/skills/roger-roger/scripts/audio.mjs +123 -19
- package/skills/roger-roger/scripts/daemon.mjs +5 -3
- package/skills/roger-roger/scripts/decisions.mjs +9 -0
- package/skills/roger-roger/scripts/handlers.mjs +29 -10
- package/skills/roger-roger/scripts/help.mjs +1 -0
- package/skills/roger-roger/scripts/herdr.mjs +84 -12
- package/skills/roger-roger/scripts/inbox.mjs +16 -0
- package/skills/roger-roger/scripts/install.mjs +299 -124
- package/skills/roger-roger/scripts/lib.mjs +57 -2
- package/skills/roger-roger/scripts/names.mjs +14 -2
- package/skills/roger-roger/scripts/roger-roger.mjs +1 -0
- package/skills/roger-roger/scripts/sessions.mjs +10 -4
- package/skills/roger-roger/scripts/tray.mjs +5 -0
- package/skills/roger-roger/tray/main.mjs +5 -1
- package/skills/roger-roger/tray/panel.html +167 -2
|
@@ -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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
|
@@ -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
|
-
|
|
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
|
|
91
|
-
|
|
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]");
|