agentdev-webui 1.0.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.
Files changed (39) hide show
  1. package/lib/agent-api.js +530 -0
  2. package/lib/auth.js +127 -0
  3. package/lib/config.js +53 -0
  4. package/lib/database.js +762 -0
  5. package/lib/device-flow.js +257 -0
  6. package/lib/email.js +420 -0
  7. package/lib/encryption.js +112 -0
  8. package/lib/github.js +339 -0
  9. package/lib/history.js +143 -0
  10. package/lib/pwa.js +107 -0
  11. package/lib/redis-logs.js +226 -0
  12. package/lib/routes.js +680 -0
  13. package/migrations/000_create_database.sql +33 -0
  14. package/migrations/001_create_agentdev_schema.sql +135 -0
  15. package/migrations/001_create_agentdev_schema.sql.old +100 -0
  16. package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
  17. package/migrations/002_add_github_token.sql +17 -0
  18. package/migrations/003_add_agent_logs_table.sql +23 -0
  19. package/migrations/004_remove_oauth_columns.sql +11 -0
  20. package/migrations/005_add_projects.sql +44 -0
  21. package/migrations/006_project_github_token.sql +7 -0
  22. package/migrations/007_project_repositories.sql +12 -0
  23. package/migrations/008_add_notifications.sql +20 -0
  24. package/migrations/009_unified_oauth.sql +153 -0
  25. package/migrations/README.md +97 -0
  26. package/package.json +37 -0
  27. package/public/css/styles.css +1140 -0
  28. package/public/device.html +384 -0
  29. package/public/docs.html +862 -0
  30. package/public/docs.md +697 -0
  31. package/public/favicon.svg +5 -0
  32. package/public/index.html +271 -0
  33. package/public/js/app.js +2379 -0
  34. package/public/login.html +224 -0
  35. package/public/profile.html +394 -0
  36. package/public/register.html +392 -0
  37. package/public/reset-password.html +349 -0
  38. package/public/verify-email.html +177 -0
  39. package/server.js +1450 -0
@@ -0,0 +1,2379 @@
1
+ // DOM elements
2
+ const tabsEl = document.getElementById('tabs');
3
+ const tabContent = document.getElementById('tabContent');
4
+ const agentsEl = document.getElementById('agents');
5
+ const agentsHistoryEl = document.getElementById('agentsHistory');
6
+ const agentsTodoEl = document.getElementById('agentsTodo');
7
+ const todoCountEl = document.getElementById('todoCount');
8
+ const mobileTodoCountEl = document.getElementById('mobileTodoCount');
9
+ const runBtn = document.getElementById('runBtn');
10
+ const stopBtn = document.getElementById('stopBtn');
11
+ const dot = document.getElementById('dot');
12
+ const statusTxt = document.getElementById('statusTxt');
13
+ const agentCount = document.getElementById('agentCount');
14
+ const tooltip = document.getElementById('tooltip');
15
+ const sidebar = document.getElementById('sidebar');
16
+ const sidebarOverlay = document.getElementById('sidebarOverlay');
17
+ const hamburger = document.getElementById('hamburger');
18
+ const bottomSheet = document.getElementById('bottomSheet');
19
+ const bottomSheetContent = document.getElementById('bottomSheetContent');
20
+ const offlineBanner = document.getElementById('offlineBanner');
21
+ const scrollToBottomBtn = document.getElementById('scrollToBottom');
22
+ const logoutBtn = document.getElementById('logoutBtn');
23
+ const projectSelector = document.getElementById('projectSelector');
24
+
25
+ // ============================================================================
26
+ // Project state
27
+ // ============================================================================
28
+ let currentProjectId = parseInt(localStorage.getItem('selectedProject')) || null;
29
+ let projectsList = [];
30
+
31
+ // Store raw SSE data for re-filtering on project switch
32
+ let lastAgentsList = [];
33
+ let lastHistoryList = [];
34
+ let lastTodosList = [];
35
+
36
+ let firstProjectMode = false;
37
+
38
+ async function loadProjects() {
39
+ try {
40
+ const res = await fetch('/api/projects');
41
+ if (res.ok) {
42
+ projectsList = await res.json();
43
+ populateProjectSelector();
44
+ // Force first project creation if none exist
45
+ if (projectsList.length === 0 && !firstProjectMode) {
46
+ openFirstProjectModal();
47
+ }
48
+ }
49
+ } catch (e) {
50
+ console.log('Could not load projects:', e);
51
+ }
52
+ }
53
+
54
+ function openFirstProjectModal() {
55
+ firstProjectMode = true;
56
+ document.getElementById('projectModalTitle').textContent = 'Create Your First Project';
57
+ document.getElementById('projectSubmitBtn').textContent = 'Create Project';
58
+ // Make token required — update hint
59
+ const tokenInput = document.getElementById('projectGithubToken');
60
+ const tokenHint = tokenInput.parentElement.querySelector('.hint');
61
+ if (tokenHint) tokenHint.textContent = 'Required. This token will be used for all GitHub operations.';
62
+ tokenInput.setAttribute('required', 'required');
63
+ // Hide the close button
64
+ const closeBtn = document.querySelector('#manageProjectsModal .modal-close');
65
+ if (closeBtn) closeBtn.style.display = 'none';
66
+ // Hide the cancel button
67
+ const cancelBtn = document.querySelector('#manageProjectsModal .modal-footer button:first-child');
68
+ if (cancelBtn) cancelBtn.style.display = 'none';
69
+ document.getElementById('manageProjectsModal').classList.add('visible');
70
+ }
71
+
72
+ function populateProjectSelector() {
73
+ projectSelector.innerHTML = '<option value="">All Projects</option>';
74
+ projectsList.forEach(p => {
75
+ const opt = document.createElement('option');
76
+ opt.value = p.id;
77
+ opt.textContent = p.name;
78
+ if (currentProjectId && currentProjectId === p.id) opt.selected = true;
79
+ projectSelector.appendChild(opt);
80
+ });
81
+ // Show/hide edit button
82
+ const editBtn = document.getElementById('editProjectBtn');
83
+ if (editBtn) editBtn.style.display = currentProjectId ? '' : 'none';
84
+ populateRepoSelector();
85
+ }
86
+
87
+ function populateRepoSelector() {
88
+ const repoSelect = document.getElementById('ticketRepo');
89
+ if (!repoSelect) return;
90
+ repoSelect.innerHTML = '';
91
+
92
+ // Collect repos from selected project or all projects
93
+ let repos = [];
94
+ if (currentProjectId) {
95
+ const project = projectsList.find(p => p.id === currentProjectId);
96
+ if (project && project.repositories) {
97
+ repos = Array.isArray(project.repositories) ? project.repositories : [];
98
+ }
99
+ } else {
100
+ // All projects — merge all repos, deduplicate
101
+ const seen = new Set();
102
+ for (const p of projectsList) {
103
+ const pRepos = Array.isArray(p.repositories) ? p.repositories : [];
104
+ for (const r of pRepos) {
105
+ if (!seen.has(r)) { seen.add(r); repos.push(r); }
106
+ }
107
+ }
108
+ }
109
+
110
+ repos.forEach(r => {
111
+ const opt = document.createElement('option');
112
+ opt.value = r;
113
+ opt.textContent = r;
114
+ repoSelect.appendChild(opt);
115
+ });
116
+ }
117
+
118
+ function switchProject(val) {
119
+ currentProjectId = val ? parseInt(val) : null;
120
+ if (currentProjectId) {
121
+ localStorage.setItem('selectedProject', currentProjectId);
122
+ } else {
123
+ localStorage.removeItem('selectedProject');
124
+ }
125
+ // Show/hide edit button
126
+ const editBtn = document.getElementById('editProjectBtn');
127
+ if (editBtn) editBtn.style.display = currentProjectId ? '' : 'none';
128
+ // Re-filter all displayed data
129
+ updateAgents(lastAgentsList);
130
+ updateAgentsHistory(lastHistoryList);
131
+ updateTodoTickets(lastTodosList);
132
+ populateRepoSelector();
133
+ }
134
+
135
+ // Manage Projects Modal
136
+ function openManageProjectsModal() {
137
+ document.getElementById('manageProjectsModal').classList.add('visible');
138
+ }
139
+
140
+ function closeManageProjectsModal() {
141
+ // Block closing if user must create their first project
142
+ if (firstProjectMode) return;
143
+
144
+ document.getElementById('manageProjectsModal').classList.remove('visible');
145
+ document.getElementById('projectFormStatus').classList.remove('visible', 'success', 'error');
146
+ // Reset form fields
147
+ document.getElementById('projectName').value = '';
148
+ document.getElementById('projectOrg').value = '';
149
+ document.getElementById('projectNumber').value = '';
150
+ document.getElementById('projectGithubId').value = '';
151
+ document.getElementById('projectStatusFieldId').value = '';
152
+ document.getElementById('projectGithubToken').value = '';
153
+ document.getElementById('projectRepositories').innerHTML = '';
154
+ document.getElementById('projectFieldsInfo').innerHTML = '';
155
+ document.getElementById('repoFetchStatus').textContent = '';
156
+ document.getElementById('fieldsFetchStatus').textContent = '';
157
+ // Reset edit state
158
+ editingProjectId = null;
159
+ document.getElementById('projectModalTitle').textContent = 'Add Project';
160
+ document.getElementById('projectSubmitBtn').textContent = 'Add Project';
161
+ // Restore close/cancel buttons
162
+ const closeBtn = document.querySelector('#manageProjectsModal .modal-close');
163
+ if (closeBtn) closeBtn.style.display = '';
164
+ const cancelBtn = document.querySelector('#manageProjectsModal .modal-footer button:first-child');
165
+ if (cancelBtn) cancelBtn.style.display = '';
166
+ // Restore token hint
167
+ const tokenInput = document.getElementById('projectGithubToken');
168
+ tokenInput.removeAttribute('required');
169
+ const tokenHint = tokenInput.parentElement.querySelector('.hint');
170
+ if (tokenHint) tokenHint.textContent = 'Optional. Used for fetching repos/fields below. If empty, uses your profile token.';
171
+
172
+ statusColumns = [];
173
+ renderStatusTable();
174
+ }
175
+
176
+ document.getElementById('manageProjectsModal')?.addEventListener('click', function(e) {
177
+ if (e.target === this && !firstProjectMode) closeManageProjectsModal();
178
+ });
179
+
180
+ async function fetchOrgRepos(btn) {
181
+ const org = document.getElementById('projectOrg').value.trim();
182
+ const token = document.getElementById('projectGithubToken').value.trim();
183
+ const statusSpan = document.getElementById('repoFetchStatus');
184
+ const selectEl = document.getElementById('projectRepositories');
185
+
186
+ if (!org) {
187
+ statusSpan.textContent = 'Fill in GitHub Org first';
188
+ statusSpan.style.color = '#ef4444';
189
+ return;
190
+ }
191
+
192
+ btn.disabled = true;
193
+ statusSpan.textContent = 'Fetching...';
194
+ statusSpan.style.color = '#888';
195
+
196
+ try {
197
+ const params = new URLSearchParams({ org });
198
+ if (token) params.set('token', token);
199
+ const res = await fetch('/api/github/repos?' + params.toString());
200
+ const data = await res.json();
201
+
202
+ if (!res.ok) {
203
+ statusSpan.textContent = data.error || 'Failed';
204
+ statusSpan.style.color = '#ef4444';
205
+ return;
206
+ }
207
+
208
+ // Preserve currently selected values
209
+ const previouslySelected = new Set(
210
+ Array.from(selectEl.selectedOptions).map(o => o.value)
211
+ );
212
+
213
+ selectEl.innerHTML = '';
214
+ data.forEach(repoName => {
215
+ const opt = document.createElement('option');
216
+ opt.value = repoName;
217
+ opt.textContent = repoName;
218
+ if (previouslySelected.has(repoName)) opt.selected = true;
219
+ selectEl.appendChild(opt);
220
+ });
221
+
222
+ statusSpan.textContent = data.length + ' repos loaded';
223
+ statusSpan.style.color = '#4ade80';
224
+ } catch (e) {
225
+ statusSpan.textContent = 'Error: ' + e.message;
226
+ statusSpan.style.color = '#ef4444';
227
+ } finally {
228
+ btn.disabled = false;
229
+ }
230
+ }
231
+
232
+ // Status columns state for the project form
233
+ let statusColumns = [];
234
+ let editingProjectId = null;
235
+
236
+ function editCurrentProject() {
237
+ if (!currentProjectId) return;
238
+ const project = projectsList.find(p => p.id === currentProjectId);
239
+ if (!project) return;
240
+
241
+ editingProjectId = currentProjectId;
242
+ document.getElementById('projectModalTitle').textContent = 'Edit Project';
243
+ document.getElementById('projectSubmitBtn').textContent = 'Save Changes';
244
+ document.getElementById('projectSubmitBtn').setAttribute('onclick', 'submitProject()');
245
+
246
+ // Populate form fields
247
+ document.getElementById('projectName').value = project.name || '';
248
+ document.getElementById('projectOrg').value = project.github_org || '';
249
+ document.getElementById('projectNumber').value = project.project_number || '';
250
+ document.getElementById('projectGithubId').value = project.github_project_id || '';
251
+ document.getElementById('projectStatusFieldId').value = project.status_field_id || '';
252
+ document.getElementById('projectGithubToken').value = '';
253
+ document.getElementById('projectFieldsInfo').innerHTML =
254
+ 'Project ID: <span style="color:#888;">' + (project.github_project_id || '').substring(0, 25) + '...</span>' +
255
+ ' &bull; Status field: <span style="color:#888;">' + (project.status_field_id || '').substring(0, 25) + '...</span>';
256
+
257
+ // Populate status columns from existing status_options
258
+ const opts = typeof project.status_options === 'string' ? JSON.parse(project.status_options) : (project.status_options || {});
259
+ statusColumns = Object.entries(opts).map(([mapTo, id]) => ({
260
+ name: mapTo,
261
+ id: id,
262
+ mapTo: mapTo
263
+ }));
264
+ renderStatusTable();
265
+
266
+ // Populate repositories
267
+ const repoSelect = document.getElementById('projectRepositories');
268
+ repoSelect.innerHTML = '';
269
+ const repos = Array.isArray(project.repositories) ? project.repositories : [];
270
+ repos.forEach(r => {
271
+ const opt = document.createElement('option');
272
+ opt.value = r;
273
+ opt.textContent = r;
274
+ opt.selected = true;
275
+ repoSelect.appendChild(opt);
276
+ });
277
+
278
+ document.getElementById('manageProjectsModal').classList.add('visible');
279
+ }
280
+
281
+ function submitProject() {
282
+ if (editingProjectId) {
283
+ updateExistingProject();
284
+ } else {
285
+ createProject();
286
+ }
287
+ }
288
+
289
+ async function updateExistingProject() {
290
+ const name = document.getElementById('projectName').value.trim();
291
+ const github_org = document.getElementById('projectOrg').value.trim();
292
+ const project_number = parseInt(document.getElementById('projectNumber').value);
293
+ const github_project_id = document.getElementById('projectGithubId').value.trim();
294
+ const status_field_id = document.getElementById('projectStatusFieldId').value.trim();
295
+ const github_token = document.getElementById('projectGithubToken').value.trim();
296
+ const repoSelect = document.getElementById('projectRepositories');
297
+ const repositories = Array.from(repoSelect.selectedOptions).map(o => o.value);
298
+ const status_options = buildStatusOptions();
299
+ const statusEl = document.getElementById('projectFormStatus');
300
+
301
+ if (!name || !github_org || !project_number) {
302
+ statusEl.textContent = 'Name, Org, and Project Number are required';
303
+ statusEl.className = 'form-status visible error';
304
+ return;
305
+ }
306
+
307
+ try {
308
+ const body = { name, github_org, project_number, github_project_id, status_field_id, status_options, repositories };
309
+ if (github_token) body.github_token = github_token;
310
+
311
+ const res = await fetch('/api/projects/' + editingProjectId, {
312
+ method: 'PUT',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify(body)
315
+ });
316
+ const data = await res.json();
317
+ if (res.ok) {
318
+ statusEl.textContent = 'Project updated!';
319
+ statusEl.className = 'form-status visible success';
320
+ await loadProjects();
321
+ setTimeout(closeManageProjectsModal, 1500);
322
+ } else {
323
+ statusEl.textContent = 'Error: ' + (data.error || 'Failed');
324
+ statusEl.className = 'form-status visible error';
325
+ }
326
+ } catch (e) {
327
+ statusEl.textContent = 'Error: ' + e.message;
328
+ statusEl.className = 'form-status visible error';
329
+ }
330
+ }
331
+
332
+ async function fetchProjectFields(btn) {
333
+ const org = document.getElementById('projectOrg').value.trim();
334
+ const projectNumber = document.getElementById('projectNumber').value.trim();
335
+ const token = document.getElementById('projectGithubToken').value.trim();
336
+ const statusSpan = document.getElementById('fieldsFetchStatus');
337
+ const infoDiv = document.getElementById('projectFieldsInfo');
338
+
339
+ if (!org || !projectNumber) {
340
+ statusSpan.textContent = 'Fill in Org and Project Number first';
341
+ statusSpan.style.color = '#ef4444';
342
+ return;
343
+ }
344
+
345
+ btn.disabled = true;
346
+ statusSpan.textContent = 'Fetching...';
347
+ statusSpan.style.color = '#888';
348
+
349
+ try {
350
+ const params = new URLSearchParams({ org, project_number: projectNumber });
351
+ if (token) params.set('token', token);
352
+ const res = await fetch('/api/github/project-fields?' + params.toString());
353
+ const data = await res.json();
354
+
355
+ if (!res.ok) {
356
+ statusSpan.textContent = data.error || 'Failed';
357
+ statusSpan.style.color = '#ef4444';
358
+ return;
359
+ }
360
+
361
+ // Auto-fill hidden fields
362
+ document.getElementById('projectGithubId').value = data.project_id || '';
363
+ if (data.status_field) {
364
+ document.getElementById('projectStatusFieldId').value = data.status_field.id;
365
+ }
366
+
367
+ infoDiv.innerHTML = 'Project: <span style="color:#4ade80;">' + (data.project_title || '?') + '</span>' +
368
+ ' &bull; ID: <span style="color:#888;">' + (data.project_id || '?').substring(0, 20) + '...</span>' +
369
+ (data.status_field ? ' &bull; Status field: <span style="color:#888;">' + data.status_field.id.substring(0, 20) + '...</span>' : '');
370
+
371
+ // Populate status columns table
372
+ if (data.status_field && data.status_field.options) {
373
+ statusColumns = data.status_field.options.map(opt => ({
374
+ name: opt.name,
375
+ id: opt.id,
376
+ mapTo: guessMapTo(opt.name)
377
+ }));
378
+ renderStatusTable();
379
+ }
380
+
381
+ statusSpan.textContent = 'Loaded';
382
+ statusSpan.style.color = '#4ade80';
383
+ } catch (e) {
384
+ statusSpan.textContent = 'Error: ' + e.message;
385
+ statusSpan.style.color = '#ef4444';
386
+ } finally {
387
+ btn.disabled = false;
388
+ }
389
+ }
390
+
391
+ function guessMapTo(name) {
392
+ const lower = name.toLowerCase().replace(/[\s_-]/g, '');
393
+ if (lower === 'todo' || lower === 'backlog' || lower === 'new') return 'TODO';
394
+ if (lower === 'inprogress' || lower === 'doing' || lower === 'active') return 'IN_PROGRESS';
395
+ if (lower === 'test' || lower === 'review' || lower === 'testing' || lower === 'qa') return 'TEST';
396
+ if (lower === 'done' || lower === 'closed' || lower === 'complete' || lower === 'completed') return 'DONE';
397
+ return '';
398
+ }
399
+
400
+ function renderStatusTable() {
401
+ const tbody = document.getElementById('statusOptionsBody');
402
+ if (statusColumns.length === 0) {
403
+ tbody.innerHTML = '<tr><td colspan="5" style="color:#666;text-align:center;padding:12px;">No columns. Click "Fetch from GitHub" or "Add Column".</td></tr>';
404
+ return;
405
+ }
406
+
407
+ tbody.innerHTML = '';
408
+ statusColumns.forEach((col, i) => {
409
+ const tr = document.createElement('tr');
410
+ // Move arrows
411
+ const moveCell = document.createElement('td');
412
+ moveCell.style.cssText = 'text-align:center;white-space:nowrap;';
413
+ const upBtn = document.createElement('button');
414
+ upBtn.className = 'btn-move';
415
+ upBtn.textContent = '\u25B2';
416
+ upBtn.title = 'Move up';
417
+ upBtn.disabled = i === 0;
418
+ upBtn.onclick = () => moveStatusColumn(i, -1);
419
+ const downBtn = document.createElement('button');
420
+ downBtn.className = 'btn-move';
421
+ downBtn.textContent = '\u25BC';
422
+ downBtn.title = 'Move down';
423
+ downBtn.disabled = i === statusColumns.length - 1;
424
+ downBtn.onclick = () => moveStatusColumn(i, 1);
425
+ moveCell.appendChild(upBtn);
426
+ moveCell.appendChild(downBtn);
427
+ tr.appendChild(moveCell);
428
+
429
+ // Map-to selector
430
+ const mapCell = document.createElement('td');
431
+ const select = document.createElement('select');
432
+ ['', 'TODO', 'IN_PROGRESS', 'TEST', 'DONE'].forEach(val => {
433
+ const opt = document.createElement('option');
434
+ opt.value = val;
435
+ opt.textContent = val || '(skip)';
436
+ if (col.mapTo === val) opt.selected = true;
437
+ select.appendChild(opt);
438
+ });
439
+ select.onchange = () => { statusColumns[i].mapTo = select.value; };
440
+ mapCell.appendChild(select);
441
+ tr.appendChild(mapCell);
442
+
443
+ // Column name
444
+ const nameCell = document.createElement('td');
445
+ nameCell.textContent = col.name;
446
+ nameCell.style.color = '#eee';
447
+ tr.appendChild(nameCell);
448
+
449
+ // ID
450
+ const idCell = document.createElement('td');
451
+ idCell.className = 'col-id';
452
+ idCell.textContent = col.id;
453
+ idCell.title = col.id;
454
+ tr.appendChild(idCell);
455
+
456
+ // Remove button
457
+ const removeCell = document.createElement('td');
458
+ removeCell.className = 'col-actions';
459
+ const removeBtn = document.createElement('button');
460
+ removeBtn.className = 'btn-remove-col';
461
+ removeBtn.textContent = '\u00D7';
462
+ removeBtn.title = 'Remove column';
463
+ removeBtn.onclick = () => { statusColumns.splice(i, 1); renderStatusTable(); };
464
+ removeCell.appendChild(removeBtn);
465
+ tr.appendChild(removeCell);
466
+
467
+ tbody.appendChild(tr);
468
+ });
469
+ }
470
+
471
+ function moveStatusColumn(index, direction) {
472
+ const newIndex = index + direction;
473
+ if (newIndex < 0 || newIndex >= statusColumns.length) return;
474
+ const temp = statusColumns[index];
475
+ statusColumns[index] = statusColumns[newIndex];
476
+ statusColumns[newIndex] = temp;
477
+ renderStatusTable();
478
+ }
479
+
480
+ function showAddColumnRow() {
481
+ document.getElementById('addColumnRow').style.display = 'flex';
482
+ document.getElementById('addColumnBtn').style.display = 'none';
483
+ document.getElementById('addColName').value = '';
484
+ document.getElementById('addColId').value = '';
485
+ document.getElementById('addColMapTo').value = '';
486
+ document.getElementById('addColName').focus();
487
+ }
488
+
489
+ function hideAddColumnRow() {
490
+ document.getElementById('addColumnRow').style.display = 'none';
491
+ document.getElementById('addColumnBtn').style.display = '';
492
+ }
493
+
494
+ function confirmAddColumn() {
495
+ const name = document.getElementById('addColName').value.trim();
496
+ const id = document.getElementById('addColId').value.trim();
497
+ const mapTo = document.getElementById('addColMapTo').value;
498
+ if (!name) { document.getElementById('addColName').focus(); return; }
499
+ if (!id) { document.getElementById('addColId').focus(); return; }
500
+ statusColumns.push({ name, id, mapTo: mapTo || guessMapTo(name) });
501
+ renderStatusTable();
502
+ hideAddColumnRow();
503
+ }
504
+
505
+ function buildStatusOptions() {
506
+ const result = {};
507
+ statusColumns.forEach(col => {
508
+ if (col.mapTo) {
509
+ result[col.mapTo] = col.id;
510
+ }
511
+ });
512
+ return result;
513
+ }
514
+
515
+ async function createProject() {
516
+ const name = document.getElementById('projectName').value.trim();
517
+ const github_org = document.getElementById('projectOrg').value.trim();
518
+ const project_number = parseInt(document.getElementById('projectNumber').value);
519
+ const github_project_id = document.getElementById('projectGithubId').value.trim();
520
+ const status_field_id = document.getElementById('projectStatusFieldId').value.trim();
521
+ const github_token = document.getElementById('projectGithubToken').value.trim();
522
+ const repoSelect = document.getElementById('projectRepositories');
523
+ const repositories = Array.from(repoSelect.selectedOptions).map(o => o.value);
524
+ const status_options = buildStatusOptions();
525
+ const statusEl = document.getElementById('projectFormStatus');
526
+
527
+ if (!name || !github_org || !project_number || !github_project_id || !status_field_id) {
528
+ statusEl.textContent = 'Required: Name, Org, Project Number. Click "Fetch from GitHub" to auto-detect Project ID and Status Field.';
529
+ statusEl.className = 'form-status visible error';
530
+ return;
531
+ }
532
+
533
+ if (Object.keys(status_options).length === 0) {
534
+ statusEl.textContent = 'Map at least one status column (e.g. TODO)';
535
+ statusEl.className = 'form-status visible error';
536
+ return;
537
+ }
538
+
539
+ // First project requires a GitHub token
540
+ if (firstProjectMode && !github_token) {
541
+ statusEl.textContent = 'GitHub token is required for your first project';
542
+ statusEl.className = 'form-status visible error';
543
+ return;
544
+ }
545
+
546
+ try {
547
+ const payload = { name, github_org, project_number, github_project_id, status_field_id, status_options, repositories };
548
+ if (github_token) payload.github_token = github_token;
549
+ if (firstProjectMode) payload.save_token_globally = true;
550
+
551
+ const res = await fetch('/api/projects', {
552
+ method: 'POST',
553
+ headers: { 'Content-Type': 'application/json' },
554
+ body: JSON.stringify(payload)
555
+ });
556
+ const data = await res.json();
557
+ if (res.ok) {
558
+ statusEl.textContent = 'Project created!';
559
+ statusEl.className = 'form-status visible success';
560
+ const wasFirstProject = firstProjectMode;
561
+ firstProjectMode = false;
562
+ await loadProjects();
563
+ // Auto-select the new project
564
+ if (wasFirstProject && data.id) {
565
+ currentProjectId = data.id;
566
+ localStorage.setItem('selectedProject', data.id);
567
+ populateProjectSelector();
568
+ }
569
+ setTimeout(closeManageProjectsModal, 1500);
570
+ } else {
571
+ statusEl.textContent = 'Error: ' + (data.error || 'Failed');
572
+ statusEl.className = 'form-status visible error';
573
+ }
574
+ } catch (e) {
575
+ statusEl.textContent = 'Error: ' + e.message;
576
+ statusEl.className = 'form-status visible error';
577
+ }
578
+ }
579
+
580
+ function getOrgForProject(projectId) {
581
+ const p = projectsList.find(pr => pr.id === projectId);
582
+ return p ? p.github_org : 'data-tamer';
583
+ }
584
+
585
+ // Load projects on startup
586
+ loadProjects();
587
+
588
+ // Logout function
589
+ async function logout() {
590
+ try {
591
+ await fetch('/api/logout', { method: 'POST' });
592
+ } catch (e) {
593
+ console.log('Logout error:', e);
594
+ }
595
+ window.location.href = '/login';
596
+ }
597
+
598
+ // Store agent data for tooltips
599
+ const agentData = new Map();
600
+
601
+ // Touch device detection
602
+ const isTouchDevice = () => 'ontouchstart' in window || navigator.maxTouchPoints > 0;
603
+
604
+ // Sidebar toggle
605
+ function toggleSidebar() {
606
+ sidebar.classList.toggle('open');
607
+ sidebarOverlay.classList.toggle('visible');
608
+ hamburger.classList.toggle('active');
609
+ }
610
+
611
+ function closeSidebar() {
612
+ sidebar.classList.remove('open');
613
+ sidebarOverlay.classList.remove('visible');
614
+ hamburger.classList.remove('active');
615
+ }
616
+
617
+ // Bottom sheet
618
+ let currentBottomSheetAgent = null;
619
+
620
+ function showBottomSheet(agent) {
621
+ const a = agentData.get(agent.id) || agent;
622
+ currentBottomSheetAgent = a;
623
+ let html = '';
624
+
625
+ if (a.authorAvatar || a.authorName) {
626
+ html += '<div class="tooltip-header">';
627
+ if (a.authorAvatar) {
628
+ html += '<img class="tooltip-avatar" src="' + a.authorAvatar + '" alt="">';
629
+ }
630
+ html += '<div class="tooltip-author">';
631
+ html += '<div class="tooltip-author-name">' + (a.authorName || 'Unknown') + '</div>';
632
+ html += '<div class="tooltip-author-time">Opened ' + (a.createdAt ? new Date(a.createdAt).toLocaleDateString() : 'recently') + '</div>';
633
+ html += '</div></div>';
634
+ }
635
+
636
+ html += '<div class="tooltip-title">#' + (a.ticket || '?') + ': ' + (a.title || 'No title') + '</div>';
637
+
638
+ if (a.description) {
639
+ const desc = a.description.replace(/@claude/gi, '').trim().slice(0, 300);
640
+ html += '<div class="tooltip-desc">' + desc + (a.description.length > 300 ? '...' : '') + '</div>';
641
+ }
642
+
643
+ html += '<div class="tooltip-meta">';
644
+ const statusClass = a.ticketStatus === 'OPEN' ? 'open' : (a.ticketStatus === 'CLOSED' ? 'closed' : 'in-progress');
645
+ html += '<span class="tooltip-status ' + statusClass + '">' + (a.ticketStatus || 'Unknown') + '</span>';
646
+ if (a.startTime) {
647
+ html += '<span class="tooltip-elapsed">⏱ ' + formatElapsed(a.startTime) + '</span>';
648
+ }
649
+ if (a.repo) {
650
+ html += '<span class="tooltip-repo">' + a.repo + '</span>';
651
+ }
652
+ html += '</div>';
653
+
654
+ html += '<button class="view-logs-btn" onclick="openAgentFromBottomSheet()">View Logs</button>';
655
+
656
+ bottomSheetContent.innerHTML = html;
657
+ bottomSheet.classList.add('visible');
658
+ }
659
+
660
+ function hideBottomSheet() {
661
+ bottomSheet.classList.remove('visible');
662
+ currentBottomSheetAgent = null;
663
+ }
664
+
665
+ function openAgentFromBottomSheet() {
666
+ if (currentBottomSheetAgent) {
667
+ const a = currentBottomSheetAgent;
668
+ hideBottomSheet();
669
+ closeSidebar();
670
+ openAgentTab(a.id, a.ticket, a.title);
671
+ }
672
+ }
673
+
674
+ // Close bottom sheet on tap outside or swipe down
675
+ bottomSheet.addEventListener('touchstart', function(e) {
676
+ if (e.target === bottomSheet || e.target.classList.contains('bottom-sheet-handle')) {
677
+ this.startY = e.touches[0].clientY;
678
+ }
679
+ });
680
+
681
+ bottomSheet.addEventListener('touchmove', function(e) {
682
+ if (this.startY !== undefined) {
683
+ const deltaY = e.touches[0].clientY - this.startY;
684
+ if (deltaY > 50) {
685
+ hideBottomSheet();
686
+ this.startY = undefined;
687
+ }
688
+ }
689
+ });
690
+
691
+ bottomSheet.addEventListener('touchend', function() {
692
+ this.startY = undefined;
693
+ });
694
+
695
+ // Tap outside to close
696
+ document.addEventListener('click', function(e) {
697
+ if (bottomSheet.classList.contains('visible') && !bottomSheet.contains(e.target) && !e.target.closest('.agent-item')) {
698
+ hideBottomSheet();
699
+ }
700
+ });
701
+
702
+ // Offline detection
703
+ let eventSource = null;
704
+
705
+ function updateOnlineStatus() {
706
+ if (navigator.onLine) {
707
+ offlineBanner.classList.remove('visible');
708
+ document.body.classList.remove('offline');
709
+ if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
710
+ connectSSE();
711
+ }
712
+ } else {
713
+ offlineBanner.classList.add('visible');
714
+ document.body.classList.add('offline');
715
+ }
716
+ }
717
+
718
+ window.addEventListener('online', updateOnlineStatus);
719
+ window.addEventListener('offline', updateOnlineStatus);
720
+
721
+ // Reconnect SSE when PWA returns from background
722
+ document.addEventListener('visibilitychange', () => {
723
+ if (document.visibilityState === 'visible' && navigator.onLine) {
724
+ if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
725
+ connectSSE();
726
+ }
727
+ }
728
+ });
729
+
730
+ // Service worker registration with auto-update
731
+ if ('serviceWorker' in navigator) {
732
+ navigator.serviceWorker.register('/sw.js').then(function(reg) {
733
+ console.log('Service worker registered');
734
+
735
+ // Check for updates every 60 seconds
736
+ setInterval(() => {
737
+ reg.update().catch(err => console.log('SW update check failed:', err));
738
+ }, 60000);
739
+
740
+ // Check for updates on page visibility change (when user returns to app)
741
+ document.addEventListener('visibilitychange', () => {
742
+ if (document.visibilityState === 'visible') {
743
+ reg.update().catch(err => console.log('SW update check failed:', err));
744
+ }
745
+ });
746
+
747
+ // Handle new service worker waiting
748
+ reg.addEventListener('updatefound', () => {
749
+ const newWorker = reg.installing;
750
+ console.log('New service worker found, installing...');
751
+
752
+ newWorker.addEventListener('statechange', () => {
753
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
754
+ console.log('New version ready, will reload...');
755
+ // New version installed, reload to activate
756
+ newWorker.postMessage({ type: 'SKIP_WAITING' });
757
+ }
758
+ });
759
+ });
760
+ }).catch(function(err) {
761
+ console.log('Service worker registration failed:', err);
762
+ });
763
+
764
+ // Listen for SW_UPDATED message and reload
765
+ navigator.serviceWorker.addEventListener('message', event => {
766
+ if (event.data && event.data.type === 'SW_UPDATED') {
767
+ console.log('Service worker updated, reloading page...');
768
+ window.location.reload();
769
+ }
770
+ });
771
+
772
+ // Reload when controller changes (new SW took over)
773
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
774
+ console.log('New service worker activated, reloading...');
775
+ window.location.reload();
776
+ });
777
+ }
778
+
779
+ // Notification system
780
+ const notificationBell = document.getElementById('notificationBell');
781
+ const notificationBadge = document.getElementById('notificationBadge');
782
+ let completionCount = 0;
783
+ const previousActiveAgents = new Map();
784
+
785
+ // Audio context for notification sound
786
+ let audioContext = null;
787
+
788
+ function getAudioContext() {
789
+ if (!audioContext) {
790
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
791
+ }
792
+ return audioContext;
793
+ }
794
+
795
+ function playNotificationSound() {
796
+ try {
797
+ const ctx = getAudioContext();
798
+ if (ctx.state === 'suspended') ctx.resume();
799
+
800
+ // Create a pleasant notification sound (two-tone chime)
801
+ const now = ctx.currentTime;
802
+
803
+ // First tone
804
+ const osc1 = ctx.createOscillator();
805
+ const gain1 = ctx.createGain();
806
+ osc1.connect(gain1);
807
+ gain1.connect(ctx.destination);
808
+ osc1.frequency.value = 880; // A5
809
+ osc1.type = 'sine';
810
+ gain1.gain.setValueAtTime(0.3, now);
811
+ gain1.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
812
+ osc1.start(now);
813
+ osc1.stop(now + 0.3);
814
+
815
+ // Second tone (higher)
816
+ const osc2 = ctx.createOscillator();
817
+ const gain2 = ctx.createGain();
818
+ osc2.connect(gain2);
819
+ gain2.connect(ctx.destination);
820
+ osc2.frequency.value = 1320; // E6
821
+ osc2.type = 'sine';
822
+ gain2.gain.setValueAtTime(0, now);
823
+ gain2.gain.setValueAtTime(0.3, now + 0.15);
824
+ gain2.gain.exponentialRampToValueAtTime(0.01, now + 0.5);
825
+ osc2.start(now + 0.15);
826
+ osc2.stop(now + 0.5);
827
+ } catch (e) {
828
+ console.log('Audio not available:', e);
829
+ }
830
+ }
831
+
832
+ // Bell state is now managed by the notification panel
833
+
834
+ let notificationItems = [];
835
+ const notificationPanel = document.getElementById('notificationPanel');
836
+ const notificationPanelBody = document.getElementById('notificationPanelBody');
837
+
838
+ function notifyCompletion(agent) {
839
+ // Play sound
840
+ playNotificationSound();
841
+
842
+ // Ring the bell
843
+ notificationBell.classList.add('ringing');
844
+ setTimeout(() => notificationBell.classList.remove('ringing'), 500);
845
+
846
+ // Add to local list
847
+ notificationItems.unshift({
848
+ title: agent.ticket ? 'Ticket #' + agent.ticket + (agent.success ? ' completed' : ' failed') : 'Agent completed',
849
+ message: (agent.title || agent.id) + ' ' + (agent.success ? 'completed' : 'failed'),
850
+ success: agent.success,
851
+ created_at: new Date().toISOString()
852
+ });
853
+
854
+ // Update badge
855
+ completionCount++;
856
+ notificationBadge.textContent = completionCount > 9 ? '9+' : completionCount;
857
+ notificationBadge.classList.add('visible');
858
+ renderNotifications();
859
+
860
+ // Show browser notification
861
+ if ('Notification' in window && Notification.permission === 'granted') {
862
+ const title = agent.ticket ? 'Ticket #' + agent.ticket + ' completed' : 'Agent completed';
863
+ const body = agent.title || agent.id.slice(0, 20) + '...';
864
+ const notification = new Notification(title, {
865
+ body: body,
866
+ icon: '/icon-192.png',
867
+ tag: 'agent-' + agent.id,
868
+ requireInteraction: false
869
+ });
870
+ notification.onclick = function() {
871
+ window.focus();
872
+ notification.close();
873
+ };
874
+ setTimeout(() => notification.close(), 5000);
875
+ }
876
+ }
877
+
878
+ function renderNotifications() {
879
+ if (notificationItems.length === 0) {
880
+ notificationPanelBody.innerHTML = '<div class="notification-empty">No notifications</div>';
881
+ return;
882
+ }
883
+ notificationPanelBody.innerHTML = notificationItems.map(n => {
884
+ const cls = n.metadata?.success !== false && n.success !== false ? 'success' : 'failure';
885
+ const ago = timeAgo(new Date(n.created_at));
886
+ return `<div class="notification-item ${cls}">
887
+ <div class="notif-title">${escapeHtml(n.title)}</div>
888
+ <div class="notif-message">${escapeHtml(n.message || '')}</div>
889
+ <div class="notif-time">${ago}</div>
890
+ </div>`;
891
+ }).join('');
892
+ }
893
+
894
+ function escapeHtml(s) {
895
+ const d = document.createElement('div');
896
+ d.textContent = s;
897
+ return d.innerHTML;
898
+ }
899
+
900
+ function timeAgo(date) {
901
+ const s = Math.floor((Date.now() - date.getTime()) / 1000);
902
+ if (s < 60) return 'just now';
903
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
904
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
905
+ return Math.floor(s / 86400) + 'd ago';
906
+ }
907
+
908
+ function toggleNotificationPanel() {
909
+ notificationPanel.classList.toggle('open');
910
+ if (notificationPanel.classList.contains('open')) {
911
+ renderNotifications();
912
+ // Request browser notification permission if not yet granted
913
+ if ('Notification' in window && Notification.permission === 'default') {
914
+ Notification.requestPermission();
915
+ }
916
+ }
917
+ }
918
+
919
+ function clearNotifications() {
920
+ notificationItems = [];
921
+ completionCount = 0;
922
+ notificationBadge.classList.remove('visible');
923
+ renderNotifications();
924
+ fetch('/api/notifications/read', { method: 'POST' }).catch(() => {});
925
+ }
926
+
927
+ // Close panel when clicking outside
928
+ document.addEventListener('click', function(e) {
929
+ if (!e.target.closest('.notification-bell') && !e.target.closest('.notification-panel')) {
930
+ notificationPanel.classList.remove('open');
931
+ }
932
+ });
933
+
934
+ // Load unread notifications from DB on page load
935
+ async function loadNotifications() {
936
+ try {
937
+ const res = await fetch('/api/notifications');
938
+ if (!res.ok) return;
939
+ const data = await res.json();
940
+ if (data.notifications && data.notifications.length > 0) {
941
+ notificationItems = data.notifications;
942
+ completionCount = data.notifications.length;
943
+ notificationBadge.textContent = completionCount > 9 ? '9+' : completionCount;
944
+ notificationBadge.classList.add('visible');
945
+ }
946
+ } catch (e) { /* ignore */ }
947
+ }
948
+
949
+ // Initialize
950
+ loadNotifications();
951
+
952
+ // Create ticket modal
953
+ const createTicketModal = document.getElementById('createTicketModal');
954
+ const ticketRepo = document.getElementById('ticketRepo');
955
+ const ticketDescription = document.getElementById('ticketDescription');
956
+ const formStatus = document.getElementById('formStatus');
957
+ const createTicketBtn = document.getElementById('createTicketBtn');
958
+
959
+ function openCreateTicketModal() {
960
+ createTicketModal.classList.add('visible');
961
+ ticketDescription.focus();
962
+ formStatus.classList.remove('visible', 'success', 'error');
963
+ }
964
+
965
+ function closeCreateTicketModal() {
966
+ createTicketModal.classList.remove('visible');
967
+ ticketDescription.value = '';
968
+ formStatus.classList.remove('visible', 'success', 'error');
969
+ }
970
+
971
+ // Close on overlay click
972
+ createTicketModal.addEventListener('click', function(e) {
973
+ if (e.target === createTicketModal) closeCreateTicketModal();
974
+ });
975
+
976
+ // Close on Escape key
977
+ document.addEventListener('keydown', function(e) {
978
+ if (e.key === 'Escape' && createTicketModal.classList.contains('visible')) {
979
+ closeCreateTicketModal();
980
+ }
981
+ });
982
+
983
+ async function createTicket() {
984
+ const repo = ticketRepo.value;
985
+ const description = ticketDescription.value.trim();
986
+
987
+ if (!description) {
988
+ formStatus.textContent = 'Please enter a description';
989
+ formStatus.className = 'form-status visible error';
990
+ return;
991
+ }
992
+
993
+ // Extract title from first line
994
+ const lines = description.split('\n');
995
+ let title = lines[0].trim();
996
+ if (title.length > 80) title = title.slice(0, 77) + '...';
997
+
998
+ // Build body with @claude tag
999
+ let body = description;
1000
+ if (!body.toLowerCase().includes('@claude')) {
1001
+ body = '@claude\n\n' + body;
1002
+ }
1003
+
1004
+ createTicketBtn.disabled = true;
1005
+ createTicketBtn.textContent = 'Creating...';
1006
+ formStatus.classList.remove('visible', 'success', 'error');
1007
+
1008
+ try {
1009
+ const res = await fetch('/create-ticket', {
1010
+ method: 'POST',
1011
+ headers: { 'Content-Type': 'application/json' },
1012
+ body: JSON.stringify({ repo, title, body, project_id: currentProjectId })
1013
+ });
1014
+
1015
+ const data = await res.json();
1016
+
1017
+ if (data.success) {
1018
+ formStatus.innerHTML = 'Ticket <a href="' + data.url + '" target="_blank">#' + data.number + '</a> created and added to project!';
1019
+ formStatus.className = 'form-status visible success';
1020
+
1021
+ // Optimistic update: add ticket to board immediately
1022
+ lastTodosList.push({
1023
+ number: data.number,
1024
+ repo: data.repo || repo,
1025
+ title: title,
1026
+ status: 'Todo',
1027
+ hasClaude: true,
1028
+ project_id: currentProjectId
1029
+ });
1030
+ updateTodoTickets(lastTodosList);
1031
+
1032
+ ticketDescription.value = '';
1033
+ setTimeout(closeCreateTicketModal, 2000);
1034
+ } else {
1035
+ formStatus.textContent = 'Error: ' + (data.error || 'Failed to create ticket');
1036
+ formStatus.className = 'form-status visible error';
1037
+ }
1038
+ } catch (e) {
1039
+ formStatus.textContent = 'Error: ' + e.message;
1040
+ formStatus.className = 'form-status visible error';
1041
+ } finally {
1042
+ createTicketBtn.disabled = false;
1043
+ createTicketBtn.textContent = 'Create Ticket';
1044
+ }
1045
+ }
1046
+
1047
+ // Ctrl+Enter to submit
1048
+ ticketDescription.addEventListener('keydown', function(e) {
1049
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1050
+ e.preventDefault();
1051
+ createTicket();
1052
+ }
1053
+ });
1054
+
1055
+ // Voice input using Web Speech API
1056
+ const voiceBtn = document.getElementById('voiceBtn');
1057
+ let recognition = null;
1058
+ let isRecording = false;
1059
+
1060
+ // Check for speech recognition support
1061
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
1062
+ if (!SpeechRecognition) {
1063
+ voiceBtn.classList.add('unsupported');
1064
+ voiceBtn.title = 'Voice input not supported in this browser';
1065
+ } else {
1066
+ recognition = new SpeechRecognition();
1067
+ recognition.continuous = true;
1068
+ recognition.interimResults = true;
1069
+ recognition.lang = 'en-US';
1070
+
1071
+ let finalTranscript = '';
1072
+ let interimTranscript = '';
1073
+ let originalText = '';
1074
+
1075
+ recognition.onstart = function() {
1076
+ isRecording = true;
1077
+ voiceBtn.classList.add('recording');
1078
+ voiceBtn.title = 'Recording... Click to stop';
1079
+ originalText = ticketDescription.value;
1080
+ finalTranscript = '';
1081
+ };
1082
+
1083
+ recognition.onresult = function(event) {
1084
+ interimTranscript = '';
1085
+ for (let i = event.resultIndex; i < event.results.length; i++) {
1086
+ const transcript = event.results[i][0].transcript;
1087
+ if (event.results[i].isFinal) {
1088
+ finalTranscript += transcript + ' ';
1089
+ } else {
1090
+ interimTranscript += transcript;
1091
+ }
1092
+ }
1093
+ // Show both final and interim results
1094
+ const separator = originalText && !originalText.endsWith('\n') ? '\n' : '';
1095
+ ticketDescription.value = originalText + separator + finalTranscript + interimTranscript;
1096
+ // Scroll to bottom of textarea
1097
+ ticketDescription.scrollTop = ticketDescription.scrollHeight;
1098
+ };
1099
+
1100
+ recognition.onerror = function(event) {
1101
+ console.log('Speech recognition error:', event.error);
1102
+ if (event.error === 'not-allowed') {
1103
+ formStatus.textContent = 'Microphone access denied. Please allow microphone access.';
1104
+ formStatus.className = 'form-status visible error';
1105
+ }
1106
+ stopRecording();
1107
+ };
1108
+
1109
+ recognition.onend = function() {
1110
+ // Clean up the final text
1111
+ if (finalTranscript) {
1112
+ const separator = originalText && !originalText.endsWith('\n') ? '\n' : '';
1113
+ ticketDescription.value = originalText + separator + finalTranscript.trim();
1114
+ }
1115
+ stopRecording();
1116
+ };
1117
+ }
1118
+
1119
+ function stopRecording() {
1120
+ isRecording = false;
1121
+ voiceBtn.classList.remove('recording');
1122
+ voiceBtn.title = 'Voice input';
1123
+ }
1124
+
1125
+ function toggleVoiceInput() {
1126
+ if (!recognition) {
1127
+ formStatus.textContent = 'Voice input not supported in this browser. Try Safari or Chrome.';
1128
+ formStatus.className = 'form-status visible error';
1129
+ return;
1130
+ }
1131
+
1132
+ if (isRecording) {
1133
+ recognition.stop();
1134
+ } else {
1135
+ try {
1136
+ recognition.start();
1137
+ } catch (e) {
1138
+ // Already started, stop and restart
1139
+ recognition.stop();
1140
+ setTimeout(() => recognition.start(), 100);
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ // Add Comment Modal
1146
+ const addCommentModal = document.getElementById('addCommentModal');
1147
+ const commentFormStatus = document.getElementById('commentFormStatus');
1148
+ const commentTicketInfo = document.getElementById('commentTicketInfo');
1149
+ const commentText = document.getElementById('commentText');
1150
+ const addCommentBtn = document.getElementById('addCommentBtn');
1151
+ const commentVoiceBtn = document.getElementById('commentVoiceBtn');
1152
+ let currentCommentAgentId = null;
1153
+
1154
+ function openCommentModal(agentId) {
1155
+ currentCommentAgentId = agentId;
1156
+ const agent = agentTabInfo.get(agentId) || agentData.get(agentId);
1157
+
1158
+ if (!agent || !agent.ticket) {
1159
+ alert('No ticket associated with this agent');
1160
+ return;
1161
+ }
1162
+
1163
+ commentTicketInfo.innerHTML = '#' + agent.ticket + ' - ' + (agent.title || 'No title') +
1164
+ '<br><span style="color:#888;font-size:11px;">Repo: ' + (agent.repo || 'unknown') + '</span>';
1165
+ commentText.value = '';
1166
+ commentFormStatus.classList.remove('visible', 'success', 'error');
1167
+ addCommentModal.classList.add('visible');
1168
+ commentText.focus();
1169
+ }
1170
+
1171
+ function closeCommentModal() {
1172
+ addCommentModal.classList.remove('visible');
1173
+ commentText.value = '';
1174
+ currentCommentAgentId = null;
1175
+ commentFormStatus.classList.remove('visible', 'success', 'error');
1176
+ }
1177
+
1178
+ // Close on overlay click
1179
+ addCommentModal.addEventListener('click', function(e) {
1180
+ if (e.target === addCommentModal) closeCommentModal();
1181
+ });
1182
+
1183
+ // Close on Escape
1184
+ document.addEventListener('keydown', function(e) {
1185
+ if (e.key === 'Escape' && addCommentModal.classList.contains('visible')) {
1186
+ closeCommentModal();
1187
+ }
1188
+ });
1189
+
1190
+ async function submitComment() {
1191
+ const agent = agentTabInfo.get(currentCommentAgentId) || agentData.get(currentCommentAgentId);
1192
+ if (!agent || !agent.ticket || !agent.repo) {
1193
+ commentFormStatus.textContent = 'Missing ticket or repo information';
1194
+ commentFormStatus.className = 'form-status visible error';
1195
+ return;
1196
+ }
1197
+
1198
+ let comment = commentText.value.trim();
1199
+ if (!comment) {
1200
+ commentFormStatus.textContent = 'Please enter a comment';
1201
+ commentFormStatus.className = 'form-status visible error';
1202
+ return;
1203
+ }
1204
+
1205
+ // Auto-add @claude if not present
1206
+ if (!comment.toLowerCase().includes('@claude')) {
1207
+ comment = comment + '\n\n@claude';
1208
+ }
1209
+
1210
+ addCommentBtn.disabled = true;
1211
+ addCommentBtn.textContent = 'Submitting...';
1212
+ commentFormStatus.classList.remove('visible', 'success', 'error');
1213
+
1214
+ try {
1215
+ const res = await fetch('/add-comment', {
1216
+ method: 'POST',
1217
+ headers: { 'Content-Type': 'application/json' },
1218
+ body: JSON.stringify({
1219
+ repo: agent.repo,
1220
+ issue: agent.ticket,
1221
+ comment: comment,
1222
+ project_id: currentProjectId
1223
+ })
1224
+ });
1225
+
1226
+ const data = await res.json();
1227
+
1228
+ if (data.success) {
1229
+ let msg = 'Comment added to ticket #' + agent.ticket;
1230
+ if (data.reopened) {
1231
+ msg += ' (ticket reopened and moved to Todo)';
1232
+ }
1233
+ commentFormStatus.textContent = msg;
1234
+ commentFormStatus.className = 'form-status visible success';
1235
+ commentText.value = '';
1236
+ setTimeout(closeCommentModal, 2000);
1237
+ } else {
1238
+ commentFormStatus.textContent = 'Error: ' + (data.error || 'Failed to add comment');
1239
+ commentFormStatus.className = 'form-status visible error';
1240
+ }
1241
+ } catch (e) {
1242
+ commentFormStatus.textContent = 'Error: ' + e.message;
1243
+ commentFormStatus.className = 'form-status visible error';
1244
+ } finally {
1245
+ addCommentBtn.disabled = false;
1246
+ addCommentBtn.textContent = 'Add Comment';
1247
+ }
1248
+ }
1249
+
1250
+ // Ctrl+Enter to submit comment
1251
+ commentText.addEventListener('keydown', function(e) {
1252
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1253
+ e.preventDefault();
1254
+ submitComment();
1255
+ }
1256
+ });
1257
+
1258
+ // Stop ticket
1259
+ async function stopTicket(agentId) {
1260
+ if (!confirm('Stop this ticket? The running process will be killed and the ticket marked as failed.')) return;
1261
+
1262
+ try {
1263
+ const res = await fetch('/api/agent/stop', {
1264
+ method: 'POST',
1265
+ headers: { 'Content-Type': 'application/json' },
1266
+ body: JSON.stringify({ agent_id: agentId })
1267
+ });
1268
+
1269
+ const data = await res.json();
1270
+ if (!data.success) {
1271
+ alert('Failed to stop: ' + (data.error || 'Unknown error'));
1272
+ }
1273
+ } catch (e) {
1274
+ alert('Failed to stop ticket: ' + e.message);
1275
+ }
1276
+ }
1277
+
1278
+ // Voice input for comment (reuse recognition)
1279
+ let commentRecording = false;
1280
+ let commentOriginalText = '';
1281
+ let commentFinalTranscript = '';
1282
+
1283
+ function toggleCommentVoiceInput() {
1284
+ if (!SpeechRecognition) {
1285
+ commentFormStatus.textContent = 'Voice input not supported in this browser.';
1286
+ commentFormStatus.className = 'form-status visible error';
1287
+ return;
1288
+ }
1289
+
1290
+ if (commentRecording) {
1291
+ if (recognition) recognition.stop();
1292
+ commentRecording = false;
1293
+ commentVoiceBtn.classList.remove('recording');
1294
+ return;
1295
+ }
1296
+
1297
+ // Create a separate recognition instance for comments
1298
+ const commentRecognition = new SpeechRecognition();
1299
+ commentRecognition.continuous = true;
1300
+ commentRecognition.interimResults = true;
1301
+ commentRecognition.lang = 'en-US';
1302
+
1303
+ commentOriginalText = commentText.value;
1304
+ commentFinalTranscript = '';
1305
+
1306
+ commentRecognition.onstart = function() {
1307
+ commentRecording = true;
1308
+ commentVoiceBtn.classList.add('recording');
1309
+ };
1310
+
1311
+ commentRecognition.onresult = function(event) {
1312
+ let interim = '';
1313
+ for (let i = event.resultIndex; i < event.results.length; i++) {
1314
+ const transcript = event.results[i][0].transcript;
1315
+ if (event.results[i].isFinal) {
1316
+ commentFinalTranscript += transcript + ' ';
1317
+ } else {
1318
+ interim += transcript;
1319
+ }
1320
+ }
1321
+ const sep = commentOriginalText && !commentOriginalText.endsWith('\n') ? '\n' : '';
1322
+ commentText.value = commentOriginalText + sep + commentFinalTranscript + interim;
1323
+ commentText.scrollTop = commentText.scrollHeight;
1324
+ };
1325
+
1326
+ commentRecognition.onend = function() {
1327
+ if (commentFinalTranscript) {
1328
+ const sep = commentOriginalText && !commentOriginalText.endsWith('\n') ? '\n' : '';
1329
+ commentText.value = commentOriginalText + sep + commentFinalTranscript.trim();
1330
+ }
1331
+ commentRecording = false;
1332
+ commentVoiceBtn.classList.remove('recording');
1333
+ };
1334
+
1335
+ commentRecognition.onerror = function() {
1336
+ commentRecording = false;
1337
+ commentVoiceBtn.classList.remove('recording');
1338
+ };
1339
+
1340
+ try {
1341
+ commentRecognition.start();
1342
+ } catch (e) {
1343
+ commentFormStatus.textContent = 'Could not start voice input';
1344
+ commentFormStatus.className = 'form-status visible error';
1345
+ }
1346
+ }
1347
+
1348
+ function formatElapsed(startTime) {
1349
+ if (!startTime) return '';
1350
+ const elapsed = Date.now() - new Date(startTime).getTime();
1351
+ const mins = Math.floor(elapsed / 60000);
1352
+ const secs = Math.floor((elapsed % 60000) / 1000);
1353
+ if (mins >= 60) {
1354
+ const hrs = Math.floor(mins / 60);
1355
+ return hrs + 'h ' + (mins % 60) + 'm';
1356
+ }
1357
+ return mins + 'm ' + secs + 's';
1358
+ }
1359
+
1360
+ function showTooltip(e, agent) {
1361
+ // Skip tooltip on touch devices - use bottom sheet instead
1362
+ if (isTouchDevice()) return;
1363
+
1364
+ const a = agentData.get(agent.id) || agent;
1365
+ let html = '';
1366
+
1367
+ // Header with author avatar and info
1368
+ if (a.authorAvatar || a.authorName) {
1369
+ html += '<div class="tooltip-header">';
1370
+ if (a.authorAvatar) {
1371
+ html += '<img class="tooltip-avatar" src="' + a.authorAvatar + '" alt="">';
1372
+ }
1373
+ html += '<div class="tooltip-author">';
1374
+ html += '<div class="tooltip-author-name">' + (a.authorName || 'Unknown') + '</div>';
1375
+ html += '<div class="tooltip-author-time">Opened ' + (a.createdAt ? new Date(a.createdAt).toLocaleDateString() : 'recently') + '</div>';
1376
+ html += '</div></div>';
1377
+ }
1378
+
1379
+ // Title
1380
+ html += '<div class="tooltip-title">#' + (a.ticket || '?') + ': ' + (a.title || 'No title') + '</div>';
1381
+
1382
+ // Description
1383
+ if (a.description) {
1384
+ const desc = a.description.replace(/@claude/gi, '').trim().slice(0, 200);
1385
+ html += '<div class="tooltip-desc">' + desc + (a.description.length > 200 ? '...' : '') + '</div>';
1386
+ }
1387
+
1388
+ // Meta info
1389
+ html += '<div class="tooltip-meta">';
1390
+
1391
+ // Status badge
1392
+ const statusClass = a.ticketStatus === 'OPEN' ? 'open' : (a.ticketStatus === 'CLOSED' ? 'closed' : 'in-progress');
1393
+ html += '<span class="tooltip-status ' + statusClass + '">' + (a.ticketStatus || 'Unknown') + '</span>';
1394
+
1395
+ // Elapsed time
1396
+ if (a.startTime) {
1397
+ html += '<span class="tooltip-elapsed">⏱ ' + formatElapsed(a.startTime) + '</span>';
1398
+ }
1399
+
1400
+ // Repo
1401
+ if (a.repo) {
1402
+ html += '<span class="tooltip-repo">' + a.repo + '</span>';
1403
+ }
1404
+
1405
+ html += '</div>';
1406
+
1407
+ tooltip.innerHTML = html;
1408
+
1409
+ // Position tooltip - use fixed width since offsetWidth may be 0 before render
1410
+ const rect = e.target.closest('.agent-item').getBoundingClientRect();
1411
+ const tooltipWidth = 350; // max-width from CSS
1412
+
1413
+ // Position to the left of sidebar
1414
+ let left = rect.left - tooltipWidth - 15;
1415
+ let top = rect.top;
1416
+
1417
+ // Keep in viewport
1418
+ if (left < 10) {
1419
+ // Position above or below if no room on left
1420
+ left = Math.max(10, rect.left - tooltipWidth/2);
1421
+ top = rect.bottom + 10;
1422
+ }
1423
+ if (top + 250 > window.innerHeight) {
1424
+ top = Math.max(10, window.innerHeight - 260);
1425
+ }
1426
+
1427
+ tooltip.style.left = left + 'px';
1428
+ tooltip.style.top = top + 'px';
1429
+ tooltip.classList.add('visible');
1430
+ }
1431
+
1432
+ function hideTooltip() {
1433
+ tooltip.classList.remove('visible');
1434
+ }
1435
+
1436
+ // Handle agent item click - show bottom sheet on mobile, open tab on desktop
1437
+ function handleAgentClick(e, agent) {
1438
+ if (isTouchDevice() && window.innerWidth <= 768) {
1439
+ e.preventDefault();
1440
+ showBottomSheet(agent);
1441
+ } else {
1442
+ openAgentTab(agent.id, agent.ticket, agent.title);
1443
+ }
1444
+ }
1445
+
1446
+ // Mobile sidebar tab bar: open sidebar with the correct tab
1447
+ function mobileSidebarTab(tab) {
1448
+ // Update active state on mobile tab bar
1449
+ document.querySelectorAll('.mobile-sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
1450
+ switchSidebarTab(tab);
1451
+ if (!sidebar.classList.contains('open')) {
1452
+ toggleSidebar();
1453
+ }
1454
+ }
1455
+
1456
+ function switchSidebarTab(tab) {
1457
+ document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.textContent.trim().toLowerCase().startsWith(tab)));
1458
+ document.querySelectorAll('.agent-list').forEach(l => l.classList.remove('active'));
1459
+ if (tab === 'active') agentsEl.classList.add('active');
1460
+ else if (tab === 'tickets') agentsTodoEl.classList.add('active');
1461
+ else agentsHistoryEl.classList.add('active');
1462
+ }
1463
+
1464
+ function updateAgentsHistory(list) {
1465
+ // Store raw data for re-filtering
1466
+ lastHistoryList = list;
1467
+
1468
+ // Filter by current project
1469
+ const filtered = currentProjectId
1470
+ ? list.filter(a => a.project_id === currentProjectId)
1471
+ : list;
1472
+
1473
+ agentsHistoryEl.innerHTML = '';
1474
+ if (filtered.length === 0) {
1475
+ agentsHistoryEl.innerHTML = '<div style="color:#666;padding:10px;font-size:11px;">No history yet</div>';
1476
+ return;
1477
+ }
1478
+ filtered.forEach(a => {
1479
+ agentData.set(a.id, a);
1480
+ const d = document.createElement('div');
1481
+ d.className = 'agent-item';
1482
+ const ticketLink = a.ticket && a.repo ?
1483
+ '<a href="https://github.com/' + getOrgForProject(a.project_id) + '/' + a.repo + '/issues/' + a.ticket + '" target="_blank" onclick="event.stopPropagation();" style="color:#4ade80;text-decoration:none;font-weight:bold;">#' + a.ticket + '</a>' :
1484
+ (a.ticket ? '<span class="ticket">#' + a.ticket + '</span>' : '');
1485
+ d.innerHTML = '<div style="display:flex;justify-content:space-between;align-items:center;">' +
1486
+ '<div>' + (a.ticket ? ticketLink : '<span style="font-size:11px;color:#666;">No ticket</span>') + '</div>' +
1487
+ '<span class="view-btn">View →</span></div>' +
1488
+ (a.title ? '<div style="font-size:11px;color:#ccc;margin:4px 0;line-height:1.3;">' + a.title.slice(0,40) + (a.title.length > 40 ? '...' : '') + '</div>' : '') +
1489
+ '<div style="font-size:10px;color:#888;margin-top:4px;">' + a.id.slice(0, 22) + '</div>' +
1490
+ '<div style="font-size:10px;color:#666;">' + a.time + '</div>';
1491
+ d.onclick = (e) => handleAgentClick(e, a);
1492
+ d.onmouseenter = (e) => showTooltip(e, a);
1493
+ d.onmouseleave = hideTooltip;
1494
+ agentsHistoryEl.appendChild(d);
1495
+ });
1496
+ }
1497
+
1498
+ const STATUS_COLORS = { 'Todo': '#4ade80', 'In Progress': '#fbbf24', 'test': '#60a5fa', 'Done': '#888' };
1499
+ const STATUS_ORDER = ['Todo', 'In Progress', 'test', 'Done'];
1500
+
1501
+ function updateTodoTickets(list) {
1502
+ // Store raw data for re-filtering
1503
+ lastTodosList = list;
1504
+
1505
+ // Filter by current project
1506
+ const projectFiltered = currentProjectId
1507
+ ? list.filter(t => t.project_id === currentProjectId)
1508
+ : list;
1509
+
1510
+ // Sidebar only shows @claude tickets
1511
+ const filtered = projectFiltered.filter(t => t.hasClaude);
1512
+
1513
+ agentsTodoEl.innerHTML = '';
1514
+
1515
+ // Update badge count (claude tickets only)
1516
+ if (filtered.length > 0) {
1517
+ todoCountEl.textContent = filtered.length;
1518
+ todoCountEl.classList.add('visible');
1519
+ if (mobileTodoCountEl) {
1520
+ mobileTodoCountEl.textContent = filtered.length;
1521
+ mobileTodoCountEl.classList.add('visible');
1522
+ }
1523
+ } else {
1524
+ todoCountEl.classList.remove('visible');
1525
+ if (mobileTodoCountEl) mobileTodoCountEl.classList.remove('visible');
1526
+ }
1527
+
1528
+ if (filtered.length > 0) {
1529
+ // Group by status for sidebar
1530
+ const grouped = {};
1531
+ filtered.forEach(t => {
1532
+ const s = t.status || 'Todo';
1533
+ if (!grouped[s]) grouped[s] = [];
1534
+ grouped[s].push(t);
1535
+ });
1536
+
1537
+ STATUS_ORDER.forEach(status => {
1538
+ const tickets = grouped[status];
1539
+ if (!tickets || tickets.length === 0) return;
1540
+
1541
+ const header = document.createElement('div');
1542
+ header.style.cssText = 'padding:8px 14px;font-size:11px;font-weight:bold;color:' + (STATUS_COLORS[status] || '#888') + ';border-bottom:1px solid #0f3460;margin-top:4px;';
1543
+ header.textContent = status + ' (' + tickets.length + ')';
1544
+ agentsTodoEl.appendChild(header);
1545
+
1546
+ tickets.forEach(t => {
1547
+ const d = document.createElement('div');
1548
+ d.className = 'agent-item';
1549
+ const ticketLink = '<a href="https://github.com/' + getOrgForProject(t.project_id) + '/' + t.repo + '/issues/' + t.number + '" target="_blank" onclick="event.stopPropagation();" style="color:#4ade80;text-decoration:none;font-weight:bold;">#' + t.number + '</a>';
1550
+ d.innerHTML = '<div style="display:flex;justify-content:space-between;align-items:center;">' +
1551
+ '<div>' + ticketLink + '</div>' +
1552
+ '<span style="font-size:10px;color:#888;">' + (t.repo || '') + '</span></div>' +
1553
+ (t.title ? '<div style="font-size:11px;color:#ccc;margin:4px 0;line-height:1.3;">' + t.title.slice(0, 60) + (t.title.length > 60 ? '...' : '') + '</div>' : '') +
1554
+ (t.author ? '<div style="font-size:10px;color:#666;margin-top:4px;">' +
1555
+ (t.authorAvatar ? '<img src="' + t.authorAvatar + '" style="width:14px;height:14px;border-radius:50%;vertical-align:middle;margin-right:4px;">' : '') +
1556
+ t.author + '</div>' : '');
1557
+ agentsTodoEl.appendChild(d);
1558
+ });
1559
+ });
1560
+ }
1561
+
1562
+ // Always update board view
1563
+ if (currentView === 'board') renderBoard();
1564
+ }
1565
+
1566
+ // ============================================================================
1567
+ // Board View (Kanban)
1568
+ // ============================================================================
1569
+ const boardEl = document.getElementById('board');
1570
+ const boardContainer = document.getElementById('boardContainer');
1571
+ const logsContainer = document.getElementById('logsContainer');
1572
+ let currentView = localStorage.getItem('currentView') || 'logs';
1573
+
1574
+ function switchView(view) {
1575
+ currentView = view;
1576
+ localStorage.setItem('currentView', view);
1577
+ document.getElementById('viewLogsBtn').classList.toggle('active', view === 'logs');
1578
+ document.getElementById('viewBoardBtn').classList.toggle('active', view === 'board');
1579
+ if (view === 'board') {
1580
+ logsContainer.style.display = 'none';
1581
+ boardContainer.classList.add('active');
1582
+ renderBoard();
1583
+ } else {
1584
+ boardContainer.classList.remove('active');
1585
+ logsContainer.style.display = '';
1586
+ }
1587
+ }
1588
+
1589
+ function renderBoard() {
1590
+ if (!boardEl) return;
1591
+ const filtered = currentProjectId
1592
+ ? lastTodosList.filter(t => t.project_id === currentProjectId)
1593
+ : lastTodosList;
1594
+
1595
+ const grouped = {};
1596
+ filtered.forEach(t => {
1597
+ const s = t.status || 'Todo';
1598
+ if (!grouped[s]) grouped[s] = [];
1599
+ grouped[s].push(t);
1600
+ });
1601
+
1602
+ boardEl.innerHTML = '';
1603
+ STATUS_ORDER.forEach(status => {
1604
+ const tickets = grouped[status] || [];
1605
+ const color = STATUS_COLORS[status] || '#888';
1606
+ const col = document.createElement('div');
1607
+ col.className = 'board-column';
1608
+ col.style.setProperty('--col-color', color);
1609
+
1610
+ col.innerHTML =
1611
+ '<div class="board-column-header">' +
1612
+ '<span class="col-title">' + status + '</span>' +
1613
+ '<span class="col-count">' + tickets.length + '</span>' +
1614
+ '</div>' +
1615
+ '<div class="board-column-body"></div>';
1616
+
1617
+ const body = col.querySelector('.board-column-body');
1618
+
1619
+ // Drop zone events
1620
+ body.dataset.status = status;
1621
+ body.addEventListener('dragover', e => {
1622
+ e.preventDefault();
1623
+ e.dataTransfer.dropEffect = 'move';
1624
+ body.classList.add('drag-over');
1625
+ });
1626
+ body.addEventListener('dragleave', e => {
1627
+ if (!body.contains(e.relatedTarget)) body.classList.remove('drag-over');
1628
+ });
1629
+ body.addEventListener('drop', e => {
1630
+ e.preventDefault();
1631
+ body.classList.remove('drag-over');
1632
+ const data = JSON.parse(e.dataTransfer.getData('text/plain'));
1633
+ if (data.fromStatus === status) return; // Same column
1634
+ moveTicket(data, status);
1635
+ });
1636
+
1637
+ if (tickets.length === 0) {
1638
+ body.innerHTML = '<div class="board-empty">No tickets</div>';
1639
+ } else {
1640
+ tickets.forEach(t => {
1641
+ const org = getOrgForProject(t.project_id);
1642
+ const url = 'https://github.com/' + org + '/' + t.repo + '/issues/' + t.number;
1643
+ const stateClass = (t.state || '').toLowerCase() === 'open' ? 'open' : 'closed';
1644
+ const timeAgo = t.createdAt ? getTimeAgo(new Date(t.createdAt)) : '';
1645
+ const card = document.createElement('div');
1646
+ card.className = 'board-card';
1647
+ card.draggable = true;
1648
+ card.addEventListener('dragstart', e => {
1649
+ e.dataTransfer.effectAllowed = 'move';
1650
+ e.dataTransfer.setData('text/plain', JSON.stringify({
1651
+ projectItemId: t.projectItemId,
1652
+ projectId: t.project_id,
1653
+ number: t.number,
1654
+ fromStatus: status
1655
+ }));
1656
+ card.classList.add('dragging');
1657
+ setTimeout(() => card.style.opacity = '0.4', 0);
1658
+ });
1659
+ card.addEventListener('dragend', () => {
1660
+ card.classList.remove('dragging');
1661
+ card.style.opacity = '';
1662
+ });
1663
+ card.addEventListener('click', e => {
1664
+ if (e.target.tagName === 'A') return; // Don't open panel on link clicks
1665
+ openTicketPanel(t);
1666
+ });
1667
+ card.innerHTML =
1668
+ '<div class="board-card-header">' +
1669
+ '<a href="' + url + '" target="_blank" class="board-card-number" onclick="event.stopPropagation();">#' + t.number + '</a>' +
1670
+ '<div style="display:flex;align-items:center;gap:4px;">' +
1671
+ (t.hasClaude ? '<span class="board-card-claude" title="@claude">AI</span>' : '') +
1672
+ '<span class="board-card-repo">' + (t.repo || '') + '</span>' +
1673
+ '</div>' +
1674
+ '</div>' +
1675
+ (t.title ? '<div class="board-card-title">' + escapeHtml(t.title) + '</div>' : '') +
1676
+ '<div class="board-card-footer">' +
1677
+ (t.author ? '<span class="board-card-author">' +
1678
+ (t.authorAvatar ? '<img src="' + t.authorAvatar + '">' : '') +
1679
+ t.author + '</span>' : '<span></span>') +
1680
+ '<div style="display:flex;align-items:center;gap:6px;">' +
1681
+ (timeAgo ? '<span>' + timeAgo + '</span>' : '') +
1682
+ '<span class="board-card-state ' + stateClass + '">' + (t.state || '') + '</span>' +
1683
+ '</div>' +
1684
+ '</div>';
1685
+ body.appendChild(card);
1686
+ });
1687
+ }
1688
+ boardEl.appendChild(col);
1689
+ });
1690
+ }
1691
+
1692
+ async function moveTicket(data, targetStatus) {
1693
+ // Optimistic update: move the ticket in local data
1694
+ const ticket = lastTodosList.find(t => t.projectItemId === data.projectItemId);
1695
+ if (ticket) {
1696
+ ticket.status = targetStatus;
1697
+ renderBoard();
1698
+ // Also update sidebar
1699
+ updateTodoTickets(lastTodosList);
1700
+ }
1701
+
1702
+ try {
1703
+ const res = await fetch('/api/tickets/move', {
1704
+ method: 'POST',
1705
+ headers: { 'Content-Type': 'application/json' },
1706
+ body: JSON.stringify({
1707
+ projectItemId: data.projectItemId,
1708
+ targetStatus: targetStatus,
1709
+ projectId: data.projectId
1710
+ })
1711
+ });
1712
+ const result = await res.json();
1713
+ if (!result.success) {
1714
+ console.error('Move failed:', result.error);
1715
+ // Revert on failure
1716
+ if (ticket) ticket.status = data.fromStatus;
1717
+ renderBoard();
1718
+ }
1719
+ } catch (e) {
1720
+ console.error('Move error:', e);
1721
+ // Revert on failure
1722
+ if (ticket) ticket.status = data.fromStatus;
1723
+ renderBoard();
1724
+ }
1725
+ }
1726
+
1727
+ function getTimeAgo(date) {
1728
+ const now = new Date();
1729
+ const diff = now - date;
1730
+ const mins = Math.floor(diff / 60000);
1731
+ if (mins < 60) return mins + 'm';
1732
+ const hrs = Math.floor(mins / 60);
1733
+ if (hrs < 24) return hrs + 'h';
1734
+ const days = Math.floor(hrs / 24);
1735
+ return days + 'd';
1736
+ }
1737
+
1738
+ function escapeHtml(text) {
1739
+ const d = document.createElement('div');
1740
+ d.textContent = text;
1741
+ return d.innerHTML;
1742
+ }
1743
+
1744
+ // ============================================================================
1745
+ // Ticket Detail Panel
1746
+ // ============================================================================
1747
+ let currentPanelTicket = null;
1748
+
1749
+ function openTicketPanel(ticket) {
1750
+ currentPanelTicket = ticket;
1751
+ const overlay = document.getElementById('ticketPanelOverlay');
1752
+ const panel = document.getElementById('ticketPanel');
1753
+ const titleEl = document.getElementById('ticketPanelTitle');
1754
+ const bodyEl = document.getElementById('ticketPanelBody');
1755
+
1756
+ const org = getOrgForProject(ticket.project_id);
1757
+ const url = 'https://github.com/' + org + '/' + ticket.repo + '/issues/' + ticket.number;
1758
+ const stateClass = (ticket.state || '').toLowerCase() === 'open' ? 'open' : 'closed';
1759
+ const statusColor = STATUS_COLORS[ticket.status] || '#888';
1760
+
1761
+ titleEl.innerHTML = '<a href="' + url + '" target="_blank">#' + ticket.number + '</a> ' +
1762
+ '<span id="ticketTitleText">' + escapeHtml(ticket.title) + '</span>' +
1763
+ ' <button class="ticket-edit-btn" onclick="editTicketTitle()" title="Edit title">&#9998;</button>';
1764
+
1765
+ let html = '<div class="ticket-meta">';
1766
+ if (ticket.authorAvatar) {
1767
+ html += '<span class="ticket-meta-item"><img src="' + ticket.authorAvatar + '">' + ticket.author + '</span>';
1768
+ } else if (ticket.author) {
1769
+ html += '<span class="ticket-meta-item">' + ticket.author + '</span>';
1770
+ }
1771
+ const isOpen = (ticket.state || '').toLowerCase() === 'open';
1772
+ html += '<span class="ticket-meta-badge ' + stateClass + '">' + (ticket.state || '') + '</span>';
1773
+ html += '<span class="ticket-meta-badge status" style="color:' + statusColor + '">' + ticket.status + '</span>';
1774
+ html += '<span class="ticket-meta-item">' + ticket.repo + '</span>';
1775
+ // Open/Close button
1776
+ if (isOpen) {
1777
+ html += '<button class="ticket-state-btn close-issue" onclick="toggleTicketState()" title="Close issue">Close issue</button>';
1778
+ } else {
1779
+ html += '<button class="ticket-state-btn reopen-issue" onclick="toggleTicketState()" title="Reopen issue">Reopen issue</button>';
1780
+ }
1781
+ if (ticket.createdAt) {
1782
+ html += '<span class="ticket-meta-item">' + new Date(ticket.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + '</span>';
1783
+ }
1784
+ html += '</div>';
1785
+
1786
+ // Issue body with edit button
1787
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">' +
1788
+ '<span style="font-size:12px;color:#888;font-weight:bold;">Description</span>' +
1789
+ '<button class="ticket-edit-btn" onclick="editTicketBody()" title="Edit description">&#9998; Edit</button>' +
1790
+ '</div>';
1791
+ html += '<div class="ticket-body" id="ticketBodyContent">' + renderMarkdown(ticket.body || '') + '</div>';
1792
+
1793
+ // Comments
1794
+ const comments = ticket.comments || [];
1795
+ html += '<div class="ticket-comments-header">Comments (' + comments.length + ')</div>';
1796
+ if (comments.length === 0) {
1797
+ html += '<div class="ticket-no-comments">No comments yet</div>';
1798
+ } else {
1799
+ comments.forEach(c => {
1800
+ const date = c.createdAt ? new Date(c.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
1801
+ html += '<div class="ticket-comment">';
1802
+ html += '<div class="ticket-comment-header">';
1803
+ if (c.authorAvatar) html += '<img src="' + c.authorAvatar + '">';
1804
+ html += '<span class="author">' + (c.author || 'unknown') + '</span>';
1805
+ html += '<span class="date">' + date + '</span>';
1806
+ html += '</div>';
1807
+ html += '<div class="ticket-comment-body">' + renderMarkdown(c.body || '') + '</div>';
1808
+ html += '</div>';
1809
+ });
1810
+ }
1811
+
1812
+ // Add comment input
1813
+ html += '<div class="ticket-add-comment">' +
1814
+ '<textarea id="newCommentText" placeholder="Write a comment..." rows="3"></textarea>' +
1815
+ '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px;">' +
1816
+ '<button class="ticket-comment-submit" id="submitCommentBtn" onclick="submitPanelComment()">Comment</button>' +
1817
+ '</div>' +
1818
+ '</div>';
1819
+
1820
+ bodyEl.innerHTML = html;
1821
+
1822
+ // Ctrl+Enter to submit comment
1823
+ const textarea = document.getElementById('newCommentText');
1824
+ if (textarea) {
1825
+ textarea.addEventListener('keydown', e => {
1826
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1827
+ e.preventDefault();
1828
+ submitPanelComment();
1829
+ }
1830
+ });
1831
+ }
1832
+
1833
+ overlay.classList.add('visible');
1834
+ requestAnimationFrame(() => panel.classList.add('open'));
1835
+ }
1836
+
1837
+ function closeTicketPanel() {
1838
+ currentPanelTicket = null;
1839
+ const overlay = document.getElementById('ticketPanelOverlay');
1840
+ const panel = document.getElementById('ticketPanel');
1841
+ panel.classList.remove('open');
1842
+ setTimeout(() => overlay.classList.remove('visible'), 300);
1843
+ }
1844
+ document.addEventListener('keydown', e => {
1845
+ if (e.key === 'Escape' && document.getElementById('ticketPanel').classList.contains('open')) {
1846
+ closeTicketPanel();
1847
+ }
1848
+ });
1849
+
1850
+ // Edit ticket title inline
1851
+ function editTicketTitle() {
1852
+ const t = currentPanelTicket;
1853
+ if (!t) return;
1854
+ const span = document.getElementById('ticketTitleText');
1855
+ const current = t.title;
1856
+ span.innerHTML = '<input type="text" id="editTitleInput" value="" style="width:100%;padding:4px 8px;background:#0f3460;border:1px solid #333;color:#eee;border-radius:4px;font-size:14px;">' +
1857
+ '<div style="display:flex;gap:4px;margin-top:4px;">' +
1858
+ '<button class="ticket-edit-save" onclick="saveTitleEdit()">Save</button>' +
1859
+ '<button class="ticket-edit-cancel" onclick="openTicketPanel(currentPanelTicket)">Cancel</button>' +
1860
+ '</div>';
1861
+ document.getElementById('editTitleInput').value = current;
1862
+ document.getElementById('editTitleInput').focus();
1863
+ document.getElementById('editTitleInput').addEventListener('keydown', e => {
1864
+ if (e.key === 'Enter') saveTitleEdit();
1865
+ if (e.key === 'Escape') openTicketPanel(currentPanelTicket);
1866
+ });
1867
+ }
1868
+
1869
+ async function saveTitleEdit() {
1870
+ const t = currentPanelTicket;
1871
+ const input = document.getElementById('editTitleInput');
1872
+ if (!t || !input) return;
1873
+ const newTitle = input.value.trim();
1874
+ if (!newTitle || newTitle === t.title) { openTicketPanel(t); return; }
1875
+
1876
+ input.disabled = true;
1877
+ try {
1878
+ const res = await fetch('/api/tickets/update', {
1879
+ method: 'POST',
1880
+ headers: { 'Content-Type': 'application/json' },
1881
+ body: JSON.stringify({ repo: t.repo, issue: t.number, title: newTitle, projectId: t.project_id })
1882
+ });
1883
+ if (res.status === 401) { window.location.reload(); return; }
1884
+ const result = await res.json();
1885
+ if (result.success) {
1886
+ t.title = newTitle;
1887
+ openTicketPanel(t);
1888
+ } else {
1889
+ alert('Error: ' + result.error);
1890
+ input.disabled = false;
1891
+ }
1892
+ } catch (e) {
1893
+ alert('Error: ' + e.message);
1894
+ input.disabled = false;
1895
+ }
1896
+ }
1897
+
1898
+ // Edit ticket body
1899
+ function editTicketBody() {
1900
+ const t = currentPanelTicket;
1901
+ if (!t) return;
1902
+ const el = document.getElementById('ticketBodyContent');
1903
+ el.innerHTML = '<textarea id="editBodyTextarea" style="width:100%;min-height:200px;padding:10px;background:#0f3460;border:1px solid #333;color:#eee;border-radius:4px;font-size:13px;font-family:monospace;resize:vertical;"></textarea>' +
1904
+ '<div style="display:flex;gap:4px;margin-top:8px;">' +
1905
+ '<button class="ticket-edit-save" onclick="saveBodyEdit()">Save</button>' +
1906
+ '<button class="ticket-edit-cancel" onclick="openTicketPanel(currentPanelTicket)">Cancel</button>' +
1907
+ '</div>';
1908
+ document.getElementById('editBodyTextarea').value = t.body || '';
1909
+ document.getElementById('editBodyTextarea').focus();
1910
+ }
1911
+
1912
+ async function saveBodyEdit() {
1913
+ const t = currentPanelTicket;
1914
+ const textarea = document.getElementById('editBodyTextarea');
1915
+ if (!t || !textarea) return;
1916
+ const newBody = textarea.value;
1917
+
1918
+ textarea.disabled = true;
1919
+ try {
1920
+ const res = await fetch('/api/tickets/update', {
1921
+ method: 'POST',
1922
+ headers: { 'Content-Type': 'application/json' },
1923
+ body: JSON.stringify({ repo: t.repo, issue: t.number, body: newBody, projectId: t.project_id })
1924
+ });
1925
+ if (res.status === 401) { window.location.reload(); return; }
1926
+ const result = await res.json();
1927
+ if (result.success) {
1928
+ t.body = newBody;
1929
+ openTicketPanel(t);
1930
+ } else {
1931
+ alert('Error: ' + result.error);
1932
+ textarea.disabled = false;
1933
+ }
1934
+ } catch (e) {
1935
+ alert('Error: ' + e.message);
1936
+ textarea.disabled = false;
1937
+ }
1938
+ }
1939
+
1940
+ // Add comment from ticket panel
1941
+ async function submitPanelComment() {
1942
+ const t = currentPanelTicket;
1943
+ const textarea = document.getElementById('newCommentText');
1944
+ const btn = document.getElementById('submitCommentBtn');
1945
+ if (!t || !textarea) return;
1946
+ const comment = textarea.value.trim();
1947
+ if (!comment) { textarea.focus(); return; }
1948
+
1949
+ textarea.disabled = true;
1950
+ btn.disabled = true;
1951
+ btn.textContent = 'Sending...';
1952
+
1953
+ try {
1954
+ const res = await fetch('/api/tickets/comment', {
1955
+ method: 'POST',
1956
+ headers: { 'Content-Type': 'application/json' },
1957
+ body: JSON.stringify({ repo: t.repo, issue: t.number, comment, projectId: t.project_id })
1958
+ });
1959
+ if (res.status === 401) { window.location.reload(); return; }
1960
+ const result = await res.json();
1961
+ if (result.success) {
1962
+ // Add comment locally and re-render
1963
+ t.comments = t.comments || [];
1964
+ t.comments.push({ body: comment, author: 'you', authorAvatar: '', createdAt: new Date().toISOString() });
1965
+ openTicketPanel(t);
1966
+ // Scroll to bottom of panel
1967
+ const panelBody = document.getElementById('ticketPanelBody');
1968
+ if (panelBody) panelBody.scrollTop = panelBody.scrollHeight;
1969
+ } else {
1970
+ alert('Error: ' + result.error);
1971
+ textarea.disabled = false;
1972
+ btn.disabled = false;
1973
+ btn.textContent = 'Comment';
1974
+ }
1975
+ } catch (e) {
1976
+ alert('Error: ' + e.message);
1977
+ textarea.disabled = false;
1978
+ btn.disabled = false;
1979
+ btn.textContent = 'Comment';
1980
+ }
1981
+ }
1982
+
1983
+ // Toggle ticket open/closed state
1984
+ async function toggleTicketState() {
1985
+ const t = currentPanelTicket;
1986
+ if (!t) return;
1987
+ const isOpen = (t.state || '').toLowerCase() === 'open';
1988
+ const newState = isOpen ? 'closed' : 'open';
1989
+ const btn = document.querySelector('.ticket-state-btn');
1990
+ if (btn) { btn.disabled = true; btn.textContent = isOpen ? 'Closing...' : 'Reopening...'; }
1991
+
1992
+ try {
1993
+ const res = await fetch('/api/tickets/update', {
1994
+ method: 'POST',
1995
+ headers: { 'Content-Type': 'application/json' },
1996
+ body: JSON.stringify({ repo: t.repo, issue: t.number, state: newState, projectId: t.project_id })
1997
+ });
1998
+ if (res.status === 401) { window.location.reload(); return; }
1999
+ const result = await res.json();
2000
+ if (result.success) {
2001
+ t.state = newState.toUpperCase();
2002
+ openTicketPanel(t);
2003
+ } else {
2004
+ alert('Error: ' + result.error);
2005
+ if (btn) { btn.disabled = false; btn.textContent = isOpen ? 'Close issue' : 'Reopen issue'; }
2006
+ }
2007
+ } catch (e) {
2008
+ alert('Error: ' + e.message);
2009
+ if (btn) { btn.disabled = false; btn.textContent = isOpen ? 'Close issue' : 'Reopen issue'; }
2010
+ }
2011
+ }
2012
+
2013
+ // Simple markdown-ish renderer for issue bodies/comments
2014
+ function renderMarkdown(text) {
2015
+ let html = escapeHtml(text);
2016
+ // Code blocks (must be before inline code)
2017
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre style="background:#0f3460;padding:10px;border-radius:4px;overflow-x:auto;font-size:12px;margin:8px 0;">$2</pre>');
2018
+ // Inline code
2019
+ html = html.replace(/`([^`]+)`/g, '<code style="background:#0f3460;padding:1px 4px;border-radius:3px;font-size:12px;">$1</code>');
2020
+ // Links
2021
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
2022
+ // Bare URLs (not inside tags)
2023
+ html = html.replace(/(^|[^"=])(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank">$2</a>');
2024
+ // Bold
2025
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
2026
+ // Italic
2027
+ html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
2028
+ // Headers
2029
+ html = html.replace(/^### (.+)$/gm, '<strong style="font-size:14px;color:#eee;">$1</strong>');
2030
+ html = html.replace(/^## (.+)$/gm, '<strong style="font-size:15px;color:#eee;">$1</strong>');
2031
+ html = html.replace(/^# (.+)$/gm, '<strong style="font-size:16px;color:#eee;">$1</strong>');
2032
+ // Checkboxes
2033
+ html = html.replace(/^- \[x\] (.+)$/gm, '<span style="color:#4ade80;">&#9745; $1</span>');
2034
+ html = html.replace(/^- \[ \] (.+)$/gm, '<span style="color:#888;">&#9744; $1</span>');
2035
+ // @mentions
2036
+ html = html.replace(/@(\w+)/g, '<span style="color:#60a5fa;font-weight:bold;">@$1</span>');
2037
+ return html;
2038
+ }
2039
+
2040
+ // Initialize view on load
2041
+ if (currentView === 'board') {
2042
+ setTimeout(() => switchView('board'), 100);
2043
+ }
2044
+
2045
+ let running = false;
2046
+ let activeTab = 'global';
2047
+ const openTabs = new Set(['global']);
2048
+ const tabLogs = { global: [] };
2049
+ const autoScroll = { global: true };
2050
+
2051
+ function setRun(r) {
2052
+ running = r;
2053
+ runBtn.disabled = r;
2054
+ stopBtn.disabled = !r;
2055
+ dot.classList.toggle('running', r);
2056
+ statusTxt.textContent = r ? 'Running' : 'Ready';
2057
+ }
2058
+
2059
+ function switchTab(tabId) {
2060
+ activeTab = tabId;
2061
+ document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabId));
2062
+ // Hide all panels and wrappers
2063
+ document.querySelectorAll('.logs-panel').forEach(p => p.classList.remove('active'));
2064
+ document.querySelectorAll('[id^="wrapper-"]').forEach(w => w.style.display = 'none');
2065
+ // Show the correct panel/wrapper
2066
+ const wrapper = document.getElementById('wrapper-' + tabId);
2067
+ if (wrapper) {
2068
+ wrapper.style.display = 'block';
2069
+ }
2070
+ const panel = document.getElementById('panel-' + tabId);
2071
+ if (panel) {
2072
+ panel.classList.add('active');
2073
+ // Always scroll to bottom when switching tabs
2074
+ requestAnimationFrame(() => {
2075
+ panel.scrollTo({ top: panel.scrollHeight });
2076
+ autoScroll[tabId] = true;
2077
+ updateScrollToBottomVisibility();
2078
+ });
2079
+ }
2080
+ }
2081
+
2082
+ // Store agent tab info for comment modal
2083
+ const agentTabInfo = new Map();
2084
+
2085
+ function openAgentTab(agentId, ticket, title) {
2086
+ if (!openTabs.has(agentId)) {
2087
+ openTabs.add(agentId);
2088
+ tabLogs[agentId] = [];
2089
+ autoScroll[agentId] = true;
2090
+
2091
+ // Store agent info for later use
2092
+ const agent = agentData.get(agentId) || { id: agentId, ticket, title };
2093
+ agentTabInfo.set(agentId, agent);
2094
+
2095
+ const tab = document.createElement('div');
2096
+ tab.className = 'tab';
2097
+ tab.dataset.tab = agentId;
2098
+ tab.innerHTML = (ticket ? '#' + ticket + ' ' : '') + agentId.slice(0, 12) + '... <span class="close" onclick="event.stopPropagation();closeTab(\'' + agentId + '\')">&times;</span>';
2099
+ tab.title = title || (ticket ? 'Ticket #' + ticket : 'Agent: ' + agentId);
2100
+ tab.onclick = () => switchTab(agentId);
2101
+ tabsEl.appendChild(tab);
2102
+
2103
+ // Create panel wrapper to hold both logs and footer
2104
+ const panelWrapper = document.createElement('div');
2105
+ panelWrapper.style.cssText = 'position:absolute;top:0;bottom:0;left:0;right:0;display:none;';
2106
+ panelWrapper.id = 'wrapper-' + agentId;
2107
+
2108
+ const panel = document.createElement('div');
2109
+ panel.className = 'logs-panel';
2110
+ panel.id = 'panel-' + agentId;
2111
+ panel.style.cssText = 'position:absolute;top:0;bottom:0;left:0;right:0;padding:16px;padding-bottom:70px;overflow-y:auto;';
2112
+ panel.addEventListener('scroll', () => {
2113
+ autoScroll[agentId] = panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 50;
2114
+ if (activeTab === agentId) {
2115
+ updateScrollToBottomVisibility();
2116
+ }
2117
+ });
2118
+
2119
+ panelWrapper.appendChild(panel);
2120
+
2121
+ // Add footer with Stop + Add Comment buttons if ticket exists
2122
+ if (ticket && agent.repo) {
2123
+ const footer = document.createElement('div');
2124
+ footer.className = 'logs-panel-footer visible';
2125
+ footer.innerHTML = '<button class="stop-btn" onclick="stopTicket(\'' + agentId + '\')">' +
2126
+ '<svg viewBox="0 0 24 24"><path d="M6 6h12v12H6z"/></svg>' +
2127
+ 'Stop</button>' +
2128
+ '<button onclick="openCommentModal(\'' + agentId + '\')">' +
2129
+ '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z"/><path d="M7 9h10v2H7zm0-3h10v2H7z"/></svg>' +
2130
+ 'Add Comment</button>';
2131
+ panelWrapper.appendChild(footer);
2132
+ }
2133
+
2134
+ tabContent.appendChild(panelWrapper);
2135
+
2136
+ tabLogs.global.filter(l => l.agent === agentId).forEach(l => {
2137
+ addLogToPanel(panel, l.content, agentId, false);
2138
+ });
2139
+ }
2140
+ switchTab(agentId);
2141
+ }
2142
+
2143
+ function closeTab(tabId) {
2144
+ if (tabId === 'global') return;
2145
+ openTabs.delete(tabId);
2146
+ delete tabLogs[tabId];
2147
+ delete autoScroll[tabId];
2148
+ agentTabInfo.delete(tabId);
2149
+ document.querySelector('.tab[data-tab="' + tabId + '"]')?.remove();
2150
+ document.getElementById('wrapper-' + tabId)?.remove();
2151
+ document.getElementById('panel-' + tabId)?.remove();
2152
+ if (activeTab === tabId) switchTab('global');
2153
+ }
2154
+
2155
+ function clearCurrentTab() {
2156
+ const panel = document.getElementById('panel-' + activeTab);
2157
+ if (panel) panel.innerHTML = '';
2158
+ if (tabLogs[activeTab]) tabLogs[activeTab] = [];
2159
+ }
2160
+
2161
+ function formatRawJson(text) {
2162
+ // Safety net: if raw JSON slips through, extract meaningful content
2163
+ try {
2164
+ const parsed = JSON.parse(text);
2165
+ if (parsed.type === 'assistant' && parsed.message?.content) {
2166
+ const parts = [];
2167
+ for (const item of parsed.message.content) {
2168
+ if (item.type === 'text' && item.text) parts.push(item.text);
2169
+ else if (item.type === 'tool_use') parts.push('🔧 ' + item.name + ': ' + (item.input?.description || item.input?.command || 'running'));
2170
+ }
2171
+ return parts.length ? parts.join('\n') : null;
2172
+ } else if (parsed.type === 'user' && parsed.message?.content) {
2173
+ const parts = [];
2174
+ for (const item of parsed.message.content) {
2175
+ if (item.type === 'tool_result') {
2176
+ const c = typeof item.content === 'string' ? item.content : (Array.isArray(item.content) ? item.content.filter(x => x.type === 'text').map(x => x.text).join('\n') : '');
2177
+ if (c.trim()) parts.push(' ✓ ' + c.substring(0, 200) + (c.length > 200 ? '...' : ''));
2178
+ }
2179
+ }
2180
+ return parts.length ? parts.join('\n') : null;
2181
+ } else if (parsed.type === 'result') {
2182
+ const cost = parsed.cost_usd ? '$' + parsed.cost_usd.toFixed(4) : '';
2183
+ return '✅ Complete' + (cost ? ' (' + cost + ')' : '');
2184
+ }
2185
+ return null; // Skip unknown JSON types
2186
+ } catch (e) {
2187
+ return text; // Not JSON, return as-is
2188
+ }
2189
+ }
2190
+
2191
+ function addLogToPanel(panel, text, agentId, scroll = true) {
2192
+ // Detect and format raw JSON
2193
+ if (text.startsWith('{') && text.includes('"type"')) {
2194
+ text = formatRawJson(text);
2195
+ if (!text) return; // Skip empty/unknown JSON
2196
+ }
2197
+
2198
+ const d = document.createElement('div');
2199
+ let cls = 'log-entry';
2200
+ if (text.includes('===')) cls += ' ts';
2201
+ else if (text.includes('✓') || text.includes('success') || text.includes('passed')) cls += ' ok';
2202
+ else if (text.includes('error') || text.includes('FAIL') || text.includes('failed')) cls += ' err';
2203
+ else if (text.startsWith('🔧')) cls += ' tool';
2204
+ else if (text.includes('PHASE')) cls += ' phase';
2205
+ d.className = cls;
2206
+
2207
+ // Truncate long lines and convert newlines to <br>
2208
+ const MAX_LENGTH = 500;
2209
+ const escapeHtml = (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2210
+ const formatText = (str) => escapeHtml(str).replace(/\\n/g, '\n').replace(/\n/g, '<br>');
2211
+
2212
+ if (text.length > MAX_LENGTH) {
2213
+ const truncated = text.substring(0, MAX_LENGTH) + '...';
2214
+ d.innerHTML = formatText(truncated);
2215
+ d.title = 'Click to expand';
2216
+ d.style.cursor = 'pointer';
2217
+ d.dataset.fullText = text;
2218
+ d.dataset.truncated = 'true';
2219
+ d.onclick = function() {
2220
+ if (this.dataset.truncated === 'true') {
2221
+ this.innerHTML = formatText(this.dataset.fullText);
2222
+ this.dataset.truncated = 'false';
2223
+ this.title = 'Click to collapse';
2224
+ } else {
2225
+ this.innerHTML = formatText(truncated);
2226
+ this.dataset.truncated = 'true';
2227
+ this.title = 'Click to expand';
2228
+ }
2229
+ };
2230
+ } else {
2231
+ d.innerHTML = formatText(text);
2232
+ }
2233
+
2234
+ panel.appendChild(d);
2235
+ if (scroll) {
2236
+ const tabId = panel.id.replace('panel-', '');
2237
+ if (autoScroll[tabId]) {
2238
+ requestAnimationFrame(() => panel.scrollTo({ top: panel.scrollHeight, behavior: 'smooth' }));
2239
+ }
2240
+ }
2241
+ }
2242
+
2243
+ function addLog(text, agentId) {
2244
+ tabLogs.global.push({ content: text, agent: agentId });
2245
+ const globalPanel = document.getElementById('panel-global');
2246
+ addLogToPanel(globalPanel, text, agentId);
2247
+
2248
+ if (agentId && openTabs.has(agentId)) {
2249
+ tabLogs[agentId].push({ content: text, agent: agentId });
2250
+ const agentPanel = document.getElementById('panel-' + agentId);
2251
+ addLogToPanel(agentPanel, text, agentId);
2252
+ }
2253
+ }
2254
+
2255
+ function updateAgents(list) {
2256
+ // Store raw data for re-filtering
2257
+ lastAgentsList = list;
2258
+
2259
+ // Detect completed agents (were active before, now gone)
2260
+ const currentActiveIds = new Set(list.filter(a => a.active).map(a => a.id));
2261
+
2262
+ for (const [agentId, agent] of previousActiveAgents) {
2263
+ if (!currentActiveIds.has(agentId)) {
2264
+ // Agent completed!
2265
+ notifyCompletion(agent);
2266
+ previousActiveAgents.delete(agentId);
2267
+ }
2268
+ }
2269
+
2270
+ // Update tracked active agents
2271
+ list.filter(a => a.active).forEach(a => {
2272
+ previousActiveAgents.set(a.id, a);
2273
+ });
2274
+
2275
+ // Filter by current project
2276
+ const filtered = currentProjectId
2277
+ ? list.filter(a => a.project_id === currentProjectId)
2278
+ : list;
2279
+
2280
+ agentsEl.innerHTML = '';
2281
+ filtered.forEach(a => {
2282
+ agentData.set(a.id, a);
2283
+ const d = document.createElement('div');
2284
+ d.className = 'agent-item' + (a.active ? ' active' : '');
2285
+ const ticketLink = a.ticket && a.repo ?
2286
+ '<a href="https://github.com/' + getOrgForProject(a.project_id) + '/' + a.repo + '/issues/' + a.ticket + '" target="_blank" onclick="event.stopPropagation();" style="color:#4ade80;text-decoration:none;font-weight:bold;">#' + a.ticket + '</a>' :
2287
+ (a.ticket ? '<span class="ticket">#' + a.ticket + '</span>' : '');
2288
+ d.innerHTML = '<div style="display:flex;justify-content:space-between;align-items:center;">' +
2289
+ '<div>' + (a.ticket ? ticketLink : '') + '</div>' +
2290
+ '<span class="view-btn">View →</span></div>' +
2291
+ (a.title ? '<div style="font-size:11px;color:#ccc;margin:4px 0;line-height:1.3;">' + a.title.slice(0,40) + (a.title.length > 40 ? '...' : '') + '</div>' : '') +
2292
+ '<div style="font-size:10px;color:#666;">' + a.id.slice(0, 22) + '</div>' +
2293
+ (a.startTime ? '<div class="elapsed">⏱ ' + formatElapsed(a.startTime) + '</div>' : '');
2294
+ d.onclick = (e) => handleAgentClick(e, a);
2295
+ d.onmouseenter = (e) => showTooltip(e, a);
2296
+ d.onmouseleave = hideTooltip;
2297
+ agentsEl.appendChild(d);
2298
+ });
2299
+ agentCount.textContent = 'Agents: ' + filtered.filter(a => a.active).length;
2300
+ if (filtered.some(a => a.active)) setRun(true); else setRun(false);
2301
+ }
2302
+
2303
+ // Update elapsed times every second
2304
+ setInterval(() => {
2305
+ document.querySelectorAll('.agent-item .elapsed').forEach(el => {
2306
+ const agentItem = el.closest('.agent-item');
2307
+ const agentId = Array.from(agentData.keys()).find(id => {
2308
+ const a = agentData.get(id);
2309
+ return a && a.active && agentItem.textContent.includes(id.slice(0, 22));
2310
+ });
2311
+ if (agentId) {
2312
+ const a = agentData.get(agentId);
2313
+ if (a && a.startTime) {
2314
+ el.textContent = '⏱ ' + formatElapsed(a.startTime);
2315
+ }
2316
+ }
2317
+ });
2318
+ }, 1000);
2319
+
2320
+ async function run() {
2321
+ setRun(true);
2322
+ try { await fetch('/run', { method: 'POST' }); } catch(e) { setRun(false); }
2323
+ }
2324
+
2325
+ async function stop() {
2326
+ try { await fetch('/stop', { method: 'POST' }); } catch(e) {}
2327
+ }
2328
+
2329
+ document.getElementById('panel-global').addEventListener('scroll', function() {
2330
+ autoScroll.global = this.scrollTop + this.clientHeight >= this.scrollHeight - 50;
2331
+ updateScrollToBottomVisibility();
2332
+ });
2333
+
2334
+ // Scroll to bottom button functionality
2335
+ function scrollToBottom() {
2336
+ const panel = document.getElementById('panel-' + activeTab);
2337
+ if (panel) {
2338
+ panel.scrollTo({ top: panel.scrollHeight, behavior: 'smooth' });
2339
+ autoScroll[activeTab] = true;
2340
+ }
2341
+ }
2342
+
2343
+ function updateScrollToBottomVisibility() {
2344
+ const panel = document.getElementById('panel-' + activeTab);
2345
+ if (panel) {
2346
+ const isAtBottom = panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 100;
2347
+ if (isAtBottom) {
2348
+ scrollToBottomBtn.classList.remove('visible');
2349
+ } else {
2350
+ scrollToBottomBtn.classList.add('visible');
2351
+ }
2352
+ }
2353
+ }
2354
+
2355
+ function connectSSE() {
2356
+ if (eventSource) {
2357
+ eventSource.close();
2358
+ }
2359
+ eventSource = new EventSource('/logs');
2360
+ eventSource.onmessage = e => {
2361
+ const d = JSON.parse(e.data);
2362
+ if (d.type === 'log') addLog(d.content, d.agent);
2363
+ else if (d.type === 'agents') updateAgents(d.list);
2364
+ else if (d.type === 'history') updateAgentsHistory(d.list);
2365
+ else if (d.type === 'todos') updateTodoTickets(d.list);
2366
+ else if (d.type === 'ticket-completed') notifyCompletion(d.ticket);
2367
+ else if (d.type === 'done') setRun(false);
2368
+ };
2369
+ eventSource.onerror = () => {
2370
+ if (!navigator.onLine) {
2371
+ offlineBanner.classList.add('visible');
2372
+ document.body.classList.add('offline');
2373
+ }
2374
+ };
2375
+ }
2376
+
2377
+ // Initial connection
2378
+ connectSSE();
2379
+ updateOnlineStatus();