agentopia 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/dist/app.d.ts +10 -0
  3. package/dist/app.d.ts.map +1 -0
  4. package/dist/app.js +121 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/config.d.ts +9 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +19 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/db/database.d.ts +5 -0
  11. package/dist/db/database.d.ts.map +1 -0
  12. package/dist/db/database.js +39 -0
  13. package/dist/db/database.js.map +1 -0
  14. package/dist/db/schema.d.ts +3 -0
  15. package/dist/db/schema.d.ts.map +1 -0
  16. package/dist/db/schema.js +621 -0
  17. package/dist/db/schema.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +49 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/logger.d.ts +4 -0
  23. package/dist/logger.d.ts.map +1 -0
  24. package/dist/logger.js +9 -0
  25. package/dist/logger.js.map +1 -0
  26. package/dist/middleware/auth.d.ts +13 -0
  27. package/dist/middleware/auth.d.ts.map +1 -0
  28. package/dist/middleware/auth.js +733 -0
  29. package/dist/middleware/auth.js.map +1 -0
  30. package/dist/routes/agents.d.ts +3 -0
  31. package/dist/routes/agents.d.ts.map +1 -0
  32. package/dist/routes/agents.js +1058 -0
  33. package/dist/routes/agents.js.map +1 -0
  34. package/dist/routes/issues.d.ts +4 -0
  35. package/dist/routes/issues.d.ts.map +1 -0
  36. package/dist/routes/issues.js +946 -0
  37. package/dist/routes/issues.js.map +1 -0
  38. package/dist/routes/knowledge.d.ts +3 -0
  39. package/dist/routes/knowledge.d.ts.map +1 -0
  40. package/dist/routes/knowledge.js +117 -0
  41. package/dist/routes/knowledge.js.map +1 -0
  42. package/dist/routes/memories.d.ts +3 -0
  43. package/dist/routes/memories.d.ts.map +1 -0
  44. package/dist/routes/memories.js +115 -0
  45. package/dist/routes/memories.js.map +1 -0
  46. package/dist/routes/messages.d.ts +3 -0
  47. package/dist/routes/messages.d.ts.map +1 -0
  48. package/dist/routes/messages.js +130 -0
  49. package/dist/routes/messages.js.map +1 -0
  50. package/dist/routes/projects.d.ts +3 -0
  51. package/dist/routes/projects.d.ts.map +1 -0
  52. package/dist/routes/projects.js +754 -0
  53. package/dist/routes/projects.js.map +1 -0
  54. package/dist/routes/templates.d.ts +3 -0
  55. package/dist/routes/templates.d.ts.map +1 -0
  56. package/dist/routes/templates.js +117 -0
  57. package/dist/routes/templates.js.map +1 -0
  58. package/dist/routes/ui.d.ts +3 -0
  59. package/dist/routes/ui.d.ts.map +1 -0
  60. package/dist/routes/ui.js +38 -0
  61. package/dist/routes/ui.js.map +1 -0
  62. package/dist/services/agent-hierarchy.d.ts +14 -0
  63. package/dist/services/agent-hierarchy.d.ts.map +1 -0
  64. package/dist/services/agent-hierarchy.js +58 -0
  65. package/dist/services/agent-hierarchy.js.map +1 -0
  66. package/dist/services/agent-issue-batch.d.ts +17 -0
  67. package/dist/services/agent-issue-batch.d.ts.map +1 -0
  68. package/dist/services/agent-issue-batch.js +57 -0
  69. package/dist/services/agent-issue-batch.js.map +1 -0
  70. package/dist/services/controller.d.ts +4 -0
  71. package/dist/services/controller.d.ts.map +1 -0
  72. package/dist/services/controller.js +237 -0
  73. package/dist/services/controller.js.map +1 -0
  74. package/dist/services/langgraph-runner.d.ts +33 -0
  75. package/dist/services/langgraph-runner.d.ts.map +1 -0
  76. package/dist/services/langgraph-runner.js +478 -0
  77. package/dist/services/langgraph-runner.js.map +1 -0
  78. package/dist/services/orchestrator.d.ts +9 -0
  79. package/dist/services/orchestrator.d.ts.map +1 -0
  80. package/dist/services/orchestrator.js +116 -0
  81. package/dist/services/orchestrator.js.map +1 -0
  82. package/dist/services/pre-controller.d.ts +7 -0
  83. package/dist/services/pre-controller.d.ts.map +1 -0
  84. package/dist/services/pre-controller.js +101 -0
  85. package/dist/services/pre-controller.js.map +1 -0
  86. package/dist/services/process-manager.d.ts +67 -0
  87. package/dist/services/process-manager.d.ts.map +1 -0
  88. package/dist/services/process-manager.js +938 -0
  89. package/dist/services/process-manager.js.map +1 -0
  90. package/dist/services/project-permissions.d.ts +84 -0
  91. package/dist/services/project-permissions.d.ts.map +1 -0
  92. package/dist/services/project-permissions.js +129 -0
  93. package/dist/services/project-permissions.js.map +1 -0
  94. package/dist/services/scheduler.d.ts +6 -0
  95. package/dist/services/scheduler.d.ts.map +1 -0
  96. package/dist/services/scheduler.js +300 -0
  97. package/dist/services/scheduler.js.map +1 -0
  98. package/dist/services/system-prompt.d.ts +3 -0
  99. package/dist/services/system-prompt.d.ts.map +1 -0
  100. package/dist/services/system-prompt.js +285 -0
  101. package/dist/services/system-prompt.js.map +1 -0
  102. package/dist/services/terminal.d.ts +18 -0
  103. package/dist/services/terminal.d.ts.map +1 -0
  104. package/dist/services/terminal.js +222 -0
  105. package/dist/services/terminal.js.map +1 -0
  106. package/dist/services/websocket.d.ts +15 -0
  107. package/dist/services/websocket.d.ts.map +1 -0
  108. package/dist/services/websocket.js +204 -0
  109. package/dist/services/websocket.js.map +1 -0
  110. package/dist/types.d.ts +108 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +3 -0
  113. package/dist/types.js.map +1 -0
  114. package/env.ini +18 -0
  115. package/package.json +38 -0
  116. package/project_id +0 -0
  117. package/public/admin-users.html +188 -0
  118. package/public/agent.html +199 -0
  119. package/public/css/issues.css +275 -0
  120. package/public/css/style.css +1299 -0
  121. package/public/index.html +166 -0
  122. package/public/issue.html +76 -0
  123. package/public/js/agent.js +19 -0
  124. package/public/js/common.js +735 -0
  125. package/public/js/dashboard.js +772 -0
  126. package/public/js/files-panel.js +703 -0
  127. package/public/js/interactive-terminal.js +201 -0
  128. package/public/js/issue-renderer.js +559 -0
  129. package/public/js/issue.js +57 -0
  130. package/public/js/project.js +2425 -0
  131. package/public/js/terminal.js +564 -0
  132. package/public/project.html +430 -0
  133. package/public/terminal.html +67 -0
  134. package/public/vendor/marked.js +74 -0
  135. package/public/vendor/xterm-addon-fit.js +2 -0
  136. package/public/vendor/xterm.css +209 -0
  137. package/public/vendor/xterm.js +2 -0
  138. package/send_message_and_update_issue.js +65 -0
  139. package/tsconfig.json +19 -0
  140. package/update_round2_and_create_round3.js +284 -0
@@ -0,0 +1,772 @@
1
+ // Cache for last activity data from summary endpoint
2
+ let _lastActivityMap = {};
3
+ let _notificationsCollapsed = false;
4
+ let _notifFilter = 'all'; // 'all' or 'action'
5
+ let _inboxSearchQuery = '';
6
+ let _inboxAllItems = []; // cached items for search filtering
7
+ let _dashboardProjectsById = {};
8
+
9
+ // Track known action-required issue IDs to detect new ones
10
+ let _knownActionIssueIds = null; // null = first load (don't ring on first load)
11
+
12
+ // Track locally acknowledged issue IDs so they survive inbox refresh
13
+ let _acknowledgedIds = new Set();
14
+
15
+ const PROJECT_ACCESS_META = {
16
+ owner: {
17
+ badge: 'OWNER',
18
+ tone: 'owner',
19
+ summary: 'Project Owner',
20
+ detail: 'Owned by you',
21
+ },
22
+ member: {
23
+ badge: 'SHARED',
24
+ tone: 'shared',
25
+ summary: 'Shared Member',
26
+ detail: 'Shared with you',
27
+ },
28
+ admin: {
29
+ badge: 'ADMIN VIEW',
30
+ tone: 'admin',
31
+ summary: 'Global Admin',
32
+ detail: 'Admin view',
33
+ },
34
+ bypass: {
35
+ badge: 'DEBUG',
36
+ tone: 'debug',
37
+ summary: 'Debug mode',
38
+ detail: 'legacy / localhost bypass',
39
+ },
40
+ none: {
41
+ badge: 'UNKNOWN',
42
+ tone: 'shared',
43
+ summary: 'Unknown role',
44
+ detail: 'Role info missing',
45
+ },
46
+ };
47
+
48
+ function displayProjectUser(user) {
49
+ if (!user) return 'Not set';
50
+ return user.display_name || user.username || 'Not set';
51
+ }
52
+
53
+ function getProjectAccessLevel(project) {
54
+ if (project?.owner?.id && _currentUser?.id && project.owner.id === _currentUser.id) {
55
+ return 'owner';
56
+ }
57
+ return project?.permission_level || 'none';
58
+ }
59
+
60
+ function getProjectAccessMeta(project) {
61
+ return PROJECT_ACCESS_META[getProjectAccessLevel(project)] || PROJECT_ACCESS_META.none;
62
+ }
63
+
64
+ async function loadDashboardSummary() {
65
+ try {
66
+ const res = await fetch('/api/dashboard/summary', { headers: apiHeaders() });
67
+ if (!res.ok) return;
68
+ const data = await res.json();
69
+
70
+ document.getElementById('stat-running').textContent = data.agents.running;
71
+ document.getElementById('stat-open-issues').textContent = data.issues.open;
72
+ const fmtTokensDash = v => v >= 1000000 ? (v / 1000000).toFixed(1) + 'M' : v >= 1000 ? (v / 1000).toFixed(1) + 'K' : v;
73
+ if (data.total_cost_usd > 0) {
74
+ document.getElementById('stat-cost').textContent = '$' + data.total_cost_usd.toFixed(2);
75
+ } else if (data.total_input_tokens > 0) {
76
+ document.getElementById('stat-cost').textContent = fmtTokensDash(data.total_input_tokens) + '↑ ' + fmtTokensDash(data.total_output_tokens) + '↓';
77
+ const costLabel = document.getElementById('stat-cost')?.closest('.stat-card')?.querySelector('.stat-label');
78
+ if (costLabel) costLabel.textContent = 'Token Usage';
79
+ }
80
+
81
+ const errCard = document.getElementById('stat-errors-card');
82
+ if (data.agents.error_count > 0) {
83
+ document.getElementById('stat-errors').textContent = data.agents.error_count;
84
+ errCard.style.display = '';
85
+ } else {
86
+ errCard.style.display = 'none';
87
+ }
88
+
89
+ document.getElementById('dashboard-stats').style.display = '';
90
+ _lastActivityMap = data.last_activity || {};
91
+ } catch (e) {
92
+ console.error('Failed to load dashboard summary', e);
93
+ }
94
+ }
95
+
96
+ async function loadNotifications() {
97
+ try {
98
+ const res = await fetch('/api/notifications', { headers: apiHeaders() });
99
+ if (!res.ok) return;
100
+ const data = await res.json();
101
+
102
+ const issues = data.user_issues || [];
103
+ const comments = (data.recent_comments || []).slice(0, 50);
104
+ const unacknowledgedIssues = issues.filter(i => !i.acknowledged_at);
105
+ const totalCount = unacknowledgedIssues.length;
106
+
107
+ // Detect new action-required issues and play notification sound
108
+ const currentIds = new Set(unacknowledgedIssues.map(i => i.id || i.number));
109
+ if (_knownActionIssueIds === null) {
110
+ _knownActionIssueIds = currentIds;
111
+ } else {
112
+ let hasNew = false;
113
+ for (const id of currentIds) {
114
+ if (!_knownActionIssueIds.has(id)) { hasNew = true; break; }
115
+ }
116
+ _knownActionIssueIds = currentIds;
117
+ if (hasNew && typeof playNotificationSound === 'function') {
118
+ playNotificationSound();
119
+ }
120
+ }
121
+
122
+ // Always show the Inbox panel
123
+ document.getElementById('notifications-panel').style.display = '';
124
+ const badge = document.getElementById('notif-count');
125
+ if (totalCount > 0) {
126
+ const prevCount = parseInt(badge.textContent, 10) || 0;
127
+ badge.textContent = totalCount;
128
+ badge.style.display = '';
129
+ if (totalCount > prevCount) {
130
+ badge.classList.remove('pulse');
131
+ void badge.offsetWidth;
132
+ badge.classList.add('pulse');
133
+ }
134
+ } else {
135
+ badge.style.display = 'none';
136
+ }
137
+
138
+ // Build items: action-required (unacknowledged) issues first, then acknowledged, then comments
139
+ // Sync local acknowledged set with server state:
140
+ // - If server says acknowledged_at is set, keep in local set
141
+ // - If server says acknowledged_at is NULL (e.g. new comment reset it), remove from local set
142
+ for (const issue of issues) {
143
+ if (issue.acknowledged_at) {
144
+ _acknowledgedIds.add(issue.id);
145
+ } else {
146
+ _acknowledgedIds.delete(issue.id);
147
+ }
148
+ }
149
+ const items = [];
150
+ for (const issue of issues) {
151
+ const isAcknowledged = !!issue.acknowledged_at || _acknowledgedIds.has(issue.id);
152
+ items.push({ type: 'issue', time: issue.updated_at, data: issue, actionRequired: !isAcknowledged });
153
+ }
154
+ for (const c of comments) {
155
+ items.push({ type: 'comment', time: c.created_at, data: c, actionRequired: false });
156
+ }
157
+ // Sort: action-required first, then by time desc
158
+ items.sort((a, b) => {
159
+ if (a.actionRequired !== b.actionRequired) return a.actionRequired ? -1 : 1;
160
+ return (b.time || '') > (a.time || '') ? 1 : -1;
161
+ });
162
+
163
+ _inboxAllItems = items;
164
+ renderInboxItems(items);
165
+ } catch (e) {
166
+ console.error('Failed to load notifications', e);
167
+ }
168
+ }
169
+
170
+ function renderInboxItems(items) {
171
+ const body = document.getElementById('notifications-body');
172
+ const query = _inboxSearchQuery.toLowerCase().trim();
173
+
174
+ let html = '';
175
+ for (const item of items) {
176
+ // Apply filter — but keep recently-acknowledged issues visible (not red)
177
+ const isLocallyAcked = item.type === 'issue' && item.data && _acknowledgedIds.has(item.data.id);
178
+ if (_notifFilter === 'action' && !item.actionRequired && !isLocallyAcked) continue;
179
+
180
+ if (item.type === 'issue') {
181
+ const issue = item.data;
182
+ // Apply search
183
+ if (query && !matchesSearch(query, '#' + issue.number, issue.title, issue.body || '')) continue;
184
+ const isAction = item.actionRequired;
185
+ const isAcked = _acknowledgedIds.has(issue.id) || !!issue.acknowledged_at;
186
+ const ackBtnHtml = isAcked ? '' : `<button class="notif-ack-btn" onclick="event.stopPropagation();acknowledgeIssue('${issue.id}')" title="Mark read">✓</button>`;
187
+ html += `<div class="notif-item${isAction ? ' notif-action-required' : ''}" id="notif-issue-${issue.id}" onclick="openIssuePanel('${issue.id}')" style="cursor:pointer">
188
+ <span class="notif-icon" style="color:${isAction ? 'var(--warning)' : 'var(--text-secondary)'}">&#9679;</span>
189
+ <span class="notif-text">
190
+ <span style="color:var(--text-secondary);font-size:10px">[${esc(issue.project_name || '')}]</span>
191
+ <a href="/projects/${issue.project_id}/issues/${issue.number}" onclick="event.stopPropagation()">#${issue.number}</a>
192
+ ${esc(issue.title)}
193
+ </span>
194
+ ${ackBtnHtml}
195
+ <span class="notif-time">${timeAgo(issue.updated_at) || ''}</span>
196
+ </div>`;
197
+ } else {
198
+ const c = item.data;
199
+ if (query && !matchesSearch(query, '#' + c.issue_number, c.issue_title || '', c.body || '')) continue;
200
+ const preview = (c.body || '').slice(0, 60) + ((c.body || '').length > 60 ? '...' : '');
201
+ html += `<div class="notif-item notif-comment" onclick="openIssuePanelByProject('${c.project_id}', ${c.issue_number})" style="cursor:pointer">
202
+ <span class="notif-icon" style="color:var(--text-secondary)">&#9998;</span>
203
+ <span class="notif-text">
204
+ <span style="color:var(--text-secondary);font-size:10px">[${esc(c.project_name || '')}]</span>
205
+ <a href="/projects/${c.project_id}/issues/${c.issue_number}" onclick="event.stopPropagation()">#${c.issue_number}</a>
206
+ <span style="color:var(--text-secondary)">${esc(preview)}</span>
207
+ </span>
208
+ <span class="notif-time">${timeAgo(c.created_at) || ''}</span>
209
+ </div>`;
210
+ }
211
+ }
212
+
213
+ if (!html && query) {
214
+ html = '<div style="padding:12px 16px;color:var(--text-secondary);font-size:12px;text-align:center">No results</div>';
215
+ } else if (!html) {
216
+ html = '<div style="padding:12px 16px;color:var(--text-secondary);font-size:12px;text-align:center">No notifications</div>';
217
+ }
218
+
219
+ body.innerHTML = html;
220
+ if (_notificationsCollapsed) {
221
+ body.classList.add('collapsed');
222
+ document.getElementById('notif-toggle-icon').classList.add('collapsed');
223
+ }
224
+ }
225
+
226
+ function matchesSearch(query, ...fields) {
227
+ for (const f of fields) {
228
+ if (f.toLowerCase().includes(query)) return true;
229
+ }
230
+ return false;
231
+ }
232
+
233
+ function filterInbox(query) {
234
+ _inboxSearchQuery = query;
235
+ if (query.trim()) {
236
+ // When searching, fetch all issues across projects
237
+ searchInboxIssues(query.trim());
238
+ } else {
239
+ // No search query — show normal inbox items
240
+ renderInboxItems(_inboxAllItems);
241
+ }
242
+ }
243
+
244
+ async function searchInboxIssues(query) {
245
+ try {
246
+ const res = await fetch('/api/inbox/search?q=' + encodeURIComponent(query), { headers: apiHeaders() });
247
+ if (!res.ok) return;
248
+ const results = await res.json();
249
+ // Only mark items as action-required if they are already in the inbox notifications
250
+ const actionIds = new Set(_inboxAllItems.filter(i => i.actionRequired && i.data && i.data.id).map(i => i.data.id));
251
+ const items = results.map(issue => ({
252
+ type: 'issue',
253
+ time: issue.updated_at,
254
+ data: issue,
255
+ actionRequired: actionIds.has(issue.id)
256
+ }));
257
+ // Sort: action-required first, then by time desc
258
+ items.sort((a, b) => {
259
+ if (a.actionRequired !== b.actionRequired) return a.actionRequired ? -1 : 1;
260
+ return (b.time || '') > (a.time || '') ? 1 : -1;
261
+ });
262
+ renderInboxItems(items);
263
+ } catch (e) {
264
+ console.error('Failed to search inbox', e);
265
+ }
266
+ }
267
+
268
+ function toggleNotifications() {
269
+ const body = document.getElementById('notifications-body');
270
+ const icon = document.getElementById('notif-toggle-icon');
271
+ _notificationsCollapsed = !_notificationsCollapsed;
272
+ body.classList.toggle('collapsed');
273
+ icon.classList.toggle('collapsed');
274
+ }
275
+
276
+ function toggleNotifFilter(filter) {
277
+ _notifFilter = filter;
278
+ document.querySelectorAll('.notif-filter-btn').forEach(btn => {
279
+ btn.classList.toggle('active', btn.dataset.filter === filter);
280
+ });
281
+ if (filter === 'my') {
282
+ loadMyIssues();
283
+ } else if (_inboxSearchQuery.trim()) {
284
+ searchInboxIssues(_inboxSearchQuery.trim());
285
+ } else {
286
+ renderInboxItems(_inboxAllItems);
287
+ }
288
+ }
289
+
290
+ async function loadMyIssues() {
291
+ try {
292
+ const res = await fetch('/api/my-issues', { headers: apiHeaders() });
293
+ if (!res.ok) return;
294
+ const issues = await res.json();
295
+ const items = issues.map(issue => ({
296
+ type: 'issue',
297
+ time: issue.updated_at,
298
+ data: issue,
299
+ actionRequired: issue.assigned_to === 'user' && ['open', 'in_progress'].includes(issue.status)
300
+ }));
301
+ renderInboxItems(items);
302
+ } catch (e) {
303
+ console.error('Failed to load my issues', e);
304
+ }
305
+ }
306
+
307
+ async function acknowledgeIssue(issueId) {
308
+ try {
309
+ const res = await fetch(`/api/issues/${issueId}/acknowledge`, { method: 'POST' });
310
+ if (res.ok) {
311
+ // Track locally so the item survives inbox refresh
312
+ _acknowledgedIds.add(issueId);
313
+ const el = document.getElementById('notif-issue-' + issueId);
314
+ if (el) {
315
+ el.classList.remove('notif-action-required');
316
+ const dot = el.querySelector('.notif-icon');
317
+ if (dot) dot.style.color = 'var(--text-secondary)';
318
+ const ackBtn = el.querySelector('.notif-ack-btn');
319
+ if (ackBtn) ackBtn.style.display = 'none';
320
+ }
321
+ // Update cached items
322
+ const cached = _inboxAllItems.find(i => i.data && i.data.id === issueId);
323
+ if (cached) cached.actionRequired = false;
324
+ // Update badge count
325
+ const remaining = document.querySelectorAll('.notif-action-required').length;
326
+ const badge = document.getElementById('notif-count');
327
+ if (badge) {
328
+ badge.textContent = remaining;
329
+ if (remaining === 0) badge.style.display = 'none';
330
+ }
331
+ }
332
+ } catch (e) {
333
+ console.error('Failed to acknowledge issue', e);
334
+ }
335
+ }
336
+
337
+ async function loadProjects() {
338
+ const container = document.getElementById('projects');
339
+ try {
340
+ const res = await fetch('/api/projects?with_stats=1', { headers: apiHeaders() });
341
+ if (!res.ok) {
342
+ container.innerHTML = renderError(null, 'loadProjects()');
343
+ return;
344
+ }
345
+ const projects = await res.json();
346
+ _dashboardProjectsById = Object.fromEntries(projects.map((project) => [project.id, project]));
347
+ if (!projects.length) {
348
+ container.innerHTML = '<div class="empty-state">No projects yet. Create one to get started.</div>';
349
+ return;
350
+ }
351
+
352
+ // Preserve quick-cmd input values before re-render
353
+ const savedInputs = {};
354
+ container.querySelectorAll('.quick-cmd-input').forEach(input => {
355
+ if (input.value) savedInputs[input.id] = input.value;
356
+ });
357
+ const savedBodies = {};
358
+ container.querySelectorAll('.quick-cmd-body').forEach(ta => {
359
+ if (ta.value) savedBodies[ta.id] = ta.value;
360
+ });
361
+ const focusedEl = document.activeElement;
362
+ const focusedId = (focusedEl?.classList.contains('quick-cmd-input') || focusedEl?.classList.contains('quick-cmd-body')) ? focusedEl.id : null;
363
+
364
+ container.innerHTML = projects.map(p => {
365
+ const s = p.stats || { agents: 0, running: 0, agentError: 0, issues: 0, openIssues: 0, userIssues: [] };
366
+ const link = `/projects/${p.id}`;
367
+ const access = getProjectAccessMeta(p);
368
+ const ownerName = displayProjectUser(p.owner);
369
+ const ownerRole = p.owner?.role === 'admin' ? 'Global Admin' : 'Project Member';
370
+ const memberCount = Number.isFinite(p.member_count) ? p.member_count : 0;
371
+ const toggleButton = p.can_manage
372
+ ? `<button onclick="event.stopPropagation();toggleProjectStatus('${p.id}','${p.status}')" title="${p.status === 'active' ? 'Pause' : 'Resume'}" style="background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1">${p.status === 'active' ? '⏸' : '▶'}</button>`
373
+ : '';
374
+ const userCount = s.userIssues?.length || 0;
375
+ const notifBadge = userCount > 0
376
+ ? `<span onclick="event.stopPropagation();window.location='${link}#issues'" style="background:var(--error);color:#fff;font-size:11px;padding:1px 8px;border-radius:10px;cursor:pointer;margin-left:6px" title="${userCount} issue(s) need your attention">${userCount}</span>`
377
+ : '';
378
+ const lastAct = _lastActivityMap[p.id];
379
+ const activityText = lastAct ? timeAgo(lastAct) : null;
380
+ const activityLine = activityText
381
+ ? `<div class="last-activity">Last activity: ${activityText}</div>`
382
+ : '';
383
+ const quickCmdBar = p.can_manage ? `
384
+ <div class="quick-cmd-bar" onclick="event.stopPropagation()">
385
+ <div class="quick-cmd-row">
386
+ <input type="text" class="quick-cmd-input" id="quick-cmd-${p.id}" placeholder="Quick command..." oninput="toggleQuickCmdBody('${p.id}')" onkeydown="if(event.key==='Enter'&&event.shiftKey){event.preventDefault();sendQuickCmd('${p.id}')}">
387
+ <button class="quick-cmd-btn" onclick="sendQuickCmd('${p.id}')" title="Send">&#9654;</button>
388
+ </div>
389
+ <textarea class="quick-cmd-body" id="quick-cmd-body-${p.id}" placeholder="Details (optional)..." rows="3" data-collapsed></textarea>
390
+ </div>
391
+ ` : '';
392
+ return `
393
+ <div class="card project-card" style="cursor:pointer" onclick="window.location='${link}'">
394
+ <div class="project-card-head">
395
+ <div class="project-card-main">
396
+ <strong class="project-card-title">${esc(p.name)}${notifBadge}</strong>
397
+ <div class="project-card-tags">
398
+ <span class="permission-badge permission-${access.tone}" title="${esc(access.summary)}">${access.badge}</span>
399
+ <span class="meta-chip" title="Project owner">
400
+ <span class="meta-chip-label">Owner</span>
401
+ <span>${esc(ownerName)}</span>
402
+ </span>
403
+ <span class="meta-chip" title="Project member count">
404
+ <span class="meta-chip-label">Members</span>
405
+ <span>${memberCount}</span>
406
+ </span>
407
+ </div>
408
+ </div>
409
+ <div style="display:flex;align-items:center;gap:6px">
410
+ <span class="status-badge status-${p.status}">${p.status}</span>
411
+ ${toggleButton}
412
+ </div>
413
+ </div>
414
+ <div class="project-card-note">
415
+ <span>${esc(access.detail)}</span>
416
+ <span>·</span>
417
+ <span>${esc(ownerRole)}</span>
418
+ </div>
419
+ <p class="project-card-desc">${esc(p.description || '')}</p>
420
+ <div class="project-card-stats">
421
+ <div style="display:flex;align-items:center;gap:4px;color:var(--text-secondary)">
422
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 8a3 3 0 100-6 3 3 0 000 6zm5 7c0-2.8-2.2-5-5-5s-5 2.2-5 5h10z"/></svg>
423
+ <span>${s.running} running</span>
424
+ <span style="opacity:0.5">/ ${s.agents}</span>
425
+ ${s.agentError > 0 ? `<span style="color:var(--error)">${s.agentError} error</span>` : ''}
426
+ </div>
427
+ <div style="display:flex;align-items:center;gap:4px;color:var(--text-secondary)">
428
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="2"/></svg>
429
+ <span>${s.openIssues} open</span>
430
+ <span style="opacity:0.5">/ ${s.issues}</span>
431
+ </div>
432
+ </div>
433
+ ${activityLine}
434
+ ${quickCmdBar}
435
+ </div>
436
+ `}).join('');
437
+
438
+ // Restore quick-cmd input values after re-render
439
+ for (const [id, value] of Object.entries(savedInputs)) {
440
+ const input = document.getElementById(id);
441
+ if (input) {
442
+ input.value = value;
443
+ // Also restore body textarea visibility
444
+ const pId = id.replace('quick-cmd-', '');
445
+ const body = document.getElementById('quick-cmd-body-' + pId);
446
+ if (body) body.removeAttribute('data-collapsed');
447
+ }
448
+ }
449
+ for (const [id, value] of Object.entries(savedBodies)) {
450
+ const ta = document.getElementById(id);
451
+ if (ta) ta.value = value;
452
+ }
453
+ if (focusedId) {
454
+ const el = document.getElementById(focusedId);
455
+ if (el) el.focus();
456
+ }
457
+ } catch (e) {
458
+ container.innerHTML = '<div class="empty-state"></div>';
459
+ container.querySelector('.empty-state').textContent = 'Error loading projects: ' + e.message;
460
+ }
461
+ }
462
+
463
+ function showCreateModal() { document.getElementById('createModal').classList.add('active'); }
464
+ function hideCreateModal() { document.getElementById('createModal').classList.remove('active'); }
465
+
466
+ async function createProject() {
467
+ const btn = document.querySelector('#createModal button[onclick="createProject()"]');
468
+ await withLoading(btn, async () => {
469
+ const task = document.getElementById('proj-task').value.trim();
470
+ const toolPath = document.getElementById('proj-cmd').value.trim() || 'cld';
471
+ if (!task) { showToast('Please describe the task to execute', 'error'); return; }
472
+
473
+ // Step 1: Call AI to generate project metadata
474
+ btn.textContent = 'Generating...';
475
+ const genRes = await fetch('/api/generate-project', {
476
+ method: 'POST', headers: apiHeaders(),
477
+ body: JSON.stringify({ description: task, tool_path: toolPath }),
478
+ });
479
+
480
+ let name, description, taskDesc, workDir, ctrlRole;
481
+ if (genRes.ok) {
482
+ const gen = await genRes.json();
483
+ name = gen.name || 'project';
484
+ description = gen.description || task.slice(0, 100);
485
+ taskDesc = gen.task_description || task;
486
+ workDir = gen.working_directory || null;
487
+ ctrlRole = gen.controller_role || null;
488
+ } else {
489
+ // Fallback if AI fails
490
+ name = task.slice(0, 30).replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase() || 'project';
491
+ description = task.slice(0, 100);
492
+ taskDesc = task;
493
+ }
494
+
495
+ // Step 2: Create the project
496
+ btn.textContent = 'Creating...';
497
+ const body = {
498
+ name,
499
+ description,
500
+ task_description: taskDesc,
501
+ command_template: toolPath,
502
+ working_directory: workDir,
503
+ controller_role: ctrlRole,
504
+ };
505
+
506
+ const res = await fetch('/api/projects', { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) });
507
+ if (res.ok) {
508
+ const proj = await res.json();
509
+ hideCreateModal();
510
+ window.location.href = '/projects/' + proj.id;
511
+ } else {
512
+ const err = await res.json();
513
+ showToast(err.error || 'Failed to create', 'error');
514
+ }
515
+ });
516
+ }
517
+
518
+ async function toggleProjectStatus(projectId, currentStatus) {
519
+ if (!_dashboardProjectsById[projectId]?.can_manage) {
520
+ showToast('Insufficient permission to update project status', 'error');
521
+ return;
522
+ }
523
+ const newStatus = currentStatus === 'active' ? 'paused' : 'active';
524
+ try {
525
+ const res = await fetch(`/api/projects/${projectId}`, {
526
+ method: 'PUT', headers: apiHeaders(), body: JSON.stringify({ status: newStatus })
527
+ });
528
+ if (res.ok) { showToast('Status updated', 'success'); loadProjects(); }
529
+ else showToast('Failed to update status', 'error');
530
+ } catch { showToast('Network error', 'error'); }
531
+ }
532
+
533
+ function toggleQuickCmdBody(projectId) {
534
+ const input = document.getElementById('quick-cmd-' + projectId);
535
+ const body = document.getElementById('quick-cmd-body-' + projectId);
536
+ if (!body) return;
537
+ if (input.value.trim()) {
538
+ body.removeAttribute('data-collapsed');
539
+ } else {
540
+ body.setAttribute('data-collapsed', '');
541
+ }
542
+ }
543
+
544
+ async function sendQuickCmd(projectId) {
545
+ if (!_dashboardProjectsById[projectId]?.can_manage) {
546
+ showToast('Insufficient permission to create task', 'error');
547
+ return;
548
+ }
549
+ const input = document.getElementById('quick-cmd-' + projectId);
550
+ const bodyEl = document.getElementById('quick-cmd-body-' + projectId);
551
+ const msg = input.value.trim();
552
+ if (!msg) return;
553
+ const bodyText = bodyEl ? bodyEl.value.trim() : '';
554
+ const btn = input.parentElement.querySelector('.quick-cmd-btn');
555
+ btn.disabled = true;
556
+ btn.textContent = '...';
557
+ try {
558
+ // Find controller agent for this project
559
+ const agentsRes = await fetch(`/api/projects/${projectId}/agents`, { headers: apiHeaders() });
560
+ if (!agentsRes.ok) { showToast('Failed to find controller', 'error'); return; }
561
+ const agents = await agentsRes.json();
562
+ const controller = agents.find(a => a.is_controller);
563
+ if (!controller) { showToast('No controller agent found', 'error'); return; }
564
+
565
+ // Create issue assigned to controller
566
+ const res = await fetch(`/api/projects/${projectId}/issues`, {
567
+ method: 'POST', headers: apiHeaders(),
568
+ body: JSON.stringify({ title: msg, body: bodyText || msg, created_by: 'user', assigned_to: controller.id })
569
+ });
570
+ if (res.ok) {
571
+ // Re-query DOM elements: loadProjects() may have re-rendered during await,
572
+ // replacing the original elements with new ones
573
+ const curInput = document.getElementById('quick-cmd-' + projectId);
574
+ const curBody = document.getElementById('quick-cmd-body-' + projectId);
575
+ if (curInput) curInput.value = '';
576
+ if (curBody) { curBody.value = ''; curBody.setAttribute('data-collapsed', ''); }
577
+ showToast('Issue created', 'success');
578
+ } else {
579
+ const err = await res.json();
580
+ showToast(err.error || 'Failed', 'error');
581
+ }
582
+ } catch (e) {
583
+ showToast('Network error', 'error');
584
+ } finally {
585
+ // Re-query button in case DOM was re-rendered
586
+ const curBtn = document.getElementById('quick-cmd-' + projectId)?.parentElement?.querySelector('.quick-cmd-btn');
587
+ if (curBtn) { curBtn.disabled = false; curBtn.innerHTML = '&#9654;'; }
588
+ }
589
+ }
590
+
591
+ // ─── Usage by Project Chart ───
592
+
593
+ let _usagePeriod = 'day';
594
+ const _projectColors = ['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#39d2c0','#ff7b72','#79c0ff','#7ee787','#e3b341'];
595
+
596
+ function switchUsagePeriod(period) {
597
+ _usagePeriod = period;
598
+ document.querySelectorAll('.usage-period-btn').forEach(btn => {
599
+ btn.classList.toggle('active', btn.dataset.period === period);
600
+ });
601
+ loadUsageByProject();
602
+ }
603
+
604
+ async function loadUsageByProject() {
605
+ try {
606
+ const res = await fetch(`/api/dashboard/usage-by-project?period=${_usagePeriod}`, { headers: apiHeaders() });
607
+ if (!res.ok) return;
608
+ const data = await res.json();
609
+
610
+ const panel = document.getElementById('usage-by-project-panel');
611
+ const container = document.getElementById('usage-by-project-chart');
612
+ if (!data.time_buckets || !data.time_buckets.length) {
613
+ panel.style.display = 'none';
614
+ return;
615
+ }
616
+ panel.style.display = '';
617
+
618
+ const projects = data.projects;
619
+ const buckets = data.time_buckets;
620
+ const chartData = data.data;
621
+
622
+ // Calculate max stacked cost per bucket
623
+ let maxCost = 0.001;
624
+ for (const t of buckets) {
625
+ let sum = 0;
626
+ for (const p of projects) {
627
+ sum += (chartData[t] && chartData[t][p.id]) ? chartData[t][p.id].cost : 0;
628
+ }
629
+ if (sum > maxCost) maxCost = sum;
630
+ }
631
+
632
+ const W = 600, H = 200;
633
+ const PAD_L = 50, PAD_R = 16, PAD_T = 12, PAD_B = 32;
634
+ const cw = W - PAD_L - PAD_R, ch = H - PAD_T - PAD_B;
635
+ const n = buckets.length;
636
+ const barW = Math.max(2, (cw / n) * 0.7);
637
+ const gap = cw / n;
638
+
639
+ // Y-axis
640
+ const yLabels = [0, maxCost / 2, maxCost].map(v => {
641
+ const y = PAD_T + ch - (v / maxCost) * ch;
642
+ return `<text x="${PAD_L - 6}" y="${y + 3}" text-anchor="end" fill="var(--text-secondary)" font-size="9">$${v < 0.01 ? v.toFixed(4) : v < 1 ? v.toFixed(3) : v.toFixed(2)}</text>
643
+ <line x1="${PAD_L}" y1="${y}" x2="${W - PAD_R}" y2="${y}" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>`;
644
+ }).join('');
645
+
646
+ // X-axis
647
+ const step = Math.max(1, Math.floor(n / 6));
648
+ const xLabels = buckets.map((d, i) => {
649
+ if (i % step !== 0 && i !== n - 1) return '';
650
+ const x = PAD_L + i * gap + gap / 2;
651
+ const label = d.length > 10 ? d.slice(5) : d.slice(5);
652
+ return `<text x="${x}" y="${H - 4}" text-anchor="middle" fill="var(--text-secondary)" font-size="8">${label}</text>`;
653
+ }).join('');
654
+
655
+ // Stacked bars
656
+ let bars = '';
657
+ for (let i = 0; i < n; i++) {
658
+ const t = buckets[i];
659
+ const x = PAD_L + i * gap + (gap - barW) / 2;
660
+ let yOffset = 0;
661
+ for (let j = 0; j < projects.length; j++) {
662
+ const p = projects[j];
663
+ const entry = chartData[t] && chartData[t][p.id];
664
+ const cost = entry ? entry.cost : 0;
665
+ if (cost <= 0) continue;
666
+ const barH = (cost / maxCost) * ch;
667
+ const y = PAD_T + ch - yOffset - barH;
668
+ const color = _projectColors[j % _projectColors.length];
669
+ bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" fill="${color}" opacity="0.85" rx="1">
670
+ <title>${esc(p.name)} ${t}: $${cost.toFixed(4)}</title>
671
+ </rect>`;
672
+ yOffset += barH;
673
+ }
674
+ }
675
+
676
+ // Legend
677
+ const legend = projects.map((p, i) => {
678
+ const color = _projectColors[i % _projectColors.length];
679
+ const name = p.name.length > 20 ? p.name.slice(0, 19) + '…' : p.name;
680
+ return `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;font-size:11px;color:var(--text-secondary)">
681
+ <span style="width:10px;height:10px;background:${color};border-radius:2px;display:inline-block"></span>${esc(name)}
682
+ </span>`;
683
+ }).join('');
684
+
685
+ container.innerHTML = `<svg width="100%" viewBox="0 0 ${W} ${H}" style="display:block">
686
+ ${yLabels}${xLabels}${bars}
687
+ </svg>
688
+ <div style="margin-top:6px;line-height:1.8">${legend}</div>`;
689
+ } catch (e) {
690
+ console.error('Failed to load usage by project', e);
691
+ }
692
+ }
693
+
694
+ // Initial load: summary + notifications + projects + usage chart in parallel
695
+ async function loadDashboard() {
696
+ await Promise.all([loadDashboardSummary(), loadNotifications(), loadProjects(), loadUsageByProject()]);
697
+ }
698
+
699
+ loadDashboard();
700
+ // Polling: 10s for lightweight data, 30s for full project list, 60s for usage chart
701
+ setInterval(() => { loadDashboardSummary(); loadNotifications(); }, 10000);
702
+ setInterval(loadProjects, 30000);
703
+ setInterval(loadUsageByProject, 60000);
704
+ window.addEventListener('argus:user-ready', () => { loadProjects(); });
705
+
706
+ // ─── Floating Issue Panel ───
707
+
708
+ let _panelIssueId = null;
709
+ let _panelAgents = [];
710
+
711
+ function openIssuePanel(issueId) {
712
+ _panelIssueId = issueId;
713
+ document.getElementById('issueDetailModal').classList.add('active');
714
+ loadIssuePanel(issueId);
715
+ }
716
+
717
+ async function openIssuePanelByProject(projectId, issueNumber) {
718
+ document.getElementById('issueDetailModal').classList.add('active');
719
+ document.getElementById('issueDetailContent').innerHTML = renderLoading('Loading issue...');
720
+ try {
721
+ const res = await fetch(`/api/projects/${projectId}/issues/number/${issueNumber}`, { headers: apiHeaders() });
722
+ if (!res.ok) { document.getElementById('issueDetailContent').innerHTML = renderError({ status: res.status }); return; }
723
+ const data = await res.json();
724
+ _panelIssueId = data.id;
725
+ loadIssuePanel(data.id);
726
+ } catch (e) {
727
+ document.getElementById('issueDetailContent').innerHTML = renderError(e, 'openIssuePanelByProject(\'' + projectId + '\',' + issueNumber + ')');
728
+ }
729
+ }
730
+
731
+ function closeIssuePanel() {
732
+ document.getElementById('issueDetailModal').classList.remove('active');
733
+ _panelIssueId = null;
734
+ }
735
+
736
+ async function loadIssuePanel(issueId) {
737
+ try {
738
+ const res = await fetch(`/api/issues/${issueId}`, { headers: apiHeaders() });
739
+ if (!res.ok) { document.getElementById('issueDetailContent').innerHTML = renderError({ status: res.status }); return; }
740
+ const issue = await res.json();
741
+
742
+ // Load agents for this project
743
+ try {
744
+ const agentsRes = await fetch(`/api/projects/${issue.project_id}/agents`, { headers: apiHeaders() });
745
+ if (agentsRes.ok) _panelAgents = await agentsRes.json();
746
+ } catch {}
747
+
748
+ IssueRenderer.render(issue, _panelAgents, document.getElementById('issueDetailContent'), {
749
+ reload: function() { loadIssuePanel(_panelIssueId); },
750
+ onAfterAction: function() { loadNotifications(); },
751
+ });
752
+ } catch (e) {
753
+ document.getElementById('issueDetailContent').innerHTML = renderError(e, 'loadIssuePanel(\'' + issueId + '\')');
754
+ }
755
+ }
756
+
757
+ // Listen for events from all projects and refresh dashboard on changes
758
+ (async function setupDashboardWS() {
759
+ try {
760
+ const res = await fetch('/api/projects', { headers: apiHeaders() });
761
+ if (!res.ok) return;
762
+ const projects = await res.json();
763
+ for (const p of projects) {
764
+ const ev = connectProjectEvents(p.id);
765
+ ev.on('*', function() {
766
+ loadDashboardSummary();
767
+ loadNotifications();
768
+ loadProjects();
769
+ });
770
+ }
771
+ } catch {}
772
+ })();