@yemi33/minions 0.1.1949 → 0.1.1951

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.
Files changed (40) hide show
  1. package/dashboard/js/command-center.js +9 -0
  2. package/dashboard/js/modal-qa.js +10 -0
  3. package/dashboard/js/refresh.js +4 -0
  4. package/dashboard/js/render-dispatch.js +25 -0
  5. package/dashboard/js/render-other.js +109 -2
  6. package/dashboard/js/settings.js +1 -1
  7. package/dashboard/layout.html +2 -2
  8. package/dashboard/pages/engine.html +6 -0
  9. package/dashboard/slim.html +1987 -0
  10. package/dashboard/styles.css +8 -0
  11. package/dashboard.js +450 -40
  12. package/docs/completion-reports.md +25 -0
  13. package/docs/design-state-storage.md +1 -1
  14. package/docs/slim-ux/architecture-suggestions.md +467 -0
  15. package/docs/slim-ux/concepts.md +824 -0
  16. package/engine/ado-mcp-wrapper.js +33 -7
  17. package/engine/ado.js +123 -15
  18. package/engine/cc-worker-pool.js +41 -0
  19. package/engine/cleanup.js +71 -34
  20. package/engine/cli.js +37 -0
  21. package/engine/dispatch.js +32 -9
  22. package/engine/features.js +6 -0
  23. package/engine/gh-token.js +137 -0
  24. package/engine/github.js +166 -29
  25. package/engine/issues.js +29 -0
  26. package/engine/keep-process-sweep.js +397 -0
  27. package/engine/lifecycle.js +150 -33
  28. package/engine/playbook.js +17 -0
  29. package/engine/queries.js +71 -0
  30. package/engine/recovery.js +6 -0
  31. package/engine/shared.js +481 -30
  32. package/engine/spawn-agent.js +44 -2
  33. package/engine/timeout.js +34 -11
  34. package/engine/worktree-pool.js +410 -0
  35. package/engine.js +643 -119
  36. package/package.json +6 -3
  37. package/playbooks/review.md +2 -0
  38. package/playbooks/shared-rules.md +3 -1
  39. package/prompts/cc-system.md +24 -0
  40. package/engine/copilot-models.json +0 -5
@@ -0,0 +1,1987 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Minions — Slim UX{{title_suffix}}</title>
6
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>{{favicon_emoji}}</text></svg>">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <style>
9
+ /* ---------------------------------------------------------------
10
+ Slim UX prototype — gated by the 'slim-ux' feature flag.
11
+ Plain HTML/CSS/vanilla JS so the human can edit this file
12
+ directly. The dashboard reads this from disk on each request,
13
+ so a page refresh shows the latest edit (no server restart).
14
+
15
+ Round-3 layout (W-moxkqfaw): three sections instead of two
16
+ panels — ACTIONS / STATUS / HISTORY. See
17
+ docs/slim-ux/concepts.md for the language we're standardizing
18
+ around, and architecture-suggestions.md for the data-shape
19
+ changes that would make this UX cheaper to maintain.
20
+ --------------------------------------------------------------- */
21
+ :root {
22
+ --bg: #0d1117;
23
+ --surface: #161b22;
24
+ --surface2: #1c2128;
25
+ --border: #30363d;
26
+ --text: #c9d1d9;
27
+ --muted: #8b949e;
28
+ --blue: #58a6ff;
29
+ --green: #3fb950;
30
+ --amber: #d29922;
31
+ --red: #f85149;
32
+ --radius: 6px;
33
+ }
34
+
35
+ * { box-sizing: border-box; }
36
+
37
+ /* Typography baseline (slim UX foundation phase).
38
+ Existing dashboard uses 'Segoe UI' / 14px on body — see styles.css.
39
+ Round-2 bumped to 15px; round-3 bumps once more to 16px because the
40
+ human flagged the slim as still feeling cramped. The scale below is
41
+ proportional to 16px (h1 22, h2 18, label 11). The existing
42
+ dashboard stays untouched. */
43
+ html, body {
44
+ margin: 0;
45
+ padding: 0;
46
+ height: 100vh;
47
+ width: 100%;
48
+ overflow: hidden;
49
+ background: var(--bg);
50
+ color: var(--text);
51
+ font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
52
+ font-size: 16px;
53
+ line-height: 1.5;
54
+ }
55
+
56
+ /* ── Top bar ──────────────────────────────────────────────── */
57
+ .topbar {
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: space-between;
61
+ padding: 8px 18px;
62
+ background: var(--surface);
63
+ border-bottom: 1px solid var(--border);
64
+ height: 46px;
65
+ flex-shrink: 0;
66
+ }
67
+ .topbar-title {
68
+ font-weight: 600;
69
+ font-size: 15px;
70
+ color: var(--text);
71
+ letter-spacing: 0.3px;
72
+ }
73
+ .topbar-title .badge {
74
+ display: inline-block;
75
+ margin-left: 8px;
76
+ padding: 1px 6px;
77
+ font-size: 10px;
78
+ font-weight: 700;
79
+ letter-spacing: 0.5px;
80
+ color: var(--blue);
81
+ border: 1px solid var(--blue);
82
+ border-radius: 3px;
83
+ vertical-align: 2px;
84
+ }
85
+ .topbar-actions { display: flex; align-items: center; gap: 8px; }
86
+ .icon-btn {
87
+ background: none;
88
+ border: none;
89
+ cursor: pointer;
90
+ color: var(--muted);
91
+ font-size: 20px;
92
+ line-height: 1;
93
+ padding: 6px 10px;
94
+ border-radius: var(--radius);
95
+ }
96
+ .icon-btn:hover { color: var(--text); background: var(--surface2); }
97
+
98
+ /* ── Layout grid ──────────────────────────────────────────────
99
+ Left column: ACTIONS (chat + action buttons) — visually dominant.
100
+ Right column: STATUS on top, HISTORY underneath.
101
+ The whole grid is sized to its content (`fit-content`) and centered
102
+ on wide monitors. minmax(0, …) keeps columns from overflowing on
103
+ narrow viewports. */
104
+ .layout {
105
+ display: grid;
106
+ grid-template-columns: minmax(0, 820px) minmax(0, 460px);
107
+ grid-template-rows: 1fr 1fr;
108
+ grid-template-areas:
109
+ "actions status"
110
+ "actions history";
111
+ gap: 10px;
112
+ padding: 10px;
113
+ width: fit-content;
114
+ max-width: 100%;
115
+ margin: 0 auto;
116
+ height: calc(100vh - 46px);
117
+ }
118
+ .panel {
119
+ background: var(--surface);
120
+ border: 1px solid var(--border);
121
+ border-radius: var(--radius);
122
+ display: flex;
123
+ flex-direction: column;
124
+ min-height: 0;
125
+ min-width: 0;
126
+ overflow: hidden;
127
+ }
128
+ .panel-header {
129
+ padding: 9px 14px;
130
+ font-size: 11px;
131
+ font-weight: 700;
132
+ letter-spacing: 0.7px;
133
+ text-transform: uppercase;
134
+ color: var(--muted);
135
+ border-bottom: 1px solid var(--border);
136
+ flex-shrink: 0;
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: space-between;
140
+ }
141
+ .panel-header .panel-sub {
142
+ text-transform: none;
143
+ letter-spacing: 0;
144
+ font-weight: 400;
145
+ color: var(--muted);
146
+ font-size: 11px;
147
+ }
148
+ .panel-body {
149
+ flex: 1;
150
+ overflow-y: auto;
151
+ padding: 14px;
152
+ min-height: 0;
153
+ }
154
+ .panel-placeholder {
155
+ color: var(--muted);
156
+ font-style: italic;
157
+ font-size: 14px;
158
+ }
159
+
160
+ .panel-actions { grid-area: actions; }
161
+ .panel-status { grid-area: status; }
162
+ .panel-history { grid-area: history; }
163
+
164
+ /* ── Actions panel: chat + action buttons ───────────────────── */
165
+ /* The chat fills the panel above the action button row. Same IDs and
166
+ class names as the previous slim chatbox so the IIFE wiring below
167
+ continues to work without changes. */
168
+ .actions-chat {
169
+ flex: 1;
170
+ display: flex;
171
+ flex-direction: column;
172
+ min-height: 0;
173
+ }
174
+ .chat-messages {
175
+ flex: 1;
176
+ overflow-y: auto;
177
+ padding: 12px;
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: 10px;
181
+ min-height: 0;
182
+ width: 100%;
183
+ max-width: 800px;
184
+ margin-left: auto;
185
+ margin-right: auto;
186
+ }
187
+ .chat-empty {
188
+ color: var(--muted);
189
+ font-size: 14px;
190
+ font-style: italic;
191
+ text-align: center;
192
+ margin-top: 24px;
193
+ }
194
+ .chat-msg {
195
+ max-width: 90%;
196
+ padding: 10px 14px;
197
+ border-radius: var(--radius);
198
+ white-space: pre-wrap;
199
+ word-wrap: break-word;
200
+ font-size: 16px;
201
+ }
202
+ .chat-msg.user {
203
+ align-self: flex-end;
204
+ background: var(--blue);
205
+ color: #fff;
206
+ }
207
+ .chat-msg.assistant {
208
+ align-self: flex-start;
209
+ background: var(--surface2);
210
+ color: var(--text);
211
+ border: 1px solid var(--border);
212
+ }
213
+ .chat-msg.system {
214
+ align-self: center;
215
+ background: transparent;
216
+ color: var(--muted);
217
+ font-size: 13px;
218
+ font-style: italic;
219
+ }
220
+ .chat-msg.error {
221
+ align-self: flex-start;
222
+ background: rgba(248, 81, 73, 0.1);
223
+ color: var(--red);
224
+ border: 1px solid var(--red);
225
+ font-size: 14px;
226
+ }
227
+ .chat-input-wrap {
228
+ display: flex;
229
+ gap: 8px;
230
+ padding: 10px 12px;
231
+ border-top: 1px solid var(--border);
232
+ flex-shrink: 0;
233
+ background: var(--surface);
234
+ width: 100%;
235
+ max-width: 800px;
236
+ margin-left: auto;
237
+ margin-right: auto;
238
+ }
239
+
240
+ /* "Working in: <project>" strip sits between the message list and the
241
+ input row so the active project is always visible while composing.
242
+ Selection persists in localStorage and is sent to /api/command-center
243
+ so CC can default action.project without the user having to name it. */
244
+ .chat-context-strip {
245
+ display: flex;
246
+ align-items: center;
247
+ gap: 10px;
248
+ padding: 6px 12px;
249
+ border-top: 1px solid var(--border);
250
+ flex-shrink: 0;
251
+ background: var(--surface);
252
+ width: 100%;
253
+ max-width: 800px;
254
+ margin-left: auto;
255
+ margin-right: auto;
256
+ font-size: 12px;
257
+ color: var(--muted);
258
+ }
259
+ .chat-context-strip .context-label {
260
+ text-transform: uppercase;
261
+ letter-spacing: 0.5px;
262
+ font-weight: 700;
263
+ font-size: 10px;
264
+ }
265
+ .chat-context-strip select {
266
+ background: var(--bg);
267
+ color: var(--text);
268
+ border: 1px solid var(--border);
269
+ border-radius: var(--radius);
270
+ padding: 4px 8px;
271
+ font-family: inherit;
272
+ font-size: 12px;
273
+ cursor: pointer;
274
+ }
275
+ .chat-context-strip select:focus { outline: none; border-color: var(--blue); }
276
+ .chat-context-strip .context-static {
277
+ color: var(--text);
278
+ font-weight: 500;
279
+ }
280
+ .chat-context-strip .context-empty {
281
+ font-style: italic;
282
+ }
283
+ .chat-context-strip .context-add {
284
+ margin-left: auto;
285
+ background: none;
286
+ color: var(--blue);
287
+ border: 1px solid var(--border);
288
+ border-radius: var(--radius);
289
+ padding: 3px 9px;
290
+ font-size: 12px;
291
+ cursor: pointer;
292
+ line-height: 1;
293
+ }
294
+ .chat-context-strip .context-add:hover {
295
+ background: var(--surface2);
296
+ border-color: var(--blue);
297
+ }
298
+ .chat-input {
299
+ flex: 1;
300
+ padding: 10px 12px;
301
+ background: var(--bg);
302
+ color: var(--text);
303
+ border: 1px solid var(--border);
304
+ border-radius: var(--radius);
305
+ font-size: 16px;
306
+ font-family: inherit;
307
+ resize: none;
308
+ /* >= 2 lines visible by default. 16px font * 1.5 line-height = 24px/line,
309
+ x 2 = 48px content + 20px vertical padding + 2px border ~= 70px. */
310
+ min-height: 70px;
311
+ max-height: 150px;
312
+ }
313
+ .chat-input:focus { outline: none; border-color: var(--blue); }
314
+ .chat-send {
315
+ padding: 10px 18px;
316
+ background: var(--blue);
317
+ color: #fff;
318
+ border: none;
319
+ border-radius: var(--radius);
320
+ font-size: 14px;
321
+ font-weight: 600;
322
+ cursor: pointer;
323
+ align-self: flex-end;
324
+ }
325
+ .chat-send:hover { filter: brightness(1.1); }
326
+ .chat-send:disabled { opacity: 0.5; cursor: not-allowed; }
327
+ .chat-stop {
328
+ padding: 10px 14px;
329
+ background: var(--surface2);
330
+ color: var(--red);
331
+ border: 1px solid var(--border);
332
+ border-radius: var(--radius);
333
+ font-size: 13px;
334
+ cursor: pointer;
335
+ align-self: flex-end;
336
+ display: none;
337
+ }
338
+ .chat-stop:hover { filter: brightness(1.1); }
339
+
340
+ /* Tool-call progress lines + thinking indicator (unchanged from r2) */
341
+ .chat-tools {
342
+ display: flex;
343
+ flex-direction: column;
344
+ gap: 2px;
345
+ margin-bottom: 4px;
346
+ }
347
+ .chat-tool {
348
+ color: var(--muted);
349
+ font-size: 12px;
350
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
351
+ white-space: nowrap;
352
+ overflow: hidden;
353
+ text-overflow: ellipsis;
354
+ }
355
+ .chat-tool::before { content: "● "; }
356
+
357
+ .chat-thinking {
358
+ color: var(--muted);
359
+ font-size: 13px;
360
+ font-style: italic;
361
+ }
362
+ .chat-thinking-dots {
363
+ display: inline-flex;
364
+ gap: 3px;
365
+ margin-left: 6px;
366
+ vertical-align: middle;
367
+ }
368
+ .chat-thinking-dots > span {
369
+ width: 4px;
370
+ height: 4px;
371
+ background: var(--blue);
372
+ border-radius: 50%;
373
+ animation: dotPulse 1.2s infinite;
374
+ }
375
+ .chat-thinking-dots > span:nth-child(2) { animation-delay: 0.2s; }
376
+ .chat-thinking-dots > span:nth-child(3) { animation-delay: 0.4s; }
377
+ @keyframes dotPulse {
378
+ 0%, 80%, 100% { opacity: 0.3; }
379
+ 40% { opacity: 1; }
380
+ }
381
+
382
+ .chat-action {
383
+ align-self: flex-start;
384
+ padding: 5px 11px;
385
+ border-radius: 4px;
386
+ font-size: 12px;
387
+ border: 1px dashed var(--border);
388
+ color: var(--muted);
389
+ }
390
+ .chat-action.ok { color: var(--green); }
391
+ .chat-action.warn { color: var(--amber); }
392
+ .chat-action.err { color: var(--red); }
393
+
394
+ /* Lightweight markdown styling inside assistant bubbles. */
395
+ .chat-msg.assistant code,
396
+ .chat-msg.assistant pre {
397
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
398
+ }
399
+ .chat-msg.assistant code {
400
+ background: var(--bg);
401
+ padding: 1px 4px;
402
+ border-radius: 3px;
403
+ font-size: 0.9em;
404
+ }
405
+ .chat-msg.assistant pre {
406
+ background: var(--bg);
407
+ padding: 10px;
408
+ border-radius: 4px;
409
+ overflow-x: auto;
410
+ font-size: 14px;
411
+ margin: 6px 0;
412
+ }
413
+ .chat-msg.assistant pre code { background: none; padding: 0; }
414
+ .chat-msg.assistant a { color: var(--blue); }
415
+ .chat-msg.assistant strong { color: var(--text); }
416
+
417
+ /* ── Action buttons row ─────────────────────────────────────── */
418
+ /* Row of four large buttons that map to the human's "concept
419
+ collapses": Work (Work Items + Pipelines), Plans (Plans + PRD),
420
+ Knowledge (Notes + KB + Pinned Context), Triggers (Schedule +
421
+ Watch). TEMP names — the human said the data model split should
422
+ NOT change in round 1, so this is a UX-only rename layer. */
423
+ .actions-buttons {
424
+ flex-shrink: 0;
425
+ display: grid;
426
+ grid-template-columns: repeat(4, 1fr);
427
+ gap: 8px;
428
+ padding: 10px 12px 12px;
429
+ border-top: 1px solid var(--border);
430
+ width: 100%;
431
+ max-width: 800px;
432
+ margin-left: auto;
433
+ margin-right: auto;
434
+ }
435
+ .action-btn {
436
+ background: var(--surface2);
437
+ color: var(--text);
438
+ border: 1px solid var(--border);
439
+ border-radius: var(--radius);
440
+ padding: 10px 12px;
441
+ cursor: pointer;
442
+ font-size: 13px;
443
+ font-family: inherit;
444
+ display: flex;
445
+ flex-direction: column;
446
+ align-items: center;
447
+ gap: 2px;
448
+ text-align: center;
449
+ transition: border-color 0.15s, background 0.15s;
450
+ }
451
+ .action-btn:hover {
452
+ border-color: var(--blue);
453
+ background: var(--surface);
454
+ }
455
+ .action-btn .action-btn-icon { font-size: 16px; }
456
+ .action-btn .action-btn-label { font-weight: 600; }
457
+ .action-btn .action-btn-sub { font-size: 10px; color: var(--muted); }
458
+
459
+ /* ── Status panel: cockpit tiles ─────────────────────────────
460
+ Tiles light up when their value is non-zero or their state is
461
+ 'active'. Dim grey otherwise. The whole panel polls /api/status
462
+ every 5s.
463
+
464
+ Tile state classes drive the colored dot + glow. Adding a new
465
+ indicator: append a tile DOM node + a key in the COCKPIT_TILES
466
+ table inside the IIFE. */
467
+ .cockpit-grid {
468
+ display: grid;
469
+ grid-template-columns: repeat(2, 1fr);
470
+ gap: 8px;
471
+ }
472
+ .cockpit-tile {
473
+ background: var(--surface2);
474
+ border: 1px solid var(--border);
475
+ border-radius: var(--radius);
476
+ padding: 10px 12px;
477
+ display: flex;
478
+ flex-direction: column;
479
+ gap: 4px;
480
+ transition: border-color 0.2s, background 0.2s;
481
+ }
482
+ .cockpit-tile.lit-blue {
483
+ border-color: rgba(88, 166, 255, 0.6);
484
+ background: rgba(88, 166, 255, 0.06);
485
+ }
486
+ .cockpit-tile.lit-green {
487
+ border-color: rgba(63, 185, 80, 0.6);
488
+ background: rgba(63, 185, 80, 0.06);
489
+ }
490
+ .cockpit-tile.lit-amber {
491
+ border-color: rgba(210, 153, 34, 0.6);
492
+ background: rgba(210, 153, 34, 0.06);
493
+ }
494
+ .cockpit-tile.lit-red {
495
+ border-color: rgba(248, 81, 73, 0.6);
496
+ background: rgba(248, 81, 73, 0.08);
497
+ }
498
+ .cockpit-label {
499
+ font-size: 10px;
500
+ font-weight: 700;
501
+ letter-spacing: 0.6px;
502
+ text-transform: uppercase;
503
+ color: var(--muted);
504
+ display: flex;
505
+ align-items: center;
506
+ gap: 6px;
507
+ }
508
+ .cockpit-dot {
509
+ display: inline-block;
510
+ width: 8px;
511
+ height: 8px;
512
+ border-radius: 50%;
513
+ background: var(--border);
514
+ }
515
+ .lit-blue .cockpit-dot { background: var(--blue); box-shadow: 0 0 6px var(--blue); }
516
+ .lit-green .cockpit-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
517
+ .lit-amber .cockpit-dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
518
+ .lit-red .cockpit-dot { background: var(--red); box-shadow: 0 0 6px var(--red); }
519
+ .cockpit-value {
520
+ font-size: 22px;
521
+ font-weight: 700;
522
+ color: var(--text);
523
+ line-height: 1;
524
+ }
525
+ .cockpit-value.dim { color: var(--muted); }
526
+ .cockpit-detail {
527
+ font-size: 11px;
528
+ color: var(--muted);
529
+ line-height: 1.3;
530
+ min-height: 14px;
531
+ }
532
+
533
+ /* ── History panel: event log ─────────────────────────────── */
534
+ .history-list {
535
+ display: flex;
536
+ flex-direction: column;
537
+ gap: 6px;
538
+ }
539
+ .history-item {
540
+ background: var(--surface2);
541
+ border: 1px solid var(--border);
542
+ border-radius: var(--radius);
543
+ padding: 8px 10px;
544
+ display: flex;
545
+ flex-direction: column;
546
+ gap: 3px;
547
+ }
548
+ .history-item.kind-completion { border-left: 3px solid var(--green); }
549
+ .history-item.kind-dispatch { border-left: 3px solid var(--blue); }
550
+ .history-item.kind-pr { border-left: 3px solid var(--amber); }
551
+ .history-item.kind-failure { border-left: 3px solid var(--red); }
552
+ .history-head {
553
+ display: flex;
554
+ justify-content: space-between;
555
+ align-items: baseline;
556
+ gap: 8px;
557
+ font-size: 13px;
558
+ }
559
+ .history-kind {
560
+ font-weight: 700;
561
+ letter-spacing: 0.4px;
562
+ text-transform: uppercase;
563
+ font-size: 10px;
564
+ color: var(--muted);
565
+ }
566
+ .history-time {
567
+ font-size: 11px;
568
+ color: var(--muted);
569
+ white-space: nowrap;
570
+ }
571
+ .history-title {
572
+ font-size: 13px;
573
+ color: var(--text);
574
+ word-break: break-word;
575
+ }
576
+ .history-meta {
577
+ font-size: 11px;
578
+ color: var(--muted);
579
+ word-break: break-word;
580
+ }
581
+ .history-meta a { color: var(--blue); }
582
+ .history-empty {
583
+ color: var(--muted);
584
+ font-style: italic;
585
+ font-size: 13px;
586
+ }
587
+
588
+ /* ── Modals (settings + action prompt) ──────────────────── */
589
+ .modal-bg {
590
+ position: fixed;
591
+ inset: 0;
592
+ background: rgba(0, 0, 0, 0.55);
593
+ display: none;
594
+ align-items: center;
595
+ justify-content: center;
596
+ z-index: 100;
597
+ }
598
+ .modal-bg.open { display: flex; }
599
+ .modal {
600
+ background: var(--surface);
601
+ border: 1px solid var(--border);
602
+ border-radius: 8px;
603
+ width: 480px;
604
+ max-width: calc(100vw - 32px);
605
+ max-height: calc(100vh - 64px);
606
+ display: flex;
607
+ flex-direction: column;
608
+ overflow: hidden;
609
+ }
610
+ .modal-header {
611
+ display: flex;
612
+ align-items: center;
613
+ justify-content: space-between;
614
+ padding: 12px 16px;
615
+ border-bottom: 1px solid var(--border);
616
+ }
617
+ .modal-header h3 { margin: 0; font-size: 16px; color: var(--blue); }
618
+ .modal-body { padding: 14px 18px; overflow-y: auto; }
619
+ .modal-body p { color: var(--muted); margin: 4px 0 12px; font-size: 13px; }
620
+ .modal-footer {
621
+ padding: 10px 16px;
622
+ border-top: 1px solid var(--border);
623
+ display: flex;
624
+ justify-content: flex-end;
625
+ gap: 8px;
626
+ }
627
+ .btn-primary {
628
+ padding: 8px 16px;
629
+ background: var(--blue);
630
+ color: #fff;
631
+ border: none;
632
+ border-radius: var(--radius);
633
+ cursor: pointer;
634
+ font-size: 13px;
635
+ font-weight: 600;
636
+ }
637
+ .btn-secondary {
638
+ padding: 8px 16px;
639
+ background: var(--surface2);
640
+ color: var(--text);
641
+ border: 1px solid var(--border);
642
+ border-radius: var(--radius);
643
+ cursor: pointer;
644
+ font-size: 13px;
645
+ }
646
+ .btn-primary:hover, .btn-secondary:hover { filter: brightness(1.1); }
647
+
648
+ .flag-row {
649
+ display: flex;
650
+ align-items: center;
651
+ justify-content: space-between;
652
+ padding: 10px 0;
653
+ border-bottom: 1px solid var(--border);
654
+ }
655
+ .flag-row:last-child { border-bottom: none; }
656
+ .flag-name { font-size: 14px; font-weight: 600; }
657
+ .flag-desc { font-size: 12px; color: var(--muted); margin-top: 2px; }
658
+ .flag-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; }
659
+ .flag-toggle input { cursor: pointer; }
660
+ .settings-link {
661
+ display: block;
662
+ margin-top: 14px;
663
+ padding: 10px 12px;
664
+ background: var(--surface2);
665
+ border: 1px solid var(--border);
666
+ border-radius: var(--radius);
667
+ color: var(--blue);
668
+ text-decoration: none;
669
+ font-size: 13px;
670
+ text-align: center;
671
+ }
672
+ .settings-link:hover { filter: brightness(1.1); }
673
+
674
+ .modal-textarea {
675
+ width: 100%;
676
+ min-height: 96px;
677
+ padding: 10px 12px;
678
+ background: var(--bg);
679
+ color: var(--text);
680
+ border: 1px solid var(--border);
681
+ border-radius: var(--radius);
682
+ font-size: 14px;
683
+ font-family: inherit;
684
+ resize: vertical;
685
+ }
686
+ .modal-textarea:focus { outline: none; border-color: var(--blue); }
687
+ </style>
688
+ </head>
689
+ <body>
690
+
691
+ <div class="topbar">
692
+ <div class="topbar-title">Minions <span class="badge">SLIM UX</span></div>
693
+ <div class="topbar-actions">
694
+ <!-- Buttons get their click handlers wired via addEventListener inside the
695
+ IIFE below. Inline `onclick=` was brittle: it depended on the IIFE
696
+ exporting handlers to window, which a single mid-IIFE error could
697
+ silently break (handler attaches before crash, button still renders,
698
+ click fires but no global handler). addEventListener attaches inside
699
+ the same scope and is observable in DevTools when wiring fails. -->
700
+ <button id="slim-new-chat-btn" class="icon-btn" title="New chat (clears session)">&#x270E;</button>
701
+ <button id="slim-settings-btn" class="icon-btn" title="Settings">&#9881;</button>
702
+ </div>
703
+ </div>
704
+
705
+ <div class="layout">
706
+ <!-- ACTIONS — chat + 4 concept-collapse buttons.
707
+ Visually dominant; this is what the human said was buried in the full
708
+ dashboard ("Command Center is not prominent enough"). -->
709
+ <div class="panel panel-actions">
710
+ <div class="panel-header">
711
+ Actions
712
+ <span class="panel-sub">Command Center &amp; quick triggers</span>
713
+ </div>
714
+ <div class="actions-chat">
715
+ <div class="chat-messages" id="chat-messages">
716
+ <div class="chat-empty">No messages yet — say hi to Command Center.</div>
717
+ </div>
718
+ <div class="chat-context-strip" id="chat-context-strip" style="display:none">
719
+ <span class="context-label">Working in</span>
720
+ <span id="chat-context-controls"></span>
721
+ </div>
722
+ <div class="chat-input-wrap">
723
+ <textarea
724
+ id="chat-input"
725
+ class="chat-input"
726
+ rows="2"
727
+ placeholder="Ask anything or give a command... (Enter to send, Shift+Enter for newline)"
728
+ ></textarea>
729
+ <button id="chat-stop" class="chat-stop" type="button" title="Stop the in-flight response">Stop</button>
730
+ <button id="chat-send" class="chat-send" type="button">Send</button>
731
+ </div>
732
+ </div>
733
+ <!-- TEMP: button labels are concept-collapse names. The data model is
734
+ unchanged — Work Items + Pipelines are still separate, etc. Each
735
+ button opens a small modal with a textarea; on submit we drop the
736
+ text into the chat input prefixed with the right intent and let CC
737
+ do the dispatch. Wiring direct API calls is a round-2 task. -->
738
+ <div class="actions-buttons">
739
+ <button class="action-btn" data-action-prompt="work" type="button">
740
+ <span class="action-btn-icon">&#x2795;</span>
741
+ <span class="action-btn-label">Work</span>
742
+ <span class="action-btn-sub">item or pipeline</span>
743
+ </button>
744
+ <button class="action-btn" data-action-prompt="plan" type="button">
745
+ <span class="action-btn-icon">&#x1F5D2;&#xFE0F;</span>
746
+ <span class="action-btn-label">Plan</span>
747
+ <span class="action-btn-sub">plan / PRD</span>
748
+ </button>
749
+ <button class="action-btn" data-action-prompt="knowledge" type="button">
750
+ <span class="action-btn-icon">&#x1F4DA;</span>
751
+ <span class="action-btn-label">Knowledge</span>
752
+ <span class="action-btn-sub">note / KB / pin</span>
753
+ </button>
754
+ <button class="action-btn" data-action-prompt="trigger" type="button">
755
+ <span class="action-btn-icon">&#x23F0;</span>
756
+ <span class="action-btn-label">Trigger</span>
757
+ <span class="action-btn-sub">schedule / watch</span>
758
+ </button>
759
+ </div>
760
+ </div>
761
+
762
+ <!-- STATUS — cockpit tiles. Lit when their indicator is non-zero. -->
763
+ <div class="panel panel-status">
764
+ <div class="panel-header">
765
+ Status
766
+ <span class="panel-sub" id="cockpit-stamp">refreshing&hellip;</span>
767
+ </div>
768
+ <div class="panel-body">
769
+ <div class="cockpit-grid" id="cockpit-grid">
770
+ <div class="cockpit-tile" data-tile="engine">
771
+ <div class="cockpit-label"><span class="cockpit-dot"></span> Engine</div>
772
+ <div class="cockpit-value dim">—</div>
773
+ <div class="cockpit-detail">checking&hellip;</div>
774
+ </div>
775
+ <div class="cockpit-tile" data-tile="dispatches">
776
+ <div class="cockpit-label"><span class="cockpit-dot"></span> Active dispatches</div>
777
+ <div class="cockpit-value dim">0</div>
778
+ <div class="cockpit-detail">none in flight</div>
779
+ </div>
780
+ <div class="cockpit-tile" data-tile="minions">
781
+ <div class="cockpit-label"><span class="cockpit-dot"></span> Minions working</div>
782
+ <div class="cockpit-value dim">0</div>
783
+ <div class="cockpit-detail">all idle</div>
784
+ </div>
785
+ <div class="cockpit-tile" data-tile="queued">
786
+ <div class="cockpit-label"><span class="cockpit-dot"></span> Queued work</div>
787
+ <div class="cockpit-value dim">0</div>
788
+ <div class="cockpit-detail">queue empty</div>
789
+ </div>
790
+ <div class="cockpit-tile" data-tile="prs">
791
+ <div class="cockpit-label"><span class="cockpit-dot"></span> Active PRs</div>
792
+ <div class="cockpit-value dim">0</div>
793
+ <div class="cockpit-detail">no open PRs</div>
794
+ </div>
795
+ <div class="cockpit-tile" data-tile="watches">
796
+ <div class="cockpit-label"><span class="cockpit-dot"></span> Watches</div>
797
+ <div class="cockpit-value dim">0</div>
798
+ <div class="cockpit-detail">no watches set</div>
799
+ </div>
800
+ </div>
801
+ </div>
802
+ </div>
803
+
804
+ <!-- HISTORY — chronological event log. Newest at top.
805
+ Round 1 sources: dispatch.completed (last 10), dispatch.active
806
+ (currently working), engineLog. -->
807
+ <div class="panel panel-history">
808
+ <div class="panel-header">
809
+ History
810
+ <span class="panel-sub" id="history-stamp">&hellip;</span>
811
+ </div>
812
+ <div class="panel-body">
813
+ <div class="history-list" id="history-list">
814
+ <div class="history-empty">Loading recent activity&hellip;</div>
815
+ </div>
816
+ </div>
817
+ </div>
818
+ </div>
819
+
820
+ <!-- Settings dialog (preserved from r2): lists experimental flags so the
821
+ user can flip 'slim-ux' off and revert to the original dashboard. -->
822
+ <div class="modal-bg" id="slim-settings-modal">
823
+ <div class="modal">
824
+ <div class="modal-header">
825
+ <h3>Settings &middot; Experimental flags</h3>
826
+ <button id="slim-settings-close" class="icon-btn" title="Close">&times;</button>
827
+ </div>
828
+ <div class="modal-body" id="slim-settings-body">
829
+ <p>Loading flags&hellip;</p>
830
+ </div>
831
+ </div>
832
+ </div>
833
+
834
+ <!-- Generic action prompt modal — used by the four Action buttons.
835
+ TEMP: routes through the chat input rather than calling /api/work-items
836
+ etc. directly. CC parses "create a work item: …" into an actions block
837
+ and dispatches it. Round-2 will replace this with direct API calls. -->
838
+ <div class="modal-bg" id="slim-action-modal">
839
+ <div class="modal">
840
+ <div class="modal-header">
841
+ <h3 id="slim-action-title">Action</h3>
842
+ <button id="slim-action-close" class="icon-btn" title="Close">&times;</button>
843
+ </div>
844
+ <div class="modal-body">
845
+ <p id="slim-action-desc"></p>
846
+ <textarea id="slim-action-input" class="modal-textarea" placeholder=""></textarea>
847
+ </div>
848
+ <div class="modal-footer">
849
+ <button id="slim-action-cancel" class="btn-secondary" type="button">Cancel</button>
850
+ <button id="slim-action-submit" class="btn-primary" type="button">Send to Command Center</button>
851
+ </div>
852
+ </div>
853
+ </div>
854
+
855
+ <!-- Add Project modal — entrypoint sits next to the "Working in" picker. Hits
856
+ /api/projects/browse for the folder dialog and /api/projects/add for the
857
+ link. Same endpoints as the main dashboard's Scan Projects flow but with
858
+ a single-path single-add UX tuned for slim. -->
859
+ <div class="modal-bg" id="slim-add-project-modal">
860
+ <div class="modal">
861
+ <div class="modal-header">
862
+ <h3>Link a project</h3>
863
+ <button id="slim-add-project-close" class="icon-btn" title="Close">&times;</button>
864
+ </div>
865
+ <div class="modal-body">
866
+ <p>Point to a local git repository. Minions will read its remote, name, and main branch automatically.</p>
867
+ <div style="display:flex; gap:8px; align-items:stretch">
868
+ <input id="slim-add-project-path" type="text" placeholder="C:\path\to\repo" style="flex:1; padding:8px 10px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius); color:var(--text); font-size:13px; font-family:inherit">
869
+ <button id="slim-add-project-browse" class="btn-secondary" type="button">Browse&hellip;</button>
870
+ </div>
871
+ <div id="slim-add-project-msg" style="margin-top:10px; font-size:12px; min-height:16px"></div>
872
+ </div>
873
+ <div class="modal-footer">
874
+ <button id="slim-add-project-cancel" class="btn-secondary" type="button">Cancel</button>
875
+ <button id="slim-add-project-submit" class="btn-primary" type="button">Link project</button>
876
+ </div>
877
+ </div>
878
+ </div>
879
+
880
+ <script>
881
+ (function() {
882
+ 'use strict';
883
+
884
+ // ── Helpers ────────────────────────────────────────────────────
885
+ function escHtml(s) {
886
+ return String(s == null ? '' : s)
887
+ .replace(/&/g, '&amp;')
888
+ .replace(/</g, '&lt;')
889
+ .replace(/>/g, '&gt;')
890
+ .replace(/"/g, '&quot;')
891
+ .replace(/'/g, '&#39;');
892
+ }
893
+
894
+ function relTime(iso) {
895
+ if (!iso) return '';
896
+ var t = typeof iso === 'number' ? iso : Date.parse(iso);
897
+ if (isNaN(t)) return '';
898
+ var s = Math.floor((Date.now() - t) / 1000);
899
+ if (s < 0) s = 0;
900
+ if (s < 60) return s + 's ago';
901
+ var m = Math.floor(s / 60);
902
+ if (m < 60) return m + 'm ago';
903
+ var h = Math.floor(m / 60);
904
+ if (h < 24) return h + 'h ago';
905
+ var d = Math.floor(h / 24);
906
+ return d + 'd ago';
907
+ }
908
+
909
+ // ── Settings dialog ────────────────────────────────────────────
910
+ async function openSlimSettings() {
911
+ var modal = document.getElementById('slim-settings-modal');
912
+ var body = document.getElementById('slim-settings-body');
913
+ body.innerHTML = '<p>Loading flags&hellip;</p>';
914
+ modal.classList.add('open');
915
+ try {
916
+ var res = await fetch('/api/features');
917
+ if (!res.ok) throw new Error('HTTP ' + res.status);
918
+ var data = await res.json();
919
+ var flags = (data && data.features) || [];
920
+
921
+ // Build the body as a document fragment so we avoid .innerHTML for
922
+ // the dynamic parts (SEC-03 ratchet — see DYNAMIC_INNERHTML_BASELINE
923
+ // in test/unit.test.js). Static intro paragraph uses innerHTML
924
+ // because it carries a <code> tag and is a pure string literal
925
+ // (exempted by _isStaticInnerHtmlRhs).
926
+ var intro = document.createElement('p');
927
+ intro.innerHTML = 'Toggle <code>slim-ux</code> off to return to the original dashboard. Advanced settings (agents, projects, runtime) live there for now.';
928
+ var frag = document.createDocumentFragment();
929
+ frag.appendChild(intro);
930
+
931
+ if (flags.length === 0) {
932
+ var none = document.createElement('p');
933
+ none.innerHTML = 'No experimental flags registered. Add entries to <code>engine/features.js</code>.';
934
+ frag.appendChild(none);
935
+ } else {
936
+ for (var i = 0; i < flags.length; i++) {
937
+ var f = flags[i];
938
+ var row = document.createElement('div');
939
+ row.className = 'flag-row';
940
+ var info = document.createElement('div');
941
+ var name = document.createElement('div');
942
+ name.className = 'flag-name';
943
+ name.textContent = f.id;
944
+ var desc = document.createElement('div');
945
+ desc.className = 'flag-desc';
946
+ desc.textContent = f.description || '';
947
+ info.appendChild(name);
948
+ info.appendChild(desc);
949
+ var lbl = document.createElement('label');
950
+ lbl.className = 'flag-toggle';
951
+ var input = document.createElement('input');
952
+ input.type = 'checkbox';
953
+ input.setAttribute('data-flag', f.id);
954
+ if (f.enabled) input.checked = true;
955
+ var stateSpan = document.createElement('span');
956
+ stateSpan.textContent = f.enabled ? 'On' : 'Off';
957
+ lbl.appendChild(input);
958
+ lbl.appendChild(stateSpan);
959
+ row.appendChild(info);
960
+ row.appendChild(lbl);
961
+ frag.appendChild(row);
962
+ }
963
+ }
964
+
965
+ // TEMP: deep-link to the full dashboard's settings page. The slim
966
+ // intentionally doesn't reimplement that whole UI — Carlos asked us
967
+ // to keep settings sprawl off the main slim view.
968
+ var link = document.createElement('a');
969
+ link.className = 'settings-link';
970
+ link.href = '/?fullDashboard=1';
971
+ link.title = 'Open the full dashboard’s settings page';
972
+ link.textContent = 'Advanced settings → full dashboard';
973
+ frag.appendChild(link);
974
+
975
+ body.replaceChildren(frag);
976
+ var inputs = body.querySelectorAll('input[type=checkbox][data-flag]');
977
+ for (var j = 0; j < inputs.length; j++) {
978
+ inputs[j].addEventListener('change', onFlagToggle);
979
+ }
980
+ } catch (e) {
981
+ var err = document.createElement('p');
982
+ err.style.color = 'var(--red)';
983
+ err.textContent = 'Failed to load flags: ' + (e && e.message ? e.message : 'unknown error');
984
+ body.replaceChildren(err);
985
+ }
986
+ }
987
+
988
+ function closeSlimSettings() {
989
+ document.getElementById('slim-settings-modal').classList.remove('open');
990
+ }
991
+
992
+ (function bindSettingsUi() {
993
+ var btn = document.getElementById('slim-settings-btn');
994
+ var closeBtn = document.getElementById('slim-settings-close');
995
+ var backdrop = document.getElementById('slim-settings-modal');
996
+ if (btn) btn.addEventListener('click', openSlimSettings);
997
+ if (closeBtn) closeBtn.addEventListener('click', closeSlimSettings);
998
+ if (backdrop) {
999
+ backdrop.addEventListener('click', function(ev) {
1000
+ if (ev.target === backdrop) closeSlimSettings();
1001
+ });
1002
+ }
1003
+ document.addEventListener('keydown', function(ev) {
1004
+ if (ev.key !== 'Escape') return;
1005
+ if (backdrop && backdrop.classList.contains('open')) closeSlimSettings();
1006
+ var actionModal = document.getElementById('slim-action-modal');
1007
+ if (actionModal && actionModal.classList.contains('open')) closeActionModal();
1008
+ });
1009
+ })();
1010
+
1011
+ async function onFlagToggle(ev) {
1012
+ var input = ev.currentTarget;
1013
+ var id = input.getAttribute('data-flag');
1014
+ var enabled = input.checked;
1015
+ var label = input.parentElement.querySelector('span');
1016
+ if (label) label.textContent = enabled ? 'On' : 'Off';
1017
+ try {
1018
+ var res = await fetch('/api/features/toggle', {
1019
+ method: 'POST',
1020
+ headers: { 'Content-Type': 'application/json' },
1021
+ body: JSON.stringify({ id: id, enabled: enabled }),
1022
+ });
1023
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1024
+ if (id === 'slim-ux' && !enabled) {
1025
+ location.reload();
1026
+ }
1027
+ } catch (e) {
1028
+ input.checked = !enabled;
1029
+ if (label) label.textContent = !enabled ? 'On' : 'Off';
1030
+ alert('Toggle failed: ' + e.message);
1031
+ }
1032
+ }
1033
+
1034
+ // ── Action prompt modal (TEMP: routes through chat) ──────────────
1035
+ // Each of the 4 action buttons opens this modal with prompt text
1036
+ // tailored to the concept. On submit we drop the user's text into the
1037
+ // chat input prefixed with intent text and submit it through the
1038
+ // existing CC stream pipeline. Direct /api/work-items style wiring is a
1039
+ // round-2 task — we want CC's natural-language handling for round 1
1040
+ // because it's already working end to end.
1041
+ var ACTION_PROMPTS = {
1042
+ work: {
1043
+ title: 'New work',
1044
+ desc: 'Describe a work item to dispatch (or a pipeline to define). Command Center will pick the right type and assignee.',
1045
+ placeholder: 'e.g. Fix the dashboard typo on the Plans page; high priority',
1046
+ prefix: 'Create a work item: ',
1047
+ },
1048
+ plan: {
1049
+ title: 'New plan',
1050
+ desc: 'Describe what should be planned. CC will draft the plan markdown for human review before it materializes.',
1051
+ placeholder: 'e.g. Plan: redesign the Settings sidebar in three small steps',
1052
+ prefix: 'Draft a plan for: ',
1053
+ },
1054
+ knowledge: {
1055
+ title: 'Add knowledge',
1056
+ desc: 'Capture a note for the team. CC will route it to the inbox; consolidation will classify it into KB.',
1057
+ placeholder: 'e.g. Note: when ADO returns 401, refresh the broker token before retrying',
1058
+ prefix: 'Add a team note: ',
1059
+ },
1060
+ trigger: {
1061
+ title: 'New trigger',
1062
+ desc: 'Describe a recurring trigger or a watch. CC will pick schedule (cron) vs watch (event-based) for you.',
1063
+ placeholder: 'e.g. Run the test suite every weekday at 8am',
1064
+ prefix: 'Create a trigger: ',
1065
+ },
1066
+ };
1067
+
1068
+ var _activeActionKey = null;
1069
+ function openActionModal(key) {
1070
+ var def = ACTION_PROMPTS[key];
1071
+ if (!def) return;
1072
+ _activeActionKey = key;
1073
+ document.getElementById('slim-action-title').textContent = def.title;
1074
+ document.getElementById('slim-action-desc').textContent = def.desc;
1075
+ var input = document.getElementById('slim-action-input');
1076
+ input.value = '';
1077
+ input.placeholder = def.placeholder;
1078
+ document.getElementById('slim-action-modal').classList.add('open');
1079
+ setTimeout(function() { input.focus(); }, 30);
1080
+ }
1081
+ function closeActionModal() {
1082
+ _activeActionKey = null;
1083
+ document.getElementById('slim-action-modal').classList.remove('open');
1084
+ }
1085
+ function submitActionModal() {
1086
+ var def = ACTION_PROMPTS[_activeActionKey];
1087
+ if (!def) return closeActionModal();
1088
+ var text = (document.getElementById('slim-action-input').value || '').trim();
1089
+ if (!text) return closeActionModal();
1090
+ var msg = def.prefix + text;
1091
+ closeActionModal();
1092
+ // Feed it through the same chat pipeline. Set the textarea, focus,
1093
+ // dispatch — CC will turn this into an actions block.
1094
+ var ta = document.getElementById('chat-input');
1095
+ ta.value = msg;
1096
+ ta.focus();
1097
+ sendMessage();
1098
+ }
1099
+ (function bindActionModalUi() {
1100
+ var buttons = document.querySelectorAll('.action-btn[data-action-prompt]');
1101
+ for (var i = 0; i < buttons.length; i++) {
1102
+ buttons[i].addEventListener('click', function(ev) {
1103
+ var key = ev.currentTarget.getAttribute('data-action-prompt');
1104
+ openActionModal(key);
1105
+ });
1106
+ }
1107
+ document.getElementById('slim-action-close').addEventListener('click', closeActionModal);
1108
+ document.getElementById('slim-action-cancel').addEventListener('click', closeActionModal);
1109
+ document.getElementById('slim-action-submit').addEventListener('click', submitActionModal);
1110
+ var backdrop = document.getElementById('slim-action-modal');
1111
+ backdrop.addEventListener('click', function(ev) {
1112
+ if (ev.target === backdrop) closeActionModal();
1113
+ });
1114
+ })();
1115
+
1116
+ // ── Chatbox wired to /api/command-center/stream ──────────────
1117
+ // Mirrors the full dashboard's command center: SSE streaming, tool-call
1118
+ // progress, abort, and per-tab session continuity.
1119
+ var SLIM_STORAGE_KEY = 'slim-cc-state-v1';
1120
+ var SLIM_TAB_KEY = 'slim-cc-tabid-v1';
1121
+ var SLIM_PROJECT_KEY = 'slim-current-project-v1';
1122
+ var SLIM_MAX_MESSAGES = 30;
1123
+ var STREAM_TIMEOUT_MS = (60 * 60 * 1000) + 60000;
1124
+
1125
+ var sessionId = null;
1126
+ var messages = [];
1127
+ var sending = false;
1128
+ var abortController = null;
1129
+ var currentProject = null;
1130
+ try { currentProject = localStorage.getItem(SLIM_PROJECT_KEY) || null; } catch (_e) { /* private mode */ }
1131
+
1132
+ var tabId = (function() {
1133
+ try {
1134
+ var t = sessionStorage.getItem(SLIM_TAB_KEY);
1135
+ if (t) return t;
1136
+ t = 'slim-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
1137
+ sessionStorage.setItem(SLIM_TAB_KEY, t);
1138
+ return t;
1139
+ } catch (_e) {
1140
+ return 'slim-' + Date.now().toString(36);
1141
+ }
1142
+ })();
1143
+
1144
+ var msgsEl = document.getElementById('chat-messages');
1145
+ var inputEl = document.getElementById('chat-input');
1146
+ var sendBtn = document.getElementById('chat-send');
1147
+ var stopBtn = document.getElementById('chat-stop');
1148
+
1149
+ function loadState() {
1150
+ try {
1151
+ var raw = localStorage.getItem(SLIM_STORAGE_KEY);
1152
+ if (!raw) return;
1153
+ var data = JSON.parse(raw) || {};
1154
+ sessionId = data.sessionId || null;
1155
+ messages = Array.isArray(data.messages) ? data.messages.slice(-SLIM_MAX_MESSAGES) : [];
1156
+ } catch (_e) { /* ignore */ }
1157
+ }
1158
+ var saveDebounce = null;
1159
+ function saveState() {
1160
+ if (saveDebounce) return;
1161
+ saveDebounce = setTimeout(function() {
1162
+ saveDebounce = null;
1163
+ try {
1164
+ localStorage.setItem(SLIM_STORAGE_KEY, JSON.stringify({
1165
+ sessionId: sessionId,
1166
+ messages: messages.slice(-SLIM_MAX_MESSAGES),
1167
+ }));
1168
+ } catch (_e) { /* localStorage full */ }
1169
+ }, 300);
1170
+ }
1171
+
1172
+ function escHtmlChat(s) {
1173
+ return String(s == null ? '' : s)
1174
+ .replace(/&/g, '&amp;')
1175
+ .replace(/</g, '&lt;')
1176
+ .replace(/>/g, '&gt;')
1177
+ .replace(/"/g, '&quot;')
1178
+ .replace(/'/g, '&#39;');
1179
+ }
1180
+ function renderMarkdown(text) {
1181
+ if (!text) return '';
1182
+ var html = escHtmlChat(text);
1183
+ var codeSlots = [];
1184
+ html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, function(_, _lang, code) {
1185
+ codeSlots.push('<pre><code>' + code + '</code></pre>');
1186
+ return '\u0001CB' + (codeSlots.length - 1) + '\u0001';
1187
+ });
1188
+ html = html.replace(/`([^`\n]+)`/g, function(_, code) {
1189
+ codeSlots.push('<code>' + code + '</code>');
1190
+ return '\u0001CB' + (codeSlots.length - 1) + '\u0001';
1191
+ });
1192
+ html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
1193
+ html = html.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
1194
+ html = html.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function(_, label, href) {
1195
+ if (!/^(https?:|mailto:|\/)/i.test(href)) return label;
1196
+ return '<a href="' + href + '" target="_blank" rel="noopener">' + label + '</a>';
1197
+ });
1198
+ html = html.replace(/\u0001CB(\d+)\u0001/g, function(_, i) { return codeSlots[+i] || ''; });
1199
+ return html;
1200
+ }
1201
+
1202
+ function clearEmpty() {
1203
+ var empty = msgsEl.querySelector('.chat-empty');
1204
+ if (empty) empty.remove();
1205
+ }
1206
+ function scrollToBottom() {
1207
+ msgsEl.scrollTop = msgsEl.scrollHeight;
1208
+ }
1209
+ function appendBubble(role, text) {
1210
+ clearEmpty();
1211
+ var div = document.createElement('div');
1212
+ div.className = 'chat-msg ' + role;
1213
+ if (role === 'assistant') div.innerHTML = renderMarkdown(text);
1214
+ else div.textContent = text;
1215
+ msgsEl.appendChild(div);
1216
+ scrollToBottom();
1217
+ return div;
1218
+ }
1219
+ function appendActionStatus(severity, label) {
1220
+ var div = document.createElement('div');
1221
+ div.className = 'chat-action ' + (severity || '');
1222
+ div.textContent = label;
1223
+ msgsEl.appendChild(div);
1224
+ scrollToBottom();
1225
+ return div;
1226
+ }
1227
+
1228
+ function formatToolSummary(name, input) {
1229
+ var inp = input || {};
1230
+ switch (name) {
1231
+ case 'Bash': {
1232
+ var cmd = String(inp.command || '');
1233
+ if (cmd.length > 80) cmd = cmd.slice(0, 77) + '...';
1234
+ return '$ ' + cmd;
1235
+ }
1236
+ case 'Read': return 'Reading ' + (inp.file_path || '');
1237
+ case 'Edit': return 'Editing ' + (inp.file_path || '');
1238
+ case 'Write': return 'Writing ' + (inp.file_path || '');
1239
+ case 'Grep': return 'Searching `' + (inp.pattern || '') + '` in ' + (inp.path || '.');
1240
+ case 'Glob': return 'Glob ' + (inp.pattern || '');
1241
+ case 'WebFetch': return 'Fetch ' + (inp.url || '');
1242
+ case 'WebSearch': return 'Search "' + (inp.query || '') + '"';
1243
+ case 'TodoWrite': {
1244
+ var items = Array.isArray(inp.todos) ? inp.todos : [];
1245
+ return 'Update todos (' + items.length + ' items)';
1246
+ }
1247
+ default: {
1248
+ var keys = Object.keys(inp);
1249
+ if (keys.length === 0) return name + '()';
1250
+ var v = String(inp[keys[0]] || '');
1251
+ if (v.length > 40) v = v.slice(0, 37) + '...';
1252
+ return name + '(' + keys[0] + ': ' + v + ')';
1253
+ }
1254
+ }
1255
+ }
1256
+
1257
+ function buildStreamBubble() {
1258
+ clearEmpty();
1259
+ var div = document.createElement('div');
1260
+ div.className = 'chat-msg assistant streaming';
1261
+
1262
+ var tools = document.createElement('div');
1263
+ tools.className = 'chat-tools';
1264
+ div.appendChild(tools);
1265
+
1266
+ var body = document.createElement('div');
1267
+ body.className = 'chat-body';
1268
+ div.appendChild(body);
1269
+
1270
+ var thinking = document.createElement('div');
1271
+ thinking.className = 'chat-thinking';
1272
+ thinking.innerHTML = 'Thinking<span class="chat-thinking-dots"><span></span><span></span><span></span></span>';
1273
+ div.appendChild(thinking);
1274
+
1275
+ msgsEl.appendChild(div);
1276
+ scrollToBottom();
1277
+
1278
+ return {
1279
+ root: div,
1280
+ tools: tools,
1281
+ body: body,
1282
+ thinking: thinking,
1283
+ addTool: function(name, input) {
1284
+ var line = document.createElement('div');
1285
+ line.className = 'chat-tool';
1286
+ line.textContent = formatToolSummary(name, input);
1287
+ tools.appendChild(line);
1288
+ scrollToBottom();
1289
+ },
1290
+ setText: function(text) {
1291
+ body.innerHTML = renderMarkdown(text || '');
1292
+ scrollToBottom();
1293
+ },
1294
+ removeThinking: function() {
1295
+ if (thinking && thinking.parentNode) thinking.parentNode.removeChild(thinking);
1296
+ },
1297
+ finalize: function(text) {
1298
+ if (thinking && thinking.parentNode) thinking.parentNode.removeChild(thinking);
1299
+ body.innerHTML = renderMarkdown(text || '');
1300
+ div.classList.remove('streaming');
1301
+ scrollToBottom();
1302
+ },
1303
+ replaceWithError: function(message) {
1304
+ if (thinking && thinking.parentNode) thinking.parentNode.removeChild(thinking);
1305
+ div.className = 'chat-msg error';
1306
+ div.innerHTML = '';
1307
+ div.textContent = message;
1308
+ scrollToBottom();
1309
+ },
1310
+ };
1311
+ }
1312
+
1313
+ function setSending(on) {
1314
+ sending = on;
1315
+ sendBtn.disabled = on;
1316
+ sendBtn.textContent = on ? 'Sending…' : 'Send';
1317
+ stopBtn.style.display = on ? 'block' : 'none';
1318
+ inputEl.disabled = on;
1319
+ }
1320
+
1321
+ function rerenderHistory() {
1322
+ msgsEl.innerHTML = '';
1323
+ if (!messages.length) {
1324
+ var empty = document.createElement('div');
1325
+ empty.className = 'chat-empty';
1326
+ empty.textContent = 'No messages yet — say hi to Command Center.';
1327
+ msgsEl.appendChild(empty);
1328
+ return;
1329
+ }
1330
+ for (var i = 0; i < messages.length; i++) {
1331
+ var m = messages[i];
1332
+ if (m.role === 'action') appendActionStatus(m.severity || '', m.text || '');
1333
+ else appendBubble(m.role, m.text || '');
1334
+ }
1335
+ }
1336
+ loadState();
1337
+ rerenderHistory();
1338
+
1339
+ // Project context picker: populated from /api/status (which already returns
1340
+ // the configured project list). Single-project: rendered as a static label
1341
+ // so it's still visible but not clickable. Multi-project: <select> dropdown.
1342
+ // Zero-project: a hint to add one. Selection persists per-browser in
1343
+ // localStorage and is included in every /api/command-center/stream call.
1344
+ // Build the "+ Add" button as a real DOM node (was an HTML string + innerHTML
1345
+ // injection — SEC-03 ratchet prefers we avoid that pattern, see
1346
+ // test/unit.test.js DYNAMIC_INNERHTML_BASELINE).
1347
+ function makeContextAddBtn() {
1348
+ var btn = document.createElement('button');
1349
+ btn.type = 'button';
1350
+ btn.className = 'context-add';
1351
+ btn.id = 'chat-context-add';
1352
+ btn.title = 'Link a new project';
1353
+ btn.textContent = '+ Add';
1354
+ return btn;
1355
+ }
1356
+ // Strip variant: empty-projects message.
1357
+ function makeContextEmptyNode() {
1358
+ var empty = document.createElement('span');
1359
+ empty.className = 'context-empty';
1360
+ empty.textContent = 'No projects linked yet.';
1361
+ return empty;
1362
+ }
1363
+ // Strip variant: single-project static label (no picker, just the name).
1364
+ function makeContextStaticNode(name) {
1365
+ var staticSpan = document.createElement('span');
1366
+ staticSpan.className = 'context-static';
1367
+ staticSpan.textContent = name;
1368
+ return staticSpan;
1369
+ }
1370
+ // Strip variant: multi-project <select>. Returns the select so the caller
1371
+ // can attach a change listener.
1372
+ function makeContextSelect(projects, selected) {
1373
+ var sel = document.createElement('select');
1374
+ sel.id = 'chat-context-select';
1375
+ sel.setAttribute('aria-label', 'Working in project');
1376
+ for (var i = 0; i < projects.length; i++) {
1377
+ var p = projects[i];
1378
+ var opt = document.createElement('option');
1379
+ opt.value = p;
1380
+ opt.textContent = p;
1381
+ if (p === selected) opt.selected = true;
1382
+ sel.appendChild(opt);
1383
+ }
1384
+ return sel;
1385
+ }
1386
+ async function loadProjectsAndRenderContext(opts) {
1387
+ var stripEl = document.getElementById('chat-context-strip');
1388
+ var controlsEl = document.getElementById('chat-context-controls');
1389
+ if (!stripEl || !controlsEl) return;
1390
+ var preferProject = (opts && opts.preferProject) || null;
1391
+ try {
1392
+ var res = await fetch('/api/status');
1393
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1394
+ var data = await res.json();
1395
+ var projects = (data && Array.isArray(data.projects) ? data.projects : [])
1396
+ .map(function(p) { return p && p.name ? String(p.name) : null; })
1397
+ .filter(Boolean);
1398
+ if (projects.length === 0) {
1399
+ stripEl.style.display = '';
1400
+ controlsEl.replaceChildren(makeContextEmptyNode(), makeContextAddBtn());
1401
+ currentProject = null;
1402
+ } else {
1403
+ if (preferProject && projects.indexOf(preferProject) !== -1) {
1404
+ currentProject = preferProject;
1405
+ try { localStorage.setItem(SLIM_PROJECT_KEY, currentProject); } catch (_e) {}
1406
+ } else if (currentProject && projects.indexOf(currentProject) === -1) {
1407
+ currentProject = null;
1408
+ try { localStorage.removeItem(SLIM_PROJECT_KEY); } catch (_e) {}
1409
+ }
1410
+ if (!currentProject) {
1411
+ currentProject = projects[0];
1412
+ try { localStorage.setItem(SLIM_PROJECT_KEY, currentProject); } catch (_e) {}
1413
+ }
1414
+ if (projects.length === 1) {
1415
+ stripEl.style.display = '';
1416
+ controlsEl.replaceChildren(makeContextStaticNode(currentProject), makeContextAddBtn());
1417
+ } else {
1418
+ var sel = makeContextSelect(projects, currentProject);
1419
+ stripEl.style.display = '';
1420
+ controlsEl.replaceChildren(sel, makeContextAddBtn());
1421
+ sel.addEventListener('change', function(ev) {
1422
+ currentProject = ev.target.value || null;
1423
+ try { localStorage.setItem(SLIM_PROJECT_KEY, currentProject); } catch (_e) {}
1424
+ });
1425
+ }
1426
+ }
1427
+ var addBtn = document.getElementById('chat-context-add');
1428
+ if (addBtn) addBtn.addEventListener('click', openAddProjectModal);
1429
+ } catch (_e) {
1430
+ stripEl.style.display = 'none';
1431
+ }
1432
+ }
1433
+
1434
+ // ── Add-project modal ────────────────────────────────────────
1435
+ function openAddProjectModal() {
1436
+ var modal = document.getElementById('slim-add-project-modal');
1437
+ var input = document.getElementById('slim-add-project-path');
1438
+ var msg = document.getElementById('slim-add-project-msg');
1439
+ if (!modal) return;
1440
+ if (input) { input.value = ''; input.disabled = false; }
1441
+ if (msg) { msg.textContent = ''; msg.style.color = ''; }
1442
+ modal.classList.add('open');
1443
+ if (input) setTimeout(function() { input.focus(); }, 0);
1444
+ }
1445
+ // Reset both modal fields here so cancel / close-after-success / Esc /
1446
+ // backdrop-click all behave the same way: open the modal next time and
1447
+ // start fresh, no stale path or status text leaking across openings.
1448
+ function closeAddProjectModal() {
1449
+ var modal = document.getElementById('slim-add-project-modal');
1450
+ var input = document.getElementById('slim-add-project-path');
1451
+ var msg = document.getElementById('slim-add-project-msg');
1452
+ if (modal) modal.classList.remove('open');
1453
+ if (input) { input.value = ''; input.disabled = false; }
1454
+ if (msg) { msg.textContent = ''; msg.style.color = ''; }
1455
+ }
1456
+ async function browseProjectPath() {
1457
+ var input = document.getElementById('slim-add-project-path');
1458
+ var msg = document.getElementById('slim-add-project-msg');
1459
+ if (!input) return;
1460
+ if (msg) { msg.textContent = 'Opening folder picker…'; msg.style.color = 'var(--muted)'; }
1461
+ // Pre-seed the picker with whatever is in the text box so users who've
1462
+ // typed a partial path don't have to re-navigate to it from their home
1463
+ // dir. Server validates this strictly (walks up to nearest existing dir).
1464
+ // modernPicker: true opts into the slim-UX Explorer-style picker
1465
+ // (Microsoft.Win32.OpenFolderDialog with native path bar + InitialDirectory
1466
+ // support). The classic dashboard doesn't send this flag and gets the
1467
+ // unchanged WinForms FolderBrowserDialog for its Scan Projects flow.
1468
+ var initialPath = (input.value || '').trim();
1469
+ try {
1470
+ var res = await fetch('/api/projects/browse', {
1471
+ method: 'POST',
1472
+ headers: { 'Content-Type': 'application/json' },
1473
+ body: JSON.stringify({ initialPath: initialPath || undefined, modernPicker: true }),
1474
+ });
1475
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1476
+ var data = await res.json();
1477
+ if (data && data.path) {
1478
+ input.value = data.path;
1479
+ if (msg) msg.textContent = '';
1480
+ } else if (msg) {
1481
+ msg.textContent = 'No folder selected.';
1482
+ }
1483
+ } catch (e) {
1484
+ if (msg) { msg.textContent = 'Browse failed: ' + (e && e.message ? e.message : 'unknown error'); msg.style.color = 'var(--red)'; }
1485
+ }
1486
+ }
1487
+ async function submitAddProject() {
1488
+ var input = document.getElementById('slim-add-project-path');
1489
+ var msg = document.getElementById('slim-add-project-msg');
1490
+ var submitBtn = document.getElementById('slim-add-project-submit');
1491
+ if (!input || !msg) return;
1492
+ var p = (input.value || '').trim();
1493
+ if (!p) { msg.textContent = 'Enter a path or click Browse.'; msg.style.color = 'var(--red)'; return; }
1494
+ msg.textContent = 'Linking…';
1495
+ msg.style.color = 'var(--muted)';
1496
+ if (submitBtn) submitBtn.disabled = true;
1497
+ if (input) input.disabled = true;
1498
+ try {
1499
+ var res = await fetch('/api/projects/add', {
1500
+ method: 'POST',
1501
+ headers: { 'Content-Type': 'application/json' },
1502
+ body: JSON.stringify({ path: p }),
1503
+ });
1504
+ var data = await res.json().catch(function() { return {}; });
1505
+ if (!res.ok) {
1506
+ var hint = data && data.needsConfirmation
1507
+ ? ' (path is not a git repo — use Settings → Scan Projects to confirm a non-repo path)'
1508
+ : '';
1509
+ throw new Error((data && data.error) ? data.error + hint : ('HTTP ' + res.status + hint));
1510
+ }
1511
+ var newName = data && data.name ? data.name : null;
1512
+ msg.textContent = 'Linked ' + (newName || p) + '. Refreshing…';
1513
+ msg.style.color = 'var(--green)';
1514
+ await loadProjectsAndRenderContext({ preferProject: newName });
1515
+ setTimeout(closeAddProjectModal, 600);
1516
+ } catch (e) {
1517
+ msg.textContent = 'Failed: ' + (e && e.message ? e.message : 'unknown error');
1518
+ msg.style.color = 'var(--red)';
1519
+ } finally {
1520
+ if (submitBtn) submitBtn.disabled = false;
1521
+ if (input) input.disabled = false;
1522
+ }
1523
+ }
1524
+
1525
+ loadProjectsAndRenderContext();
1526
+ // Wire add-project modal once at startup; the trigger button gets a click
1527
+ // listener re-bound on every render of the context strip.
1528
+ (function wireAddProjectModal() {
1529
+ var modal = document.getElementById('slim-add-project-modal');
1530
+ if (!modal) return;
1531
+ var closeBtn = document.getElementById('slim-add-project-close');
1532
+ var cancelBtn = document.getElementById('slim-add-project-cancel');
1533
+ var browseBtn = document.getElementById('slim-add-project-browse');
1534
+ var submitBtn = document.getElementById('slim-add-project-submit');
1535
+ var input = document.getElementById('slim-add-project-path');
1536
+ if (closeBtn) closeBtn.addEventListener('click', closeAddProjectModal);
1537
+ if (cancelBtn) cancelBtn.addEventListener('click', closeAddProjectModal);
1538
+ if (browseBtn) browseBtn.addEventListener('click', browseProjectPath);
1539
+ if (submitBtn) submitBtn.addEventListener('click', submitAddProject);
1540
+ if (input) input.addEventListener('keydown', function(ev) {
1541
+ if (ev.key === 'Enter') { ev.preventDefault(); submitAddProject(); }
1542
+ });
1543
+ modal.addEventListener('click', function(ev) {
1544
+ if (ev.target === modal) closeAddProjectModal();
1545
+ });
1546
+ })();
1547
+
1548
+ function describeAction(action) {
1549
+ if (!action || !action.type) return 'action';
1550
+ var t = action.type;
1551
+ var label = action.title || action.id || action.file || action.agent || '';
1552
+ return label ? t + ': ' + label : t;
1553
+ }
1554
+ function renderActionResults(actions, results) {
1555
+ if (!actions || !actions.length) return;
1556
+ for (var i = 0; i < actions.length; i++) {
1557
+ var action = actions[i];
1558
+ var r = (results && results[i]) || null;
1559
+ var label = describeAction(action);
1560
+ var severity = 'ok';
1561
+ var symbol = '✓';
1562
+ var detail = '';
1563
+ if (r && r.error) { severity = 'err'; symbol = '✗'; detail = ' — ' + r.error; }
1564
+ else if (r && r.warning) { severity = 'warn'; symbol = 'ℹ'; detail = ' — ' + r.warning; }
1565
+ else if (r && r.duplicate) { severity = 'warn'; symbol = '↺'; detail = ' (already exists)'; }
1566
+ var text = symbol + ' ' + label + detail;
1567
+ appendActionStatus(severity, text);
1568
+ messages.push({ role: 'action', severity: severity, text: text });
1569
+ }
1570
+ saveState();
1571
+ }
1572
+
1573
+ async function sendMessage() {
1574
+ if (sending) return;
1575
+ var text = inputEl.value.trim();
1576
+ if (!text) return;
1577
+
1578
+ appendBubble('user', text);
1579
+ messages.push({ role: 'user', text: text });
1580
+ saveState();
1581
+
1582
+ inputEl.value = '';
1583
+ inputEl.style.height = 'auto';
1584
+ setSending(true);
1585
+
1586
+ var stream = buildStreamBubble();
1587
+ var streamedText = '';
1588
+ abortController = new AbortController();
1589
+ var userAborted = false;
1590
+ var timeoutSignal = null;
1591
+ try { timeoutSignal = AbortSignal.timeout(STREAM_TIMEOUT_MS); } catch (_e) { /* old browsers */ }
1592
+ var signal = timeoutSignal && AbortSignal.any
1593
+ ? AbortSignal.any([abortController.signal, timeoutSignal])
1594
+ : abortController.signal;
1595
+
1596
+ try {
1597
+ var res = await fetch('/api/command-center/stream', {
1598
+ method: 'POST',
1599
+ headers: { 'Content-Type': 'application/json' },
1600
+ body: JSON.stringify({ message: text, tabId: tabId, sessionId: sessionId, currentProject: currentProject || undefined }),
1601
+ signal: signal,
1602
+ });
1603
+ if (!res.ok) {
1604
+ var errBody = await res.text().catch(function() { return ''; });
1605
+ throw new Error('HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
1606
+ }
1607
+ if (!res.body || !res.body.getReader) {
1608
+ throw new Error('Streaming not supported by this browser.');
1609
+ }
1610
+
1611
+ var reader = res.body.getReader();
1612
+ var decoder = new TextDecoder();
1613
+ var buf = '';
1614
+ var doneEvt = null;
1615
+ var errorEvt = null;
1616
+
1617
+ while (true) {
1618
+ var r = await reader.read();
1619
+ if (r.done) break;
1620
+ buf += decoder.decode(r.value, { stream: true });
1621
+ var lines = buf.split('\n');
1622
+ buf = lines.pop();
1623
+ for (var i = 0; i < lines.length; i++) {
1624
+ var line = lines[i];
1625
+ if (!line.startsWith('data: ')) continue;
1626
+ var evt;
1627
+ try { evt = JSON.parse(line.slice(6)); } catch (_e) { continue; }
1628
+ if (evt.type === 'chunk') {
1629
+ streamedText += (evt.text || '');
1630
+ stream.setText(streamedText);
1631
+ } else if (evt.type === 'tool') {
1632
+ stream.addTool(evt.name, evt.input || {});
1633
+ } else if (evt.type === 'heartbeat') {
1634
+ /* server keep-alive — ignored on purpose */
1635
+ } else if (evt.type === 'done') {
1636
+ doneEvt = evt;
1637
+ } else if (evt.type === 'error') {
1638
+ errorEvt = evt;
1639
+ }
1640
+ }
1641
+ }
1642
+
1643
+ if (errorEvt) {
1644
+ stream.replaceWithError(errorEvt.error || 'Error');
1645
+ messages.push({ role: 'error', text: errorEvt.error || 'Error' });
1646
+ saveState();
1647
+ return;
1648
+ }
1649
+
1650
+ if (doneEvt) {
1651
+ var finalText = (doneEvt.text != null && doneEvt.text !== '') ? doneEvt.text : streamedText;
1652
+ stream.finalize(finalText || '(no response)');
1653
+ messages.push({ role: 'assistant', text: finalText || '(no response)' });
1654
+ if (doneEvt.sessionId !== undefined) sessionId = doneEvt.sessionId || null;
1655
+ saveState();
1656
+ if (doneEvt.actionParseError) {
1657
+ var pe = '⚠️ Actions block emitted but JSON could not be parsed — no actions executed.';
1658
+ appendActionStatus('err', pe);
1659
+ messages.push({ role: 'action', severity: 'err', text: pe });
1660
+ saveState();
1661
+ }
1662
+ if (doneEvt.actions && doneEvt.actions.length > 0) {
1663
+ renderActionResults(doneEvt.actions, doneEvt.actionResults || []);
1664
+ }
1665
+ // Refresh status + history opportunistically — actions probably
1666
+ // changed something.
1667
+ scheduleStatusRefresh(800);
1668
+ } else {
1669
+ stream.finalize(streamedText || '(stream ended before completion)');
1670
+ messages.push({ role: 'assistant', text: streamedText || '(stream ended before completion)' });
1671
+ saveState();
1672
+ }
1673
+ } catch (e) {
1674
+ if (e && (e.name === 'AbortError' || /aborted/i.test(String(e.message || '')))) {
1675
+ userAborted = true;
1676
+ if (streamedText) {
1677
+ stream.finalize(streamedText);
1678
+ messages.push({ role: 'assistant', text: streamedText });
1679
+ } else {
1680
+ stream.replaceWithError('Stopped.');
1681
+ messages.push({ role: 'error', text: 'Stopped.' });
1682
+ }
1683
+ saveState();
1684
+ } else {
1685
+ var msg = 'Send failed: ' + (e && e.message ? e.message : e);
1686
+ stream.replaceWithError(msg);
1687
+ messages.push({ role: 'error', text: msg });
1688
+ saveState();
1689
+ }
1690
+ } finally {
1691
+ setSending(false);
1692
+ abortController = null;
1693
+ if (!userAborted) inputEl.focus();
1694
+ }
1695
+ }
1696
+
1697
+ function abortInFlight() {
1698
+ if (!sending || !abortController) return;
1699
+ try {
1700
+ fetch('/api/command-center/abort', {
1701
+ method: 'POST',
1702
+ headers: { 'Content-Type': 'application/json' },
1703
+ body: JSON.stringify({ tabId: tabId }),
1704
+ }).catch(function() {});
1705
+ } catch (_e) { /* ignore */ }
1706
+ try { abortController.abort(); } catch (_e) { /* ignore */ }
1707
+ }
1708
+
1709
+ async function slimChatNew() {
1710
+ if (sending) abortInFlight();
1711
+ sessionId = null;
1712
+ messages = [];
1713
+ saveState();
1714
+ rerenderHistory();
1715
+ try {
1716
+ fetch('/api/cc-sessions/' + encodeURIComponent(tabId), { method: 'DELETE' }).catch(function() {});
1717
+ } catch (_e) { /* ignore */ }
1718
+ inputEl.focus();
1719
+ }
1720
+
1721
+ var newChatBtn = document.getElementById('slim-new-chat-btn');
1722
+ if (newChatBtn) newChatBtn.addEventListener('click', slimChatNew);
1723
+ sendBtn.addEventListener('click', sendMessage);
1724
+ stopBtn.addEventListener('click', abortInFlight);
1725
+ inputEl.addEventListener('keydown', function(ev) {
1726
+ if (ev.key === 'Enter' && !ev.shiftKey) {
1727
+ ev.preventDefault();
1728
+ sendMessage();
1729
+ }
1730
+ });
1731
+ inputEl.addEventListener('input', function() {
1732
+ inputEl.style.height = 'auto';
1733
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 130) + 'px';
1734
+ });
1735
+
1736
+ inputEl.focus();
1737
+
1738
+ // ── Status cockpit + history feed ──────────────────────────────
1739
+ // Polls /api/status every 5s and updates the right column. The full
1740
+ // dashboard exposes a richer SSE /api/status-stream; we use plain
1741
+ // polling for round 1 to keep the slim self-contained. Round-2 should
1742
+ // switch to SSE so the "minions working" tile reacts in real time.
1743
+
1744
+ var STATUS_POLL_MS = 5000;
1745
+ var pollTimer = null;
1746
+ var refreshTimer = null;
1747
+
1748
+ function scheduleStatusRefresh(delay) {
1749
+ if (refreshTimer) clearTimeout(refreshTimer);
1750
+ refreshTimer = setTimeout(function() {
1751
+ refreshTimer = null;
1752
+ pollStatusOnce();
1753
+ }, delay || 0);
1754
+ }
1755
+
1756
+ // Apply lit-state and value/detail text to a tile.
1757
+ function updateTile(key, value, detail, lit) {
1758
+ var tile = document.querySelector('.cockpit-tile[data-tile="' + key + '"]');
1759
+ if (!tile) return;
1760
+ var valEl = tile.querySelector('.cockpit-value');
1761
+ var detEl = tile.querySelector('.cockpit-detail');
1762
+ if (valEl) {
1763
+ valEl.textContent = (value === null || value === undefined) ? '—' : String(value);
1764
+ valEl.classList.toggle('dim', !lit);
1765
+ }
1766
+ if (detEl) detEl.textContent = detail || '';
1767
+ tile.classList.remove('lit-blue', 'lit-green', 'lit-amber', 'lit-red');
1768
+ if (lit) tile.classList.add('lit-' + lit);
1769
+ }
1770
+
1771
+ function describeDispatch(d) {
1772
+ var who = d.agent || d.dispatched_to || 'someone';
1773
+ var what = d.title || d.workItemTitle || d.id || '(untitled)';
1774
+ return who + ' — ' + what;
1775
+ }
1776
+
1777
+ function applyStatus(data) {
1778
+ if (!data) return;
1779
+ // ── Engine tile ────────────────────────────────────────────
1780
+ var engine = data.engine || {};
1781
+ var engineRunning = !!(engine.codeVersion || engine.running || engine.startedAt);
1782
+ var engineMode = engine.controlState || engine.mode || (engineRunning ? 'running' : 'stopped');
1783
+ if (typeof engineMode !== 'string') engineMode = engineRunning ? 'running' : 'stopped';
1784
+ var engineLit = engineRunning && engineMode !== 'stopped' ? 'green' : (engineMode === 'paused' ? 'amber' : 'red');
1785
+ updateTile(
1786
+ 'engine',
1787
+ engineRunning ? engineMode : 'down',
1788
+ engine.startedAt ? 'up since ' + relTime(engine.startedAt) : '—',
1789
+ engineLit
1790
+ );
1791
+
1792
+ // ── Dispatches + minions ───────────────────────────────────
1793
+ var dispatch = data.dispatch || {};
1794
+ var active = Array.isArray(dispatch.active) ? dispatch.active : [];
1795
+ var pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
1796
+ var completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
1797
+ var workingAgents = {};
1798
+ for (var i = 0; i < active.length; i++) {
1799
+ var a = active[i].agent || active[i].dispatched_to;
1800
+ if (a) workingAgents[a] = true;
1801
+ }
1802
+ var workingCount = Object.keys(workingAgents).length;
1803
+ updateTile(
1804
+ 'dispatches',
1805
+ active.length,
1806
+ active.length ? 'in flight' : 'none in flight',
1807
+ active.length ? 'blue' : null
1808
+ );
1809
+ updateTile(
1810
+ 'minions',
1811
+ workingCount,
1812
+ workingCount ? Object.keys(workingAgents).slice(0, 3).join(', ') : 'all idle',
1813
+ workingCount ? 'green' : null
1814
+ );
1815
+ updateTile(
1816
+ 'queued',
1817
+ pending.length,
1818
+ pending.length ? 'waiting to spawn' : 'queue empty',
1819
+ pending.length ? 'amber' : null
1820
+ );
1821
+
1822
+ // ── PR tile (status snapshot from cached pull-requests) ─────
1823
+ var prs = Array.isArray(data.pullRequests) ? data.pullRequests : [];
1824
+ var activePrs = prs.filter(function(p) {
1825
+ return p && (p.status === 'active' || p.status === 'linked');
1826
+ });
1827
+ var failingPrs = activePrs.filter(function(p) { return p.buildStatus === 'failing'; });
1828
+ var prDetail = activePrs.length === 0
1829
+ ? 'no open PRs'
1830
+ : (failingPrs.length ? failingPrs.length + ' failing build' + (failingPrs.length === 1 ? '' : 's')
1831
+ : 'all healthy');
1832
+ updateTile(
1833
+ 'prs',
1834
+ activePrs.length,
1835
+ prDetail,
1836
+ activePrs.length === 0 ? null : (failingPrs.length ? 'red' : 'blue')
1837
+ );
1838
+
1839
+ // ── Watches tile ───────────────────────────────────────────
1840
+ var watches = Array.isArray(data.watches) ? data.watches : [];
1841
+ var activeWatches = watches.filter(function(w) { return w && w.status === 'active'; });
1842
+ updateTile(
1843
+ 'watches',
1844
+ activeWatches.length,
1845
+ activeWatches.length ? 'monitoring' : 'no watches set',
1846
+ activeWatches.length ? 'blue' : null
1847
+ );
1848
+
1849
+ // ── Stamps + history ───────────────────────────────────────
1850
+ var stamp = document.getElementById('cockpit-stamp');
1851
+ if (stamp) stamp.textContent = 'updated ' + relTime(data.timestamp || Date.now());
1852
+
1853
+ renderHistoryFeed({ active: active, completed: completed, prs: activePrs });
1854
+ var hStamp = document.getElementById('history-stamp');
1855
+ if (hStamp) hStamp.textContent = relTime(data.timestamp || Date.now());
1856
+ }
1857
+
1858
+ function renderHistoryFeed(input) {
1859
+ var listEl = document.getElementById('history-list');
1860
+ if (!listEl) return;
1861
+ var events = [];
1862
+
1863
+ // Active dispatches (blue) — at the top so users know what's running.
1864
+ (input.active || []).forEach(function(d) {
1865
+ var ts = d.startedAt || d.dispatchedAt || d.createdAt || d._ts;
1866
+ events.push({
1867
+ kind: 'dispatch',
1868
+ kindLabel: 'Dispatch',
1869
+ title: describeDispatch(d),
1870
+ meta: 'started ' + relTime(ts) + (d.workItemId ? ' · ' + d.workItemId : ''),
1871
+ ts: ts ? Date.parse(ts) || Date.now() : Date.now(),
1872
+ });
1873
+ });
1874
+
1875
+ // Recent completions (green or red) — last 12 most recent first.
1876
+ (input.completed || []).slice().reverse().slice(0, 12).forEach(function(c) {
1877
+ var ok = c.result === 'success' || c.status === 'done';
1878
+ var ts = c.completedAt || c.endedAt || c.startedAt || c._ts;
1879
+ var who = c.agent || c.dispatched_to || 'someone';
1880
+ var what = c.title || c.workItemTitle || c.id || '(work item)';
1881
+ var prSuffix = c.pr ? ' · PR ' + c.pr : '';
1882
+ events.push({
1883
+ kind: ok ? 'completion' : 'failure',
1884
+ kindLabel: ok ? 'Completion' : 'Failure',
1885
+ title: who + ' — ' + what,
1886
+ meta: (ok ? 'finished ' : 'failed ') + relTime(ts) + prSuffix +
1887
+ (c.summary ? ' · ' + String(c.summary).slice(0, 90) : ''),
1888
+ ts: ts ? Date.parse(ts) || 0 : 0,
1889
+ });
1890
+ });
1891
+
1892
+ // PR events (amber) — shows PRs we're tracking, anchored on updatedAt.
1893
+ // `metaUrl` carries the optional "open PR" link so the renderer can build
1894
+ // it as a real <a> node instead of splicing raw HTML into innerHTML.
1895
+ (input.prs || []).slice(0, 6).forEach(function(p) {
1896
+ var ts = p.updatedAt || p.createdAt;
1897
+ events.push({
1898
+ kind: 'pr',
1899
+ kindLabel: 'PR',
1900
+ title: '#' + (p.prNumber || p.id || '?') + ' — ' + (p.title || '(untitled PR)'),
1901
+ meta: (p.buildStatus || 'no-build') + ' · ' + (p.reviewStatus || 'no-review'),
1902
+ metaUrl: p.url || null,
1903
+ ts: ts ? Date.parse(ts) || 0 : 0,
1904
+ });
1905
+ });
1906
+
1907
+ events.sort(function(a, b) { return (b.ts || 0) - (a.ts || 0); });
1908
+ events = events.slice(0, 20);
1909
+
1910
+ if (events.length === 0) {
1911
+ listEl.innerHTML = '<div class="history-empty">Nothing has happened yet — Command Center will fill this in.</div>';
1912
+ return;
1913
+ }
1914
+ // Build the feed as DOM nodes so SEC-03's .innerHTML ratchet stays happy
1915
+ // (test/unit.test.js DYNAMIC_INNERHTML_BASELINE). Each event becomes
1916
+ // .history-item > .history-head + .history-title + .history-meta, with
1917
+ // the optional PR "open" link constructed as a real <a> node.
1918
+ var frag = document.createDocumentFragment();
1919
+ for (var i = 0; i < events.length; i++) {
1920
+ var e = events[i];
1921
+ var item = document.createElement('div');
1922
+ item.className = 'history-item kind-' + e.kind;
1923
+ var head = document.createElement('div');
1924
+ head.className = 'history-head';
1925
+ var kind = document.createElement('span');
1926
+ kind.className = 'history-kind';
1927
+ kind.textContent = e.kindLabel;
1928
+ var when = document.createElement('span');
1929
+ when.className = 'history-time';
1930
+ when.textContent = e.ts ? relTime(e.ts) : '';
1931
+ head.appendChild(kind);
1932
+ head.appendChild(when);
1933
+ var titleEl = document.createElement('div');
1934
+ titleEl.className = 'history-title';
1935
+ titleEl.textContent = e.title;
1936
+ var metaEl = document.createElement('div');
1937
+ metaEl.className = 'history-meta';
1938
+ metaEl.appendChild(document.createTextNode(e.meta || ''));
1939
+ if (e.metaUrl) {
1940
+ metaEl.appendChild(document.createTextNode(' · '));
1941
+ var a = document.createElement('a');
1942
+ a.href = e.metaUrl;
1943
+ a.target = '_blank';
1944
+ a.rel = 'noopener';
1945
+ a.textContent = 'open';
1946
+ metaEl.appendChild(a);
1947
+ }
1948
+ item.appendChild(head);
1949
+ item.appendChild(titleEl);
1950
+ item.appendChild(metaEl);
1951
+ frag.appendChild(item);
1952
+ }
1953
+ listEl.replaceChildren(frag);
1954
+ }
1955
+
1956
+ async function pollStatusOnce() {
1957
+ try {
1958
+ var res = await fetch('/api/status', { headers: { 'Accept': 'application/json' } });
1959
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1960
+ var data = await res.json();
1961
+ applyStatus(data);
1962
+ } catch (e) {
1963
+ // Soft failure — leave previous values, surface in stamp
1964
+ var stamp = document.getElementById('cockpit-stamp');
1965
+ if (stamp) stamp.textContent = 'status fetch failed: ' + (e.message || e);
1966
+ }
1967
+ }
1968
+
1969
+ function startStatusPolling() {
1970
+ pollStatusOnce();
1971
+ if (pollTimer) clearInterval(pollTimer);
1972
+ pollTimer = setInterval(pollStatusOnce, STATUS_POLL_MS);
1973
+ }
1974
+ // Pause polling when the tab is hidden; resume when it's visible again.
1975
+ // Saves us 12 fetches/min on background tabs.
1976
+ document.addEventListener('visibilitychange', function() {
1977
+ if (document.hidden) {
1978
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
1979
+ } else {
1980
+ startStatusPolling();
1981
+ }
1982
+ });
1983
+ startStatusPolling();
1984
+ })();
1985
+ </script>
1986
+ </body>
1987
+ </html>