ralphflow 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -87
- package/dist/chunk-TCCMQDVT.js +505 -0
- package/dist/ralphflow.js +207 -275
- package/dist/server-DOSLU36L.js +821 -0
- package/package.json +1 -2
- package/src/dashboard/ui/index.html +2760 -350
- package/src/templates/code-implementation/loops/00-story-loop/prompt.md +12 -6
- package/src/templates/code-implementation/ralphflow.yaml +3 -0
- package/src/templates/research/ralphflow.yaml +4 -0
- package/dist/chunk-GVOJO5IN.js +0 -274
- package/dist/server-O6J52DZT.js +0 -323
|
@@ -312,6 +312,18 @@
|
|
|
312
312
|
border-color: var(--accent);
|
|
313
313
|
}
|
|
314
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); }
|
|
315
327
|
.btn:disabled { opacity: 0.5; cursor: default; }
|
|
316
328
|
.dirty-indicator {
|
|
317
329
|
font-size: 11px;
|
|
@@ -322,6 +334,264 @@
|
|
|
322
334
|
color: var(--green);
|
|
323
335
|
}
|
|
324
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
|
+
|
|
325
595
|
/* Tracker viewer */
|
|
326
596
|
.tracker-viewer {
|
|
327
597
|
background: var(--bg-surface);
|
|
@@ -336,6 +606,13 @@
|
|
|
336
606
|
white-space: pre-wrap;
|
|
337
607
|
word-wrap: break-word;
|
|
338
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
|
+
}
|
|
339
616
|
.tracker-viewer h1, .tracker-viewer h2, .tracker-viewer h3 {
|
|
340
617
|
font-family: var(--sans);
|
|
341
618
|
margin: 12px 0 6px;
|
|
@@ -380,457 +657,2590 @@
|
|
|
380
657
|
.status-dot.connected { background: var(--green); }
|
|
381
658
|
.status-dot.disconnected { background: var(--red); }
|
|
382
659
|
.status-dot.connecting { background: var(--yellow); }
|
|
383
|
-
</style>
|
|
384
|
-
</head>
|
|
385
|
-
<body>
|
|
386
660
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
}
|
|
403
684
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
}
|
|
409
727
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
}
|
|
421
767
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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>
|
|
1204
|
+
</head>
|
|
1205
|
+
<body>
|
|
1206
|
+
|
|
1207
|
+
<div class="header">
|
|
1208
|
+
<h1>RalphFlow Dashboard</h1>
|
|
1209
|
+
<span class="host" id="hostDisplay"></span>
|
|
1210
|
+
</div>
|
|
1211
|
+
|
|
1212
|
+
<div class="main">
|
|
1213
|
+
<div class="sidebar" id="sidebar">
|
|
1214
|
+
<div class="sidebar-section">
|
|
1215
|
+
<div class="sidebar-label">Apps</div>
|
|
1216
|
+
<div id="sidebarApps"></div>
|
|
1217
|
+
</div>
|
|
1218
|
+
<div class="sidebar-section" style="border-top: 1px solid var(--border)">
|
|
1219
|
+
<div class="sidebar-label">Manage</div>
|
|
1220
|
+
<div class="sidebar-item" id="templatesNav">Templates</div>
|
|
1221
|
+
</div>
|
|
1222
|
+
</div>
|
|
1223
|
+
<div class="content" id="content">
|
|
1224
|
+
<div class="content-empty">Select an app to view details</div>
|
|
1225
|
+
</div>
|
|
1226
|
+
</div>
|
|
1227
|
+
|
|
1228
|
+
<div class="statusbar">
|
|
1229
|
+
<span><span class="status-dot disconnected" id="statusDot"></span> <span id="statusText">Connecting...</span></span>
|
|
1230
|
+
<span>Last update: <span id="lastUpdate">--</span></span>
|
|
1231
|
+
<span>Events: <span id="eventCount">0</span></span>
|
|
1232
|
+
</div>
|
|
1233
|
+
|
|
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');
|
|
427
1267
|
const statusDot = $('#statusDot');
|
|
428
1268
|
const statusText = $('#statusText');
|
|
429
1269
|
const lastUpdate = $('#lastUpdate');
|
|
430
1270
|
const eventCountEl = $('#eventCount');
|
|
431
1271
|
|
|
432
|
-
hostDisplay.textContent = location.host;
|
|
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)}">×</button>
|
|
1569
|
+
</div>`;
|
|
1570
|
+
}
|
|
1571
|
+
} else {
|
|
1572
|
+
html += `<span class="bell-icon">🔔</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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/^> (.+)$/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">☑ $1</li>');
|
|
1822
|
+
html = html.replace(/^- \[ \] (.+)$/gm, '<li>☐ $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
|
+
}
|
|
433
2049
|
|
|
434
|
-
//
|
|
435
|
-
function
|
|
436
|
-
|
|
437
|
-
|
|
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
|
+
}
|
|
438
2059
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
+
}
|
|
444
2068
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
}
|
|
451
2077
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
+
}
|
|
455
2086
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
};
|
|
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 });
|
|
463
2093
|
}
|
|
464
2094
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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">🗃</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">▶</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">📄</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">×</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>';
|
|
487
2219
|
}
|
|
488
2220
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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] = [];
|
|
493
2277
|
}
|
|
494
2278
|
}
|
|
2279
|
+
|
|
2280
|
+
renderArchivesView(container, appName);
|
|
495
2281
|
}
|
|
496
2282
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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);
|
|
501
2289
|
}
|
|
502
2290
|
|
|
503
|
-
async function
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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)';
|
|
508
2300
|
}
|
|
509
2301
|
}
|
|
510
2302
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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">×</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';
|
|
519
2391
|
}
|
|
520
2392
|
}
|
|
521
2393
|
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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">×</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;
|
|
538
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';
|
|
539
2482
|
}
|
|
540
|
-
|
|
2483
|
+
}
|
|
541
2484
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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">×</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
|
+
}
|
|
548
2542
|
});
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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">✓ 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
|
+
});
|
|
556
2652
|
});
|
|
557
2653
|
});
|
|
558
2654
|
}
|
|
559
2655
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
+
};
|
|
567
2682
|
}
|
|
568
2683
|
|
|
569
|
-
function
|
|
570
|
-
|
|
571
|
-
promptDirty = false;
|
|
572
|
-
renderSidebar();
|
|
573
|
-
renderContent();
|
|
2684
|
+
function initTemplateBuilderState() {
|
|
2685
|
+
return { name: '', description: '', loops: [createEmptyLoop()] };
|
|
574
2686
|
}
|
|
575
2687
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
content.innerHTML = '<div class="content-empty">Select an app to view details</div>';
|
|
2688
|
+
async function renderTemplatesPage() {
|
|
2689
|
+
if (showTemplateBuilder) {
|
|
2690
|
+
renderTemplateBuilder();
|
|
580
2691
|
return;
|
|
581
2692
|
}
|
|
582
2693
|
|
|
583
|
-
|
|
584
|
-
|
|
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
|
+
}
|
|
585
2720
|
|
|
586
|
-
|
|
2721
|
+
content.innerHTML = html;
|
|
587
2722
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
2723
|
+
const createBtn = document.getElementById('createTemplateBtn');
|
|
2724
|
+
if (createBtn) {
|
|
2725
|
+
createBtn.addEventListener('click', () => {
|
|
2726
|
+
showTemplateBuilder = true;
|
|
2727
|
+
templateBuilderState = initTemplateBuilderState();
|
|
2728
|
+
renderTemplatesPage();
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
594
2731
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
app.loops.forEach((loop, i) => {
|
|
598
|
-
if (i > 0) html += '<div class="pipeline-connector"></div>';
|
|
599
|
-
const statusClass = getLoopStatusClass(loop);
|
|
600
|
-
const isSelected = loop.key === selectedLoop;
|
|
601
|
-
html += `<div class="pipeline-node${isSelected ? ' selected' : ''}" data-loop="${esc(loop.key)}">
|
|
602
|
-
<span class="node-name">${esc(loop.name)}</span>
|
|
603
|
-
<span class="node-status ${statusClass}">${statusClass}</span>
|
|
604
|
-
</div>`;
|
|
2732
|
+
content.querySelectorAll('[data-delete-template]').forEach(btn => {
|
|
2733
|
+
btn.addEventListener('click', () => openDeleteTemplateModal(btn.dataset.deleteTemplate));
|
|
605
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">← 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>`;
|
|
606
2755
|
html += '</div></div>';
|
|
607
2756
|
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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}">×</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}">×</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>
|
|
620
2817
|
</div>
|
|
621
|
-
<div class="meta-card"><div class="meta-label">Stages</div><div class="meta-value" style="font-size:11px">${(currentLoop.stages || []).join(' → ')}</div></div>
|
|
622
2818
|
</div>`;
|
|
2819
|
+
}
|
|
2820
|
+
html += '</div>';
|
|
623
2821
|
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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>';
|
|
634
2833
|
}
|
|
635
2834
|
|
|
636
|
-
html += '</div>';
|
|
2835
|
+
html += '</div>'; // close loop-card
|
|
2836
|
+
});
|
|
637
2837
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
<div class="editor-wrap">
|
|
642
|
-
<textarea class="editor" id="promptEditor" placeholder="Loading..."></textarea>
|
|
643
|
-
<div class="editor-actions">
|
|
644
|
-
<button class="btn btn-primary" id="savePromptBtn" disabled>Save</button>
|
|
645
|
-
<button class="btn" id="resetPromptBtn" disabled>Reset</button>
|
|
646
|
-
<span class="dirty-indicator" id="dirtyIndicator" style="display:none">Unsaved changes</span>
|
|
647
|
-
<span class="save-ok" id="saveOk" style="display:none">Saved</span>
|
|
648
|
-
</div>
|
|
649
|
-
</div>
|
|
650
|
-
</div>`;
|
|
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
|
|
651
2841
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
658
2855
|
|
|
659
2856
|
content.innerHTML = html;
|
|
2857
|
+
bindTemplateBuilderEvents();
|
|
2858
|
+
}
|
|
660
2859
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
2860
|
+
function bindTemplateBuilderEvents() {
|
|
2861
|
+
const backBtn = document.getElementById('builderBackBtn');
|
|
2862
|
+
if (backBtn) backBtn.addEventListener('click', () => {
|
|
2863
|
+
showTemplateBuilder = false;
|
|
2864
|
+
templateBuilderState = null;
|
|
2865
|
+
renderTemplatesPage();
|
|
664
2866
|
});
|
|
665
2867
|
|
|
666
|
-
|
|
667
|
-
if (
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
2868
|
+
const cancelBtn = document.getElementById('builderCancelBtn');
|
|
2869
|
+
if (cancelBtn) cancelBtn.addEventListener('click', () => {
|
|
2870
|
+
showTemplateBuilder = false;
|
|
2871
|
+
templateBuilderState = null;
|
|
2872
|
+
renderTemplatesPage();
|
|
2873
|
+
});
|
|
672
2874
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
if (!editor) return;
|
|
2875
|
+
const saveBtn = document.getElementById('builderSaveBtn');
|
|
2876
|
+
if (saveBtn) saveBtn.addEventListener('click', saveTemplate);
|
|
676
2877
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
+
});
|
|
683
2896
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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();
|
|
687
2925
|
});
|
|
2926
|
+
});
|
|
688
2927
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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 === ',') {
|
|
692
2933
|
e.preventDefault();
|
|
693
|
-
|
|
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
|
+
}
|
|
694
2944
|
}
|
|
695
2945
|
});
|
|
2946
|
+
});
|
|
696
2947
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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();
|
|
704
2956
|
});
|
|
705
|
-
}
|
|
706
|
-
editor.value = '(Error loading prompt)';
|
|
707
|
-
}
|
|
708
|
-
}
|
|
2957
|
+
});
|
|
709
2958
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
+
});
|
|
713
2968
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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();
|
|
719
2976
|
});
|
|
720
|
-
|
|
721
|
-
promptDirty = false;
|
|
722
|
-
updateDirtyState();
|
|
723
|
-
const saveOk = $('#saveOk');
|
|
724
|
-
if (saveOk) {
|
|
725
|
-
saveOk.style.display = 'inline';
|
|
726
|
-
setTimeout(() => { saveOk.style.display = 'none'; }, 2000);
|
|
727
|
-
}
|
|
728
|
-
} catch {
|
|
729
|
-
alert('Failed to save prompt');
|
|
730
|
-
}
|
|
2977
|
+
});
|
|
731
2978
|
}
|
|
732
2979
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
if (
|
|
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
|
+
});
|
|
740
3004
|
}
|
|
741
3005
|
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
if (
|
|
745
|
-
|
|
746
|
-
try {
|
|
747
|
-
const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/tracker`);
|
|
748
|
-
viewer.innerHTML = renderMarkdown(data.content || '(empty)');
|
|
749
|
-
} catch {
|
|
750
|
-
viewer.innerHTML = '(No tracker file found)';
|
|
3006
|
+
function updateYamlPreview() {
|
|
3007
|
+
const preview = document.getElementById('yamlPreview');
|
|
3008
|
+
if (preview && templateBuilderState) {
|
|
3009
|
+
preview.textContent = generateYamlPreview(templateBuilderState);
|
|
751
3010
|
}
|
|
752
3011
|
}
|
|
753
3012
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
+
}
|
|
760
3052
|
|
|
761
|
-
|
|
762
|
-
|
|
3053
|
+
yaml += ` model: ${loop.model || 'claude-sonnet-4-6'}\n`;
|
|
3054
|
+
yaml += ` cadence: 0\n`;
|
|
763
3055
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
inTable = true;
|
|
768
|
-
tableHtml = '<table>';
|
|
769
|
-
// Header row
|
|
770
|
-
const cells = line.split('|').filter(Boolean).map(c => c.trim());
|
|
771
|
-
tableHtml += '<thead><tr>' + cells.map(c => `<th>${esc(c)}</th>`).join('') + '</tr></thead><tbody>';
|
|
772
|
-
continue;
|
|
773
|
-
}
|
|
774
|
-
// Separator row
|
|
775
|
-
if (line.match(/^\|[\s\-|]+\|$/)) continue;
|
|
776
|
-
// Data row
|
|
777
|
-
const cells = line.split('|').filter(Boolean).map(c => c.trim());
|
|
778
|
-
tableHtml += '<tr>' + cells.map(c => `<td>${esc(c)}</td>`).join('') + '</tr>';
|
|
779
|
-
continue;
|
|
780
|
-
} else if (inTable) {
|
|
781
|
-
inTable = false;
|
|
782
|
-
tableHtml += '</tbody></table>';
|
|
783
|
-
html += tableHtml;
|
|
784
|
-
tableHtml = '';
|
|
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`; });
|
|
785
3059
|
}
|
|
3060
|
+
if (loop.entities && loop.entities.length > 0) {
|
|
3061
|
+
yaml += ` entities: [${loop.entities.join(', ')}]\n`;
|
|
3062
|
+
}
|
|
3063
|
+
});
|
|
786
3064
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
if (line.startsWith('## ')) { html += `<h2>${esc(line.slice(3))}</h2>`; continue; }
|
|
790
|
-
if (line.startsWith('# ')) { html += `<h1>${esc(line.slice(2))}</h1>`; continue; }
|
|
3065
|
+
return yaml;
|
|
3066
|
+
}
|
|
791
3067
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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;
|
|
796
3082
|
}
|
|
797
|
-
if (
|
|
798
|
-
|
|
799
|
-
|
|
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;
|
|
800
3090
|
}
|
|
801
|
-
|
|
802
|
-
// Regular lines
|
|
803
|
-
html += line.trim() === '' ? '<br>' : `<div>${esc(line)}</div>`;
|
|
804
3091
|
}
|
|
805
3092
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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...';
|
|
809
3125
|
}
|
|
810
3126
|
|
|
811
|
-
|
|
812
|
-
|
|
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();
|
|
813
3134
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
+
}
|
|
823
3155
|
}
|
|
824
3156
|
|
|
825
|
-
function
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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">×</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
|
+
});
|
|
830
3224
|
}
|
|
831
3225
|
|
|
3226
|
+
// -----------------------------------------------------------------------
|
|
832
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
|
+
|
|
833
3242
|
fetchApps();
|
|
3243
|
+
fetchNotifications();
|
|
834
3244
|
connectWs();
|
|
835
3245
|
})();
|
|
836
3246
|
</script>
|