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.
- package/lib/agent-api.js +530 -0
- package/lib/auth.js +127 -0
- package/lib/config.js +53 -0
- package/lib/database.js +762 -0
- package/lib/device-flow.js +257 -0
- package/lib/email.js +420 -0
- package/lib/encryption.js +112 -0
- package/lib/github.js +339 -0
- package/lib/history.js +143 -0
- package/lib/pwa.js +107 -0
- package/lib/redis-logs.js +226 -0
- package/lib/routes.js +680 -0
- package/migrations/000_create_database.sql +33 -0
- package/migrations/001_create_agentdev_schema.sql +135 -0
- package/migrations/001_create_agentdev_schema.sql.old +100 -0
- package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
- package/migrations/002_add_github_token.sql +17 -0
- package/migrations/003_add_agent_logs_table.sql +23 -0
- package/migrations/004_remove_oauth_columns.sql +11 -0
- package/migrations/005_add_projects.sql +44 -0
- package/migrations/006_project_github_token.sql +7 -0
- package/migrations/007_project_repositories.sql +12 -0
- package/migrations/008_add_notifications.sql +20 -0
- package/migrations/009_unified_oauth.sql +153 -0
- package/migrations/README.md +97 -0
- package/package.json +37 -0
- package/public/css/styles.css +1140 -0
- package/public/device.html +384 -0
- package/public/docs.html +862 -0
- package/public/docs.md +697 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +271 -0
- package/public/js/app.js +2379 -0
- package/public/login.html +224 -0
- package/public/profile.html +394 -0
- package/public/register.html +392 -0
- package/public/reset-password.html +349 -0
- package/public/verify-email.html +177 -0
- package/server.js +1450 -0
package/public/js/app.js
ADDED
|
@@ -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
|
+
' • 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
|
+
' • ID: <span style="color:#888;">' + (data.project_id || '?').substring(0, 20) + '...</span>' +
|
|
369
|
+
(data.status_field ? ' • 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">✎</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">✎ 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;">☑ $1</span>');
|
|
2034
|
+
html = html.replace(/^- \[ \] (.+)$/gm, '<span style="color:#888;">☐ $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 + '\')">×</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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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();
|