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.
- package/.claude/settings.local.json +28 -0
- package/dist/app.d.ts +10 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +121 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/db/database.d.ts +5 -0
- package/dist/db/database.d.ts.map +1 -0
- package/dist/db/database.js +39 -0
- package/dist/db/database.js.map +1 -0
- package/dist/db/schema.d.ts +3 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +621 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +9 -0
- package/dist/logger.js.map +1 -0
- package/dist/middleware/auth.d.ts +13 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +733 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/agents.d.ts +3 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +1058 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/issues.d.ts +4 -0
- package/dist/routes/issues.d.ts.map +1 -0
- package/dist/routes/issues.js +946 -0
- package/dist/routes/issues.js.map +1 -0
- package/dist/routes/knowledge.d.ts +3 -0
- package/dist/routes/knowledge.d.ts.map +1 -0
- package/dist/routes/knowledge.js +117 -0
- package/dist/routes/knowledge.js.map +1 -0
- package/dist/routes/memories.d.ts +3 -0
- package/dist/routes/memories.d.ts.map +1 -0
- package/dist/routes/memories.js +115 -0
- package/dist/routes/memories.js.map +1 -0
- package/dist/routes/messages.d.ts +3 -0
- package/dist/routes/messages.d.ts.map +1 -0
- package/dist/routes/messages.js +130 -0
- package/dist/routes/messages.js.map +1 -0
- package/dist/routes/projects.d.ts +3 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +754 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/templates.d.ts +3 -0
- package/dist/routes/templates.d.ts.map +1 -0
- package/dist/routes/templates.js +117 -0
- package/dist/routes/templates.js.map +1 -0
- package/dist/routes/ui.d.ts +3 -0
- package/dist/routes/ui.d.ts.map +1 -0
- package/dist/routes/ui.js +38 -0
- package/dist/routes/ui.js.map +1 -0
- package/dist/services/agent-hierarchy.d.ts +14 -0
- package/dist/services/agent-hierarchy.d.ts.map +1 -0
- package/dist/services/agent-hierarchy.js +58 -0
- package/dist/services/agent-hierarchy.js.map +1 -0
- package/dist/services/agent-issue-batch.d.ts +17 -0
- package/dist/services/agent-issue-batch.d.ts.map +1 -0
- package/dist/services/agent-issue-batch.js +57 -0
- package/dist/services/agent-issue-batch.js.map +1 -0
- package/dist/services/controller.d.ts +4 -0
- package/dist/services/controller.d.ts.map +1 -0
- package/dist/services/controller.js +237 -0
- package/dist/services/controller.js.map +1 -0
- package/dist/services/langgraph-runner.d.ts +33 -0
- package/dist/services/langgraph-runner.d.ts.map +1 -0
- package/dist/services/langgraph-runner.js +478 -0
- package/dist/services/langgraph-runner.js.map +1 -0
- package/dist/services/orchestrator.d.ts +9 -0
- package/dist/services/orchestrator.d.ts.map +1 -0
- package/dist/services/orchestrator.js +116 -0
- package/dist/services/orchestrator.js.map +1 -0
- package/dist/services/pre-controller.d.ts +7 -0
- package/dist/services/pre-controller.d.ts.map +1 -0
- package/dist/services/pre-controller.js +101 -0
- package/dist/services/pre-controller.js.map +1 -0
- package/dist/services/process-manager.d.ts +67 -0
- package/dist/services/process-manager.d.ts.map +1 -0
- package/dist/services/process-manager.js +938 -0
- package/dist/services/process-manager.js.map +1 -0
- package/dist/services/project-permissions.d.ts +84 -0
- package/dist/services/project-permissions.d.ts.map +1 -0
- package/dist/services/project-permissions.js +129 -0
- package/dist/services/project-permissions.js.map +1 -0
- package/dist/services/scheduler.d.ts +6 -0
- package/dist/services/scheduler.d.ts.map +1 -0
- package/dist/services/scheduler.js +300 -0
- package/dist/services/scheduler.js.map +1 -0
- package/dist/services/system-prompt.d.ts +3 -0
- package/dist/services/system-prompt.d.ts.map +1 -0
- package/dist/services/system-prompt.js +285 -0
- package/dist/services/system-prompt.js.map +1 -0
- package/dist/services/terminal.d.ts +18 -0
- package/dist/services/terminal.d.ts.map +1 -0
- package/dist/services/terminal.js +222 -0
- package/dist/services/terminal.js.map +1 -0
- package/dist/services/websocket.d.ts +15 -0
- package/dist/services/websocket.d.ts.map +1 -0
- package/dist/services/websocket.js +204 -0
- package/dist/services/websocket.js.map +1 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/env.ini +18 -0
- package/package.json +38 -0
- package/project_id +0 -0
- package/public/admin-users.html +188 -0
- package/public/agent.html +199 -0
- package/public/css/issues.css +275 -0
- package/public/css/style.css +1299 -0
- package/public/index.html +166 -0
- package/public/issue.html +76 -0
- package/public/js/agent.js +19 -0
- package/public/js/common.js +735 -0
- package/public/js/dashboard.js +772 -0
- package/public/js/files-panel.js +703 -0
- package/public/js/interactive-terminal.js +201 -0
- package/public/js/issue-renderer.js +559 -0
- package/public/js/issue.js +57 -0
- package/public/js/project.js +2425 -0
- package/public/js/terminal.js +564 -0
- package/public/project.html +430 -0
- package/public/terminal.html +67 -0
- package/public/vendor/marked.js +74 -0
- package/public/vendor/xterm-addon-fit.js +2 -0
- package/public/vendor/xterm.css +209 -0
- package/public/vendor/xterm.js +2 -0
- package/send_message_and_update_issue.js +65 -0
- package/tsconfig.json +19 -0
- package/update_round2_and_create_round3.js +284 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
// ─── User Menu ───
|
|
2
|
+
|
|
3
|
+
let _currentUser = null;
|
|
4
|
+
|
|
5
|
+
async function initUserMenu() {
|
|
6
|
+
try {
|
|
7
|
+
const res = await fetch('/api/auth/me', { cache: 'no-cache' });
|
|
8
|
+
if (!res.ok) {
|
|
9
|
+
console.warn('[Argus] initUserMenu: /api/auth/me returned', res.status, '— avatar will not show');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
_currentUser = await res.json();
|
|
13
|
+
window.dispatchEvent(new CustomEvent('argus:user-ready', { detail: _currentUser }));
|
|
14
|
+
} catch (e) {
|
|
15
|
+
console.warn('[Argus] initUserMenu: fetch failed —', e.message || e);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Append user menu to .header-right if it exists, otherwise to header
|
|
20
|
+
const headerRight = document.querySelector('.header-right') || document.querySelector('header');
|
|
21
|
+
if (!headerRight) return;
|
|
22
|
+
|
|
23
|
+
const menu = document.createElement('div');
|
|
24
|
+
menu.className = 'user-menu';
|
|
25
|
+
const initials = (_currentUser.display_name || _currentUser.username || '?').charAt(0).toUpperCase();
|
|
26
|
+
menu.innerHTML = `
|
|
27
|
+
<button class="user-menu-btn" title="${esc(_currentUser.display_name || _currentUser.username)}">${esc(initials)}</button>
|
|
28
|
+
<div class="user-menu-dropdown" id="user-menu-dropdown">
|
|
29
|
+
<div class="user-menu-info">
|
|
30
|
+
<div class="name">${esc(_currentUser.display_name || _currentUser.username)}</div>
|
|
31
|
+
<div class="role">${esc(_currentUser.role)}</div>
|
|
32
|
+
</div>
|
|
33
|
+
${_currentUser.role === 'admin' ? '<a href="/admin/users">User Management</a>' : ''}
|
|
34
|
+
<a href="/change-password">Change Password</a>
|
|
35
|
+
<div class="divider"></div>
|
|
36
|
+
<button class="menu-item" onclick="doLogout()">Logout</button>
|
|
37
|
+
</div>
|
|
38
|
+
`;
|
|
39
|
+
headerRight.appendChild(menu);
|
|
40
|
+
|
|
41
|
+
const btn = menu.querySelector('.user-menu-btn');
|
|
42
|
+
const dropdown = menu.querySelector('.user-menu-dropdown');
|
|
43
|
+
btn.addEventListener('click', (e) => {
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
dropdown.classList.toggle('open');
|
|
46
|
+
});
|
|
47
|
+
document.addEventListener('click', () => dropdown.classList.remove('open'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function doLogout() {
|
|
51
|
+
await fetch('/api/auth/logout', { method: 'POST' });
|
|
52
|
+
window.location.href = '/login';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
document.addEventListener('DOMContentLoaded', initUserMenu);
|
|
56
|
+
|
|
57
|
+
// ─── Shared utility functions ───
|
|
58
|
+
|
|
59
|
+
function esc(s) {
|
|
60
|
+
const d = document.createElement('div');
|
|
61
|
+
d.textContent = s || '';
|
|
62
|
+
return d.innerHTML;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function apiHeaders() {
|
|
66
|
+
return { 'Content-Type': 'application/json' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function withLoading(btn, asyncFn) {
|
|
70
|
+
if (!btn || btn.disabled) return;
|
|
71
|
+
const originalText = btn.textContent;
|
|
72
|
+
btn.disabled = true;
|
|
73
|
+
btn.textContent = originalText + '…';
|
|
74
|
+
try {
|
|
75
|
+
await asyncFn();
|
|
76
|
+
} finally {
|
|
77
|
+
btn.disabled = false;
|
|
78
|
+
btn.textContent = originalText;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseServerDate(dateStr) {
|
|
83
|
+
if (!dateStr) return null;
|
|
84
|
+
const value = String(dateStr).trim();
|
|
85
|
+
if (!value) return null;
|
|
86
|
+
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/.test(value) ? value : value.replace(' ', 'T') + 'Z';
|
|
87
|
+
const date = new Date(normalized);
|
|
88
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatLocalDateTime(dateStr) {
|
|
92
|
+
const date = parseServerDate(dateStr);
|
|
93
|
+
if (!date) return '-';
|
|
94
|
+
return date.toLocaleString([], {
|
|
95
|
+
year: 'numeric',
|
|
96
|
+
month: '2-digit',
|
|
97
|
+
day: '2-digit',
|
|
98
|
+
hour: '2-digit',
|
|
99
|
+
minute: '2-digit',
|
|
100
|
+
second: '2-digit',
|
|
101
|
+
hour12: false,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatLocalTime(dateStr) {
|
|
106
|
+
const date = parseServerDate(dateStr);
|
|
107
|
+
if (!date) return '-';
|
|
108
|
+
return date.toLocaleTimeString([], {
|
|
109
|
+
hour: '2-digit',
|
|
110
|
+
minute: '2-digit',
|
|
111
|
+
second: '2-digit',
|
|
112
|
+
hour12: false,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function timeAgo(dateStr) {
|
|
117
|
+
if (!dateStr) return '-';
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const thenDate = parseServerDate(dateStr);
|
|
120
|
+
if (!thenDate) return '-';
|
|
121
|
+
const then = thenDate.getTime();
|
|
122
|
+
const diff = now - then;
|
|
123
|
+
if (diff < 0) return 'just now';
|
|
124
|
+
const mins = Math.floor(diff / 60000);
|
|
125
|
+
if (mins < 1) return 'just now';
|
|
126
|
+
if (mins < 60) return mins + 'm ago';
|
|
127
|
+
const hours = Math.floor(mins / 60);
|
|
128
|
+
if (hours < 24) return hours + 'h ago';
|
|
129
|
+
const days = Math.floor(hours / 24);
|
|
130
|
+
return days + 'd ago';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function priorityBadge(p) {
|
|
134
|
+
if (p >= 10) return '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:rgba(220,50,47,0.15);color:var(--error)">USER</span>';
|
|
135
|
+
if (p >= 5) return '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:rgba(181,137,0,0.15);color:var(--warning)">CTRL</span>';
|
|
136
|
+
return '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:rgba(88,110,117,0.15);color:var(--text-secondary)">AGENT</span>';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// nameOf resolves agent IDs to names. Uses the global `agentsData` array if available.
|
|
140
|
+
function nameOf(id) {
|
|
141
|
+
if (id === 'user') return 'User';
|
|
142
|
+
if (id === 'all') return 'All';
|
|
143
|
+
if (typeof agentsData !== 'undefined') {
|
|
144
|
+
const a = agentsData.find(x => x.id === id);
|
|
145
|
+
if (a) return a.name;
|
|
146
|
+
}
|
|
147
|
+
return (id || '').slice(0, 8);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Loading & Error helpers ───
|
|
151
|
+
|
|
152
|
+
function renderLoading(text, small) {
|
|
153
|
+
var cls = 'loading-spinner' + (small ? ' small' : '');
|
|
154
|
+
return '<div class="' + cls + '"><div class="spinner"></div>' + esc(text || 'Loading...') + '</div>';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderError(err, onRetryId) {
|
|
158
|
+
var msg = 'Failed to load';
|
|
159
|
+
if (err) {
|
|
160
|
+
if (typeof err === 'string') msg = err;
|
|
161
|
+
else if (err.status === 0 || err.message === 'Failed to fetch') msg = 'Network error, please check your connection';
|
|
162
|
+
else if (err.status >= 500) msg = 'Server error, please try again later';
|
|
163
|
+
else if (err.status >= 400) msg = 'Request failed (resource not found or no permission)';
|
|
164
|
+
else if (err.message) msg = err.message;
|
|
165
|
+
}
|
|
166
|
+
var retryHtml = onRetryId ? '<button class="retry-btn" onclick="' + onRetryId + '">Retry</button>' : '';
|
|
167
|
+
return '<div class="error-retry"><div class="error-msg">' + esc(msg) + '</div>' + retryHtml + '</div>';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderCollapsibleText(text, options) {
|
|
171
|
+
const value = text == null ? '' : String(text);
|
|
172
|
+
const opts = options || {};
|
|
173
|
+
const previewChars = Number.isFinite(opts.previewChars) ? opts.previewChars : 120;
|
|
174
|
+
const className = opts.className ? ' ' + opts.className : '';
|
|
175
|
+
const styleAttr = opts.style ? ` style="${opts.style}"` : '';
|
|
176
|
+
const expandLabel = opts.expandLabel || 'Expand';
|
|
177
|
+
const collapseLabel = opts.collapseLabel || 'Collapse';
|
|
178
|
+
const contentHtml = `<span class="collapsible-text__content">${esc(value)}</span>`;
|
|
179
|
+
const needsCollapse = value.length > previewChars || /[\r\n]/.test(value);
|
|
180
|
+
|
|
181
|
+
if (!needsCollapse) {
|
|
182
|
+
return `<span class="collapsible-text${className}"${styleAttr}>${contentHtml}</span>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return `<button type="button" class="collapsible-text is-collapsible${className}" data-collapsible-text data-expanded="false" data-expand-label="${esc(expandLabel)}" data-collapse-label="${esc(collapseLabel)}" aria-expanded="false" title="Click to expand"${styleAttr}>${contentHtml}<span class="collapsible-text__hint" aria-hidden="true">${esc(expandLabel)}</span></button>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
document.addEventListener('click', function(e) {
|
|
189
|
+
const trigger = e.target.closest('[data-collapsible-text]');
|
|
190
|
+
if (!trigger) return;
|
|
191
|
+
const expanded = trigger.getAttribute('data-expanded') === 'true';
|
|
192
|
+
const nextExpanded = !expanded;
|
|
193
|
+
trigger.setAttribute('data-expanded', nextExpanded ? 'true' : 'false');
|
|
194
|
+
trigger.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false');
|
|
195
|
+
trigger.title = nextExpanded ? 'Click to collapse' : 'Click to expand';
|
|
196
|
+
const hint = trigger.querySelector('.collapsible-text__hint');
|
|
197
|
+
if (hint) {
|
|
198
|
+
hint.textContent = trigger.getAttribute(nextExpanded ? 'data-collapse-label' : 'data-expand-label') || (nextExpanded ? 'Collapse' : 'Expand');
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Toast notifications
|
|
203
|
+
function showToast(message, type) {
|
|
204
|
+
type = type || 'info';
|
|
205
|
+
let container = document.getElementById('toast-container');
|
|
206
|
+
if (!container) {
|
|
207
|
+
container = document.createElement('div');
|
|
208
|
+
container.id = 'toast-container';
|
|
209
|
+
container.className = 'toast-container';
|
|
210
|
+
document.body.appendChild(container);
|
|
211
|
+
}
|
|
212
|
+
const toast = document.createElement('div');
|
|
213
|
+
toast.className = 'toast toast-' + type;
|
|
214
|
+
toast.textContent = message;
|
|
215
|
+
toast.onclick = function() { toast.remove(); };
|
|
216
|
+
container.appendChild(toast);
|
|
217
|
+
setTimeout(function() { toast.remove(); }, 3000);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function showConfirm(message) {
|
|
221
|
+
return new Promise(resolve => {
|
|
222
|
+
let overlay = document.getElementById('confirm-overlay');
|
|
223
|
+
if (!overlay) {
|
|
224
|
+
overlay = document.createElement('div');
|
|
225
|
+
overlay.id = 'confirm-overlay';
|
|
226
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999';
|
|
227
|
+
document.body.appendChild(overlay);
|
|
228
|
+
}
|
|
229
|
+
overlay.style.display = 'flex';
|
|
230
|
+
overlay.innerHTML = `<div style="background:var(--bg-secondary,#1e1e2e);border:1px solid var(--border,#333);border-radius:8px;padding:20px;max-width:400px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.3)">
|
|
231
|
+
<div style="margin-bottom:16px;font-size:14px;line-height:1.5">${esc(message)}</div>
|
|
232
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
233
|
+
<button class="btn btn-sm" id="confirm-cancel" style="padding:6px 16px">Cancel</button>
|
|
234
|
+
<button class="btn btn-sm" id="confirm-ok" style="padding:6px 16px;background:var(--accent);color:#fff">OK</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>`;
|
|
237
|
+
const close = val => { overlay.style.display = 'none'; resolve(val); };
|
|
238
|
+
document.getElementById('confirm-ok').onclick = () => close(true);
|
|
239
|
+
document.getElementById('confirm-cancel').onclick = () => close(false);
|
|
240
|
+
overlay.onclick = e => { if (e.target === overlay) close(false); };
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Keyboard shortcuts ───
|
|
245
|
+
|
|
246
|
+
// ESC to close modals
|
|
247
|
+
document.addEventListener('keydown', function(e) {
|
|
248
|
+
if (e.key === 'Escape') {
|
|
249
|
+
// Close the topmost open modal-overlay
|
|
250
|
+
const modals = document.querySelectorAll('.modal-overlay.active');
|
|
251
|
+
if (modals.length > 0) {
|
|
252
|
+
modals[modals.length - 1].classList.remove('active');
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Close drawer if open
|
|
257
|
+
const drawer = document.getElementById('drawer');
|
|
258
|
+
if (drawer && drawer.classList.contains('open')) {
|
|
259
|
+
closeDrawer();
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Click overlay background to close modal
|
|
266
|
+
document.addEventListener('click', function(e) {
|
|
267
|
+
if (e.target.classList.contains('modal-overlay') && e.target.classList.contains('active')) {
|
|
268
|
+
e.target.classList.remove('active');
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Ctrl+Enter to submit forms
|
|
273
|
+
document.addEventListener('keydown', function(e) {
|
|
274
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
275
|
+
const el = e.target;
|
|
276
|
+
// Check if inside a modal — find the submit button
|
|
277
|
+
const modal = el.closest('.modal-overlay.active');
|
|
278
|
+
if (modal) {
|
|
279
|
+
const submitBtn = modal.querySelector('.btn-primary');
|
|
280
|
+
if (submitBtn) { submitBtn.click(); e.preventDefault(); return; }
|
|
281
|
+
}
|
|
282
|
+
// Comment input on issue page
|
|
283
|
+
if (el.id === 'ir-comment-input') {
|
|
284
|
+
const submitBtn = document.querySelector('button[onclick="IssueRenderer.addComment()"]');
|
|
285
|
+
if (submitBtn) { submitBtn.click(); e.preventDefault(); }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ─── Avatars — GitHub-style identicon based on name hash ───
|
|
291
|
+
const AVATAR_COLORS = ['#e06c75','#98c379','#e5c07b','#61afef','#c678dd','#56b6c2','#be5046','#d19a66','#7ec8e3','#b5bd68','#cc6666','#8abeb7','#f0c674','#81a2be','#b294bb'];
|
|
292
|
+
|
|
293
|
+
function hashCode(s) {
|
|
294
|
+
let h = 0;
|
|
295
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
296
|
+
return Math.abs(h);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function avatarSvg(name, size) {
|
|
300
|
+
size = size || 28;
|
|
301
|
+
if (name === 'user' || name === 'User') {
|
|
302
|
+
// User: fixed person silhouette
|
|
303
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 5 5"><rect width="5" height="5" rx="0.5" fill="#268bd2"/><circle cx="2.5" cy="1.8" r="0.9" fill="rgba(255,255,255,0.9)"/><ellipse cx="2.5" cy="4.2" rx="1.5" ry="1.2" fill="rgba(255,255,255,0.9)"/></svg>`;
|
|
304
|
+
}
|
|
305
|
+
if (name === 'all' || name === 'All') {
|
|
306
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 5 5"><rect width="5" height="5" rx="0.5" fill="#859900"/><circle cx="1.5" cy="1.8" r="0.7" fill="rgba(255,255,255,0.85)"/><circle cx="3.5" cy="1.8" r="0.7" fill="rgba(255,255,255,0.85)"/><ellipse cx="2.5" cy="4" rx="2" ry="1" fill="rgba(255,255,255,0.85)"/></svg>`;
|
|
307
|
+
}
|
|
308
|
+
// GitHub-style 5x5 symmetric identicon
|
|
309
|
+
const h = hashCode(name || '?');
|
|
310
|
+
const color = AVATAR_COLORS[h % AVATAR_COLORS.length];
|
|
311
|
+
// Generate 15 bits for the left half + center column of 5x5 grid (mirrored)
|
|
312
|
+
let bits = h;
|
|
313
|
+
let cells = '';
|
|
314
|
+
for (let y = 0; y < 5; y++) {
|
|
315
|
+
for (let x = 0; x < 3; x++) {
|
|
316
|
+
if ((bits >> (y * 3 + x)) & 1) {
|
|
317
|
+
cells += `<rect x="${x}" y="${y}" width="1" height="1" fill="${color}"/>`;
|
|
318
|
+
if (x < 2) cells += `<rect x="${4-x}" y="${y}" width="1" height="1" fill="${color}"/>`;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return `<svg width="${size}" height="${size}" viewBox="-0.5 -0.5 6 6"><rect x="-0.5" y="-0.5" width="6" height="6" rx="0.8" fill="var(--selected-bg, #eee)"/>${cells}</svg>`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Themes
|
|
326
|
+
const themes = {
|
|
327
|
+
'github-dark': { bg:'#0d1117', fg:'#e6edf3', headerBg:'#161b22', drawerBg:'#161b22', border:'#30363d', textSecondary:'#8b949e', accent:'#58a6ff', success:'#3fb950', warning:'#d29922', error:'#f85149', selectedBg:'#21262d' },
|
|
328
|
+
'dracula': { bg:'#282a36', fg:'#f8f8f2', headerBg:'#21222c', drawerBg:'#21222c', border:'#44475a', textSecondary:'#6272a4', accent:'#8be9fd', success:'#50fa7b', warning:'#f1fa8c', error:'#ff5555', selectedBg:'#282a36' },
|
|
329
|
+
'nord-dark': { bg:'#2e3440', fg:'#d8dee9', headerBg:'#3b4252', drawerBg:'#3b4252', border:'#4c566a', textSecondary:'#81a1c1', accent:'#88c0d0', success:'#a3be8c', warning:'#ebcb8b', error:'#bf616a', selectedBg:'#2e3440' },
|
|
330
|
+
'nord-light': { bg:'#ECEFF4', fg:'#2E3440', headerBg:'#E5E9F0', drawerBg:'#E5E9F0', border:'#D8DEE9', textSecondary:'#4C566A', accent:'#5E81AC', success:'#A3BE8C', warning:'#EBCB8B', error:'#BF616A', selectedBg:'#D8DEE9' },
|
|
331
|
+
'monokai': { bg:'#272822', fg:'#f8f8f2', headerBg:'#1e1f1c', drawerBg:'#1e1f1c', border:'#3e3d32', textSecondary:'#75715e', accent:'#66d9ef', success:'#a6e22e', warning:'#e6db74', error:'#f92672', selectedBg:'#272822' },
|
|
332
|
+
'solarized-dark': { bg:'#002b36', fg:'#839496', headerBg:'#073642', drawerBg:'#073642', border:'#586e75', textSecondary:'#657b83', accent:'#268bd2', success:'#859900', warning:'#b58900', error:'#dc322f', selectedBg:'#002b36' },
|
|
333
|
+
'solarized-light': { bg:'#fdf6e3', fg:'#073642', headerBg:'#eee8d5', drawerBg:'#eee8d5', border:'#c9bba3', textSecondary:'#586e75', accent:'#268bd2', success:'#859900', warning:'#b58900', error:'#dc322f', selectedBg:'#e8dcc8' },
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
function applyTheme(name) {
|
|
337
|
+
// Backward compat: 'nord' was renamed to 'nord-dark'
|
|
338
|
+
if (name === 'nord') { name = 'nord-dark'; localStorage.setItem('argus-theme', name); }
|
|
339
|
+
const t = themes[name] || themes['github-dark'];
|
|
340
|
+
const r = document.documentElement;
|
|
341
|
+
r.style.setProperty('--bg', t.bg);
|
|
342
|
+
r.style.setProperty('--fg', t.fg);
|
|
343
|
+
r.style.setProperty('--header-bg', t.headerBg);
|
|
344
|
+
r.style.setProperty('--drawer-bg', t.drawerBg);
|
|
345
|
+
r.style.setProperty('--border', t.border);
|
|
346
|
+
r.style.setProperty('--text-secondary', t.textSecondary);
|
|
347
|
+
r.style.setProperty('--accent', t.accent);
|
|
348
|
+
r.style.setProperty('--success', t.success);
|
|
349
|
+
r.style.setProperty('--warning', t.warning);
|
|
350
|
+
r.style.setProperty('--error', t.error);
|
|
351
|
+
r.style.setProperty('--selected-bg', t.selectedBg);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function changeTheme(name) {
|
|
355
|
+
localStorage.setItem('argus-theme', name);
|
|
356
|
+
applyTheme(name);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Drawer
|
|
360
|
+
function toggleDrawer() {
|
|
361
|
+
const drawer = document.getElementById('drawer');
|
|
362
|
+
const overlay = document.getElementById('overlay');
|
|
363
|
+
const isOpen = drawer.classList.contains('open');
|
|
364
|
+
if (isOpen) { closeDrawer(); } else { openDrawer(); }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function openDrawer() {
|
|
368
|
+
document.getElementById('drawer').classList.add('open');
|
|
369
|
+
document.getElementById('overlay').classList.add('open');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function closeDrawer() {
|
|
373
|
+
document.getElementById('drawer').classList.remove('open');
|
|
374
|
+
document.getElementById('overlay').classList.remove('open');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Override fetch — on 401 API response, redirect to /login
|
|
378
|
+
const _originalFetch = window.fetch;
|
|
379
|
+
window.fetch = function(input, init) {
|
|
380
|
+
return _originalFetch.call(this, input, init).then(function(resp) {
|
|
381
|
+
if (resp.status === 401) {
|
|
382
|
+
var url = typeof input === 'string' ? input : (input && input.url ? input.url : '');
|
|
383
|
+
// Don't redirect for auth endpoints (login/setup/logout)
|
|
384
|
+
if (url.indexOf('/api/auth') === -1) {
|
|
385
|
+
window.location.href = '/login';
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return resp;
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// Logout
|
|
393
|
+
async function logout() {
|
|
394
|
+
await fetch('/api/auth/logout', { method: 'POST' });
|
|
395
|
+
window.location.href = '/login';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Request notification permission
|
|
399
|
+
if ('Notification' in window && Notification.permission === 'default') {
|
|
400
|
+
Notification.requestPermission();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Notification Sound ───
|
|
404
|
+
|
|
405
|
+
// Web Audio API notification sound (short "ding")
|
|
406
|
+
let _notifAudioCtx = null;
|
|
407
|
+
let _notifLastPlayTime = 0;
|
|
408
|
+
|
|
409
|
+
// Unlock AudioContext on first user interaction (browser autoplay policy)
|
|
410
|
+
// Listen on multiple event types to maximize chances of unlocking
|
|
411
|
+
function _unlockAudioCtx() {
|
|
412
|
+
if (!_notifAudioCtx) {
|
|
413
|
+
_notifAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
414
|
+
}
|
|
415
|
+
if (_notifAudioCtx.state === 'suspended') {
|
|
416
|
+
_notifAudioCtx.resume();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
['click', 'keydown', 'touchstart', 'mousedown'].forEach(function(evt) {
|
|
420
|
+
document.addEventListener(evt, _unlockAudioCtx, { once: false, passive: true });
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
function _playDingSound(ctx) {
|
|
424
|
+
var t = ctx.currentTime;
|
|
425
|
+
var osc1 = ctx.createOscillator();
|
|
426
|
+
var osc2 = ctx.createOscillator();
|
|
427
|
+
var gain = ctx.createGain();
|
|
428
|
+
|
|
429
|
+
osc1.type = 'sine';
|
|
430
|
+
osc1.frequency.setValueAtTime(880, t); // A5
|
|
431
|
+
osc2.type = 'sine';
|
|
432
|
+
osc2.frequency.setValueAtTime(1175, t + 0.1); // D6
|
|
433
|
+
|
|
434
|
+
gain.gain.setValueAtTime(0.3, t);
|
|
435
|
+
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.4);
|
|
436
|
+
|
|
437
|
+
osc1.connect(gain);
|
|
438
|
+
osc2.connect(gain);
|
|
439
|
+
gain.connect(ctx.destination);
|
|
440
|
+
|
|
441
|
+
osc1.start(t);
|
|
442
|
+
osc1.stop(t + 0.15);
|
|
443
|
+
osc2.start(t + 0.1);
|
|
444
|
+
osc2.stop(t + 0.4);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function playNotificationSound() {
|
|
448
|
+
// Check setting
|
|
449
|
+
if (localStorage.getItem('argus-notification-sound') === 'off') return;
|
|
450
|
+
|
|
451
|
+
// Throttle: no more than once per 5 seconds
|
|
452
|
+
var now = Date.now();
|
|
453
|
+
if (now - _notifLastPlayTime < 5000) return;
|
|
454
|
+
_notifLastPlayTime = now;
|
|
455
|
+
|
|
456
|
+
// Create AudioContext if needed
|
|
457
|
+
if (!_notifAudioCtx) {
|
|
458
|
+
_notifAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
var ctx = _notifAudioCtx;
|
|
462
|
+
|
|
463
|
+
// If suspended, resume first then play after resume completes
|
|
464
|
+
if (ctx.state === 'suspended') {
|
|
465
|
+
ctx.resume().then(function() {
|
|
466
|
+
if (ctx.state === 'running') {
|
|
467
|
+
_playDingSound(ctx);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
} else if (ctx.state === 'running') {
|
|
471
|
+
_playDingSound(ctx);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function toggleNotificationSound() {
|
|
476
|
+
const current = localStorage.getItem('argus-notification-sound') !== 'off';
|
|
477
|
+
const newVal = current ? 'off' : 'on';
|
|
478
|
+
localStorage.setItem('argus-notification-sound', newVal);
|
|
479
|
+
// Update all toggles on the page
|
|
480
|
+
document.querySelectorAll('.notif-sound-toggle').forEach(function(el) {
|
|
481
|
+
el.classList.toggle('on', newVal === 'on');
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Init notification sound toggles on page load
|
|
486
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
487
|
+
const isOn = localStorage.getItem('argus-notification-sound') !== 'off';
|
|
488
|
+
document.querySelectorAll('.notif-sound-toggle').forEach(function(el) {
|
|
489
|
+
el.classList.toggle('on', isOn);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Init theme
|
|
494
|
+
(function() {
|
|
495
|
+
const saved = localStorage.getItem('argus-theme') || 'solarized-light';
|
|
496
|
+
applyTheme(saved);
|
|
497
|
+
const sel = document.getElementById('theme-select');
|
|
498
|
+
if (sel) sel.value = saved;
|
|
499
|
+
})();
|
|
500
|
+
|
|
501
|
+
// ─── Project-level WebSocket for real-time updates ───
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Connect to a project's event stream. Returns an object with .close() and .on(type, cb).
|
|
505
|
+
* Reconnects automatically on disconnect.
|
|
506
|
+
* Event types: agent_status, issue_created, issue_updated, comment_added
|
|
507
|
+
*/
|
|
508
|
+
function connectProjectEvents(projectId) {
|
|
509
|
+
const listeners = {};
|
|
510
|
+
let ws = null;
|
|
511
|
+
let closed = false;
|
|
512
|
+
let retryDelay = 1000;
|
|
513
|
+
|
|
514
|
+
function on(type, cb) {
|
|
515
|
+
if (!listeners[type]) listeners[type] = [];
|
|
516
|
+
listeners[type].push(cb);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function emit(type, data) {
|
|
520
|
+
(listeners[type] || []).forEach(cb => { try { cb(data); } catch(e) { console.error('WS listener error:', e); } });
|
|
521
|
+
(listeners['*'] || []).forEach(cb => { try { cb({ type, ...data }); } catch(e) {} });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function updateWsIndicator(state) {
|
|
525
|
+
const el = document.getElementById('ws-status-indicator');
|
|
526
|
+
if (!el) return;
|
|
527
|
+
const colors = { connected: '#3fb950', connecting: '#d29922', disconnected: '#8b949e' };
|
|
528
|
+
const labels = { connected: 'Live updates connected', connecting: 'Connecting...', disconnected: 'Live updates disconnected' };
|
|
529
|
+
el.innerHTML = `<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${colors[state] || colors.disconnected};margin-right:4px"></span><span style="font-size:11px;color:var(--text-secondary)">${labels[state] || ''}</span>`;
|
|
530
|
+
el.title = labels[state] || '';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function connect() {
|
|
534
|
+
if (closed) return;
|
|
535
|
+
updateWsIndicator('connecting');
|
|
536
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
537
|
+
ws = new WebSocket(`${proto}//${location.host}/ws/projects/${projectId}/events`);
|
|
538
|
+
|
|
539
|
+
ws.onopen = function() { retryDelay = 1000; updateWsIndicator('connected'); };
|
|
540
|
+
|
|
541
|
+
ws.onmessage = function(e) {
|
|
542
|
+
try {
|
|
543
|
+
const msg = JSON.parse(e.data);
|
|
544
|
+
if (msg.type) emit(msg.type, msg.data || msg);
|
|
545
|
+
} catch {}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
ws.onclose = function() {
|
|
549
|
+
updateWsIndicator('disconnected');
|
|
550
|
+
if (!closed) {
|
|
551
|
+
setTimeout(connect, retryDelay);
|
|
552
|
+
retryDelay = Math.min(retryDelay * 1.5, 15000);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
ws.onerror = function() { /* onclose will fire */ };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
connect();
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
on: on,
|
|
563
|
+
close: function() { closed = true; if (ws) ws.close(); },
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ─── @mention autocomplete for textareas ───
|
|
568
|
+
// Usage: setupMentionAutocomplete(textareaElement, agentsArray)
|
|
569
|
+
// agentsArray: [{name, role, ...}, ...]
|
|
570
|
+
function setupMentionAutocomplete(textarea, agents) {
|
|
571
|
+
if (!textarea || textarea._mentionSetup) return;
|
|
572
|
+
textarea._mentionSetup = true;
|
|
573
|
+
|
|
574
|
+
let dropdown = null;
|
|
575
|
+
let mentionStart = -1;
|
|
576
|
+
let selectedIdx = 0;
|
|
577
|
+
let blurTimeout = null;
|
|
578
|
+
|
|
579
|
+
function removeDropdown() {
|
|
580
|
+
if (dropdown) { dropdown.remove(); dropdown = null; }
|
|
581
|
+
selectedIdx = 0;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function cancelMention() {
|
|
585
|
+
removeDropdown();
|
|
586
|
+
mentionStart = -1;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function getCaretPixelPos() {
|
|
590
|
+
// Use a mirror element to measure caret position within textarea
|
|
591
|
+
const mirror = document.createElement('div');
|
|
592
|
+
const style = getComputedStyle(textarea);
|
|
593
|
+
['font', 'fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'letterSpacing',
|
|
594
|
+
'padding', 'paddingTop', 'paddingLeft', 'paddingRight', 'paddingBottom',
|
|
595
|
+
'border', 'borderWidth', 'boxSizing', 'whiteSpace', 'wordWrap', 'wordBreak', 'overflowWrap'
|
|
596
|
+
].forEach(p => { mirror.style[p] = style[p]; });
|
|
597
|
+
mirror.style.position = 'absolute';
|
|
598
|
+
mirror.style.visibility = 'hidden';
|
|
599
|
+
mirror.style.whiteSpace = 'pre-wrap';
|
|
600
|
+
mirror.style.wordWrap = 'break-word';
|
|
601
|
+
mirror.style.overflow = 'hidden';
|
|
602
|
+
mirror.style.width = style.width;
|
|
603
|
+
mirror.style.height = 'auto';
|
|
604
|
+
|
|
605
|
+
const textBefore = textarea.value.substring(0, textarea.selectionStart);
|
|
606
|
+
const textNode = document.createTextNode(textBefore);
|
|
607
|
+
const span = document.createElement('span');
|
|
608
|
+
span.textContent = '\u200b'; // zero-width space as caret marker
|
|
609
|
+
mirror.appendChild(textNode);
|
|
610
|
+
mirror.appendChild(span);
|
|
611
|
+
document.body.appendChild(mirror);
|
|
612
|
+
|
|
613
|
+
const textareaRect = textarea.getBoundingClientRect();
|
|
614
|
+
const mirrorRect = mirror.getBoundingClientRect();
|
|
615
|
+
const spanRect = span.getBoundingClientRect();
|
|
616
|
+
|
|
617
|
+
const top = textareaRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop;
|
|
618
|
+
const left = textareaRect.left + (spanRect.left - mirrorRect.left) - textarea.scrollLeft;
|
|
619
|
+
mirror.remove();
|
|
620
|
+
return { top, left };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function showDropdown(items) {
|
|
624
|
+
removeDropdown();
|
|
625
|
+
if (items.length === 0) return;
|
|
626
|
+
dropdown = document.createElement('div');
|
|
627
|
+
dropdown.className = 'mention-dropdown';
|
|
628
|
+
dropdown.style.cssText = 'position:fixed;z-index:300;background:var(--header-bg);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-height:200px;overflow-y:auto;min-width:200px;';
|
|
629
|
+
|
|
630
|
+
const coords = getCaretPixelPos();
|
|
631
|
+
dropdown.style.top = (coords.top + 22) + 'px';
|
|
632
|
+
dropdown.style.left = coords.left + 'px';
|
|
633
|
+
|
|
634
|
+
items.forEach((agent, i) => {
|
|
635
|
+
const item = document.createElement('div');
|
|
636
|
+
item.style.cssText = 'padding:6px 12px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px;';
|
|
637
|
+
if (i === selectedIdx) item.style.background = 'var(--selected-bg)';
|
|
638
|
+
const roleText = (agent.role || '').slice(0, 30);
|
|
639
|
+
item.innerHTML = `${avatarSvg(agent.name, 18)}<span><strong>${esc(agent.name)}</strong> <span style="color:var(--text-secondary);font-size:11px">${esc(roleText)}</span></span>`;
|
|
640
|
+
item.onmouseenter = () => { selectedIdx = i; updateSelection(); };
|
|
641
|
+
item.onmousedown = (e) => { e.preventDefault(); selectItem(agent.name); };
|
|
642
|
+
dropdown.appendChild(item);
|
|
643
|
+
});
|
|
644
|
+
document.body.appendChild(dropdown);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function updateSelection() {
|
|
648
|
+
if (!dropdown) return;
|
|
649
|
+
const children = dropdown.children;
|
|
650
|
+
for (let i = 0; i < children.length; i++) {
|
|
651
|
+
children[i].style.background = i === selectedIdx ? 'var(--selected-bg)' : '';
|
|
652
|
+
}
|
|
653
|
+
// Scroll selected into view
|
|
654
|
+
if (children[selectedIdx]) children[selectedIdx].scrollIntoView({ block: 'nearest' });
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function selectItem(name) {
|
|
658
|
+
// Cancel any pending blur timeout to prevent race condition
|
|
659
|
+
if (blurTimeout) { clearTimeout(blurTimeout); blurTimeout = null; }
|
|
660
|
+
|
|
661
|
+
// Recalculate mention position from current textarea state for robustness.
|
|
662
|
+
// mentionStart may be stale if a blur/cancelMention fired between input and selection.
|
|
663
|
+
const cursorPos = textarea.selectionStart;
|
|
664
|
+
const textBefore = textarea.value.substring(0, cursorPos);
|
|
665
|
+
const reMatch = textBefore.match(/(?:^|[\s])@([\w-]*)$/);
|
|
666
|
+
const atPos = reMatch
|
|
667
|
+
? textBefore.length - reMatch[0].length + (reMatch[0].startsWith('@') ? 0 : 1)
|
|
668
|
+
: mentionStart;
|
|
669
|
+
|
|
670
|
+
if (atPos < 0 || atPos > textarea.value.length) {
|
|
671
|
+
removeDropdown();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const before = textarea.value.substring(0, atPos);
|
|
676
|
+
const after = textarea.value.substring(cursorPos);
|
|
677
|
+
textarea.value = before + '@' + name + ' ' + after;
|
|
678
|
+
const newPos = atPos + name.length + 2;
|
|
679
|
+
textarea.setSelectionRange(newPos, newPos);
|
|
680
|
+
textarea.focus();
|
|
681
|
+
mentionStart = -1;
|
|
682
|
+
removeDropdown();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function getFilteredAgents(query) {
|
|
686
|
+
if (!query) return agents.slice();
|
|
687
|
+
const q = query.toLowerCase();
|
|
688
|
+
return agents.filter(a => a.name.toLowerCase().includes(q));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
textarea.addEventListener('input', () => {
|
|
692
|
+
if (blurTimeout) { clearTimeout(blurTimeout); blurTimeout = null; }
|
|
693
|
+
const pos = textarea.selectionStart;
|
|
694
|
+
const text = textarea.value.substring(0, pos);
|
|
695
|
+
const match = text.match(/(?:^|[\s])@([\w-]*)$/);
|
|
696
|
+
if (match) {
|
|
697
|
+
selectedIdx = 0;
|
|
698
|
+
showDropdown(getFilteredAgents(match[1]));
|
|
699
|
+
mentionStart = text.length - match[0].length + (match[0].startsWith('@') ? 0 : 1);
|
|
700
|
+
} else {
|
|
701
|
+
cancelMention();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
textarea.addEventListener('keydown', (e) => {
|
|
706
|
+
if (!dropdown) return;
|
|
707
|
+
const items = dropdown.children;
|
|
708
|
+
if (items.length === 0) return;
|
|
709
|
+
if (e.key === 'ArrowDown') {
|
|
710
|
+
e.preventDefault();
|
|
711
|
+
selectedIdx = (selectedIdx + 1) % items.length;
|
|
712
|
+
updateSelection();
|
|
713
|
+
} else if (e.key === 'ArrowUp') {
|
|
714
|
+
e.preventDefault();
|
|
715
|
+
selectedIdx = (selectedIdx - 1 + items.length) % items.length;
|
|
716
|
+
updateSelection();
|
|
717
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
718
|
+
e.preventDefault();
|
|
719
|
+
// Recalculate query from current textarea state (don't rely on mentionStart alone)
|
|
720
|
+
const curPos = textarea.selectionStart;
|
|
721
|
+
const txtBefore = textarea.value.substring(0, curPos);
|
|
722
|
+
const km = txtBefore.match(/(?:^|[\s])@([\w-]*)$/);
|
|
723
|
+
const query = km ? km[1] : textarea.value.substring(mentionStart + 1, curPos);
|
|
724
|
+
const filtered = getFilteredAgents(query);
|
|
725
|
+
if (filtered[selectedIdx]) selectItem(filtered[selectedIdx].name);
|
|
726
|
+
} else if (e.key === 'Escape') {
|
|
727
|
+
e.preventDefault();
|
|
728
|
+
cancelMention();
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
textarea.addEventListener('blur', () => {
|
|
733
|
+
blurTimeout = setTimeout(cancelMention, 200);
|
|
734
|
+
});
|
|
735
|
+
}
|