claudeboard 2.16.0 → 3.1.1

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/public/app.js ADDED
@@ -0,0 +1,1411 @@
1
+ /* ============================================================
2
+ ClaudeBoard v3 — Frontend Application
3
+ Vanilla JS, no frameworks, no build step
4
+ ============================================================ */
5
+
6
+ 'use strict';
7
+
8
+
9
+ // Virtual task IDs — appear only in the detail panel, never on the kanban board
10
+ const VIRTUAL_TASK_IDS = new Set(['__qa__', '__checklist__', '__deploy__']);
11
+
12
+ // ── State ────────────────────────────────────────────────────
13
+ const state = {
14
+ ws: null,
15
+ connected: false,
16
+ reconnectTimer: null,
17
+ reconnectDelay: 1500,
18
+ maxReconnectDelay: 30000,
19
+
20
+ tasks: new Map(), // taskId -> task object
21
+ selectedTaskId: null,
22
+ currentAssistantMsgEl: null, // streaming message element
23
+ chatHistory: [], // [{role, content}]
24
+ attachedImage: null, // base64 DataURL of pending image attachment
25
+
26
+ elapsedTimers: new Map(), // taskId -> intervalId
27
+ taskStartTimes: new Map(), // taskId -> Date
28
+
29
+ panelOpen: false,
30
+ prdAvailable: false,
31
+ };
32
+
33
+ // ── DOM refs ─────────────────────────────────────────────────
34
+ const dom = {
35
+ connStatus: document.getElementById('connStatus'),
36
+ chatMessages: document.getElementById('chatMessages'),
37
+ chatInput: document.getElementById('chatInput'),
38
+ sendBtn: document.getElementById('sendBtn'),
39
+ typingIndicator: document.getElementById('typingIndicator'),
40
+ kanbanBoard: document.getElementById('kanbanBoard'),
41
+ runAllBtn: document.getElementById('runAllBtn'),
42
+ pauseAllBtn: document.getElementById('pauseAllBtn'),
43
+ clearAllBtn: document.getElementById('clearAllBtn'),
44
+ prdBtn: document.getElementById('prdBtn'),
45
+ prdModal: document.getElementById('prdModal'),
46
+ prdContent: document.getElementById('prdContent'),
47
+ app: document.getElementById('app'),
48
+ // Sidebar collapse
49
+ sidebarCollapseBtn: document.getElementById('sidebarCollapseBtn'),
50
+ sidebarExpandBtn: document.getElementById('sidebarExpandBtn'),
51
+ sysMonitor: document.getElementById('sysMonitor'),
52
+ // Detail panel
53
+ detailPanel: document.getElementById('detail-panel'),
54
+ closePanel: document.getElementById('closePanel'),
55
+ panelBadge: document.getElementById('panelBadge'),
56
+ panelAgentLabel: document.getElementById('panelAgentLabel'),
57
+ panelElapsed: document.getElementById('panelElapsed'),
58
+ panelTaskName: document.getElementById('panelTaskName'),
59
+ panelTaskDesc: document.getElementById('panelTaskDesc'),
60
+ panelCriteria: document.getElementById('panelCriteria'),
61
+ panelCriteriaWrap: document.getElementById('panelCriteriaWrap'),
62
+ panelTerminalBody: document.getElementById('panelTerminalBody'),
63
+ clearPanelTerminal: document.getElementById('clearPanelTerminal'),
64
+ // QA toast
65
+ qaToast: document.getElementById('qaToast'),
66
+ qaToastText: document.getElementById('qaToastText'),
67
+ // Image attachment
68
+ imgAttach: document.getElementById('imgAttach'),
69
+ imgPreview: document.getElementById('imgPreview'),
70
+ imgPreviewImg: document.getElementById('imgPreviewImg'),
71
+ removeImg: document.getElementById('removeImg'),
72
+ // QA button
73
+ qaBtn: document.getElementById('qaBtn'),
74
+ // Checklist & Deploy buttons
75
+ checklistBtn: document.getElementById('checklistBtn'),
76
+ deployBtn: document.getElementById('deployBtn'),
77
+ // Board empty state
78
+ boardEmptyState: document.getElementById('boardEmptyState'),
79
+ // PRD upload
80
+ prdFileInput: document.getElementById('prdFileInput'),
81
+ // Supabase modal
82
+ supabaseModal: document.getElementById('supabaseModal'),
83
+ supabaseBackdrop: document.getElementById('supabaseModalBackdrop'),
84
+ closeSupabaseModal: document.getElementById('closeSupabaseModal'),
85
+ cancelSupabaseModal:document.getElementById('cancelSupabaseModal'),
86
+ submitSupabaseModal:document.getElementById('submitSupabaseModal'),
87
+ sbTaskList: document.getElementById('sbTaskList'),
88
+ sbUrl: document.getElementById('sbUrl'),
89
+ sbAnonKey: document.getElementById('sbAnonKey'),
90
+ sbServiceKey: document.getElementById('sbServiceKey'),
91
+ sbWriteEnv: document.getElementById('sbWriteEnv'),
92
+ sbCliLogin: document.getElementById('sbCliLogin'),
93
+ };
94
+
95
+ // ── WebSocket ────────────────────────────────────────────────
96
+ function connectWS() {
97
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
98
+ const url = `${protocol}//${location.host}`;
99
+
100
+ try {
101
+ state.ws = new WebSocket(url);
102
+ } catch (err) {
103
+ scheduleReconnect();
104
+ return;
105
+ }
106
+
107
+ state.ws.onopen = () => {
108
+ state.connected = true;
109
+ state.reconnectDelay = 1500;
110
+ setConnectionStatus('connected', 'Connected');
111
+ clearTimeout(state.reconnectTimer);
112
+ };
113
+
114
+ state.ws.onclose = () => {
115
+ state.connected = false;
116
+ setConnectionStatus('disconnected', 'Disconnected');
117
+ scheduleReconnect();
118
+ };
119
+
120
+ state.ws.onerror = () => {
121
+ state.connected = false;
122
+ setConnectionStatus('disconnected', 'Connection error');
123
+ };
124
+
125
+ state.ws.onmessage = (event) => {
126
+ let msg;
127
+ try {
128
+ msg = JSON.parse(event.data);
129
+ } catch {
130
+ return;
131
+ }
132
+ handleMessage(msg);
133
+ };
134
+ }
135
+
136
+ function scheduleReconnect() {
137
+ clearTimeout(state.reconnectTimer);
138
+ state.reconnectTimer = setTimeout(() => {
139
+ setConnectionStatus('connecting', `Reconnecting…`);
140
+ connectWS();
141
+ }, state.reconnectDelay);
142
+ state.reconnectDelay = Math.min(state.reconnectDelay * 1.5, state.maxReconnectDelay);
143
+ }
144
+
145
+ function sendWS(obj) {
146
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
147
+ state.ws.send(JSON.stringify(obj));
148
+ return true;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ function setConnectionStatus(status, label) {
154
+ const el = dom.connStatus;
155
+ el.className = `connection-status ${status}`;
156
+ el.querySelector('span:last-child').textContent = label;
157
+ }
158
+
159
+ // ── Message handler ──────────────────────────────────────────
160
+ function handleMessage(msg) {
161
+ switch (msg.type) {
162
+ case 'init':
163
+ handleInit(msg);
164
+ break;
165
+ case 'orchestrator:chunk':
166
+ handleOrchestratorChunk(msg.chunk);
167
+ break;
168
+ case 'orchestrator:thinking':
169
+ showTypingIndicator(true);
170
+ finalizeAssistantMessage();
171
+ break;
172
+ case 'orchestrator:done':
173
+ showTypingIndicator(false);
174
+ finalizeAssistantMessage();
175
+ break;
176
+ case 'orchestrator:error':
177
+ showTypingIndicator(false);
178
+ finalizeAssistantMessage();
179
+ appendChatError(msg.error || 'An error occurred.');
180
+ break;
181
+ case 'tasks:created':
182
+ handleTasksCreated(msg.tasks);
183
+ break;
184
+ case 'task:started':
185
+ handleTaskStarted(msg.taskId, msg.agentLabel);
186
+ break;
187
+ case 'task:output':
188
+ handleTaskOutput(msg.taskId, msg.chunk, 'stdout');
189
+ break;
190
+ case 'task:verifying':
191
+ handleTaskVerifying(msg.taskId);
192
+ break;
193
+ case 'task:verifier_output':
194
+ handleTaskOutput(msg.taskId, msg.chunk, 'stdout');
195
+ break;
196
+ case 'task:done':
197
+ handleTaskDone(msg.taskId);
198
+ break;
199
+ case 'task:error':
200
+ handleTaskError(msg.taskId, msg.error, msg.needsHuman);
201
+ break;
202
+ case 'task:stopped':
203
+ handleTaskStopped(msg.taskId);
204
+ break;
205
+ case 'task:updated':
206
+ handleTaskUpdated(msg.task);
207
+ break;
208
+ case 'task:deleted':
209
+ state.tasks.delete(msg.taskId);
210
+ if (state.selectedTaskId === msg.taskId) closePanel();
211
+ stopElapsedTimer(msg.taskId);
212
+ renderBoard();
213
+ updateClearAllVisibility();
214
+ break;
215
+ case 'tasks:cleared':
216
+ state.tasks.forEach((_, id) => { if (!VIRTUAL_TASK_IDS.has(id)) stopElapsedTimer(id); });
217
+ [...state.tasks.keys()].filter(id => !VIRTUAL_TASK_IDS.has(id)).forEach(id => state.tasks.delete(id));
218
+ if (state.selectedTaskId && !VIRTUAL_TASK_IDS.has(state.selectedTaskId)) closePanel();
219
+ renderBoard();
220
+ updateClearAllVisibility();
221
+ dom.runAllBtn.style.display = 'none';
222
+ dom.qaBtn.style.display = 'none';
223
+ if (dom.checklistBtn) dom.checklistBtn.style.display = 'none';
224
+ if (dom.deployBtn) dom.deployBtn.style.display = 'none';
225
+ break;
226
+ case 'prd:generated':
227
+ state.prdAvailable = true;
228
+ dom.prdBtn.style.display = 'inline-flex';
229
+ break;
230
+ case 'qa:started':
231
+ handleQAStarted();
232
+ break;
233
+ case 'qa:output':
234
+ handleQAOutput(msg.chunk);
235
+ break;
236
+ case 'qa:done':
237
+ handleQADone();
238
+ break;
239
+ case 'qa:screenshot':
240
+ handleQAScreenshot(msg.image);
241
+ break;
242
+ case 'checklist:started':
243
+ handleChecklistStarted(msg.isMobile);
244
+ break;
245
+ case 'checklist:output':
246
+ handleChecklistOutput(msg.chunk);
247
+ break;
248
+ case 'checklist:done':
249
+ handleChecklistDone(msg.passed);
250
+ break;
251
+ case 'deploy:started':
252
+ handleDeployStarted();
253
+ break;
254
+ case 'deploy:output':
255
+ handleDeployOutput(msg.chunk);
256
+ break;
257
+ case 'deploy:done':
258
+ handleDeployDone(msg.url);
259
+ break;
260
+ case 'supabase:preflight':
261
+ handleSupabasePreflight(msg.taskTitles || []);
262
+ break;
263
+ case 'supabase:env_saved':
264
+ appendChatMessage('assistant', '✅ Variables de Supabase guardadas en `.env`. Iniciando tareas…');
265
+ break;
266
+ default:
267
+ break;
268
+ }
269
+ }
270
+
271
+ // ── Init ─────────────────────────────────────────────────────
272
+ function handleInit(msg) {
273
+ if (msg.tasks && Array.isArray(msg.tasks)) {
274
+ msg.tasks.forEach(task => {
275
+ state.tasks.set(task.id, { ...task, output: Array.isArray(task.output) ? task.output : [] });
276
+ if (task.status === 'in_progress' || task.status === 'verifying') {
277
+ startElapsedTimer(task.id, task.startedAt ? new Date(task.startedAt) : new Date());
278
+ }
279
+ });
280
+ renderBoard();
281
+ updateClearAllVisibility();
282
+ }
283
+
284
+ if (msg.prd != null) {
285
+ state.prdAvailable = true;
286
+ dom.prdBtn.style.display = 'inline-flex';
287
+ }
288
+ }
289
+
290
+ // ── Task handlers ────────────────────────────────────────────
291
+ function handleTasksCreated(tasks) {
292
+ tasks.forEach(task => {
293
+ state.tasks.set(task.id, { ...task, output: [] });
294
+ });
295
+ renderBoard();
296
+ updateClearAllVisibility();
297
+ }
298
+
299
+ function handleTaskStarted(taskId, agentLabel) {
300
+ const task = state.tasks.get(taskId);
301
+ if (!task) return;
302
+ task.status = 'in_progress';
303
+ task.startedAt = new Date().toISOString();
304
+ if (agentLabel) task.agentLabel = agentLabel;
305
+ state.tasks.set(taskId, task);
306
+ startElapsedTimer(taskId, new Date());
307
+ updatePauseAllVisibility();
308
+ renderBoard();
309
+ if (state.selectedTaskId === taskId) refreshPanelHeader(task);
310
+ }
311
+
312
+ function handleTaskOutput(taskId, chunk, type) {
313
+ const task = state.tasks.get(taskId);
314
+ if (!task && taskId !== '__qa__') return;
315
+ if (task) {
316
+ if (!task.output) task.output = [];
317
+ task.output.push({ chunk, type });
318
+ state.tasks.set(taskId, task);
319
+ }
320
+ if (state.selectedTaskId === taskId) {
321
+ appendPanelLine(chunk, type);
322
+ }
323
+ }
324
+
325
+ function handleTaskVerifying(taskId) {
326
+ const task = state.tasks.get(taskId);
327
+ if (!task) return;
328
+ task.status = 'verifying';
329
+ state.tasks.set(taskId, task);
330
+ renderBoard();
331
+ if (state.selectedTaskId === taskId) {
332
+ appendPanelLine('\n[Verifier] Iniciando verificación…\n', 'system');
333
+ refreshPanelHeader(task);
334
+ }
335
+ }
336
+
337
+ function handleTaskDone(taskId) {
338
+ const task = state.tasks.get(taskId);
339
+ if (!task) return;
340
+ task.status = 'done';
341
+ task.completedAt = new Date().toISOString();
342
+ state.tasks.set(taskId, task);
343
+ stopElapsedTimer(taskId);
344
+ updatePauseAllVisibility();
345
+ renderBoard();
346
+ if (state.selectedTaskId === taskId) {
347
+ appendPanelLine('\n[Done] Tarea completada con éxito.\n', 'success');
348
+ refreshPanelHeader(task);
349
+ }
350
+ }
351
+
352
+ function handleTaskError(taskId, errorMsg, needsHuman) {
353
+ const task = state.tasks.get(taskId);
354
+ if (!task) return;
355
+ task.status = 'error';
356
+ task.error = errorMsg;
357
+ if (needsHuman) task.needsHuman = true;
358
+ state.tasks.set(taskId, task);
359
+ stopElapsedTimer(taskId);
360
+ updatePauseAllVisibility();
361
+ renderBoard();
362
+ if (state.selectedTaskId === taskId) {
363
+ appendPanelLine(`\n[Error] ${errorMsg || 'Tarea fallida.'}\n`, 'stderr');
364
+ refreshPanelHeader(task);
365
+ }
366
+ }
367
+
368
+ function handleTaskUpdated(serverTask) {
369
+ if (!serverTask || !serverTask.id) return;
370
+ const existing = state.tasks.get(serverTask.id);
371
+ const merged = { ...existing, ...serverTask, output: existing ? (existing.output || []) : [] };
372
+ state.tasks.set(serverTask.id, merged);
373
+ // Stop timer if no longer active
374
+ if (serverTask.status !== 'in_progress' && serverTask.status !== 'verifying') {
375
+ stopElapsedTimer(serverTask.id);
376
+ }
377
+ updatePauseAllVisibility();
378
+ renderBoard();
379
+ if (state.selectedTaskId === serverTask.id) refreshPanelHeader(merged);
380
+ }
381
+
382
+ function handleTaskStopped(taskId) {
383
+ const task = state.tasks.get(taskId);
384
+ if (!task) return;
385
+ task.status = 'backlog';
386
+ delete task.startedAt;
387
+ state.tasks.set(taskId, task);
388
+ stopElapsedTimer(taskId);
389
+ updatePauseAllVisibility();
390
+ renderBoard();
391
+ if (state.selectedTaskId === taskId) refreshPanelHeader(task);
392
+ }
393
+
394
+ // ── QA Toast ─────────────────────────────────────────────────
395
+ function showQAToast(text) {
396
+ dom.qaToastText.textContent = text;
397
+ dom.qaToast.style.display = 'flex';
398
+ }
399
+ function hideQAToast() {
400
+ dom.qaToast.style.display = 'none';
401
+ }
402
+
403
+ // ── QA virtual task (not a kanban card — only appears in the panel) ──────────
404
+ function handleQAStarted() {
405
+ showQAToast('Agente QA revisando el proyecto…');
406
+ // Create/reset a virtual task so the panel shows live QA output
407
+ state.tasks.set('__qa__', {
408
+ id: '__qa__',
409
+ title: '🔍 QA Review — Chrome screenshot',
410
+ description: 'Revisión visual automática: Chrome abre el proyecto, captura pantalla y analiza el resultado.',
411
+ status: 'in_progress',
412
+ agentLabel: 'QA Engineer',
413
+ output: [],
414
+ startedAt: new Date().toISOString(),
415
+ });
416
+ startElapsedTimer('__qa__', new Date());
417
+ selectTask('__qa__');
418
+ }
419
+
420
+ function handleQAOutput(chunk) {
421
+ const task = state.tasks.get('__qa__');
422
+ if (task) {
423
+ task.output.push({ chunk, type: 'stdout' });
424
+ }
425
+ if (state.selectedTaskId === '__qa__') {
426
+ appendPanelLine(chunk, 'stdout');
427
+ }
428
+ }
429
+
430
+ function handleQADone() {
431
+ hideQAToast();
432
+ stopElapsedTimer('__qa__');
433
+ const task = state.tasks.get('__qa__');
434
+ if (task) task.status = 'done';
435
+ dom.qaBtn.disabled = false;
436
+ dom.qaBtn.innerHTML = '🔍 Revisar en Chrome';
437
+ if (state.selectedTaskId === '__qa__') {
438
+ appendPanelLine('\n[QA] Revisión completada.\n', 'success');
439
+ refreshPanelHeader(state.tasks.get('__qa__'));
440
+ }
441
+ }
442
+
443
+ // ── QA Screenshot inline ──────────────────────────────────────
444
+ function handleQAScreenshot(imageDataUrl) {
445
+ if (!imageDataUrl) return;
446
+ // Append to QA panel terminal if open
447
+ if (state.selectedTaskId === '__qa__') {
448
+ const img = document.createElement('img');
449
+ img.src = imageDataUrl;
450
+ img.style.cssText = 'max-width:100%;border-radius:8px;margin-top:8px;display:block;';
451
+ dom.panelTerminalBody.appendChild(img);
452
+ scrollPanelToBottom();
453
+ }
454
+ // Show inline in chat
455
+ const wrapper = document.createElement('div');
456
+ wrapper.className = 'chat-message assistant';
457
+ const label = document.createElement('span');
458
+ label.className = 'message-label';
459
+ label.textContent = 'QA Agent';
460
+ const bubble = document.createElement('div');
461
+ bubble.className = 'message-bubble';
462
+ const caption = document.createElement('div');
463
+ caption.style.cssText = 'font-size:0.75rem;color:var(--text-subtle);margin-bottom:6px;';
464
+ caption.textContent = '📸 Screenshot del proyecto';
465
+ const img = document.createElement('img');
466
+ img.src = imageDataUrl;
467
+ img.style.cssText = 'max-width:100%;border-radius:8px;display:block;cursor:zoom-in;';
468
+ img.title = 'Click para ver en tamaño completo';
469
+ img.addEventListener('click', () => window.open(imageDataUrl, '_blank'));
470
+ bubble.appendChild(caption);
471
+ bubble.appendChild(img);
472
+ wrapper.appendChild(label);
473
+ wrapper.appendChild(bubble);
474
+ const welcome = dom.chatMessages.querySelector('.chat-welcome');
475
+ if (welcome) welcome.remove();
476
+ dom.chatMessages.appendChild(wrapper);
477
+ scrollChatToBottom();
478
+ }
479
+
480
+ // ── Checklist Agent ───────────────────────────────────────────
481
+ function handleChecklistStarted(isMobile) {
482
+ showQAToast(`Checklist de producción ${isMobile ? 'móvil' : 'web'} en curso…`);
483
+ state.tasks.set('__checklist__', {
484
+ id: '__checklist__',
485
+ title: `✅ Checklist de producción — ${isMobile ? 'App Móvil' : 'Web'}`,
486
+ description: `Verificando que el proyecto esté listo para producción: favicon, metas, console.log, etc.`,
487
+ status: 'in_progress',
488
+ agentLabel: 'QA Engineer',
489
+ output: [],
490
+ startedAt: new Date().toISOString(),
491
+ });
492
+ startElapsedTimer('__checklist__', new Date());
493
+ selectTask('__checklist__');
494
+ if (dom.checklistBtn) { dom.checklistBtn.disabled = true; dom.checklistBtn.textContent = '⏳ Revisando…'; }
495
+ }
496
+
497
+ function handleChecklistOutput(chunk) {
498
+ const task = state.tasks.get('__checklist__');
499
+ if (task) task.output.push({ chunk, type: 'stdout' });
500
+ if (state.selectedTaskId === '__checklist__') appendPanelLine(chunk, 'stdout');
501
+ }
502
+
503
+ function handleChecklistDone(passed) {
504
+ hideQAToast();
505
+ stopElapsedTimer('__checklist__');
506
+ const task = state.tasks.get('__checklist__');
507
+ if (task) task.status = 'done';
508
+ if (dom.checklistBtn) { dom.checklistBtn.disabled = false; dom.checklistBtn.innerHTML = '✅ Checklist'; }
509
+ if (state.selectedTaskId === '__checklist__') {
510
+ appendPanelLine(passed ? '\n[Checklist] ✅ Proyecto listo para producción.\n' : '\n[Checklist] Revisá las tareas creadas.\n', passed ? 'success' : 'system');
511
+ refreshPanelHeader(state.tasks.get('__checklist__'));
512
+ }
513
+ // Post result in chat
514
+ appendChatMessage('assistant', passed
515
+ ? '✅ **Checklist de producción pasado.** El proyecto está listo para publicar.'
516
+ : '⚠️ **Checklist de producción:** encontré algunos problemas. Creé tareas para resolverlos — revisá el board.');
517
+ }
518
+
519
+ // ── Deploy Agent ──────────────────────────────────────────────
520
+ function handleDeployStarted() {
521
+ showQAToast('Agente Deploy conectando con Netlify…');
522
+ state.tasks.set('__deploy__', {
523
+ id: '__deploy__',
524
+ title: '🚀 Deploy a Netlify',
525
+ description: 'Build y deploy automático del proyecto a Netlify.',
526
+ status: 'in_progress',
527
+ agentLabel: 'DevOps',
528
+ output: [],
529
+ startedAt: new Date().toISOString(),
530
+ });
531
+ startElapsedTimer('__deploy__', new Date());
532
+ selectTask('__deploy__');
533
+ if (dom.deployBtn) { dom.deployBtn.disabled = true; dom.deployBtn.textContent = '⏳ Deployando…'; }
534
+ }
535
+
536
+ function handleDeployOutput(chunk) {
537
+ const task = state.tasks.get('__deploy__');
538
+ if (task) task.output.push({ chunk, type: 'stdout' });
539
+ if (state.selectedTaskId === '__deploy__') appendPanelLine(chunk, 'stdout');
540
+ }
541
+
542
+ function handleDeployDone(url) {
543
+ hideQAToast();
544
+ stopElapsedTimer('__deploy__');
545
+ const task = state.tasks.get('__deploy__');
546
+ if (task) task.status = url ? 'done' : 'error';
547
+ if (dom.deployBtn) { dom.deployBtn.disabled = false; dom.deployBtn.innerHTML = '🚀 Deploy'; }
548
+ if (state.selectedTaskId === '__deploy__') {
549
+ appendPanelLine(url ? `\n[Deploy] ✅ Publicado en: ${url}\n` : '\n[Deploy] ❌ Deploy falló. Revisá el output arriba.\n', url ? 'success' : 'stderr');
550
+ refreshPanelHeader(state.tasks.get('__deploy__'));
551
+ }
552
+ // Show URL in chat
553
+ if (url) {
554
+ const wrapper = document.createElement('div');
555
+ wrapper.className = 'chat-message assistant';
556
+ const label = document.createElement('span');
557
+ label.className = 'message-label';
558
+ label.textContent = 'Deploy Agent';
559
+ const bubble = document.createElement('div');
560
+ bubble.className = 'message-bubble';
561
+ bubble.innerHTML = `🚀 <strong>Deploy exitoso!</strong><br><a href="${escapeHTML(url)}" target="_blank" style="color:var(--accent)">${escapeHTML(url)}</a>`;
562
+ wrapper.appendChild(label);
563
+ wrapper.appendChild(bubble);
564
+ const welcome = dom.chatMessages.querySelector('.chat-welcome');
565
+ if (welcome) welcome.remove();
566
+ dom.chatMessages.appendChild(wrapper);
567
+ scrollChatToBottom();
568
+ } else {
569
+ appendChatMessage('assistant', '❌ **Deploy falló.** Abrí el panel de Deploy para ver el error.');
570
+ }
571
+ }
572
+
573
+ // ── Supabase Pre-flight Modal ─────────────────────────────────
574
+ function handleSupabasePreflight(taskTitles) {
575
+ // Populate task list
576
+ dom.sbTaskList.innerHTML = '';
577
+ taskTitles.forEach(title => {
578
+ const li = document.createElement('li');
579
+ li.textContent = title;
580
+ dom.sbTaskList.appendChild(li);
581
+ });
582
+ // Clear fields
583
+ dom.sbUrl.value = '';
584
+ dom.sbAnonKey.value = '';
585
+ dom.sbServiceKey.value = '';
586
+ dom.sbWriteEnv.checked = true;
587
+ dom.sbCliLogin.checked = false;
588
+ // Show modal
589
+ dom.supabaseModal.style.display = 'flex';
590
+ setTimeout(() => dom.sbUrl.focus(), 100);
591
+ }
592
+
593
+ function closeSupabaseModal() {
594
+ dom.supabaseModal.style.display = 'none';
595
+ }
596
+
597
+ function submitSupabaseCredentials() {
598
+ const supabaseUrl = dom.sbUrl.value.trim();
599
+ const anonKey = dom.sbAnonKey.value.trim();
600
+ const serviceKey = dom.sbServiceKey.value.trim();
601
+ const writeDotEnv = dom.sbWriteEnv.checked;
602
+
603
+ if (!supabaseUrl || !anonKey) {
604
+ dom.sbUrl.style.borderColor = supabaseUrl ? '' : 'var(--col-error-border)';
605
+ dom.sbAnonKey.style.borderColor = anonKey ? '' : 'var(--col-error-border)';
606
+ return;
607
+ }
608
+
609
+ sendWS({ type: 'supabase:credentials', supabaseUrl, anonKey, serviceKey, writeDotEnv });
610
+ closeSupabaseModal();
611
+ appendChatMessage('assistant', '🔐 Credenciales recibidas. Iniciando las tareas…');
612
+ }
613
+
614
+ // ── PRD File Upload ───────────────────────────────────────────
615
+ function handlePRDFileUpload(file) {
616
+ if (!file) return;
617
+ const reader = new FileReader();
618
+ reader.onload = (e) => {
619
+ const content = e.target.result;
620
+ if (!content || !content.trim()) return;
621
+ sendWS({ type: 'prd:upload', content });
622
+ appendChatMessage('user', `📄 Subí el PRD: **${escapeHTML(file.name)}**`);
623
+ appendChatMessage('assistant', '¡Perfecto! Estoy analizando tu PRD para crear las tareas…');
624
+ };
625
+ reader.readAsText(file);
626
+ }
627
+
628
+ // ── Elapsed time timers ──────────────────────────────────────
629
+ function startElapsedTimer(taskId, startTime) {
630
+ stopElapsedTimer(taskId);
631
+ state.taskStartTimes.set(taskId, startTime);
632
+ const intervalId = setInterval(() => {
633
+ updateElapsedDisplay(taskId);
634
+ }, 1000);
635
+ state.elapsedTimers.set(taskId, intervalId);
636
+ updateElapsedDisplay(taskId);
637
+ }
638
+
639
+ function stopElapsedTimer(taskId) {
640
+ const id = state.elapsedTimers.get(taskId);
641
+ if (id) {
642
+ clearInterval(id);
643
+ state.elapsedTimers.delete(taskId);
644
+ }
645
+ }
646
+
647
+ function updateElapsedDisplay(taskId) {
648
+ const el = document.querySelector(`.task-card[data-task-id="${taskId}"] .card-elapsed`);
649
+ if (el) el.textContent = formatElapsed(taskId);
650
+ if (state.selectedTaskId === taskId) {
651
+ dom.panelElapsed.textContent = formatElapsed(taskId);
652
+ }
653
+ }
654
+
655
+ function formatElapsed(taskId) {
656
+ const start = state.taskStartTimes.get(taskId);
657
+ if (!start) return '';
658
+ const secs = Math.floor((Date.now() - start.getTime()) / 1000);
659
+ if (secs < 60) return `${secs}s`;
660
+ const mins = Math.floor(secs / 60);
661
+ const remSecs = secs % 60;
662
+ if (mins < 60) return `${mins}m ${String(remSecs).padStart(2, '0')}s`;
663
+ const hrs = Math.floor(mins / 60);
664
+ const remMins = mins % 60;
665
+ return `${hrs}h ${String(remMins).padStart(2, '0')}m`;
666
+ }
667
+
668
+ // ── Board Rendering ──────────────────────────────────────────
669
+ function renderBoard() {
670
+ _renderBoard();
671
+ refreshDraggableCards();
672
+ }
673
+
674
+ function _renderBoard() {
675
+ const cols = ['backlog', 'in_progress', 'verifying', 'done', 'error'];
676
+ const buckets = {};
677
+ cols.forEach(c => { buckets[c] = []; });
678
+
679
+ state.tasks.forEach(task => {
680
+ if (VIRTUAL_TASK_IDS.has(task.id)) return; // virtual tasks — panel only, no kanban card
681
+ const col = buckets[task.status] || buckets['backlog'];
682
+ col.push(task);
683
+ });
684
+
685
+ cols.forEach(col => {
686
+ const container = document.getElementById(`col-${col}`);
687
+ const countEl = document.getElementById(`count-${col}`);
688
+ if (!container || !countEl) return;
689
+
690
+ const tasks = buckets[col];
691
+ countEl.textContent = tasks.length;
692
+
693
+ const existingIds = new Set(
694
+ [...container.querySelectorAll('.task-card')].map(el => el.dataset.taskId)
695
+ );
696
+ const newIds = new Set(tasks.map(t => t.id));
697
+
698
+ existingIds.forEach(id => {
699
+ if (!newIds.has(id)) {
700
+ const el = container.querySelector(`.task-card[data-task-id="${id}"]`);
701
+ if (el) el.remove();
702
+ }
703
+ });
704
+
705
+ tasks.forEach((task) => {
706
+ let cardEl = container.querySelector(`.task-card[data-task-id="${task.id}"]`);
707
+ if (!cardEl) {
708
+ cardEl = createTaskCardEl(task);
709
+ container.appendChild(cardEl);
710
+ } else {
711
+ updateTaskCardEl(cardEl, task);
712
+ }
713
+ });
714
+
715
+ let emptyEl = container.querySelector('.col-empty');
716
+ if (tasks.length === 0) {
717
+ if (!emptyEl) {
718
+ emptyEl = document.createElement('div');
719
+ emptyEl.className = 'col-empty';
720
+ emptyEl.textContent = col === 'in_progress' ? 'Arrastrá tareas con error aquí' : 'No tasks';
721
+ container.appendChild(emptyEl);
722
+ }
723
+ } else {
724
+ if (emptyEl) emptyEl.remove();
725
+ }
726
+ });
727
+
728
+ const totalTasks = [...state.tasks.keys()].filter(id => !VIRTUAL_TASK_IDS.has(id)).length;
729
+ if (dom.boardEmptyState) {
730
+ dom.boardEmptyState.style.display = totalTasks === 0 ? 'flex' : 'none';
731
+ }
732
+ }
733
+
734
+ function attachCardListeners(card, task) {
735
+ card.addEventListener('click', (e) => {
736
+ if (e.target.closest('.btn-stop-task') || e.target.closest('.btn-resolve')) return;
737
+ selectTask(task.id);
738
+ });
739
+ card.addEventListener('click', (e) => {
740
+ const stopBtn = e.target.closest('.btn-stop-task');
741
+ if (stopBtn) { e.stopPropagation(); sendWS({ type: 'task:stop', taskId: stopBtn.dataset.taskId }); return; }
742
+ const resolveBtn = e.target.closest('.btn-resolve');
743
+ if (resolveBtn) { e.stopPropagation(); sendWS({ type: 'task:resolve', taskId: resolveBtn.dataset.taskId }); return; }
744
+ const deleteBtn = e.target.closest('.btn-delete-task');
745
+ if (deleteBtn) { e.stopPropagation(); sendWS({ type: 'task:delete', taskId: deleteBtn.dataset.taskId }); return; }
746
+ });
747
+ }
748
+
749
+ function createTaskCardEl(task) {
750
+ const card = document.createElement('div');
751
+ card.className = 'task-card';
752
+ card.dataset.taskId = task.id;
753
+ card.dataset.status = task.status;
754
+ if (task.needsHuman) card.dataset.needsHuman = 'true';
755
+ if (task.id === state.selectedTaskId) card.classList.add('selected');
756
+ card.innerHTML = renderTaskCardHTML(task);
757
+ attachCardListeners(card, task);
758
+ return card;
759
+ }
760
+
761
+ function updateTaskCardEl(cardEl, task) {
762
+ cardEl.dataset.status = task.status;
763
+ cardEl.dataset.needsHuman = task.needsHuman ? 'true' : '';
764
+ cardEl.classList.toggle('selected', task.id === state.selectedTaskId);
765
+ cardEl.innerHTML = renderTaskCardHTML(task);
766
+ attachCardListeners(cardEl, task);
767
+ }
768
+
769
+ function renderTaskCardHTML(task) {
770
+ const isActive = task.status === 'in_progress' || task.status === 'verifying';
771
+ const isVerifying = task.status === 'verifying';
772
+ const elapsed = state.taskStartTimes.has(task.id) ? formatElapsed(task.id) : '';
773
+ const title = escapeHTML(task.title || task.name || 'Untitled task');
774
+ const agentLabel = escapeHTML(task.agentLabel || (isVerifying ? 'Verificador' : ''));
775
+
776
+ let badge;
777
+ if (task.needsHuman && task.status === 'error') {
778
+ badge = `<span class="card-badge badge-error" data-human="true" style="background:rgba(245,158,11,.18);color:#f59e0b">⚠ Intervención</span>`;
779
+ } else {
780
+ badge = badgeHTML(task.status);
781
+ }
782
+
783
+ const spinnerOrAvatar = isActive
784
+ ? `<div class="card-spinner"></div>`
785
+ : (agentLabel ? `<div class="agent-avatar agent-avatar--role">${escapeHTML(agentLabel[0])}</div>` : '');
786
+
787
+ const stopBtn = task.status === 'in_progress'
788
+ ? `<button class="btn-stop-task" data-task-id="${task.id}" title="Detener">&#9632;</button>`
789
+ : '';
790
+ const deleteBtn = (task.status !== 'in_progress' && task.status !== 'verifying')
791
+ ? `<button class="btn-delete-task" data-task-id="${task.id}" title="Eliminar tarea">&#10005;</button>`
792
+ : '';
793
+
794
+ const resolveBtn = task.needsHuman
795
+ ? `<button class="btn-resolve" data-task-id="${task.id}" title="Marcar como resuelto y reintentar">✓ Resolver</button>`
796
+ : '';
797
+
798
+ const humanNote = task.needsHuman && task.humanReason
799
+ ? `<p class="human-reason">⚠ ${escapeHTML(task.humanReason.slice(0, 120))}</p>`
800
+ : '';
801
+
802
+ return `
803
+ <div class="card-header">
804
+ <span class="card-title">${title}</span>
805
+ </div>
806
+ ${humanNote}
807
+ <div class="card-footer">
808
+ <div class="card-footer-left">
809
+ ${badge}
810
+ ${spinnerOrAvatar}
811
+ </div>
812
+ <div class="card-footer-right">
813
+ <span class="card-elapsed">${elapsed}</span>
814
+ ${resolveBtn}
815
+ ${stopBtn}
816
+ ${deleteBtn}
817
+ </div>
818
+ </div>
819
+ ${agentLabel ? `<div class="card-agent-row"><span class="card-agent-name">${agentLabel}</span></div>` : ''}
820
+ `.trim();
821
+ }
822
+
823
+ function badgeHTML(status) {
824
+ const labels = {
825
+ backlog: 'Pendiente',
826
+ in_progress: 'Corriendo',
827
+ verifying: 'Verificando',
828
+ done: 'Listo',
829
+ error: 'Error',
830
+ };
831
+ const label = labels[status] || status;
832
+ return `<span class="card-badge badge-${status}">${label}</span>`;
833
+ }
834
+
835
+ // ── Drag and Drop ────────────────────────────────────────────
836
+ let dragTaskId = null;
837
+
838
+ function onCardDragStart(e) {
839
+ dragTaskId = e.currentTarget.dataset.taskId;
840
+ e.currentTarget.classList.add('dragging');
841
+ e.dataTransfer.effectAllowed = 'move';
842
+ e.dataTransfer.setData('text/plain', dragTaskId);
843
+ // Highlight valid drop target
844
+ document.querySelectorAll('.kanban-col[data-status="in_progress"]').forEach(c => c.classList.add('drop-zone'));
845
+ }
846
+
847
+ function onCardDragEnd(e) {
848
+ e.currentTarget.classList.remove('dragging');
849
+ dragTaskId = null;
850
+ document.querySelectorAll('.kanban-col').forEach(c => {
851
+ c.classList.remove('drag-over');
852
+ c.classList.remove('drop-zone');
853
+ });
854
+ // Always keep col-in_progress as drop zone
855
+ const col = document.getElementById('col-in_progress');
856
+ if (col) col.closest('.kanban-col').classList.add('drop-zone');
857
+ }
858
+
859
+ function initDropZones() {
860
+ // Make the whole in_progress column a drop zone
861
+ document.querySelectorAll('.kanban-col[data-status="in_progress"]').forEach(zone => {
862
+ zone.classList.add('drop-zone');
863
+ zone.addEventListener('dragover', (e) => {
864
+ e.preventDefault();
865
+ e.dataTransfer.dropEffect = 'move';
866
+ zone.classList.add('drag-over');
867
+ });
868
+ zone.addEventListener('dragleave', (e) => {
869
+ if (!zone.contains(e.relatedTarget)) zone.classList.remove('drag-over');
870
+ });
871
+ zone.addEventListener('drop', (e) => {
872
+ e.preventDefault();
873
+ zone.classList.remove('drag-over');
874
+ const taskId = e.dataTransfer.getData('text/plain') || dragTaskId;
875
+ if (!taskId) return;
876
+ const task = state.tasks.get(taskId);
877
+ if (task && (task.status === 'error')) {
878
+ sendWS({ type: 'task:start', taskId });
879
+ }
880
+ });
881
+ });
882
+ }
883
+
884
+ // Add drag listeners to error cards (called after renderBoard)
885
+ function refreshDraggableCards() {
886
+ state.tasks.forEach(task => {
887
+ if (task.status === 'error') {
888
+ const el = document.querySelector(`.task-card[data-task-id="${task.id}"]`);
889
+ if (el && el.getAttribute('draggable') !== 'true') {
890
+ el.setAttribute('draggable', 'true');
891
+ el.addEventListener('dragstart', onCardDragStart);
892
+ el.addEventListener('dragend', onCardDragEnd);
893
+ }
894
+ }
895
+ });
896
+ }
897
+
898
+ // ── Detail Panel ─────────────────────────────────────────────
899
+ function openPanel() {
900
+ state.panelOpen = true;
901
+ dom.app.classList.add('panel-open');
902
+ }
903
+
904
+ function closePanel() {
905
+ state.panelOpen = false;
906
+ dom.app.classList.remove('panel-open');
907
+ state.selectedTaskId = null;
908
+ document.querySelectorAll('.task-card.selected').forEach(el => el.classList.remove('selected'));
909
+ }
910
+
911
+ function selectTask(taskId) {
912
+ // Deselect previous
913
+ if (state.selectedTaskId) {
914
+ const prev = document.querySelector(`.task-card[data-task-id="${state.selectedTaskId}"]`);
915
+ if (prev) prev.classList.remove('selected');
916
+ }
917
+
918
+ state.selectedTaskId = taskId;
919
+ const card = document.querySelector(`.task-card[data-task-id="${taskId}"]`);
920
+ if (card) card.classList.add('selected');
921
+
922
+ const task = state.tasks.get(taskId);
923
+ if (!task) return;
924
+
925
+ openPanel();
926
+ refreshPanelHeader(task);
927
+ renderPanelOutput(task);
928
+ }
929
+
930
+ function refreshPanelHeader(task) {
931
+ if (state.selectedTaskId !== task.id) return;
932
+ dom.panelTaskName.textContent = task.title || 'Tarea';
933
+ dom.panelTaskDesc.textContent = task.description || '';
934
+ dom.panelCriteria.textContent = task.successCriteria || '';
935
+ dom.panelCriteriaWrap.style.display = task.successCriteria ? '' : 'none';
936
+ dom.panelBadge.innerHTML = badgeHTML(task.status);
937
+ dom.panelAgentLabel.textContent = task.agentLabel || '';
938
+ dom.panelElapsed.textContent = state.taskStartTimes.has(task.id) ? formatElapsed(task.id) : '';
939
+ }
940
+
941
+ function renderPanelOutput(task) {
942
+ dom.panelTerminalBody.innerHTML = '';
943
+ if (!task.output || task.output.length === 0) {
944
+ const p = document.createElement('div');
945
+ p.className = 'terminal-placeholder';
946
+ p.textContent = task.status === 'backlog'
947
+ ? 'Tarea en cola. El output aparecerá cuando empiece.'
948
+ : 'Sin output todavía.';
949
+ dom.panelTerminalBody.appendChild(p);
950
+ return;
951
+ }
952
+ task.output.forEach(entry => appendPanelLine(entry.chunk, entry.type));
953
+ scrollPanelToBottom();
954
+ }
955
+
956
+ function appendPanelLine(text, type) {
957
+ const placeholder = dom.panelTerminalBody.querySelector('.terminal-placeholder');
958
+ if (placeholder) placeholder.remove();
959
+
960
+ const line = document.createElement('span');
961
+ line.className = `terminal-line ${type || 'stdout'}`;
962
+ line.textContent = text;
963
+ dom.panelTerminalBody.appendChild(line);
964
+ scrollPanelToBottom();
965
+ }
966
+
967
+ function scrollPanelToBottom() {
968
+ dom.panelTerminalBody.scrollTop = dom.panelTerminalBody.scrollHeight;
969
+ }
970
+
971
+ // ── Pause All / Clear All visibility ─────────────────────────
972
+ function updatePauseAllVisibility() {
973
+ const hasRunning = [...state.tasks.values()].some(
974
+ t => t.status === 'in_progress' || t.status === 'verifying'
975
+ );
976
+ dom.pauseAllBtn.style.display = hasRunning ? 'inline-flex' : 'none';
977
+ }
978
+
979
+ function updateClearAllVisibility() {
980
+ const hasTasks = [...state.tasks.values()].some(t => !VIRTUAL_TASK_IDS.has(t.id));
981
+ dom.clearAllBtn.style.display = hasTasks ? 'inline-flex' : 'none';
982
+ if (hasTasks) {
983
+ dom.runAllBtn.style.display = 'inline-flex';
984
+ dom.qaBtn.style.display = 'inline-flex';
985
+ if (dom.checklistBtn) dom.checklistBtn.style.display = 'inline-flex';
986
+ if (dom.deployBtn) dom.deployBtn.style.display = 'inline-flex';
987
+ }
988
+ }
989
+
990
+ // ── Chat UI ──────────────────────────────────────────────────
991
+ function sendMessage() {
992
+ const text = dom.chatInput.value.trim();
993
+ if (!text && !state.attachedImage) return;
994
+ if (!state.connected) {
995
+ appendChatError('Not connected to server. Please wait…');
996
+ return;
997
+ }
998
+
999
+ dom.chatInput.value = '';
1000
+ dom.chatInput.style.height = '';
1001
+ const displayText = text || '📎 [imagen adjunta]';
1002
+ appendChatMessage('user', displayText);
1003
+ state.chatHistory.push({ role: 'user', content: displayText });
1004
+
1005
+ const wsMsg = { type: 'orchestrator:message', text: text || '(El usuario envió una imagen sin texto)' };
1006
+ if (state.attachedImage) {
1007
+ wsMsg.image = state.attachedImage;
1008
+ state.attachedImage = null;
1009
+ dom.imgPreview.style.display = 'none';
1010
+ dom.imgPreviewImg.src = '';
1011
+ }
1012
+
1013
+ sendWS(wsMsg);
1014
+ showTypingIndicator(true);
1015
+ }
1016
+
1017
+ function appendChatMessage(role, content) {
1018
+ if (role === 'assistant') finalizeAssistantMessage();
1019
+
1020
+ const wrapper = document.createElement('div');
1021
+ wrapper.className = `chat-message ${role}`;
1022
+
1023
+ const label = document.createElement('span');
1024
+ label.className = 'message-label';
1025
+ label.textContent = role === 'user' ? 'Vos' : 'Orquestador';
1026
+
1027
+ const bubble = document.createElement('div');
1028
+ bubble.className = 'message-bubble';
1029
+ if (role === 'assistant') {
1030
+ bubble.innerHTML = renderMarkdown(content);
1031
+ } else {
1032
+ bubble.textContent = content;
1033
+ }
1034
+
1035
+ wrapper.appendChild(label);
1036
+ wrapper.appendChild(bubble);
1037
+
1038
+ const welcome = dom.chatMessages.querySelector('.chat-welcome');
1039
+ if (welcome) welcome.remove();
1040
+
1041
+ dom.chatMessages.appendChild(wrapper);
1042
+ scrollChatToBottom();
1043
+
1044
+ return wrapper;
1045
+ }
1046
+
1047
+ function appendChatError(errorText) {
1048
+ const wrapper = document.createElement('div');
1049
+ wrapper.className = 'chat-message assistant error';
1050
+
1051
+ const label = document.createElement('span');
1052
+ label.className = 'message-label';
1053
+ label.textContent = 'Error';
1054
+
1055
+ const bubble = document.createElement('div');
1056
+ bubble.className = 'message-bubble';
1057
+ bubble.textContent = errorText;
1058
+
1059
+ wrapper.appendChild(label);
1060
+ wrapper.appendChild(bubble);
1061
+ dom.chatMessages.appendChild(wrapper);
1062
+ scrollChatToBottom();
1063
+ }
1064
+
1065
+ function handleOrchestratorChunk(chunk) {
1066
+ showTypingIndicator(false);
1067
+
1068
+ if (!state.currentAssistantMsgEl) {
1069
+ const wrapper = document.createElement('div');
1070
+ wrapper.className = 'chat-message assistant';
1071
+
1072
+ const label = document.createElement('span');
1073
+ label.className = 'message-label';
1074
+ label.textContent = 'Orquestador';
1075
+
1076
+ const bubble = document.createElement('div');
1077
+ bubble.className = 'message-bubble streaming-cursor';
1078
+ bubble.textContent = '';
1079
+ bubble._rawText = '';
1080
+
1081
+ wrapper.appendChild(label);
1082
+ wrapper.appendChild(bubble);
1083
+
1084
+ const welcome = dom.chatMessages.querySelector('.chat-welcome');
1085
+ if (welcome) welcome.remove();
1086
+
1087
+ dom.chatMessages.appendChild(wrapper);
1088
+ state.currentAssistantMsgEl = bubble;
1089
+ }
1090
+
1091
+ state.currentAssistantMsgEl._rawText = (state.currentAssistantMsgEl._rawText || '') + chunk;
1092
+
1093
+ // During streaming, hide PRD/TASKS blocks — show only the ack line + pending indicator
1094
+ const raw = state.currentAssistantMsgEl._rawText;
1095
+ let streamDisplay;
1096
+ if (raw.includes('<PRD>') || raw.includes('<TASKS>')) {
1097
+ const beforePrd = raw.split('<PRD>')[0].trim();
1098
+ const ackLine = beforePrd.split('\n').find(l => l.trim().length > 0) || '';
1099
+ streamDisplay = (ackLine ? ackLine + '\n\n' : '') + '⏳ Creando tareas…';
1100
+ } else {
1101
+ streamDisplay = raw;
1102
+ }
1103
+ state.currentAssistantMsgEl.innerHTML = renderMarkdown(streamDisplay) + '<span class="cursor-blink">▊</span>';
1104
+ scrollChatToBottom();
1105
+ }
1106
+
1107
+ function finalizeAssistantMessage() {
1108
+ if (state.currentAssistantMsgEl) {
1109
+ state.currentAssistantMsgEl.classList.remove('streaming-cursor');
1110
+ const raw = state.currentAssistantMsgEl._rawText || state.currentAssistantMsgEl.textContent;
1111
+
1112
+ let display;
1113
+ if (raw.includes('<TASKS>')) {
1114
+ // Tasks were created — only show the short ack line before <PRD>, then badge
1115
+ const beforePrd = raw.split('<PRD>')[0].trim();
1116
+ const ackLine = beforePrd.split('\n').find(l => l.trim().length > 0) || '';
1117
+ display = (ackLine ? ackLine + '\n\n' : '') + '✅ Tareas creadas — revisá el board →';
1118
+ } else {
1119
+ // Regular conversation — strip PRD block if present, show the rest
1120
+ display = raw.replace(/<PRD>[\s\S]*?<\/PRD>/g, '').trim();
1121
+ }
1122
+
1123
+ state.currentAssistantMsgEl.innerHTML = renderMarkdown(display);
1124
+ if (raw) state.chatHistory.push({ role: 'assistant', content: raw });
1125
+ state.currentAssistantMsgEl = null;
1126
+ }
1127
+ }
1128
+
1129
+ function showTypingIndicator(show) {
1130
+ dom.typingIndicator.style.display = show ? 'flex' : 'none';
1131
+ }
1132
+
1133
+ function scrollChatToBottom() {
1134
+ dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
1135
+ }
1136
+
1137
+ // ── PRD Modal ────────────────────────────────────────────────
1138
+ async function openPRD() {
1139
+ dom.prdModal.style.display = 'flex';
1140
+ dom.prdContent.textContent = 'Loading…';
1141
+
1142
+ try {
1143
+ const res = await fetch('/api/prd');
1144
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1145
+ const data = await res.json();
1146
+ const md = data.prd || '';
1147
+ dom.prdContent.innerHTML = renderMarkdown(md);
1148
+ } catch (err) {
1149
+ dom.prdContent.textContent = `Failed to load PRD: ${err.message}`;
1150
+ }
1151
+ }
1152
+
1153
+ function closePRD() {
1154
+ dom.prdModal.style.display = 'none';
1155
+ }
1156
+
1157
+ // Minimal Markdown renderer (no external deps)
1158
+ function renderMarkdown(md) {
1159
+ let html = escapeHTML(md);
1160
+
1161
+ const codeBlocks = [];
1162
+ html = html.replace(/```[\s\S]*?```/g, match => {
1163
+ const idx = codeBlocks.length;
1164
+ codeBlocks.push(match);
1165
+ return `\x00CODE_BLOCK_${idx}\x00`;
1166
+ });
1167
+
1168
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
1169
+ html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>');
1170
+ html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>');
1171
+ html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
1172
+ html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
1173
+ html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
1174
+ html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
1175
+ html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
1176
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1177
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
1178
+ html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
1179
+ html = html.replace(/_(.+?)_/g, '<em>$1</em>');
1180
+ html = html.replace(/^---+$/gm, '<hr>');
1181
+ html = html.replace(/^[ \t]*[-*+] (.+)$/gm, '<li>$1</li>');
1182
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, match => `<ul>${match}</ul>`);
1183
+ html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
1184
+ html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
1185
+ html = html.replace(/\n{2,}/g, '\n</p>\n<p>');
1186
+ html = '<p>' + html + '</p>';
1187
+ html = html.replace(/<p>(<h[1-6]>)/g, '$1');
1188
+ html = html.replace(/(<\/h[1-6]>)<\/p>/g, '$1');
1189
+ html = html.replace(/<p>(<ul>)/g, '$1');
1190
+ html = html.replace(/(<\/ul>)<\/p>/g, '$1');
1191
+ html = html.replace(/<p>(<blockquote>)/g, '$1');
1192
+ html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
1193
+ html = html.replace(/<p>(<hr>)<\/p>/g, '$1');
1194
+ html = html.replace(/<p><\/p>/g, '');
1195
+
1196
+ codeBlocks.forEach((block, idx) => {
1197
+ const lang = block.match(/^```(\w+)/)?.[1] || '';
1198
+ const code = block.replace(/^```\w*\n?/, '').replace(/\n?```$/, '');
1199
+ html = html.replace(
1200
+ `\x00CODE_BLOCK_${idx}\x00`,
1201
+ `<pre><code class="lang-${lang}">${code}</code></pre>`
1202
+ );
1203
+ });
1204
+
1205
+ return html;
1206
+ }
1207
+
1208
+ // ── Utilities ────────────────────────────────────────────────
1209
+ function escapeHTML(str) {
1210
+ if (typeof str !== 'string') return '';
1211
+ return str
1212
+ .replace(/&/g, '&amp;')
1213
+ .replace(/</g, '&lt;')
1214
+ .replace(/>/g, '&gt;')
1215
+ .replace(/"/g, '&quot;')
1216
+ .replace(/'/g, '&#39;');
1217
+ }
1218
+
1219
+ // ── Event Listeners ──────────────────────────────────────────
1220
+ dom.sendBtn.addEventListener('click', sendMessage);
1221
+
1222
+ dom.chatInput.addEventListener('keydown', (e) => {
1223
+ if (e.key === 'Enter' && !e.shiftKey) {
1224
+ e.preventDefault();
1225
+ sendMessage();
1226
+ }
1227
+ });
1228
+
1229
+ dom.chatInput.addEventListener('input', () => {
1230
+ dom.chatInput.style.height = 'auto';
1231
+ dom.chatInput.style.height = Math.min(dom.chatInput.scrollHeight, 120) + 'px';
1232
+ });
1233
+
1234
+ // Paste image from clipboard (Ctrl+V on screenshot)
1235
+ dom.chatInput.addEventListener('paste', (e) => {
1236
+ const items = e.clipboardData?.items;
1237
+ if (!items) return;
1238
+ for (const item of items) {
1239
+ if (item.type.startsWith('image/')) {
1240
+ e.preventDefault();
1241
+ const blob = item.getAsFile();
1242
+ const reader = new FileReader();
1243
+ reader.onload = (ev) => {
1244
+ state.attachedImage = ev.target.result;
1245
+ dom.imgPreviewImg.src = ev.target.result;
1246
+ dom.imgPreview.style.display = 'flex';
1247
+ };
1248
+ reader.readAsDataURL(blob);
1249
+ break;
1250
+ }
1251
+ }
1252
+ });
1253
+
1254
+ dom.runAllBtn.addEventListener('click', () => {
1255
+ if (!state.connected) return;
1256
+ sendWS({ type: 'queue:process' });
1257
+ dom.runAllBtn.disabled = true;
1258
+ dom.runAllBtn.textContent = '⏳ Running…';
1259
+ setTimeout(() => {
1260
+ dom.runAllBtn.disabled = false;
1261
+ dom.runAllBtn.innerHTML = '&#9654; Run All Tasks';
1262
+ }, 3000);
1263
+ });
1264
+
1265
+ dom.pauseAllBtn.addEventListener('click', () => {
1266
+ if (!state.connected) return;
1267
+ sendWS({ type: 'agents:pause' });
1268
+ });
1269
+
1270
+ dom.clearAllBtn.addEventListener('click', () => {
1271
+ if (!state.connected) return;
1272
+ if (!confirm('¿Borrar todas las tareas del board?')) return;
1273
+ sendWS({ type: 'tasks:clear' });
1274
+ });
1275
+
1276
+ dom.qaBtn.addEventListener('click', () => {
1277
+ if (!state.connected) return;
1278
+ sendWS({ type: 'qa:run' });
1279
+ showQAToast('Agente QA abriendo Chrome y revisando el proyecto…');
1280
+ dom.qaBtn.disabled = true;
1281
+ dom.qaBtn.textContent = '⏳ Revisando…';
1282
+ setTimeout(() => {
1283
+ dom.qaBtn.disabled = false;
1284
+ dom.qaBtn.innerHTML = '&#128269; Revisar en Chrome';
1285
+ }, 8000);
1286
+ });
1287
+
1288
+ if (dom.checklistBtn) {
1289
+ dom.checklistBtn.addEventListener('click', () => {
1290
+ if (!state.connected) return;
1291
+ sendWS({ type: 'checklist:run' });
1292
+ });
1293
+ }
1294
+
1295
+ if (dom.deployBtn) {
1296
+ dom.deployBtn.addEventListener('click', () => {
1297
+ if (!state.connected) return;
1298
+ if (!confirm('¿Deployar el proyecto a Netlify? Asegurate de que el netlify CLI esté instalado y logueado.')) return;
1299
+ sendWS({ type: 'deploy:run' });
1300
+ });
1301
+ }
1302
+
1303
+ dom.prdBtn.addEventListener('click', openPRD);
1304
+
1305
+ // PRD file upload
1306
+ dom.prdFileInput.addEventListener('change', (e) => {
1307
+ handlePRDFileUpload(e.target.files[0]);
1308
+ dom.prdFileInput.value = '';
1309
+ });
1310
+
1311
+ // Supabase modal events
1312
+ dom.closeSupabaseModal.addEventListener('click', closeSupabaseModal);
1313
+ dom.cancelSupabaseModal.addEventListener('click', () => {
1314
+ closeSupabaseModal();
1315
+ // Let tasks run without credentials anyway (user chose to cancel)
1316
+ sendWS({ type: 'supabase:credentials', supabaseUrl: '', anonKey: '', serviceKey: '', writeDotEnv: false });
1317
+ });
1318
+ dom.submitSupabaseModal.addEventListener('click', submitSupabaseCredentials);
1319
+ dom.supabaseBackdrop.addEventListener('click', closeSupabaseModal);
1320
+ // Submit with Enter key on last required field
1321
+ dom.sbAnonKey.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitSupabaseCredentials(); });
1322
+
1323
+ // Sidebar collapse/expand
1324
+ dom.sidebarCollapseBtn.addEventListener('click', () => {
1325
+ dom.app.classList.add('sidebar-collapsed');
1326
+ dom.sidebarExpandBtn.style.display = 'inline-flex';
1327
+ });
1328
+ dom.sidebarExpandBtn.addEventListener('click', () => {
1329
+ dom.app.classList.remove('sidebar-collapsed');
1330
+ dom.sidebarExpandBtn.style.display = 'none';
1331
+ });
1332
+
1333
+ // Redo all error tasks
1334
+ document.getElementById('redoErrorsBtn').addEventListener('click', () => {
1335
+ if (!state.connected) return;
1336
+ sendWS({ type: 'errors:retry-all' });
1337
+ });
1338
+
1339
+ // Image attachment
1340
+ dom.imgAttach.addEventListener('change', (e) => {
1341
+ const file = e.target.files[0];
1342
+ if (!file) return;
1343
+ const reader = new FileReader();
1344
+ reader.onload = (ev) => {
1345
+ state.attachedImage = ev.target.result;
1346
+ dom.imgPreviewImg.src = ev.target.result;
1347
+ dom.imgPreview.style.display = 'flex';
1348
+ };
1349
+ reader.readAsDataURL(file);
1350
+ dom.imgAttach.value = ''; // allow re-selecting same file
1351
+ });
1352
+
1353
+ dom.removeImg.addEventListener('click', () => {
1354
+ state.attachedImage = null;
1355
+ dom.imgPreviewImg.src = '';
1356
+ dom.imgPreview.style.display = 'none';
1357
+ });
1358
+
1359
+ dom.closePanel.addEventListener('click', closePanel);
1360
+
1361
+ dom.clearPanelTerminal.addEventListener('click', (e) => {
1362
+ e.stopPropagation();
1363
+ dom.panelTerminalBody.innerHTML = '';
1364
+ const p = document.createElement('div');
1365
+ p.className = 'terminal-placeholder';
1366
+ p.textContent = 'Terminal cleared.';
1367
+ dom.panelTerminalBody.appendChild(p);
1368
+ });
1369
+
1370
+ document.addEventListener('keydown', (e) => {
1371
+ if (e.key === 'Escape') {
1372
+ if (dom.prdModal.style.display !== 'none') {
1373
+ closePRD();
1374
+ } else if (state.panelOpen) {
1375
+ closePanel();
1376
+ }
1377
+ }
1378
+ });
1379
+
1380
+ // ── Global exposed functions ─────────────────────────────────
1381
+ window.closePRD = closePRD;
1382
+
1383
+ // ── System monitor ────────────────────────────────────────────
1384
+ async function updateSysMonitor() {
1385
+ try {
1386
+ const res = await fetch('/api/system');
1387
+ if (!res.ok) return;
1388
+ const { cpu, ramUsed, ramTotal, ramPercent } = await res.json();
1389
+ const cpuHigh = cpu > 80 ? 'high' : '';
1390
+ const ramHigh = ramPercent > 80 ? 'high' : '';
1391
+ dom.sysMonitor.innerHTML = `
1392
+ <span class="sys-stat">
1393
+ <span>CPU</span>
1394
+ <span class="sys-bar"><span class="sys-bar-fill ${cpuHigh}" style="width:${cpu}%"></span></span>
1395
+ <span>${cpu}%</span>
1396
+ </span>
1397
+ <span class="sys-stat">
1398
+ <span>RAM</span>
1399
+ <span class="sys-bar"><span class="sys-bar-fill ${ramHigh}" style="width:${ramPercent}%"></span></span>
1400
+ <span>${ramUsed}MB</span>
1401
+ </span>
1402
+ `;
1403
+ } catch {}
1404
+ }
1405
+ updateSysMonitor();
1406
+ setInterval(updateSysMonitor, 5000);
1407
+
1408
+ // ── Init ─────────────────────────────────────────────────────
1409
+ connectWS();
1410
+ renderBoard();
1411
+ initDropZones();