jarvis-hq 0.0.3

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.
@@ -0,0 +1,2241 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.html = void 0;
4
+ exports.html = `<!DOCTYPE html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="UTF-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
9
+ <title>Jarvis</title>
10
+ <style>
11
+ :root {
12
+ --bg: #f7f7f9;
13
+ --surface: #fff;
14
+ --surface-2: #f0f0f4;
15
+ --text: #16161a;
16
+ --muted: #71717f;
17
+ --faint: #a1a1b0;
18
+ --border: #e4e4ec;
19
+ --accent: #2f6fed;
20
+ --accent-text: #fff;
21
+ --danger: #cf3b2f;
22
+ --ok: #1f9d55;
23
+ --radius: 16px;
24
+ --shadow-sm: 0 1px 2px rgba(16,16,24,.05);
25
+ --shadow-md: 0 2px 6px rgba(16,16,24,.06), 0 12px 28px rgba(16,16,24,.08);
26
+ --tint-l: 94%;
27
+ --tint-s: 70%;
28
+ --ink-l: 34%;
29
+ }
30
+ @media (prefers-color-scheme: dark) {
31
+ :root:not(.light) {
32
+ --bg: #101013;
33
+ --surface: #18181d;
34
+ --surface-2: #202027;
35
+ --text: #ececf2;
36
+ --muted: #9494a4;
37
+ --faint: #6b6b7c;
38
+ --border: #292932;
39
+ --accent: #5f8dff;
40
+ --accent-text: #0c0c10;
41
+ --danger: #f0685c;
42
+ --ok: #45c07d;
43
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.4);
44
+ --shadow-md: 0 2px 6px rgba(0,0,0,.4), 0 12px 28px rgba(0,0,0,.35);
45
+ --tint-l: 24%;
46
+ --tint-s: 38%;
47
+ --ink-l: 76%;
48
+ }
49
+ }
50
+ * { box-sizing: border-box; }
51
+ html, body { height: 100%; }
52
+ body {
53
+ margin: 0; background: var(--bg); color: var(--text);
54
+ font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
55
+ -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
56
+ }
57
+ button { font: inherit; color: inherit; cursor: pointer; background: none; border: 0; }
58
+ input, textarea, select { font: inherit; color: inherit; }
59
+ h1, h2, h3 { margin: 0; letter-spacing: -.02em; }
60
+
61
+ /* Every screen owns the full window; we navigate by depth, not by columns. */
62
+ .view { display: none; flex-direction: column; height: 100vh; height: 100dvh; }
63
+ .view.active { display: flex; }
64
+ .scroll { flex: 1; overflow-y: auto; }
65
+ .wrap { width: 100%; max-width: 940px; margin: 0 auto; padding: 0 24px; }
66
+ .wrap.narrow { max-width: 760px; }
67
+
68
+ /* --- Top bars --- */
69
+ .bar { border-bottom: 1px solid var(--border); background: var(--bg); position: sticky; top: 0; z-index: 5; }
70
+ .bar-inner { display: flex; align-items: center; gap: 14px; min-height: 68px; padding-top: 12px; padding-bottom: 12px; }
71
+ .bar-inner h1 { font-size: 22px; flex: 1; }
72
+ .bar-title { flex: 1; min-width: 0; }
73
+ .bar-title h2 { font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
74
+ .bar-title .sub { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
75
+
76
+ .back {
77
+ display: inline-flex; align-items: center; gap: 5px; flex: none;
78
+ color: var(--muted); padding: 6px 10px 6px 6px; margin-left: -6px; border-radius: 9px;
79
+ font-size: 13.5px; font-weight: 550;
80
+ }
81
+ .back:hover { background: var(--surface-2); color: var(--text); }
82
+ .back .chev { font-size: 17px; line-height: 1; }
83
+
84
+ .btn {
85
+ display: inline-flex; align-items: center; gap: 7px; flex: none;
86
+ padding: 9px 15px; border-radius: 11px; font-size: 14px; font-weight: 550;
87
+ border: 1px solid var(--border); background: var(--surface); box-shadow: var(--shadow-sm);
88
+ }
89
+ .btn:hover { border-color: var(--faint); }
90
+ .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
91
+ .btn.primary:hover { filter: brightness(1.06); }
92
+ .btn.ghost { box-shadow: none; background: none; border-color: transparent; color: var(--muted); }
93
+ .btn.ghost:hover { background: var(--surface-2); color: var(--text); }
94
+ .btn.danger { color: var(--danger); border-color: transparent; background: none; box-shadow: none; }
95
+ .btn.danger:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); }
96
+
97
+ /* --- Bot identity ---
98
+ Each bot gets a stable hue from its name, so colour carries identity
99
+ rather than decoration. --bot-hue is set inline per element. */
100
+ .avatar {
101
+ display: grid; place-items: center; flex: none;
102
+ width: 44px; height: 44px; border-radius: 13px;
103
+ background: hsl(var(--bot-hue) var(--tint-s) var(--tint-l));
104
+ font-size: 21px; line-height: 1;
105
+ }
106
+ .avatar.lg { width: 56px; height: 56px; border-radius: 16px; font-size: 27px; }
107
+ .avatar.sm { width: 30px; height: 30px; border-radius: 9px; font-size: 15px; }
108
+
109
+ /* --- Home: the roster --- */
110
+ .grid {
111
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
112
+ gap: 16px; padding: 24px 0 60px;
113
+ }
114
+ .card {
115
+ position: relative; overflow: hidden; text-align: left;
116
+ display: flex; flex-direction: column; gap: 10px;
117
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
118
+ padding: 18px; min-height: 178px; box-shadow: var(--shadow-sm);
119
+ transition: transform .14s ease, box-shadow .14s ease, border-color .14s ease;
120
+ }
121
+ .card::before {
122
+ content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
123
+ background: hsl(var(--bot-hue) 65% 55%);
124
+ }
125
+ .card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); border-color: hsl(var(--bot-hue) 50% 62%); }
126
+ .card h3 { font-size: 16px; }
127
+ .card .desc {
128
+ color: var(--muted); font-size: 13.5px; flex: 1;
129
+ display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
130
+ }
131
+ .card .foot {
132
+ display: flex; align-items: center; gap: 8px;
133
+ color: var(--faint); font-size: 12px; padding-top: 2px;
134
+ }
135
+ .card .foot .dot { margin-left: auto; }
136
+ /* A card is a button, so its share affordance is a span acting as one. */
137
+ .card .share {
138
+ position: absolute; top: 12px; right: 12px; z-index: 1;
139
+ display: grid; place-items: center; width: 28px; height: 28px; border-radius: 9px;
140
+ color: var(--faint); font-size: 14px; line-height: 1; cursor: pointer;
141
+ opacity: 0; transition: opacity .14s ease, background .14s ease, color .14s ease;
142
+ }
143
+ .card:hover .share, .card .share:focus-visible { opacity: 1; }
144
+ .card .share:hover { background: var(--surface-2); color: var(--text); }
145
+ @media (hover: none) { .card .share { opacity: 1; } }
146
+
147
+ .new-card {
148
+ display: grid; place-items: center; gap: 8px; min-height: 178px;
149
+ border: 1.5px dashed var(--border); border-radius: var(--radius); color: var(--muted);
150
+ font-size: 14px; font-weight: 550;
151
+ }
152
+ .new-card:hover { border-color: var(--accent); color: var(--accent); background: var(--surface); }
153
+ .new-card .plus { font-size: 24px; line-height: 1; }
154
+
155
+ .dot { display: inline-flex; align-items: center; gap: 5px; }
156
+ .dot::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--faint); }
157
+ .dot.live { color: var(--ok); }
158
+ .dot.live::before { background: var(--ok); box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 60%, transparent); animation: pulse 1.8s ease-out infinite; }
159
+ @keyframes pulse { to { box-shadow: 0 0 0 7px transparent; } }
160
+ @media (prefers-reduced-motion: reduce) { .dot.live::before { animation: none; } }
161
+
162
+ /* --- Bot page --- */
163
+
164
+ /* Identity lives in the bar: name, a status dot, and the instructions as one
165
+ clamped line. The page below belongs to the threads. */
166
+ .name-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
167
+ .name-row h2 { min-width: 0; }
168
+ .ready { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); flex: none; }
169
+ .ready.pending { background: var(--accent); }
170
+ .ready.failed { background: var(--danger); }
171
+
172
+ .brief-line {
173
+ display: flex; align-items: center; gap: 6px; max-width: 100%;
174
+ background: none; border: 0; padding: 0; cursor: pointer; text-align: left;
175
+ color: var(--muted); font-size: 12.5px;
176
+ }
177
+ .brief-line > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
178
+ .brief-line:hover { color: var(--text); }
179
+ .brief-line .chev-d { flex: none; font-size: 10px; transition: transform .15s ease; }
180
+ .brief-line.open .chev-d { transform: rotate(180deg); }
181
+
182
+ .btn.icon { padding: 6px 9px; font-size: 16px; line-height: 1; }
183
+
184
+ .menu {
185
+ position: fixed; z-index: 30; min-width: 170px;
186
+ background: var(--surface); border: 1px solid var(--border); border-radius: 11px;
187
+ box-shadow: var(--shadow-lg, 0 10px 30px rgba(0,0,0,.14)); padding: 5px;
188
+ }
189
+ .menu button {
190
+ display: block; width: 100%; text-align: left; background: none; border: 0;
191
+ padding: 8px 10px; border-radius: 7px; font: inherit; color: var(--text); cursor: pointer;
192
+ }
193
+ .menu button:hover { background: var(--surface-2); }
194
+
195
+ .brief {
196
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
197
+ padding: 14px 16px; margin: 18px 0 0; box-shadow: var(--shadow-sm);
198
+ border-left: 3px solid hsl(var(--bot-hue) 60% 55%);
199
+ }
200
+ .brief .label { font-size: 11px; letter-spacing: .07em; text-transform: uppercase; color: var(--faint); font-weight: 650; margin-bottom: 6px; }
201
+ .brief p { margin: 0; color: var(--text); white-space: pre-wrap; }
202
+ .brief p.none { color: var(--faint); font-style: italic; }
203
+
204
+ /* The setup notice sits above the brief: until it clears, it is the page. */
205
+ .setup {
206
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
207
+ padding: 16px 18px; margin: 22px 0 0; box-shadow: var(--shadow-sm);
208
+ border-left: 3px solid var(--accent);
209
+ display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap;
210
+ }
211
+ .setup.failed { border-left-color: var(--danger); }
212
+ .setup.done { border-left-color: var(--ok); }
213
+ .setup .txt { flex: 1; min-width: 220px; }
214
+ .setup .hd { font-weight: 650; margin-bottom: 4px; }
215
+ .setup.failed .hd { color: var(--danger); }
216
+ .setup p { margin: 0; color: var(--muted); font-size: 13.5px; white-space: pre-wrap; }
217
+ .setup .acts { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
218
+
219
+ .tag {
220
+ font-size: 10.5px; letter-spacing: .06em; text-transform: uppercase; font-weight: 650;
221
+ color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
222
+ border-radius: 999px; padding: 1px 7px; margin-left: 8px; vertical-align: 1px;
223
+ }
224
+ .tag.done { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
225
+ .tag.failed { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); }
226
+
227
+ .section-head { display: flex; align-items: center; gap: 12px; margin: 22px 0 10px; }
228
+ .section-head h3 { font-size: 13px; letter-spacing: .06em; text-transform: uppercase; color: var(--faint); }
229
+ .section-path {
230
+ flex: 1; min-width: 0; font-size: 11.5px; color: var(--faint);
231
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
232
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: ltr;
233
+ }
234
+
235
+ /* Rows read as one list: shared border, hairline dividers, no per-row card. */
236
+ .threads {
237
+ display: flex; flex-direction: column; margin-bottom: 60px;
238
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
239
+ box-shadow: var(--shadow-sm); overflow: hidden;
240
+ }
241
+ .threads:empty { display: none; }
242
+ .thread {
243
+ position: relative; display: flex; align-items: center; gap: 14px; text-align: left; width: 100%;
244
+ background: none; border: 0; border-top: 1px solid var(--border);
245
+ padding: 13px 16px; transition: background .12s ease;
246
+ }
247
+ .thread:first-child { border-top: 0; }
248
+ .thread:hover { background: var(--surface-2); }
249
+ .thread .body { flex: 1; min-width: 0; }
250
+ .thread .title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
251
+ .thread .prev { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 1px; }
252
+ .thread .path {
253
+ display: inline-block; max-width: 100%; margin-top: 5px; font-size: 11.5px;
254
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--faint);
255
+ background: var(--surface-2); border-radius: 6px; padding: 1px 6px;
256
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: ltr;
257
+ }
258
+ .thread .meta { color: var(--faint); font-size: 12px; flex: none; text-align: right; }
259
+ .thread .kill { opacity: 0; color: var(--faint); font-size: 17px; padding: 4px 6px; border-radius: 8px; flex: none; }
260
+ .thread:hover .kill { opacity: 1; }
261
+ .thread .kill:hover { color: var(--danger); background: var(--surface-2); }
262
+
263
+ /* --- Jarvis: the front door. One question, then whatever is going on. --- */
264
+ .ask { padding: 40px 0 8px; }
265
+ .ask h2 { font-size: 26px; margin-bottom: 16px; letter-spacing: -.02em; }
266
+ .ask .box { box-shadow: var(--shadow-md); }
267
+ .ask .box textarea { min-height: 52px; font-size: 16px; }
268
+ .hints { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
269
+ .hint {
270
+ font-size: 13px; color: var(--muted); padding: 6px 12px; border-radius: 999px;
271
+ border: 1px solid var(--border); background: var(--surface);
272
+ }
273
+ .hint:hover { color: var(--text); border-color: var(--faint); }
274
+ .thread .who {
275
+ display: inline-block; font-size: 11.5px; font-weight: 600; margin-right: 6px;
276
+ color: hsl(var(--bot-hue) var(--tint-s) var(--ink-l)); vertical-align: 1px;
277
+ }
278
+ .lead { color: var(--muted); font-size: 14px; margin: 0 0 8px; }
279
+
280
+ /* Work Jarvis sent elsewhere: a card in its transcript that opens the thread. */
281
+ .dispatch {
282
+ display: flex; align-items: center; gap: 12px; margin-top: 7px;
283
+ background: var(--surface); border: 1px solid var(--border); border-radius: 13px;
284
+ padding: 10px 14px; box-shadow: var(--shadow-sm);
285
+ }
286
+ .dispatch .body { flex: 1; min-width: 0; }
287
+ .dispatch .hd { font-size: 12px; color: var(--faint); letter-spacing: .04em; text-transform: uppercase; }
288
+ .dispatch .ttl { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
289
+ .dispatch .dot { font-size: 12px; color: var(--faint); flex: none; }
290
+
291
+ .empty { text-align: center; padding: 64px 20px; color: var(--muted); }
292
+ .empty .big { font-size: 40px; margin-bottom: 12px; }
293
+ .empty h3 { font-size: 17px; margin-bottom: 6px; }
294
+ .empty p { margin: 0 auto 18px; max-width: 380px; font-size: 14px; }
295
+
296
+ /* --- Conversation --- */
297
+ .messages { flex: 1; overflow-y: auto; }
298
+ .messages .wrap { padding-top: 26px; padding-bottom: 20px; display: flex; flex-direction: column; gap: 18px; }
299
+ .msg { display: flex; gap: 12px; max-width: 100%; }
300
+ .msg.user { flex-direction: row-reverse; }
301
+ .msg .content { min-width: 0; max-width: 86%; }
302
+ .bubble { padding: 11px 15px; border-radius: 15px; white-space: pre-wrap; overflow-wrap: anywhere; }
303
+ .msg.user .bubble { background: var(--accent); color: var(--accent-text); border-bottom-right-radius: 5px; }
304
+ .msg.assistant .bubble { background: var(--surface); border: 1px solid var(--border); border-bottom-left-radius: 5px; box-shadow: var(--shadow-sm); }
305
+ .msg.assistant .bubble:empty { display: none; }
306
+ .bubble + .bubble, .tool + .bubble { margin-top: 7px; }
307
+ .msg.error .bubble { background: var(--surface); border: 1px solid var(--danger); color: var(--danger); font-size: 13.5px; }
308
+
309
+ /* Markdown, as rendered by marked into assistant bubbles. Margins collapse at
310
+ the bubble edges so a one-paragraph answer still looks like a chat line. */
311
+ .bubble.md { white-space: normal; }
312
+ .bubble.md > :first-child { margin-top: 0; }
313
+ .bubble.md > :last-child { margin-bottom: 0; }
314
+ .bubble.md p, .bubble.md ul, .bubble.md ol, .bubble.md pre, .bubble.md blockquote, .bubble.md table { margin: 0 0 10px; }
315
+ .bubble.md h1, .bubble.md h2, .bubble.md h3, .bubble.md h4 { margin: 16px 0 8px; line-height: 1.3; }
316
+ .bubble.md h1 { font-size: 18px; }
317
+ .bubble.md h2 { font-size: 16px; }
318
+ .bubble.md h3, .bubble.md h4 { font-size: 14.5px; }
319
+ .bubble.md ul, .bubble.md ol { padding-left: 22px; }
320
+ .bubble.md li { margin: 3px 0; }
321
+ .bubble.md li > ul, .bubble.md li > ol { margin: 3px 0; }
322
+ .bubble.md a { color: var(--accent); }
323
+ .bubble.md code {
324
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
325
+ background: var(--surface-2); border-radius: 5px; padding: 1px 5px;
326
+ }
327
+ .bubble.md pre {
328
+ background: var(--surface-2); border-radius: 10px; padding: 10px 12px;
329
+ overflow-x: auto; white-space: pre; -webkit-overflow-scrolling: touch;
330
+ }
331
+ .bubble.md pre code { background: none; padding: 0; font-size: 12.5px; line-height: 1.5; }
332
+ .bubble.md blockquote { border-left: 3px solid var(--border); padding-left: 12px; color: var(--muted); }
333
+ .bubble.md hr { border: 0; border-top: 1px solid var(--border); margin: 14px 0; }
334
+ .bubble.md table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 13px; }
335
+ .bubble.md th, .bubble.md td { border: 1px solid var(--border); padding: 5px 9px; text-align: left; }
336
+ .bubble.md th { background: var(--surface-2); }
337
+ .bubble.md img { max-width: 100%; border-radius: 10px; }
338
+ .who { font-size: 12px; color: var(--faint); margin: 0 4px 4px; }
339
+ .msg.user .who { text-align: right; }
340
+
341
+ .tool {
342
+ display: flex; align-items: center; gap: 9px; margin-top: 7px;
343
+ background: var(--surface-2); border-radius: 10px; padding: 7px 11px;
344
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted);
345
+ }
346
+ .tool b { color: var(--text); font-weight: 600; flex: none; }
347
+ .tool span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
348
+ .note { color: var(--faint); font-size: 12.5px; text-align: center; font-style: italic; }
349
+
350
+ .perm {
351
+ background: var(--surface); border: 1px solid var(--accent); border-radius: 14px;
352
+ padding: 14px 16px; box-shadow: var(--shadow-md);
353
+ }
354
+ .perm .head { font-weight: 600; margin-bottom: 8px; }
355
+ .perm pre {
356
+ margin: 0 0 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
357
+ color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 150px; overflow: auto;
358
+ }
359
+ .perm .acts { display: flex; gap: 8px; }
360
+
361
+ /* --- Composer --- */
362
+ .composer { border-top: 1px solid var(--border); background: var(--bg); }
363
+ .composer .wrap { padding-top: 12px; padding-bottom: 18px; }
364
+ .activity { display: flex; align-items: center; gap: 8px; height: 22px; padding-left: 4px; color: var(--muted); font-size: 12.5px; }
365
+ .activity:empty { display: none; }
366
+ .spinner {
367
+ width: 11px; height: 11px; flex: none; border-radius: 50%;
368
+ border: 1.5px solid var(--border); border-top-color: var(--accent); animation: spin .7s linear infinite;
369
+ }
370
+ @keyframes spin { to { transform: rotate(360deg); } }
371
+ @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
372
+ .box {
373
+ display: flex; gap: 10px; align-items: flex-end;
374
+ background: var(--surface); border: 1px solid var(--border); border-radius: 17px;
375
+ padding: 9px 9px 9px 16px; box-shadow: var(--shadow-sm);
376
+ }
377
+ .box:focus-within { border-color: var(--accent); }
378
+ .box textarea { flex: 1; border: 0; background: none; resize: none; outline: none; max-height: 200px; padding: 6px 0; }
379
+ .send {
380
+ width: 36px; height: 36px; border-radius: 11px; flex: none;
381
+ background: var(--accent); color: var(--accent-text); font-size: 17px;
382
+ display: flex; align-items: center; justify-content: center;
383
+ }
384
+ .send:disabled { opacity: .35; cursor: default; }
385
+ .btn:disabled { opacity: .45; cursor: default; }
386
+ .send.stop { background: var(--danger); }
387
+ .send.stop::before { content: ""; width: 11px; height: 11px; border-radius: 2px; background: currentColor; }
388
+ .mic {
389
+ width: 36px; height: 36px; border-radius: 11px; flex: none;
390
+ color: var(--muted); display: flex; align-items: center; justify-content: center;
391
+ }
392
+ .mic:hover { color: var(--text); background: var(--surface-2); }
393
+ .mic svg { width: 18px; height: 18px; fill: currentColor; }
394
+ .mic.recording { color: var(--accent-text); background: var(--danger); animation: pulse 1.2s ease-in-out infinite; }
395
+ .mic.busy { color: var(--faint); cursor: default; }
396
+ .mic.busy svg { display: none; }
397
+ .mic.busy::before { content: ""; width: 14px; height: 14px; border-radius: 50%;
398
+ border: 1.5px solid var(--border); border-top-color: var(--accent); animation: spin .7s linear infinite; }
399
+ @keyframes pulse { 50% { opacity: .6; } }
400
+ @media (prefers-reduced-motion: reduce) { .mic.recording { animation: none; } }
401
+
402
+ /* --- Modal --- */
403
+ .backdrop {
404
+ position: fixed; inset: 0; background: rgba(10,10,14,.5); backdrop-filter: blur(3px);
405
+ display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 30;
406
+ }
407
+ .modal {
408
+ background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
409
+ box-shadow: var(--shadow-md); width: min(580px, 100%); max-height: 90vh; overflow-y: auto; padding: 26px;
410
+ }
411
+ .modal h2 { font-size: 19px; }
412
+ .modal-head { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
413
+ .modal-head h2 { flex: 1; margin: 0; }
414
+ .modal-x {
415
+ flex: none; width: 30px; height: 30px; border-radius: 9px; border: 1px solid var(--border);
416
+ background: var(--bg); color: var(--faint); font-size: 16px; line-height: 1; cursor: pointer;
417
+ }
418
+ .modal-x:hover { color: var(--text); border-color: var(--faint); }
419
+ .field { margin-bottom: 16px; }
420
+ .field label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 6px; }
421
+ .field .hint { font-weight: 400; color: var(--faint); }
422
+ .field input, .field textarea, .field select {
423
+ width: 100%; padding: 10px 12px; border-radius: 11px;
424
+ border: 1px solid var(--border); background: var(--bg); outline: none;
425
+ }
426
+ .field input:focus, .field textarea:focus, .field select:focus { border-color: var(--accent); }
427
+ .field textarea { resize: vertical; min-height: 110px; }
428
+ .row2 { display: grid; grid-template-columns: 92px 1fr; gap: 10px; }
429
+ .acts { display: flex; gap: 9px; justify-content: flex-end; align-items: center; margin-top: 22px; }
430
+ .acts .spacer { flex: 1; }
431
+
432
+ /* --- Folder picker --- */
433
+ .pathbar {
434
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
435
+ color: var(--muted); background: var(--surface-2); border-radius: 10px;
436
+ padding: 9px 12px; margin-bottom: 10px; overflow-wrap: anywhere;
437
+ }
438
+ .jumps { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 10px; }
439
+ .chip {
440
+ padding: 5px 11px; border-radius: 999px; font-size: 12.5px; font-weight: 550;
441
+ border: 1px solid var(--border); background: var(--surface); color: var(--muted);
442
+ }
443
+ .chip:hover { border-color: var(--accent); color: var(--accent); }
444
+ .picker {
445
+ border: 1px solid var(--border); border-radius: 12px; background: var(--bg);
446
+ max-height: 46vh; overflow-y: auto;
447
+ }
448
+ .picker-row {
449
+ display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
450
+ padding: 10px 13px; border-bottom: 1px solid var(--border); font-size: 14px;
451
+ }
452
+ .picker-row:last-child { border-bottom: 0; }
453
+ .picker-row:hover { background: var(--surface-2); }
454
+ .picker-row .ic { flex: none; font-size: 15px; line-height: 1; }
455
+ .picker-row .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
456
+ .picker-note { padding: 20px 13px; text-align: center; color: var(--faint); font-size: 13.5px; }
457
+
458
+ /* --- Sharing --- */
459
+ .code {
460
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
461
+ width: 100%; min-height: 96px; resize: vertical; overflow-wrap: anywhere;
462
+ padding: 10px 12px; border-radius: 11px; border: 1px solid var(--border);
463
+ background: var(--surface-2); color: var(--muted); outline: none;
464
+ }
465
+ .preview {
466
+ display: flex; align-items: center; gap: 12px; margin: 4px 0 16px;
467
+ background: var(--surface-2); border-radius: 12px; padding: 12px 14px;
468
+ }
469
+ .preview .nm { font-weight: 600; }
470
+ .preview .ds { color: var(--muted); font-size: 13px; }
471
+ .preview .bad { color: var(--danger); font-size: 13.5px; }
472
+
473
+ .toast {
474
+ position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
475
+ z-index: 40; padding: 10px 18px; border-radius: 999px;
476
+ background: var(--text); color: var(--bg); font-size: 13.5px; font-weight: 550;
477
+ box-shadow: var(--shadow-md); animation: rise .18s ease;
478
+ }
479
+ @keyframes rise { from { opacity: 0; transform: translate(-50%, 8px); } }
480
+
481
+ @media (max-width: 640px) {
482
+ .wrap { padding: 0 16px; }
483
+ .grid { grid-template-columns: 1fr 1fr; gap: 12px; }
484
+ .bar-inner { min-height: 60px; }
485
+ }
486
+ </style>
487
+ <!--vendor-->
488
+ </head>
489
+ <body>
490
+
491
+ <!-- Jarvis: where every interaction starts -->
492
+ <section class="view active" id="view-jarvis">
493
+ <div class="bar"><div class="wrap narrow bar-inner">
494
+ <h1>Jarvis</h1>
495
+ <button class="btn ghost" id="to-projects">Projects</button>
496
+ </div></div>
497
+ <div class="scroll"><div class="wrap narrow">
498
+ <div class="ask">
499
+ <h2 id="ask-title">What do you need?</h2>
500
+ <div class="box">
501
+ <textarea id="ask-input" rows="1" placeholder="Ask about any project, or hand over some work…"></textarea>
502
+ <button class="mic" id="ask-mic" title="Dictate" aria-label="Dictate"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a4 4 0 0 0 4-4V6a4 4 0 0 0-8 0v5a4 4 0 0 0 4 4zm6-4a6 6 0 0 1-5 5.92V20h3v2H8v-2h3v-3.08A6 6 0 0 1 6 11h2a4 4 0 0 0 8 0h2z"/></svg></button>
503
+ <button class="send" id="ask-send" title="Send" aria-label="Send">&uarr;</button>
504
+ </div>
505
+ <div class="hints" id="hints"></div>
506
+ </div>
507
+ <div class="section-head"><h3>Recent</h3></div>
508
+ <div id="recent-lead"></div>
509
+ <div class="threads" id="recent"></div>
510
+ </div></div>
511
+ </section>
512
+
513
+ <!-- Projects and bots: the roster -->
514
+ <section class="view" id="view-home">
515
+ <div class="bar"><div class="wrap bar-inner">
516
+ <button class="back" id="to-jarvis"><span class="chev">&lsaquo;</span> Jarvis</button>
517
+ <h1>Projects &amp; bots</h1>
518
+ <button class="btn ghost" id="import-bot">Import</button>
519
+ <button class="btn primary" id="new-bot"><span>+</span> New bot</button>
520
+ </div></div>
521
+ <div class="scroll"><div class="wrap"><div class="grid" id="grid"></div></div></div>
522
+ </section>
523
+
524
+ <!-- One bot: its brief and its threads -->
525
+ <section class="view" id="view-bot">
526
+ <div class="bar"><div class="wrap bar-inner">
527
+ <button class="back" id="to-home"><span class="chev">&lsaquo;</span> Bots</button>
528
+ <div class="avatar sm" id="bot-avatar"></div>
529
+ <div class="bar-title">
530
+ <div class="name-row">
531
+ <h2 id="bot-name"></h2>
532
+ <span class="ready" id="bot-ready" hidden></span>
533
+ </div>
534
+ <button class="brief-line" id="brief-line" type="button" hidden>
535
+ <span id="brief-line-text"></span><span class="chev-d">&#9662;</span>
536
+ </button>
537
+ </div>
538
+ <button class="btn ghost" id="share-bot">Share</button>
539
+ <button class="btn ghost" id="edit-bot">Edit</button>
540
+ <button class="btn ghost icon" id="bot-more" title="More" aria-label="More">&#8943;</button>
541
+ </div></div>
542
+ <div class="scroll"><div class="wrap">
543
+ <div class="brief" id="brief" hidden><p id="brief-text"></p></div>
544
+ <div id="setup-banner"></div>
545
+ <div class="section-head">
546
+ <h3>Threads</h3>
547
+ <span class="section-path" id="threads-path"></span>
548
+ <button class="btn primary" id="new-thread">+ New thread</button>
549
+ </div>
550
+ <div class="threads" id="threads"></div>
551
+ </div></div>
552
+ <div class="menu" id="bot-menu" hidden></div>
553
+ </section>
554
+
555
+ <!-- One thread -->
556
+ <section class="view" id="view-thread">
557
+ <div class="bar"><div class="wrap narrow bar-inner">
558
+ <button class="back" id="to-bot"><span class="chev">&lsaquo;</span> <span id="to-bot-label">Back</span></button>
559
+ <div class="avatar sm" id="thread-avatar"></div>
560
+ <div class="bar-title">
561
+ <h2 id="thread-title"></h2>
562
+ <div class="sub" id="thread-sub"></div>
563
+ </div>
564
+ </div></div>
565
+ <div class="messages" id="messages"><div class="wrap narrow" id="messages-inner"></div></div>
566
+ <div class="composer"><div class="wrap narrow">
567
+ <div class="activity" id="activity"></div>
568
+ <div class="box">
569
+ <textarea id="input" rows="1" placeholder="Message…"></textarea>
570
+ <button class="mic" id="mic" title="Dictate" aria-label="Dictate"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a4 4 0 0 0 4-4V6a4 4 0 0 0-8 0v5a4 4 0 0 0 4 4zm6-4a6 6 0 0 1-5 5.92V20h3v2H8v-2h3v-3.08A6 6 0 0 1 6 11h2a4 4 0 0 0 8 0h2z"/></svg></button>
571
+ <button class="send" id="send" title="Send" aria-label="Send">&uarr;</button>
572
+ </div>
573
+ </div></div>
574
+ </section>
575
+
576
+ <script>
577
+ (function () {
578
+ "use strict";
579
+
580
+ // --- State ---
581
+ var bots = [];
582
+ var allThreads = []; // every thread, for roster counts
583
+ var threads = []; // threads of the open bot
584
+ var activeBot = null;
585
+ var activeThread = null;
586
+ var view = "jarvis"; // jarvis | home | bot | thread
587
+ var jarvis = null; // the Jarvis bot, from /jarvis
588
+ var projects = [];
589
+ // Where the thread screen's back button goes: the bot page, or straight
590
+ // back to Jarvis when the thread was reached from there.
591
+ var threadBack = "bot";
592
+
593
+ var sessionId = null;
594
+ var stream = null;
595
+ var liveBubble = null;
596
+ // Turns that are still running server-side, keyed by thread. A turn outlives
597
+ // the screen it was started from, so this is what lets us rejoin one.
598
+ var live = {}; // threadId -> sessionId
599
+ var streamThreadId = null; // thread the open EventSource belongs to
600
+
601
+ // Turn lifecycle. "idle" is the only state in which the button sends;
602
+ // otherwise it stops. "aborting" waits for the agent to confirm.
603
+ var state = "idle"; // idle | starting | running | aborting
604
+ var pendingPerms = 0;
605
+ var pendingFilter = null; // during catch-up: tool requests still unanswered
606
+
607
+ var $ = function (id) { return document.getElementById(id); };
608
+
609
+ function el(tag, cls, text) {
610
+ var n = document.createElement(tag);
611
+ if (cls) n.className = cls;
612
+ if (text !== undefined && text !== null) n.textContent = String(text);
613
+ return n;
614
+ }
615
+
616
+ /** Modal title row with the "x" close button; modals never close on an outside click. */
617
+ function modalHead(modal, title, close) {
618
+ var head = el("div", "modal-head");
619
+ head.appendChild(el("h2", null, title));
620
+ var x = el("button", "modal-x", "\u00D7");
621
+ x.title = "Close";
622
+ x.setAttribute("aria-label", "Close");
623
+ x.onclick = close;
624
+ head.appendChild(x);
625
+ modal.appendChild(head);
626
+ return head;
627
+ }
628
+
629
+ // --- API ---
630
+ function api(path, opts) {
631
+ opts = opts || {};
632
+ var init = { method: opts.method || "GET", headers: {} };
633
+ if (opts.body !== undefined) {
634
+ init.headers["Content-Type"] = "application/json";
635
+ init.body = JSON.stringify(opts.body);
636
+ }
637
+ return fetch(path, init).then(function (r) {
638
+ return r.json().catch(function () { return {}; }).then(function (d) {
639
+ if (!r.ok) {
640
+ var err = new Error(d.error || ("Request failed: " + r.status));
641
+ err.data = d; // some errors are acted on, not just shown
642
+ throw err;
643
+ }
644
+ return d;
645
+ });
646
+ });
647
+ }
648
+
649
+ /** A stable hue per bot, so colour means identity rather than decoration. */
650
+ function hueOf(bot) {
651
+ var seed = String(bot && (bot.id || bot.name) || "");
652
+ var h = 0;
653
+ for (var i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360;
654
+ return h;
655
+ }
656
+ function tint(node, bot) { node.style.setProperty("--bot-hue", hueOf(bot)); return node; }
657
+
658
+ function avatar(bot, size) {
659
+ var n = tint(el("div", "avatar" + (size ? " " + size : "")), bot);
660
+ n.textContent = bot.emoji || "\\u{1F916}";
661
+ return n;
662
+ }
663
+
664
+ function relTime(iso) {
665
+ var d = (Date.now() - new Date(iso).getTime()) / 1000;
666
+ if (d < 60) return "just now";
667
+ if (d < 3600) return Math.floor(d / 60) + "m ago";
668
+ if (d < 86400) return Math.floor(d / 3600) + "h ago";
669
+ if (d < 604800) return Math.floor(d / 86400) + "d ago";
670
+ return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
671
+ }
672
+
673
+ /**
674
+ * A working directory is only recognisable by its tail, so show the last
675
+ * whole segments that fit and mark the elision with a leading ellipsis.
676
+ */
677
+ function shortPath(p, max) {
678
+ if (!p) return "";
679
+ max = max || 22;
680
+ // Empty segments from a leading or trailing slash fall out with the filter.
681
+ var parts = p.split("/").filter(Boolean);
682
+ if (!parts.length) return p;
683
+ var out = parts[parts.length - 1];
684
+ if (out.length > max) out = "\u2026" + out.slice(out.length - max + 1);
685
+ for (var i = parts.length - 2; i >= 0; i--) {
686
+ var next = parts[i] + "/" + out;
687
+ if (next.length > max) return "\u2026/" + out;
688
+ out = next;
689
+ }
690
+ return (p.charAt(0) === "/" ? "/" : "") + out;
691
+ }
692
+
693
+ // --- Navigation: depth, not columns ---
694
+ function setView(next, push) {
695
+ view = next;
696
+ ["jarvis", "home", "bot", "thread"].forEach(function (v) {
697
+ $("view-" + v).classList.toggle("active", v === next);
698
+ });
699
+ if (push !== false) {
700
+ try { history.pushState({ view: next }, ""); } catch (e) {}
701
+ }
702
+ }
703
+
704
+ window.addEventListener("popstate", function () {
705
+ if (view === "thread") { if (threadBack === "jarvis") goJarvis(false); else goBotView(false); }
706
+ else if (view === "bot") goHome(false);
707
+ else if (view === "home") goJarvis(false);
708
+ });
709
+
710
+ function goHome(push) {
711
+ activeBot = null;
712
+ activeThread = null;
713
+ detach();
714
+ setView("home", push);
715
+ return loadRoster();
716
+ }
717
+
718
+ // --- Jarvis home ---
719
+ function goJarvis(push) {
720
+ activeBot = null;
721
+ activeThread = null;
722
+ detach();
723
+ setView("jarvis", push);
724
+ return loadRecent();
725
+ }
726
+
727
+ function loadRecent() {
728
+ return pruneLive().then(function () {
729
+ return Promise.all([api("/jarvis"), api("/bots"), api("/threads")]);
730
+ }).then(function (r) {
731
+ jarvis = r[0].bot || null;
732
+ projects = r[0].projects || [];
733
+ bots = r[1].bots || [];
734
+ allThreads = r[2].threads || [];
735
+ renderHints();
736
+ renderRecent();
737
+ });
738
+ }
739
+
740
+ var HINTS = [
741
+ "What changed across my projects this week?",
742
+ "What's the latest commit on ",
743
+ "Review the latest PR on "
744
+ ];
745
+
746
+ function renderHints() {
747
+ var box = $("hints");
748
+ box.innerHTML = "";
749
+ HINTS.forEach(function (h) {
750
+ var b = el("button", "hint", h.replace(/ $/, "\u2026"));
751
+ b.onclick = function () {
752
+ var input = $("ask-input");
753
+ input.value = h;
754
+ input.focus();
755
+ // A hint that ends in a space wants a project name; one that does not is
756
+ // a whole question.
757
+ if (!/ $/.test(h)) askJarvis(h);
758
+ };
759
+ box.appendChild(b);
760
+ });
761
+ }
762
+
763
+ function botById(id) {
764
+ for (var i = 0; i < bots.length; i++) if (bots[i].id === id) return bots[i];
765
+ return null;
766
+ }
767
+
768
+ function renderRecent() {
769
+ var list = $("recent");
770
+ var lead = $("recent-lead");
771
+ list.innerHTML = "";
772
+ lead.innerHTML = "";
773
+
774
+ if (!projects.length) {
775
+ lead.appendChild(el("p", "lead", "Jarvis doesn\u2019t know any projects yet. Tell it about one \u2014 \u201cadd my repo github.com/you/thing, call it Thing\u201d \u2014 or just ask about it and it will ask for the repo."));
776
+ }
777
+
778
+ var rows = allThreads.filter(function (t) { return t.kind !== "setup"; }).slice(0, 30);
779
+ if (!rows.length) return;
780
+
781
+ rows.forEach(function (t) {
782
+ var bot = botById(t.botId) || { name: "?", id: t.botId };
783
+ var row = tint(el("div", "thread"), bot);
784
+ var body = el("div", "body");
785
+ var title = el("div", "title");
786
+ title.appendChild(el("span", "who", bot.name));
787
+ title.appendChild(document.createTextNode(t.title));
788
+ body.appendChild(title);
789
+ body.appendChild(el("div", "prev", t.preview || "No messages yet"));
790
+ row.appendChild(body);
791
+ var meta = el("div", "meta");
792
+ if (live[t.id]) meta.appendChild(el("span", "dot live", "running"));
793
+ else meta.appendChild(el("div", null, relTime(t.updatedAt)));
794
+ row.appendChild(meta);
795
+ row.onclick = function () { openFromJarvis(t); };
796
+ list.appendChild(row);
797
+ });
798
+ }
799
+
800
+ /** Opens any thread straight from the Jarvis screen; back returns here. */
801
+ function openFromJarvis(thread) {
802
+ var bot = botById(thread.botId);
803
+ if (!bot) { toast("That thread\u2019s bot is gone"); return Promise.resolve(); }
804
+ activeBot = bot;
805
+ threads = [];
806
+ threadBack = "jarvis";
807
+ return openThread(thread);
808
+ }
809
+
810
+ /** A fresh conversation with Jarvis: the server opens the thread and sends
811
+ * the first message in one go; we land in the thread and follow along. */
812
+ function askJarvis(text) {
813
+ text = (text || "").trim();
814
+ if (!text) return;
815
+ var input = $("ask-input");
816
+ var btn = $("ask-send");
817
+ btn.disabled = true;
818
+ api("/jarvis/ask", { method: "POST", body: { prompt: text } })
819
+ .then(function (d) {
820
+ input.value = "";
821
+ input.style.height = "auto";
822
+ if (!jarvis) jarvis = botById(d.thread.botId);
823
+ activeBot = jarvis || botById(d.thread.botId);
824
+ threads = [];
825
+ threadBack = "jarvis";
826
+ live[d.thread.id] = d.sessionId;
827
+ allThreads.unshift(d.thread);
828
+ return openThread(d.thread);
829
+ })
830
+ .catch(function (err) { toast(err && err.message ? err.message : String(err)); })
831
+ .then(function () { btn.disabled = false; });
832
+ }
833
+
834
+ function goBotView(push) {
835
+ detach();
836
+ activeThread = null;
837
+ setView("bot", push);
838
+ renderThreads();
839
+ // Refresh in the background so a turn that ended while we were inside the
840
+ // thread stops showing as running, and titles pick up the latest turn.
841
+ loadThreads().catch(function () {});
842
+ }
843
+
844
+ // --- Home ---
845
+ function loadRoster() {
846
+ return pruneLive().then(function () {
847
+ return Promise.all([api("/bots"), api("/threads")]);
848
+ }).then(function (r) {
849
+ bots = r[0].bots || [];
850
+ allThreads = r[1].threads || [];
851
+ renderRoster();
852
+ });
853
+ }
854
+
855
+ function renderRoster() {
856
+ var grid = $("grid");
857
+ grid.innerHTML = "";
858
+
859
+ if (!bots.length) {
860
+ var e = el("div", "empty");
861
+ e.appendChild(el("div", "big", "\\u{1F916}"));
862
+ e.appendChild(el("h3", null, "No bots yet"));
863
+ e.appendChild(el("p", null, "A bot is a standing set of instructions with a job to do \\u2014 keep the docs in sync, review every PR, cut a release. Give it a name and a brief, then talk to it in threads."));
864
+ var cta = el("button", "btn primary", "Create your first bot");
865
+ cta.onclick = function () { openBotModal(null); };
866
+ e.appendChild(cta);
867
+ grid.style.display = "block";
868
+ grid.appendChild(e);
869
+ return;
870
+ }
871
+ grid.style.display = "";
872
+
873
+ bots.forEach(function (bot) {
874
+ // Jarvis has its own front door; it is not one of the roster.
875
+ if (bot.role === "jarvis") return;
876
+ var mine = allThreads.filter(function (t) { return t.botId === bot.id; });
877
+ var card = tint(el("button", "card"), bot);
878
+ card.appendChild(avatar(bot));
879
+ card.appendChild(el("h3", null, bot.name));
880
+ card.appendChild(el("div", "desc", bot.description || "No description yet."));
881
+
882
+ var foot = el("div", "foot");
883
+ foot.appendChild(el("span", null, mine.length ? mine.length + (mine.length === 1 ? " thread" : " threads") : "No threads"));
884
+ var anyLive = mine.some(function (t) { return !!live[t.id]; });
885
+ if (anyLive) foot.appendChild(el("span", "dot live", "running"));
886
+ else if (mine.length) foot.appendChild(el("span", "dot", relTime(mine[0].updatedAt)));
887
+ card.appendChild(foot);
888
+
889
+ var share = el("span", "share", "\u2934");
890
+ share.setAttribute("role", "button");
891
+ share.setAttribute("tabindex", "0");
892
+ share.title = "Copy a share code for " + bot.name;
893
+ share.setAttribute("aria-label", "Share " + bot.name);
894
+ share.onclick = function (ev) { ev.stopPropagation(); shareBot(bot); };
895
+ share.onkeydown = function (ev) {
896
+ if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); ev.stopPropagation(); shareBot(bot); }
897
+ };
898
+ card.appendChild(share);
899
+
900
+ card.onclick = function () { openBot(bot); };
901
+ grid.appendChild(card);
902
+ });
903
+
904
+ var add = el("button", "new-card");
905
+ add.appendChild(el("div", "plus", "+"));
906
+ add.appendChild(el("div", null, "New bot"));
907
+ add.onclick = function () { openBotModal(null); };
908
+ grid.appendChild(add);
909
+ }
910
+
911
+ // --- Bot page ---
912
+ function openBot(bot) {
913
+ activeBot = bot;
914
+ activeThread = null;
915
+ threadBack = "bot";
916
+ detach();
917
+ $("bot-name").textContent = bot.name;
918
+ var av = $("bot-avatar");
919
+ av.textContent = bot.emoji || "\\u{1F916}";
920
+ tint(av, bot);
921
+ tint($("brief"), bot);
922
+ var text = bot.instructions || "No instructions \\u2014 this bot behaves like plain Claude Code.";
923
+ var brief = $("brief-text");
924
+ brief.textContent = text;
925
+ brief.className = bot.instructions ? "" : "none";
926
+ // The instructions are the bot's identity, not the page's content: one line
927
+ // under the name, opened only when someone asks for the rest.
928
+ $("brief-line-text").textContent = text.split("\\n")[0];
929
+ $("brief-line").hidden = false;
930
+ setBriefOpen(false);
931
+ renderSetup();
932
+ setView("bot");
933
+ return loadThreads();
934
+ }
935
+
936
+ function loadThreads() {
937
+ if (!activeBot) return Promise.resolve();
938
+ return pruneLive().then(function () {
939
+ return api("/threads?botId=" + encodeURIComponent(activeBot.id));
940
+ }).then(function (d) {
941
+ threads = d.threads || [];
942
+ // Keep the roster's counts honest without a second round trip.
943
+ allThreads = allThreads.filter(function (t) { return t.botId !== activeBot.id; }).concat(threads);
944
+ // A thread can be renamed by the turn that just ran, so keep the open
945
+ // thread's header in step with the list.
946
+ if (activeThread) {
947
+ for (var i = 0; i < threads.length; i++) {
948
+ if (threads[i].id !== activeThread.id) continue;
949
+ activeThread = threads[i];
950
+ $("thread-title").textContent = threads[i].title;
951
+ break;
952
+ }
953
+ }
954
+ renderThreads();
955
+ renderSetup();
956
+ // A setup run reports its verdict on the bot, not the thread, so pick the
957
+ // bot up again while the machine is still unprepared.
958
+ if (needsSetup(activeBot)) {
959
+ var id = activeBot.id;
960
+ var was = activeBot.setupStatus;
961
+ return api("/bots/" + id).then(function (r) {
962
+ if (!activeBot || activeBot.id !== id) return;
963
+ replaceBot(r.bot);
964
+ renderSetup();
965
+ renderThreads();
966
+ // The run says how it went in the thread; say it once out here too,
967
+ // since the verdict is what unblocks the rest of the bot.
968
+ var now = r.bot.setupStatus;
969
+ if (now !== was && now === "complete") toast(r.bot.name + " is set up on this machine");
970
+ else if (now !== was && now === "failed") toast("Setup did not finish \u2014 open the setup thread");
971
+ }).catch(function () {});
972
+ }
973
+ });
974
+ }
975
+
976
+ // --- Setup ---
977
+ // A bot may declare what it needs from a machine. Until this machine has it,
978
+ // the bot has exactly one thread it is allowed to run: the setup thread.
979
+
980
+ var SETUP_PROMPT = "Start setup.";
981
+
982
+ function needsSetup(bot) {
983
+ return !!(bot && bot.setupInstructions && bot.setupStatus !== "complete");
984
+ }
985
+
986
+ function setupThreadOf() {
987
+ for (var i = 0; i < threads.length; i++) if (threads[i].kind === "setup") return threads[i];
988
+ return null;
989
+ }
990
+
991
+ /** Keeps the roster's copy of a bot in step with one the server just returned. */
992
+ function replaceBot(bot) {
993
+ if (!bot) return;
994
+ if (activeBot && activeBot.id === bot.id) activeBot = bot;
995
+ for (var i = 0; i < bots.length; i++) if (bots[i].id === bot.id) { bots[i] = bot; break; }
996
+ }
997
+
998
+ function renderSetup() {
999
+ var box = $("setup-banner");
1000
+ box.innerHTML = "";
1001
+ var bot = activeBot;
1002
+ $("new-thread").disabled = needsSetup(bot);
1003
+ $("bot-more").hidden = !(bot && bot.setupInstructions);
1004
+ if (!bot || !bot.setupInstructions) { $("bot-ready").hidden = true; return; }
1005
+
1006
+ var status = bot.setupStatus || "pending";
1007
+ // A ready machine is the steady state, so it says so with a dot in the bar
1008
+ // and gets out of the way. Only pending and failed earn a banner.
1009
+ var dot = $("bot-ready");
1010
+ dot.hidden = false;
1011
+ dot.className = "ready" + (status === "complete" ? "" : status === "failed" ? " failed" : " pending");
1012
+ dot.title = status === "complete" ? "Ready on this machine"
1013
+ : status === "failed" ? "Setup did not finish" : "Setup needed on this machine";
1014
+ if (status === "complete") return;
1015
+
1016
+ var wrap = el("div", "setup" + (status === "failed" ? " failed" : ""));
1017
+ var txt = el("div", "txt");
1018
+ if (status === "failed") {
1019
+ txt.appendChild(el("div", "hd", "Setup did not finish"));
1020
+ txt.appendChild(el("p", null, "Open the setup thread to see what stopped it \u2014 it is an ordinary conversation, so you can answer it and carry on."));
1021
+ } else {
1022
+ txt.appendChild(el("div", "hd", "Setup needed on this machine"));
1023
+ txt.appendChild(el("p", null, bot.name + " needs this machine prepared before it can take work. New threads open once setup is done."));
1024
+ }
1025
+ wrap.appendChild(txt);
1026
+
1027
+ var acts = el("div", "acts");
1028
+ var t = setupThreadOf();
1029
+ var open = el("button", "btn primary", t && t.messageCount ? "Open setup" : "Run setup");
1030
+ open.onclick = function () { openSetup(); };
1031
+ acts.appendChild(open);
1032
+ var mark = el("button", "btn", "Mark as done");
1033
+ mark.title = "Use this if you sorted it out yourself";
1034
+ mark.onclick = function () { setupAction("complete"); };
1035
+ acts.appendChild(mark);
1036
+ wrap.appendChild(acts);
1037
+ box.appendChild(wrap);
1038
+ }
1039
+
1040
+ function setupAction(action) {
1041
+ if (!activeBot) return Promise.resolve();
1042
+ return api("/bots/" + activeBot.id + "/setup", { method: "POST", body: { action: action } })
1043
+ .then(function (d) { replaceBot(d.bot); return loadThreads(); })
1044
+ .catch(function (err) { toast(err && err.message ? err.message : String(err)); });
1045
+ }
1046
+
1047
+ /** Opens the setup thread, making one first if it is missing. */
1048
+ function openSetup() {
1049
+ if (!activeBot || !activeBot.setupInstructions) return Promise.resolve();
1050
+ var t = setupThreadOf();
1051
+ if (t) return openThread(t).then(function () { autoStartSetup(t); });
1052
+ return setupAction("reset").then(function () {
1053
+ var fresh = setupThreadOf();
1054
+ if (!fresh) return;
1055
+ return openThread(fresh).then(function () { autoStartSetup(fresh); });
1056
+ });
1057
+ }
1058
+
1059
+ /**
1060
+ * The opening message of a setup run is written and sent for the user: the
1061
+ * bot's author already said what has to happen, so there is nothing to type.
1062
+ * Everything after it is an ordinary conversation.
1063
+ */
1064
+ function autoStartSetup(thread) {
1065
+ if (!thread || thread.messageCount || live[thread.id] || state !== "idle") return;
1066
+ sendText(SETUP_PROMPT);
1067
+ }
1068
+
1069
+ /** Called right after a bot lands here, whether it was created or imported. */
1070
+ function afterBotAdded(bot) {
1071
+ return openBot(bot).then(function () {
1072
+ if (needsSetup(bot)) return openSetup();
1073
+ });
1074
+ }
1075
+
1076
+ function renderThreads() {
1077
+ var list = $("threads");
1078
+ list.innerHTML = "";
1079
+ $("threads-path").textContent = "";
1080
+ if (!activeBot) return;
1081
+
1082
+ if (!threads.length) {
1083
+ var e = el("div", "empty");
1084
+ e.appendChild(el("h3", null, "No threads yet"));
1085
+ e.appendChild(el("p", null, "Every conversation with " + activeBot.name + " lives in its own thread, and each one keeps its history."));
1086
+ var blocked = needsSetup(activeBot);
1087
+ var cta = el("button", "btn primary", blocked ? "Run setup first" : "Start the first thread");
1088
+ cta.onclick = blocked ? function () { openSetup(); } : newThread;
1089
+ e.appendChild(cta);
1090
+ list.appendChild(e);
1091
+ return;
1092
+ }
1093
+
1094
+ // Nearly every thread runs in the same folder, so the path is stated once
1095
+ // above the list; a row only carries a chip when it broke from the pack.
1096
+ var base = commonPath();
1097
+ $("threads-path").textContent = base ? shortPath(base, 40) : "";
1098
+ $("threads-path").title = base || "";
1099
+
1100
+ threads.forEach(function (t) {
1101
+ var row = tint(el("div", "thread"), activeBot);
1102
+ var body = el("div", "body");
1103
+ var title = el("div", "title", t.title);
1104
+ if (t.kind === "setup") {
1105
+ var st = activeBot.setupStatus || "pending";
1106
+ title.appendChild(el("span", "tag" + (st === "complete" ? " done" : st === "failed" ? " failed" : ""), "setup"));
1107
+ }
1108
+ body.appendChild(title);
1109
+ body.appendChild(el("div", "prev", t.preview || "No messages yet"));
1110
+ if ((t.repoPath || "") !== base) {
1111
+ var where = el("div", "path", shortPath(t.repoPath));
1112
+ where.title = t.repoPath || "";
1113
+ body.appendChild(where);
1114
+ }
1115
+ row.appendChild(body);
1116
+
1117
+ var meta = el("div", "meta");
1118
+ if (live[t.id]) meta.appendChild(el("span", "dot live", "running"));
1119
+ else meta.appendChild(el("div", null, relTime(t.updatedAt)));
1120
+ row.appendChild(meta);
1121
+
1122
+ var kill = el("button", "kill", "\\u00D7");
1123
+ kill.title = "Delete thread";
1124
+ kill.onclick = function (ev) {
1125
+ ev.stopPropagation();
1126
+ if (!confirm("Delete this thread? The Claude Code transcript stays on disk.")) return;
1127
+ api("/threads/" + t.id, { method: "DELETE" }).then(loadThreads).catch(showError);
1128
+ };
1129
+ row.appendChild(kill);
1130
+
1131
+ row.onclick = function () { openThread(t); };
1132
+ list.appendChild(row);
1133
+ });
1134
+ }
1135
+
1136
+ /** The folder most of this bot's threads run in \u2014 the list's implied home. */
1137
+ function commonPath() {
1138
+ var counts = {}, best = null, bestN = 0;
1139
+ threads.forEach(function (t) {
1140
+ var key = t.repoPath || "";
1141
+ counts[key] = (counts[key] || 0) + 1;
1142
+ if (counts[key] > bestN) { bestN = counts[key]; best = key; }
1143
+ });
1144
+ return best;
1145
+ }
1146
+
1147
+ function setBriefOpen(open) {
1148
+ $("brief").hidden = !open;
1149
+ $("brief-line").classList.toggle("open", open);
1150
+ }
1151
+
1152
+ /** The rare, machine-level actions live behind the bar's overflow, not in it. */
1153
+ function openBotMenu() {
1154
+ var menu = $("bot-menu");
1155
+ menu.innerHTML = "";
1156
+ if (!activeBot) return;
1157
+ var again = el("button", null, "Run setup again");
1158
+ again.onclick = function () { closeBotMenu(); setupAction("reset").then(openSetup); };
1159
+ menu.appendChild(again);
1160
+ var r = $("bot-more").getBoundingClientRect();
1161
+ menu.hidden = false;
1162
+ menu.style.top = (r.bottom + 6) + "px";
1163
+ menu.style.left = Math.max(8, r.right - menu.offsetWidth) + "px";
1164
+ }
1165
+
1166
+ function closeBotMenu() { $("bot-menu").hidden = true; }
1167
+
1168
+ function newThread() {
1169
+ if (!activeBot) return;
1170
+ if (needsSetup(activeBot)) {
1171
+ toast("Set up " + activeBot.name + " on this machine first");
1172
+ openSetup();
1173
+ return;
1174
+ }
1175
+ // A thread is pinned to a folder for its whole life, so the folder is
1176
+ // chosen up front rather than argued about later.
1177
+ openFolderPicker(activeBot.repoPath || null, function (repoPath) {
1178
+ api("/threads", { method: "POST", body: { botId: activeBot.id, repoPath: repoPath || undefined } })
1179
+ .then(function (d) { return loadThreads().then(function () { openThread(d.thread); }); })
1180
+ .catch(function (err) {
1181
+ // The server has the last word on whether setup is done.
1182
+ if (err && err.data && err.data.setupRequired) {
1183
+ toast(err.message);
1184
+ loadThreads().then(function () { openSetup(); });
1185
+ return;
1186
+ }
1187
+ toast(err && err.message ? err.message : String(err));
1188
+ });
1189
+ });
1190
+ }
1191
+
1192
+ /**
1193
+ * Pick the directory a thread runs in. Browsing is confined to the directory
1194
+ * the CLI was started in and roams from there; "Use default" skips the choice,
1195
+ * which leaves the server to fall back to the bot's directory or its own.
1196
+ */
1197
+ function openFolderPicker(startPath, onPick) {
1198
+ var backdrop = el("div", "backdrop");
1199
+ var modal = el("div", "modal");
1200
+ function close() {
1201
+ backdrop.remove();
1202
+ document.removeEventListener("keydown", esc);
1203
+ }
1204
+ function esc(ev) { if (ev.key === "Escape") close(); }
1205
+ modalHead(modal, "Where should this thread run?", close);
1206
+
1207
+ var jumps = el("div", "jumps");
1208
+ var pathbar = el("div", "pathbar", "\u2026");
1209
+ var list = el("div", "picker");
1210
+ modal.appendChild(jumps);
1211
+ modal.appendChild(pathbar);
1212
+ modal.appendChild(list);
1213
+
1214
+ var current = null;
1215
+
1216
+ function jump(label, path) {
1217
+ var b = el("button", "chip", label);
1218
+ b.title = path;
1219
+ b.onclick = function () { load(path); };
1220
+ jumps.appendChild(b);
1221
+ }
1222
+
1223
+ function row(icon, name, onClick) {
1224
+ var r = el("button", "picker-row");
1225
+ r.appendChild(el("span", "ic", icon));
1226
+ r.appendChild(el("span", "nm", name));
1227
+ r.onclick = onClick;
1228
+ list.appendChild(r);
1229
+ return r;
1230
+ }
1231
+
1232
+ function load(path) {
1233
+ return api("/browse" + (path ? "?path=" + encodeURIComponent(path) : ""))
1234
+ .then(function (d) {
1235
+ current = d.path;
1236
+ pathbar.textContent = d.path;
1237
+ jumps.innerHTML = "";
1238
+ jump("Workspace", d.workspace);
1239
+ jump("Home", d.home);
1240
+ jump("/", "/");
1241
+ list.innerHTML = "";
1242
+ if (d.parent) row("\u2191", "..", function () { load(d.parent); });
1243
+ (d.dirs || []).forEach(function (dir) {
1244
+ row("\u{1F4C1}", dir.name, function () { load(dir.path); });
1245
+ });
1246
+ if (!d.dirs.length && !d.parent) {
1247
+ list.appendChild(el("div", "picker-note", "No subfolders here."));
1248
+ }
1249
+ })
1250
+ .catch(function (err) {
1251
+ // A remembered folder can be gone or unreadable; the workspace is
1252
+ // always browsable, so fall back to it.
1253
+ if (path) return load(null);
1254
+ showError(err);
1255
+ });
1256
+ }
1257
+
1258
+ var acts = el("div", "acts");
1259
+ var def = el("button", "btn ghost", "Use default");
1260
+ def.title = "Run where the CLI was started (or the bot's directory)";
1261
+ def.onclick = function () { close(); onPick(null); };
1262
+ acts.appendChild(def);
1263
+ acts.appendChild(el("div", "spacer"));
1264
+ var cancel = el("button", "btn", "Cancel");
1265
+ cancel.onclick = close;
1266
+ var pick = el("button", "btn primary", "Run here");
1267
+ pick.onclick = function () {
1268
+ if (!current) return;
1269
+ close();
1270
+ onPick(current);
1271
+ };
1272
+ acts.appendChild(cancel);
1273
+ acts.appendChild(pick);
1274
+ modal.appendChild(acts);
1275
+
1276
+ backdrop.appendChild(modal);
1277
+ document.addEventListener("keydown", esc);
1278
+ document.body.appendChild(backdrop);
1279
+ load(startPath);
1280
+ }
1281
+
1282
+ // --- Thread ---
1283
+ function openThread(thread) {
1284
+ activeThread = thread;
1285
+ detach();
1286
+ $("thread-title").textContent = thread.title;
1287
+ $("thread-sub").textContent = shortPath(thread.repoPath, 30);
1288
+ $("thread-sub").title = thread.repoPath || "";
1289
+ $("to-bot-label").textContent = threadBack === "jarvis" ? "Jarvis" : (activeBot ? activeBot.name : "Back");
1290
+ var av = $("thread-avatar");
1291
+ av.textContent = activeBot ? (activeBot.emoji || "\\u{1F916}") : "\\u{1F916}";
1292
+ tint(av, activeBot || {});
1293
+ $("input").placeholder = "Message " + (activeBot ? activeBot.name : "bot") + "\\u2026";
1294
+ setState("idle");
1295
+ setActivity("");
1296
+ setView("thread");
1297
+
1298
+ // If this thread's turn is still running, rejoin it rather than rendering a
1299
+ // transcript that stops short of what the agent is doing right now.
1300
+ var sid = live[thread.id];
1301
+ if (sid) return rejoin(thread, sid);
1302
+ return loadMessages();
1303
+ }
1304
+
1305
+ function loadMessages() {
1306
+ var box = $("messages-inner");
1307
+ box.innerHTML = "";
1308
+ if (!activeThread) return Promise.resolve();
1309
+ return api("/threads/" + activeThread.id + "/messages").then(function (d) {
1310
+ renderTranscript(d.messages || []);
1311
+ });
1312
+ }
1313
+
1314
+ function renderTranscript(msgs) {
1315
+ var box = $("messages-inner");
1316
+ box.innerHTML = "";
1317
+ if (!msgs.length) {
1318
+ var e = el("div", "empty");
1319
+ e.appendChild(el("h3", null, "Say the first thing"));
1320
+ e.appendChild(el("p", null, activeBot ? activeBot.name + " already knows its job \\u2014 tell it what to do this time." : "Send a message to start."));
1321
+ box.appendChild(e);
1322
+ return;
1323
+ }
1324
+ msgs.forEach(function (m) {
1325
+ var content = startMessage(m.role === "user" ? "user" : "assistant");
1326
+ (m.content || []).forEach(function (b) {
1327
+ if (b.type === "text") appendText(content, b.text);
1328
+ else if (b.type === "tool_use") appendTool(content, b.tool_name, b.tool_input);
1329
+ else if (b.type === "image_url") {
1330
+ var img = document.createElement("img");
1331
+ img.src = b.url; img.style.maxWidth = "100%"; img.style.borderRadius = "12px";
1332
+ content.appendChild(img);
1333
+ }
1334
+ });
1335
+ });
1336
+ scrollDown();
1337
+ }
1338
+
1339
+ /** Opens a message row and returns its content column, ready for blocks. */
1340
+ function startMessage(kind) {
1341
+ var box = $("messages-inner");
1342
+ var placeholder = box.querySelector(".empty");
1343
+ if (placeholder) placeholder.remove();
1344
+
1345
+ var row = el("div", "msg " + kind);
1346
+ if (kind === "assistant" && activeBot) row.appendChild(avatar(activeBot, "sm"));
1347
+ var content = el("div", "content");
1348
+ if (kind === "assistant" && activeBot) content.appendChild(el("div", "who", activeBot.name));
1349
+ row.appendChild(content);
1350
+ box.appendChild(row);
1351
+ scrollDown();
1352
+ return content;
1353
+ }
1354
+
1355
+ /** The bubble text should flow into: the trailing one when the last block is
1356
+ * already text, a fresh one when a tool chip closed it off. Keeps text and
1357
+ * tools interleaved in arrival order instead of piling tools at the bottom. */
1358
+ function bubbleOf(content, md) {
1359
+ var last = content.lastElementChild;
1360
+ if (last && last.classList && last.classList.contains("bubble")) return last;
1361
+ var b = el("div", md ? "bubble md" : "bubble");
1362
+ content.appendChild(b);
1363
+ return b;
1364
+ }
1365
+
1366
+ /** Assistant text is markdown: agents write lists, code fences and tables.
1367
+ * marked renders it, DOMPurify strips anything unsafe the model may have
1368
+ * echoed back. User and error text stays literal \u2014 nobody wants their
1369
+ * asterisks eaten. Falls back to plain text if the libs failed to load. */
1370
+ var MD = (function () {
1371
+ if (typeof marked === "undefined" || typeof DOMPurify === "undefined") return null;
1372
+ marked.setOptions({ gfm: true, breaks: true });
1373
+ return function (src) {
1374
+ try { return DOMPurify.sanitize(marked.parse(src)); }
1375
+ catch (e) { return null; } // malformed input falls back to plain text
1376
+ };
1377
+ })();
1378
+
1379
+ function isAssistant(content) {
1380
+ var row = content.parentNode;
1381
+ return !!(row && row.classList && row.classList.contains("assistant"));
1382
+ }
1383
+
1384
+ function appendText(content, text) {
1385
+ if (!text) return;
1386
+ var md = !!MD && isAssistant(content);
1387
+ var b = bubbleOf(content, md);
1388
+ // The raw source is kept so streamed chunks re-render as one document
1389
+ // rather than each being parsed on its own (a fence can span chunks).
1390
+ b.__md = b.__md ? b.__md + "\\n\\n" + text : text;
1391
+ var out = md ? MD(b.__md) : null;
1392
+ if (out === null) { b.className = "bubble"; b.textContent = b.__md; return; }
1393
+ b.innerHTML = out;
1394
+ var links = b.querySelectorAll("a[href]");
1395
+ for (var i = 0; i < links.length; i++) {
1396
+ links[i].target = "_blank";
1397
+ links[i].rel = "noopener noreferrer";
1398
+ }
1399
+ }
1400
+
1401
+ function appendTool(content, name, input) {
1402
+ var chip = el("div", "tool");
1403
+ // Jarvis's own tools read better without the MCP plumbing in the name.
1404
+ chip.appendChild(el("b", null, name.replace(/^mcp__jarvis__/, "")));
1405
+ var summary = typeof input === "string" ? input : JSON.stringify(input || {});
1406
+ chip.appendChild(el("span", null, summary.slice(0, 200)));
1407
+ content.appendChild(chip);
1408
+ scrollDown();
1409
+ }
1410
+
1411
+ function scrollDown() {
1412
+ var m = $("messages");
1413
+ m.scrollTop = m.scrollHeight;
1414
+ }
1415
+
1416
+ function showError(err) {
1417
+ var content = startMessage("error");
1418
+ appendText(content, err && err.message ? err.message : String(err));
1419
+ }
1420
+
1421
+ // --- Sending ---
1422
+ function send() {
1423
+ var input = $("input");
1424
+ var text = input.value.trim();
1425
+ if (!text) return;
1426
+ input.value = "";
1427
+ input.style.height = "auto";
1428
+ sendText(text);
1429
+ }
1430
+
1431
+ /** Sends a turn on the open thread. The setup run uses this with a written prompt. */
1432
+ function sendText(text) {
1433
+ if (!text || !activeThread || state !== "idle") return;
1434
+ appendText(startMessage("user"), text);
1435
+ setState("starting");
1436
+ setActivity("Sending\u2026");
1437
+
1438
+ var threadId = activeThread.id;
1439
+ api("/chat", { method: "POST", body: { threadId: threadId, prompt: text } })
1440
+ .then(function (d) {
1441
+ sessionId = d.sessionId;
1442
+ live[threadId] = d.sessionId;
1443
+ streamThreadId = threadId;
1444
+ setState("running");
1445
+ setActivity("Thinking\u2026");
1446
+ attachStream(d.sessionId, function (type, data, ev) { handleEvent(type, data, ev, false); });
1447
+ loadThreads();
1448
+ })
1449
+ .catch(function (err) { showError(err); finishTurn(); });
1450
+ }
1451
+
1452
+ /** Asks the server to abort. The agent's "aborted" event is what actually
1453
+ * returns the button to Send. */
1454
+ function abort() {
1455
+ if (!sessionId || state === "idle" || state === "aborting") return;
1456
+ setState("aborting");
1457
+ setActivity("Stopping\u2026");
1458
+ api("/sessions/" + encodeURIComponent(sessionId) + "/abort", { method: "POST" })
1459
+ .catch(function (err) { showError(err); finishTurn(); });
1460
+ }
1461
+
1462
+ /**
1463
+ * Rejoins a turn that is still running after we navigated away. /events
1464
+ * replays the whole turn before going live, so the catch-up is free — but the
1465
+ * transcript on disk may already hold part of that same turn. So: buffer the
1466
+ * replay, use its prompt to trim the overlap off the transcript, paint the
1467
+ * history, then let the buffered events through.
1468
+ */
1469
+ function rejoin(thread, sid) {
1470
+ sessionId = sid;
1471
+ streamThreadId = thread.id;
1472
+ setState("running");
1473
+ setActivity("Catching up\u2026");
1474
+
1475
+ var buffer = [];
1476
+ var flushed = false;
1477
+ var prompt = null;
1478
+ var promptSeen = false;
1479
+ pendingFilter = null;
1480
+
1481
+ attachStream(sid, function (type, data, ev) {
1482
+ if (flushed) { handleEvent(type, data, ev, false); return; }
1483
+ if (type === "user_prompt") { prompt = data.prompt || ""; promptSeen = true; }
1484
+ buffer.push([type, data, ev]);
1485
+ });
1486
+
1487
+ var transcript = api("/threads/" + thread.id + "/messages").catch(function () { return { messages: [] }; });
1488
+
1489
+ // Approvals resolved while we were away must not be offered again.
1490
+ var pending = api("/sessions/" + encodeURIComponent(sid) + "/permissions")
1491
+ .then(function (d) { pendingFilter = d.pending || []; })
1492
+ .catch(function () { pendingFilter = []; });
1493
+
1494
+ // Give the replay a moment to arrive; it is written the instant we connect,
1495
+ // but don't hang the screen on it if the turn produced nothing yet.
1496
+ var settled = new Promise(function (resolve) {
1497
+ var waited = 0;
1498
+ (function tick() {
1499
+ if (promptSeen || waited >= 600) return resolve();
1500
+ waited += 50;
1501
+ setTimeout(tick, 50);
1502
+ })();
1503
+ });
1504
+
1505
+ return Promise.all([transcript, settled, pending]).then(function (r) {
1506
+ if (activeThread !== thread) return; // navigated away again
1507
+ renderTranscript(trimInFlight(r[0].messages || [], prompt));
1508
+ flushed = true;
1509
+ buffer.forEach(function (item) { handleEvent(item[0], item[1], item[2], true); });
1510
+ buffer = [];
1511
+ pendingFilter = null;
1512
+ scrollDown();
1513
+ });
1514
+ }
1515
+
1516
+ /** Drops the tail of the transcript belonging to the turn still in flight,
1517
+ * identified by the prompt that started it. */
1518
+ function trimInFlight(messages, prompt) {
1519
+ if (!prompt) return messages;
1520
+ for (var i = messages.length - 1; i >= 0; i--) {
1521
+ var m = messages[i];
1522
+ if (m.role !== "user") continue;
1523
+ var text = (m.content || []).filter(function (b) { return b.type === "text"; })
1524
+ .map(function (b) { return b.text; }).join("\\n\\n");
1525
+ if (text.trim() === prompt.trim()) return messages.slice(0, i);
1526
+ }
1527
+ return messages;
1528
+ }
1529
+
1530
+ // Every event type the agent emits on the turn stream.
1531
+ var STREAM_EVENTS = ["user_prompt", "assistant", "tool_use", "status", "permission_request", "aborted", "done", "result", "error",
1532
+ "delegation", "handoff", "project_created"];
1533
+
1534
+ function attachStream(sid, onEvent) {
1535
+ closeStream();
1536
+ liveBubble = null;
1537
+ stream = new EventSource("/events?sessionId=" + encodeURIComponent(sid));
1538
+ STREAM_EVENTS.forEach(function (type) {
1539
+ stream.addEventListener(type, function (ev) {
1540
+ var data = {};
1541
+ if (ev.data) { try { data = JSON.parse(ev.data); } catch (e) {} }
1542
+ onEvent(type, data, ev);
1543
+ });
1544
+ });
1545
+ }
1546
+
1547
+ /** Renders one stream event. In catch-up mode the prompt that started the
1548
+ * turn still needs painting; on the live path we already showed it. */
1549
+ function handleEvent(type, data, ev, replay) {
1550
+ switch (type) {
1551
+ case "user_prompt":
1552
+ if (replay && data.prompt) appendText(startMessage("user"), data.prompt);
1553
+ break;
1554
+
1555
+ case "assistant":
1556
+ if (!liveBubble) liveBubble = startMessage("assistant");
1557
+ appendText(liveBubble, data.content);
1558
+ scrollDown();
1559
+ break;
1560
+
1561
+ case "tool_use":
1562
+ if (!liveBubble) liveBubble = startMessage("assistant");
1563
+ appendTool(liveBubble, data.tool_name, data.tool_input);
1564
+ break;
1565
+
1566
+ case "status":
1567
+ if (state === "aborting") break;
1568
+ if (data.status === "thinking") setActivity("Thinking\u2026");
1569
+ else if (data.status === "tool") setActivity("Running " + (data.tool_name || "tool").replace(/^mcp__jarvis__/, "") + "\u2026");
1570
+ else if (data.status === "tool_summary" && data.summary) setActivity(data.summary);
1571
+ break;
1572
+
1573
+ case "permission_request":
1574
+ if (replay && pendingFilter && pendingFilter.indexOf(data.toolUseID) === -1) break;
1575
+ pendingPerms++;
1576
+ setActivity("Waiting for your approval\u2026");
1577
+ renderPermission(data);
1578
+ break;
1579
+
1580
+ case "delegation":
1581
+ renderDispatch(data, "Delegated to " + (data.project ? data.project.name : "project"));
1582
+ break;
1583
+
1584
+ case "handoff":
1585
+ renderDispatch(data, "Handed off to " + (data.project ? data.project.name : "project"));
1586
+ // Live, the conversation moves with the work; on a replay the card is
1587
+ // enough, since the user chose to look at Jarvis's side of it.
1588
+ if (!replay) followHandoff(data);
1589
+ break;
1590
+
1591
+ case "project_created":
1592
+ if (data.project) {
1593
+ $("messages-inner").appendChild(el("div", "note", "New project: " + data.project.name));
1594
+ projects.push(data.project);
1595
+ scrollDown();
1596
+ }
1597
+ break;
1598
+
1599
+ case "aborted":
1600
+ $("messages-inner").appendChild(el("div", "note", "Stopped."));
1601
+ scrollDown();
1602
+ finishTurn();
1603
+ break;
1604
+
1605
+ case "done":
1606
+ finishTurn();
1607
+ break;
1608
+
1609
+ case "error":
1610
+ // Fires for an agent error (has data) and for a transport drop (none).
1611
+ // EventSource retries drops itself, so only give up once it is closed.
1612
+ if (ev && ev.data) {
1613
+ showError(new Error(data.message || "Stream error"));
1614
+ finishTurn();
1615
+ } else if (!stream || stream.readyState === 2) {
1616
+ setActivity("Connection lost");
1617
+ finishTurn();
1618
+ } else {
1619
+ setActivity("Reconnecting\u2026");
1620
+ }
1621
+ break;
1622
+ }
1623
+ }
1624
+
1625
+ /** A card for work Jarvis sent to a project: one per thread, updated in place
1626
+ * as the status changes, opening the thread on tap. */
1627
+ function renderDispatch(data, label) {
1628
+ var id = "dispatch-" + data.threadId;
1629
+ var card = document.getElementById(id);
1630
+ if (!card) {
1631
+ if (!liveBubble) liveBubble = startMessage("assistant");
1632
+ card = el("div", "dispatch");
1633
+ card.id = id;
1634
+ var body = el("div", "body");
1635
+ body.appendChild(el("div", "hd", label));
1636
+ body.appendChild(el("div", "ttl", data.title || "Working\u2026"));
1637
+ card.appendChild(body);
1638
+ card.appendChild(el("span", "dot", ""));
1639
+ var open = el("button", "btn", "Open");
1640
+ open.onclick = function () { openDispatched(data); };
1641
+ card.appendChild(open);
1642
+ liveBubble.appendChild(card);
1643
+ }
1644
+ var dot = card.querySelector(".dot");
1645
+ var status = data.status || "running";
1646
+ dot.className = "dot" + (status === "running" ? " live" : "");
1647
+ dot.textContent = status === "running" ? "running" : status === "done" ? "done" : "failed";
1648
+ if (status === "running") live[data.threadId] = data.sessionId; else delete live[data.threadId];
1649
+ scrollDown();
1650
+ }
1651
+
1652
+ /** Opens the project thread behind a dispatch card; back returns to Jarvis. */
1653
+ function openDispatched(data) {
1654
+ return Promise.all([
1655
+ api("/bots/" + encodeURIComponent(data.botId)),
1656
+ api("/threads/" + encodeURIComponent(data.threadId))
1657
+ ]).then(function (r) {
1658
+ replaceBot(r[0].bot);
1659
+ if (!botById(r[0].bot.id)) bots.push(r[0].bot);
1660
+ activeBot = r[0].bot;
1661
+ threads = [];
1662
+ threadBack = "jarvis";
1663
+ return openThread(r[1].thread);
1664
+ }).catch(function (err) { toast(err && err.message ? err.message : String(err)); });
1665
+ }
1666
+
1667
+ /** The moment Jarvis hands off, the user is in the project. Jarvis's own turn
1668
+ * finishes on its own in the background. */
1669
+ function followHandoff(data) {
1670
+ var from = activeThread;
1671
+ setActivity("Moving to " + (data.project ? data.project.name : "the project") + "\u2026");
1672
+ live[data.threadId] = data.sessionId;
1673
+ setTimeout(function () {
1674
+ if (activeThread !== from) return; // the user already went elsewhere
1675
+ openDispatched(data);
1676
+ }, 600);
1677
+ }
1678
+
1679
+ function renderPermission(data) {
1680
+ var card = el("div", "perm");
1681
+ card.appendChild(el("div", "head", "Allow " + data.toolName + "?"));
1682
+ card.appendChild(el("pre", null, JSON.stringify(data.input, null, 2)));
1683
+ var acts = el("div", "acts");
1684
+ var allow = el("button", "btn primary", "Allow");
1685
+ var deny = el("button", "btn", "Deny");
1686
+ function respond(ok) {
1687
+ allow.disabled = deny.disabled = true;
1688
+ api("/sessions/" + encodeURIComponent(sessionId) + "/permission", { method: "POST", body: { toolUseID: data.toolUseID, approved: ok } })
1689
+ .then(function () {
1690
+ card.replaceWith(el("div", "note", (ok ? "Allowed " : "Denied ") + data.toolName));
1691
+ pendingPerms = Math.max(0, pendingPerms - 1);
1692
+ if (!pendingPerms && state === "running") setActivity("Thinking\u2026");
1693
+ })
1694
+ .catch(showError);
1695
+ }
1696
+ allow.onclick = function () { respond(true); };
1697
+ deny.onclick = function () { respond(false); };
1698
+ acts.appendChild(allow);
1699
+ acts.appendChild(deny);
1700
+ card.appendChild(acts);
1701
+ $("messages-inner").appendChild(card);
1702
+ scrollDown();
1703
+ }
1704
+
1705
+ function finishTurn() {
1706
+ closeStream();
1707
+ liveBubble = null;
1708
+ pendingPerms = 0;
1709
+ if (streamThreadId) delete live[streamThreadId];
1710
+ streamThreadId = null;
1711
+ var keep = $("activity").textContent === "Connection lost";
1712
+ setState("idle");
1713
+ if (!keep) setActivity("");
1714
+ loadThreads();
1715
+ }
1716
+
1717
+ function closeStream() { if (stream) { stream.close(); stream = null; } }
1718
+
1719
+ /** Drops turns that finished while we were not watching, so a thread does not
1720
+ * keep claiming to be running forever. */
1721
+ function pruneLive() {
1722
+ var ids = Object.keys(live);
1723
+ return Promise.all(ids.map(function (threadId) {
1724
+ return api("/sessions/" + encodeURIComponent(live[threadId]) + "/status")
1725
+ .then(function (d) { if (!d.streaming) delete live[threadId]; })
1726
+ .catch(function () { delete live[threadId]; });
1727
+ })).then(function () {
1728
+ // Turns started elsewhere \u2014 by Jarvis, or from another device \u2014 are
1729
+ // running too; the server is the one place that knows all of them.
1730
+ return api("/sessions/active").then(function (d) {
1731
+ (d.active || []).forEach(function (a) { live[a.threadId] = a.sessionId; });
1732
+ }).catch(function () {});
1733
+ });
1734
+ }
1735
+
1736
+ /** Leaves a running turn alone server-side, but stops following it here. */
1737
+ function detach() {
1738
+ closeStream();
1739
+ liveBubble = null;
1740
+ pendingPerms = 0;
1741
+ setState("idle");
1742
+ setActivity("");
1743
+ }
1744
+
1745
+ /** The one place that decides what the composer looks like. */
1746
+ function setState(next) {
1747
+ state = next;
1748
+ var running = next !== "idle";
1749
+ var send = $("send");
1750
+ send.classList.toggle("stop", running);
1751
+ send.textContent = running ? "" : "\\u2191"; // stop glyph is drawn by CSS
1752
+ send.title = running ? "Stop" : "Send";
1753
+ send.setAttribute("aria-label", running ? "Stop" : "Send");
1754
+ send.disabled = next === "aborting" || (!running && !activeThread);
1755
+ }
1756
+
1757
+ function setActivity(text) {
1758
+ var box = $("activity");
1759
+ box.textContent = "";
1760
+ if (!text) return;
1761
+ if (state !== "idle" && text.indexOf("Waiting for your approval") === -1 && text !== "Connection lost") {
1762
+ box.appendChild(el("div", "spinner"));
1763
+ }
1764
+ box.appendChild(el("span", null, text));
1765
+ }
1766
+
1767
+ // --- Sharing ---
1768
+ // A bot is portable: everything that defines its behaviour travels, and
1769
+ // nothing that is local to one machine does. repoPath is deliberately left
1770
+ // behind — the folder a bot works in is the receiver's to choose.
1771
+ var SHARE_PREFIX = "jarvis:v1:";
1772
+ // Pre-rename prefixes. Codes already in circulation carry them, so import
1773
+ // still accepts them; export only ever writes the current one.
1774
+ var LEGACY_SHARE_PREFIXES = ["gitbot:v1:", "grassbot:v1:"];
1775
+ // setupStatus and setupThreadId stay behind with repoPath: they describe this
1776
+ // machine, not the bot. The receiving machine works out its own.
1777
+ var SHARE_FIELDS = ["name", "emoji", "description", "instructions", "setupInstructions",
1778
+ "model", "permissionMode", "allowedTools", "disallowedTools"];
1779
+
1780
+ function toB64(str) {
1781
+ var bytes = new TextEncoder().encode(str);
1782
+ var bin = "";
1783
+ for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
1784
+ var out = btoa(bin).split("+").join("-").split("/").join("_");
1785
+ while (out.charAt(out.length - 1) === "=") out = out.slice(0, -1);
1786
+ return out;
1787
+ }
1788
+
1789
+ function fromB64(str) {
1790
+ var b = str.split("-").join("+").split("_").join("/");
1791
+ while (b.length % 4) b += "=";
1792
+ var bin = atob(b);
1793
+ var bytes = new Uint8Array(bin.length);
1794
+ for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1795
+ return new TextDecoder().decode(bytes);
1796
+ }
1797
+
1798
+ function shareCode(bot) {
1799
+ var payload = {};
1800
+ SHARE_FIELDS.forEach(function (k) {
1801
+ if (bot[k] !== undefined && bot[k] !== null && bot[k] !== "") payload[k] = bot[k];
1802
+ });
1803
+ return SHARE_PREFIX + toB64(JSON.stringify(payload));
1804
+ }
1805
+
1806
+ /** Accepts a share code or the bare JSON inside one. Returns null if neither. */
1807
+ function parseShare(text) {
1808
+ var raw = String(text || "").trim();
1809
+ if (!raw) return null;
1810
+ var json = raw;
1811
+ var prefixes = [SHARE_PREFIX].concat(LEGACY_SHARE_PREFIXES);
1812
+ for (var p = 0; p < prefixes.length; p++) {
1813
+ var at = raw.indexOf(prefixes[p]);
1814
+ if (at === -1) continue;
1815
+ // Tolerate a code that picked up quotes or a wrapping sentence in transit.
1816
+ var code = raw.slice(at + prefixes[p].length).split(/[^A-Za-z0-9_-]/)[0];
1817
+ try { json = fromB64(code); } catch (e) { return null; }
1818
+ break;
1819
+ }
1820
+ var obj;
1821
+ try { obj = JSON.parse(json); } catch (e) { return null; }
1822
+ if (!obj || typeof obj !== "object" || typeof obj.name !== "string" || !obj.name.trim()) return null;
1823
+
1824
+ // Only known fields cross the boundary, each checked for its own shape.
1825
+ var bot = { name: obj.name.trim() };
1826
+ ["emoji", "description", "instructions", "setupInstructions", "model"].forEach(function (k) {
1827
+ if (typeof obj[k] === "string") bot[k] = obj[k];
1828
+ });
1829
+ if (["ask-permissions", "auto-approve", "plan"].indexOf(obj.permissionMode) !== -1) {
1830
+ bot.permissionMode = obj.permissionMode;
1831
+ }
1832
+ ["allowedTools", "disallowedTools"].forEach(function (k) {
1833
+ if (Array.isArray(obj[k])) {
1834
+ var tools = obj[k].filter(function (t) { return typeof t === "string" && t.trim(); });
1835
+ if (tools.length) bot[k] = tools;
1836
+ }
1837
+ });
1838
+ return bot;
1839
+ }
1840
+
1841
+ // --- Dictation ---
1842
+ // Tap to record, tap again to stop. The clip goes to /dictate, which returns
1843
+ // the cleaned-up instruction; that lands in the textarea for review, never
1844
+ // the raw transcript. One recording at a time across both boxes.
1845
+ var recording = null; // { btn, recorder, chunks, stream }
1846
+
1847
+ function recordingMime() {
1848
+ var types = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
1849
+ for (var i = 0; i < types.length; i++) {
1850
+ if (window.MediaRecorder && MediaRecorder.isTypeSupported(types[i])) return types[i];
1851
+ }
1852
+ return "";
1853
+ }
1854
+
1855
+ function insertDictation(textarea, text) {
1856
+ if (!text) { toast("Didn't catch that"); return; }
1857
+ var cur = textarea.value;
1858
+ textarea.value = cur && !/\s$/.test(cur) ? cur + " " + text : cur + text;
1859
+ textarea.dispatchEvent(new Event("input")); // resize + any review hooks
1860
+ textarea.focus();
1861
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
1862
+ }
1863
+
1864
+ function blobToBase64(blob) {
1865
+ return new Promise(function (resolve, reject) {
1866
+ var r = new FileReader();
1867
+ r.onload = function () { resolve(String(r.result).split(",")[1] || ""); };
1868
+ r.onerror = function () { reject(r.error); };
1869
+ r.readAsDataURL(blob);
1870
+ });
1871
+ }
1872
+
1873
+ function stopRecording() {
1874
+ var rec = recording;
1875
+ if (!rec) return;
1876
+ recording = null;
1877
+ rec.btn.classList.remove("recording");
1878
+ rec.btn.classList.add("busy");
1879
+ rec.recorder.stop();
1880
+ rec.stream.getTracks().forEach(function (t) { t.stop(); });
1881
+ }
1882
+
1883
+ function startRecording(btn, textarea) {
1884
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder) {
1885
+ toast("Dictation isn't supported in this browser"); return;
1886
+ }
1887
+ if (!window.isSecureContext) {
1888
+ toast("Dictation needs https or localhost"); return;
1889
+ }
1890
+ navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
1891
+ var mime = recordingMime();
1892
+ var recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
1893
+ var chunks = [];
1894
+ recorder.ondataavailable = function (ev) { if (ev.data && ev.data.size) chunks.push(ev.data); };
1895
+ recorder.onstop = function () {
1896
+ var type = (recorder.mimeType || mime || "audio/webm").split(";")[0];
1897
+ var blob = new Blob(chunks, { type: type });
1898
+ if (blob.size < 1000) { btn.classList.remove("busy"); return; } // tapped twice by accident
1899
+ blobToBase64(blob).then(function (audio) {
1900
+ return api("/dictate", { method: "POST", body: { audio: audio, mimeType: type } });
1901
+ }).then(function (d) {
1902
+ insertDictation(textarea, d.text);
1903
+ }).catch(function (err) {
1904
+ toast(err.message || "Dictation failed");
1905
+ }).then(function () {
1906
+ btn.classList.remove("busy");
1907
+ });
1908
+ };
1909
+ recorder.start();
1910
+ recording = { btn: btn, recorder: recorder, chunks: chunks, stream: stream };
1911
+ btn.classList.add("recording");
1912
+ }).catch(function (err) {
1913
+ toast(err && err.name === "NotAllowedError" ? "Microphone access was denied" : "Couldn't start the microphone");
1914
+ });
1915
+ }
1916
+
1917
+ function wireMic(btn, textarea) {
1918
+ btn.onclick = function () {
1919
+ if (btn.classList.contains("busy")) return;
1920
+ if (recording && recording.btn === btn) { stopRecording(); return; }
1921
+ if (recording) stopRecording();
1922
+ startRecording(btn, textarea);
1923
+ };
1924
+ }
1925
+
1926
+ function toast(text) {
1927
+ var old = document.querySelector(".toast");
1928
+ if (old) old.remove();
1929
+ var t = el("div", "toast", text);
1930
+ document.body.appendChild(t);
1931
+ setTimeout(function () { t.remove(); }, 2400);
1932
+ }
1933
+
1934
+ function copyText(text) {
1935
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1936
+ return navigator.clipboard.writeText(text);
1937
+ }
1938
+ // execCommand is the only route on a plain-http origin, which is the
1939
+ // common case when the CLI is reached over a LAN address.
1940
+ return new Promise(function (resolve, reject) {
1941
+ var ta = el("textarea");
1942
+ ta.value = text;
1943
+ ta.style.position = "fixed";
1944
+ ta.style.opacity = "0";
1945
+ document.body.appendChild(ta);
1946
+ ta.select();
1947
+ var ok = false;
1948
+ try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
1949
+ ta.remove();
1950
+ ok ? resolve() : reject(new Error("copy blocked"));
1951
+ });
1952
+ }
1953
+
1954
+ function shareBot(bot) {
1955
+ var code = shareCode(bot);
1956
+ copyText(code)
1957
+ .then(function () { toast("Copied " + bot.name + " to the clipboard"); })
1958
+ .catch(function () { openShareModal(bot, code); });
1959
+ }
1960
+
1961
+ /** Shown only when the clipboard is unavailable: the code, ready to copy by hand. */
1962
+ function openShareModal(bot, code) {
1963
+ var parts = modalShell("Share " + bot.name);
1964
+ parts.modal.appendChild(el("p", "picker-note", "Copying was blocked by the browser. Copy this code and paste it into another Jarvis."));
1965
+ var box = el("textarea", "code");
1966
+ box.value = code;
1967
+ box.readOnly = true;
1968
+ parts.modal.appendChild(box);
1969
+ var acts = el("div", "acts");
1970
+ acts.appendChild(el("div", "spacer"));
1971
+ var done = el("button", "btn primary", "Done");
1972
+ done.onclick = parts.close;
1973
+ acts.appendChild(done);
1974
+ parts.modal.appendChild(acts);
1975
+ parts.open();
1976
+ box.focus();
1977
+ box.select();
1978
+ }
1979
+
1980
+ function openImportModal() {
1981
+ var parts = modalShell("Import a bot");
1982
+ var field = el("div", "field");
1983
+ var lab = el("label", null, "Share code");
1984
+ lab.appendChild(el("span", "hint", " paste what someone shared with you"));
1985
+ field.appendChild(lab);
1986
+ var box = el("textarea", "code");
1987
+ box.placeholder = SHARE_PREFIX + "…";
1988
+ field.appendChild(box);
1989
+ parts.modal.appendChild(field);
1990
+
1991
+ var preview = el("div", "preview");
1992
+ preview.style.display = "none";
1993
+ parts.modal.appendChild(preview);
1994
+
1995
+ var add = el("button", "btn primary", "Add bot");
1996
+ add.disabled = true;
1997
+ var parsed = null;
1998
+
1999
+ function review() {
2000
+ parsed = parseShare(box.value);
2001
+ preview.innerHTML = "";
2002
+ if (!box.value.trim()) {
2003
+ preview.style.display = "none";
2004
+ add.disabled = true;
2005
+ return;
2006
+ }
2007
+ preview.style.display = "";
2008
+ if (!parsed) {
2009
+ preview.appendChild(el("div", "bad", "That does not look like a bot share code."));
2010
+ add.disabled = true;
2011
+ return;
2012
+ }
2013
+ preview.appendChild(avatar(parsed));
2014
+ var body = el("div");
2015
+ body.appendChild(el("div", "nm", parsed.name));
2016
+ body.appendChild(el("div", "ds", parsed.description || "No description."));
2017
+ if (parsed.setupInstructions) {
2018
+ body.appendChild(el("div", "ds", "Needs setup on this machine \u2014 a setup thread starts when you add it."));
2019
+ }
2020
+ preview.appendChild(body);
2021
+ add.disabled = false;
2022
+ }
2023
+
2024
+ box.addEventListener("input", review);
2025
+ box.addEventListener("paste", function () { setTimeout(review, 0); });
2026
+
2027
+ add.onclick = function () {
2028
+ if (!parsed) return;
2029
+ add.disabled = true;
2030
+ api("/bots", { method: "POST", body: parsed })
2031
+ .then(function (d) {
2032
+ parts.close();
2033
+ return loadRoster().then(function () { return afterBotAdded(d.bot); });
2034
+ })
2035
+ .catch(function (err) {
2036
+ add.disabled = false;
2037
+ preview.innerHTML = "";
2038
+ preview.style.display = "";
2039
+ preview.appendChild(el("div", "bad", err && err.message ? err.message : String(err)));
2040
+ });
2041
+ };
2042
+
2043
+ var acts = el("div", "acts");
2044
+ acts.appendChild(el("div", "spacer"));
2045
+ var cancel = el("button", "btn", "Cancel");
2046
+ cancel.onclick = parts.close;
2047
+ acts.appendChild(cancel);
2048
+ acts.appendChild(add);
2049
+ parts.modal.appendChild(acts);
2050
+ parts.open();
2051
+ box.focus();
2052
+ }
2053
+
2054
+ /** Backdrop, escape key and dismissal, shared by the small modals. */
2055
+ function modalShell(title) {
2056
+ var backdrop = el("div", "backdrop");
2057
+ var modal = el("div", "modal");
2058
+ backdrop.appendChild(modal);
2059
+ function close() {
2060
+ backdrop.remove();
2061
+ document.removeEventListener("keydown", esc);
2062
+ }
2063
+ function esc(ev) { if (ev.key === "Escape") close(); }
2064
+ modalHead(modal, title, close);
2065
+ return {
2066
+ modal: modal,
2067
+ close: close,
2068
+ open: function () {
2069
+ document.addEventListener("keydown", esc);
2070
+ document.body.appendChild(backdrop);
2071
+ }
2072
+ };
2073
+ }
2074
+
2075
+ // --- Bot editor ---
2076
+ function openBotModal(bot) {
2077
+ var editing = !!bot;
2078
+ var backdrop = el("div", "backdrop");
2079
+ var modal = el("div", "modal");
2080
+ function close() {
2081
+ backdrop.remove();
2082
+ document.removeEventListener("keydown", esc);
2083
+ }
2084
+ function esc(ev) { if (ev.key === "Escape") close(); }
2085
+ modalHead(modal, editing ? "Edit bot" : "New bot", close);
2086
+
2087
+ function field(label, hint, control) {
2088
+ var wrap = el("div", "field");
2089
+ var lab = el("label", null, label);
2090
+ if (hint) lab.appendChild(el("span", "hint", " " + hint));
2091
+ wrap.appendChild(lab);
2092
+ wrap.appendChild(control);
2093
+ modal.appendChild(wrap);
2094
+ return control;
2095
+ }
2096
+
2097
+ var emoji = el("input");
2098
+ emoji.value = bot ? bot.emoji : "\\u{1F916}";
2099
+ var name = el("input");
2100
+ name.value = bot ? bot.name : "";
2101
+ name.placeholder = "Doc Spot";
2102
+ var row = el("div", "row2");
2103
+ row.appendChild(emoji);
2104
+ row.appendChild(name);
2105
+ field("Name", null, row);
2106
+
2107
+ var description = field("Description", "one line, shown on the card", el("input"));
2108
+ description.value = bot ? bot.description : "";
2109
+ description.placeholder = "Keeps documentation in sync with the code";
2110
+
2111
+ var instructions = field("Instructions", "appended to Claude Code's system prompt", el("textarea"));
2112
+ instructions.value = bot ? bot.instructions : "";
2113
+ instructions.placeholder = "You keep documentation in sync with the code. On each run, read the latest commit and update the docs it affects.";
2114
+
2115
+ var setupInstructions = field("Setup instructions", "run once per machine \u2014 blank means no setup", el("textarea"));
2116
+ setupInstructions.value = bot ? (bot.setupInstructions || "") : "";
2117
+ setupInstructions.placeholder = "This bot needs ffmpeg on PATH. Check for it and install it with the machine's package manager if it is missing.";
2118
+
2119
+ var repoPath = field("Working directory", "default for new threads", el("input"));
2120
+ repoPath.value = bot ? (bot.repoPath || "") : "";
2121
+ repoPath.placeholder = "blank uses the server's directory";
2122
+
2123
+ var model = field("Model", "optional", el("input"));
2124
+ model.value = bot ? (bot.model || "") : "";
2125
+ model.placeholder = "claude-sonnet-4-6";
2126
+
2127
+ var permissionMode = field("Permissions", null, el("select"));
2128
+ [["ask-permissions", "Ask before each tool"], ["auto-approve", "Auto-approve tools"], ["plan", "Plan only (no edits)"]]
2129
+ .forEach(function (o) {
2130
+ var opt = el("option", null, o[1]);
2131
+ opt.value = o[0];
2132
+ permissionMode.appendChild(opt);
2133
+ });
2134
+ permissionMode.value = bot ? bot.permissionMode : "ask-permissions";
2135
+
2136
+ var allowedTools = field("Allowed tools", "comma-separated; blank means all", el("input"));
2137
+ allowedTools.value = bot && bot.allowedTools ? bot.allowedTools.join(", ") : "";
2138
+ allowedTools.placeholder = "Read, Grep, Edit, Bash";
2139
+
2140
+ var acts = el("div", "acts");
2141
+ if (editing) {
2142
+ var del = el("button", "btn danger", "Delete");
2143
+ del.onclick = function () {
2144
+ if (!confirm("Delete " + bot.name + " and all of its threads?")) return;
2145
+ api("/bots/" + bot.id, { method: "DELETE" })
2146
+ .then(function () { close(); return goHome(); })
2147
+ .catch(showError);
2148
+ };
2149
+ acts.appendChild(del);
2150
+ }
2151
+ acts.appendChild(el("div", "spacer"));
2152
+ var cancel = el("button", "btn", "Cancel");
2153
+ cancel.onclick = close;
2154
+ var save = el("button", "btn primary", editing ? "Save" : "Create bot");
2155
+ save.onclick = function () {
2156
+ if (!name.value.trim()) { name.focus(); return; }
2157
+ var tools = allowedTools.value.split(",").map(function (t) { return t.trim(); }).filter(Boolean);
2158
+ var body = {
2159
+ name: name.value.trim(),
2160
+ emoji: emoji.value.trim() || "\\u{1F916}",
2161
+ description: description.value.trim(),
2162
+ instructions: instructions.value,
2163
+ setupInstructions: setupInstructions.value.trim() || undefined,
2164
+ repoPath: repoPath.value.trim() || undefined,
2165
+ model: model.value.trim() || undefined,
2166
+ permissionMode: permissionMode.value,
2167
+ allowedTools: tools.length ? tools : undefined
2168
+ };
2169
+ var req = editing
2170
+ ? api("/bots/" + bot.id, { method: "PATCH", body: body })
2171
+ : api("/bots", { method: "POST", body: body });
2172
+ req.then(function (d) {
2173
+ close();
2174
+ return api("/threads").then(function (r) {
2175
+ allThreads = r.threads || [];
2176
+ return api("/bots");
2177
+ }).then(function (r) {
2178
+ bots = r.bots || [];
2179
+ // A new bot may owe this machine a setup run; an edit just goes back
2180
+ // to the bot's page.
2181
+ if (editing) return openBot(d.bot);
2182
+ renderRoster();
2183
+ return afterBotAdded(d.bot);
2184
+ });
2185
+ }).catch(showError);
2186
+ };
2187
+ acts.appendChild(cancel);
2188
+ acts.appendChild(save);
2189
+ modal.appendChild(acts);
2190
+
2191
+ backdrop.appendChild(modal);
2192
+ document.addEventListener("keydown", esc);
2193
+ document.body.appendChild(backdrop);
2194
+ name.focus();
2195
+ }
2196
+
2197
+ // --- Wiring ---
2198
+ $("new-bot").onclick = function () { openBotModal(null); };
2199
+ $("import-bot").onclick = openImportModal;
2200
+ $("brief-line").onclick = function () { setBriefOpen($("brief").hidden); };
2201
+ $("bot-more").onclick = function (ev) {
2202
+ ev.stopPropagation();
2203
+ if ($("bot-menu").hidden) openBotMenu(); else closeBotMenu();
2204
+ };
2205
+ document.addEventListener("click", function () { closeBotMenu(); });
2206
+ document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") closeBotMenu(); });
2207
+ $("share-bot").onclick = function () { if (activeBot) shareBot(activeBot); };
2208
+ $("edit-bot").onclick = function () { if (activeBot) openBotModal(activeBot); };
2209
+ $("new-thread").onclick = newThread;
2210
+ $("to-home").onclick = function () { goHome(); };
2211
+ $("to-jarvis").onclick = function () { goJarvis(); };
2212
+ $("to-projects").onclick = function () { goHome(); };
2213
+ $("to-bot").onclick = function () { if (threadBack === "jarvis") goJarvis(); else goBotView(); };
2214
+ $("ask-send").onclick = function () { askJarvis($("ask-input").value); };
2215
+ var ask = $("ask-input");
2216
+ ask.addEventListener("input", function () {
2217
+ ask.style.height = "auto";
2218
+ ask.style.height = Math.min(ask.scrollHeight, 200) + "px";
2219
+ });
2220
+ ask.addEventListener("keydown", function (ev) {
2221
+ if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); askJarvis(ask.value); }
2222
+ });
2223
+ $("send").onclick = function () { if (state === "idle") send(); else abort(); };
2224
+ wireMic($("ask-mic"), $("ask-input"));
2225
+ wireMic($("mic"), $("input"));
2226
+
2227
+ var input = $("input");
2228
+ input.addEventListener("input", function () {
2229
+ input.style.height = "auto";
2230
+ input.style.height = Math.min(input.scrollHeight, 200) + "px";
2231
+ });
2232
+ input.addEventListener("keydown", function (ev) {
2233
+ if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); if (state === "idle") send(); }
2234
+ });
2235
+
2236
+ setView("jarvis", false);
2237
+ loadRecent().catch(function (err) { console.error(err); });
2238
+ })();
2239
+ </script>
2240
+ </body>
2241
+ </html>`;