codeswarm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,758 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Agentic Dashboard</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <script>
10
+ tailwind.config = {
11
+ darkMode: 'class',
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ bg: '#0f1117',
16
+ card: '#1a1d27',
17
+ border: '#2a2d3a',
18
+ accent: '#6366f1',
19
+ success: '#22c55e',
20
+ warning: '#eab308',
21
+ danger: '#ef4444',
22
+ }
23
+ }
24
+ }
25
+ }
26
+ </script>
27
+ <style>
28
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap');
29
+
30
+ body {
31
+ font-family: 'Inter', sans-serif;
32
+ background: #0f1117;
33
+ color: #e2e8f0;
34
+ }
35
+
36
+ .mono {
37
+ font-family: 'JetBrains Mono', monospace;
38
+ }
39
+
40
+ .log-box {
41
+ font-family: 'JetBrains Mono', monospace;
42
+ font-size: 12px;
43
+ line-height: 1.6;
44
+ }
45
+
46
+ @keyframes pulse-ring {
47
+ 0% {
48
+ transform: scale(0.8);
49
+ opacity: 1;
50
+ }
51
+
52
+ 100% {
53
+ transform: scale(1.8);
54
+ opacity: 0;
55
+ }
56
+ }
57
+
58
+ .pulse-ring {
59
+ animation: pulse-ring 1.5s ease-out infinite;
60
+ }
61
+
62
+ @keyframes spin {
63
+ to {
64
+ transform: rotate(360deg);
65
+ }
66
+ }
67
+
68
+ .agent-spin {
69
+ animation: spin 2s linear infinite;
70
+ }
71
+
72
+ .flow-line {
73
+ stroke-dasharray: 8 4;
74
+ animation: dash 1s linear infinite;
75
+ }
76
+
77
+ @keyframes dash {
78
+ to {
79
+ stroke-dashoffset: -12;
80
+ }
81
+ }
82
+
83
+ .scroll-auto {
84
+ scroll-behavior: smooth;
85
+ }
86
+
87
+ ::-webkit-scrollbar {
88
+ width: 6px;
89
+ }
90
+
91
+ ::-webkit-scrollbar-track {
92
+ background: #1a1d27;
93
+ }
94
+
95
+ ::-webkit-scrollbar-thumb {
96
+ background: #3a3d4a;
97
+ border-radius: 3px;
98
+ }
99
+
100
+ .tab-active {
101
+ border-bottom: 2px solid #6366f1;
102
+ color: #e2e8f0;
103
+ }
104
+
105
+ .tab-inactive {
106
+ border-bottom: 2px solid transparent;
107
+ color: #64748b;
108
+ }
109
+
110
+ .status-dot {
111
+ width: 10px;
112
+ height: 10px;
113
+ border-radius: 50%;
114
+ display: inline-block;
115
+ }
116
+
117
+ .glow-green {
118
+ box-shadow: 0 0 12px rgba(34, 197, 94, 0.3);
119
+ }
120
+
121
+ .glow-yellow {
122
+ box-shadow: 0 0 12px rgba(234, 179, 8, 0.3);
123
+ }
124
+
125
+ .glow-red {
126
+ box-shadow: 0 0 12px rgba(239, 68, 68, 0.3);
127
+ }
128
+
129
+ .glow-purple {
130
+ box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
131
+ }
132
+
133
+ .search-highlight {
134
+ background: rgba(234, 179, 8, 0.3);
135
+ border-radius: 2px;
136
+ padding: 0 2px;
137
+ }
138
+
139
+ .phase-badge {
140
+ font-size: 10px;
141
+ padding: 2px 8px;
142
+ border-radius: 9999px;
143
+ font-weight: 600;
144
+ text-transform: uppercase;
145
+ letter-spacing: 0.05em;
146
+ }
147
+
148
+ .phase-reading {
149
+ background: rgba(59, 130, 246, 0.2);
150
+ color: #60a5fa;
151
+ }
152
+
153
+ .phase-implementing {
154
+ background: rgba(168, 85, 247, 0.2);
155
+ color: #c084fc;
156
+ }
157
+
158
+ .phase-testing {
159
+ background: rgba(234, 179, 8, 0.2);
160
+ color: #fbbf24;
161
+ }
162
+
163
+ .phase-building {
164
+ background: rgba(99, 102, 241, 0.2);
165
+ color: #818cf8;
166
+ }
167
+
168
+ .phase-committing {
169
+ background: rgba(34, 197, 94, 0.2);
170
+ color: #4ade80;
171
+ }
172
+
173
+ .phase-reviewing {
174
+ background: rgba(244, 114, 182, 0.2);
175
+ color: #f472b6;
176
+ }
177
+
178
+ .timeline-dot {
179
+ width: 8px;
180
+ height: 8px;
181
+ border-radius: 50%;
182
+ flex-shrink: 0;
183
+ margin-top: 5px;
184
+ }
185
+
186
+ .timeline-line {
187
+ width: 2px;
188
+ background: #2a2d3a;
189
+ margin-left: 3px;
190
+ flex-shrink: 0;
191
+ }
192
+ </style>
193
+ </head>
194
+
195
+ <body class="min-h-screen">
196
+ <div id="app"></div>
197
+
198
+ <script>
199
+ // ─── State ──────────────────────────────────────────
200
+ let state = null;
201
+ let selectedAgent = null;
202
+ let selectedLogFile = null;
203
+ let logContent = '';
204
+ let wsConnected = false;
205
+ let searchQuery = '';
206
+ let searchResults = null;
207
+ let currentPhase = null;
208
+ let showDirectives = false;
209
+
210
+ const AGENT_COLORS = {
211
+ claude: '#f97316',
212
+ codex: '#22c55e',
213
+ gemini: '#3b82f6',
214
+ amp: '#a855f7',
215
+ default: '#6366f1'
216
+ };
217
+
218
+ const AGENT_ICONS = {
219
+ claude: '🟠',
220
+ codex: '🟢',
221
+ gemini: '🔵',
222
+ amp: '🟣'
223
+ };
224
+
225
+ function agentColor(name) {
226
+ return AGENT_COLORS[name] || AGENT_COLORS.default;
227
+ }
228
+
229
+ // ─── WebSocket ──────────────────────────────────────
230
+ function connect() {
231
+ const ws = new WebSocket(`ws://${location.host}`);
232
+ ws.onopen = () => { wsConnected = true; render(); };
233
+ ws.onclose = () => {
234
+ wsConnected = false;
235
+ render();
236
+ setTimeout(connect, 2000);
237
+ };
238
+ ws.onmessage = (e) => {
239
+ const msg = JSON.parse(e.data);
240
+ if (msg.state) {
241
+ state = msg.state;
242
+ render();
243
+ // Auto-refresh selected log
244
+ if (selectedLogFile) fetchLog(selectedLogFile);
245
+ }
246
+ };
247
+ }
248
+ connect();
249
+
250
+ // ─── API ────────────────────────────────────────────
251
+ async function fetchLog(file) {
252
+ selectedLogFile = file;
253
+ try {
254
+ const res = await fetch(`/api/log/${file}?lines=500`);
255
+ const data = await res.json();
256
+ logContent = data.content || '';
257
+ searchQuery = '';
258
+ searchResults = null;
259
+ render();
260
+ // Auto-scroll log
261
+ const logEl = document.getElementById('log-content');
262
+ if (logEl) logEl.scrollTop = logEl.scrollHeight;
263
+ // Fetch phase info
264
+ fetchPhase(file);
265
+ } catch (e) {
266
+ logContent = 'Error loading log';
267
+ render();
268
+ }
269
+ }
270
+
271
+ async function fetchPhase(file) {
272
+ try {
273
+ const res = await fetch(`/api/log-phases/${file}`);
274
+ const data = await res.json();
275
+ currentPhase = data.currentPhase;
276
+ render();
277
+ } catch (e) { /* ignore */ }
278
+ }
279
+
280
+ async function searchLog(query) {
281
+ searchQuery = query;
282
+ if (!selectedLogFile || !query) {
283
+ searchResults = null;
284
+ render();
285
+ return;
286
+ }
287
+ try {
288
+ const res = await fetch(`/api/log-search/${selectedLogFile}?q=${encodeURIComponent(query)}`);
289
+ searchResults = await res.json();
290
+ render();
291
+ } catch (e) {
292
+ searchResults = { matches: [], total: 0 };
293
+ render();
294
+ }
295
+ }
296
+
297
+ function downloadLog() {
298
+ if (!selectedLogFile) return;
299
+ window.open(`/api/log-download/${selectedLogFile}`, '_blank');
300
+ }
301
+
302
+ async function switchSession(sid) {
303
+ const res = await fetch(`/api/session/${sid}`);
304
+ state = await res.json();
305
+ selectedAgent = null;
306
+ selectedLogFile = null;
307
+ logContent = '';
308
+ render();
309
+ }
310
+
311
+ // ─── Render ─────────────────────────────────────────
312
+ function render() {
313
+ const app = document.getElementById('app');
314
+ if (!state) {
315
+ app.innerHTML = renderLoading();
316
+ return;
317
+ }
318
+ if (state.error) {
319
+ app.innerHTML = renderError();
320
+ return;
321
+ }
322
+ app.innerHTML = `
323
+ ${renderHeader()}
324
+ <div class="grid grid-cols-12 gap-4 p-4 h-[calc(100vh-140px)]">
325
+ <div class="col-span-3 flex flex-col gap-4 overflow-y-auto">
326
+ ${renderAgentFlow()}
327
+ ${renderSubtasks()}
328
+ </div>
329
+ <div class="col-span-6 flex flex-col gap-4 overflow-hidden">
330
+ ${renderLogPanel()}
331
+ </div>
332
+ <div class="col-span-3 flex flex-col gap-4 overflow-y-auto">
333
+ ${state.task.isPrd ? renderPrdProgress() : ''}
334
+ ${renderDirectiveTimeline()}
335
+ ${renderCoordLog()}
336
+ </div>
337
+ </div>
338
+ `;
339
+ }
340
+
341
+ function renderLoading() {
342
+ return `
343
+ <div class="flex items-center justify-center h-screen">
344
+ <div class="text-center">
345
+ <div class="text-4xl mb-4 agent-spin inline-block">⚙️</div>
346
+ <h2 class="text-xl font-semibold text-gray-300">Connecting to Dashboard...</h2>
347
+ <p class="text-gray-500 mt-2 mono text-sm">${wsConnected ? 'Waiting for session data' : 'Connecting WebSocket...'}</p>
348
+ </div>
349
+ </div>
350
+ `;
351
+ }
352
+
353
+ function renderError() {
354
+ return `
355
+ <div class="flex items-center justify-center h-screen">
356
+ <div class="text-center max-w-md">
357
+ <div class="text-4xl mb-4">📭</div>
358
+ <h2 class="text-xl font-semibold text-gray-300">No Active Session</h2>
359
+ <p class="text-gray-500 mt-2">Start the coordinator to begin monitoring.</p>
360
+ ${state.sessions && state.sessions.length > 0 ? `
361
+ <div class="mt-6">
362
+ <h3 class="text-sm font-medium text-gray-400 mb-3">Previous Sessions</h3>
363
+ <div class="flex flex-col gap-2">
364
+ ${state.sessions.map(s => `
365
+ <button onclick="switchSession('${s}')" class="px-4 py-2 bg-card rounded-lg border border-border hover:border-accent transition mono text-sm text-left">
366
+ ${s}
367
+ </button>
368
+ `).join('')}
369
+ </div>
370
+ </div>
371
+ ` : ''}
372
+ </div>
373
+ </div>
374
+ `;
375
+ }
376
+
377
+ function renderHeader() {
378
+ const done = state.task.subtasks.filter(s => s.status === 'done').length;
379
+ const total = state.task.subtasks.length;
380
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
381
+ const running = state.runningAgents.length;
382
+
383
+ return `
384
+ <header class="bg-card border-b border-border px-6 py-3">
385
+ <div class="flex items-center justify-between">
386
+ <div class="flex items-center gap-4">
387
+ <h1 class="text-lg font-bold text-white flex items-center gap-2">
388
+ <span class="text-2xl">🧠</span> Agentic Dashboard
389
+ </h1>
390
+ <div class="flex items-center gap-2">
391
+ <span class="status-dot ${wsConnected ? 'bg-success' : 'bg-danger'}"></span>
392
+ <span class="text-xs text-gray-400 mono">${wsConnected ? 'LIVE' : 'DISCONNECTED'}</span>
393
+ </div>
394
+ </div>
395
+ <div class="flex items-center gap-6">
396
+ <div class="text-right">
397
+ <div class="text-xs text-gray-500">Session</div>
398
+ <div class="mono text-sm text-gray-300">${state.sessionId}</div>
399
+ </div>
400
+ <div class="text-right">
401
+ <div class="text-xs text-gray-500">Round</div>
402
+ <div class="mono text-sm text-accent font-bold">${state.currentRound}</div>
403
+ </div>
404
+ <div class="text-right">
405
+ <div class="text-xs text-gray-500">Progress</div>
406
+ <div class="mono text-sm font-bold ${pct === 100 ? 'text-success' : 'text-white'}">${done}/${total} (${pct}%)</div>
407
+ </div>
408
+ <div class="text-right">
409
+ <div class="text-xs text-gray-500">Active</div>
410
+ <div class="mono text-sm ${running > 0 ? 'text-warning' : 'text-gray-500'}">${running} agent${running !== 1 ? 's' : ''}</div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ <!-- Progress bar -->
415
+ <div class="mt-2 h-1.5 bg-border rounded-full overflow-hidden">
416
+ <div class="h-full rounded-full transition-all duration-500 ${pct === 100 ? 'bg-success' : 'bg-accent'}" style="width: ${pct}%"></div>
417
+ </div>
418
+ ${state.task.title ? `<div class="mt-1 text-xs text-gray-500 truncate">${state.task.title}</div>` : ''}
419
+ </header>
420
+ `;
421
+ }
422
+
423
+ function renderAgentFlow() {
424
+ const r = state.roles;
425
+ const agents = state.agents;
426
+ const running = new Set(state.runningAgents);
427
+
428
+ function agentNode(name, role, emoji) {
429
+ if (!name) return '';
430
+ const isRunning = running.has(name);
431
+ const agent = agents.find(a => a.name === name);
432
+ const logCount = agent ? agent.logs.length : 0;
433
+ const color = agentColor(name);
434
+ const borderClass = isRunning ? 'glow-yellow border-warning' : 'border-border';
435
+
436
+ return `
437
+ <div class="flex items-center gap-3 p-3 bg-card rounded-lg border ${borderClass} cursor-pointer hover:border-accent transition"
438
+ onclick="selectAgent('${name}')">
439
+ <div class="relative">
440
+ <span class="text-xl">${emoji || AGENT_ICONS[name] || '⚪'}</span>
441
+ ${isRunning ? '<span class="absolute -top-1 -right-1 w-3 h-3 bg-warning rounded-full pulse-ring"></span>' : ''}
442
+ </div>
443
+ <div class="flex-1 min-w-0">
444
+ <div class="font-semibold text-sm text-white">${name}</div>
445
+ <div class="text-xs text-gray-500">${role}</div>
446
+ </div>
447
+ <div class="text-xs mono text-gray-500">${logCount} logs</div>
448
+ </div>
449
+ `;
450
+ }
451
+
452
+ return `
453
+ <div class="bg-card/50 rounded-xl border border-border p-4">
454
+ <h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Agent Flow</h2>
455
+ <div class="flex flex-col gap-2">
456
+ ${agentNode(r.planner, 'Planner (brain)', '🧠')}
457
+ <div class="flex justify-center text-gray-600">↓</div>
458
+ ${agentNode(r.executor, 'Backend Executor', '⚡')}
459
+ <div class="flex justify-center text-gray-600">↓</div>
460
+ ${r.reviewers.map(rev => agentNode(rev, 'Backend Reviewer', '🔍')).join('')}
461
+ ${r.frontendDev ? `
462
+ <div class="mt-2 pt-2 border-t border-border">
463
+ <div class="text-xs text-gray-500 mb-2">Frontend</div>
464
+ ${agentNode(r.frontendDev, 'Frontend Dev', '🎨')}
465
+ <div class="flex justify-center text-gray-600">↓</div>
466
+ ${r.frontendReviewers.map(rev => agentNode(rev, 'FE Reviewer', '🔍')).join('')}
467
+ </div>
468
+ ` : ''}
469
+ </div>
470
+ </div>
471
+ `;
472
+ }
473
+
474
+ function renderSubtasks() {
475
+ const subtasks = state.task.subtasks;
476
+ if (subtasks.length === 0) {
477
+ return `
478
+ <div class="bg-card/50 rounded-xl border border-border p-4">
479
+ <h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Subtasks</h2>
480
+ <p class="text-gray-500 text-sm">No subtasks yet — waiting for planner...</p>
481
+ </div>
482
+ `;
483
+ }
484
+
485
+ const statusIcon = { done: '✅', in_progress: '🔄', pending: '⬜' };
486
+ const statusColor = { done: 'text-success', in_progress: 'text-warning', pending: 'text-gray-500' };
487
+
488
+ return `
489
+ <div class="bg-card/50 rounded-xl border border-border p-4 flex-1 overflow-y-auto">
490
+ <h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
491
+ Subtasks (${subtasks.filter(s => s.status === 'done').length}/${subtasks.length})
492
+ </h2>
493
+ <div class="flex flex-col gap-1.5">
494
+ ${subtasks.map(s => `
495
+ <div class="flex items-start gap-2 p-2 rounded-lg ${s.status === 'in_progress' ? 'bg-warning/5 border border-warning/20' : 'hover:bg-white/5'} transition">
496
+ <span class="text-sm flex-shrink-0 mt-0.5">${statusIcon[s.status]}</span>
497
+ <div class="min-w-0">
498
+ <span class="text-xs mono ${statusColor[s.status]}">#${s.num}</span>
499
+ <span class="text-xs text-gray-300 ml-1">${s.title.length > 60 ? s.title.substring(0, 60) + '...' : s.title}</span>
500
+ </div>
501
+ </div>
502
+ `).join('')}
503
+ </div>
504
+ </div>
505
+ `;
506
+ }
507
+
508
+ function selectAgent(name) {
509
+ selectedAgent = name;
510
+ const agent = state.agents.find(a => a.name === name);
511
+ if (agent && agent.latestLog) {
512
+ fetchLog(agent.latestLog.file);
513
+ }
514
+ render();
515
+ }
516
+
517
+ function renderLogPanel() {
518
+ const agents = state.agents;
519
+ if (agents.length === 0) {
520
+ return `
521
+ <div class="bg-card/50 rounded-xl border border-border p-6 flex-1 flex items-center justify-center">
522
+ <div class="text-center text-gray-500">
523
+ <div class="text-3xl mb-2">📋</div>
524
+ <p>No agent logs yet</p>
525
+ </div>
526
+ </div>
527
+ `;
528
+ }
529
+
530
+ // Tab bar
531
+ const tabs = agents.map(a => {
532
+ const isActive = selectedAgent === a.name;
533
+ const isRunning = state.runningAgents.includes(a.name);
534
+ return `
535
+ <button onclick="selectAgent('${a.name}')"
536
+ class="px-4 py-2 text-sm font-medium whitespace-nowrap ${isActive ? 'tab-active' : 'tab-inactive'} hover:text-gray-300 transition flex items-center gap-2">
537
+ <span>${AGENT_ICONS[a.name] || '⚪'}</span>
538
+ ${a.name}
539
+ ${isRunning ? '<span class="w-2 h-2 bg-warning rounded-full pulse-ring"></span>' : ''}
540
+ <span class="text-xs text-gray-600">(${a.logs.length})</span>
541
+ </button>
542
+ `;
543
+ });
544
+
545
+ // Log file selector + controls for selected agent
546
+ const agent = agents.find(a => a.name === selectedAgent);
547
+ const logSelector = agent ? agent.logs.map(l => `
548
+ <button onclick="fetchLog('${l.file}')"
549
+ class="px-2 py-1 text-xs mono rounded ${selectedLogFile === l.file ? 'bg-accent text-white' : 'bg-border text-gray-400 hover:bg-gray-700'} transition">
550
+ #${l.seq} (${(l.size / 1024).toFixed(1)}KB)
551
+ </button>
552
+ `).join('') : '';
553
+
554
+ // Phase badge
555
+ const phaseBadge = currentPhase && selectedAgent && state.runningAgents.includes(selectedAgent)
556
+ ? `<span class="phase-badge phase-${currentPhase.toLowerCase()}">${currentPhase}</span>`
557
+ : '';
558
+
559
+ // Search and controls toolbar
560
+ const toolbar = selectedAgent ? `
561
+ <div class="flex items-center gap-2 p-2 border-b border-border">
562
+ <div class="flex-1 relative">
563
+ <input type="text" id="log-search-input"
564
+ placeholder="Search logs... (Ctrl+F)"
565
+ value="${escapeHtml(searchQuery)}"
566
+ oninput="searchLog(this.value)"
567
+ class="w-full bg-bg border border-border rounded px-3 py-1.5 text-xs mono text-gray-300 focus:border-accent focus:outline-none placeholder-gray-600"
568
+ />
569
+ ${searchResults ? `<span class="absolute right-2 top-1.5 text-xs mono text-gray-500">${searchResults.total} match${searchResults.total !== 1 ? 'es' : ''}</span>` : ''}
570
+ </div>
571
+ ${phaseBadge}
572
+ <button onclick="downloadLog()" title="Download log" class="p-1.5 rounded hover:bg-border text-gray-500 hover:text-gray-300 transition">
573
+
574
+ </button>
575
+ </div>
576
+ ` : '';
577
+
578
+ // Render log content or search results
579
+ let logBody = '';
580
+ if (searchResults && searchResults.matches.length > 0) {
581
+ logBody = searchResults.matches.map(m => {
582
+ const highlighted = escapeHtml(m.content).replace(
583
+ new RegExp(escapeHtml(searchQuery), 'gi'),
584
+ match => `<span class="search-highlight">${match}</span>`
585
+ );
586
+ return `<div class="flex gap-2"><span class="text-gray-600 select-none">${m.line}:</span><span class="text-gray-300">${highlighted}</span></div>`;
587
+ }).join('');
588
+ } else if (selectedAgent && logContent) {
589
+ logBody = logContent.split('\n').map(line => {
590
+ let cls = 'text-gray-400';
591
+ if (line.includes('error') || line.includes('Error') || line.includes('ERROR')) cls = 'text-danger';
592
+ else if (line.includes('✓') || line.includes('success') || line.includes('PASS')) cls = 'text-success';
593
+ else if (line.includes('⚠') || line.includes('warn') || line.includes('WARN')) cls = 'text-warning';
594
+ return `<div class="${cls}">${escapeHtml(line)}</div>`;
595
+ }).join('');
596
+ } else {
597
+ logBody = '<div class="text-gray-500 text-center mt-10">← Select an agent to view logs</div>';
598
+ }
599
+
600
+ return `
601
+ <div class="bg-card/50 rounded-xl border border-border flex flex-col flex-1 overflow-hidden">
602
+ <div class="flex border-b border-border overflow-x-auto">
603
+ ${tabs.join('')}
604
+ </div>
605
+ ${agent ? `
606
+ <div class="flex gap-1 p-2 border-b border-border overflow-x-auto flex-shrink-0">
607
+ ${logSelector}
608
+ </div>
609
+ ` : ''}
610
+ ${toolbar}
611
+ <div id="log-content" class="flex-1 overflow-y-auto p-4 log-box text-gray-300 scroll-auto">
612
+ ${logBody}
613
+ </div>
614
+ </div>
615
+ `;
616
+ }
617
+
618
+ function renderPrdProgress() {
619
+ const subtasks = state.task.subtasks.filter(s => s.storyId);
620
+ if (subtasks.length === 0) return '';
621
+
622
+ const totalAC = subtasks.reduce((sum, s) => sum + s.acceptanceCriteria.length, 0);
623
+ const passedAC = subtasks.reduce((sum, s) => sum + s.acceptanceCriteria.filter(ac => ac.status === 'pass').length, 0);
624
+
625
+ return `
626
+ <div class="bg-card/50 rounded-xl border border-border p-4">
627
+ <div class="flex items-center justify-between mb-3">
628
+ <h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">PRD Progress</h2>
629
+ ${totalAC > 0 ? `<span class="text-xs mono ${passedAC === totalAC ? 'text-success' : 'text-gray-500'}">${passedAC}/${totalAC} AC</span>` : ''}
630
+ </div>
631
+ <div class="flex flex-col gap-2">
632
+ ${subtasks.map(s => {
633
+ const icon = s.status === 'done' ? '✅' : s.status === 'in_progress' ? '🔄' : '⬜';
634
+ const acSummary = s.acceptanceCriteria.length > 0
635
+ ? `<div class="flex gap-1 mt-1">${s.acceptanceCriteria.map(ac =>
636
+ `<span class="w-2 h-2 rounded-full ${ac.status === 'pass' ? 'bg-success' : ac.status === 'in_progress' ? 'bg-warning' : 'bg-gray-600'}"></span>`
637
+ ).join('')}</div>`
638
+ : '';
639
+ return `
640
+ <div class="p-2 rounded-lg hover:bg-white/5 transition">
641
+ <div class="flex items-center gap-2">
642
+ <span class="text-xs">${icon}</span>
643
+ <span class="text-xs mono text-accent">${s.storyId}</span>
644
+ <span class="text-xs text-gray-400 truncate flex-1">${s.title.replace(/\*\*/g, '').replace(/\[US-\d+\]\s*/, '').substring(0, 40)}</span>
645
+ </div>
646
+ ${acSummary}
647
+ </div>
648
+ `;
649
+ }).join('')}
650
+ </div>
651
+ </div>
652
+ `;
653
+ }
654
+
655
+ function renderDirectiveTimeline() {
656
+ const directives = state.directives || [];
657
+ if (directives.length === 0) return '';
658
+
659
+ const actionColors = {
660
+ EXECUTE: 'bg-blue-400', REVIEW: 'bg-purple-400', APPROVE: 'bg-success',
661
+ SKIP: 'bg-warning', DONE: 'bg-success', FRONTEND_EXECUTE: 'bg-indigo-400',
662
+ FRONTEND_REVIEW: 'bg-pink-400'
663
+ };
664
+ const actionIcons = {
665
+ EXECUTE: '⚡', REVIEW: '🔍', APPROVE: '✅',
666
+ SKIP: '⏭', DONE: '🎉', FRONTEND_EXECUTE: '🎨',
667
+ FRONTEND_REVIEW: '🔎'
668
+ };
669
+
670
+ return `
671
+ <div class="bg-card/50 rounded-xl border border-border p-4">
672
+ <h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Directive Timeline</h2>
673
+ <div class="flex flex-col">
674
+ ${directives.slice(-10).map((d, i, arr) => `
675
+ <div class="flex gap-3">
676
+ <div class="flex flex-col items-center">
677
+ <div class="timeline-dot ${actionColors[d.action] || 'bg-gray-500'}"></div>
678
+ ${i < arr.length - 1 ? '<div class="timeline-line flex-1 min-h-[16px]"></div>' : ''}
679
+ </div>
680
+ <div class="pb-3 min-w-0">
681
+ <div class="flex items-center gap-2">
682
+ <span class="text-xs">${actionIcons[d.action] || '❓'}</span>
683
+ <span class="text-xs font-semibold ${d.action === 'APPROVE' || d.action === 'DONE' ? 'text-success' : d.action === 'SKIP' ? 'text-warning' : 'text-gray-300'}">${d.action}</span>
684
+ ${d.subtask ? `<span class="text-xs mono text-gray-500">#${d.subtask}</span>` : ''}
685
+ </div>
686
+ ${d.instructions ? `<div class="text-xs text-gray-500 mt-0.5 truncate">${escapeHtml(d.instructions.substring(0, 60))}</div>` : ''}
687
+ </div>
688
+ </div>
689
+ `).join('')}
690
+ </div>
691
+ </div>
692
+ `;
693
+ }
694
+
695
+ function renderCoordLog() {
696
+ const logs = state.coordLog || [];
697
+
698
+ return `
699
+ <div class="bg-card/50 rounded-xl border border-border flex flex-col h-full overflow-hidden">
700
+ <div class="p-3 border-b border-border flex items-center justify-between">
701
+ <h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Coordinator Log</h2>
702
+ <span class="text-xs mono text-gray-600">${logs.length} entries</span>
703
+ </div>
704
+ <div class="flex-1 overflow-y-auto p-3 log-box">
705
+ ${logs.map(entry => {
706
+ let cls = 'text-gray-500';
707
+ const l = entry.raw;
708
+ if (l.includes('✓') || l.includes('APPROVED') || l.includes('done')) cls = 'text-success';
709
+ else if (l.includes('🚀') || l.includes('Sending')) cls = 'text-accent';
710
+ else if (l.includes('⚠') || l.includes('stale') || l.includes('SKIP')) cls = 'text-warning';
711
+ else if (l.includes('ERROR') || l.includes('failed') || l.includes('exited 1')) cls = 'text-danger';
712
+ else if (l.includes('EXECUTE') || l.includes('REVIEW')) cls = 'text-blue-400';
713
+ else if (l.includes('FRONTEND')) cls = 'text-purple-400';
714
+ return `<div class="${cls} leading-relaxed">${escapeHtml(l)}</div>`;
715
+ }).join('')}
716
+ </div>
717
+ ${state.sessions.length > 1 ? `
718
+ <div class="p-2 border-t border-border">
719
+ <select onchange="switchSession(this.value)" class="w-full bg-bg border border-border rounded px-2 py-1 text-xs mono text-gray-400">
720
+ ${state.sessions.map(s => `
721
+ <option value="${s}" ${s === state.sessionId ? 'selected' : ''}>${s}</option>
722
+ `).join('')}
723
+ </select>
724
+ </div>
725
+ ` : ''}
726
+ </div>
727
+ `;
728
+ }
729
+
730
+ function escapeHtml(str) {
731
+ return str
732
+ .replace(/&/g, '&amp;')
733
+ .replace(/</g, '&lt;')
734
+ .replace(/>/g, '&gt;')
735
+ .replace(/"/g, '&quot;');
736
+ }
737
+
738
+ // Auto-refresh log every 3s if agent is running
739
+ setInterval(() => {
740
+ if (selectedLogFile && state && state.runningAgents.includes(selectedAgent)) {
741
+ fetchLog(selectedLogFile);
742
+ }
743
+ }, 3000);
744
+
745
+ // Ctrl+F shortcut for log search
746
+ document.addEventListener('keydown', (e) => {
747
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
748
+ const searchInput = document.getElementById('log-search-input');
749
+ if (searchInput) {
750
+ e.preventDefault();
751
+ searchInput.focus();
752
+ }
753
+ }
754
+ });
755
+ </script>
756
+ </body>
757
+
758
+ </html>