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/README.md +89 -93
- package/bin/cli.js +198 -238
- package/bin/init-context.js +22 -0
- package/package.json +25 -43
- package/public/app.js +1411 -0
- package/public/index.html +250 -0
- package/public/style.css +1872 -0
- package/src/context-template.md +20 -0
- package/src/notifier.js +65 -0
- package/src/orchestrator.js +939 -0
- package/src/scanner.js +153 -0
- package/src/server.js +205 -0
- package/src/store.js +182 -0
- package/src/verifier.js +131 -0
- package/agents/architect.js +0 -166
- package/agents/board-client.js +0 -126
- package/agents/claude-api.js +0 -124
- package/agents/claude-resolver.js +0 -167
- package/agents/developer.js +0 -224
- package/agents/expo-health.js +0 -727
- package/agents/orchestrator.js +0 -306
- package/agents/qa.js +0 -336
- package/dashboard/index.html +0 -1980
- package/dashboard/server.js +0 -412
- package/sql/setup.sql +0 -57
- package/tools/filesystem.js +0 -95
- package/tools/screenshot.js +0 -74
- package/tools/supabase-reader.js +0 -74
- package/tools/terminal.js +0 -63
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">■</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">✕</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(/^> (.+)$/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, '&')
|
|
1213
|
+
.replace(/</g, '<')
|
|
1214
|
+
.replace(/>/g, '>')
|
|
1215
|
+
.replace(/"/g, '"')
|
|
1216
|
+
.replace(/'/g, ''');
|
|
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 = '▶ 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 = '🔍 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();
|