ralphflow 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,880 @@
1
+ // Main content rendering: pipeline, loop detail panels, prompt editing,
2
+ // tracker/config viewers, model selector, and app modals.
3
+
4
+ import { $, state, dom, actions } from './state.js';
5
+ import { esc, fetchJson, renderMarkdown, calculatePipelineProgress, getLoopStatusClass, formatModelName, extractNotifMessage } from './utils.js';
6
+ import { renderDecisionGroups, dismissNotification, dismissDecision } from './notifications.js';
7
+ import { switchAppTab, loadArchives } from './archives.js';
8
+
9
+ // -----------------------------------------------------------------------
10
+ // Main content renderer
11
+ // -----------------------------------------------------------------------
12
+
13
+ export function renderContent() {
14
+ if (state.currentPage === 'templates') {
15
+ actions.renderTemplatesPage();
16
+ return;
17
+ }
18
+ if (!state.selectedApp) {
19
+ dom.content.innerHTML = '<div class="content-empty">Select an app to view details</div>';
20
+ return;
21
+ }
22
+
23
+ const app = state.selectedApp;
24
+ const currentLoop = app.loops.find(l => l.key === state.selectedLoop);
25
+
26
+ let html = '';
27
+
28
+ // App header
29
+ html += `<div class="app-header">
30
+ <div style="display:flex;align-items:center;gap:10px;justify-content:space-between;width:100%">
31
+ <div style="display:flex;align-items:center;gap:10px">
32
+ <h2>${esc(app.appName)}</h2>
33
+ <span class="app-type-badge">${esc(app.appType)}</span>
34
+ </div>
35
+ <div style="display:flex;gap:6px">
36
+ <button class="btn btn-muted" style="font-size:12px;padding:4px 10px" onclick="openArchiveAppModal('${esc(app.appName)}')">Archive</button>
37
+ <button class="btn btn-danger" style="font-size:12px;padding:4px 10px" onclick="openDeleteAppModal('${esc(app.appName)}')">Delete</button>
38
+ </div>
39
+ </div>
40
+ ${app.description ? `<div class="app-desc">${esc(app.description)}</div>` : ''}
41
+ </div>`;
42
+
43
+ // App-level tabs: Loops | Archives
44
+ html += `<div class="app-tabs">
45
+ <button class="app-tab${state.activeAppTab === 'loops' ? ' active' : ''}" data-app-tab="loops">Loops</button>
46
+ <button class="app-tab${state.activeAppTab === 'archives' ? ' active' : ''}" data-app-tab="archives">Archives</button>
47
+ </div>`;
48
+
49
+ if (state.activeAppTab === 'archives') {
50
+ html += '<div id="archivesContainer">Loading archives...</div>';
51
+ dom.content.innerHTML = html;
52
+
53
+ // Bind app tab clicks
54
+ dom.content.querySelectorAll('.app-tab').forEach(tab => {
55
+ tab.addEventListener('click', () => switchAppTab(tab.dataset.appTab));
56
+ });
57
+
58
+ loadArchives(app.appName);
59
+ return;
60
+ }
61
+
62
+ // --- Loops tab content ---
63
+
64
+ // Pipeline
65
+ const pipelineProgress = calculatePipelineProgress(app.loops);
66
+ const progressByKey = {};
67
+ pipelineProgress.perLoop.forEach(p => { progressByKey[p.key] = p; });
68
+ html += '<div class="section"><div class="section-title">Pipeline</div><div class="pipeline">';
69
+ app.loops.forEach((loop, i) => {
70
+ if (i > 0) {
71
+ const prevComplete = getLoopStatusClass(app.loops[i - 1]) === 'complete';
72
+ const prevOut = app.loops[i - 1].feeds || [];
73
+ const curIn = loop.fed_by || [];
74
+ const sharedFiles = prevOut.filter(f => curIn.includes(f));
75
+ if (sharedFiles.length > 0) {
76
+ html += `<div class="pipeline-connector-wrap">
77
+ <div class="pipeline-connector${prevComplete ? ' complete' : ''}"></div>
78
+ <span class="connector-file" title="${esc(sharedFiles.join(', '))}">${esc(sharedFiles.join(', '))}</span>
79
+ </div>`;
80
+ } else {
81
+ html += `<div class="pipeline-connector${prevComplete ? ' complete' : ''}"></div>`;
82
+ }
83
+ }
84
+ const statusClass = getLoopStatusClass(loop);
85
+ const isSelected = loop.key === state.selectedLoop;
86
+ const modelDisplay = formatModelName(loop.model);
87
+ const lp = progressByKey[loop.key] || { completed: 0, total: 0, fraction: 0 };
88
+ const progressText = lp.total > 0 ? `${lp.completed}/${lp.total}` : '\u2014';
89
+ const progressPct = Math.round(lp.fraction * 100);
90
+ const fedBy = loop.fed_by || [];
91
+ const feeds = loop.feeds || [];
92
+ const inputIo = fedBy.length > 0 ? `<div class="node-io node-io-in">${fedBy.map(f => `<span class="node-io-label" title="input: ${esc(f)}">${esc(f)}</span>`).join('')}</div>` : '';
93
+ const outputIo = feeds.length > 0 ? `<div class="node-io node-io-out">${feeds.map(f => `<span class="node-io-label" title="output: ${esc(f)}">${esc(f)}</span>`).join('')}</div>` : '';
94
+ html += `<div class="pipeline-node${isSelected ? ' selected' : ''}" data-loop="${esc(loop.key)}">
95
+ ${inputIo}
96
+ <span class="node-name">${esc(loop.name)}</span>
97
+ <span class="node-status-row">
98
+ <span class="node-status ${statusClass}">${statusClass}</span>
99
+ <span class="node-model-sep">&middot;</span>
100
+ <span class="node-model${modelDisplay ? '' : ' node-model-default'}">${modelDisplay ? esc(modelDisplay) : 'default'}</span>
101
+ </span>
102
+ <div class="node-progress">
103
+ <span class="node-progress-text">${progressText}</span>
104
+ <div class="node-progress-bar"><div class="node-progress-fill" style="width:${progressPct}%"></div></div>
105
+ </div>
106
+ ${outputIo}
107
+ </div>`;
108
+ });
109
+ html += '</div></div>';
110
+
111
+ // Commands section
112
+ html += '<div class="section"><div class="section-title">Commands</div><div class="commands-list">';
113
+ app.loops.forEach(loop => {
114
+ const alias = loop.key.replace(/-loop$/, '');
115
+ let cmd = `npx ralphflow run ${alias} -f ${app.appName}`;
116
+ if (loop.multiAgent) cmd += ' --multi-agent';
117
+ if (loop.model) cmd += ` --model ${loop.model}`;
118
+ html += `<div class="cmd-item">
119
+ <span class="cmd-text">${esc(cmd)}</span>
120
+ <button class="cmd-copy" data-cmd="${esc(cmd)}">Copy</button>
121
+ </div>`;
122
+ });
123
+ const e2eCmd = `npx ralphflow e2e -f ${app.appName}`;
124
+ html += `<div class="cmd-item">
125
+ <span class="cmd-text">${esc(e2eCmd)}</span>
126
+ <button class="cmd-copy" data-cmd="${esc(e2eCmd)}">Copy</button>
127
+ </div>`;
128
+ html += '</div></div>';
129
+
130
+ // Loop detail — two-column three-panel layout
131
+ if (currentLoop) {
132
+ const st = currentLoop.status || {};
133
+
134
+ html += '<div class="panel-grid">';
135
+
136
+ // Left column: Interactive + Progress
137
+ html += '<div class="panel-col-left">';
138
+
139
+ // Interactive panel — Notifications + Decisions
140
+ const loopNotifs = state.notificationsList.filter(n => n.app === app.appName && n.loop === currentLoop.key);
141
+ const loopDecisions = state.decisionsList.filter(d => d.app === app.appName && d.loop === currentLoop.key);
142
+ const hasNotifs = loopNotifs.length > 0;
143
+ const hasDecisions = loopDecisions.length > 0;
144
+ const interactiveTotal = loopNotifs.length + loopDecisions.length;
145
+ const hasInteractive = hasNotifs || hasDecisions;
146
+ html += `<div class="panel panel-interactive${hasInteractive ? ' has-notifs' : ''}">
147
+ <div class="panel-header">Interactive${interactiveTotal > 0 ? ' <span style="color:var(--accent)">(' + interactiveTotal + ')</span>' : ''}</div>
148
+ <div class="panel-body">`;
149
+
150
+ if (!hasInteractive) {
151
+ html += `<span class="bell-icon">&#128276;</span><span>No notifications or decisions</span>`;
152
+ } else {
153
+ // Notifications section
154
+ if (hasNotifs) {
155
+ html += '<div class="interactive-section-header">Notifications</div>';
156
+ for (const n of loopNotifs) {
157
+ const time = new Date(n.timestamp).toLocaleTimeString();
158
+ const msg = extractNotifMessage(n.payload);
159
+ html += `<div class="notif-card" data-notif-id="${esc(n.id)}">
160
+ <span class="notif-time">${esc(time)}</span>
161
+ <span class="notif-msg">${esc(msg)}</span>
162
+ <button class="notif-dismiss" data-dismiss-id="${esc(n.id)}">&times;</button>
163
+ </div>`;
164
+ }
165
+ }
166
+
167
+ // Decisions section with nested grouping
168
+ if (hasDecisions) {
169
+ html += '<div class="interactive-section-header">Decisions</div>';
170
+ html += renderDecisionGroups(loopDecisions);
171
+ }
172
+ }
173
+ html += '</div></div>';
174
+
175
+ // Progress panel
176
+ html += `<div class="panel panel-progress">
177
+ <div class="panel-header">Progress</div>
178
+ <div class="panel-body">
179
+ <div class="loop-meta">
180
+ <div class="meta-card"><div class="meta-label">Stage</div><div class="meta-value">${esc(st.stage || '—')}</div></div>
181
+ <div class="meta-card"><div class="meta-label">Active</div><div class="meta-value">${esc(st.active || 'none')}</div></div>
182
+ <div class="meta-card">
183
+ <div class="meta-label">Progress</div>
184
+ <div class="meta-value">${st.completed || 0}/${st.total || 0}</div>
185
+ <div class="progress-bar"><div class="progress-fill" style="width:${st.total ? (st.completed / st.total * 100) : 0}%"></div></div>
186
+ </div>
187
+ <div class="meta-card"><div class="meta-label">Stages</div><div class="meta-value" style="font-size:11px">${(currentLoop.stages || []).join(' → ')}</div></div>
188
+ </div>`;
189
+
190
+ // Agent table
191
+ if (st.agents && st.agents.length > 0) {
192
+ html += `<div style="margin-top:16px">
193
+ <table class="agent-table">
194
+ <thead><tr><th>Agent</th><th>Active Task</th><th>Stage</th><th>Heartbeat</th></tr></thead>
195
+ <tbody>`;
196
+ for (const ag of st.agents) {
197
+ html += `<tr><td>${esc(ag.name)}</td><td>${esc(ag.activeTask)}</td><td>${esc(ag.stage)}</td><td>${esc(ag.lastHeartbeat)}</td></tr>`;
198
+ }
199
+ html += '</tbody></table></div>';
200
+ }
201
+
202
+ // Tracker viewer (inside Progress panel)
203
+ html += `<div class="tracker-viewer" id="trackerViewer">Loading...</div>`;
204
+
205
+ html += '</div></div>'; // close .panel-body + .panel-progress
206
+ html += '</div>'; // close .panel-col-left
207
+
208
+ // Right column: Edit panel with tabs
209
+ html += `<div class="panel panel-edit">
210
+ <div class="edit-tabs">
211
+ <button class="edit-tab${state.activeEditTab === 'prompt' ? ' active' : ''}" data-tab="prompt">Prompt</button>
212
+ <button class="edit-tab${state.activeEditTab === 'tracker' ? ' active' : ''}" data-tab="tracker">Tracker</button>
213
+ <button class="edit-tab${state.activeEditTab === 'config' ? ' active' : ''}" data-tab="config">Config</button>
214
+ <div class="model-selector-wrap">
215
+ <label>Model</label>
216
+ <select class="model-selector" id="modelSelector">
217
+ <option value="">Default</option>
218
+ <option value="claude-opus-4-6">claude-opus-4-6</option>
219
+ <option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
220
+ <option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
221
+ </select>
222
+ <span class="model-save-ok" id="modelSaveOk">Saved</span>
223
+ </div>
224
+ </div>
225
+ <div class="panel-body" id="editTabContent"></div>
226
+ </div>`;
227
+
228
+ html += '</div>'; // close .panel-grid
229
+ }
230
+
231
+ dom.content.innerHTML = html;
232
+
233
+ // Bind app-level tab clicks
234
+ dom.content.querySelectorAll('.app-tab').forEach(tab => {
235
+ tab.addEventListener('click', () => switchAppTab(tab.dataset.appTab));
236
+ });
237
+
238
+ // Bind pipeline node clicks
239
+ dom.content.querySelectorAll('.pipeline-node').forEach(el => {
240
+ el.addEventListener('click', () => actions.selectLoop(el.dataset.loop));
241
+ });
242
+
243
+ // Bind command copy buttons
244
+ dom.content.querySelectorAll('.commands-list .cmd-copy').forEach(btn => {
245
+ btn.addEventListener('click', () => {
246
+ const cmd = btn.dataset.cmd || '';
247
+ navigator.clipboard.writeText(cmd).then(() => {
248
+ const orig = btn.textContent;
249
+ btn.textContent = 'Copied!';
250
+ setTimeout(() => { btn.textContent = orig; }, 1500);
251
+ });
252
+ });
253
+ });
254
+
255
+ // Bind notification dismiss buttons
256
+ dom.content.querySelectorAll('.notif-dismiss').forEach(btn => {
257
+ btn.addEventListener('click', () => dismissNotification(btn.dataset.dismissId));
258
+ });
259
+
260
+ // Bind decision dismiss buttons
261
+ dom.content.querySelectorAll('.decision-dismiss').forEach(btn => {
262
+ btn.addEventListener('click', () => dismissDecision(btn.dataset.dismissDecisionId));
263
+ });
264
+
265
+ // Bind decision group collapse/expand
266
+ dom.content.querySelectorAll('.decision-group-header').forEach(hdr => {
267
+ hdr.addEventListener('click', () => {
268
+ const groupId = hdr.dataset.decisionGroup;
269
+ const body = document.getElementById(groupId);
270
+ const chevron = hdr.querySelector('.group-chevron');
271
+ if (body && chevron) {
272
+ body.classList.toggle('collapsed');
273
+ chevron.classList.toggle('expanded');
274
+ }
275
+ });
276
+ });
277
+
278
+ // Bind edit tabs + load content
279
+ if (currentLoop) {
280
+ dom.content.querySelectorAll('.edit-tab').forEach(tab => {
281
+ tab.addEventListener('click', () => switchEditTab(tab.dataset.tab, app.appName, currentLoop.key));
282
+ });
283
+ renderEditTabContent(app.appName, currentLoop.key);
284
+ loadTracker(app.appName, currentLoop.key);
285
+ loadModelSelector(app.appName, currentLoop.key);
286
+ }
287
+ }
288
+
289
+ // -----------------------------------------------------------------------
290
+ // Edit panel: prompt, tracker, config tabs
291
+ // -----------------------------------------------------------------------
292
+
293
+ function bindPromptEditor(appName, loopKey) {
294
+ const editor = $('#promptEditor');
295
+ if (!editor) return;
296
+
297
+ editor.addEventListener('input', () => {
298
+ state.promptDirty = editor.value !== state.promptOriginal;
299
+ updateDirtyState();
300
+ });
301
+
302
+ editor.addEventListener('keydown', (e) => {
303
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
304
+ e.preventDefault();
305
+ savePrompt(appName, loopKey);
306
+ }
307
+ });
308
+
309
+ const saveBtn = $('#savePromptBtn');
310
+ const resetBtn = $('#resetPromptBtn');
311
+ if (saveBtn) saveBtn.addEventListener('click', () => savePrompt(appName, loopKey));
312
+ if (resetBtn) resetBtn.addEventListener('click', () => {
313
+ editor.value = state.promptOriginal;
314
+ state.promptDirty = false;
315
+ updateDirtyState();
316
+ });
317
+ }
318
+
319
+ async function loadPrompt(appName, loopKey) {
320
+ const editor = $('#promptEditor');
321
+ if (!editor) return;
322
+
323
+ try {
324
+ const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`);
325
+ editor.value = data.content || '';
326
+ state.promptOriginal = editor.value;
327
+ state.promptDirty = false;
328
+ updateDirtyState();
329
+ } catch {
330
+ editor.value = '(Error loading prompt)';
331
+ }
332
+ bindPromptEditor(appName, loopKey);
333
+ }
334
+
335
+ async function loadPromptPreview(appName, loopKey) {
336
+ const preview = $('#promptPreview');
337
+ if (!preview) return;
338
+ try {
339
+ const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`);
340
+ state.promptOriginal = data.content || '';
341
+ preview.innerHTML = renderMarkdown(state.promptOriginal);
342
+ } catch {
343
+ preview.innerHTML = '<p style="color:var(--text-dim)">(Error loading prompt)</p>';
344
+ }
345
+ }
346
+
347
+ async function savePrompt(appName, loopKey) {
348
+ const editor = $('#promptEditor');
349
+ if (!editor || !state.promptDirty) return;
350
+
351
+ try {
352
+ await fetch(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`, {
353
+ method: 'PUT',
354
+ headers: { 'Content-Type': 'application/json' },
355
+ body: JSON.stringify({ content: editor.value }),
356
+ });
357
+ state.promptOriginal = editor.value;
358
+ state.promptDirty = false;
359
+ updateDirtyState();
360
+ const saveOk = $('#saveOk');
361
+ if (saveOk) {
362
+ saveOk.style.display = 'inline';
363
+ setTimeout(() => { saveOk.style.display = 'none'; }, 2000);
364
+ }
365
+ } catch {
366
+ alert('Failed to save prompt');
367
+ }
368
+ }
369
+
370
+ function updateDirtyState() {
371
+ const saveBtn = $('#savePromptBtn');
372
+ const resetBtn = $('#resetPromptBtn');
373
+ const indicator = $('#dirtyIndicator');
374
+ if (saveBtn) saveBtn.disabled = !state.promptDirty;
375
+ if (resetBtn) resetBtn.disabled = !state.promptDirty;
376
+ if (indicator) indicator.style.display = state.promptDirty ? 'inline' : 'none';
377
+ }
378
+
379
+ export async function loadTracker(appName, loopKey) {
380
+ const viewer = $('#trackerViewer');
381
+ if (!viewer) return;
382
+
383
+ try {
384
+ const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/tracker`);
385
+ viewer.innerHTML = renderMarkdown(data.content || '(empty)');
386
+ } catch {
387
+ viewer.innerHTML = '(No tracker file found)';
388
+ }
389
+ }
390
+
391
+ function switchEditTab(tab, appName, loopKey) {
392
+ if (tab === state.activeEditTab) return;
393
+ if (state.activeEditTab === 'prompt') {
394
+ const editor = $('#promptEditor');
395
+ if (editor) {
396
+ state.cachedPromptValue = editor.value;
397
+ }
398
+ }
399
+ state.activeEditTab = tab;
400
+ document.querySelectorAll('.edit-tab').forEach(t => {
401
+ t.classList.toggle('active', t.dataset.tab === tab);
402
+ });
403
+ renderEditTabContent(appName, loopKey);
404
+ }
405
+
406
+ function renderEditTabContent(appName, loopKey) {
407
+ const container = $('#editTabContent');
408
+ if (!container) return;
409
+
410
+ if (state.activeEditTab === 'prompt') {
411
+ const isRead = state.promptViewMode === 'read';
412
+ const isEdit = state.promptViewMode === 'edit';
413
+ container.innerHTML = `
414
+ <div class="prompt-mode-toggle">
415
+ <button class="prompt-mode-btn${isRead ? ' active' : ''}" data-mode="read">Read</button>
416
+ <button class="prompt-mode-btn${isEdit ? ' active' : ''}" data-mode="edit">Edit</button>
417
+ </div>
418
+ ${isEdit ? `<div class="editor-wrap">
419
+ <textarea class="editor" id="promptEditor" placeholder="Loading..."></textarea>
420
+ <div class="editor-actions">
421
+ <button class="btn btn-primary" id="savePromptBtn" disabled>Save</button>
422
+ <button class="btn" id="resetPromptBtn" disabled>Reset</button>
423
+ <span class="dirty-indicator" id="dirtyIndicator" style="display:none">Unsaved changes</span>
424
+ <span class="save-ok" id="saveOk" style="display:none">Saved</span>
425
+ </div>
426
+ </div>` : `<div class="prompt-preview" id="promptPreview">Loading...</div>`}`;
427
+ // Bind toggle buttons
428
+ container.querySelectorAll('.prompt-mode-btn').forEach(btn => {
429
+ btn.addEventListener('click', () => {
430
+ if (btn.dataset.mode === state.promptViewMode) return;
431
+ // Cache editor value before switching away from edit
432
+ if (state.promptViewMode === 'edit') {
433
+ const editor = $('#promptEditor');
434
+ if (editor) state.cachedPromptValue = editor.value;
435
+ }
436
+ state.promptViewMode = btn.dataset.mode;
437
+ renderEditTabContent(appName, loopKey);
438
+ });
439
+ });
440
+ if (isEdit) {
441
+ if (state.cachedPromptValue !== null) {
442
+ const editor = $('#promptEditor');
443
+ if (editor) {
444
+ editor.value = state.cachedPromptValue;
445
+ updateDirtyState();
446
+ bindPromptEditor(appName, loopKey);
447
+ }
448
+ state.cachedPromptValue = null;
449
+ } else {
450
+ loadPrompt(appName, loopKey);
451
+ }
452
+ } else {
453
+ // Read mode — render markdown preview
454
+ if (state.cachedPromptValue !== null || state.promptOriginal) {
455
+ const content = state.cachedPromptValue !== null ? state.cachedPromptValue : state.promptOriginal;
456
+ const preview = $('#promptPreview');
457
+ if (preview) preview.innerHTML = renderMarkdown(content);
458
+ } else {
459
+ // Need to fetch
460
+ loadPromptPreview(appName, loopKey);
461
+ }
462
+ }
463
+ } else if (state.activeEditTab === 'tracker') {
464
+ container.innerHTML = '<pre class="code-viewer" id="editTrackerViewer">Loading...</pre>';
465
+ loadEditTracker(appName, loopKey);
466
+ } else if (state.activeEditTab === 'config') {
467
+ container.innerHTML = '<pre class="code-viewer" id="editConfigViewer">Loading...</pre>';
468
+ loadEditConfig(appName);
469
+ }
470
+ }
471
+
472
+ async function loadEditTracker(appName, loopKey) {
473
+ const viewer = $('#editTrackerViewer');
474
+ if (!viewer) return;
475
+ try {
476
+ const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/tracker`);
477
+ viewer.textContent = data.content || '(empty)';
478
+ } catch {
479
+ viewer.textContent = '(No tracker file found)';
480
+ }
481
+ }
482
+
483
+ async function loadEditConfig(appName) {
484
+ const viewer = $('#editConfigViewer');
485
+ if (!viewer) return;
486
+ try {
487
+ const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/config`);
488
+ viewer.textContent = data._rawYaml || JSON.stringify(data, null, 2);
489
+ } catch {
490
+ viewer.textContent = '(Error loading config)';
491
+ }
492
+ }
493
+
494
+ async function loadModelSelector(appName, loopKey) {
495
+ const selector = $('#modelSelector');
496
+ if (!selector) return;
497
+
498
+ try {
499
+ const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/config`);
500
+ const loopConfig = data.loops && data.loops[loopKey];
501
+ const currentModel = loopConfig && loopConfig.model ? loopConfig.model : '';
502
+ selector.value = currentModel;
503
+ } catch {
504
+ // Leave at default
505
+ }
506
+
507
+ selector.addEventListener('change', () => changeModel(appName, loopKey, selector.value));
508
+ }
509
+
510
+ async function changeModel(appName, loopKey, model) {
511
+ const saveOk = $('#modelSaveOk');
512
+ try {
513
+ await fetch(`/api/apps/${encodeURIComponent(appName)}/config/model`, {
514
+ method: 'PUT',
515
+ headers: { 'Content-Type': 'application/json' },
516
+ body: JSON.stringify({ loop: loopKey, model: model || null }),
517
+ });
518
+ if (saveOk) {
519
+ saveOk.classList.add('visible');
520
+ setTimeout(() => saveOk.classList.remove('visible'), 2000);
521
+ }
522
+ // Refresh config tab if it's currently visible
523
+ if (state.activeEditTab === 'config') {
524
+ loadEditConfig(appName);
525
+ }
526
+ } catch {
527
+ alert('Failed to update model');
528
+ }
529
+ }
530
+
531
+ // -----------------------------------------------------------------------
532
+ // App modals: Delete, Archive, Create
533
+ // -----------------------------------------------------------------------
534
+
535
+ export function openDeleteAppModal(appName) {
536
+ const existing = document.querySelector('.modal-overlay');
537
+ if (existing) existing.remove();
538
+
539
+ const overlay = document.createElement('div');
540
+ overlay.className = 'modal-overlay';
541
+ overlay.innerHTML = `
542
+ <div class="modal">
543
+ <div class="modal-header">
544
+ <h3>Delete App</h3>
545
+ <button class="modal-close" data-action="close">&times;</button>
546
+ </div>
547
+ <div class="modal-body" id="deleteModalBody">
548
+ <p style="margin-bottom:12px">Are you sure you want to delete <strong>${esc(appName)}</strong>?</p>
549
+ <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>
550
+ <div id="deleteModalMessage"></div>
551
+ </div>
552
+ <div class="modal-footer">
553
+ <button class="btn" data-action="close">Cancel</button>
554
+ <button class="btn btn-danger" id="deleteModalBtn">Delete</button>
555
+ </div>
556
+ </div>
557
+ `;
558
+
559
+ document.body.appendChild(overlay);
560
+
561
+ const escHandler = (e) => {
562
+ if (e.key === 'Escape') {
563
+ overlay.remove();
564
+ document.removeEventListener('keydown', escHandler);
565
+ }
566
+ };
567
+
568
+ overlay.addEventListener('click', (e) => {
569
+ if (e.target === overlay || e.target.dataset.action === 'close') {
570
+ overlay.remove();
571
+ document.removeEventListener('keydown', escHandler);
572
+ }
573
+ });
574
+
575
+ document.addEventListener('keydown', escHandler);
576
+
577
+ overlay.querySelector('#deleteModalBtn').addEventListener('click', () => submitDeleteApp(overlay, appName));
578
+ }
579
+
580
+ async function submitDeleteApp(overlay, appName) {
581
+ const msgEl = overlay.querySelector('#deleteModalMessage');
582
+ const deleteBtn = overlay.querySelector('#deleteModalBtn');
583
+
584
+ deleteBtn.disabled = true;
585
+ deleteBtn.textContent = 'Deleting...';
586
+ msgEl.innerHTML = '';
587
+
588
+ try {
589
+ const res = await fetch('/api/apps/' + encodeURIComponent(appName), { method: 'DELETE' });
590
+ const data = await res.json();
591
+
592
+ if (!res.ok) {
593
+ msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to delete app')}</div>`;
594
+ deleteBtn.disabled = false;
595
+ deleteBtn.textContent = 'Delete';
596
+ return;
597
+ }
598
+
599
+ overlay.remove();
600
+
601
+ // Clean up client state
602
+ state.notificationsList = state.notificationsList.filter(n => n.app !== appName);
603
+ state.decisionsList = state.decisionsList.filter(d => d.app !== appName);
604
+ if (state.selectedApp && state.selectedApp.appName === appName) {
605
+ state.selectedApp = null;
606
+ state.selectedLoop = null;
607
+ document.title = 'RalphFlow Dashboard';
608
+ }
609
+ actions.fetchApps();
610
+ } catch (err) {
611
+ msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
612
+ deleteBtn.disabled = false;
613
+ deleteBtn.textContent = 'Delete';
614
+ }
615
+ }
616
+
617
+ export function openArchiveAppModal(appName) {
618
+ const existing = document.querySelector('.modal-overlay');
619
+ if (existing) existing.remove();
620
+
621
+ const overlay = document.createElement('div');
622
+ overlay.className = 'modal-overlay';
623
+ overlay.innerHTML = `
624
+ <div class="modal">
625
+ <div class="modal-header">
626
+ <h3>Archive App</h3>
627
+ <button class="modal-close" data-action="close">&times;</button>
628
+ </div>
629
+ <div class="modal-body" id="archiveModalBody">
630
+ <p style="margin-bottom:12px">Archive <strong>${esc(appName)}</strong>?</p>
631
+ <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>
632
+ <ul style="color:var(--text-dim);font-size:13px;margin-left:18px;margin-bottom:12px;line-height:1.6">
633
+ <li>Stories, tasks, and trackers saved to <code style="font-family:var(--mono);font-size:12px;color:var(--text)">.archives/</code></li>
634
+ <li>Tracker and data files reset to template defaults</li>
635
+ <li>Prompts and config preserved</li>
636
+ </ul>
637
+ <div id="archiveModalMessage"></div>
638
+ </div>
639
+ <div class="modal-footer" id="archiveModalFooter">
640
+ <button class="btn" data-action="close">Cancel</button>
641
+ <button class="btn btn-primary" id="archiveModalBtn">Archive</button>
642
+ </div>
643
+ </div>
644
+ `;
645
+
646
+ document.body.appendChild(overlay);
647
+
648
+ const escHandler = (e) => {
649
+ if (e.key === 'Escape') {
650
+ overlay.remove();
651
+ document.removeEventListener('keydown', escHandler);
652
+ }
653
+ };
654
+
655
+ overlay.addEventListener('click', (e) => {
656
+ if (e.target === overlay || e.target.dataset.action === 'close') {
657
+ overlay.remove();
658
+ document.removeEventListener('keydown', escHandler);
659
+ }
660
+ });
661
+
662
+ document.addEventListener('keydown', escHandler);
663
+
664
+ overlay.querySelector('#archiveModalBtn').addEventListener('click', () => submitArchiveApp(overlay, appName));
665
+ }
666
+
667
+ async function submitArchiveApp(overlay, appName) {
668
+ const msgEl = overlay.querySelector('#archiveModalMessage');
669
+ const archiveBtn = overlay.querySelector('#archiveModalBtn');
670
+
671
+ archiveBtn.disabled = true;
672
+ archiveBtn.textContent = 'Archiving...';
673
+ msgEl.innerHTML = '';
674
+
675
+ try {
676
+ const res = await fetch('/api/apps/' + encodeURIComponent(appName) + '/archive', { method: 'POST' });
677
+ const data = await res.json();
678
+
679
+ if (!res.ok) {
680
+ msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to archive app')}</div>`;
681
+ archiveBtn.disabled = false;
682
+ archiveBtn.textContent = 'Archive';
683
+ return;
684
+ }
685
+
686
+ // Show success state
687
+ const body = overlay.querySelector('#archiveModalBody');
688
+ const footer = overlay.querySelector('#archiveModalFooter');
689
+
690
+ body.innerHTML = `
691
+ <p style="color:var(--green);margin-bottom:12px">Archived successfully.</p>
692
+ <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>
693
+ <p style="font-size:13px;color:var(--text-dim);margin-top:8px">Timestamp: <strong style="color:var(--text)">${esc(data.timestamp)}</strong></p>
694
+ `;
695
+ footer.innerHTML = `<button class="btn btn-primary" data-action="close">Done</button>`;
696
+
697
+ // Clean up client state and refresh
698
+ state.notificationsList = state.notificationsList.filter(n => n.app !== appName);
699
+ state.decisionsList = state.decisionsList.filter(d => d.app !== appName);
700
+ actions.fetchApps();
701
+ } catch (err) {
702
+ msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
703
+ archiveBtn.disabled = false;
704
+ archiveBtn.textContent = 'Archive';
705
+ }
706
+ }
707
+
708
+ export async function openCreateAppModal() {
709
+ // Remove any existing modal
710
+ const existing = document.querySelector('.modal-overlay');
711
+ if (existing) existing.remove();
712
+
713
+ // Fetch available templates (built-in + custom)
714
+ let templates = [];
715
+ try {
716
+ templates = await fetchJson('/api/templates');
717
+ } catch {
718
+ templates = [
719
+ { name: 'code-implementation', type: 'built-in' },
720
+ { name: 'research', type: 'built-in' }
721
+ ];
722
+ }
723
+
724
+ let optionsHtml = '';
725
+ for (const tpl of templates) {
726
+ optionsHtml += `<option value="${esc(tpl.name)}">${esc(tpl.name)}${tpl.type === 'custom' ? ' (custom)' : ''}</option>`;
727
+ }
728
+
729
+ const overlay = document.createElement('div');
730
+ overlay.className = 'modal-overlay';
731
+ overlay.innerHTML = `
732
+ <div class="modal">
733
+ <div class="modal-header">
734
+ <h3>Create New App</h3>
735
+ <button class="modal-close" data-action="close">&times;</button>
736
+ </div>
737
+ <div class="modal-body" id="modalBody">
738
+ <div class="form-group">
739
+ <label class="form-label">Template</label>
740
+ <select class="form-select" id="modalTemplate">
741
+ ${optionsHtml}
742
+ </select>
743
+ </div>
744
+ <div class="form-group">
745
+ <label class="form-label">App Name</label>
746
+ <input class="form-input" id="modalName" type="text" placeholder="my-feature" autocomplete="off">
747
+ </div>
748
+ <div id="modalMessage"></div>
749
+ </div>
750
+ <div class="modal-footer" id="modalFooter">
751
+ <button class="btn" data-action="close">Cancel</button>
752
+ <button class="btn btn-primary" id="modalCreateBtn">Create</button>
753
+ </div>
754
+ </div>
755
+ `;
756
+
757
+ document.body.appendChild(overlay);
758
+
759
+ // Close on overlay click or close buttons
760
+ overlay.addEventListener('click', (e) => {
761
+ if (e.target === overlay || e.target.dataset.action === 'close') {
762
+ overlay.remove();
763
+ }
764
+ });
765
+
766
+ // Close on Escape
767
+ const escHandler = (e) => {
768
+ if (e.key === 'Escape') {
769
+ overlay.remove();
770
+ document.removeEventListener('keydown', escHandler);
771
+ }
772
+ };
773
+ document.addEventListener('keydown', escHandler);
774
+
775
+ // Focus name input
776
+ const nameInput = overlay.querySelector('#modalName');
777
+ setTimeout(() => nameInput.focus(), 50);
778
+
779
+ // Submit on Enter in name input
780
+ nameInput.addEventListener('keydown', (e) => {
781
+ if (e.key === 'Enter') {
782
+ e.preventDefault();
783
+ submitCreateApp(overlay);
784
+ }
785
+ });
786
+
787
+ // Create button
788
+ const createBtn = overlay.querySelector('#modalCreateBtn');
789
+ createBtn.addEventListener('click', () => submitCreateApp(overlay));
790
+ }
791
+
792
+ async function submitCreateApp(overlay) {
793
+ const templateEl = overlay.querySelector('#modalTemplate');
794
+ const nameEl = overlay.querySelector('#modalName');
795
+ const msgEl = overlay.querySelector('#modalMessage');
796
+ const createBtn = overlay.querySelector('#modalCreateBtn');
797
+
798
+ const template = templateEl.value;
799
+ const name = nameEl.value.trim();
800
+
801
+ // Client-side validation
802
+ if (!name) {
803
+ msgEl.innerHTML = '<div class="form-error">Name is required</div>';
804
+ nameEl.focus();
805
+ return;
806
+ }
807
+
808
+ // Disable button during request
809
+ createBtn.disabled = true;
810
+ createBtn.textContent = 'Creating...';
811
+ msgEl.innerHTML = '';
812
+
813
+ try {
814
+ const res = await fetch('/api/apps', {
815
+ method: 'POST',
816
+ headers: { 'Content-Type': 'application/json' },
817
+ body: JSON.stringify({ template, name }),
818
+ });
819
+ const data = await res.json();
820
+
821
+ if (!res.ok) {
822
+ msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to create app')}</div>`;
823
+ createBtn.disabled = false;
824
+ createBtn.textContent = 'Create';
825
+ return;
826
+ }
827
+
828
+ // Success — show next-steps view
829
+ showNextSteps(overlay, data);
830
+ } catch (err) {
831
+ msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
832
+ createBtn.disabled = false;
833
+ createBtn.textContent = 'Create';
834
+ }
835
+ }
836
+
837
+ function showNextSteps(overlay, data) {
838
+ const body = overlay.querySelector('#modalBody');
839
+ const footer = overlay.querySelector('#modalFooter');
840
+
841
+ let warningHtml = '';
842
+ if (data.warning) {
843
+ warningHtml = `<div class="form-warning">${esc(data.warning)}</div>`;
844
+ }
845
+
846
+ let cmdsHtml = '';
847
+ for (const cmd of data.commands) {
848
+ cmdsHtml += `
849
+ <div class="cmd-item">
850
+ <span class="cmd-text">${esc(cmd)}</span>
851
+ <button class="cmd-copy" data-cmd="${esc(cmd)}">Copy</button>
852
+ </div>`;
853
+ }
854
+
855
+ body.innerHTML = `
856
+ <div class="next-steps-success">&#10003; Created ${esc(data.appName)}</div>
857
+ ${warningHtml}
858
+ <div class="next-steps-label">Next steps — run one of these in your terminal:</div>
859
+ ${cmdsHtml}
860
+ `;
861
+
862
+ footer.innerHTML = `<button class="btn btn-primary" data-action="close">Done</button>`;
863
+ footer.querySelector('[data-action="close"]').addEventListener('click', () => overlay.remove());
864
+
865
+ // Copy-to-clipboard buttons
866
+ body.querySelectorAll('.cmd-copy').forEach((btn) => {
867
+ btn.addEventListener('click', () => {
868
+ const cmd = btn.dataset.cmd || '';
869
+ navigator.clipboard.writeText(cmd).then(() => {
870
+ const orig = btn.textContent;
871
+ btn.textContent = 'Copied!';
872
+ setTimeout(() => { btn.textContent = orig; }, 1500);
873
+ });
874
+ });
875
+ });
876
+ }
877
+
878
+ // Expose modal functions globally for inline onclick handlers
879
+ window.openDeleteAppModal = openDeleteAppModal;
880
+ window.openArchiveAppModal = openArchiveAppModal;