ralphflow 0.5.0 → 0.5.2

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.
@@ -4,1203 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>RalphFlow Dashboard</title>
7
- <style>
8
- :root {
9
- --bg: #0d1117;
10
- --bg-surface: #161b22;
11
- --bg-hover: #1c2128;
12
- --bg-active: #21262d;
13
- --border: #30363d;
14
- --text: #e6edf3;
15
- --text-dim: #8b949e;
16
- --text-muted: #484f58;
17
- --accent: #58a6ff;
18
- --green: #3fb950;
19
- --blue: #58a6ff;
20
- --yellow: #d29922;
21
- --red: #f85149;
22
- --purple: #bc8cff;
23
- --mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
24
- --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
25
- --radius: 6px;
26
- }
27
-
28
- * { margin: 0; padding: 0; box-sizing: border-box; }
29
-
30
- body {
31
- font-family: var(--sans);
32
- background: var(--bg);
33
- color: var(--text);
34
- height: 100vh;
35
- display: flex;
36
- flex-direction: column;
37
- overflow: hidden;
38
- }
39
-
40
- /* Header */
41
- .header {
42
- display: flex;
43
- align-items: center;
44
- justify-content: space-between;
45
- padding: 12px 20px;
46
- border-bottom: 1px solid var(--border);
47
- background: var(--bg-surface);
48
- flex-shrink: 0;
49
- }
50
- .header h1 {
51
- font-size: 15px;
52
- font-weight: 600;
53
- letter-spacing: -0.3px;
54
- }
55
- .header .host {
56
- font-family: var(--mono);
57
- font-size: 12px;
58
- color: var(--text-dim);
59
- }
60
-
61
- /* Main layout */
62
- .main {
63
- display: flex;
64
- flex: 1;
65
- overflow: hidden;
66
- }
67
-
68
- /* Sidebar */
69
- .sidebar {
70
- width: 240px;
71
- border-right: 1px solid var(--border);
72
- background: var(--bg-surface);
73
- overflow-y: auto;
74
- flex-shrink: 0;
75
- }
76
- .sidebar-section {
77
- padding: 12px 0;
78
- }
79
- .sidebar-label {
80
- padding: 0 16px;
81
- font-size: 11px;
82
- font-weight: 600;
83
- text-transform: uppercase;
84
- letter-spacing: 0.5px;
85
- color: var(--text-dim);
86
- margin-bottom: 4px;
87
- }
88
- .sidebar-item {
89
- display: block;
90
- padding: 6px 16px;
91
- font-size: 13px;
92
- color: var(--text-dim);
93
- cursor: pointer;
94
- border-left: 2px solid transparent;
95
- transition: background 0.1s;
96
- }
97
- .sidebar-item:hover { background: var(--bg-hover); color: var(--text); }
98
- .sidebar-item.active {
99
- background: var(--bg-active);
100
- color: var(--text);
101
- border-left-color: var(--accent);
102
- }
103
- .sidebar-item.app-item {
104
- font-weight: 600;
105
- color: var(--text);
106
- padding: 8px 16px;
107
- }
108
- .sidebar-item.loop-item { padding-left: 32px; font-size: 12px; }
109
- .sidebar-item .badge {
110
- font-size: 10px;
111
- font-family: var(--mono);
112
- padding: 1px 6px;
113
- border-radius: 10px;
114
- margin-left: 6px;
115
- background: var(--bg-hover);
116
- color: var(--text-dim);
117
- }
118
-
119
- /* Content */
120
- .content {
121
- flex: 1;
122
- overflow-y: auto;
123
- padding: 24px 32px;
124
- }
125
- .content-empty {
126
- display: flex;
127
- align-items: center;
128
- justify-content: center;
129
- height: 100%;
130
- color: var(--text-muted);
131
- font-size: 14px;
132
- }
133
-
134
- /* App header */
135
- .app-header { margin-bottom: 24px; }
136
- .app-header h2 {
137
- font-size: 20px;
138
- font-weight: 600;
139
- margin-bottom: 4px;
140
- }
141
- .app-type-badge {
142
- display: inline-block;
143
- font-family: var(--mono);
144
- font-size: 11px;
145
- color: var(--purple);
146
- background: rgba(188, 140, 255, 0.1);
147
- padding: 2px 8px;
148
- border-radius: 4px;
149
- margin-right: 8px;
150
- }
151
- .app-desc {
152
- color: var(--text-dim);
153
- font-size: 13px;
154
- margin-top: 6px;
155
- }
156
-
157
- /* Pipeline view */
158
- .pipeline {
159
- display: flex;
160
- align-items: center;
161
- gap: 0;
162
- margin-bottom: 28px;
163
- padding: 16px 0;
164
- }
165
- .pipeline-node {
166
- display: flex;
167
- flex-direction: column;
168
- align-items: center;
169
- gap: 6px;
170
- cursor: pointer;
171
- padding: 12px 20px;
172
- border-radius: var(--radius);
173
- border: 1px solid var(--border);
174
- background: var(--bg-surface);
175
- transition: border-color 0.15s, background 0.15s;
176
- min-width: 120px;
177
- }
178
- .pipeline-node:hover { border-color: var(--text-dim); }
179
- .pipeline-node.selected { border-color: var(--accent); background: rgba(88, 166, 255, 0.05); }
180
- .pipeline-node .node-name { font-size: 12px; font-weight: 600; }
181
- .pipeline-node .node-status {
182
- font-family: var(--mono);
183
- font-size: 10px;
184
- padding: 2px 8px;
185
- border-radius: 10px;
186
- }
187
- .pipeline-node .node-status.complete { background: rgba(63,185,80,0.15); color: var(--green); }
188
- .pipeline-node .node-status.running { background: rgba(88,166,255,0.15); color: var(--blue); }
189
- .pipeline-node .node-status.pending { background: rgba(139,148,158,0.1); color: var(--text-muted); }
190
- .pipeline-connector {
191
- width: 32px;
192
- height: 2px;
193
- background: var(--border);
194
- flex-shrink: 0;
195
- }
196
-
197
- /* Section */
198
- .section {
199
- margin-bottom: 24px;
200
- }
201
- .section-title {
202
- font-size: 11px;
203
- font-weight: 600;
204
- text-transform: uppercase;
205
- letter-spacing: 0.5px;
206
- color: var(--text-dim);
207
- margin-bottom: 12px;
208
- }
209
-
210
- /* Loop detail */
211
- .loop-meta {
212
- display: grid;
213
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
214
- gap: 12px;
215
- margin-bottom: 16px;
216
- }
217
- .meta-card {
218
- padding: 12px;
219
- background: var(--bg-surface);
220
- border: 1px solid var(--border);
221
- border-radius: var(--radius);
222
- }
223
- .meta-label {
224
- font-size: 11px;
225
- color: var(--text-dim);
226
- margin-bottom: 4px;
227
- }
228
- .meta-value {
229
- font-family: var(--mono);
230
- font-size: 13px;
231
- }
232
-
233
- /* Progress bar */
234
- .progress-bar {
235
- height: 6px;
236
- background: var(--bg-active);
237
- border-radius: 3px;
238
- overflow: hidden;
239
- margin-top: 8px;
240
- }
241
- .progress-fill {
242
- height: 100%;
243
- background: var(--green);
244
- border-radius: 3px;
245
- transition: width 0.3s ease;
246
- }
247
-
248
- /* Agent table */
249
- .agent-table {
250
- width: 100%;
251
- border-collapse: collapse;
252
- font-size: 12px;
253
- font-family: var(--mono);
254
- }
255
- .agent-table th {
256
- text-align: left;
257
- padding: 8px 12px;
258
- border-bottom: 1px solid var(--border);
259
- color: var(--text-dim);
260
- font-weight: 500;
261
- font-size: 11px;
262
- }
263
- .agent-table td {
264
- padding: 8px 12px;
265
- border-bottom: 1px solid var(--border);
266
- color: var(--text);
267
- }
268
- .agent-table tr:hover td { background: var(--bg-hover); }
269
-
270
- /* Editor */
271
- .editor-wrap {
272
- position: relative;
273
- }
274
- .editor {
275
- width: 100%;
276
- min-height: 300px;
277
- background: var(--bg-surface);
278
- border: 1px solid var(--border);
279
- border-radius: var(--radius);
280
- color: var(--text);
281
- font-family: var(--mono);
282
- font-size: 13px;
283
- line-height: 1.6;
284
- padding: 16px;
285
- resize: vertical;
286
- outline: none;
287
- tab-size: 2;
288
- }
289
- .editor:focus { border-color: var(--accent); }
290
- .editor-actions {
291
- display: flex;
292
- gap: 8px;
293
- margin-top: 8px;
294
- align-items: center;
295
- }
296
- .btn {
297
- font-family: var(--sans);
298
- font-size: 12px;
299
- font-weight: 500;
300
- padding: 6px 14px;
301
- border-radius: var(--radius);
302
- border: 1px solid var(--border);
303
- cursor: pointer;
304
- transition: background 0.1s, border-color 0.1s;
305
- background: var(--bg-surface);
306
- color: var(--text);
307
- }
308
- .btn:hover { background: var(--bg-hover); border-color: var(--text-dim); }
309
- .btn-primary {
310
- background: var(--accent);
311
- color: #000;
312
- border-color: var(--accent);
313
- }
314
- .btn-primary:hover { background: #79c0ff; }
315
- .btn-danger {
316
- background: transparent;
317
- color: var(--red);
318
- border-color: var(--red);
319
- }
320
- .btn-danger:hover { background: rgba(248, 81, 73, 0.15); }
321
- .btn-muted {
322
- background: transparent;
323
- color: var(--text-dim);
324
- border-color: var(--border);
325
- }
326
- .btn-muted:hover { background: var(--bg-hover); color: var(--text); }
327
- .btn:disabled { opacity: 0.5; cursor: default; }
328
- .dirty-indicator {
329
- font-size: 11px;
330
- color: var(--yellow);
331
- }
332
- .save-ok {
333
- font-size: 11px;
334
- color: var(--green);
335
- }
336
-
337
- /* Three-panel layout */
338
- .panel-grid {
339
- display: grid;
340
- grid-template-columns: 40fr 60fr;
341
- gap: 16px;
342
- min-height: 0;
343
- }
344
- .panel-col-left {
345
- display: flex;
346
- flex-direction: column;
347
- gap: 16px;
348
- min-height: 0;
349
- }
350
- .panel {
351
- background: var(--bg-surface);
352
- border: 1px solid var(--border);
353
- border-radius: var(--radius);
354
- display: flex;
355
- flex-direction: column;
356
- }
357
- .panel-header {
358
- padding: 10px 16px;
359
- font-size: 11px;
360
- font-weight: 600;
361
- text-transform: uppercase;
362
- letter-spacing: 0.5px;
363
- color: var(--text-dim);
364
- border-bottom: 1px solid var(--border);
365
- flex-shrink: 0;
366
- }
367
- .panel-body {
368
- padding: 16px;
369
- overflow-y: auto;
370
- flex: 1;
371
- min-height: 0;
372
- }
373
- .panel-interactive {
374
- flex-shrink: 0;
375
- }
376
- .panel-interactive .panel-body {
377
- display: flex;
378
- align-items: center;
379
- gap: 8px;
380
- padding: 12px 16px;
381
- color: var(--text-muted);
382
- font-size: 12px;
383
- }
384
- .panel-interactive .bell-icon {
385
- font-size: 14px;
386
- opacity: 0.5;
387
- }
388
- .panel-progress {
389
- flex: 1;
390
- min-height: 0;
391
- }
392
- .panel-edit {
393
- min-height: 0;
394
- max-height: calc(100vh - 200px);
395
- }
396
- .panel-edit .panel-body {
397
- display: flex;
398
- flex-direction: column;
399
- }
400
- .panel-edit .editor {
401
- flex: 1;
402
- min-height: 60vh;
403
- }
404
-
405
- /* Edit tabs */
406
- .edit-tabs {
407
- display: flex;
408
- border-bottom: 1px solid var(--border);
409
- flex-shrink: 0;
410
- }
411
- .edit-tab {
412
- padding: 10px 16px;
413
- font-family: var(--sans);
414
- font-size: 12px;
415
- font-weight: 500;
416
- color: var(--text-dim);
417
- background: none;
418
- border: none;
419
- border-bottom: 2px solid transparent;
420
- cursor: pointer;
421
- transition: color 0.1s, border-color 0.1s;
422
- }
423
- .edit-tab:hover { color: var(--text); }
424
- .edit-tab.active {
425
- color: var(--text);
426
- border-bottom-color: var(--accent);
427
- }
428
-
429
- /* Model selector */
430
- .model-selector-wrap {
431
- display: flex;
432
- align-items: center;
433
- gap: 8px;
434
- margin-left: auto;
435
- padding: 4px 12px;
436
- }
437
- .model-selector-wrap label {
438
- font-size: 11px;
439
- color: var(--text-dim);
440
- white-space: nowrap;
441
- }
442
- .model-selector {
443
- font-family: var(--mono);
444
- font-size: 11px;
445
- color: var(--text);
446
- background: var(--bg);
447
- border: 1px solid var(--border);
448
- border-radius: var(--radius);
449
- padding: 4px 8px;
450
- cursor: pointer;
451
- outline: none;
452
- }
453
- .model-selector:hover { border-color: var(--text-dim); }
454
- .model-selector:focus { border-color: var(--accent); }
455
- .model-save-ok {
456
- font-size: 11px;
457
- color: var(--green);
458
- opacity: 0;
459
- transition: opacity 0.2s;
460
- }
461
- .model-save-ok.visible { opacity: 1; }
462
-
463
- /* Prompt mode toggle */
464
- .prompt-mode-toggle {
465
- display: flex;
466
- gap: 0;
467
- margin-bottom: 12px;
468
- border: 1px solid var(--border);
469
- border-radius: var(--radius);
470
- overflow: hidden;
471
- width: fit-content;
472
- }
473
- .prompt-mode-btn {
474
- padding: 6px 16px;
475
- font-family: var(--sans);
476
- font-size: 12px;
477
- font-weight: 500;
478
- color: var(--text-dim);
479
- background: var(--bg);
480
- border: none;
481
- cursor: pointer;
482
- transition: all 0.15s;
483
- }
484
- .prompt-mode-btn:not(:last-child) {
485
- border-right: 1px solid var(--border);
486
- }
487
- .prompt-mode-btn:hover { color: var(--text); background: var(--bg-hover); }
488
- .prompt-mode-btn.active {
489
- color: var(--text);
490
- background: var(--bg-active);
491
- }
492
-
493
- /* Markdown preview */
494
- .prompt-preview {
495
- flex: 1;
496
- min-height: 60vh;
497
- background: var(--bg-surface);
498
- border: 1px solid var(--border);
499
- border-radius: var(--radius);
500
- color: var(--text);
501
- font-family: var(--sans);
502
- font-size: 14px;
503
- line-height: 1.7;
504
- padding: 24px 28px;
505
- overflow: auto;
506
- }
507
- .prompt-preview h1 { font-size: 22px; font-weight: 600; margin: 24px 0 12px; color: var(--text); border-bottom: 1px solid var(--border); padding-bottom: 8px; }
508
- .prompt-preview h1:first-child { margin-top: 0; }
509
- .prompt-preview h2 { font-size: 18px; font-weight: 600; margin: 20px 0 10px; color: var(--text); }
510
- .prompt-preview h3 { font-size: 15px; font-weight: 600; margin: 16px 0 8px; color: var(--text); }
511
- .prompt-preview h4 { font-size: 13px; font-weight: 600; margin: 14px 0 6px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
512
- .prompt-preview p { margin: 0 0 12px; }
513
- .prompt-preview ul, .prompt-preview ol { margin: 0 0 12px; padding-left: 24px; }
514
- .prompt-preview li { margin: 4px 0; }
515
- .prompt-preview li.task-done { color: var(--text-dim); text-decoration: line-through; }
516
- .prompt-preview code {
517
- font-family: var(--mono);
518
- font-size: 12px;
519
- background: var(--bg);
520
- border: 1px solid var(--border);
521
- border-radius: 3px;
522
- padding: 2px 6px;
523
- }
524
- .prompt-preview pre {
525
- background: var(--bg);
526
- border: 1px solid var(--border);
527
- border-radius: var(--radius);
528
- padding: 14px 16px;
529
- margin: 0 0 12px;
530
- overflow-x: auto;
531
- }
532
- .prompt-preview pre code {
533
- background: none;
534
- border: none;
535
- padding: 0;
536
- font-size: 13px;
537
- line-height: 1.5;
538
- }
539
- .prompt-preview blockquote {
540
- border-left: 3px solid var(--accent);
541
- padding: 4px 16px;
542
- margin: 0 0 12px;
543
- color: var(--text-dim);
544
- background: var(--bg);
545
- border-radius: 0 var(--radius) var(--radius) 0;
546
- }
547
- .prompt-preview hr {
548
- border: none;
549
- border-top: 1px solid var(--border);
550
- margin: 16px 0;
551
- }
552
- .prompt-preview strong { font-weight: 600; color: var(--text); }
553
- .prompt-preview em { font-style: italic; }
554
- .prompt-preview table {
555
- width: 100%;
556
- border-collapse: collapse;
557
- margin: 0 0 12px;
558
- font-size: 13px;
559
- }
560
- .prompt-preview th, .prompt-preview td {
561
- border: 1px solid var(--border);
562
- padding: 8px 12px;
563
- text-align: left;
564
- }
565
- .prompt-preview th {
566
- background: var(--bg);
567
- font-weight: 600;
568
- font-size: 12px;
569
- text-transform: uppercase;
570
- letter-spacing: 0.3px;
571
- color: var(--text-dim);
572
- }
573
- .prompt-preview a { color: var(--accent); text-decoration: none; }
574
- .prompt-preview a:hover { text-decoration: underline; }
575
-
576
- /* Read-only code viewer */
577
- .code-viewer {
578
- width: 100%;
579
- flex: 1;
580
- min-height: 200px;
581
- background: var(--bg);
582
- border: 1px solid var(--border);
583
- border-radius: var(--radius);
584
- color: var(--text);
585
- font-family: var(--mono);
586
- font-size: 13px;
587
- line-height: 1.6;
588
- padding: 16px;
589
- overflow: auto;
590
- white-space: pre-wrap;
591
- word-wrap: break-word;
592
- margin: 0;
593
- }
594
-
595
- /* Tracker viewer */
596
- .tracker-viewer {
597
- background: var(--bg-surface);
598
- border: 1px solid var(--border);
599
- border-radius: var(--radius);
600
- padding: 16px;
601
- font-family: var(--mono);
602
- font-size: 13px;
603
- line-height: 1.7;
604
- max-height: 400px;
605
- overflow-y: auto;
606
- white-space: pre-wrap;
607
- word-wrap: break-word;
608
- }
609
- .panel-progress .tracker-viewer {
610
- border: none;
611
- border-radius: 0;
612
- max-height: none;
613
- padding: 0;
614
- margin-top: 16px;
615
- }
616
- .tracker-viewer h1, .tracker-viewer h2, .tracker-viewer h3 {
617
- font-family: var(--sans);
618
- margin: 12px 0 6px;
619
- }
620
- .tracker-viewer h1 { font-size: 16px; }
621
- .tracker-viewer h2 { font-size: 14px; }
622
- .tracker-viewer h3 { font-size: 13px; }
623
- .tracker-viewer .cb-done { color: var(--green); }
624
- .tracker-viewer .cb-todo { color: var(--text-muted); }
625
- .tracker-viewer table {
626
- border-collapse: collapse;
627
- margin: 8px 0;
628
- width: 100%;
629
- }
630
- .tracker-viewer th, .tracker-viewer td {
631
- border: 1px solid var(--border);
632
- padding: 4px 10px;
633
- text-align: left;
634
- font-size: 12px;
635
- }
636
- .tracker-viewer th { background: var(--bg-active); color: var(--text-dim); }
637
-
638
- /* Status bar */
639
- .statusbar {
640
- display: flex;
641
- align-items: center;
642
- gap: 16px;
643
- padding: 6px 20px;
644
- border-top: 1px solid var(--border);
645
- background: var(--bg-surface);
646
- font-size: 11px;
647
- color: var(--text-dim);
648
- flex-shrink: 0;
649
- }
650
- .status-dot {
651
- display: inline-block;
652
- width: 7px;
653
- height: 7px;
654
- border-radius: 50%;
655
- margin-right: 4px;
656
- }
657
- .status-dot.connected { background: var(--green); }
658
- .status-dot.disconnected { background: var(--red); }
659
- .status-dot.connecting { background: var(--yellow); }
660
-
661
- /* New App button */
662
- .new-app-btn {
663
- display: flex;
664
- align-items: center;
665
- gap: 6px;
666
- width: calc(100% - 24px);
667
- margin: 8px 12px 4px;
668
- padding: 6px 12px;
669
- font-family: var(--sans);
670
- font-size: 12px;
671
- font-weight: 500;
672
- color: var(--text-dim);
673
- background: transparent;
674
- border: 1px dashed var(--border);
675
- border-radius: var(--radius);
676
- cursor: pointer;
677
- transition: background 0.1s, color 0.1s, border-color 0.1s;
678
- }
679
- .new-app-btn:hover {
680
- background: var(--bg-hover);
681
- color: var(--text);
682
- border-color: var(--text-dim);
683
- }
684
-
685
- /* Modal overlay */
686
- .modal-overlay {
687
- position: fixed;
688
- inset: 0;
689
- background: rgba(0, 0, 0, 0.6);
690
- display: flex;
691
- align-items: center;
692
- justify-content: center;
693
- z-index: 1000;
694
- }
695
- .modal {
696
- background: var(--bg-surface);
697
- border: 1px solid var(--border);
698
- border-radius: 8px;
699
- width: 420px;
700
- max-width: 90vw;
701
- box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
702
- }
703
- .modal-header {
704
- display: flex;
705
- align-items: center;
706
- justify-content: space-between;
707
- padding: 16px 20px;
708
- border-bottom: 1px solid var(--border);
709
- }
710
- .modal-header h3 {
711
- font-size: 14px;
712
- font-weight: 600;
713
- }
714
- .modal-close {
715
- background: none;
716
- border: none;
717
- color: var(--text-dim);
718
- font-size: 18px;
719
- cursor: pointer;
720
- padding: 0 4px;
721
- line-height: 1;
722
- }
723
- .modal-close:hover { color: var(--text); }
724
- .modal-body {
725
- padding: 20px;
726
- }
727
-
728
- /* Form elements */
729
- .form-group {
730
- margin-bottom: 16px;
731
- }
732
- .form-label {
733
- display: block;
734
- font-size: 12px;
735
- font-weight: 500;
736
- color: var(--text-dim);
737
- margin-bottom: 6px;
738
- }
739
- .form-select, .form-input {
740
- width: 100%;
741
- padding: 8px 12px;
742
- font-family: var(--sans);
743
- font-size: 13px;
744
- color: var(--text);
745
- background: var(--bg);
746
- border: 1px solid var(--border);
747
- border-radius: var(--radius);
748
- outline: none;
749
- }
750
- .form-select:focus, .form-input:focus {
751
- border-color: var(--accent);
752
- }
753
- .form-select option {
754
- background: var(--bg);
755
- color: var(--text);
756
- }
757
- .form-error {
758
- font-size: 12px;
759
- color: var(--red);
760
- margin-top: 12px;
761
- }
762
- .form-warning {
763
- font-size: 12px;
764
- color: var(--yellow);
765
- margin-top: 12px;
766
- }
767
-
768
- /* Next-steps view */
769
- .next-steps-success {
770
- display: flex;
771
- align-items: center;
772
- gap: 8px;
773
- font-size: 13px;
774
- color: var(--green);
775
- margin-bottom: 16px;
776
- }
777
- .next-steps-label {
778
- font-size: 12px;
779
- color: var(--text-dim);
780
- margin-bottom: 8px;
781
- }
782
- .cmd-item {
783
- display: flex;
784
- align-items: center;
785
- gap: 8px;
786
- margin-bottom: 8px;
787
- background: var(--bg);
788
- border: 1px solid var(--border);
789
- border-radius: var(--radius);
790
- padding: 8px 12px;
791
- font-family: var(--mono);
792
- font-size: 12px;
793
- color: var(--text);
794
- }
795
- .cmd-text {
796
- flex: 1;
797
- overflow-x: auto;
798
- white-space: nowrap;
799
- }
800
- .cmd-copy {
801
- background: none;
802
- border: 1px solid var(--border);
803
- border-radius: 4px;
804
- color: var(--text-dim);
805
- font-family: var(--sans);
806
- font-size: 11px;
807
- padding: 2px 8px;
808
- cursor: pointer;
809
- flex-shrink: 0;
810
- transition: background 0.1s, color 0.1s;
811
- }
812
- .cmd-copy:hover { background: var(--bg-hover); color: var(--text); }
813
-
814
- .modal-footer {
815
- padding: 12px 20px;
816
- border-top: 1px solid var(--border);
817
- display: flex;
818
- justify-content: flex-end;
819
- gap: 8px;
820
- }
821
-
822
- /* Notification list in interactive panel */
823
- .panel-interactive.has-notifs .panel-body {
824
- display: block;
825
- padding: 8px;
826
- max-height: 240px;
827
- overflow-y: auto;
828
- color: var(--text);
829
- }
830
- .notif-card {
831
- display: flex;
832
- align-items: flex-start;
833
- gap: 8px;
834
- padding: 8px 10px;
835
- background: var(--bg);
836
- border: 1px solid var(--border);
837
- border-radius: var(--radius);
838
- margin-bottom: 6px;
839
- font-size: 12px;
840
- }
841
- .notif-card:last-child { margin-bottom: 0; }
842
- .notif-time {
843
- color: var(--text-dim);
844
- font-family: var(--mono);
845
- font-size: 11px;
846
- flex-shrink: 0;
847
- white-space: nowrap;
848
- }
849
- .notif-msg {
850
- flex: 1;
851
- color: var(--text);
852
- word-break: break-word;
853
- }
854
- .notif-dismiss {
855
- background: none;
856
- border: none;
857
- color: var(--text-muted);
858
- font-size: 16px;
859
- cursor: pointer;
860
- padding: 0 2px;
861
- line-height: 1;
862
- flex-shrink: 0;
863
- }
864
- .notif-dismiss:hover { color: var(--red); }
865
-
866
- /* Sidebar notification badge */
867
- .notif-badge {
868
- font-size: 10px;
869
- font-family: var(--mono);
870
- padding: 1px 6px;
871
- border-radius: 10px;
872
- margin-left: 6px;
873
- background: var(--accent);
874
- color: #000;
875
- font-weight: 600;
876
- }
877
-
878
- /* App-level tabs (Loops / Archives) */
879
- .app-tabs {
880
- display: flex;
881
- gap: 0;
882
- border-bottom: 1px solid var(--border);
883
- margin-bottom: 24px;
884
- }
885
- .app-tab {
886
- padding: 10px 20px;
887
- font-family: var(--sans);
888
- font-size: 13px;
889
- font-weight: 500;
890
- color: var(--text-dim);
891
- background: none;
892
- border: none;
893
- border-bottom: 2px solid transparent;
894
- cursor: pointer;
895
- transition: color 0.1s, border-color 0.1s;
896
- }
897
- .app-tab:hover { color: var(--text); }
898
- .app-tab.active {
899
- color: var(--text);
900
- border-bottom-color: var(--accent);
901
- }
902
-
903
- /* Archive timeline */
904
- .archive-timeline {
905
- display: flex;
906
- flex-direction: column;
907
- gap: 12px;
908
- }
909
- .archive-empty {
910
- text-align: center;
911
- padding: 48px 24px;
912
- color: var(--text-muted);
913
- font-size: 14px;
914
- }
915
- .archive-empty-icon {
916
- font-size: 32px;
917
- margin-bottom: 12px;
918
- opacity: 0.4;
919
- }
920
- .archive-card {
921
- background: var(--bg-surface);
922
- border: 1px solid var(--border);
923
- border-radius: var(--radius);
924
- overflow: hidden;
925
- transition: border-color 0.15s;
926
- }
927
- .archive-card:hover { border-color: var(--text-dim); }
928
- .archive-card.expanded { border-color: var(--accent); }
929
- .archive-card-header {
930
- display: flex;
931
- align-items: center;
932
- justify-content: space-between;
933
- padding: 14px 16px;
934
- cursor: pointer;
935
- transition: background 0.1s;
936
- }
937
- .archive-card-header:hover { background: var(--bg-hover); }
938
- .archive-card-date {
939
- font-family: var(--mono);
940
- font-size: 13px;
941
- font-weight: 600;
942
- color: var(--text);
943
- }
944
- .archive-card-stats {
945
- display: flex;
946
- gap: 12px;
947
- font-size: 12px;
948
- color: var(--text-dim);
949
- }
950
- .archive-card-stat {
951
- display: flex;
952
- align-items: center;
953
- gap: 4px;
954
- }
955
- .archive-card-stat .stat-val {
956
- font-family: var(--mono);
957
- color: var(--text);
958
- }
959
- .archive-card-chevron {
960
- font-size: 12px;
961
- color: var(--text-muted);
962
- transition: transform 0.2s;
963
- margin-left: 12px;
964
- }
965
- .archive-card.expanded .archive-card-chevron {
966
- transform: rotate(90deg);
967
- }
968
-
969
- /* Archive file browser */
970
- .archive-files {
971
- border-top: 1px solid var(--border);
972
- max-height: 400px;
973
- overflow-y: auto;
974
- }
975
- .archive-file-item {
976
- display: flex;
977
- align-items: center;
978
- gap: 8px;
979
- padding: 8px 16px;
980
- font-family: var(--mono);
981
- font-size: 12px;
982
- color: var(--text-dim);
983
- cursor: pointer;
984
- transition: background 0.1s;
985
- border-bottom: 1px solid var(--border);
986
- }
987
- .archive-file-item:last-child { border-bottom: none; }
988
- .archive-file-item:hover { background: var(--bg-hover); color: var(--text); }
989
- .archive-file-item.active { background: var(--bg-active); color: var(--accent); }
990
- .archive-file-icon { opacity: 0.5; font-size: 11px; }
991
-
992
- /* Archive file viewer (inline) */
993
- .archive-file-viewer {
994
- border-top: 1px solid var(--border);
995
- display: flex;
996
- flex-direction: column;
997
- max-height: 500px;
998
- }
999
- .archive-file-viewer-header {
1000
- display: flex;
1001
- align-items: center;
1002
- justify-content: space-between;
1003
- padding: 8px 16px;
1004
- background: var(--bg-active);
1005
- font-family: var(--mono);
1006
- font-size: 12px;
1007
- color: var(--text-dim);
1008
- flex-shrink: 0;
1009
- }
1010
- .archive-file-viewer-close {
1011
- background: none;
1012
- border: none;
1013
- color: var(--text-muted);
1014
- font-size: 16px;
1015
- cursor: pointer;
1016
- padding: 0 4px;
1017
- line-height: 1;
1018
- }
1019
- .archive-file-viewer-close:hover { color: var(--text); }
1020
- .archive-file-content {
1021
- flex: 1;
1022
- overflow: auto;
1023
- padding: 16px;
1024
- font-family: var(--mono);
1025
- font-size: 13px;
1026
- line-height: 1.6;
1027
- white-space: pre-wrap;
1028
- word-wrap: break-word;
1029
- background: var(--bg);
1030
- color: var(--text);
1031
- }
1032
-
1033
- /* Templates page */
1034
- .templates-header {
1035
- display: flex;
1036
- align-items: center;
1037
- justify-content: space-between;
1038
- margin-bottom: 24px;
1039
- }
1040
- .templates-header h2 { font-size: 20px; font-weight: 600; }
1041
- .template-grid {
1042
- display: grid;
1043
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1044
- gap: 12px;
1045
- }
1046
- .template-card {
1047
- background: var(--bg-surface);
1048
- border: 1px solid var(--border);
1049
- border-radius: var(--radius);
1050
- padding: 16px;
1051
- transition: border-color 0.15s;
1052
- }
1053
- .template-card:hover { border-color: var(--text-dim); }
1054
- .template-card-header {
1055
- display: flex;
1056
- align-items: center;
1057
- justify-content: space-between;
1058
- margin-bottom: 8px;
1059
- }
1060
- .template-card-name { font-size: 14px; font-weight: 600; }
1061
- .template-card-type {
1062
- font-family: var(--mono);
1063
- font-size: 10px;
1064
- padding: 2px 8px;
1065
- border-radius: 10px;
1066
- }
1067
- .template-card-type.built-in { background: rgba(88,166,255,0.1); color: var(--blue); }
1068
- .template-card-type.custom { background: rgba(63,185,80,0.1); color: var(--green); }
1069
- .template-card-desc { font-size: 12px; color: var(--text-dim); margin-bottom: 8px; }
1070
- .template-card-meta {
1071
- display: flex;
1072
- align-items: center;
1073
- justify-content: space-between;
1074
- font-size: 11px;
1075
- color: var(--text-muted);
1076
- }
1077
-
1078
- /* Template builder */
1079
- .template-builder { max-width: 800px; }
1080
- .builder-section { margin-bottom: 24px; }
1081
- .builder-section-title {
1082
- font-size: 13px;
1083
- font-weight: 600;
1084
- margin-bottom: 12px;
1085
- color: var(--text);
1086
- }
1087
- .loop-cards { display: flex; flex-direction: column; gap: 12px; }
1088
- .loop-card {
1089
- background: var(--bg-surface);
1090
- border: 1px solid var(--border);
1091
- border-radius: var(--radius);
1092
- padding: 16px;
1093
- }
1094
- .loop-card-header {
1095
- display: flex;
1096
- align-items: center;
1097
- justify-content: space-between;
1098
- margin-bottom: 12px;
1099
- }
1100
- .loop-card-title { font-size: 13px; font-weight: 600; }
1101
- .loop-card-remove {
1102
- background: none;
1103
- border: none;
1104
- color: var(--text-muted);
1105
- font-size: 16px;
1106
- cursor: pointer;
1107
- padding: 0 4px;
1108
- line-height: 1;
1109
- }
1110
- .loop-card-remove:hover { color: var(--red); }
1111
- .loop-card-grid {
1112
- display: grid;
1113
- grid-template-columns: 1fr 1fr;
1114
- gap: 12px;
1115
- }
1116
- .loop-card-grid .form-group { margin-bottom: 0; }
1117
- .loop-card-full { grid-column: 1 / -1; }
1118
- .stage-tags {
1119
- display: flex;
1120
- flex-wrap: wrap;
1121
- gap: 6px;
1122
- padding: 6px 8px;
1123
- background: var(--bg);
1124
- border: 1px solid var(--border);
1125
- border-radius: var(--radius);
1126
- min-height: 36px;
1127
- align-items: center;
1128
- cursor: text;
1129
- }
1130
- .stage-tags:focus-within { border-color: var(--accent); }
1131
- .stage-tag {
1132
- display: flex;
1133
- align-items: center;
1134
- gap: 4px;
1135
- font-family: var(--mono);
1136
- font-size: 11px;
1137
- padding: 2px 8px;
1138
- background: rgba(88,166,255,0.1);
1139
- color: var(--accent);
1140
- border-radius: 4px;
1141
- }
1142
- .stage-tag-remove {
1143
- background: none;
1144
- border: none;
1145
- color: var(--text-muted);
1146
- font-size: 14px;
1147
- cursor: pointer;
1148
- padding: 0 2px;
1149
- line-height: 1;
1150
- }
1151
- .stage-tag-remove:hover { color: var(--red); }
1152
- .stage-tags input {
1153
- border: none;
1154
- background: none;
1155
- color: var(--text);
1156
- font-family: var(--sans);
1157
- font-size: 13px;
1158
- outline: none;
1159
- min-width: 80px;
1160
- flex: 1;
1161
- }
1162
- .multi-agent-fields {
1163
- margin-top: 8px;
1164
- padding: 12px;
1165
- background: var(--bg);
1166
- border: 1px solid var(--border);
1167
- border-radius: var(--radius);
1168
- }
1169
- .toggle-wrap { display: flex; align-items: center; gap: 8px; }
1170
- .toggle-input { accent-color: var(--accent); }
1171
- .toggle-label { font-size: 12px; color: var(--text-dim); }
1172
- .yaml-preview-section { margin-top: 24px; }
1173
- .yaml-preview {
1174
- background: var(--bg);
1175
- border: 1px solid var(--border);
1176
- border-radius: var(--radius);
1177
- padding: 16px;
1178
- font-family: var(--mono);
1179
- font-size: 12px;
1180
- line-height: 1.6;
1181
- color: var(--text);
1182
- white-space: pre-wrap;
1183
- word-wrap: break-word;
1184
- max-height: 400px;
1185
- overflow-y: auto;
1186
- }
1187
- .builder-actions { display: flex; gap: 8px; margin-top: 20px; }
1188
- .optional-toggle {
1189
- font-size: 12px;
1190
- color: var(--accent);
1191
- background: none;
1192
- border: none;
1193
- cursor: pointer;
1194
- padding: 0;
1195
- margin-top: 8px;
1196
- }
1197
- .optional-toggle:hover { text-decoration: underline; }
1198
- .optional-fields {
1199
- margin-top: 12px;
1200
- padding-top: 12px;
1201
- border-top: 1px solid var(--border);
1202
- }
1203
- </style>
7
+ <link rel="stylesheet" href="styles.css">
1204
8
  </head>
1205
9
  <body>
1206
10
 
@@ -1231,2018 +35,6 @@
1231
35
  <span>Events: <span id="eventCount">0</span></span>
1232
36
  </div>
1233
37
 
1234
- <script>
1235
- (function() {
1236
- // State
1237
- let apps = [];
1238
- let selectedApp = null;
1239
- let selectedLoop = null;
1240
- let eventCounter = 0;
1241
- let promptDirty = false;
1242
- let promptOriginal = '';
1243
- let ws = null;
1244
- let reconnectDelay = 1000;
1245
- let activeEditTab = 'prompt';
1246
- let promptViewMode = 'read';
1247
- let cachedPromptValue = null;
1248
- let notificationsList = [];
1249
- let notifPermissionRequested = false;
1250
- let audioCtx = null;
1251
- let audioCtxInitialized = false;
1252
- let activeAppTab = 'loops';
1253
- let archivesData = [];
1254
- let expandedArchive = null;
1255
- let archiveFilesCache = {};
1256
- let viewingArchiveFile = null;
1257
- let currentPage = 'app';
1258
- let templatesList = [];
1259
- let showTemplateBuilder = false;
1260
- let templateBuilderState = null;
1261
-
1262
- // DOM refs
1263
- const $ = (sel) => document.querySelector(sel);
1264
- const hostDisplay = $('#hostDisplay');
1265
- const sidebarApps = $('#sidebarApps');
1266
- const content = $('#content');
1267
- const statusDot = $('#statusDot');
1268
- const statusText = $('#statusText');
1269
- const lastUpdate = $('#lastUpdate');
1270
- const eventCountEl = $('#eventCount');
1271
-
1272
- hostDisplay.textContent = location.host;
1273
-
1274
- // Fetch project context for header display
1275
- fetch('/api/context')
1276
- .then(r => r.json())
1277
- .then(ctx => {
1278
- hostDisplay.textContent = ctx.projectName + ' :' + ctx.port;
1279
- })
1280
- .catch(() => { /* keep location.host as fallback */ });
1281
-
1282
- // WebSocket
1283
- function connectWs() {
1284
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1285
- ws = new WebSocket(`${proto}//${location.host}/ws`);
1286
-
1287
- ws.onopen = () => {
1288
- statusDot.className = 'status-dot connected';
1289
- statusText.textContent = 'Connected';
1290
- reconnectDelay = 1000;
1291
- };
1292
-
1293
- ws.onclose = () => {
1294
- statusDot.className = 'status-dot disconnected';
1295
- statusText.textContent = 'Disconnected';
1296
- setTimeout(connectWs, reconnectDelay);
1297
- reconnectDelay = Math.min(reconnectDelay * 2, 30000);
1298
- };
1299
-
1300
- ws.onerror = () => {
1301
- ws.close();
1302
- };
1303
-
1304
- ws.onmessage = (e) => {
1305
- const event = JSON.parse(e.data);
1306
- eventCounter++;
1307
- eventCountEl.textContent = eventCounter;
1308
- lastUpdate.textContent = new Date().toLocaleTimeString();
1309
- handleWsEvent(event);
1310
- };
1311
- }
1312
-
1313
- function handleWsEvent(event) {
1314
- if (event.type === 'status:full') {
1315
- apps = event.apps;
1316
- renderSidebar();
1317
- if (selectedApp) {
1318
- const updated = apps.find(a => a.appName === selectedApp.appName);
1319
- if (updated) {
1320
- selectedApp = updated;
1321
- renderContent();
1322
- }
1323
- }
1324
- } else if (event.type === 'tracker:updated') {
1325
- if (selectedApp && selectedApp.appName === event.app) {
1326
- // Update the loop status in our local state
1327
- const loopEntry = selectedApp.loops.find(l => l.key === event.loop);
1328
- if (loopEntry) {
1329
- loopEntry.status = event.status;
1330
- }
1331
- renderContent();
1332
- // Refresh tracker viewer if this loop is selected
1333
- if (selectedLoop === event.loop) {
1334
- loadTracker(event.app, event.loop);
1335
- }
1336
- }
1337
- } else if (event.type === 'file:changed') {
1338
- if (selectedApp && selectedApp.appName === event.app) {
1339
- // Refresh status
1340
- fetchAppStatus(event.app);
1341
- }
1342
- } else if (event.type === 'notification:attention') {
1343
- const n = event.notification;
1344
- notificationsList.unshift(n);
1345
- renderSidebar();
1346
- renderContent();
1347
- maybeRequestNotifPermission();
1348
- showBrowserNotification(n);
1349
- playNotificationChime();
1350
- } else if (event.type === 'notification:dismissed') {
1351
- notificationsList = notificationsList.filter(n => n.id !== event.id);
1352
- renderSidebar();
1353
- renderContent();
1354
- }
1355
- }
1356
-
1357
- // API helpers
1358
- async function fetchJson(url) {
1359
- const res = await fetch(url);
1360
- return res.json();
1361
- }
1362
-
1363
- async function fetchApps() {
1364
- apps = await fetchJson('/api/apps');
1365
- renderSidebar();
1366
- if (apps.length > 0 && !selectedApp) {
1367
- selectApp(apps[0]);
1368
- }
1369
- }
1370
-
1371
- async function fetchAppStatus(appName) {
1372
- const statuses = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/status`);
1373
- if (selectedApp && selectedApp.appName === appName) {
1374
- statuses.forEach(s => {
1375
- const loop = selectedApp.loops.find(l => l.key === s.key);
1376
- if (loop) loop.status = s;
1377
- });
1378
- renderContent();
1379
- }
1380
- }
1381
-
1382
- // Sidebar
1383
- function renderSidebar() {
1384
- let html = '';
1385
- for (const app of apps) {
1386
- const appActive = selectedApp && selectedApp.appName === app.appName;
1387
- html += `<div class="sidebar-item app-item${appActive ? ' active' : ''}" data-app="${esc(app.appName)}">
1388
- ${esc(app.appName)}
1389
- <span class="badge">${esc(app.appType)}</span>
1390
- </div>`;
1391
- if (app.loops) {
1392
- for (const loop of app.loops) {
1393
- const loopActive = appActive && selectedLoop === loop.key;
1394
- const loopNotifCount = notificationsList.filter(n => n.app === app.appName && n.loop === loop.key).length;
1395
- const badgeHtml = loopNotifCount > 0 ? ` <span class="notif-badge">${loopNotifCount}</span>` : '';
1396
- html += `<div class="sidebar-item loop-item${loopActive ? ' active' : ''}" data-app="${esc(app.appName)}" data-loop="${esc(loop.key)}">
1397
- ${esc(loop.name)}${badgeHtml}
1398
- </div>`;
1399
- }
1400
- }
1401
- }
1402
- sidebarApps.innerHTML = html;
1403
-
1404
- // "+ New App" button
1405
- const newAppBtn = document.createElement('button');
1406
- newAppBtn.className = 'new-app-btn';
1407
- newAppBtn.innerHTML = '+ New App';
1408
- newAppBtn.addEventListener('click', openCreateAppModal);
1409
- sidebarApps.appendChild(newAppBtn);
1410
-
1411
- // Event delegation
1412
- sidebarApps.querySelectorAll('.app-item').forEach(el => {
1413
- el.addEventListener('click', () => {
1414
- const app = apps.find(a => a.appName === el.dataset.app);
1415
- if (app) selectApp(app);
1416
- });
1417
- });
1418
- sidebarApps.querySelectorAll('.loop-item').forEach(el => {
1419
- el.addEventListener('click', () => {
1420
- const app = apps.find(a => a.appName === el.dataset.app);
1421
- if (app) {
1422
- selectApp(app);
1423
- selectLoop(el.dataset.loop);
1424
- }
1425
- });
1426
- });
1427
-
1428
- // Update Templates nav active state
1429
- const templatesNav = document.getElementById('templatesNav');
1430
- if (templatesNav) {
1431
- templatesNav.classList.toggle('active', currentPage === 'templates');
1432
- }
1433
- }
1434
-
1435
- function selectApp(app) {
1436
- currentPage = 'app';
1437
- selectedApp = app;
1438
- selectedLoop = app.loops.length > 0 ? app.loops[0].key : null;
1439
- promptDirty = false;
1440
- activeEditTab = 'prompt';
1441
- cachedPromptValue = null;
1442
- activeAppTab = 'loops';
1443
- archivesData = [];
1444
- expandedArchive = null;
1445
- archiveFilesCache = {};
1446
- viewingArchiveFile = null;
1447
- document.title = app.appName + ' - RalphFlow Dashboard';
1448
- renderSidebar();
1449
- renderContent();
1450
- fetchAppStatus(app.appName);
1451
- }
1452
-
1453
- function selectLoop(loopKey) {
1454
- selectedLoop = loopKey;
1455
- promptDirty = false;
1456
- activeEditTab = 'prompt';
1457
- cachedPromptValue = null;
1458
- renderSidebar();
1459
- renderContent();
1460
- }
1461
-
1462
- // Main content
1463
- function renderContent() {
1464
- if (currentPage === 'templates') {
1465
- renderTemplatesPage();
1466
- return;
1467
- }
1468
- if (!selectedApp) {
1469
- content.innerHTML = '<div class="content-empty">Select an app to view details</div>';
1470
- return;
1471
- }
1472
-
1473
- const app = selectedApp;
1474
- const currentLoop = app.loops.find(l => l.key === selectedLoop);
1475
-
1476
- let html = '';
1477
-
1478
- // App header
1479
- html += `<div class="app-header">
1480
- <div style="display:flex;align-items:center;gap:10px;justify-content:space-between;width:100%">
1481
- <div style="display:flex;align-items:center;gap:10px">
1482
- <h2>${esc(app.appName)}</h2>
1483
- <span class="app-type-badge">${esc(app.appType)}</span>
1484
- </div>
1485
- <div style="display:flex;gap:6px">
1486
- <button class="btn btn-muted" style="font-size:12px;padding:4px 10px" onclick="openArchiveAppModal('${esc(app.appName)}')">Archive</button>
1487
- <button class="btn btn-danger" style="font-size:12px;padding:4px 10px" onclick="openDeleteAppModal('${esc(app.appName)}')">Delete</button>
1488
- </div>
1489
- </div>
1490
- ${app.description ? `<div class="app-desc">${esc(app.description)}</div>` : ''}
1491
- </div>`;
1492
-
1493
- // App-level tabs: Loops | Archives
1494
- html += `<div class="app-tabs">
1495
- <button class="app-tab${activeAppTab === 'loops' ? ' active' : ''}" data-app-tab="loops">Loops</button>
1496
- <button class="app-tab${activeAppTab === 'archives' ? ' active' : ''}" data-app-tab="archives">Archives</button>
1497
- </div>`;
1498
-
1499
- if (activeAppTab === 'archives') {
1500
- html += '<div id="archivesContainer">Loading archives...</div>';
1501
- content.innerHTML = html;
1502
-
1503
- // Bind app tab clicks
1504
- content.querySelectorAll('.app-tab').forEach(tab => {
1505
- tab.addEventListener('click', () => switchAppTab(tab.dataset.appTab));
1506
- });
1507
-
1508
- loadArchives(app.appName);
1509
- return;
1510
- }
1511
-
1512
- // --- Loops tab content ---
1513
-
1514
- // Pipeline
1515
- html += '<div class="section"><div class="section-title">Pipeline</div><div class="pipeline">';
1516
- app.loops.forEach((loop, i) => {
1517
- if (i > 0) html += '<div class="pipeline-connector"></div>';
1518
- const statusClass = getLoopStatusClass(loop);
1519
- const isSelected = loop.key === selectedLoop;
1520
- html += `<div class="pipeline-node${isSelected ? ' selected' : ''}" data-loop="${esc(loop.key)}">
1521
- <span class="node-name">${esc(loop.name)}</span>
1522
- <span class="node-status ${statusClass}">${statusClass}</span>
1523
- </div>`;
1524
- });
1525
- html += '</div></div>';
1526
-
1527
- // Commands section
1528
- html += '<div class="section"><div class="section-title">Commands</div><div class="commands-list">';
1529
- app.loops.forEach(loop => {
1530
- const alias = loop.key.replace(/-loop$/, '');
1531
- let cmd = `npx ralphflow run ${alias} -f ${app.appName}`;
1532
- if (loop.multiAgent) cmd += ' --multi-agent';
1533
- if (loop.model) cmd += ` --model ${loop.model}`;
1534
- html += `<div class="cmd-item">
1535
- <span class="cmd-text">${esc(cmd)}</span>
1536
- <button class="cmd-copy" data-cmd="${esc(cmd)}">Copy</button>
1537
- </div>`;
1538
- });
1539
- const e2eCmd = `npx ralphflow e2e -f ${app.appName}`;
1540
- html += `<div class="cmd-item">
1541
- <span class="cmd-text">${esc(e2eCmd)}</span>
1542
- <button class="cmd-copy" data-cmd="${esc(e2eCmd)}">Copy</button>
1543
- </div>`;
1544
- html += '</div></div>';
1545
-
1546
- // Loop detail — two-column three-panel layout
1547
- if (currentLoop) {
1548
- const st = currentLoop.status || {};
1549
-
1550
- html += '<div class="panel-grid">';
1551
-
1552
- // Left column: Interactive + Progress
1553
- html += '<div class="panel-col-left">';
1554
-
1555
- // Interactive panel
1556
- const loopNotifs = notificationsList.filter(n => n.app === app.appName && n.loop === currentLoop.key);
1557
- const hasNotifs = loopNotifs.length > 0;
1558
- html += `<div class="panel panel-interactive${hasNotifs ? ' has-notifs' : ''}">
1559
- <div class="panel-header">Interactive${hasNotifs ? ' <span style="color:var(--accent)">(' + loopNotifs.length + ')</span>' : ''}</div>
1560
- <div class="panel-body">`;
1561
- if (hasNotifs) {
1562
- for (const n of loopNotifs) {
1563
- const time = new Date(n.timestamp).toLocaleTimeString();
1564
- const msg = extractNotifMessage(n.payload);
1565
- html += `<div class="notif-card" data-notif-id="${esc(n.id)}">
1566
- <span class="notif-time">${esc(time)}</span>
1567
- <span class="notif-msg">${esc(msg)}</span>
1568
- <button class="notif-dismiss" data-dismiss-id="${esc(n.id)}">&times;</button>
1569
- </div>`;
1570
- }
1571
- } else {
1572
- html += `<span class="bell-icon">&#128276;</span><span>No notifications</span>`;
1573
- }
1574
- html += '</div></div>';
1575
-
1576
- // Progress panel
1577
- html += `<div class="panel panel-progress">
1578
- <div class="panel-header">Progress</div>
1579
- <div class="panel-body">
1580
- <div class="loop-meta">
1581
- <div class="meta-card"><div class="meta-label">Stage</div><div class="meta-value">${esc(st.stage || '—')}</div></div>
1582
- <div class="meta-card"><div class="meta-label">Active</div><div class="meta-value">${esc(st.active || 'none')}</div></div>
1583
- <div class="meta-card">
1584
- <div class="meta-label">Progress</div>
1585
- <div class="meta-value">${st.completed || 0}/${st.total || 0}</div>
1586
- <div class="progress-bar"><div class="progress-fill" style="width:${st.total ? (st.completed / st.total * 100) : 0}%"></div></div>
1587
- </div>
1588
- <div class="meta-card"><div class="meta-label">Stages</div><div class="meta-value" style="font-size:11px">${(currentLoop.stages || []).join(' → ')}</div></div>
1589
- </div>`;
1590
-
1591
- // Agent table
1592
- if (st.agents && st.agents.length > 0) {
1593
- html += `<div style="margin-top:16px">
1594
- <table class="agent-table">
1595
- <thead><tr><th>Agent</th><th>Active Task</th><th>Stage</th><th>Heartbeat</th></tr></thead>
1596
- <tbody>`;
1597
- for (const ag of st.agents) {
1598
- html += `<tr><td>${esc(ag.name)}</td><td>${esc(ag.activeTask)}</td><td>${esc(ag.stage)}</td><td>${esc(ag.lastHeartbeat)}</td></tr>`;
1599
- }
1600
- html += '</tbody></table></div>';
1601
- }
1602
-
1603
- // Tracker viewer (inside Progress panel)
1604
- html += `<div class="tracker-viewer" id="trackerViewer">Loading...</div>`;
1605
-
1606
- html += '</div></div>'; // close .panel-body + .panel-progress
1607
- html += '</div>'; // close .panel-col-left
1608
-
1609
- // Right column: Edit panel with tabs
1610
- html += `<div class="panel panel-edit">
1611
- <div class="edit-tabs">
1612
- <button class="edit-tab${activeEditTab === 'prompt' ? ' active' : ''}" data-tab="prompt">Prompt</button>
1613
- <button class="edit-tab${activeEditTab === 'tracker' ? ' active' : ''}" data-tab="tracker">Tracker</button>
1614
- <button class="edit-tab${activeEditTab === 'config' ? ' active' : ''}" data-tab="config">Config</button>
1615
- <div class="model-selector-wrap">
1616
- <label>Model</label>
1617
- <select class="model-selector" id="modelSelector">
1618
- <option value="">Default</option>
1619
- <option value="claude-opus-4-6">claude-opus-4-6</option>
1620
- <option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
1621
- <option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
1622
- </select>
1623
- <span class="model-save-ok" id="modelSaveOk">Saved</span>
1624
- </div>
1625
- </div>
1626
- <div class="panel-body" id="editTabContent"></div>
1627
- </div>`;
1628
-
1629
- html += '</div>'; // close .panel-grid
1630
- }
1631
-
1632
- content.innerHTML = html;
1633
-
1634
- // Bind app-level tab clicks
1635
- content.querySelectorAll('.app-tab').forEach(tab => {
1636
- tab.addEventListener('click', () => switchAppTab(tab.dataset.appTab));
1637
- });
1638
-
1639
- // Bind pipeline node clicks
1640
- content.querySelectorAll('.pipeline-node').forEach(el => {
1641
- el.addEventListener('click', () => selectLoop(el.dataset.loop));
1642
- });
1643
-
1644
- // Bind command copy buttons
1645
- content.querySelectorAll('.commands-list .cmd-copy').forEach(btn => {
1646
- btn.addEventListener('click', () => {
1647
- const cmd = btn.dataset.cmd || '';
1648
- navigator.clipboard.writeText(cmd).then(() => {
1649
- const orig = btn.textContent;
1650
- btn.textContent = 'Copied!';
1651
- setTimeout(() => { btn.textContent = orig; }, 1500);
1652
- });
1653
- });
1654
- });
1655
-
1656
- // Bind notification dismiss buttons
1657
- content.querySelectorAll('.notif-dismiss').forEach(btn => {
1658
- btn.addEventListener('click', () => dismissNotification(btn.dataset.dismissId));
1659
- });
1660
-
1661
- // Bind edit tabs + load content
1662
- if (currentLoop) {
1663
- content.querySelectorAll('.edit-tab').forEach(tab => {
1664
- tab.addEventListener('click', () => switchEditTab(tab.dataset.tab, app.appName, currentLoop.key));
1665
- });
1666
- renderEditTabContent(app.appName, currentLoop.key);
1667
- loadTracker(app.appName, currentLoop.key);
1668
- loadModelSelector(app.appName, currentLoop.key);
1669
- }
1670
- }
1671
-
1672
- function bindPromptEditor(appName, loopKey) {
1673
- const editor = $('#promptEditor');
1674
- if (!editor) return;
1675
-
1676
- editor.addEventListener('input', () => {
1677
- promptDirty = editor.value !== promptOriginal;
1678
- updateDirtyState();
1679
- });
1680
-
1681
- editor.addEventListener('keydown', (e) => {
1682
- if ((e.metaKey || e.ctrlKey) && e.key === 's') {
1683
- e.preventDefault();
1684
- savePrompt(appName, loopKey);
1685
- }
1686
- });
1687
-
1688
- const saveBtn = $('#savePromptBtn');
1689
- const resetBtn = $('#resetPromptBtn');
1690
- if (saveBtn) saveBtn.addEventListener('click', () => savePrompt(appName, loopKey));
1691
- if (resetBtn) resetBtn.addEventListener('click', () => {
1692
- editor.value = promptOriginal;
1693
- promptDirty = false;
1694
- updateDirtyState();
1695
- });
1696
- }
1697
-
1698
- async function loadPrompt(appName, loopKey) {
1699
- const editor = $('#promptEditor');
1700
- if (!editor) return;
1701
-
1702
- try {
1703
- const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`);
1704
- editor.value = data.content || '';
1705
- promptOriginal = editor.value;
1706
- promptDirty = false;
1707
- updateDirtyState();
1708
- } catch {
1709
- editor.value = '(Error loading prompt)';
1710
- }
1711
- bindPromptEditor(appName, loopKey);
1712
- }
1713
-
1714
- async function loadPromptPreview(appName, loopKey) {
1715
- const preview = $('#promptPreview');
1716
- if (!preview) return;
1717
- try {
1718
- const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`);
1719
- promptOriginal = data.content || '';
1720
- preview.innerHTML = renderMarkdown(promptOriginal);
1721
- } catch {
1722
- preview.innerHTML = '<p style="color:var(--text-dim)">(Error loading prompt)</p>';
1723
- }
1724
- }
1725
-
1726
- async function savePrompt(appName, loopKey) {
1727
- const editor = $('#promptEditor');
1728
- if (!editor || !promptDirty) return;
1729
-
1730
- try {
1731
- await fetch(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`, {
1732
- method: 'PUT',
1733
- headers: { 'Content-Type': 'application/json' },
1734
- body: JSON.stringify({ content: editor.value }),
1735
- });
1736
- promptOriginal = editor.value;
1737
- promptDirty = false;
1738
- updateDirtyState();
1739
- const saveOk = $('#saveOk');
1740
- if (saveOk) {
1741
- saveOk.style.display = 'inline';
1742
- setTimeout(() => { saveOk.style.display = 'none'; }, 2000);
1743
- }
1744
- } catch {
1745
- alert('Failed to save prompt');
1746
- }
1747
- }
1748
-
1749
- function updateDirtyState() {
1750
- const saveBtn = $('#savePromptBtn');
1751
- const resetBtn = $('#resetPromptBtn');
1752
- const indicator = $('#dirtyIndicator');
1753
- if (saveBtn) saveBtn.disabled = !promptDirty;
1754
- if (resetBtn) resetBtn.disabled = !promptDirty;
1755
- if (indicator) indicator.style.display = promptDirty ? 'inline' : 'none';
1756
- }
1757
-
1758
- async function loadTracker(appName, loopKey) {
1759
- const viewer = $('#trackerViewer');
1760
- if (!viewer) return;
1761
-
1762
- try {
1763
- const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/tracker`);
1764
- viewer.innerHTML = renderMarkdown(data.content || '(empty)');
1765
- } catch {
1766
- viewer.innerHTML = '(No tracker file found)';
1767
- }
1768
- }
1769
-
1770
- function switchEditTab(tab, appName, loopKey) {
1771
- if (tab === activeEditTab) return;
1772
- if (activeEditTab === 'prompt') {
1773
- const editor = $('#promptEditor');
1774
- if (editor) {
1775
- cachedPromptValue = editor.value;
1776
- }
1777
- }
1778
- activeEditTab = tab;
1779
- document.querySelectorAll('.edit-tab').forEach(t => {
1780
- t.classList.toggle('active', t.dataset.tab === tab);
1781
- });
1782
- renderEditTabContent(appName, loopKey);
1783
- }
1784
-
1785
- function renderMarkdown(md) {
1786
- if (!md) return '<p style="color:var(--text-dim)">(empty)</p>';
1787
- let html = md;
1788
- // Escape HTML
1789
- html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1790
- // Fenced code blocks
1791
- html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
1792
- '<pre><code>' + code.trimEnd() + '</code></pre>'
1793
- );
1794
- // Tables
1795
- html = html.replace(/((?:^\|.+\|$\n?)+)/gm, (block) => {
1796
- const rows = block.trim().split('\n').filter(r => r.trim());
1797
- if (rows.length < 2) return block;
1798
- const parseRow = r => r.split('|').slice(1, -1).map(c => c.trim());
1799
- const headers = parseRow(rows[0]);
1800
- // Skip separator row
1801
- const isSep = rows[1] && /^\|[\s:|-]+\|$/.test(rows[1].trim());
1802
- const dataRows = rows.slice(isSep ? 2 : 1);
1803
- let t = '<table><thead><tr>' + headers.map(h => '<th>' + h + '</th>').join('') + '</tr></thead><tbody>';
1804
- for (const row of dataRows) {
1805
- const cells = parseRow(row);
1806
- t += '<tr>' + cells.map(c => '<td>' + c + '</td>').join('') + '</tr>';
1807
- }
1808
- t += '</tbody></table>';
1809
- return t;
1810
- });
1811
- // Blockquotes
1812
- html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
1813
- // Horizontal rules
1814
- html = html.replace(/^---+$/gm, '<hr>');
1815
- // Headings
1816
- html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
1817
- html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
1818
- html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
1819
- html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
1820
- // Task list items
1821
- html = html.replace(/^- \[x\] (.+)$/gm, '<li class="task-done">&#9745; $1</li>');
1822
- html = html.replace(/^- \[ \] (.+)$/gm, '<li>&#9744; $1</li>');
1823
- // Unordered list items
1824
- html = html.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
1825
- // Ordered list items
1826
- html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
1827
- // Wrap consecutive <li> in <ul>
1828
- html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
1829
- // Inline code (but not inside <pre>)
1830
- html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
1831
- // Bold and italic
1832
- html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
1833
- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1834
- html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
1835
- // Links
1836
- html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
1837
- // Mustache/template vars — highlight them
1838
- html = html.replace(/\{\{(\w+)\}\}/g, '<code style="color:var(--purple)">{{$1}}</code>');
1839
- // Paragraphs: wrap remaining plain text lines
1840
- html = html.replace(/^(?!<[a-z/])((?!$).+)$/gm, '<p>$1</p>');
1841
- // Clean up double-wrapped
1842
- html = html.replace(/<p><(h[1-4]|ul|ol|li|blockquote|pre|table|hr)/g, '<$1');
1843
- html = html.replace(/<\/(h[1-4]|ul|ol|li|blockquote|pre|table)><\/p>/g, '</$1>');
1844
- return html;
1845
- }
1846
-
1847
- function renderEditTabContent(appName, loopKey) {
1848
- const container = $('#editTabContent');
1849
- if (!container) return;
1850
-
1851
- if (activeEditTab === 'prompt') {
1852
- const isRead = promptViewMode === 'read';
1853
- const isEdit = promptViewMode === 'edit';
1854
- container.innerHTML = `
1855
- <div class="prompt-mode-toggle">
1856
- <button class="prompt-mode-btn${isRead ? ' active' : ''}" data-mode="read">Read</button>
1857
- <button class="prompt-mode-btn${isEdit ? ' active' : ''}" data-mode="edit">Edit</button>
1858
- </div>
1859
- ${isEdit ? `<div class="editor-wrap">
1860
- <textarea class="editor" id="promptEditor" placeholder="Loading..."></textarea>
1861
- <div class="editor-actions">
1862
- <button class="btn btn-primary" id="savePromptBtn" disabled>Save</button>
1863
- <button class="btn" id="resetPromptBtn" disabled>Reset</button>
1864
- <span class="dirty-indicator" id="dirtyIndicator" style="display:none">Unsaved changes</span>
1865
- <span class="save-ok" id="saveOk" style="display:none">Saved</span>
1866
- </div>
1867
- </div>` : `<div class="prompt-preview" id="promptPreview">Loading...</div>`}`;
1868
- // Bind toggle buttons
1869
- container.querySelectorAll('.prompt-mode-btn').forEach(btn => {
1870
- btn.addEventListener('click', () => {
1871
- if (btn.dataset.mode === promptViewMode) return;
1872
- // Cache editor value before switching away from edit
1873
- if (promptViewMode === 'edit') {
1874
- const editor = $('#promptEditor');
1875
- if (editor) cachedPromptValue = editor.value;
1876
- }
1877
- promptViewMode = btn.dataset.mode;
1878
- renderEditTabContent(appName, loopKey);
1879
- });
1880
- });
1881
- if (isEdit) {
1882
- if (cachedPromptValue !== null) {
1883
- const editor = $('#promptEditor');
1884
- if (editor) {
1885
- editor.value = cachedPromptValue;
1886
- updateDirtyState();
1887
- bindPromptEditor(appName, loopKey);
1888
- }
1889
- cachedPromptValue = null;
1890
- } else {
1891
- loadPrompt(appName, loopKey);
1892
- }
1893
- } else {
1894
- // Read mode — render markdown preview
1895
- if (cachedPromptValue !== null || promptOriginal) {
1896
- const content = cachedPromptValue !== null ? cachedPromptValue : promptOriginal;
1897
- const preview = $('#promptPreview');
1898
- if (preview) preview.innerHTML = renderMarkdown(content);
1899
- } else {
1900
- // Need to fetch
1901
- loadPromptPreview(appName, loopKey);
1902
- }
1903
- }
1904
- } else if (activeEditTab === 'tracker') {
1905
- container.innerHTML = '<pre class="code-viewer" id="editTrackerViewer">Loading...</pre>';
1906
- loadEditTracker(appName, loopKey);
1907
- } else if (activeEditTab === 'config') {
1908
- container.innerHTML = '<pre class="code-viewer" id="editConfigViewer">Loading...</pre>';
1909
- loadEditConfig(appName);
1910
- }
1911
- }
1912
-
1913
- async function loadEditTracker(appName, loopKey) {
1914
- const viewer = $('#editTrackerViewer');
1915
- if (!viewer) return;
1916
- try {
1917
- const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/tracker`);
1918
- viewer.textContent = data.content || '(empty)';
1919
- } catch {
1920
- viewer.textContent = '(No tracker file found)';
1921
- }
1922
- }
1923
-
1924
- async function loadEditConfig(appName) {
1925
- const viewer = $('#editConfigViewer');
1926
- if (!viewer) return;
1927
- try {
1928
- const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/config`);
1929
- viewer.textContent = data._rawYaml || JSON.stringify(data, null, 2);
1930
- } catch {
1931
- viewer.textContent = '(Error loading config)';
1932
- }
1933
- }
1934
-
1935
- async function loadModelSelector(appName, loopKey) {
1936
- const selector = $('#modelSelector');
1937
- if (!selector) return;
1938
-
1939
- try {
1940
- const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/config`);
1941
- const loopConfig = data.loops && data.loops[loopKey];
1942
- const currentModel = loopConfig && loopConfig.model ? loopConfig.model : '';
1943
- selector.value = currentModel;
1944
- } catch {
1945
- // Leave at default
1946
- }
1947
-
1948
- selector.addEventListener('change', () => changeModel(appName, loopKey, selector.value));
1949
- }
1950
-
1951
- async function changeModel(appName, loopKey, model) {
1952
- const saveOk = $('#modelSaveOk');
1953
- try {
1954
- await fetch(`/api/apps/${encodeURIComponent(appName)}/config/model`, {
1955
- method: 'PUT',
1956
- headers: { 'Content-Type': 'application/json' },
1957
- body: JSON.stringify({ loop: loopKey, model: model || null }),
1958
- });
1959
- if (saveOk) {
1960
- saveOk.classList.add('visible');
1961
- setTimeout(() => saveOk.classList.remove('visible'), 2000);
1962
- }
1963
- // Refresh config tab if it's currently visible
1964
- if (activeEditTab === 'config') {
1965
- loadEditConfig(appName);
1966
- }
1967
- } catch {
1968
- alert('Failed to update model');
1969
- }
1970
- }
1971
-
1972
- // Minimal markdown renderer
1973
- function renderMarkdown(md) {
1974
- let html = '';
1975
- const lines = md.split('\n');
1976
- let inTable = false;
1977
- let tableHtml = '';
1978
-
1979
- for (let i = 0; i < lines.length; i++) {
1980
- const line = lines[i];
1981
-
1982
- // Table detection
1983
- if (line.match(/^\|.+\|$/)) {
1984
- if (!inTable) {
1985
- inTable = true;
1986
- tableHtml = '<table>';
1987
- // Header row
1988
- const cells = line.split('|').filter(Boolean).map(c => c.trim());
1989
- tableHtml += '<thead><tr>' + cells.map(c => `<th>${esc(c)}</th>`).join('') + '</tr></thead><tbody>';
1990
- continue;
1991
- }
1992
- // Separator row
1993
- if (line.match(/^\|[\s\-|]+\|$/)) continue;
1994
- // Data row
1995
- const cells = line.split('|').filter(Boolean).map(c => c.trim());
1996
- tableHtml += '<tr>' + cells.map(c => `<td>${esc(c)}</td>`).join('') + '</tr>';
1997
- continue;
1998
- } else if (inTable) {
1999
- inTable = false;
2000
- tableHtml += '</tbody></table>';
2001
- html += tableHtml;
2002
- tableHtml = '';
2003
- }
2004
-
2005
- // Headers
2006
- if (line.startsWith('### ')) { html += `<h3>${esc(line.slice(4))}</h3>`; continue; }
2007
- if (line.startsWith('## ')) { html += `<h2>${esc(line.slice(3))}</h2>`; continue; }
2008
- if (line.startsWith('# ')) { html += `<h1>${esc(line.slice(2))}</h1>`; continue; }
2009
-
2010
- // Checkboxes
2011
- if (line.match(/^- \[x\]/i)) {
2012
- html += `<div class="cb-done">${esc(line)}</div>`;
2013
- continue;
2014
- }
2015
- if (line.match(/^- \[ \]/)) {
2016
- html += `<div class="cb-todo">${esc(line)}</div>`;
2017
- continue;
2018
- }
2019
-
2020
- // Regular lines
2021
- html += line.trim() === '' ? '<br>' : `<div>${esc(line)}</div>`;
2022
- }
2023
-
2024
- if (inTable) {
2025
- tableHtml += '</tbody></table>';
2026
- html += tableHtml;
2027
- }
2028
-
2029
- return html;
2030
- }
2031
-
2032
- function getLoopStatusClass(loop) {
2033
- if (!loop.status) return 'pending';
2034
- const st = loop.status;
2035
- if (st.total > 0 && st.completed === st.total) return 'complete';
2036
- // Running if: has active agents, or has partial progress, or stage indicates activity (not idle/—)
2037
- if (st.agents && st.agents.length > 0) return 'running';
2038
- if (st.total > 0 && st.completed > 0 && st.completed < st.total) return 'running';
2039
- if (st.stage && st.stage !== '—' && st.stage !== 'idle') return 'running';
2040
- return 'pending';
2041
- }
2042
-
2043
- function esc(s) {
2044
- if (s == null) return '';
2045
- const d = document.createElement('div');
2046
- d.textContent = String(s);
2047
- return d.innerHTML;
2048
- }
2049
-
2050
- // Notifications
2051
- async function fetchNotifications() {
2052
- try {
2053
- const data = await fetchJson('/api/notifications');
2054
- notificationsList = Array.isArray(data) ? data : [];
2055
- renderSidebar();
2056
- renderContent();
2057
- } catch { /* ignore */ }
2058
- }
2059
-
2060
- async function dismissNotification(id) {
2061
- try {
2062
- await fetch(`/api/notification/${encodeURIComponent(id)}`, { method: 'DELETE' });
2063
- notificationsList = notificationsList.filter(n => n.id !== id);
2064
- renderSidebar();
2065
- renderContent();
2066
- } catch { /* ignore */ }
2067
- }
2068
-
2069
- function extractNotifMessage(payload) {
2070
- if (!payload) return 'Attention needed';
2071
- if (typeof payload === 'string') return payload;
2072
- if (payload.message) return payload.message;
2073
- if (payload.type) return payload.type;
2074
- if (payload.event) return payload.event;
2075
- return 'Attention needed';
2076
- }
2077
-
2078
- function maybeRequestNotifPermission() {
2079
- if (notifPermissionRequested) return;
2080
- if (!('Notification' in window)) return;
2081
- if (Notification.permission === 'default') {
2082
- notifPermissionRequested = true;
2083
- Notification.requestPermission();
2084
- }
2085
- }
2086
-
2087
- function showBrowserNotification(n) {
2088
- if (!('Notification' in window)) return;
2089
- if (Notification.permission !== 'granted') return;
2090
- if (document.hasFocus()) return;
2091
- const msg = extractNotifMessage(n.payload);
2092
- new Notification('RalphFlow — ' + (n.loop || 'Notification'), { body: msg });
2093
- }
2094
-
2095
- // Audio notification chime (Web Audio API)
2096
- function initAudioContext() {
2097
- if (audioCtxInitialized) return;
2098
- audioCtxInitialized = true;
2099
- try {
2100
- audioCtx = new (window.AudioContext || window.webkitAudioContext)();
2101
- } catch (e) {
2102
- // Silent fail — audio is best-effort
2103
- }
2104
- }
2105
-
2106
- function playNotificationChime() {
2107
- if (!audioCtx) return;
2108
- try {
2109
- const now = audioCtx.currentTime;
2110
- // First tone: E5 (659 Hz), 120ms
2111
- const osc1 = audioCtx.createOscillator();
2112
- const gain1 = audioCtx.createGain();
2113
- osc1.type = 'sine';
2114
- osc1.frequency.value = 659;
2115
- gain1.gain.setValueAtTime(0.15, now);
2116
- gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
2117
- osc1.connect(gain1);
2118
- gain1.connect(audioCtx.destination);
2119
- osc1.start(now);
2120
- osc1.stop(now + 0.12);
2121
- // Second tone: A5 (880 Hz), 150ms, starts 80ms after first
2122
- const osc2 = audioCtx.createOscillator();
2123
- const gain2 = audioCtx.createGain();
2124
- osc2.type = 'sine';
2125
- osc2.frequency.value = 880;
2126
- gain2.gain.setValueAtTime(0, now + 0.08);
2127
- gain2.gain.linearRampToValueAtTime(0.12, now + 0.1);
2128
- gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
2129
- osc2.connect(gain2);
2130
- gain2.connect(audioCtx.destination);
2131
- osc2.start(now + 0.08);
2132
- osc2.stop(now + 0.25);
2133
- } catch (e) {
2134
- // Silent fail
2135
- }
2136
- }
2137
-
2138
- // Initialize audio context on first user interaction (autoplay policy)
2139
- function onFirstInteraction() {
2140
- initAudioContext();
2141
- document.removeEventListener('click', onFirstInteraction);
2142
- document.removeEventListener('keydown', onFirstInteraction);
2143
- }
2144
- document.addEventListener('click', onFirstInteraction);
2145
- document.addEventListener('keydown', onFirstInteraction);
2146
-
2147
- // App-level tab switching (Loops / Archives)
2148
- function switchAppTab(tab) {
2149
- if (tab === activeAppTab) return;
2150
- activeAppTab = tab;
2151
- expandedArchive = null;
2152
- archiveFilesCache = {};
2153
- viewingArchiveFile = null;
2154
- renderContent();
2155
- }
2156
-
2157
- // Archives view
2158
- async function loadArchives(appName) {
2159
- const container = document.getElementById('archivesContainer');
2160
- if (!container) return;
2161
-
2162
- try {
2163
- archivesData = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/archives`);
2164
- renderArchivesView(container, appName);
2165
- } catch {
2166
- container.innerHTML = '<div class="archive-empty"><div>Error loading archives</div></div>';
2167
- }
2168
- }
2169
-
2170
- function renderArchivesView(container, appName) {
2171
- if (archivesData.length === 0) {
2172
- container.innerHTML = `<div class="archive-empty">
2173
- <div class="archive-empty-icon">&#128451;</div>
2174
- <div>No archives yet</div>
2175
- <div style="margin-top:8px;font-size:12px">Use the Archive button to snapshot current work</div>
2176
- </div>`;
2177
- return;
2178
- }
2179
-
2180
- let html = '<div class="archive-timeline">';
2181
- for (const archive of archivesData) {
2182
- const isExpanded = expandedArchive === archive.timestamp;
2183
- const dateStr = formatArchiveTimestamp(archive.timestamp);
2184
- html += `<div class="archive-card${isExpanded ? ' expanded' : ''}" data-archive="${esc(archive.timestamp)}">
2185
- <div class="archive-card-header" data-archive-toggle="${esc(archive.timestamp)}">
2186
- <span class="archive-card-date">${esc(dateStr)}</span>
2187
- <div class="archive-card-stats">
2188
- <span class="archive-card-stat">Stories: <span class="stat-val">${archive.summary.storyCount}</span></span>
2189
- <span class="archive-card-stat">Tasks: <span class="stat-val">${archive.summary.taskCount}</span></span>
2190
- <span class="archive-card-stat">Files: <span class="stat-val">${archive.fileCount}</span></span>
2191
- <span class="archive-card-chevron">&#9654;</span>
2192
- </div>
2193
- </div>`;
2194
-
2195
- if (isExpanded) {
2196
- const files = archiveFilesCache[archive.timestamp];
2197
- if (files) {
2198
- html += '<div class="archive-files">';
2199
- for (const file of files) {
2200
- const isActive = viewingArchiveFile === file.path;
2201
- html += `<div class="archive-file-item${isActive ? ' active' : ''}" data-archive-file="${esc(file.path)}" data-archive-ts="${esc(archive.timestamp)}">
2202
- <span class="archive-file-icon">&#128196;</span>
2203
- <span>${esc(file.path)}</span>
2204
- </div>`;
2205
- }
2206
- html += '</div>';
2207
-
2208
- if (viewingArchiveFile) {
2209
- html += `<div class="archive-file-viewer">
2210
- <div class="archive-file-viewer-header">
2211
- <span>${esc(viewingArchiveFile)}</span>
2212
- <button class="archive-file-viewer-close" data-close-viewer="true">&times;</button>
2213
- </div>
2214
- <div class="archive-file-content" id="archiveFileContent">Loading...</div>
2215
- </div>`;
2216
- }
2217
- } else {
2218
- html += '<div class="archive-files" style="padding:16px;color:var(--text-dim);font-size:12px">Loading files...</div>';
2219
- }
2220
- }
2221
-
2222
- html += '</div>';
2223
- }
2224
- html += '</div>';
2225
-
2226
- container.innerHTML = html;
2227
-
2228
- // Bind archive card toggle clicks
2229
- container.querySelectorAll('.archive-card-header').forEach(header => {
2230
- header.addEventListener('click', () => toggleArchiveCard(appName, header.dataset.archiveToggle));
2231
- });
2232
-
2233
- // Bind archive file clicks
2234
- container.querySelectorAll('.archive-file-item').forEach(item => {
2235
- item.addEventListener('click', () => {
2236
- viewArchiveFile(appName, item.dataset.archiveTs, item.dataset.archiveFile);
2237
- });
2238
- });
2239
-
2240
- // Bind file viewer close button
2241
- const closeBtn = container.querySelector('[data-close-viewer]');
2242
- if (closeBtn) {
2243
- closeBtn.addEventListener('click', () => {
2244
- viewingArchiveFile = null;
2245
- renderArchivesView(container, appName);
2246
- });
2247
- }
2248
-
2249
- // Load file content if viewer is open
2250
- if (viewingArchiveFile && expandedArchive) {
2251
- loadArchiveFileContent(appName, expandedArchive, viewingArchiveFile);
2252
- }
2253
- }
2254
-
2255
- async function toggleArchiveCard(appName, timestamp) {
2256
- const container = document.getElementById('archivesContainer');
2257
- if (!container) return;
2258
-
2259
- if (expandedArchive === timestamp) {
2260
- expandedArchive = null;
2261
- viewingArchiveFile = null;
2262
- renderArchivesView(container, appName);
2263
- return;
2264
- }
2265
-
2266
- expandedArchive = timestamp;
2267
- viewingArchiveFile = null;
2268
-
2269
- // Load files if not cached
2270
- if (!archiveFilesCache[timestamp]) {
2271
- renderArchivesView(container, appName);
2272
- try {
2273
- const files = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/archives/${encodeURIComponent(timestamp)}/files`);
2274
- archiveFilesCache[timestamp] = files;
2275
- } catch {
2276
- archiveFilesCache[timestamp] = [];
2277
- }
2278
- }
2279
-
2280
- renderArchivesView(container, appName);
2281
- }
2282
-
2283
- async function viewArchiveFile(appName, timestamp, filePath) {
2284
- const container = document.getElementById('archivesContainer');
2285
- if (!container) return;
2286
-
2287
- viewingArchiveFile = filePath;
2288
- renderArchivesView(container, appName);
2289
- }
2290
-
2291
- async function loadArchiveFileContent(appName, timestamp, filePath) {
2292
- const contentEl = document.getElementById('archiveFileContent');
2293
- if (!contentEl) return;
2294
-
2295
- try {
2296
- const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/archives/${encodeURIComponent(timestamp)}/files/${filePath}`);
2297
- contentEl.textContent = data.content || '(empty file)';
2298
- } catch {
2299
- contentEl.textContent = '(Error loading file)';
2300
- }
2301
- }
2302
-
2303
- function formatArchiveTimestamp(ts) {
2304
- // Format: 2026-03-14_15-30 → Mar 14, 2026 at 15:30
2305
- const match = ts.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})/);
2306
- if (!match) return ts;
2307
- const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
2308
- const [, year, month, day, hour, min] = match;
2309
- return `${months[parseInt(month, 10) - 1]} ${parseInt(day, 10)}, ${year} at ${hour}:${min}`;
2310
- }
2311
-
2312
- // Delete App Modal — exposed globally for inline onclick handlers
2313
- window.openDeleteAppModal = openDeleteAppModal;
2314
- function openDeleteAppModal(appName) {
2315
- const existing = document.querySelector('.modal-overlay');
2316
- if (existing) existing.remove();
2317
-
2318
- const overlay = document.createElement('div');
2319
- overlay.className = 'modal-overlay';
2320
- overlay.innerHTML = `
2321
- <div class="modal">
2322
- <div class="modal-header">
2323
- <h3>Delete App</h3>
2324
- <button class="modal-close" data-action="close">&times;</button>
2325
- </div>
2326
- <div class="modal-body" id="deleteModalBody">
2327
- <p style="margin-bottom:12px">Are you sure you want to delete <strong>${esc(appName)}</strong>?</p>
2328
- <p style="color:var(--red);font-size:13px">This will permanently remove the app directory and all associated data. This action cannot be undone.</p>
2329
- <div id="deleteModalMessage"></div>
2330
- </div>
2331
- <div class="modal-footer">
2332
- <button class="btn" data-action="close">Cancel</button>
2333
- <button class="btn btn-danger" id="deleteModalBtn">Delete</button>
2334
- </div>
2335
- </div>
2336
- `;
2337
-
2338
- document.body.appendChild(overlay);
2339
-
2340
- overlay.addEventListener('click', (e) => {
2341
- if (e.target === overlay || e.target.dataset.action === 'close') {
2342
- overlay.remove();
2343
- document.removeEventListener('keydown', escHandler);
2344
- }
2345
- });
2346
-
2347
- const escHandler = (e) => {
2348
- if (e.key === 'Escape') {
2349
- overlay.remove();
2350
- document.removeEventListener('keydown', escHandler);
2351
- }
2352
- };
2353
- document.addEventListener('keydown', escHandler);
2354
-
2355
- overlay.querySelector('#deleteModalBtn').addEventListener('click', () => submitDeleteApp(overlay, appName));
2356
- }
2357
-
2358
- async function submitDeleteApp(overlay, appName) {
2359
- const msgEl = overlay.querySelector('#deleteModalMessage');
2360
- const deleteBtn = overlay.querySelector('#deleteModalBtn');
2361
-
2362
- deleteBtn.disabled = true;
2363
- deleteBtn.textContent = 'Deleting...';
2364
- msgEl.innerHTML = '';
2365
-
2366
- try {
2367
- const res = await fetch('/api/apps/' + encodeURIComponent(appName), { method: 'DELETE' });
2368
- const data = await res.json();
2369
-
2370
- if (!res.ok) {
2371
- msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to delete app')}</div>`;
2372
- deleteBtn.disabled = false;
2373
- deleteBtn.textContent = 'Delete';
2374
- return;
2375
- }
2376
-
2377
- overlay.remove();
2378
-
2379
- // Clean up client state
2380
- notificationsList = notificationsList.filter(n => n.app !== appName);
2381
- if (selectedApp && selectedApp.appName === appName) {
2382
- selectedApp = null;
2383
- selectedLoop = null;
2384
- document.title = 'RalphFlow Dashboard';
2385
- }
2386
- fetchApps();
2387
- } catch (err) {
2388
- msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
2389
- deleteBtn.disabled = false;
2390
- deleteBtn.textContent = 'Delete';
2391
- }
2392
- }
2393
-
2394
- // Archive App Modal — exposed globally for inline onclick handlers
2395
- window.openArchiveAppModal = openArchiveAppModal;
2396
- function openArchiveAppModal(appName) {
2397
- const existing = document.querySelector('.modal-overlay');
2398
- if (existing) existing.remove();
2399
-
2400
- const overlay = document.createElement('div');
2401
- overlay.className = 'modal-overlay';
2402
- overlay.innerHTML = `
2403
- <div class="modal">
2404
- <div class="modal-header">
2405
- <h3>Archive App</h3>
2406
- <button class="modal-close" data-action="close">&times;</button>
2407
- </div>
2408
- <div class="modal-body" id="archiveModalBody">
2409
- <p style="margin-bottom:12px">Archive <strong>${esc(appName)}</strong>?</p>
2410
- <p style="color:var(--text-dim);font-size:13px;margin-bottom:8px">This will snapshot all current work and reset to a clean slate:</p>
2411
- <ul style="color:var(--text-dim);font-size:13px;margin-left:18px;margin-bottom:12px;line-height:1.6">
2412
- <li>Stories, tasks, and trackers saved to <code style="font-family:var(--mono);font-size:12px;color:var(--text)">.archives/</code></li>
2413
- <li>Tracker and data files reset to template defaults</li>
2414
- <li>Prompts and config preserved</li>
2415
- </ul>
2416
- <div id="archiveModalMessage"></div>
2417
- </div>
2418
- <div class="modal-footer" id="archiveModalFooter">
2419
- <button class="btn" data-action="close">Cancel</button>
2420
- <button class="btn btn-primary" id="archiveModalBtn">Archive</button>
2421
- </div>
2422
- </div>
2423
- `;
2424
-
2425
- document.body.appendChild(overlay);
2426
-
2427
- overlay.addEventListener('click', (e) => {
2428
- if (e.target === overlay || e.target.dataset.action === 'close') {
2429
- overlay.remove();
2430
- document.removeEventListener('keydown', escHandler);
2431
- }
2432
- });
2433
-
2434
- const escHandler = (e) => {
2435
- if (e.key === 'Escape') {
2436
- overlay.remove();
2437
- document.removeEventListener('keydown', escHandler);
2438
- }
2439
- };
2440
- document.addEventListener('keydown', escHandler);
2441
-
2442
- overlay.querySelector('#archiveModalBtn').addEventListener('click', () => submitArchiveApp(overlay, appName));
2443
- }
2444
-
2445
- async function submitArchiveApp(overlay, appName) {
2446
- const msgEl = overlay.querySelector('#archiveModalMessage');
2447
- const archiveBtn = overlay.querySelector('#archiveModalBtn');
2448
-
2449
- archiveBtn.disabled = true;
2450
- archiveBtn.textContent = 'Archiving...';
2451
- msgEl.innerHTML = '';
2452
-
2453
- try {
2454
- const res = await fetch('/api/apps/' + encodeURIComponent(appName) + '/archive', { method: 'POST' });
2455
- const data = await res.json();
2456
-
2457
- if (!res.ok) {
2458
- msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to archive app')}</div>`;
2459
- archiveBtn.disabled = false;
2460
- archiveBtn.textContent = 'Archive';
2461
- return;
2462
- }
2463
-
2464
- // Show success state
2465
- const body = overlay.querySelector('#archiveModalBody');
2466
- const footer = overlay.querySelector('#archiveModalFooter');
2467
-
2468
- body.innerHTML = `
2469
- <p style="color:var(--green);margin-bottom:12px">Archived successfully.</p>
2470
- <p style="font-size:13px;color:var(--text-dim)">Snapshot saved to <code style="font-family:var(--mono);font-size:12px;color:var(--text)">${esc(data.archivePath)}</code></p>
2471
- <p style="font-size:13px;color:var(--text-dim);margin-top:8px">Timestamp: <strong style="color:var(--text)">${esc(data.timestamp)}</strong></p>
2472
- `;
2473
- footer.innerHTML = `<button class="btn btn-primary" data-action="close">Done</button>`;
2474
-
2475
- // Clean up client state and refresh
2476
- notificationsList = notificationsList.filter(n => n.app !== appName);
2477
- fetchApps();
2478
- } catch (err) {
2479
- msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
2480
- archiveBtn.disabled = false;
2481
- archiveBtn.textContent = 'Archive';
2482
- }
2483
- }
2484
-
2485
- // Create App Modal
2486
- async function openCreateAppModal() {
2487
- // Remove any existing modal
2488
- const existing = document.querySelector('.modal-overlay');
2489
- if (existing) existing.remove();
2490
-
2491
- // Fetch available templates (built-in + custom)
2492
- let templates = [];
2493
- try {
2494
- templates = await fetchJson('/api/templates');
2495
- } catch {
2496
- templates = [
2497
- { name: 'code-implementation', type: 'built-in' },
2498
- { name: 'research', type: 'built-in' }
2499
- ];
2500
- }
2501
-
2502
- let optionsHtml = '';
2503
- for (const tpl of templates) {
2504
- optionsHtml += `<option value="${esc(tpl.name)}">${esc(tpl.name)}${tpl.type === 'custom' ? ' (custom)' : ''}</option>`;
2505
- }
2506
-
2507
- const overlay = document.createElement('div');
2508
- overlay.className = 'modal-overlay';
2509
- overlay.innerHTML = `
2510
- <div class="modal">
2511
- <div class="modal-header">
2512
- <h3>Create New App</h3>
2513
- <button class="modal-close" data-action="close">&times;</button>
2514
- </div>
2515
- <div class="modal-body" id="modalBody">
2516
- <div class="form-group">
2517
- <label class="form-label">Template</label>
2518
- <select class="form-select" id="modalTemplate">
2519
- ${optionsHtml}
2520
- </select>
2521
- </div>
2522
- <div class="form-group">
2523
- <label class="form-label">App Name</label>
2524
- <input class="form-input" id="modalName" type="text" placeholder="my-feature" autocomplete="off">
2525
- </div>
2526
- <div id="modalMessage"></div>
2527
- </div>
2528
- <div class="modal-footer" id="modalFooter">
2529
- <button class="btn" data-action="close">Cancel</button>
2530
- <button class="btn btn-primary" id="modalCreateBtn">Create</button>
2531
- </div>
2532
- </div>
2533
- `;
2534
-
2535
- document.body.appendChild(overlay);
2536
-
2537
- // Close on overlay click or close buttons
2538
- overlay.addEventListener('click', (e) => {
2539
- if (e.target === overlay || e.target.dataset.action === 'close') {
2540
- overlay.remove();
2541
- }
2542
- });
2543
-
2544
- // Close on Escape
2545
- const escHandler = (e) => {
2546
- if (e.key === 'Escape') {
2547
- overlay.remove();
2548
- document.removeEventListener('keydown', escHandler);
2549
- }
2550
- };
2551
- document.addEventListener('keydown', escHandler);
2552
-
2553
- // Focus name input
2554
- const nameInput = overlay.querySelector('#modalName');
2555
- setTimeout(() => nameInput.focus(), 50);
2556
-
2557
- // Submit on Enter in name input
2558
- nameInput.addEventListener('keydown', (e) => {
2559
- if (e.key === 'Enter') {
2560
- e.preventDefault();
2561
- submitCreateApp(overlay);
2562
- }
2563
- });
2564
-
2565
- // Create button
2566
- const createBtn = overlay.querySelector('#modalCreateBtn');
2567
- createBtn.addEventListener('click', () => submitCreateApp(overlay));
2568
- }
2569
-
2570
- async function submitCreateApp(overlay) {
2571
- const templateEl = overlay.querySelector('#modalTemplate');
2572
- const nameEl = overlay.querySelector('#modalName');
2573
- const msgEl = overlay.querySelector('#modalMessage');
2574
- const createBtn = overlay.querySelector('#modalCreateBtn');
2575
-
2576
- const template = templateEl.value;
2577
- const name = nameEl.value.trim();
2578
-
2579
- // Client-side validation
2580
- if (!name) {
2581
- msgEl.innerHTML = '<div class="form-error">Name is required</div>';
2582
- nameEl.focus();
2583
- return;
2584
- }
2585
-
2586
- // Disable button during request
2587
- createBtn.disabled = true;
2588
- createBtn.textContent = 'Creating...';
2589
- msgEl.innerHTML = '';
2590
-
2591
- try {
2592
- const res = await fetch('/api/apps', {
2593
- method: 'POST',
2594
- headers: { 'Content-Type': 'application/json' },
2595
- body: JSON.stringify({ template, name }),
2596
- });
2597
- const data = await res.json();
2598
-
2599
- if (!res.ok) {
2600
- msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to create app')}</div>`;
2601
- createBtn.disabled = false;
2602
- createBtn.textContent = 'Create';
2603
- return;
2604
- }
2605
-
2606
- // Success — show next-steps view
2607
- showNextSteps(overlay, data);
2608
- } catch (err) {
2609
- msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
2610
- createBtn.disabled = false;
2611
- createBtn.textContent = 'Create';
2612
- }
2613
- }
2614
-
2615
- function showNextSteps(overlay, data) {
2616
- const body = overlay.querySelector('#modalBody');
2617
- const footer = overlay.querySelector('#modalFooter');
2618
-
2619
- let warningHtml = '';
2620
- if (data.warning) {
2621
- warningHtml = `<div class="form-warning">${esc(data.warning)}</div>`;
2622
- }
2623
-
2624
- let cmdsHtml = '';
2625
- for (const cmd of data.commands) {
2626
- cmdsHtml += `
2627
- <div class="cmd-item">
2628
- <span class="cmd-text">${esc(cmd)}</span>
2629
- <button class="cmd-copy" data-cmd="${esc(cmd)}">Copy</button>
2630
- </div>`;
2631
- }
2632
-
2633
- body.innerHTML = `
2634
- <div class="next-steps-success">&#10003; Created ${esc(data.appName)}</div>
2635
- ${warningHtml}
2636
- <div class="next-steps-label">Next steps — run one of these in your terminal:</div>
2637
- ${cmdsHtml}
2638
- `;
2639
-
2640
- footer.innerHTML = `<button class="btn btn-primary" data-action="close">Done</button>`;
2641
- footer.querySelector('[data-action="close"]').addEventListener('click', () => overlay.remove());
2642
-
2643
- // Copy-to-clipboard buttons
2644
- body.querySelectorAll('.cmd-copy').forEach((btn) => {
2645
- btn.addEventListener('click', () => {
2646
- const cmd = btn.dataset.cmd || '';
2647
- navigator.clipboard.writeText(cmd).then(() => {
2648
- const orig = btn.textContent;
2649
- btn.textContent = 'Copied!';
2650
- setTimeout(() => { btn.textContent = orig; }, 1500);
2651
- });
2652
- });
2653
- });
2654
- }
2655
-
2656
- // -----------------------------------------------------------------------
2657
- // Templates page
2658
- // -----------------------------------------------------------------------
2659
-
2660
- async function fetchTemplates() {
2661
- try {
2662
- templatesList = await fetchJson('/api/templates');
2663
- } catch {
2664
- templatesList = [];
2665
- }
2666
- }
2667
-
2668
- function createEmptyLoop() {
2669
- return {
2670
- name: '',
2671
- stages: ['init'],
2672
- completion: 'LOOP COMPLETE',
2673
- model: 'claude-sonnet-4-6',
2674
- multi_agent: false,
2675
- max_agents: 3,
2676
- strategy: 'parallel',
2677
- agent_placeholder: '{{AGENT_NAME}}',
2678
- data_files: [],
2679
- entities: [],
2680
- showOptional: false
2681
- };
2682
- }
2683
-
2684
- function initTemplateBuilderState() {
2685
- return { name: '', description: '', loops: [createEmptyLoop()] };
2686
- }
2687
-
2688
- async function renderTemplatesPage() {
2689
- if (showTemplateBuilder) {
2690
- renderTemplateBuilder();
2691
- return;
2692
- }
2693
-
2694
- await fetchTemplates();
2695
-
2696
- let html = '<div class="templates-header">';
2697
- html += '<h2>Templates</h2>';
2698
- html += '<button class="btn btn-primary" id="createTemplateBtn">Create Template</button>';
2699
- html += '</div>';
2700
-
2701
- if (templatesList.length === 0) {
2702
- html += '<div class="content-empty">No templates found</div>';
2703
- } else {
2704
- html += '<div class="template-grid">';
2705
- for (const tpl of templatesList) {
2706
- html += `<div class="template-card">
2707
- <div class="template-card-header">
2708
- <span class="template-card-name">${esc(tpl.name)}</span>
2709
- <span class="template-card-type ${tpl.type}">${esc(tpl.type)}</span>
2710
- </div>
2711
- ${tpl.description ? `<div class="template-card-desc">${esc(tpl.description)}</div>` : ''}
2712
- <div class="template-card-meta">
2713
- <span>${tpl.loopCount} loop${tpl.loopCount !== 1 ? 's' : ''}</span>
2714
- ${tpl.type === 'custom' ? `<button class="btn btn-danger" style="font-size:11px;padding:2px 8px" data-delete-template="${esc(tpl.name)}">Delete</button>` : ''}
2715
- </div>
2716
- </div>`;
2717
- }
2718
- html += '</div>';
2719
- }
2720
-
2721
- content.innerHTML = html;
2722
-
2723
- const createBtn = document.getElementById('createTemplateBtn');
2724
- if (createBtn) {
2725
- createBtn.addEventListener('click', () => {
2726
- showTemplateBuilder = true;
2727
- templateBuilderState = initTemplateBuilderState();
2728
- renderTemplatesPage();
2729
- });
2730
- }
2731
-
2732
- content.querySelectorAll('[data-delete-template]').forEach(btn => {
2733
- btn.addEventListener('click', () => openDeleteTemplateModal(btn.dataset.deleteTemplate));
2734
- });
2735
- }
2736
-
2737
- function renderTemplateBuilder() {
2738
- const state = templateBuilderState;
2739
- let html = '';
2740
-
2741
- html += '<div class="templates-header">';
2742
- html += '<div style="display:flex;align-items:center;gap:12px"><button class="btn btn-muted" id="builderBackBtn" style="padding:4px 10px">&larr; Back</button><h2>Create Template</h2></div>';
2743
- html += '</div>';
2744
-
2745
- html += '<div class="template-builder">';
2746
-
2747
- // Basic info
2748
- html += '<div class="builder-section">';
2749
- html += '<div class="builder-section-title">Basic Info</div>';
2750
- html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">';
2751
- html += `<div class="form-group"><label class="form-label">Template Name</label>
2752
- <input class="form-input" id="tplName" type="text" value="${esc(state.name)}" placeholder="my-pipeline" autocomplete="off"></div>`;
2753
- html += `<div class="form-group"><label class="form-label">Description</label>
2754
- <input class="form-input" id="tplDesc" type="text" value="${esc(state.description)}" placeholder="Pipeline description" autocomplete="off"></div>`;
2755
- html += '</div></div>';
2756
-
2757
- // Loops
2758
- html += '<div class="builder-section">';
2759
- html += '<div class="builder-section-title">Loops</div>';
2760
- html += '<div class="loop-cards">';
2761
-
2762
- state.loops.forEach((loop, i) => {
2763
- html += `<div class="loop-card" data-loop-index="${i}">`;
2764
- html += `<div class="loop-card-header">
2765
- <span class="loop-card-title">Loop ${i + 1}</span>
2766
- ${state.loops.length > 1 ? `<button class="loop-card-remove" data-remove-loop="${i}">&times;</button>` : ''}
2767
- </div>`;
2768
-
2769
- html += '<div class="loop-card-grid">';
2770
-
2771
- // Name
2772
- html += `<div class="form-group"><label class="form-label">Name</label>
2773
- <input class="form-input loop-input" data-loop-idx="${i}" data-field="name" type="text" value="${esc(loop.name)}" placeholder="Story" autocomplete="off"></div>`;
2774
-
2775
- // Model
2776
- html += `<div class="form-group"><label class="form-label">Model</label>
2777
- <select class="form-select loop-input" data-loop-idx="${i}" data-field="model">
2778
- <option value="claude-sonnet-4-6"${loop.model === 'claude-sonnet-4-6' ? ' selected' : ''}>claude-sonnet-4-6</option>
2779
- <option value="claude-opus-4-6"${loop.model === 'claude-opus-4-6' ? ' selected' : ''}>claude-opus-4-6</option>
2780
- <option value="claude-haiku-4-5-20251001"${loop.model === 'claude-haiku-4-5-20251001' ? ' selected' : ''}>claude-haiku-4-5-20251001</option>
2781
- </select></div>`;
2782
-
2783
- // Stages (tag input)
2784
- html += `<div class="form-group loop-card-full"><label class="form-label">Stages</label>
2785
- <div class="stage-tags" data-loop-idx="${i}">`;
2786
- loop.stages.forEach((stage, si) => {
2787
- html += `<span class="stage-tag">${esc(stage)}<button class="stage-tag-remove" data-loop-idx="${i}" data-stage-idx="${si}">&times;</button></span>`;
2788
- });
2789
- html += `<input type="text" placeholder="Type stage, press Enter" data-stage-input="${i}" autocomplete="off">`;
2790
- html += '</div></div>';
2791
-
2792
- // Completion
2793
- html += `<div class="form-group loop-card-full"><label class="form-label">Completion String</label>
2794
- <input class="form-input loop-input" data-loop-idx="${i}" data-field="completion" type="text" value="${esc(loop.completion)}" placeholder="LOOP COMPLETE" autocomplete="off"></div>`;
2795
-
2796
- html += '</div>'; // close loop-card-grid
2797
-
2798
- // Multi-agent toggle
2799
- html += `<div style="margin-top:12px">
2800
- <label class="toggle-wrap">
2801
- <input type="checkbox" class="toggle-input" data-loop-idx="${i}" data-field="multi_agent" ${loop.multi_agent ? 'checked' : ''}>
2802
- <span class="toggle-label">Multi-agent</span>
2803
- </label>`;
2804
-
2805
- if (loop.multi_agent) {
2806
- html += `<div class="multi-agent-fields">
2807
- <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
2808
- <div class="form-group" style="margin-bottom:0"><label class="form-label">Max Agents</label>
2809
- <input class="form-input loop-input" data-loop-idx="${i}" data-field="max_agents" type="number" value="${loop.max_agents}" min="2" max="10"></div>
2810
- <div class="form-group" style="margin-bottom:0"><label class="form-label">Strategy</label>
2811
- <select class="form-select loop-input" data-loop-idx="${i}" data-field="strategy">
2812
- <option value="parallel"${loop.strategy === 'parallel' ? ' selected' : ''}>parallel</option>
2813
- <option value="sequential"${loop.strategy === 'sequential' ? ' selected' : ''}>sequential</option>
2814
- </select></div>
2815
- <div class="form-group" style="margin-bottom:0"><label class="form-label">Agent Placeholder</label>
2816
- <input class="form-input loop-input" data-loop-idx="${i}" data-field="agent_placeholder" type="text" value="${esc(loop.agent_placeholder)}"></div>
2817
- </div>
2818
- </div>`;
2819
- }
2820
- html += '</div>';
2821
-
2822
- // Optional fields toggle
2823
- html += `<button class="optional-toggle" data-toggle-optional="${i}">${loop.showOptional ? 'Hide optional fields' : 'Show optional fields'}</button>`;
2824
-
2825
- if (loop.showOptional) {
2826
- html += '<div class="optional-fields">';
2827
- html += '<div class="loop-card-grid">';
2828
- html += `<div class="form-group"><label class="form-label">Data Files <span style="color:var(--text-muted)">(comma-separated)</span></label>
2829
- <input class="form-input loop-input" data-loop-idx="${i}" data-field="data_files" type="text" value="${esc((loop.data_files || []).join(', '))}" placeholder="stories.md, tasks.md" autocomplete="off"></div>`;
2830
- html += `<div class="form-group"><label class="form-label">Entities <span style="color:var(--text-muted)">(comma-separated)</span></label>
2831
- <input class="form-input loop-input" data-loop-idx="${i}" data-field="entities" type="text" value="${esc((loop.entities || []).join(', '))}" placeholder="STORY, TASK" autocomplete="off"></div>`;
2832
- html += '</div></div>';
2833
- }
2834
-
2835
- html += '</div>'; // close loop-card
2836
- });
2837
-
2838
- html += '</div>'; // close loop-cards
2839
- html += `<button class="btn btn-muted" id="addLoopBtn" style="margin-top:12px;width:100%">+ Add Loop</button>`;
2840
- html += '</div>'; // close builder-section
2841
-
2842
- // YAML Preview
2843
- html += '<div class="builder-section yaml-preview-section">';
2844
- html += '<div class="builder-section-title">YAML Preview</div>';
2845
- html += `<pre class="yaml-preview" id="yamlPreview">${esc(generateYamlPreview(state))}</pre>`;
2846
- html += '</div>';
2847
-
2848
- // Actions
2849
- html += '<div class="builder-actions">';
2850
- html += '<button class="btn" id="builderCancelBtn">Cancel</button>';
2851
- html += '<button class="btn btn-primary" id="builderSaveBtn">Save Template</button>';
2852
- html += '</div>';
2853
-
2854
- html += '</div>'; // close template-builder
2855
-
2856
- content.innerHTML = html;
2857
- bindTemplateBuilderEvents();
2858
- }
2859
-
2860
- function bindTemplateBuilderEvents() {
2861
- const backBtn = document.getElementById('builderBackBtn');
2862
- if (backBtn) backBtn.addEventListener('click', () => {
2863
- showTemplateBuilder = false;
2864
- templateBuilderState = null;
2865
- renderTemplatesPage();
2866
- });
2867
-
2868
- const cancelBtn = document.getElementById('builderCancelBtn');
2869
- if (cancelBtn) cancelBtn.addEventListener('click', () => {
2870
- showTemplateBuilder = false;
2871
- templateBuilderState = null;
2872
- renderTemplatesPage();
2873
- });
2874
-
2875
- const saveBtn = document.getElementById('builderSaveBtn');
2876
- if (saveBtn) saveBtn.addEventListener('click', saveTemplate);
2877
-
2878
- const addLoopBtn = document.getElementById('addLoopBtn');
2879
- if (addLoopBtn) addLoopBtn.addEventListener('click', () => {
2880
- captureBuilderInputs();
2881
- templateBuilderState.loops.push(createEmptyLoop());
2882
- renderTemplateBuilder();
2883
- });
2884
-
2885
- // Template name/desc inputs
2886
- const tplName = document.getElementById('tplName');
2887
- const tplDesc = document.getElementById('tplDesc');
2888
- if (tplName) tplName.addEventListener('input', () => {
2889
- templateBuilderState.name = tplName.value;
2890
- updateYamlPreview();
2891
- });
2892
- if (tplDesc) tplDesc.addEventListener('input', () => {
2893
- templateBuilderState.description = tplDesc.value;
2894
- updateYamlPreview();
2895
- });
2896
-
2897
- // Loop text inputs
2898
- content.querySelectorAll('.loop-input').forEach(input => {
2899
- const idx = parseInt(input.dataset.loopIdx);
2900
- const field = input.dataset.field;
2901
- const evtType = input.tagName === 'SELECT' ? 'change' : 'input';
2902
-
2903
- if (field === 'multi_agent') {
2904
- input.addEventListener('change', () => {
2905
- captureBuilderInputs();
2906
- templateBuilderState.loops[idx].multi_agent = input.checked;
2907
- renderTemplateBuilder();
2908
- });
2909
- return;
2910
- }
2911
-
2912
- input.addEventListener(evtType, () => {
2913
- const loop = templateBuilderState.loops[idx];
2914
- if (!loop) return;
2915
- if (field === 'max_agents') {
2916
- loop.max_agents = parseInt(input.value) || 3;
2917
- } else if (field === 'data_files') {
2918
- loop.data_files = input.value.split(',').map(s => s.trim()).filter(Boolean);
2919
- } else if (field === 'entities') {
2920
- loop.entities = input.value.split(',').map(s => s.trim()).filter(Boolean);
2921
- } else {
2922
- loop[field] = input.value;
2923
- }
2924
- updateYamlPreview();
2925
- });
2926
- });
2927
-
2928
- // Stage tag inputs
2929
- content.querySelectorAll('[data-stage-input]').forEach(input => {
2930
- const idx = parseInt(input.dataset.stageInput);
2931
- input.addEventListener('keydown', (e) => {
2932
- if (e.key === 'Enter' || e.key === ',') {
2933
- e.preventDefault();
2934
- const value = input.value.trim().replace(/,/g, '');
2935
- if (value && !templateBuilderState.loops[idx].stages.includes(value)) {
2936
- captureBuilderInputs();
2937
- templateBuilderState.loops[idx].stages.push(value);
2938
- renderTemplateBuilder();
2939
- setTimeout(() => {
2940
- const newInput = content.querySelector(`[data-stage-input="${idx}"]`);
2941
- if (newInput) newInput.focus();
2942
- }, 0);
2943
- }
2944
- }
2945
- });
2946
- });
2947
-
2948
- // Stage tag remove buttons
2949
- content.querySelectorAll('.stage-tag-remove').forEach(btn => {
2950
- btn.addEventListener('click', () => {
2951
- captureBuilderInputs();
2952
- const loopIdx = parseInt(btn.dataset.loopIdx);
2953
- const stageIdx = parseInt(btn.dataset.stageIdx);
2954
- templateBuilderState.loops[loopIdx].stages.splice(stageIdx, 1);
2955
- renderTemplateBuilder();
2956
- });
2957
- });
2958
-
2959
- // Remove loop buttons
2960
- content.querySelectorAll('[data-remove-loop]').forEach(btn => {
2961
- btn.addEventListener('click', () => {
2962
- captureBuilderInputs();
2963
- const idx = parseInt(btn.dataset.removeLoop);
2964
- templateBuilderState.loops.splice(idx, 1);
2965
- renderTemplateBuilder();
2966
- });
2967
- });
2968
-
2969
- // Optional fields toggle
2970
- content.querySelectorAll('[data-toggle-optional]').forEach(btn => {
2971
- btn.addEventListener('click', () => {
2972
- captureBuilderInputs();
2973
- const idx = parseInt(btn.dataset.toggleOptional);
2974
- templateBuilderState.loops[idx].showOptional = !templateBuilderState.loops[idx].showOptional;
2975
- renderTemplateBuilder();
2976
- });
2977
- });
2978
- }
2979
-
2980
- // Capture current input values into state before re-render
2981
- function captureBuilderInputs() {
2982
- const state = templateBuilderState;
2983
- if (!state) return;
2984
- const tplName = document.getElementById('tplName');
2985
- const tplDesc = document.getElementById('tplDesc');
2986
- if (tplName) state.name = tplName.value;
2987
- if (tplDesc) state.description = tplDesc.value;
2988
-
2989
- content.querySelectorAll('.loop-input').forEach(input => {
2990
- const idx = parseInt(input.dataset.loopIdx);
2991
- const field = input.dataset.field;
2992
- const loop = state.loops[idx];
2993
- if (!loop || field === 'multi_agent') return;
2994
- if (field === 'max_agents') {
2995
- loop.max_agents = parseInt(input.value) || 3;
2996
- } else if (field === 'data_files') {
2997
- loop.data_files = input.value.split(',').map(s => s.trim()).filter(Boolean);
2998
- } else if (field === 'entities') {
2999
- loop.entities = input.value.split(',').map(s => s.trim()).filter(Boolean);
3000
- } else {
3001
- loop[field] = input.value;
3002
- }
3003
- });
3004
- }
3005
-
3006
- function updateYamlPreview() {
3007
- const preview = document.getElementById('yamlPreview');
3008
- if (preview && templateBuilderState) {
3009
- preview.textContent = generateYamlPreview(templateBuilderState);
3010
- }
3011
- }
3012
-
3013
- function generateYamlPreview(state) {
3014
- let yaml = '';
3015
- yaml += `name: ${state.name || 'my-template'}\n`;
3016
- yaml += `description: "${state.description || ''}"\n`;
3017
- yaml += `version: 1\n`;
3018
- yaml += `dir: .ralph-flow\n`;
3019
- yaml += `entities: {}\n`;
3020
- yaml += `loops:\n`;
3021
-
3022
- state.loops.forEach((loop, index) => {
3023
- const baseName = (loop.name || `loop-${index + 1}`).toLowerCase().replace(/\s+/g, '-');
3024
- const loopKey = baseName.endsWith('-loop') ? baseName : `${baseName}-loop`;
3025
- const dirPrefix = String(index).padStart(2, '0');
3026
- const loopDirName = `${dirPrefix}-${loopKey}`;
3027
-
3028
- yaml += ` ${loopKey}:\n`;
3029
- yaml += ` order: ${index}\n`;
3030
- yaml += ` name: "${loop.name || `Loop ${index + 1}`}"\n`;
3031
- yaml += ` prompt: ${loopDirName}/prompt.md\n`;
3032
- yaml += ` tracker: ${loopDirName}/tracker.md\n`;
3033
- yaml += ` stages: [${loop.stages.join(', ')}]\n`;
3034
- yaml += ` completion: "${loop.completion || 'LOOP COMPLETE'}"\n`;
3035
-
3036
- if (loop.multi_agent) {
3037
- yaml += ` multi_agent:\n`;
3038
- yaml += ` enabled: true\n`;
3039
- yaml += ` max_agents: ${loop.max_agents || 3}\n`;
3040
- yaml += ` strategy: ${loop.strategy || 'parallel'}\n`;
3041
- yaml += ` agent_placeholder: "${loop.agent_placeholder || '{{AGENT_NAME}}'}"\n`;
3042
- yaml += ` lock:\n`;
3043
- yaml += ` file: ${loopDirName}/.tracker-lock\n`;
3044
- yaml += ` type: echo\n`;
3045
- yaml += ` stale_seconds: 60\n`;
3046
- yaml += ` worktree:\n`;
3047
- yaml += ` strategy: shared\n`;
3048
- yaml += ` auto_merge: true\n`;
3049
- } else {
3050
- yaml += ` multi_agent: false\n`;
3051
- }
3052
-
3053
- yaml += ` model: ${loop.model || 'claude-sonnet-4-6'}\n`;
3054
- yaml += ` cadence: 0\n`;
3055
-
3056
- if (loop.data_files && loop.data_files.length > 0) {
3057
- yaml += ` data_files:\n`;
3058
- loop.data_files.forEach(f => { yaml += ` - ${loopDirName}/${f}\n`; });
3059
- }
3060
- if (loop.entities && loop.entities.length > 0) {
3061
- yaml += ` entities: [${loop.entities.join(', ')}]\n`;
3062
- }
3063
- });
3064
-
3065
- return yaml;
3066
- }
3067
-
3068
- async function saveTemplate() {
3069
- captureBuilderInputs();
3070
- const state = templateBuilderState;
3071
-
3072
- if (!state.name || !state.name.trim()) {
3073
- alert('Template name is required');
3074
- return;
3075
- }
3076
-
3077
- for (let i = 0; i < state.loops.length; i++) {
3078
- const loop = state.loops[i];
3079
- if (!loop.name || !loop.name.trim()) {
3080
- alert(`Loop ${i + 1}: name is required`);
3081
- return;
3082
- }
3083
- if (loop.stages.length === 0) {
3084
- alert(`Loop "${loop.name}": at least one stage is required`);
3085
- return;
3086
- }
3087
- if (!loop.completion || !loop.completion.trim()) {
3088
- alert(`Loop "${loop.name}": completion string is required`);
3089
- return;
3090
- }
3091
- }
3092
-
3093
- const definition = {
3094
- name: state.name.trim(),
3095
- description: state.description.trim(),
3096
- loops: state.loops.map(loop => {
3097
- const loopDef = {
3098
- name: loop.name.trim(),
3099
- stages: loop.stages,
3100
- completion: loop.completion.trim(),
3101
- model: loop.model || undefined,
3102
- };
3103
- if (loop.multi_agent) {
3104
- loopDef.multi_agent = {
3105
- enabled: true,
3106
- max_agents: loop.max_agents || 3,
3107
- strategy: loop.strategy || 'parallel',
3108
- agent_placeholder: loop.agent_placeholder || '{{AGENT_NAME}}'
3109
- };
3110
- }
3111
- if (loop.data_files && loop.data_files.length > 0) {
3112
- loopDef.data_files = loop.data_files;
3113
- }
3114
- if (loop.entities && loop.entities.length > 0) {
3115
- loopDef.entities = loop.entities;
3116
- }
3117
- return loopDef;
3118
- })
3119
- };
3120
-
3121
- const saveBtn = document.getElementById('builderSaveBtn');
3122
- if (saveBtn) {
3123
- saveBtn.disabled = true;
3124
- saveBtn.textContent = 'Saving...';
3125
- }
3126
-
3127
- try {
3128
- const res = await fetch('/api/templates', {
3129
- method: 'POST',
3130
- headers: { 'Content-Type': 'application/json' },
3131
- body: JSON.stringify(definition)
3132
- });
3133
- const data = await res.json();
3134
-
3135
- if (!res.ok) {
3136
- alert(data.error || 'Failed to save template');
3137
- if (saveBtn) {
3138
- saveBtn.disabled = false;
3139
- saveBtn.textContent = 'Save Template';
3140
- }
3141
- return;
3142
- }
3143
-
3144
- showTemplateBuilder = false;
3145
- templateBuilderState = null;
3146
- templatesList = [];
3147
- renderTemplatesPage();
3148
- } catch {
3149
- alert('Network error — could not reach server');
3150
- if (saveBtn) {
3151
- saveBtn.disabled = false;
3152
- saveBtn.textContent = 'Save Template';
3153
- }
3154
- }
3155
- }
3156
-
3157
- function openDeleteTemplateModal(templateName) {
3158
- const existing = document.querySelector('.modal-overlay');
3159
- if (existing) existing.remove();
3160
-
3161
- const overlay = document.createElement('div');
3162
- overlay.className = 'modal-overlay';
3163
- overlay.innerHTML = `
3164
- <div class="modal">
3165
- <div class="modal-header">
3166
- <h3>Delete Template</h3>
3167
- <button class="modal-close" data-action="close">&times;</button>
3168
- </div>
3169
- <div class="modal-body">
3170
- <p style="margin-bottom:12px">Delete template <strong>${esc(templateName)}</strong>?</p>
3171
- <p style="color:var(--red);font-size:13px">This will permanently remove the template. Apps already created from it are not affected.</p>
3172
- <div id="deleteTemplateMessage"></div>
3173
- </div>
3174
- <div class="modal-footer">
3175
- <button class="btn" data-action="close">Cancel</button>
3176
- <button class="btn btn-danger" id="deleteTemplateBtn">Delete</button>
3177
- </div>
3178
- </div>
3179
- `;
3180
-
3181
- document.body.appendChild(overlay);
3182
-
3183
- overlay.addEventListener('click', (e) => {
3184
- if (e.target === overlay || e.target.dataset.action === 'close') {
3185
- overlay.remove();
3186
- document.removeEventListener('keydown', escHandler);
3187
- }
3188
- });
3189
-
3190
- const escHandler = (e) => {
3191
- if (e.key === 'Escape') {
3192
- overlay.remove();
3193
- document.removeEventListener('keydown', escHandler);
3194
- }
3195
- };
3196
- document.addEventListener('keydown', escHandler);
3197
-
3198
- overlay.querySelector('#deleteTemplateBtn').addEventListener('click', async () => {
3199
- const btn = overlay.querySelector('#deleteTemplateBtn');
3200
- const msgEl = overlay.querySelector('#deleteTemplateMessage');
3201
- btn.disabled = true;
3202
- btn.textContent = 'Deleting...';
3203
-
3204
- try {
3205
- const res = await fetch('/api/templates/' + encodeURIComponent(templateName), { method: 'DELETE' });
3206
- const data = await res.json();
3207
-
3208
- if (!res.ok) {
3209
- msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to delete')}</div>`;
3210
- btn.disabled = false;
3211
- btn.textContent = 'Delete';
3212
- return;
3213
- }
3214
-
3215
- overlay.remove();
3216
- templatesList = [];
3217
- renderTemplatesPage();
3218
- } catch {
3219
- msgEl.innerHTML = '<div class="form-error">Network error</div>';
3220
- btn.disabled = false;
3221
- btn.textContent = 'Delete';
3222
- }
3223
- });
3224
- }
3225
-
3226
- // -----------------------------------------------------------------------
3227
- // Init
3228
- // -----------------------------------------------------------------------
3229
-
3230
- // Templates nav click handler
3231
- document.getElementById('templatesNav').addEventListener('click', () => {
3232
- currentPage = 'templates';
3233
- selectedApp = null;
3234
- selectedLoop = null;
3235
- showTemplateBuilder = false;
3236
- templateBuilderState = null;
3237
- document.title = 'Templates - RalphFlow Dashboard';
3238
- renderSidebar();
3239
- renderContent();
3240
- });
3241
-
3242
- fetchApps();
3243
- fetchNotifications();
3244
- connectWs();
3245
- })();
3246
- </script>
38
+ <script type="module" src="app.js"></script>
3247
39
  </body>
3248
40
  </html>