ralphflow 0.2.0 → 0.4.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.
@@ -0,0 +1,838 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RalphFlow Dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117;
10
+ --bg-surface: #161b22;
11
+ --bg-hover: #1c2128;
12
+ --bg-active: #21262d;
13
+ --border: #30363d;
14
+ --text: #e6edf3;
15
+ --text-dim: #8b949e;
16
+ --text-muted: #484f58;
17
+ --accent: #58a6ff;
18
+ --green: #3fb950;
19
+ --blue: #58a6ff;
20
+ --yellow: #d29922;
21
+ --red: #f85149;
22
+ --purple: #bc8cff;
23
+ --mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
24
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
25
+ --radius: 6px;
26
+ }
27
+
28
+ * { margin: 0; padding: 0; box-sizing: border-box; }
29
+
30
+ body {
31
+ font-family: var(--sans);
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ height: 100vh;
35
+ display: flex;
36
+ flex-direction: column;
37
+ overflow: hidden;
38
+ }
39
+
40
+ /* Header */
41
+ .header {
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ padding: 12px 20px;
46
+ border-bottom: 1px solid var(--border);
47
+ background: var(--bg-surface);
48
+ flex-shrink: 0;
49
+ }
50
+ .header h1 {
51
+ font-size: 15px;
52
+ font-weight: 600;
53
+ letter-spacing: -0.3px;
54
+ }
55
+ .header .host {
56
+ font-family: var(--mono);
57
+ font-size: 12px;
58
+ color: var(--text-dim);
59
+ }
60
+
61
+ /* Main layout */
62
+ .main {
63
+ display: flex;
64
+ flex: 1;
65
+ overflow: hidden;
66
+ }
67
+
68
+ /* Sidebar */
69
+ .sidebar {
70
+ width: 240px;
71
+ border-right: 1px solid var(--border);
72
+ background: var(--bg-surface);
73
+ overflow-y: auto;
74
+ flex-shrink: 0;
75
+ }
76
+ .sidebar-section {
77
+ padding: 12px 0;
78
+ }
79
+ .sidebar-label {
80
+ padding: 0 16px;
81
+ font-size: 11px;
82
+ font-weight: 600;
83
+ text-transform: uppercase;
84
+ letter-spacing: 0.5px;
85
+ color: var(--text-dim);
86
+ margin-bottom: 4px;
87
+ }
88
+ .sidebar-item {
89
+ display: block;
90
+ padding: 6px 16px;
91
+ font-size: 13px;
92
+ color: var(--text-dim);
93
+ cursor: pointer;
94
+ border-left: 2px solid transparent;
95
+ transition: background 0.1s;
96
+ }
97
+ .sidebar-item:hover { background: var(--bg-hover); color: var(--text); }
98
+ .sidebar-item.active {
99
+ background: var(--bg-active);
100
+ color: var(--text);
101
+ border-left-color: var(--accent);
102
+ }
103
+ .sidebar-item.app-item {
104
+ font-weight: 600;
105
+ color: var(--text);
106
+ padding: 8px 16px;
107
+ }
108
+ .sidebar-item.loop-item { padding-left: 32px; font-size: 12px; }
109
+ .sidebar-item .badge {
110
+ font-size: 10px;
111
+ font-family: var(--mono);
112
+ padding: 1px 6px;
113
+ border-radius: 10px;
114
+ margin-left: 6px;
115
+ background: var(--bg-hover);
116
+ color: var(--text-dim);
117
+ }
118
+
119
+ /* Content */
120
+ .content {
121
+ flex: 1;
122
+ overflow-y: auto;
123
+ padding: 24px 32px;
124
+ }
125
+ .content-empty {
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ height: 100%;
130
+ color: var(--text-muted);
131
+ font-size: 14px;
132
+ }
133
+
134
+ /* App header */
135
+ .app-header { margin-bottom: 24px; }
136
+ .app-header h2 {
137
+ font-size: 20px;
138
+ font-weight: 600;
139
+ margin-bottom: 4px;
140
+ }
141
+ .app-type-badge {
142
+ display: inline-block;
143
+ font-family: var(--mono);
144
+ font-size: 11px;
145
+ color: var(--purple);
146
+ background: rgba(188, 140, 255, 0.1);
147
+ padding: 2px 8px;
148
+ border-radius: 4px;
149
+ margin-right: 8px;
150
+ }
151
+ .app-desc {
152
+ color: var(--text-dim);
153
+ font-size: 13px;
154
+ margin-top: 6px;
155
+ }
156
+
157
+ /* Pipeline view */
158
+ .pipeline {
159
+ display: flex;
160
+ align-items: center;
161
+ gap: 0;
162
+ margin-bottom: 28px;
163
+ padding: 16px 0;
164
+ }
165
+ .pipeline-node {
166
+ display: flex;
167
+ flex-direction: column;
168
+ align-items: center;
169
+ gap: 6px;
170
+ cursor: pointer;
171
+ padding: 12px 20px;
172
+ border-radius: var(--radius);
173
+ border: 1px solid var(--border);
174
+ background: var(--bg-surface);
175
+ transition: border-color 0.15s, background 0.15s;
176
+ min-width: 120px;
177
+ }
178
+ .pipeline-node:hover { border-color: var(--text-dim); }
179
+ .pipeline-node.selected { border-color: var(--accent); background: rgba(88, 166, 255, 0.05); }
180
+ .pipeline-node .node-name { font-size: 12px; font-weight: 600; }
181
+ .pipeline-node .node-status {
182
+ font-family: var(--mono);
183
+ font-size: 10px;
184
+ padding: 2px 8px;
185
+ border-radius: 10px;
186
+ }
187
+ .pipeline-node .node-status.complete { background: rgba(63,185,80,0.15); color: var(--green); }
188
+ .pipeline-node .node-status.running { background: rgba(88,166,255,0.15); color: var(--blue); }
189
+ .pipeline-node .node-status.pending { background: rgba(139,148,158,0.1); color: var(--text-muted); }
190
+ .pipeline-connector {
191
+ width: 32px;
192
+ height: 2px;
193
+ background: var(--border);
194
+ flex-shrink: 0;
195
+ }
196
+
197
+ /* Section */
198
+ .section {
199
+ margin-bottom: 24px;
200
+ }
201
+ .section-title {
202
+ font-size: 11px;
203
+ font-weight: 600;
204
+ text-transform: uppercase;
205
+ letter-spacing: 0.5px;
206
+ color: var(--text-dim);
207
+ margin-bottom: 12px;
208
+ }
209
+
210
+ /* Loop detail */
211
+ .loop-meta {
212
+ display: grid;
213
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
214
+ gap: 12px;
215
+ margin-bottom: 16px;
216
+ }
217
+ .meta-card {
218
+ padding: 12px;
219
+ background: var(--bg-surface);
220
+ border: 1px solid var(--border);
221
+ border-radius: var(--radius);
222
+ }
223
+ .meta-label {
224
+ font-size: 11px;
225
+ color: var(--text-dim);
226
+ margin-bottom: 4px;
227
+ }
228
+ .meta-value {
229
+ font-family: var(--mono);
230
+ font-size: 13px;
231
+ }
232
+
233
+ /* Progress bar */
234
+ .progress-bar {
235
+ height: 6px;
236
+ background: var(--bg-active);
237
+ border-radius: 3px;
238
+ overflow: hidden;
239
+ margin-top: 8px;
240
+ }
241
+ .progress-fill {
242
+ height: 100%;
243
+ background: var(--green);
244
+ border-radius: 3px;
245
+ transition: width 0.3s ease;
246
+ }
247
+
248
+ /* Agent table */
249
+ .agent-table {
250
+ width: 100%;
251
+ border-collapse: collapse;
252
+ font-size: 12px;
253
+ font-family: var(--mono);
254
+ }
255
+ .agent-table th {
256
+ text-align: left;
257
+ padding: 8px 12px;
258
+ border-bottom: 1px solid var(--border);
259
+ color: var(--text-dim);
260
+ font-weight: 500;
261
+ font-size: 11px;
262
+ }
263
+ .agent-table td {
264
+ padding: 8px 12px;
265
+ border-bottom: 1px solid var(--border);
266
+ color: var(--text);
267
+ }
268
+ .agent-table tr:hover td { background: var(--bg-hover); }
269
+
270
+ /* Editor */
271
+ .editor-wrap {
272
+ position: relative;
273
+ }
274
+ .editor {
275
+ width: 100%;
276
+ min-height: 300px;
277
+ background: var(--bg-surface);
278
+ border: 1px solid var(--border);
279
+ border-radius: var(--radius);
280
+ color: var(--text);
281
+ font-family: var(--mono);
282
+ font-size: 13px;
283
+ line-height: 1.6;
284
+ padding: 16px;
285
+ resize: vertical;
286
+ outline: none;
287
+ tab-size: 2;
288
+ }
289
+ .editor:focus { border-color: var(--accent); }
290
+ .editor-actions {
291
+ display: flex;
292
+ gap: 8px;
293
+ margin-top: 8px;
294
+ align-items: center;
295
+ }
296
+ .btn {
297
+ font-family: var(--sans);
298
+ font-size: 12px;
299
+ font-weight: 500;
300
+ padding: 6px 14px;
301
+ border-radius: var(--radius);
302
+ border: 1px solid var(--border);
303
+ cursor: pointer;
304
+ transition: background 0.1s, border-color 0.1s;
305
+ background: var(--bg-surface);
306
+ color: var(--text);
307
+ }
308
+ .btn:hover { background: var(--bg-hover); border-color: var(--text-dim); }
309
+ .btn-primary {
310
+ background: var(--accent);
311
+ color: #000;
312
+ border-color: var(--accent);
313
+ }
314
+ .btn-primary:hover { background: #79c0ff; }
315
+ .btn:disabled { opacity: 0.5; cursor: default; }
316
+ .dirty-indicator {
317
+ font-size: 11px;
318
+ color: var(--yellow);
319
+ }
320
+ .save-ok {
321
+ font-size: 11px;
322
+ color: var(--green);
323
+ }
324
+
325
+ /* Tracker viewer */
326
+ .tracker-viewer {
327
+ background: var(--bg-surface);
328
+ border: 1px solid var(--border);
329
+ border-radius: var(--radius);
330
+ padding: 16px;
331
+ font-family: var(--mono);
332
+ font-size: 13px;
333
+ line-height: 1.7;
334
+ max-height: 400px;
335
+ overflow-y: auto;
336
+ white-space: pre-wrap;
337
+ word-wrap: break-word;
338
+ }
339
+ .tracker-viewer h1, .tracker-viewer h2, .tracker-viewer h3 {
340
+ font-family: var(--sans);
341
+ margin: 12px 0 6px;
342
+ }
343
+ .tracker-viewer h1 { font-size: 16px; }
344
+ .tracker-viewer h2 { font-size: 14px; }
345
+ .tracker-viewer h3 { font-size: 13px; }
346
+ .tracker-viewer .cb-done { color: var(--green); }
347
+ .tracker-viewer .cb-todo { color: var(--text-muted); }
348
+ .tracker-viewer table {
349
+ border-collapse: collapse;
350
+ margin: 8px 0;
351
+ width: 100%;
352
+ }
353
+ .tracker-viewer th, .tracker-viewer td {
354
+ border: 1px solid var(--border);
355
+ padding: 4px 10px;
356
+ text-align: left;
357
+ font-size: 12px;
358
+ }
359
+ .tracker-viewer th { background: var(--bg-active); color: var(--text-dim); }
360
+
361
+ /* Status bar */
362
+ .statusbar {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 16px;
366
+ padding: 6px 20px;
367
+ border-top: 1px solid var(--border);
368
+ background: var(--bg-surface);
369
+ font-size: 11px;
370
+ color: var(--text-dim);
371
+ flex-shrink: 0;
372
+ }
373
+ .status-dot {
374
+ display: inline-block;
375
+ width: 7px;
376
+ height: 7px;
377
+ border-radius: 50%;
378
+ margin-right: 4px;
379
+ }
380
+ .status-dot.connected { background: var(--green); }
381
+ .status-dot.disconnected { background: var(--red); }
382
+ .status-dot.connecting { background: var(--yellow); }
383
+ </style>
384
+ </head>
385
+ <body>
386
+
387
+ <div class="header">
388
+ <h1>RalphFlow Dashboard</h1>
389
+ <span class="host" id="hostDisplay"></span>
390
+ </div>
391
+
392
+ <div class="main">
393
+ <div class="sidebar" id="sidebar">
394
+ <div class="sidebar-section">
395
+ <div class="sidebar-label">Apps</div>
396
+ <div id="sidebarApps"></div>
397
+ </div>
398
+ </div>
399
+ <div class="content" id="content">
400
+ <div class="content-empty">Select an app to view details</div>
401
+ </div>
402
+ </div>
403
+
404
+ <div class="statusbar">
405
+ <span><span class="status-dot disconnected" id="statusDot"></span> <span id="statusText">Connecting...</span></span>
406
+ <span>Last update: <span id="lastUpdate">--</span></span>
407
+ <span>Events: <span id="eventCount">0</span></span>
408
+ </div>
409
+
410
+ <script>
411
+ (function() {
412
+ // State
413
+ let apps = [];
414
+ let selectedApp = null;
415
+ let selectedLoop = null;
416
+ let eventCounter = 0;
417
+ let promptDirty = false;
418
+ let promptOriginal = '';
419
+ let ws = null;
420
+ let reconnectDelay = 1000;
421
+
422
+ // DOM refs
423
+ const $ = (sel) => document.querySelector(sel);
424
+ const hostDisplay = $('#hostDisplay');
425
+ const sidebarApps = $('#sidebarApps');
426
+ const content = $('#content');
427
+ const statusDot = $('#statusDot');
428
+ const statusText = $('#statusText');
429
+ const lastUpdate = $('#lastUpdate');
430
+ const eventCountEl = $('#eventCount');
431
+
432
+ hostDisplay.textContent = location.host;
433
+
434
+ // WebSocket
435
+ function connectWs() {
436
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
437
+ ws = new WebSocket(`${proto}//${location.host}/ws`);
438
+
439
+ ws.onopen = () => {
440
+ statusDot.className = 'status-dot connected';
441
+ statusText.textContent = 'Connected';
442
+ reconnectDelay = 1000;
443
+ };
444
+
445
+ ws.onclose = () => {
446
+ statusDot.className = 'status-dot disconnected';
447
+ statusText.textContent = 'Disconnected';
448
+ setTimeout(connectWs, reconnectDelay);
449
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000);
450
+ };
451
+
452
+ ws.onerror = () => {
453
+ ws.close();
454
+ };
455
+
456
+ ws.onmessage = (e) => {
457
+ const event = JSON.parse(e.data);
458
+ eventCounter++;
459
+ eventCountEl.textContent = eventCounter;
460
+ lastUpdate.textContent = new Date().toLocaleTimeString();
461
+ handleWsEvent(event);
462
+ };
463
+ }
464
+
465
+ function handleWsEvent(event) {
466
+ if (event.type === 'status:full') {
467
+ apps = event.apps;
468
+ renderSidebar();
469
+ if (selectedApp) {
470
+ const updated = apps.find(a => a.appName === selectedApp.appName);
471
+ if (updated) {
472
+ selectedApp = updated;
473
+ renderContent();
474
+ }
475
+ }
476
+ } else if (event.type === 'tracker:updated') {
477
+ if (selectedApp && selectedApp.appName === event.app) {
478
+ // Update the loop status in our local state
479
+ const loopEntry = selectedApp.loops.find(l => l.key === event.loop);
480
+ if (loopEntry) {
481
+ loopEntry.status = event.status;
482
+ }
483
+ renderContent();
484
+ // Refresh tracker viewer if this loop is selected
485
+ if (selectedLoop === event.loop) {
486
+ loadTracker(event.app, event.loop);
487
+ }
488
+ }
489
+ } else if (event.type === 'file:changed') {
490
+ if (selectedApp && selectedApp.appName === event.app) {
491
+ // Refresh status
492
+ fetchAppStatus(event.app);
493
+ }
494
+ }
495
+ }
496
+
497
+ // API helpers
498
+ async function fetchJson(url) {
499
+ const res = await fetch(url);
500
+ return res.json();
501
+ }
502
+
503
+ async function fetchApps() {
504
+ apps = await fetchJson('/api/apps');
505
+ renderSidebar();
506
+ if (apps.length > 0 && !selectedApp) {
507
+ selectApp(apps[0]);
508
+ }
509
+ }
510
+
511
+ async function fetchAppStatus(appName) {
512
+ const statuses = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/status`);
513
+ if (selectedApp && selectedApp.appName === appName) {
514
+ statuses.forEach(s => {
515
+ const loop = selectedApp.loops.find(l => l.key === s.key);
516
+ if (loop) loop.status = s;
517
+ });
518
+ renderContent();
519
+ }
520
+ }
521
+
522
+ // Sidebar
523
+ function renderSidebar() {
524
+ let html = '';
525
+ for (const app of apps) {
526
+ const appActive = selectedApp && selectedApp.appName === app.appName;
527
+ html += `<div class="sidebar-item app-item${appActive ? ' active' : ''}" data-app="${esc(app.appName)}">
528
+ ${esc(app.appName)}
529
+ <span class="badge">${esc(app.appType)}</span>
530
+ </div>`;
531
+ if (app.loops) {
532
+ for (const loop of app.loops) {
533
+ const loopActive = appActive && selectedLoop === loop.key;
534
+ html += `<div class="sidebar-item loop-item${loopActive ? ' active' : ''}" data-app="${esc(app.appName)}" data-loop="${esc(loop.key)}">
535
+ ${esc(loop.name)}
536
+ </div>`;
537
+ }
538
+ }
539
+ }
540
+ sidebarApps.innerHTML = html;
541
+
542
+ // Event delegation
543
+ sidebarApps.querySelectorAll('.app-item').forEach(el => {
544
+ el.addEventListener('click', () => {
545
+ const app = apps.find(a => a.appName === el.dataset.app);
546
+ if (app) selectApp(app);
547
+ });
548
+ });
549
+ sidebarApps.querySelectorAll('.loop-item').forEach(el => {
550
+ el.addEventListener('click', () => {
551
+ const app = apps.find(a => a.appName === el.dataset.app);
552
+ if (app) {
553
+ selectApp(app);
554
+ selectLoop(el.dataset.loop);
555
+ }
556
+ });
557
+ });
558
+ }
559
+
560
+ function selectApp(app) {
561
+ selectedApp = app;
562
+ selectedLoop = app.loops.length > 0 ? app.loops[0].key : null;
563
+ promptDirty = false;
564
+ renderSidebar();
565
+ renderContent();
566
+ fetchAppStatus(app.appName);
567
+ }
568
+
569
+ function selectLoop(loopKey) {
570
+ selectedLoop = loopKey;
571
+ promptDirty = false;
572
+ renderSidebar();
573
+ renderContent();
574
+ }
575
+
576
+ // Main content
577
+ function renderContent() {
578
+ if (!selectedApp) {
579
+ content.innerHTML = '<div class="content-empty">Select an app to view details</div>';
580
+ return;
581
+ }
582
+
583
+ const app = selectedApp;
584
+ const currentLoop = app.loops.find(l => l.key === selectedLoop);
585
+
586
+ let html = '';
587
+
588
+ // App header
589
+ html += `<div class="app-header">
590
+ <h2>${esc(app.appName)}</h2>
591
+ <span class="app-type-badge">${esc(app.appType)}</span>
592
+ ${app.description ? `<div class="app-desc">${esc(app.description)}</div>` : ''}
593
+ </div>`;
594
+
595
+ // Pipeline
596
+ html += '<div class="section"><div class="section-title">Pipeline</div><div class="pipeline">';
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>`;
605
+ });
606
+ html += '</div></div>';
607
+
608
+ // Loop detail
609
+ if (currentLoop) {
610
+ const st = currentLoop.status || {};
611
+ html += `<div class="section">
612
+ <div class="section-title">Loop Detail: ${esc(currentLoop.name)}</div>
613
+ <div class="loop-meta">
614
+ <div class="meta-card"><div class="meta-label">Stage</div><div class="meta-value">${esc(st.stage || '—')}</div></div>
615
+ <div class="meta-card"><div class="meta-label">Active</div><div class="meta-value">${esc(st.active || 'none')}</div></div>
616
+ <div class="meta-card">
617
+ <div class="meta-label">Progress</div>
618
+ <div class="meta-value">${st.completed || 0}/${st.total || 0}</div>
619
+ <div class="progress-bar"><div class="progress-fill" style="width:${st.total ? (st.completed / st.total * 100) : 0}%"></div></div>
620
+ </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
+ </div>`;
623
+
624
+ // Agent table
625
+ if (st.agents && st.agents.length > 0) {
626
+ html += `<div style="margin-bottom:16px">
627
+ <table class="agent-table">
628
+ <thead><tr><th>Agent</th><th>Active Task</th><th>Stage</th><th>Heartbeat</th></tr></thead>
629
+ <tbody>`;
630
+ for (const ag of st.agents) {
631
+ html += `<tr><td>${esc(ag.name)}</td><td>${esc(ag.activeTask)}</td><td>${esc(ag.stage)}</td><td>${esc(ag.lastHeartbeat)}</td></tr>`;
632
+ }
633
+ html += '</tbody></table></div>';
634
+ }
635
+
636
+ html += '</div>';
637
+
638
+ // Prompt editor
639
+ html += `<div class="section">
640
+ <div class="section-title">Prompt Editor</div>
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>`;
651
+
652
+ // Tracker viewer
653
+ html += `<div class="section">
654
+ <div class="section-title">Tracker (live, read-only)</div>
655
+ <div class="tracker-viewer" id="trackerViewer">Loading...</div>
656
+ </div>`;
657
+ }
658
+
659
+ content.innerHTML = html;
660
+
661
+ // Bind pipeline node clicks
662
+ content.querySelectorAll('.pipeline-node').forEach(el => {
663
+ el.addEventListener('click', () => selectLoop(el.dataset.loop));
664
+ });
665
+
666
+ // Load prompt + tracker
667
+ if (currentLoop) {
668
+ loadPrompt(app.appName, currentLoop.key);
669
+ loadTracker(app.appName, currentLoop.key);
670
+ }
671
+ }
672
+
673
+ async function loadPrompt(appName, loopKey) {
674
+ const editor = $('#promptEditor');
675
+ if (!editor) return;
676
+
677
+ try {
678
+ const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`);
679
+ editor.value = data.content || '';
680
+ promptOriginal = editor.value;
681
+ promptDirty = false;
682
+ updateDirtyState();
683
+
684
+ editor.addEventListener('input', () => {
685
+ promptDirty = editor.value !== promptOriginal;
686
+ updateDirtyState();
687
+ });
688
+
689
+ // Cmd/Ctrl+S to save
690
+ editor.addEventListener('keydown', (e) => {
691
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
692
+ e.preventDefault();
693
+ savePrompt(appName, loopKey);
694
+ }
695
+ });
696
+
697
+ const saveBtn = $('#savePromptBtn');
698
+ const resetBtn = $('#resetPromptBtn');
699
+ if (saveBtn) saveBtn.addEventListener('click', () => savePrompt(appName, loopKey));
700
+ if (resetBtn) resetBtn.addEventListener('click', () => {
701
+ editor.value = promptOriginal;
702
+ promptDirty = false;
703
+ updateDirtyState();
704
+ });
705
+ } catch {
706
+ editor.value = '(Error loading prompt)';
707
+ }
708
+ }
709
+
710
+ async function savePrompt(appName, loopKey) {
711
+ const editor = $('#promptEditor');
712
+ if (!editor || !promptDirty) return;
713
+
714
+ try {
715
+ await fetch(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`, {
716
+ method: 'PUT',
717
+ headers: { 'Content-Type': 'application/json' },
718
+ body: JSON.stringify({ content: editor.value }),
719
+ });
720
+ promptOriginal = editor.value;
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
+ }
731
+ }
732
+
733
+ function updateDirtyState() {
734
+ const saveBtn = $('#savePromptBtn');
735
+ const resetBtn = $('#resetPromptBtn');
736
+ const indicator = $('#dirtyIndicator');
737
+ if (saveBtn) saveBtn.disabled = !promptDirty;
738
+ if (resetBtn) resetBtn.disabled = !promptDirty;
739
+ if (indicator) indicator.style.display = promptDirty ? 'inline' : 'none';
740
+ }
741
+
742
+ async function loadTracker(appName, loopKey) {
743
+ const viewer = $('#trackerViewer');
744
+ if (!viewer) return;
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)';
751
+ }
752
+ }
753
+
754
+ // Minimal markdown renderer
755
+ function renderMarkdown(md) {
756
+ let html = '';
757
+ const lines = md.split('\n');
758
+ let inTable = false;
759
+ let tableHtml = '';
760
+
761
+ for (let i = 0; i < lines.length; i++) {
762
+ const line = lines[i];
763
+
764
+ // Table detection
765
+ if (line.match(/^\|.+\|$/)) {
766
+ if (!inTable) {
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 = '';
785
+ }
786
+
787
+ // Headers
788
+ if (line.startsWith('### ')) { html += `<h3>${esc(line.slice(4))}</h3>`; continue; }
789
+ if (line.startsWith('## ')) { html += `<h2>${esc(line.slice(3))}</h2>`; continue; }
790
+ if (line.startsWith('# ')) { html += `<h1>${esc(line.slice(2))}</h1>`; continue; }
791
+
792
+ // Checkboxes
793
+ if (line.match(/^- \[x\]/i)) {
794
+ html += `<div class="cb-done">${esc(line)}</div>`;
795
+ continue;
796
+ }
797
+ if (line.match(/^- \[ \]/)) {
798
+ html += `<div class="cb-todo">${esc(line)}</div>`;
799
+ continue;
800
+ }
801
+
802
+ // Regular lines
803
+ html += line.trim() === '' ? '<br>' : `<div>${esc(line)}</div>`;
804
+ }
805
+
806
+ if (inTable) {
807
+ tableHtml += '</tbody></table>';
808
+ html += tableHtml;
809
+ }
810
+
811
+ return html;
812
+ }
813
+
814
+ function getLoopStatusClass(loop) {
815
+ if (!loop.status) return 'pending';
816
+ const st = loop.status;
817
+ if (st.total > 0 && st.completed === st.total) return 'complete';
818
+ // Running if: has active agents, or has partial progress, or stage indicates activity (not idle/—)
819
+ if (st.agents && st.agents.length > 0) return 'running';
820
+ if (st.total > 0 && st.completed > 0 && st.completed < st.total) return 'running';
821
+ if (st.stage && st.stage !== '—' && st.stage !== 'idle') return 'running';
822
+ return 'pending';
823
+ }
824
+
825
+ function esc(s) {
826
+ if (s == null) return '';
827
+ const d = document.createElement('div');
828
+ d.textContent = String(s);
829
+ return d.innerHTML;
830
+ }
831
+
832
+ // Init
833
+ fetchApps();
834
+ connectWs();
835
+ })();
836
+ </script>
837
+ </body>
838
+ </html>