roger-roger 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/package.json +45 -0
- package/skills/roger-roger/SKILL.md +289 -0
- package/skills/roger-roger/herdr-plugin.toml +38 -0
- package/skills/roger-roger/scripts/agent.mjs +132 -0
- package/skills/roger-roger/scripts/audio.mjs +392 -0
- package/skills/roger-roger/scripts/client.mjs +121 -0
- package/skills/roger-roger/scripts/daemon.mjs +604 -0
- package/skills/roger-roger/scripts/decisions.mjs +158 -0
- package/skills/roger-roger/scripts/handlers.mjs +1151 -0
- package/skills/roger-roger/scripts/herdr.mjs +140 -0
- package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
- package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
- package/skills/roger-roger/scripts/hooks.mjs +420 -0
- package/skills/roger-roger/scripts/inbox.mjs +381 -0
- package/skills/roger-roger/scripts/install.mjs +560 -0
- package/skills/roger-roger/scripts/lib.mjs +1133 -0
- package/skills/roger-roger/scripts/names.mjs +84 -0
- package/skills/roger-roger/scripts/progress.mjs +91 -0
- package/skills/roger-roger/scripts/protocol.mjs +71 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
- package/skills/roger-roger/scripts/router.mjs +86 -0
- package/skills/roger-roger/scripts/sessions.mjs +218 -0
- package/skills/roger-roger/scripts/slack.mjs +240 -0
- package/skills/roger-roger/scripts/slackapp.mjs +205 -0
- package/skills/roger-roger/scripts/slackcli.mjs +144 -0
- package/skills/roger-roger/scripts/speaker.mjs +224 -0
- package/skills/roger-roger/scripts/speechkey.mjs +106 -0
- package/skills/roger-roger/scripts/tray.mjs +128 -0
- package/skills/roger-roger/scripts/tts.mjs +275 -0
- package/skills/roger-roger/scripts/tui.mjs +465 -0
- package/skills/roger-roger/slack/manifest.json +34 -0
- package/skills/roger-roger/sounds/alert.wav +0 -0
- package/skills/roger-roger/sounds/bubble.wav +0 -0
- package/skills/roger-roger/sounds/chime.wav +0 -0
- package/skills/roger-roger/sounds/ding.wav +0 -0
- package/skills/roger-roger/sounds/marimba.wav +0 -0
- package/skills/roger-roger/tray/main.mjs +749 -0
- package/skills/roger-roger/tray/panel.html +501 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>roger-roger</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root {
|
|
8
|
+
color-scheme: dark;
|
|
9
|
+
--bg: #101317;
|
|
10
|
+
--bg-section: #1a2027;
|
|
11
|
+
--border: #39424d;
|
|
12
|
+
--border-subtle: #28313a;
|
|
13
|
+
--text: #f2f5f7;
|
|
14
|
+
--text-dim: #b3bdc7;
|
|
15
|
+
--text-muted: #7f8b96;
|
|
16
|
+
--accent: #e5b94f;
|
|
17
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
18
|
+
color: var(--text);
|
|
19
|
+
}
|
|
20
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
21
|
+
html, body { height: 100%; background: transparent; overflow: hidden; }
|
|
22
|
+
body { margin: 0; font-size: 13px; }
|
|
23
|
+
|
|
24
|
+
button, input, select, textarea { font: inherit; color: inherit; }
|
|
25
|
+
button, input, select, textarea {
|
|
26
|
+
border: 1px solid var(--border);
|
|
27
|
+
border-radius: 0;
|
|
28
|
+
background: var(--bg-section);
|
|
29
|
+
}
|
|
30
|
+
input, select, textarea { padding: 6px 8px; }
|
|
31
|
+
button { cursor: pointer; padding: 6px 9px; }
|
|
32
|
+
button:hover { border-color: var(--accent); }
|
|
33
|
+
.secondary-btn, .icon-btn { color: var(--text-dim); }
|
|
34
|
+
.slim-btn { padding: 4px 7px; font-size: 11px; }
|
|
35
|
+
.icon-btn { display: grid; place-items: center; padding: 5px; }
|
|
36
|
+
.badge { padding: 3px 6px; border: 1px solid var(--accent); color: var(--accent); font-size: 10px; text-transform: uppercase; }
|
|
37
|
+
.status-dot { width: 7px; height: 7px; background: var(--text-muted); }
|
|
38
|
+
.switch { position: relative; display: inline-flex; }
|
|
39
|
+
.switch input { position: absolute; opacity: 0; pointer-events: none; }
|
|
40
|
+
.switch-track { width: 32px; height: 18px; padding: 2px; border: 1px solid var(--border); background: var(--bg-section); }
|
|
41
|
+
.switch-thumb { display: block; width: 12px; height: 12px; background: var(--text-muted); transition: transform 120ms ease; }
|
|
42
|
+
.switch input:checked + .switch-track { border-color: var(--accent); }
|
|
43
|
+
.switch input:checked + .switch-track .switch-thumb { background: var(--accent); transform: translateX(14px); }
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* Translucent, not blurred: a webview on Windows cannot blur what is behind its own window.
|
|
47
|
+
* Letting the desktop through and lighting the surface from the top left is the whole effect.
|
|
48
|
+
*/
|
|
49
|
+
.card {
|
|
50
|
+
height: 100%;
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
border: 1px solid var(--border);
|
|
54
|
+
background:
|
|
55
|
+
linear-gradient(150deg, rgba(255, 255, 255, 0.06), transparent 46%),
|
|
56
|
+
color-mix(in srgb, var(--bg) 78%, transparent);
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
header {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: 8px;
|
|
64
|
+
padding: 10px 10px 10px 14px;
|
|
65
|
+
border-bottom: 1px solid var(--border-subtle, var(--border));
|
|
66
|
+
background: color-mix(in srgb, var(--bg-section) 55%, transparent);
|
|
67
|
+
}
|
|
68
|
+
/*
|
|
69
|
+
* The header is the handle. Windows does the dragging itself — a page relaying pointer moves
|
|
70
|
+
* to its own window loses them the moment the window starts moving — so the header is marked
|
|
71
|
+
* as the window's title bar, and the gear, which must still be clickable, is marked out of it.
|
|
72
|
+
*/
|
|
73
|
+
header { app-region: drag; -webkit-app-region: drag; user-select: none; }
|
|
74
|
+
header button { app-region: no-drag; -webkit-app-region: no-drag; }
|
|
75
|
+
header h1 { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; margin: 0; }
|
|
76
|
+
header .count { font-size: 11px; color: var(--text-dim); }
|
|
77
|
+
header .spacer { margin-left: auto; }
|
|
78
|
+
|
|
79
|
+
main { flex: 1; overflow-y: auto; padding: 6px; }
|
|
80
|
+
main::-webkit-scrollbar { width: 0; }
|
|
81
|
+
|
|
82
|
+
.group { padding: 12px 8px 4px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted, var(--text-dim)); }
|
|
83
|
+
|
|
84
|
+
.row { padding: 8px 8px; border-bottom: 1px solid var(--border-subtle, var(--border)); }
|
|
85
|
+
.row:last-child { border-bottom: 0; }
|
|
86
|
+
.row:hover { background: color-mix(in srgb, var(--bg-section) 60%, transparent); }
|
|
87
|
+
.top { display: flex; align-items: center; gap: 8px; }
|
|
88
|
+
.status-dot { flex: none; }
|
|
89
|
+
.who { font-weight: 600; }
|
|
90
|
+
.state { font-size: 11px; color: var(--text-dim); }
|
|
91
|
+
.age { margin-left: auto; font-size: 11px; color: var(--text-muted, var(--text-dim)); font-variant-numeric: tabular-nums; }
|
|
92
|
+
.meta, .sub { margin-top: 3px; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
93
|
+
.meta { color: var(--text-dim); }
|
|
94
|
+
.sub { color: var(--text-muted, var(--text-dim)); }
|
|
95
|
+
.sub strong { font-weight: 600; color: var(--text-dim); }
|
|
96
|
+
|
|
97
|
+
.empty { padding: 34px 14px; text-align: center; color: var(--text-muted, var(--text-dim)); }
|
|
98
|
+
|
|
99
|
+
.stale { margin: 2px 2px 6px; padding: 6px 8px; border: 1px solid var(--border); color: var(--text-dim); font-size: 11px; }
|
|
100
|
+
.flash { margin: 0 6px 6px; padding: 6px 8px; border: 1px solid var(--accent); color: var(--text); font-size: 11px; }
|
|
101
|
+
|
|
102
|
+
/* Settings rows: label left, control right, nothing clever. */
|
|
103
|
+
.setting { display: flex; align-items: center; gap: 10px; padding: 7px 8px; }
|
|
104
|
+
.setting > label:first-child { flex: none; width: 118px; color: var(--text-dim); font-size: 12px; }
|
|
105
|
+
.setting .control { margin-left: auto; flex: 1; min-width: 0; display: flex; justify-content: flex-end; gap: 4px; flex-wrap: wrap; }
|
|
106
|
+
.setting select, .setting input[type="text"] { width: 100%; }
|
|
107
|
+
.setting textarea { width: 100%; height: 54px; resize: none; }
|
|
108
|
+
.setting .switch { margin-left: auto; }
|
|
109
|
+
|
|
110
|
+
footer { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--border-subtle, var(--border)); }
|
|
111
|
+
footer .secondary-btn { flex: 1; }
|
|
112
|
+
|
|
113
|
+
.secondary-btn.is-active { border-color: var(--accent); color: var(--text); }
|
|
114
|
+
.icon-btn.is-active { border-color: var(--accent); color: var(--accent); }
|
|
115
|
+
#pin.is-active svg { fill: currentColor; }
|
|
116
|
+
|
|
117
|
+
/* Settings, the parts that aren't a plain label and a control. */
|
|
118
|
+
.chips { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
|
119
|
+
.chip { padding: 4px 9px; font-size: 11px; color: var(--text-muted); background: transparent; border-color: var(--border-subtle); }
|
|
120
|
+
.chip.is-active { color: #101317; background: var(--accent); border-color: var(--accent); font-weight: 600; }
|
|
121
|
+
.chip:disabled { opacity: 0.4; cursor: default; }
|
|
122
|
+
.chip:disabled:hover { border-color: var(--border-subtle); }
|
|
123
|
+
.hint { margin: -2px 8px 6px; font-size: 11px; color: var(--text-muted); line-height: 1.4; }
|
|
124
|
+
.hint .ok { color: #7fd1a0; }
|
|
125
|
+
.hint .warn { color: var(--accent); }
|
|
126
|
+
.keyline { padding-top: 0; }
|
|
127
|
+
.keyline .hint { margin: 0; flex: 1; }
|
|
128
|
+
.times { justify-content: flex-end; }
|
|
129
|
+
.times label { color: var(--text-dim); font-size: 12px; }
|
|
130
|
+
.times label:first-child { flex: 1; width: auto; }
|
|
131
|
+
.times input[type="time"] { width: 92px; color-scheme: dark; }
|
|
132
|
+
.times.is-off { opacity: 0.45; }
|
|
133
|
+
.advanced { margin-top: 10px; border-top: 1px solid var(--border-subtle); }
|
|
134
|
+
.advanced summary { padding: 12px 8px 6px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); cursor: pointer; list-style: none; }
|
|
135
|
+
.advanced summary::before { content: "▸ "; }
|
|
136
|
+
.advanced[open] summary::before { content: "▾ "; }
|
|
137
|
+
.advanced .setting > label:first-child { width: 170px; }
|
|
138
|
+
/* A switch needs no room of its own, so its label can have the line. */
|
|
139
|
+
.setting:has(.switch) > label:first-child { flex: 1; width: auto; }
|
|
140
|
+
.slider { display: flex; align-items: center; gap: 8px; width: 100%; }
|
|
141
|
+
.slider input[type="range"] { flex: 1; accent-color: var(--accent); padding: 0; border: 0; background: transparent; }
|
|
142
|
+
.slider output { width: 40px; text-align: right; font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
|
143
|
+
.flash.is-ok { border-color: #7fd1a0; }
|
|
144
|
+
svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
145
|
+
</style>
|
|
146
|
+
</head>
|
|
147
|
+
<body>
|
|
148
|
+
<div class="card">
|
|
149
|
+
<header>
|
|
150
|
+
<h1>roger-roger</h1>
|
|
151
|
+
<span class="count" id="count"></span>
|
|
152
|
+
<span class="spacer"></span>
|
|
153
|
+
<span class="badge" id="badge" hidden></span>
|
|
154
|
+
<button class="icon-btn icon-btn-sm" id="pin" title="Keep open when I click elsewhere">
|
|
155
|
+
<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
|
+
</button>
|
|
157
|
+
<button class="icon-btn icon-btn-sm" id="gear" title="Settings">
|
|
158
|
+
<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
|
+
</button>
|
|
160
|
+
</header>
|
|
161
|
+
<main id="body"></main>
|
|
162
|
+
<div class="flash" id="flash" hidden></div>
|
|
163
|
+
<footer id="actions"></footer>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<script>
|
|
167
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
168
|
+
const plural = (n, one, many) => n + " " + (n === 1 ? one : many);
|
|
169
|
+
const send = (body) => window.ipc.postMessage(JSON.stringify(body));
|
|
170
|
+
|
|
171
|
+
let latest = { agents: [], questions: [], working: [] };
|
|
172
|
+
let settingsOpen = false;
|
|
173
|
+
let advancedOpen = false;
|
|
174
|
+
|
|
175
|
+
function ago(ms) {
|
|
176
|
+
const s = Math.round((ms ?? 0) / 1000);
|
|
177
|
+
if (s < 45) return "just now";
|
|
178
|
+
const m = Math.round(s / 60);
|
|
179
|
+
if (m < 60) return m + "m ago";
|
|
180
|
+
const h = Math.floor(m / 60);
|
|
181
|
+
return h + "h" + (m % 60 ? " " + (m % 60) + "m" : "") + " ago";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function until(iso) {
|
|
185
|
+
const ms = Date.parse(iso) - Date.now();
|
|
186
|
+
if (!isFinite(ms)) return "";
|
|
187
|
+
if (ms <= 0) return "settling now";
|
|
188
|
+
const m = Math.round(ms / 60000);
|
|
189
|
+
return m < 60 ? m + "m left" : Math.floor(m / 60) + "h " + (m % 60) + "m left";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function agentRow(a) {
|
|
193
|
+
const colour = a.colour ? ' style="background:' + esc(a.colour) + '; box-shadow: 0 0 0 3px ' + esc(a.colour) + '22"' : "";
|
|
194
|
+
const line = [a.project, a.agent, a.model, a.name].filter(Boolean).map(esc).join(" · ");
|
|
195
|
+
const bits = [];
|
|
196
|
+
if (a.waitingOn) bits.push("<strong>waiting on you</strong> · " + esc(a.waitingOn.question));
|
|
197
|
+
if (a.queued) bits.push(plural(a.queued, "message", "messages") + " unread");
|
|
198
|
+
if (a.owes) bits.push(plural(a.owes, "answer", "answers") + " to collect");
|
|
199
|
+
return (
|
|
200
|
+
'<div class="row"><div class="top">' +
|
|
201
|
+
'<span class="status-dot"' + colour + "></span>" +
|
|
202
|
+
'<span class="who">' + esc(a.nickname) + "</span>" +
|
|
203
|
+
'<span class="state">' + esc(a.state) + "</span>" +
|
|
204
|
+
'<span class="age">' + ago(a.quietForMs) + "</span></div>" +
|
|
205
|
+
(line ? '<div class="meta">' + line + "</div>" : "") +
|
|
206
|
+
(bits.length ? '<div class="sub">' + bits.join(" · ") + "</div>" : "") +
|
|
207
|
+
"</div>"
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function questionRow(q) {
|
|
212
|
+
const mark = q.swatch ? esc(q.swatch) + " " : "";
|
|
213
|
+
const choices = (q.choices ?? []).length ? " · " + q.choices.map(esc).join(" / ") : "";
|
|
214
|
+
return (
|
|
215
|
+
'<div class="row"><div class="meta" style="margin:0;color:var(--text)">' + mark + esc(q.question) + "</div>" +
|
|
216
|
+
'<div class="sub">' + esc(q.nickname || "an agent") + " · " + until(q.expiresAt) + choices + "</div></div>"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------- settings
|
|
221
|
+
|
|
222
|
+
const setting = (label, control, hint) =>
|
|
223
|
+
'<div class="setting"><label>' + esc(label) + '</label><div class="control">' + control + "</div></div>" +
|
|
224
|
+
(hint ? '<p class="hint">' + hint + "</p>" : "");
|
|
225
|
+
|
|
226
|
+
// Options as [value, what a person would call it]. A bare value is its own label.
|
|
227
|
+
const select = (flag, value, options) =>
|
|
228
|
+
'<select data-set="' + flag + '">' +
|
|
229
|
+
(options || [])
|
|
230
|
+
.map((o) => (Array.isArray(o) ? o : [o, o]))
|
|
231
|
+
.map(([v, label]) => '<option value="' + esc(v) + '"' + (v === value ? " selected" : "") + ">" + esc(label) + "</option>")
|
|
232
|
+
.join("") +
|
|
233
|
+
"</select>";
|
|
234
|
+
|
|
235
|
+
const chips = (flag, chosen, options) =>
|
|
236
|
+
'<div class="chips" data-chips="' + flag + '">' +
|
|
237
|
+
(options || [])
|
|
238
|
+
.map((o) => '<button class="chip' + ((chosen || []).includes(o) ? " is-active" : "") + '" data-chip="' + esc(o) + '">' + esc(o) + "</button>")
|
|
239
|
+
.join("") +
|
|
240
|
+
"</div>";
|
|
241
|
+
|
|
242
|
+
const toggle = (flag, on) =>
|
|
243
|
+
'<label class="switch" data-toggle="' + flag + '">' +
|
|
244
|
+
'<input type="checkbox"' + (on ? " checked" : "") + " />" +
|
|
245
|
+
'<span class="switch-track"><span class="switch-thumb"></span></span></label>';
|
|
246
|
+
|
|
247
|
+
const WHEN = [
|
|
248
|
+
["auto", "When it makes sense"],
|
|
249
|
+
["done-and-blocked", "Done or blocked"],
|
|
250
|
+
["blocked-only", "Only when blocked"],
|
|
251
|
+
["on-request", "Only when I ask"],
|
|
252
|
+
];
|
|
253
|
+
const SPEECH = [
|
|
254
|
+
["auto", "Agent decides"],
|
|
255
|
+
["brief", "One short sentence"],
|
|
256
|
+
["same", "Same as the Slack message"],
|
|
257
|
+
];
|
|
258
|
+
const ON_EXPIRE = [
|
|
259
|
+
["recommended", "Pick the recommended"],
|
|
260
|
+
["nothing", "Let it expire"],
|
|
261
|
+
];
|
|
262
|
+
const REMIND = [["off", "Never"], ["5m", "After 5 minutes"], ["10m", "After 10 minutes"], ["15m", "After 15 minutes"], ["30m", "After 30 minutes"], ["1h", "After an hour"]];
|
|
263
|
+
|
|
264
|
+
// Quiet hours remembered while they are switched off, so turning them back on offers the same
|
|
265
|
+
// times rather than making you set them again.
|
|
266
|
+
let quietDraft = { from: "22:00", to: "08:00" };
|
|
267
|
+
|
|
268
|
+
function quietSection(s, o) {
|
|
269
|
+
if (s.quietHours) quietDraft = { from: s.quietHours.from, to: s.quietHours.to };
|
|
270
|
+
const on = Boolean(s.quietHours);
|
|
271
|
+
return (
|
|
272
|
+
'<p class="group">Quiet hours</p>' +
|
|
273
|
+
setting("Every night", toggle("quiet-on", on)) +
|
|
274
|
+
'<div class="setting times' + (on ? "" : " is-off") + '">' +
|
|
275
|
+
'<label>From</label><input type="time" data-quiet="from" value="' + esc(quietDraft.from) + '" />' +
|
|
276
|
+
'<label class="to">to</label><input type="time" data-quiet="to" value="' + esc(quietDraft.to) + '" />' +
|
|
277
|
+
"</div>" +
|
|
278
|
+
(on ? setting("Silence", chips("quiet-mute", s.quietMute, o.methods)) : "")
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Provider first, then where its key is, then — once the key is there and the provider has
|
|
283
|
+
// been asked — its own voices and models, and only then a test. Each step waits for the last.
|
|
284
|
+
function speechSection(s, o) {
|
|
285
|
+
const providers = (o.speechProviders || []).map((p) => [p.id, p.label]);
|
|
286
|
+
const provider = (o.speechProviders || []).find((p) => p.id === s.speechProvider) || { keyVars: [] };
|
|
287
|
+
const cat = o.speechCatalog || { voices: [], models: [] };
|
|
288
|
+
const status = s.speechKeyFound
|
|
289
|
+
? '<span class="ok">Key found in ' + esc(s.speechKeyUsed) + "</span>"
|
|
290
|
+
: '<span class="warn">Nothing in ' + esc(s.speechKeyUsed) + " — set it, then come back</span>";
|
|
291
|
+
const rows = [
|
|
292
|
+
setting("Provider", select("speech-provider", s.speechProvider, providers)),
|
|
293
|
+
setting("API key variable", '<input type="text" data-set="speech-key-var" spellcheck="false" value="' + esc(s.speechKeyVar) +
|
|
294
|
+
'" placeholder="' + esc(provider.keyVars.join(" or ")) + '" />'),
|
|
295
|
+
'<p class="hint">' + status + "</p>",
|
|
296
|
+
];
|
|
297
|
+
if (!s.speechKeyFound) return rows.join("");
|
|
298
|
+
const loading = !cat.fetchedAt;
|
|
299
|
+
const voiceOptions = cat.voices.map((v) => [v.id, v.name + (v.description ? " — " + v.description : "")]);
|
|
300
|
+
const modelOptions = cat.models.map((m) => [m.id, m.name]);
|
|
301
|
+
return rows.concat([
|
|
302
|
+
cat.voices.length
|
|
303
|
+
? setting("Voice", select("voice", s.voice, voiceOptions))
|
|
304
|
+
: setting("Voice", '<span class="hint">' + (loading ? "Loading voices…" : "No voices found") + "</span>"),
|
|
305
|
+
cat.models.length ? setting("Model", select("model", s.model, modelOptions)) : "",
|
|
306
|
+
'<div class="setting keyline"><p class="hint">' + (loading ? "Asking " + esc(provider.label || "the provider") + " what it has…" : "") + "</p>" +
|
|
307
|
+
'<button class="chip" data-do="refresh-voices" title="Ask the provider again">Refresh</button>' +
|
|
308
|
+
'<button class="chip" data-do="test-voice" id="test-voice">Test</button></div>',
|
|
309
|
+
]).join("");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A slider sends its value when it is let go; while it moves, only the number next to it does.
|
|
313
|
+
const slider = (flag, value, min, max, suffix) =>
|
|
314
|
+
'<div class="slider"><input type="range" data-set="' + flag + '" min="' + min + '" max="' + max + '" step="5" value="' + esc(value) + '" />' +
|
|
315
|
+
'<output>' + esc(value) + suffix + "</output></div>";
|
|
316
|
+
|
|
317
|
+
// Music and videos turned down while the voice speaks, so it can be heard. Test does it too.
|
|
318
|
+
function duckRows(s) {
|
|
319
|
+
return (
|
|
320
|
+
setting("Lower other sounds while speaking", toggle("duck", s.duck)) +
|
|
321
|
+
(s.duck ? setting("Other sounds at", slider("duck-level", s.duckLevel, 0, 100, "%")) : "")
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function settingsView(s, o) {
|
|
326
|
+
if (!s) return '<p class="empty">Not set up yet — ask an agent to set up roger-roger.</p>';
|
|
327
|
+
const speaks = (s.methods || []).includes("speech");
|
|
328
|
+
return [
|
|
329
|
+
'<p class="group">Notifications</p>',
|
|
330
|
+
setting("How", chips("methods", s.methods, o.methods)),
|
|
331
|
+
setting("When", select("when", s.when, WHEN)),
|
|
332
|
+
setting("Sound", select("sound", s.sound, o.sounds)),
|
|
333
|
+
'<p class="group">Speech</p>',
|
|
334
|
+
speechSection(s, o),
|
|
335
|
+
setting("Says", select("speech", s.speech, SPEECH)),
|
|
336
|
+
setting("Say who it is", toggle("say-who", s.sayWho)),
|
|
337
|
+
setting("Voice volume", slider("speech-volume", s.speechVolume ?? 100, 0, 150, "%")),
|
|
338
|
+
duckRows(s),
|
|
339
|
+
speaks ? "" : '<p class="hint">Speech is off — turn it on under How to hear any of this.</p>',
|
|
340
|
+
quietSection(s, o),
|
|
341
|
+
'<p class="group">Questions</p>',
|
|
342
|
+
setting("Remind me", select("remind", s.remind || "off", REMIND.some(([v]) => v === (s.remind || "off")) ? REMIND : REMIND.concat([[s.remind, s.remind]]))),
|
|
343
|
+
setting("If I don't answer", select("on-expire", s.onExpire, ON_EXPIRE)),
|
|
344
|
+
'<p class="group">Notes for every agent</p>',
|
|
345
|
+
'<div class="setting"><div class="control"><textarea data-set="notes" placeholder="e.g. never speak after 10pm; only Slack for errors">' + esc(s.notes) + "</textarea></div></div>",
|
|
346
|
+
'<details class="advanced"' + (advancedOpen ? " open" : "") + "><summary>Advanced</summary>",
|
|
347
|
+
setting("Name line in Slack", toggle("session-label", s.sessionLabel)),
|
|
348
|
+
setting("Computer voice if Gemini fails", toggle("local-fallback", s.localFallback)),
|
|
349
|
+
setting("Tray starts with the daemon", toggle("tray", s.tray !== "off")),
|
|
350
|
+
"</details>",
|
|
351
|
+
].join("");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------- drawing
|
|
355
|
+
|
|
356
|
+
function draw() {
|
|
357
|
+
const state = latest;
|
|
358
|
+
const agents = state.agents ?? [];
|
|
359
|
+
const questions = state.questions ?? [];
|
|
360
|
+
const working = state.working ?? [];
|
|
361
|
+
const snoozed = state.muted && state.muted.snoozeUntil;
|
|
362
|
+
const quiet = state.muted && state.muted.quietNow;
|
|
363
|
+
|
|
364
|
+
document.getElementById("count").textContent = settingsOpen
|
|
365
|
+
? "settings"
|
|
366
|
+
: agents.length
|
|
367
|
+
? plural(agents.length, "agent", "agents") + (questions.length ? " · " + plural(questions.length, "question", "questions") : "")
|
|
368
|
+
: "nothing running";
|
|
369
|
+
|
|
370
|
+
const badge = document.getElementById("badge");
|
|
371
|
+
badge.hidden = !snoozed && !quiet;
|
|
372
|
+
badge.textContent = snoozed ? "snoozed" : "quiet hours";
|
|
373
|
+
document.getElementById("gear").classList.toggle("is-active", settingsOpen);
|
|
374
|
+
|
|
375
|
+
const parts = [];
|
|
376
|
+
// The tray says so when the file it reads has stopped being kept up to date.
|
|
377
|
+
if (state.stale) parts.push('<p class="stale">Daemon not running — state may be old.</p>');
|
|
378
|
+
if (settingsOpen) {
|
|
379
|
+
parts.push(settingsView(state.settings, state.options || {}));
|
|
380
|
+
} else if (!agents.length && !questions.length && !working.length) {
|
|
381
|
+
parts.push('<p class="empty">Nothing is running.</p>');
|
|
382
|
+
} else {
|
|
383
|
+
if (agents.length) parts.push(agents.map(agentRow).join(""));
|
|
384
|
+
if (questions.length) {
|
|
385
|
+
parts.push('<p class="group">Waiting on you</p>');
|
|
386
|
+
parts.push(questions.map(questionRow).join(""));
|
|
387
|
+
}
|
|
388
|
+
if (working.length) {
|
|
389
|
+
parts.push('<p class="group">In progress</p>');
|
|
390
|
+
parts.push(working.map((w) => '<div class="row"><div class="meta" style="margin:0;color:var(--text)">' + esc(w.headline) + "</div></div>").join(""));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
document.getElementById("body").innerHTML = parts.join("");
|
|
394
|
+
|
|
395
|
+
document.getElementById("actions").innerHTML = snoozed
|
|
396
|
+
? '<button class="secondary-btn" data-do="snooze" data-value="off">Stop snoozing</button>'
|
|
397
|
+
: '<button class="secondary-btn" data-do="snooze" data-value="15m">Snooze 15m</button>' +
|
|
398
|
+
'<button class="secondary-btn" data-do="snooze" data-value="1h">Snooze 1h</button>';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// When a settings control was last touched. A change takes a moment to go through `setup`
|
|
402
|
+
// and come back in the snapshot, and a redraw in between would put the old value back.
|
|
403
|
+
let touchedAt = 0;
|
|
404
|
+
const TOUCH_MS = 3000;
|
|
405
|
+
const inSettings = (el) => settingsOpen && el && el.closest && el.closest("#body");
|
|
406
|
+
|
|
407
|
+
window.render = function (state) {
|
|
408
|
+
latest = state || latest;
|
|
409
|
+
// Redrawing while someone is typing would take the words out from under them, and redrawing
|
|
410
|
+
// just after a click would flick a chip or switch back until the new value arrives.
|
|
411
|
+
const active = document.activeElement;
|
|
412
|
+
const typing = active && /INPUT|TEXTAREA|SELECT/.test(active.tagName) && inSettings(active);
|
|
413
|
+
const recent = settingsOpen && Date.now() - touchedAt < TOUCH_MS;
|
|
414
|
+
if (!typing && !recent) draw();
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/** A short message from the tray, usually that a setting was refused. */
|
|
418
|
+
let flashTimer = null;
|
|
419
|
+
window.flash = function (message, kind) {
|
|
420
|
+
const el = document.getElementById("flash");
|
|
421
|
+
el.textContent = String(message ?? "");
|
|
422
|
+
el.hidden = !el.textContent;
|
|
423
|
+
el.classList.toggle("is-ok", kind === "ok");
|
|
424
|
+
clearTimeout(flashTimer);
|
|
425
|
+
flashTimer = setTimeout(() => (el.hidden = true), 6000);
|
|
426
|
+
// A refused change should show the value that is really in force, not the one just tried.
|
|
427
|
+
touchedAt = 0;
|
|
428
|
+
window.render();
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
for (const type of ["pointerdown", "input", "change"]) {
|
|
432
|
+
document.addEventListener(type, (e) => {
|
|
433
|
+
if (inSettings(e.target)) touchedAt = Date.now();
|
|
434
|
+
}, true);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
document.addEventListener("input", (e) => {
|
|
438
|
+
if (e.target.type === "range") e.target.nextElementSibling.textContent = e.target.value + "%";
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
document.addEventListener("toggle", (e) => {
|
|
442
|
+
if (e.target.classList && e.target.classList.contains("advanced")) advancedOpen = e.target.open;
|
|
443
|
+
}, true);
|
|
444
|
+
|
|
445
|
+
// Pinned, the panel stays up when you click elsewhere. The tray keeps the real state; this
|
|
446
|
+
// button only shows it and flips it.
|
|
447
|
+
let keepOpen = false;
|
|
448
|
+
document.getElementById("pin").addEventListener("click", () => {
|
|
449
|
+
keepOpen = !keepOpen;
|
|
450
|
+
const pin = document.getElementById("pin");
|
|
451
|
+
pin.classList.toggle("is-active", keepOpen);
|
|
452
|
+
pin.title = keepOpen ? "Pinned: stays open until you click the tray icon" : "Keep open when I click elsewhere";
|
|
453
|
+
send({ do: "keep-open", value: keepOpen });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
document.getElementById("gear").addEventListener("click", () => {
|
|
457
|
+
settingsOpen = !settingsOpen;
|
|
458
|
+
draw();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
document.addEventListener("click", (e) => {
|
|
462
|
+
const action = e.target.closest("[data-do]");
|
|
463
|
+
if (action) {
|
|
464
|
+
e.preventDefault();
|
|
465
|
+
if (action.dataset.do === "test-voice") {
|
|
466
|
+
action.disabled = true;
|
|
467
|
+
action.textContent = "Speaking…";
|
|
468
|
+
}
|
|
469
|
+
return send({ do: action.dataset.do, value: action.dataset.value });
|
|
470
|
+
}
|
|
471
|
+
const chip = e.target.closest("[data-chip]");
|
|
472
|
+
if (chip) {
|
|
473
|
+
const group = chip.closest("[data-chips]");
|
|
474
|
+
chip.classList.toggle("is-active");
|
|
475
|
+
const chosen = [...group.querySelectorAll(".is-active")].map((c) => c.dataset.chip);
|
|
476
|
+
return send({ do: "set", flag: group.dataset.chips, value: chosen.join(",") || "off" });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
document.addEventListener("change", (e) => {
|
|
481
|
+
const flip = e.target.closest("[data-toggle]");
|
|
482
|
+
if (flip && flip.dataset.toggle === "quiet-on") {
|
|
483
|
+
return send({ do: "set", flag: "quiet-hours", value: e.target.checked ? quietDraft.from + "-" + quietDraft.to : "off" });
|
|
484
|
+
}
|
|
485
|
+
if (flip) return send({ do: "set", flag: flip.dataset.toggle, value: e.target.checked ? "on" : "off" });
|
|
486
|
+
const time = e.target.closest("[data-quiet]");
|
|
487
|
+
if (time) {
|
|
488
|
+
if (!e.target.value) return;
|
|
489
|
+
quietDraft[time.dataset.quiet] = e.target.value;
|
|
490
|
+
// Changing the times while quiet hours are off only remembers them for when they go on.
|
|
491
|
+
if (latest.settings && latest.settings.quietHours) send({ do: "set", flag: "quiet-hours", value: quietDraft.from + "-" + quietDraft.to });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const control = e.target.closest("[data-set]");
|
|
495
|
+
if (control) send({ do: "set", flag: control.dataset.set, value: control.value });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
draw();
|
|
499
|
+
</script>
|
|
500
|
+
</body>
|
|
501
|
+
</html>
|