agent-tasks 1.6.0 → 1.6.1

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 (91) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +181 -188
  3. package/package.json +84 -84
  4. package/dist/context.d.ts +0 -21
  5. package/dist/context.d.ts.map +0 -1
  6. package/dist/context.js +0 -48
  7. package/dist/context.js.map +0 -1
  8. package/dist/db.d.ts +0 -10
  9. package/dist/db.d.ts.map +0 -1
  10. package/dist/db.js +0 -112
  11. package/dist/db.js.map +0 -1
  12. package/dist/domain/agent-bridge.d.ts +0 -13
  13. package/dist/domain/agent-bridge.d.ts.map +0 -1
  14. package/dist/domain/agent-bridge.js +0 -99
  15. package/dist/domain/agent-bridge.js.map +0 -1
  16. package/dist/domain/approvals.d.ts +0 -19
  17. package/dist/domain/approvals.d.ts.map +0 -1
  18. package/dist/domain/approvals.js +0 -99
  19. package/dist/domain/approvals.js.map +0 -1
  20. package/dist/domain/cleanup.d.ts +0 -28
  21. package/dist/domain/cleanup.d.ts.map +0 -1
  22. package/dist/domain/cleanup.js +0 -68
  23. package/dist/domain/cleanup.js.map +0 -1
  24. package/dist/domain/collaborators.d.ts +0 -15
  25. package/dist/domain/collaborators.d.ts.map +0 -1
  26. package/dist/domain/collaborators.js +0 -72
  27. package/dist/domain/collaborators.js.map +0 -1
  28. package/dist/domain/comments.d.ts +0 -14
  29. package/dist/domain/comments.d.ts.map +0 -1
  30. package/dist/domain/comments.js +0 -66
  31. package/dist/domain/comments.js.map +0 -1
  32. package/dist/domain/events.d.ts +0 -10
  33. package/dist/domain/events.d.ts.map +0 -1
  34. package/dist/domain/events.js +0 -61
  35. package/dist/domain/events.js.map +0 -1
  36. package/dist/domain/rules.d.ts +0 -2
  37. package/dist/domain/rules.d.ts.map +0 -1
  38. package/dist/domain/rules.js +0 -67
  39. package/dist/domain/rules.js.map +0 -1
  40. package/dist/domain/tasks.d.ts +0 -66
  41. package/dist/domain/tasks.d.ts.map +0 -1
  42. package/dist/domain/tasks.js +0 -655
  43. package/dist/domain/tasks.js.map +0 -1
  44. package/dist/domain/validate.d.ts +0 -16
  45. package/dist/domain/validate.d.ts.map +0 -1
  46. package/dist/domain/validate.js +0 -32
  47. package/dist/domain/validate.js.map +0 -1
  48. package/dist/event-bus.d.ts +0 -10
  49. package/dist/event-bus.d.ts.map +0 -1
  50. package/dist/event-bus.js +0 -38
  51. package/dist/event-bus.js.map +0 -1
  52. package/dist/index.d.ts +0 -3
  53. package/dist/index.d.ts.map +0 -1
  54. package/dist/index.js +0 -132
  55. package/dist/index.js.map +0 -1
  56. package/dist/server.d.ts +0 -10
  57. package/dist/server.d.ts.map +0 -1
  58. package/dist/server.js +0 -95
  59. package/dist/server.js.map +0 -1
  60. package/dist/session.d.ts +0 -7
  61. package/dist/session.d.ts.map +0 -1
  62. package/dist/session.js +0 -11
  63. package/dist/session.js.map +0 -1
  64. package/dist/storage/database.d.ts +0 -15
  65. package/dist/storage/database.d.ts.map +0 -1
  66. package/dist/storage/database.js +0 -224
  67. package/dist/storage/database.js.map +0 -1
  68. package/dist/tasks.d.ts +0 -32
  69. package/dist/tasks.d.ts.map +0 -1
  70. package/dist/tasks.js +0 -410
  71. package/dist/tasks.js.map +0 -1
  72. package/dist/transport/mcp.d.ts +0 -6
  73. package/dist/transport/mcp.d.ts.map +0 -1
  74. package/dist/transport/mcp.js +0 -731
  75. package/dist/transport/mcp.js.map +0 -1
  76. package/dist/transport/rest.d.ts +0 -4
  77. package/dist/transport/rest.d.ts.map +0 -1
  78. package/dist/transport/rest.js +0 -534
  79. package/dist/transport/rest.js.map +0 -1
  80. package/dist/transport/ws.d.ts +0 -10
  81. package/dist/transport/ws.d.ts.map +0 -1
  82. package/dist/transport/ws.js +0 -191
  83. package/dist/transport/ws.js.map +0 -1
  84. package/dist/types.d.ts +0 -147
  85. package/dist/types.d.ts.map +0 -1
  86. package/dist/types.js +0 -35
  87. package/dist/types.js.map +0 -1
  88. package/dist/ui/app.js +0 -1973
  89. package/dist/ui/index.html +0 -172
  90. package/dist/ui/morphdom.min.js +0 -1
  91. package/dist/ui/styles.css +0 -2435
package/dist/ui/app.js DELETED
@@ -1,1973 +0,0 @@
1
- // =============================================================================
2
- // agent-tasks — Pipeline dashboard client
3
- //
4
- // Complete UI overhaul: side panel detail view, rich task cards, inline editing,
5
- // inline task creation, drag-and-drop polish, animations, responsive design.
6
- // =============================================================================
7
-
8
- // ---- DOM morphing (morphdom) ----
9
-
10
- function morph(el, newInnerHTML) {
11
- const wrap = document.createElement(el.tagName);
12
- wrap.innerHTML = newInnerHTML;
13
- morphdom(el, wrap, {
14
- childrenOnly: true,
15
- getNodeKey(node) {
16
- if (node.id) return node.id;
17
- if (node.dataset) {
18
- if (node.dataset.taskId) return 'task-' + node.dataset.taskId;
19
- if (node.dataset.stage && node.classList && node.classList.contains('kanban-column'))
20
- return 'col-' + node.dataset.stage;
21
- }
22
- return null;
23
- },
24
- onBeforeElUpdated(fromEl, toEl) {
25
- if (fromEl.classList && fromEl.classList.contains('task-card')) {
26
- toEl.classList.add('no-anim');
27
- }
28
- return true;
29
- },
30
- });
31
- }
32
-
33
- // ---- Constants ----
34
-
35
- const STAGE_ICONS = {
36
- backlog: 'inbox',
37
- spec: 'description',
38
- plan: 'map',
39
- implement: 'code',
40
- test: 'science',
41
- review: 'rate_review',
42
- done: 'check_circle',
43
- cancelled: 'cancel',
44
- };
45
-
46
- const STAGE_EMPTY_MESSAGES = {
47
- backlog: { icon: 'inbox', text: 'Nothing in backlog', cta: 'Add a task', ctaIcon: 'add' },
48
- spec: {
49
- icon: 'description',
50
- text: 'No specs yet',
51
- cta: 'Drag tasks here',
52
- ctaIcon: 'drag_indicator',
53
- },
54
- plan: {
55
- icon: 'map',
56
- text: 'No plans in progress',
57
- cta: 'Drag tasks here',
58
- ctaIcon: 'drag_indicator',
59
- },
60
- implement: {
61
- icon: 'code',
62
- text: 'Nothing being built',
63
- cta: 'Drag tasks here',
64
- ctaIcon: 'drag_indicator',
65
- },
66
- test: {
67
- icon: 'science',
68
- text: 'Nothing to test',
69
- cta: 'Drag tasks here',
70
- ctaIcon: 'drag_indicator',
71
- },
72
- review: {
73
- icon: 'rate_review',
74
- text: 'Nothing in review',
75
- cta: 'Drag tasks here',
76
- ctaIcon: 'drag_indicator',
77
- },
78
- done: { icon: 'check_circle', text: 'No completed tasks', cta: '', ctaIcon: '' },
79
- cancelled: { icon: 'cancel', text: 'No cancelled tasks', cta: '', ctaIcon: '' },
80
- };
81
-
82
- const AVATAR_COLORS = [
83
- '#5d8da8',
84
- '#6f42c1',
85
- '#28a745',
86
- '#fd7e14',
87
- '#dc3545',
88
- '#007bff',
89
- '#5856d6',
90
- '#f59e0b',
91
- '#e83e8c',
92
- '#20c997',
93
- ];
94
-
95
- const WIP_WARNING = 5;
96
- const WIP_DANGER = 8;
97
-
98
- // ---- State ----
99
-
100
- const state = {
101
- tasks: [],
102
- dependencies: [],
103
- artifactCounts: {},
104
- commentCounts: {},
105
- subtaskProgress: {},
106
- collaborators: {},
107
- stages: ['backlog', 'spec', 'plan', 'implement', 'test', 'review', 'done', 'cancelled'],
108
- collapsedColumns: new Set(),
109
- panelTaskId: null,
110
- };
111
-
112
- const filters = {
113
- search: '',
114
- project: '',
115
- assignee: '',
116
- minPriority: 0,
117
- };
118
-
119
- let ws = null;
120
- let reconnectTimer = null;
121
- let searchDebounce = null;
122
- let draggedTaskId = null;
123
- let dragScrollInterval = null;
124
- let activeInlineCreate = null;
125
- let activeDropdown = null;
126
- let _lastStatValues = {};
127
-
128
- // ---- Restore persisted state ----
129
-
130
- try {
131
- const saved = JSON.parse(localStorage.getItem('agent-tasks-filters') || '{}');
132
- if (saved.search) filters.search = saved.search;
133
- if (saved.project) filters.project = saved.project;
134
- if (saved.assignee) filters.assignee = saved.assignee;
135
- if (saved.minPriority) filters.minPriority = saved.minPriority;
136
- } catch {
137
- /* ignore */
138
- }
139
-
140
- try {
141
- const collapsed = JSON.parse(localStorage.getItem('agent-tasks-collapsed') || '[]');
142
- if (Array.isArray(collapsed)) collapsed.forEach((s) => state.collapsedColumns.add(s));
143
- } catch {
144
- /* ignore */
145
- }
146
-
147
- function saveFilters() {
148
- localStorage.setItem('agent-tasks-filters', JSON.stringify(filters));
149
- }
150
-
151
- function saveCollapsed() {
152
- localStorage.setItem('agent-tasks-collapsed', JSON.stringify([...state.collapsedColumns]));
153
- }
154
-
155
- // ---- Theme ----
156
-
157
- function updateThemeIcon(theme) {
158
- const icon = document.querySelector('.theme-icon');
159
- if (icon) icon.textContent = theme === 'dark' ? 'light_mode' : 'dark_mode';
160
- }
161
-
162
- const savedTheme = localStorage.getItem('agent-tasks-theme');
163
- if (savedTheme === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
164
- updateThemeIcon(savedTheme || 'light');
165
-
166
- document.getElementById('theme-toggle').addEventListener('click', () => {
167
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
168
- const next = isDark ? 'light' : 'dark';
169
- if (isDark) {
170
- document.documentElement.removeAttribute('data-theme');
171
- } else {
172
- document.documentElement.setAttribute('data-theme', 'dark');
173
- }
174
- localStorage.setItem('agent-tasks-theme', next);
175
- updateThemeIcon(next);
176
- });
177
-
178
- // ---- WebSocket ----
179
-
180
- function connect() {
181
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
182
- ws = new WebSocket(`${proto}//${location.host}`);
183
- setConnectionStatus('connecting');
184
-
185
- ws.onopen = () => setConnectionStatus('connected');
186
-
187
- ws.onmessage = (evt) => {
188
- let data;
189
- try {
190
- data = JSON.parse(evt.data);
191
- } catch {
192
- return;
193
- }
194
-
195
- if (data.type === 'reload') {
196
- location.reload();
197
- return;
198
- } else if (data.type === 'state') {
199
- handleFullState(data);
200
- } else if (data.type && data.data) {
201
- handleEvent(data);
202
- }
203
- };
204
-
205
- ws.onclose = () => {
206
- setConnectionStatus('disconnected');
207
- ws = null;
208
- reconnectTimer = setTimeout(connect, 3000);
209
- };
210
-
211
- ws.onerror = () => {};
212
- }
213
-
214
- function setConnectionStatus(status) {
215
- const el = document.getElementById('connection-status');
216
- el.textContent =
217
- status === 'connected' ? 'Connected' : status === 'connecting' ? 'Connecting' : 'Disconnected';
218
- el.className = 'status-badge ' + status;
219
- }
220
-
221
- // ---- State handlers ----
222
-
223
- let _lastStateFingerprint = '';
224
-
225
- function handleFullState(data) {
226
- const fp = quickFingerprint(data);
227
- if (fp === _lastStateFingerprint) return;
228
- _lastStateFingerprint = fp;
229
-
230
- state.tasks = data.tasks || [];
231
- state.dependencies = data.dependencies || [];
232
- state.artifactCounts = data.artifactCounts || {};
233
- state.commentCounts = data.commentCounts || {};
234
- state.subtaskProgress = data.subtaskProgress || {};
235
- state.collaborators = data.collaborators || {};
236
- if (data.stages) state.stages = data.stages;
237
- if (data.version) {
238
- document.getElementById('version').textContent = 'v' + data.version;
239
- }
240
- updateFilterDropdowns();
241
- applyRestoredFilters();
242
- render();
243
- dismissLoading();
244
- }
245
-
246
- function quickFingerprint(data) {
247
- const tasks = data.tasks || [];
248
- let fp = tasks.length + ':';
249
- for (let i = 0; i < tasks.length; i++) {
250
- const t = tasks[i];
251
- fp +=
252
- t.id + '.' + t.stage + '.' + t.status + '.' + (t.updated_at || '') + '.' + t.priority + ',';
253
- }
254
- fp += '|' + (data.dependencies || []).length;
255
- fp += '|' + JSON.stringify(data.artifactCounts || {});
256
- fp += '|' + JSON.stringify(data.commentCounts || {});
257
- fp += '|' + JSON.stringify(data.subtaskProgress || {});
258
- return fp;
259
- }
260
-
261
- function dismissLoading() {
262
- const overlay = document.getElementById('loading-overlay');
263
- if (overlay && !overlay.classList.contains('hidden')) {
264
- overlay.classList.add('hidden');
265
- overlay.setAttribute('aria-hidden', 'true');
266
- overlay.addEventListener(
267
- 'transitionend',
268
- () => {
269
- overlay.style.display = 'none';
270
- },
271
- { once: true },
272
- );
273
- }
274
- }
275
-
276
- function applyRestoredFilters() {
277
- const searchInput = document.getElementById('filter-search');
278
- if (filters.search && searchInput) searchInput.value = filters.search;
279
- const projectSelect = document.getElementById('filter-project');
280
- if (filters.project && projectSelect) projectSelect.value = filters.project;
281
- const assigneeSelect = document.getElementById('filter-assignee');
282
- if (filters.assignee && assigneeSelect) assigneeSelect.value = filters.assignee;
283
- const prioritySelect = document.getElementById('filter-priority');
284
- if (filters.minPriority && prioritySelect) prioritySelect.value = String(filters.minPriority);
285
- }
286
-
287
- function handleEvent(event) {
288
- const d = event.data || {};
289
-
290
- switch (event.type) {
291
- case 'task:created': {
292
- if (d.task) {
293
- const idx = state.tasks.findIndex((t) => t.id === d.task.id);
294
- if (idx >= 0) state.tasks[idx] = d.task;
295
- else state.tasks.unshift(d.task);
296
- }
297
- showToast('Task created', d.task?.title || '');
298
- break;
299
- }
300
- case 'task:updated':
301
- case 'task:claimed':
302
- case 'task:advanced':
303
- case 'task:regressed':
304
- case 'task:completed':
305
- case 'task:failed':
306
- case 'task:cancelled': {
307
- if (d.task) {
308
- const idx = state.tasks.findIndex((t) => t.id === d.task.id);
309
- if (idx >= 0) state.tasks[idx] = d.task;
310
- else state.tasks.unshift(d.task);
311
- }
312
- break;
313
- }
314
- case 'task:deleted': {
315
- if (d.task) {
316
- state.tasks = state.tasks.filter((t) => t.id !== d.task.id);
317
- if (state.panelTaskId === d.task.id) closePanel();
318
- }
319
- break;
320
- }
321
- case 'artifact:created': {
322
- if (d.artifact) {
323
- const tid = d.artifact.task_id;
324
- state.artifactCounts[tid] = (state.artifactCounts[tid] || 0) + 1;
325
- }
326
- break;
327
- }
328
- case 'comment:created': {
329
- if (d.comment) {
330
- const tid = d.comment.task_id;
331
- state.commentCounts[tid] = (state.commentCounts[tid] || 0) + 1;
332
- }
333
- break;
334
- }
335
- case 'dependency:added': {
336
- if (d.task_id !== undefined && d.depends_on !== undefined) {
337
- state.dependencies.push({ task_id: d.task_id, depends_on: d.depends_on });
338
- }
339
- break;
340
- }
341
- case 'dependency:removed': {
342
- state.dependencies = state.dependencies.filter(
343
- (dep) => !(dep.task_id === d.task_id && dep.depends_on === d.depends_on),
344
- );
345
- break;
346
- }
347
- case 'pipeline:configured': {
348
- if (d.stages) state.stages = d.stages;
349
- break;
350
- }
351
- case 'collaborator:added': {
352
- if (d.task_id && d.agent_id) {
353
- if (!state.collaborators[d.task_id]) state.collaborators[d.task_id] = [];
354
- const existing = state.collaborators[d.task_id].find((c) => c.agent_id === d.agent_id);
355
- if (!existing) {
356
- state.collaborators[d.task_id].push({
357
- task_id: d.task_id,
358
- agent_id: d.agent_id,
359
- role: d.role || 'collaborator',
360
- });
361
- }
362
- }
363
- break;
364
- }
365
- case 'collaborator:removed': {
366
- if (d.task_id && d.agent_id && state.collaborators[d.task_id]) {
367
- state.collaborators[d.task_id] = state.collaborators[d.task_id].filter(
368
- (c) => c.agent_id !== d.agent_id,
369
- );
370
- }
371
- break;
372
- }
373
- }
374
-
375
- render();
376
-
377
- if (state.panelTaskId) {
378
- const updated = d.task && d.task.id === state.panelTaskId;
379
- if (updated || event.type === 'artifact:created' || event.type === 'comment:created') {
380
- openPanel(state.panelTaskId);
381
- }
382
- }
383
- }
384
-
385
- // ---- Filters ----
386
-
387
- function getFilteredTasks() {
388
- return state.tasks.filter((t) => {
389
- if (filters.project && t.project !== filters.project) return false;
390
- if (filters.assignee && t.assigned_to !== filters.assignee) return false;
391
- if (filters.minPriority && t.priority < filters.minPriority) return false;
392
- if (filters.search) {
393
- const q = filters.search.toLowerCase();
394
- const inTitle = t.title.toLowerCase().includes(q);
395
- const inDesc = (t.description || '').toLowerCase().includes(q);
396
- const inId = `#${t.id}`.includes(q);
397
- if (!inTitle && !inDesc && !inId) return false;
398
- }
399
- return true;
400
- });
401
- }
402
-
403
- function updateFilterDropdowns() {
404
- const projects = [...new Set(state.tasks.map((t) => t.project).filter(Boolean))].sort();
405
- const assignees = [...new Set(state.tasks.map((t) => t.assigned_to).filter(Boolean))].sort();
406
-
407
- const projectSelect = document.getElementById('filter-project');
408
- const currentProject = projectSelect.value;
409
- projectSelect.innerHTML =
410
- '<option value="">All projects</option>' +
411
- projects.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('');
412
- projectSelect.value = currentProject;
413
-
414
- const assigneeSelect = document.getElementById('filter-assignee');
415
- const currentAssignee = assigneeSelect.value;
416
- assigneeSelect.innerHTML =
417
- '<option value="">All assignees</option>' +
418
- assignees.map((a) => `<option value="${esc(a)}">${esc(a)}</option>`).join('');
419
- assigneeSelect.value = currentAssignee;
420
- }
421
-
422
- document.getElementById('filter-search').addEventListener('input', (e) => {
423
- clearTimeout(searchDebounce);
424
- searchDebounce = setTimeout(() => {
425
- filters.search = e.target.value;
426
- saveFilters();
427
- render();
428
- }, 200);
429
- });
430
-
431
- document.getElementById('filter-project').addEventListener('change', (e) => {
432
- filters.project = e.target.value;
433
- saveFilters();
434
- render();
435
- });
436
-
437
- document.getElementById('filter-assignee').addEventListener('change', (e) => {
438
- filters.assignee = e.target.value;
439
- saveFilters();
440
- render();
441
- });
442
-
443
- document.getElementById('filter-priority').addEventListener('change', (e) => {
444
- filters.minPriority = parseInt(e.target.value) || 0;
445
- saveFilters();
446
- render();
447
- });
448
-
449
- // ---- Blocked tasks ----
450
-
451
- function getBlockedTaskIds() {
452
- const blocked = new Set();
453
- const doneOrCancelled = new Set(
454
- state.tasks.filter((t) => t.stage === 'done' || t.stage === 'cancelled').map((t) => t.id),
455
- );
456
- for (const dep of state.dependencies) {
457
- if (!doneOrCancelled.has(dep.depends_on)) {
458
- blocked.add(dep.task_id);
459
- }
460
- }
461
- return blocked;
462
- }
463
-
464
- // ---- Relative time ----
465
-
466
- function relativeTime(iso) {
467
- if (!iso) return '';
468
- try {
469
- const d = new Date(iso + 'Z');
470
- const now = Date.now();
471
- const diff = now - d.getTime();
472
- if (diff < 0) return 'just now';
473
- const secs = Math.floor(diff / 1000);
474
- if (secs < 60) return 'just now';
475
- const mins = Math.floor(secs / 60);
476
- if (mins < 60) return `${mins}m ago`;
477
- const hrs = Math.floor(mins / 60);
478
- if (hrs < 24) return `${hrs}h ago`;
479
- const days = Math.floor(hrs / 24);
480
- if (days < 30) return `${days}d ago`;
481
- const months = Math.floor(days / 30);
482
- if (months < 12) return `${months}mo ago`;
483
- return `${Math.floor(months / 12)}y ago`;
484
- } catch {
485
- return '';
486
- }
487
- }
488
-
489
- // ---- Avatar ----
490
-
491
- function avatarColor(name) {
492
- let hash = 0;
493
- for (let i = 0; i < name.length; i++) {
494
- hash = name.charCodeAt(i) + ((hash << 5) - hash);
495
- }
496
- return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
497
- }
498
-
499
- function avatarInitials(name) {
500
- if (!name) return '?';
501
- const parts = name
502
- .replace(/[^a-zA-Z0-9\s-]/g, '')
503
- .split(/[\s-]+/)
504
- .filter(Boolean);
505
- if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
506
- return name.substring(0, 2).toUpperCase();
507
- }
508
-
509
- function renderAvatar(name, sizeClass) {
510
- if (!name) return '';
511
- const color = avatarColor(name);
512
- const initials = avatarInitials(name);
513
- const cls = sizeClass ? `avatar-circle ${sizeClass}` : 'avatar-circle';
514
- return `<div class="${cls}" style="background:${color}" title="${esc(name)}">${esc(initials)}</div>`;
515
- }
516
-
517
- // ---- Markdown Rendering ----
518
-
519
- function renderMarkdown(text) {
520
- if (!text) return '';
521
- if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
522
- try {
523
- const html = DOMPurify.sanitize(marked.parse(text, { breaks: true, gfm: true }));
524
- return '<div class="rendered-md prose">' + html + '</div>';
525
- } catch (e) {
526
- return '<div class="rendered-md">' + esc(text) + '</div>';
527
- }
528
- }
529
- return '<div class="rendered-md">' + esc(text).replace(/\n/g, '<br>') + '</div>';
530
- }
531
-
532
- // ---- Syntax Highlighting (via highlight.js CDN) ----
533
-
534
- function highlightCode(code, langHint) {
535
- if (!code) return esc(code);
536
- if (typeof hljs !== 'undefined') {
537
- try {
538
- if (langHint) {
539
- const result = hljs.highlight(code, { language: langHint, ignoreIllegals: true });
540
- return result.value;
541
- }
542
- const result = hljs.highlightAuto(code);
543
- return result.value;
544
- } catch (e) {
545
- return esc(code);
546
- }
547
- }
548
- return esc(code);
549
- }
550
-
551
- // Keep backward compat for callers using old name
552
- function highlightSyntax(code, langHint) {
553
- return highlightCode(code, langHint);
554
- }
555
-
556
- function detectLanguage(name) {
557
- if (!name) return '';
558
- const n = name.toLowerCase();
559
- if (/\.(js|ts|jsx|tsx)/.test(n) || /javascript|typescript/.test(n)) return 'javascript';
560
- if (/\.(py)/.test(n) || /python/.test(n)) return 'python';
561
- if (/\.(sh|bash)/.test(n) || /shell|bash/.test(n)) return 'bash';
562
- if (/\.json/.test(n)) return 'json';
563
- if (/\.(css|scss)/.test(n)) return 'css';
564
- if (/\.(html|xml)/.test(n)) return 'xml';
565
- if (/\.sql/.test(n)) return 'sql';
566
- if (/\.ya?ml/.test(n)) return 'yaml';
567
- if (/\.rs/.test(n) || /rust/.test(n)) return 'rust';
568
- if (/\.go/.test(n)) return 'go';
569
- return '';
570
- }
571
-
572
- // ---- Diff Detection & Rendering ----
573
-
574
- function isDiff(content) {
575
- if (!content) return false;
576
- const dLines = content.split('\n').slice(0, 30);
577
- let hasHunkHeader = false;
578
- let hasMinusFile = false;
579
- let hasPlusFile = false;
580
- let hasDiffCmd = false;
581
- for (const line of dLines) {
582
- if (/^@@\s/.test(line)) hasHunkHeader = true;
583
- if (/^--- [ab\/]/.test(line)) hasMinusFile = true;
584
- if (/^\+\+\+ [ab\/]/.test(line)) hasPlusFile = true;
585
- if (/^diff --git/.test(line)) hasDiffCmd = true;
586
- }
587
- return hasHunkHeader || (hasMinusFile && hasPlusFile) || hasDiffCmd;
588
- }
589
-
590
- function renderDiff(content) {
591
- const dLines = content.split('\n');
592
- const leftRows = [];
593
- const rightRows = [];
594
- let leftLn = 0;
595
- let rightLn = 0;
596
-
597
- for (const line of dLines) {
598
- if (/^(---|\+\+\+|diff |index )/.test(line)) {
599
- continue;
600
- } else if (/^@@/.test(line)) {
601
- const m = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/);
602
- if (m) {
603
- leftLn = parseInt(m[1], 10) - 1;
604
- rightLn = parseInt(m[2], 10) - 1;
605
- }
606
- const escaped = esc(line);
607
- leftRows.push('<tr class="diff-section-header"><td colspan="2">' + escaped + '</td></tr>');
608
- rightRows.push('<tr class="diff-section-header"><td colspan="2">' + escaped + '</td></tr>');
609
- } else if (/^\+/.test(line)) {
610
- rightLn++;
611
- const escaped = esc(line.slice(1));
612
- leftRows.push(
613
- '<tr class="diff-add"><td class="diff-ln"></td><td class="diff-code"></td></tr>',
614
- );
615
- rightRows.push(
616
- '<tr class="diff-add"><td class="diff-ln">' +
617
- rightLn +
618
- '</td><td class="diff-code">' +
619
- escaped +
620
- '</td></tr>',
621
- );
622
- } else if (/^-/.test(line)) {
623
- leftLn++;
624
- const escaped = esc(line.slice(1));
625
- leftRows.push(
626
- '<tr class="diff-del"><td class="diff-ln">' +
627
- leftLn +
628
- '</td><td class="diff-code">' +
629
- escaped +
630
- '</td></tr>',
631
- );
632
- rightRows.push(
633
- '<tr class="diff-del"><td class="diff-ln"></td><td class="diff-code"></td></tr>',
634
- );
635
- } else {
636
- leftLn++;
637
- rightLn++;
638
- const text = line.startsWith(' ') ? line.slice(1) : line;
639
- const escaped = esc(text);
640
- leftRows.push(
641
- '<tr class="diff-context"><td class="diff-ln">' +
642
- leftLn +
643
- '</td><td class="diff-code">' +
644
- escaped +
645
- '</td></tr>',
646
- );
647
- rightRows.push(
648
- '<tr class="diff-context"><td class="diff-ln">' +
649
- rightLn +
650
- '</td><td class="diff-code">' +
651
- escaped +
652
- '</td></tr>',
653
- );
654
- }
655
- }
656
-
657
- return (
658
- '<div class="diff-viewer">' +
659
- '<div class="diff-side diff-left"><div class="diff-header">Original</div>' +
660
- '<table class="diff-table">' +
661
- leftRows.join('') +
662
- '</table></div>' +
663
- '<div class="diff-side diff-right"><div class="diff-header">Modified</div>' +
664
- '<table class="diff-table">' +
665
- rightRows.join('') +
666
- '</table></div>' +
667
- '</div>'
668
- );
669
- }
670
-
671
- // ---- Expandable Artifact Rendering ----
672
-
673
- function renderArtifactContent(content, name) {
674
- if (isDiff(content)) return renderDiff(content);
675
- const lang = detectLanguage(name, content);
676
- const highlighted = highlightSyntax(content, lang);
677
- const aLines = content.split('\n');
678
- const lineNums = aLines.map((_, i) => i + 1).join('\n');
679
- return (
680
- '<div class="artifact-lines"><div class="artifact-line-numbers">' +
681
- lineNums +
682
- '</div><div class="artifact-line-content"><pre class="artifact-code">' +
683
- highlighted +
684
- '</pre></div></div>'
685
- );
686
- }
687
-
688
- function renderArtifactBlock(artifact) {
689
- const vLabel = artifact.version > 1 ? ' v' + artifact.version : '';
690
- const aLines = (artifact.content || '').split('\n');
691
- const needsCollapse = aLines.length > 8;
692
- const wrapperClass = needsCollapse
693
- ? 'artifact-wrapper artifact-collapsed'
694
- : 'artifact-wrapper artifact-expanded';
695
- const artId = 'artifact-' + artifact.id;
696
- let html = '<div class="panel-artifact"><div class="artifact-header">';
697
- html +=
698
- '<h4><span class="material-symbols-outlined" style="font-size:14px">description</span> ' +
699
- esc(artifact.name) +
700
- vLabel +
701
- ' <span style="color:var(--text-dim);font-weight:400">(' +
702
- esc(artifact.stage) +
703
- ', ' +
704
- esc(artifact.created_by) +
705
- ')</span></h4>';
706
- html +=
707
- '<button class="artifact-fullscreen-btn" data-artifact-id="' +
708
- artId +
709
- '" title="Open fullscreen"><span class="material-symbols-outlined">open_in_full</span></button>' +
710
- '<button class="artifact-copy-btn" data-artifact-id="' +
711
- artId +
712
- '" title="Copy to clipboard"><span class="material-symbols-outlined">content_copy</span> Copy</button>';
713
- html += '</div><div class="' + wrapperClass + '" id="' + artId + '">';
714
- html += renderArtifactContent(artifact.content || '', artifact.name || '');
715
- html += '<div class="artifact-fade"></div></div>';
716
- if (needsCollapse) {
717
- html +=
718
- '<button class="artifact-toggle" data-artifact-id="' +
719
- artId +
720
- '"><span class="material-symbols-outlined">expand_more</span> Show more (' +
721
- aLines.length +
722
- ' lines)</button>';
723
- }
724
- html += '</div>';
725
- return html;
726
- }
727
-
728
- // ---- Rendering ----
729
-
730
- function render() {
731
- renderBoard();
732
- renderStats();
733
- }
734
-
735
- function renderStats() {
736
- const total = state.tasks.length;
737
- const active = state.tasks.filter((t) => t.status === 'in_progress').length;
738
- const pending = state.tasks.filter((t) => t.status === 'pending').length;
739
- const done = state.tasks.filter((t) => t.status === 'completed').length;
740
-
741
- const statsEl = document.getElementById('stats');
742
- const values = { total, active, pending, done };
743
-
744
- morph(
745
- statsEl,
746
- `<span class="stat">Total <span class="stat-value" data-stat="total">${total}</span></span>` +
747
- `<span class="stat">Active <span class="stat-value" data-stat="active">${active}</span></span>` +
748
- `<span class="stat">Pending <span class="stat-value" data-stat="pending">${pending}</span></span>` +
749
- `<span class="stat">Done <span class="stat-value" data-stat="done">${done}</span></span>`,
750
- );
751
-
752
- for (const key of Object.keys(values)) {
753
- if (_lastStatValues[key] !== undefined && _lastStatValues[key] !== values[key]) {
754
- const el = statsEl.querySelector(`[data-stat="${key}"]`);
755
- if (el) {
756
- el.classList.remove('pulse');
757
- void el.offsetWidth;
758
- el.classList.add('pulse');
759
- }
760
- }
761
- }
762
- _lastStatValues = values;
763
- }
764
-
765
- function renderBoard() {
766
- const board = document.getElementById('board');
767
- const blocked = getBlockedTaskIds();
768
- const filtered = getFilteredTasks();
769
- const visibleStages = state.stages.filter((s) => s !== 'cancelled');
770
-
771
- if (state.tasks.length === 0) {
772
- morph(
773
- board,
774
- `<div class="board-empty">
775
- <span class="material-symbols-outlined">view_kanban</span>
776
- <h3>No tasks yet</h3>
777
- <p>Create tasks via MCP tools (task_create) or the REST API (POST /api/tasks) to get started.</p>
778
- <div class="empty-steps">
779
- <div class="empty-step">
780
- <span class="material-symbols-outlined">add_task</span>
781
- <span>Create a task</span>
782
- </div>
783
- <div class="empty-step">
784
- <span class="material-symbols-outlined">drag_indicator</span>
785
- <span>Drag through stages</span>
786
- </div>
787
- <div class="empty-step">
788
- <span class="material-symbols-outlined">check_circle</span>
789
- <span>Complete the work</span>
790
- </div>
791
- </div>
792
- </div>`,
793
- );
794
- return;
795
- }
796
-
797
- const byStage = {};
798
- for (const s of state.stages) byStage[s] = [];
799
- for (const t of filtered) {
800
- if (byStage[t.stage]) byStage[t.stage].push(t);
801
- else byStage[t.stage] = [t];
802
- }
803
-
804
- for (const s of Object.keys(byStage)) {
805
- byStage[s].sort((a, b) => b.priority - a.priority);
806
- }
807
-
808
- const columnsToShow = [...visibleStages];
809
- if (byStage['cancelled']?.length > 0 && !columnsToShow.includes('cancelled')) {
810
- columnsToShow.push('cancelled');
811
- }
812
-
813
- morph(
814
- board,
815
- columnsToShow
816
- .map((stage) => {
817
- const tasks = byStage[stage] || [];
818
- const isCollapsed = state.collapsedColumns.has(stage);
819
- const colClass = isCollapsed ? 'kanban-column collapsed' : 'kanban-column';
820
- const icon = STAGE_ICONS[stage] || 'label';
821
-
822
- let countClass = 'column-count';
823
- if (tasks.length >= WIP_DANGER) countClass += ' wip-danger';
824
- else if (tasks.length >= WIP_WARNING) countClass += ' wip-warning';
825
-
826
- const emptyMsg = STAGE_EMPTY_MESSAGES[stage] || {
827
- icon: 'label',
828
- text: 'No tasks',
829
- cta: '',
830
- };
831
-
832
- let bodyContent;
833
- if (tasks.length === 0 && !isCollapsed) {
834
- bodyContent = `<div class="column-empty">
835
- <span class="material-symbols-outlined">${emptyMsg.icon}</span>
836
- <div class="empty-text">${esc(emptyMsg.text)}</div>
837
- ${emptyMsg.cta ? `<div class="empty-cta" data-action="add-task" data-stage="${esc(stage)}">${emptyMsg.ctaIcon ? `<span class="material-symbols-outlined">${emptyMsg.ctaIcon}</span>` : ''}${esc(emptyMsg.cta)}</div>` : ''}
838
- </div>`;
839
- } else {
840
- bodyContent = tasks.map((t, i) => renderCard(t, blocked.has(t.id), stage, i)).join('');
841
- }
842
-
843
- return `<div class="${colClass}" id="col-${esc(stage)}" data-stage="${esc(stage)}">
844
- <div class="column-header" data-action="toggle-collapse" data-stage="${esc(stage)}">
845
- <div class="column-header-left">
846
- <span class="material-symbols-outlined">${icon}</span>
847
- <h3>${esc(stage)}</h3>
848
- </div>
849
- <span class="${countClass}" aria-label="${tasks.length} tasks">${tasks.length}</span>
850
- </div>
851
- <div class="column-body" role="listbox" aria-label="${esc(stage)} tasks">
852
- ${bodyContent}
853
- </div>
854
- ${
855
- !isCollapsed
856
- ? `<button class="column-add-btn" data-action="inline-create" data-stage="${esc(stage)}">
857
- <span class="material-symbols-outlined">add</span> New task
858
- </button>`
859
- : ''
860
- }
861
- </div>`;
862
- })
863
- .join(''),
864
- );
865
-
866
- requestAnimationFrame(() => {
867
- const cards = board.querySelectorAll('.task-card:not(.no-anim):not(.animated)');
868
- cards.forEach((card, i) => {
869
- card.classList.add('animated');
870
- card.style.animationDelay = `${i * 30}ms`;
871
- card.classList.add('animate-in');
872
- });
873
- });
874
- }
875
-
876
- function renderCard(task, isBlocked, stage, index) {
877
- const tags = [];
878
-
879
- if (task.project) {
880
- tags.push(`<span class="task-tag tag-project">${esc(task.project)}</span>`);
881
- }
882
- if (task.priority > 0) {
883
- tags.push(
884
- `<span class="task-tag tag-priority clickable" data-action="cycle-priority" data-task-id="${task.id}" title="Click to cycle priority">P${task.priority}</span>`,
885
- );
886
- }
887
- const artCount = state.artifactCounts[task.id];
888
- if (artCount) {
889
- tags.push(`<span class="task-tag tag-artifacts">${artCount} art.</span>`);
890
- }
891
- const cmtCount = state.commentCounts[task.id];
892
- if (cmtCount) {
893
- tags.push(`<span class="task-tag tag-comments">${cmtCount} cmt.</span>`);
894
- }
895
- if (isBlocked) {
896
- tags.push(`<span class="task-tag tag-blocked">blocked</span>`);
897
- }
898
-
899
- const progress = state.subtaskProgress[task.id];
900
- let progressBar = '';
901
- if (progress && progress.total > 0) {
902
- const pct = Math.round((progress.done / progress.total) * 100);
903
- tags.push(`<span class="task-tag tag-subtasks">${progress.done}/${progress.total}</span>`);
904
- progressBar = `<div class="subtask-progress"><div class="subtask-progress-fill" style="width:${pct}%"></div></div>`;
905
- }
906
-
907
- const priorityClass =
908
- task.priority >= 5 ? ' priority-high' : task.priority >= 3 ? ' priority-medium' : '';
909
-
910
- const statusClass =
911
- task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'
912
- ? ` status-${task.status}`
913
- : '';
914
-
915
- const descPreview = task.description ? task.description.split('\n')[0].substring(0, 120) : '';
916
-
917
- const timeAgo = relativeTime(task.updated_at);
918
-
919
- const assigneeAvatar = task.assigned_to ? renderAvatar(task.assigned_to) : '';
920
-
921
- const isActive = state.panelTaskId === task.id;
922
- const activeClass = isActive ? ' active-card' : '';
923
-
924
- const statusIndicator = renderStatusIndicator(task.status);
925
-
926
- const collabs = state.collaborators[task.id] || [];
927
- const collabHtml = renderCollaborators(collabs);
928
-
929
- return `<div class="task-card${priorityClass}${statusClass}${activeClass}" tabindex="0" draggable="true"
930
- data-task-id="${task.id}" data-stage="${esc(stage)}"
931
- role="option"
932
- style="animation-delay: ${index * 30}ms"
933
- aria-label="Task #${task.id}: ${esc(task.title)}">
934
- <div class="task-card-header">
935
- <span class="task-card-id">#${task.id}${statusIndicator}</span>
936
- ${timeAgo ? `<span class="task-card-time">${esc(timeAgo)}</span>` : ''}
937
- </div>
938
- <div class="task-card-title" data-action="edit-title" data-task-id="${task.id}">${esc(task.title)}</div>
939
- ${descPreview ? `<div class="task-card-desc">${esc(descPreview)}</div>` : ''}
940
- <div class="task-card-footer">
941
- <div class="task-card-meta">${tags.join('')}</div>
942
- ${assigneeAvatar ? `<div class="task-card-assignee" data-action="change-assignee" data-task-id="${task.id}">${assigneeAvatar}</div>` : ''}
943
- </div>
944
- ${collabHtml}
945
- ${progressBar}
946
- </div>`;
947
- }
948
-
949
- function renderStatusIndicator(status) {
950
- const icons = {
951
- in_progress: 'pending',
952
- completed: 'check_circle',
953
- failed: 'cancel',
954
- pending: 'radio_button_unchecked',
955
- cancelled: 'block',
956
- };
957
- const icon = icons[status];
958
- if (!icon) return '';
959
- return `<span class="task-status-indicator status-${status}"><span class="material-symbols-outlined">${icon}</span></span>`;
960
- }
961
-
962
- function renderCollaborators(collabs) {
963
- if (!collabs || collabs.length === 0) return '';
964
- const maxVisible = 3;
965
- const visible = collabs.slice(0, maxVisible);
966
- const overflow = collabs.length - maxVisible;
967
- let html = '<div class="task-card-collabs">';
968
- for (const c of visible) {
969
- const initials = avatarInitials(c.agent_id);
970
- const color = avatarColor(c.agent_id);
971
- html += `<div class="collab-avatar" style="background:${color}" title="${esc(c.agent_id)} (${esc(c.role)})">${esc(initials)}</div>`;
972
- }
973
- if (overflow > 0) {
974
- html += `<div class="collab-overflow" title="${collabs.length} collaborators">+${overflow}</div>`;
975
- }
976
- html += '</div>';
977
- return html;
978
- }
979
-
980
- // ---- Event Delegation (board) ----
981
-
982
- document.getElementById('board').addEventListener('click', (e) => {
983
- const action = e.target.closest('[data-action]');
984
-
985
- if (action) {
986
- const act = action.dataset.action;
987
-
988
- if (act === 'toggle-collapse') {
989
- e.stopPropagation();
990
- const stage = action.dataset.stage;
991
- if (state.collapsedColumns.has(stage)) {
992
- state.collapsedColumns.delete(stage);
993
- } else {
994
- state.collapsedColumns.add(stage);
995
- }
996
- saveCollapsed();
997
- render();
998
- return;
999
- }
1000
-
1001
- if (act === 'inline-create') {
1002
- e.stopPropagation();
1003
- showInlineCreate(action.dataset.stage);
1004
- return;
1005
- }
1006
-
1007
- if (act === 'add-task') {
1008
- e.stopPropagation();
1009
- showInlineCreate(action.dataset.stage);
1010
- return;
1011
- }
1012
-
1013
- if (act === 'cycle-priority') {
1014
- e.stopPropagation();
1015
- cyclePriority(parseInt(action.dataset.taskId, 10));
1016
- return;
1017
- }
1018
-
1019
- if (act === 'change-assignee') {
1020
- e.stopPropagation();
1021
- showAssigneeDropdown(parseInt(action.dataset.taskId, 10), action);
1022
- return;
1023
- }
1024
- }
1025
-
1026
- const card = e.target.closest('.task-card[data-task-id]');
1027
- if (card) {
1028
- openPanel(parseInt(card.dataset.taskId, 10));
1029
- }
1030
- });
1031
-
1032
- document.getElementById('board').addEventListener('dblclick', (e) => {
1033
- const titleEl = e.target.closest('[data-action="edit-title"]');
1034
- if (titleEl) {
1035
- e.stopPropagation();
1036
- startInlineEdit(titleEl);
1037
- }
1038
- });
1039
-
1040
- document.getElementById('board').addEventListener('keydown', (e) => {
1041
- if (e.key === 'Enter') {
1042
- const card = e.target.closest('.task-card[data-task-id]');
1043
- if (card) openPanel(parseInt(card.dataset.taskId, 10));
1044
- }
1045
- });
1046
-
1047
- // ---- Collapsed column click (expand) ----
1048
-
1049
- document.getElementById('board').addEventListener('click', (e) => {
1050
- const col = e.target.closest('.kanban-column.collapsed');
1051
- if (col) {
1052
- const stage = col.dataset.stage;
1053
- state.collapsedColumns.delete(stage);
1054
- saveCollapsed();
1055
- render();
1056
- }
1057
- });
1058
-
1059
- // ---- Drag and Drop ----
1060
-
1061
- document.getElementById('board').addEventListener('dragstart', (e) => {
1062
- const card = e.target.closest('.task-card[data-task-id]');
1063
- if (card) onDragStart(e, card);
1064
- });
1065
-
1066
- document.getElementById('board').addEventListener('dragend', (e) => {
1067
- onDragEnd(e);
1068
- });
1069
-
1070
- document.getElementById('board').addEventListener('dragover', (e) => {
1071
- const col = e.target.closest('.kanban-column');
1072
- if (col) onDragOver(e, col);
1073
- });
1074
-
1075
- document.getElementById('board').addEventListener('dragleave', (e) => {
1076
- const col = e.target.closest('.kanban-column');
1077
- if (col && !col.contains(e.relatedTarget)) {
1078
- col.classList.remove('drag-over');
1079
- }
1080
- });
1081
-
1082
- document.getElementById('board').addEventListener('drop', (e) => {
1083
- const col = e.target.closest('.kanban-column');
1084
- if (col) onDrop(e, col);
1085
- });
1086
-
1087
- function onDragStart(e, card) {
1088
- draggedTaskId = parseInt(card.dataset.taskId, 10);
1089
- e.dataTransfer.effectAllowed = 'move';
1090
- e.dataTransfer.setData('text/plain', String(draggedTaskId));
1091
-
1092
- requestAnimationFrame(() => {
1093
- card.classList.add('dragging');
1094
- });
1095
-
1096
- startDragAutoScroll();
1097
- }
1098
-
1099
- function onDragEnd(e) {
1100
- const card = e.target.closest('.task-card');
1101
- if (card) card.classList.remove('dragging');
1102
- draggedTaskId = null;
1103
- stopDragAutoScroll();
1104
- document
1105
- .querySelectorAll('.kanban-column.drag-over')
1106
- .forEach((c) => c.classList.remove('drag-over'));
1107
- document.querySelectorAll('.drop-placeholder').forEach((p) => p.remove());
1108
- const board = document.getElementById('board');
1109
- board.classList.remove('drag-scroll-left', 'drag-scroll-right');
1110
- }
1111
-
1112
- function onDragOver(e, col) {
1113
- e.preventDefault();
1114
- e.dataTransfer.dropEffect = 'move';
1115
- if (col && !col.classList.contains('drag-over')) {
1116
- document
1117
- .querySelectorAll('.kanban-column.drag-over')
1118
- .forEach((c) => c.classList.remove('drag-over'));
1119
- col.classList.add('drag-over');
1120
- }
1121
- }
1122
-
1123
- function onDrop(e, col) {
1124
- e.preventDefault();
1125
- if (col) col.classList.remove('drag-over');
1126
- document.querySelectorAll('.drop-placeholder').forEach((p) => p.remove());
1127
-
1128
- if (!draggedTaskId) return;
1129
- const targetStage = col.dataset.stage;
1130
- const task = state.tasks.find((t) => t.id === draggedTaskId);
1131
- if (!task || task.stage === targetStage) return;
1132
-
1133
- fetch(`/api/tasks/${draggedTaskId}/stage`, {
1134
- method: 'PUT',
1135
- headers: { 'Content-Type': 'application/json' },
1136
- body: JSON.stringify({ stage: targetStage }),
1137
- })
1138
- .then((r) => r.json())
1139
- .then((result) => {
1140
- if (result.error) {
1141
- showToast('Move failed', result.error, 'error');
1142
- }
1143
- })
1144
- .catch(() => showToast('Move failed', 'Network error', 'error'));
1145
- }
1146
-
1147
- function startDragAutoScroll() {
1148
- const board = document.getElementById('board');
1149
- dragScrollInterval = setInterval(() => {
1150
- if (!draggedTaskId) return;
1151
- const rect = board.getBoundingClientRect();
1152
- const mouseX = _lastMouseX;
1153
- const edgeSize = 80;
1154
-
1155
- if (mouseX < rect.left + edgeSize) {
1156
- board.scrollLeft -= 8;
1157
- board.classList.add('drag-scroll-left');
1158
- } else {
1159
- board.classList.remove('drag-scroll-left');
1160
- }
1161
-
1162
- if (mouseX > rect.right - edgeSize) {
1163
- board.scrollLeft += 8;
1164
- board.classList.add('drag-scroll-right');
1165
- } else {
1166
- board.classList.remove('drag-scroll-right');
1167
- }
1168
- }, 16);
1169
- }
1170
-
1171
- function stopDragAutoScroll() {
1172
- clearInterval(dragScrollInterval);
1173
- dragScrollInterval = null;
1174
- }
1175
-
1176
- let _lastMouseX = 0;
1177
- document.addEventListener('dragover', (e) => {
1178
- _lastMouseX = e.clientX;
1179
- });
1180
-
1181
- // ---- Side Panel ----
1182
-
1183
- function openPanel(id) {
1184
- const task = state.tasks.find((t) => t.id === id);
1185
- if (!task) return;
1186
-
1187
- state.panelTaskId = id;
1188
- const wrapper = document.getElementById('board-wrapper');
1189
- wrapper.classList.add('panel-open');
1190
-
1191
- const panel = document.getElementById('side-panel');
1192
- const hasArtifacts = (state.artifactCounts[id] || 0) > 0;
1193
- if (hasArtifacts) {
1194
- panel.classList.add('panel-wide');
1195
- } else {
1196
- panel.classList.remove('panel-wide');
1197
- }
1198
-
1199
- renderPanelContent(task);
1200
- highlightActiveCard(id);
1201
-
1202
- showPanelBackdrop();
1203
- }
1204
-
1205
- function closePanel() {
1206
- state.panelTaskId = null;
1207
- const wrapper = document.getElementById('board-wrapper');
1208
- wrapper.classList.remove('panel-open');
1209
- hidePanelBackdrop();
1210
- highlightActiveCard(null);
1211
- }
1212
-
1213
- function showPanelBackdrop() {
1214
- let backdrop = document.getElementById('panel-backdrop');
1215
- if (!backdrop) {
1216
- backdrop = document.createElement('div');
1217
- backdrop.id = 'panel-backdrop';
1218
- backdrop.className = 'panel-backdrop';
1219
- backdrop.addEventListener('click', closePanel);
1220
- document.body.appendChild(backdrop);
1221
- }
1222
- backdrop.style.display = '';
1223
- }
1224
-
1225
- function hidePanelBackdrop() {
1226
- const backdrop = document.getElementById('panel-backdrop');
1227
- if (backdrop) backdrop.style.display = 'none';
1228
- }
1229
-
1230
- function highlightActiveCard(id) {
1231
- document
1232
- .querySelectorAll('.task-card.active-card')
1233
- .forEach((c) => c.classList.remove('active-card'));
1234
- if (id) {
1235
- const card = document.querySelector(`.task-card[data-task-id="${id}"]`);
1236
- if (card) card.classList.add('active-card');
1237
- }
1238
- }
1239
-
1240
- function renderPanelContent(task) {
1241
- const panel = document.getElementById('side-panel');
1242
- const panelBody = document.getElementById('panel-body');
1243
- const panelHeader = document.getElementById('panel-header-content');
1244
-
1245
- const stageClass = `stage-${task.stage}`;
1246
-
1247
- panelHeader.innerHTML = `
1248
- <div class="panel-header-left">
1249
- <span class="panel-task-id">#${task.id}</span>
1250
- <span class="panel-stage-badge ${stageClass}">${esc(task.stage)}</span>
1251
- </div>
1252
- <button class="panel-close-btn" data-action="close-panel" aria-label="Close panel">
1253
- <span class="material-symbols-outlined">close</span>
1254
- </button>`;
1255
-
1256
- const deps = state.dependencies.filter((d) => d.task_id === task.id);
1257
- const blocking = state.dependencies.filter((d) => d.depends_on === task.id);
1258
-
1259
- let html = `<div class="panel-title">${esc(task.title)}</div>`;
1260
-
1261
- html += '<div class="panel-section">';
1262
- html +=
1263
- '<div class="panel-section-title"><span class="material-symbols-outlined">info</span> Details</div>';
1264
- html += '<div class="panel-grid">';
1265
-
1266
- const gridRows = [
1267
- ['Status', task.status],
1268
- ['Priority', `P${task.priority}`],
1269
- ['Created by', task.created_by || '\u2014'],
1270
- ['Assigned to', task.assigned_to || '\u2014'],
1271
- ['Project', task.project || '\u2014'],
1272
- ['Created', formatDate(task.created_at)],
1273
- ['Updated', relativeTime(task.updated_at) || formatDate(task.updated_at)],
1274
- ];
1275
-
1276
- if (task.parent_id) {
1277
- const parent = state.tasks.find((t) => t.id === task.parent_id);
1278
- gridRows.push(['Parent', parent ? `#${parent.id} ${parent.title}` : `#${task.parent_id}`]);
1279
- }
1280
-
1281
- if (task.tags) {
1282
- try {
1283
- const parsed = JSON.parse(task.tags);
1284
- if (Array.isArray(parsed) && parsed.length) {
1285
- gridRows.push(['Tags', parsed.join(', ')]);
1286
- }
1287
- } catch {
1288
- /* ignore */
1289
- }
1290
- }
1291
-
1292
- for (const [label, value] of gridRows) {
1293
- html += `<span class="panel-label">${esc(label)}</span><span class="panel-value">${esc(String(value))}</span>`;
1294
- }
1295
-
1296
- html += '</div></div>';
1297
-
1298
- if (task.description) {
1299
- html += '<div class="panel-section">';
1300
- html +=
1301
- '<div class="panel-section-title"><span class="material-symbols-outlined">notes</span> Description</div>';
1302
- html += `<div class="panel-description">${renderMarkdown(task.description)}</div>`;
1303
- html += '</div>';
1304
- }
1305
-
1306
- if (task.result) {
1307
- html += '<div class="panel-section">';
1308
- html +=
1309
- '<div class="panel-section-title"><span class="material-symbols-outlined">output</span> Result</div>';
1310
- html += `<div class="panel-description">${renderMarkdown(task.result)}</div>`;
1311
- html += '</div>';
1312
- }
1313
-
1314
- if (deps.length) {
1315
- html += '<div class="panel-section">';
1316
- html +=
1317
- '<div class="panel-section-title"><span class="material-symbols-outlined">link</span> Dependencies</div>';
1318
- for (const d of deps) {
1319
- const t = state.tasks.find((x) => x.id === d.depends_on);
1320
- const name = t ? `${t.title}` : `Task`;
1321
- html += `<div class="panel-subtask" data-subtask-id="${d.depends_on}">
1322
- <span class="subtask-id">#${d.depends_on}</span>
1323
- <span>${esc(name)}</span>
1324
- ${t ? `<span class="subtask-stage stage-${t.stage}">${esc(t.stage)}</span>` : ''}
1325
- </div>`;
1326
- }
1327
- html += '</div>';
1328
- }
1329
-
1330
- if (blocking.length) {
1331
- html += '<div class="panel-section">';
1332
- html +=
1333
- '<div class="panel-section-title"><span class="material-symbols-outlined">block</span> Blocks</div>';
1334
- for (const d of blocking) {
1335
- const t = state.tasks.find((x) => x.id === d.task_id);
1336
- const name = t ? `${t.title}` : `Task`;
1337
- html += `<div class="panel-subtask" data-subtask-id="${d.task_id}">
1338
- <span class="subtask-id">#${d.task_id}</span>
1339
- <span>${esc(name)}</span>
1340
- ${t ? `<span class="subtask-stage stage-${t.stage}">${esc(t.stage)}</span>` : ''}
1341
- </div>`;
1342
- }
1343
- html += '</div>';
1344
- }
1345
-
1346
- const skeletonHTML =
1347
- '<div class="panel-loading">' +
1348
- '<div class="skeleton-line skeleton-wide"></div>' +
1349
- '<div class="skeleton-line"></div>' +
1350
- '<div class="skeleton-line"></div>' +
1351
- '<div class="skeleton-line skeleton-short"></div>' +
1352
- '</div>';
1353
-
1354
- panelBody.innerHTML = html + skeletonHTML;
1355
-
1356
- Promise.all([
1357
- fetch(`/api/tasks/${task.id}/artifacts`)
1358
- .then((r) => r.json())
1359
- .catch(() => []),
1360
- fetch(`/api/tasks/${task.id}/comments`)
1361
- .then((r) => r.json())
1362
- .catch(() => []),
1363
- fetch(`/api/tasks/${task.id}/subtasks`)
1364
- .then((r) => r.json())
1365
- .catch(() => []),
1366
- ]).then(([artifacts, comments, subtasks]) => {
1367
- let extra = '';
1368
-
1369
- if (subtasks.length) {
1370
- extra += '<div class="panel-section">';
1371
- extra += `<div class="panel-section-title"><span class="material-symbols-outlined">account_tree</span> Subtasks (${subtasks.length})</div>`;
1372
- for (const s of subtasks) {
1373
- extra += `<div class="panel-subtask" data-subtask-id="${s.id}">
1374
- <span class="subtask-id">#${s.id}</span>
1375
- <span>${esc(s.title)}</span>
1376
- <span class="subtask-stage stage-${s.stage}">${esc(s.stage)}</span>
1377
- </div>`;
1378
- }
1379
- extra += '</div>';
1380
- }
1381
-
1382
- if (artifacts.length) {
1383
- extra += '<div class="panel-section">';
1384
- extra += `<div class="panel-section-title"><span class="material-symbols-outlined">inventory_2</span> Artifacts (${artifacts.length})</div>`;
1385
- for (const a of artifacts) {
1386
- extra += renderArtifactBlock(a);
1387
- }
1388
- extra += '</div>';
1389
- }
1390
-
1391
- extra += '<div class="panel-section panel-comments">';
1392
- extra += `<div class="panel-section-title"><span class="material-symbols-outlined">chat</span> Comments (${comments.length})</div>`;
1393
- for (const c of comments) {
1394
- const isReply = c.parent_comment_id ? ' reply' : '';
1395
- extra += `<div class="comment-item${isReply}">
1396
- <div class="comment-header">
1397
- ${renderAvatar(c.agent_id, 'avatar-sm')}
1398
- <span class="comment-agent">${esc(c.agent_id)}</span>
1399
- <span class="comment-time">${relativeTime(c.created_at) || formatDate(c.created_at)}</span>
1400
- </div>
1401
- <div class="comment-body">${renderMarkdown(c.content)}</div>
1402
- </div>`;
1403
- }
1404
- extra += `<div class="comment-form">
1405
- <textarea id="comment-input" placeholder="Add a comment..." rows="1" aria-label="Add a comment"></textarea>
1406
- <button id="comment-send-btn" data-task-id="${task.id}" aria-label="Send comment">Send</button>
1407
- </div></div>`;
1408
-
1409
- panelBody.innerHTML = html + extra;
1410
- });
1411
- }
1412
-
1413
- // ---- Panel event delegation ----
1414
-
1415
- document.getElementById('side-panel').addEventListener('click', (e) => {
1416
- const closeBtn = e.target.closest('[data-action="close-panel"]');
1417
- if (closeBtn) {
1418
- closePanel();
1419
- return;
1420
- }
1421
-
1422
- const subtask = e.target.closest('[data-subtask-id]');
1423
- if (subtask) {
1424
- openPanel(parseInt(subtask.dataset.subtaskId, 10));
1425
- return;
1426
- }
1427
-
1428
- const sendBtn = e.target.closest('#comment-send-btn');
1429
- if (sendBtn) {
1430
- submitComment(parseInt(sendBtn.dataset.taskId, 10));
1431
- return;
1432
- }
1433
-
1434
- const toggleBtn = e.target.closest('.artifact-toggle');
1435
- if (toggleBtn) {
1436
- const artId = toggleBtn.dataset.artifactId;
1437
- if (artId) toggleArtifact(artId);
1438
- return;
1439
- }
1440
-
1441
- const copyBtn = e.target.closest('.artifact-copy-btn');
1442
- if (copyBtn) {
1443
- copyArtifact(copyBtn);
1444
- return;
1445
- }
1446
-
1447
- const fsBtn = e.target.closest('.artifact-fullscreen-btn');
1448
- if (fsBtn) {
1449
- const artId = fsBtn.dataset.artifactId;
1450
- if (artId) openArtifactFullscreen(artId);
1451
- return;
1452
- }
1453
- });
1454
-
1455
- // ---- Artifact fullscreen ----
1456
-
1457
- function openArtifactFullscreen(artId) {
1458
- const wrapper = document.getElementById(artId);
1459
- if (!wrapper) return;
1460
- const content = wrapper.querySelector('.artifact-code, .diff-viewer');
1461
- if (!content) return;
1462
-
1463
- const header = wrapper.closest('.panel-artifact')?.querySelector('h4');
1464
- const title = header ? header.textContent : 'Artifact';
1465
-
1466
- const overlay = document.createElement('div');
1467
- overlay.className = 'artifact-fullscreen-overlay';
1468
- overlay.innerHTML =
1469
- '<div class="artifact-fullscreen-header">' +
1470
- '<h3>' +
1471
- esc(title) +
1472
- '</h3>' +
1473
- '<button class="icon-btn" aria-label="Close fullscreen"><span class="material-symbols-outlined">close</span></button>' +
1474
- '</div>' +
1475
- '<div class="artifact-fullscreen-body"></div>';
1476
-
1477
- const body = overlay.querySelector('.artifact-fullscreen-body');
1478
- body.innerHTML = wrapper.innerHTML;
1479
- const fade = body.querySelector('.artifact-fade');
1480
- if (fade) fade.remove();
1481
- // Expand everything in fullscreen
1482
- const artWrapper = body.querySelector('.artifact-wrapper');
1483
- if (artWrapper) {
1484
- artWrapper.classList.remove('artifact-collapsed');
1485
- artWrapper.classList.add('artifact-expanded');
1486
- }
1487
-
1488
- overlay.querySelector('button').addEventListener('click', () => overlay.remove());
1489
- overlay.addEventListener('keydown', (e) => {
1490
- if (e.key === 'Escape') overlay.remove();
1491
- });
1492
-
1493
- document.body.appendChild(overlay);
1494
- overlay.querySelector('button').focus();
1495
- }
1496
-
1497
- // ---- Panel resize ----
1498
-
1499
- (function initPanelResize() {
1500
- const panel = document.getElementById('side-panel');
1501
- if (!panel) return;
1502
- const handle = document.createElement('div');
1503
- handle.className = 'panel-resize-handle';
1504
- panel.appendChild(handle);
1505
-
1506
- let isResizing = false;
1507
- let startX = 0;
1508
- let startWidth = 0;
1509
-
1510
- handle.addEventListener('mousedown', (e) => {
1511
- isResizing = true;
1512
- startX = e.clientX;
1513
- startWidth = panel.offsetWidth;
1514
- document.body.style.cursor = 'col-resize';
1515
- document.body.style.userSelect = 'none';
1516
- e.preventDefault();
1517
- });
1518
-
1519
- document.addEventListener('mousemove', (e) => {
1520
- if (!isResizing) return;
1521
- const dx = startX - e.clientX;
1522
- const newWidth = Math.max(400, Math.min(startWidth + dx, window.innerWidth * 0.8));
1523
- panel.style.width = newWidth + 'px';
1524
- panel.style.minWidth = newWidth + 'px';
1525
- });
1526
-
1527
- document.addEventListener('mouseup', () => {
1528
- if (!isResizing) return;
1529
- isResizing = false;
1530
- document.body.style.cursor = '';
1531
- document.body.style.userSelect = '';
1532
- });
1533
- })();
1534
-
1535
- // ---- Inline Task Creation ----
1536
-
1537
- function showInlineCreate(stage) {
1538
- dismissInlineCreate();
1539
-
1540
- const col = document.querySelector(`.kanban-column[data-stage="${stage}"]`);
1541
- if (!col) return;
1542
-
1543
- const addBtn = col.querySelector('.column-add-btn');
1544
- if (addBtn) addBtn.style.display = 'none';
1545
-
1546
- const form = document.createElement('div');
1547
- form.className = 'inline-create-form';
1548
- form.innerHTML = `<div class="inline-create-card">
1549
- <input class="inline-create-input" type="text" placeholder="Task title..." autofocus />
1550
- <div class="inline-create-hint">
1551
- <span><kbd>Enter</kbd> to create</span>
1552
- <span><kbd>Esc</kbd> to cancel</span>
1553
- </div>
1554
- </div>`;
1555
-
1556
- col.appendChild(form);
1557
- activeInlineCreate = { stage, form, col };
1558
-
1559
- const input = form.querySelector('.inline-create-input');
1560
- input.focus();
1561
-
1562
- input.addEventListener('keydown', (e) => {
1563
- if (e.key === 'Enter' && input.value.trim()) {
1564
- e.preventDefault();
1565
- createTaskInline(input.value.trim(), stage);
1566
- dismissInlineCreate();
1567
- } else if (e.key === 'Escape') {
1568
- e.preventDefault();
1569
- dismissInlineCreate();
1570
- }
1571
- });
1572
-
1573
- input.addEventListener('blur', () => {
1574
- setTimeout(() => dismissInlineCreate(), 150);
1575
- });
1576
- }
1577
-
1578
- function dismissInlineCreate() {
1579
- if (!activeInlineCreate) return;
1580
- const { form, col } = activeInlineCreate;
1581
- if (form && form.parentNode) form.remove();
1582
- const addBtn = col.querySelector('.column-add-btn');
1583
- if (addBtn) addBtn.style.display = '';
1584
- activeInlineCreate = null;
1585
- }
1586
-
1587
- function createTaskInline(title, stage) {
1588
- fetch('/api/tasks', {
1589
- method: 'POST',
1590
- headers: { 'Content-Type': 'application/json' },
1591
- body: JSON.stringify({ title, stage, created_by: 'dashboard' }),
1592
- })
1593
- .then((r) => r.json())
1594
- .then((result) => {
1595
- if (result.error) {
1596
- showToast('Create failed', result.error, 'error');
1597
- }
1598
- })
1599
- .catch(() => showToast('Create failed', 'Network error', 'error'));
1600
- }
1601
-
1602
- // ---- Inline Editing ----
1603
-
1604
- function startInlineEdit(titleEl) {
1605
- const taskId = parseInt(titleEl.dataset.taskId, 10);
1606
- const task = state.tasks.find((t) => t.id === taskId);
1607
- if (!task) return;
1608
-
1609
- titleEl.setAttribute('contenteditable', 'true');
1610
- titleEl.focus();
1611
-
1612
- const range = document.createRange();
1613
- range.selectNodeContents(titleEl);
1614
- const sel = window.getSelection();
1615
- sel.removeAllRanges();
1616
- sel.addRange(range);
1617
-
1618
- const finish = () => {
1619
- titleEl.removeAttribute('contenteditable');
1620
- const newTitle = titleEl.textContent.trim();
1621
- if (newTitle && newTitle !== task.title) {
1622
- updateTask(taskId, { title: newTitle });
1623
- } else {
1624
- titleEl.textContent = task.title;
1625
- }
1626
- };
1627
-
1628
- titleEl.addEventListener('blur', finish, { once: true });
1629
- titleEl.addEventListener(
1630
- 'keydown',
1631
- (e) => {
1632
- if (e.key === 'Enter') {
1633
- e.preventDefault();
1634
- titleEl.blur();
1635
- } else if (e.key === 'Escape') {
1636
- e.preventDefault();
1637
- titleEl.textContent = task.title;
1638
- titleEl.removeAttribute('contenteditable');
1639
- }
1640
- },
1641
- { once: true },
1642
- );
1643
- }
1644
-
1645
- function cyclePriority(taskId) {
1646
- const task = state.tasks.find((t) => t.id === taskId);
1647
- if (!task) return;
1648
-
1649
- const levels = [0, 1, 3, 5, 10];
1650
- const current = levels.indexOf(task.priority);
1651
- const next = levels[(current + 1) % levels.length];
1652
- updateTask(taskId, { priority: next });
1653
- }
1654
-
1655
- function showAssigneeDropdown(taskId, anchor) {
1656
- dismissDropdown();
1657
-
1658
- const task = state.tasks.find((t) => t.id === taskId);
1659
- if (!task) return;
1660
-
1661
- const assignees = [...new Set(state.tasks.map((t) => t.assigned_to).filter(Boolean))].sort();
1662
- if (!assignees.length) return;
1663
-
1664
- const dropdown = document.createElement('div');
1665
- dropdown.className = 'inline-dropdown';
1666
-
1667
- dropdown.innerHTML =
1668
- `<div class="inline-dropdown-item${!task.assigned_to ? ' active' : ''}" data-value="">
1669
- <span style="color:var(--text-dim)">Unassigned</span>
1670
- </div>` +
1671
- assignees
1672
- .map(
1673
- (a) =>
1674
- `<div class="inline-dropdown-item${task.assigned_to === a ? ' active' : ''}" data-value="${esc(a)}">
1675
- ${renderAvatar(a, 'avatar-sm')}
1676
- <span>${esc(a)}</span>
1677
- </div>`,
1678
- )
1679
- .join('');
1680
-
1681
- const rect = anchor.getBoundingClientRect();
1682
- dropdown.style.position = 'fixed';
1683
- dropdown.style.top = `${rect.bottom + 4}px`;
1684
- dropdown.style.left = `${Math.max(8, rect.left - 100)}px`;
1685
-
1686
- document.body.appendChild(dropdown);
1687
- activeDropdown = dropdown;
1688
-
1689
- dropdown.addEventListener('click', (e) => {
1690
- const item = e.target.closest('.inline-dropdown-item');
1691
- if (item) {
1692
- const value = item.dataset.value || null;
1693
- updateTask(taskId, { assigned_to: value });
1694
- dismissDropdown();
1695
- }
1696
- });
1697
-
1698
- setTimeout(() => {
1699
- document.addEventListener('click', dismissDropdownOnOutsideClick, { once: true });
1700
- }, 0);
1701
- }
1702
-
1703
- function dismissDropdown() {
1704
- if (activeDropdown) {
1705
- activeDropdown.remove();
1706
- activeDropdown = null;
1707
- }
1708
- }
1709
-
1710
- function dismissDropdownOnOutsideClick(e) {
1711
- if (activeDropdown && !activeDropdown.contains(e.target)) {
1712
- dismissDropdown();
1713
- }
1714
- }
1715
-
1716
- function updateTask(taskId, updates) {
1717
- fetch(`/api/tasks/${taskId}`, {
1718
- method: 'PUT',
1719
- headers: { 'Content-Type': 'application/json' },
1720
- body: JSON.stringify(updates),
1721
- })
1722
- .then((r) => r.json())
1723
- .then((result) => {
1724
- if (result.error) {
1725
- showToast('Update failed', result.error, 'error');
1726
- }
1727
- })
1728
- .catch(() => showToast('Update failed', 'Network error', 'error'));
1729
- }
1730
-
1731
- // ---- Comment submission ----
1732
-
1733
- function submitComment(taskId) {
1734
- const input = document.getElementById('comment-input');
1735
- const content = input?.value?.trim();
1736
- if (!content) return;
1737
-
1738
- fetch(`/api/tasks/${taskId}/comments`, {
1739
- method: 'POST',
1740
- headers: { 'Content-Type': 'application/json' },
1741
- body: JSON.stringify({ content, agent_id: 'dashboard' }),
1742
- })
1743
- .then((r) => r.json())
1744
- .then(() => {
1745
- openPanel(taskId);
1746
- })
1747
- .catch(() => showToast('Error', 'Failed to post comment', 'error'));
1748
- }
1749
-
1750
- // ---- Legacy Modal (cleanup only) ----
1751
-
1752
- function closeModal() {
1753
- document.getElementById('task-modal').hidden = true;
1754
- }
1755
-
1756
- document.getElementById('modal-close-btn')?.addEventListener('click', closeModal);
1757
- document.getElementById('task-modal').addEventListener('click', (e) => {
1758
- if (e.target === e.currentTarget) closeModal();
1759
- });
1760
-
1761
- // ---- Keyboard Navigation ----
1762
-
1763
- document.addEventListener('keydown', (e) => {
1764
- if (e.key === 'Escape') {
1765
- if (activeDropdown) {
1766
- dismissDropdown();
1767
- return;
1768
- }
1769
- if (activeInlineCreate) {
1770
- dismissInlineCreate();
1771
- return;
1772
- }
1773
- if (state.panelTaskId) {
1774
- closePanel();
1775
- return;
1776
- }
1777
- const modal = document.getElementById('task-modal');
1778
- if (!modal.hidden) {
1779
- closeModal();
1780
- return;
1781
- }
1782
- const cleanupModal = document.getElementById('cleanup-modal');
1783
- if (!cleanupModal.classList.contains('hidden')) {
1784
- cleanupModal.classList.add('hidden');
1785
- return;
1786
- }
1787
- }
1788
-
1789
- const isInput =
1790
- document.activeElement?.tagName === 'INPUT' ||
1791
- document.activeElement?.tagName === 'TEXTAREA' ||
1792
- document.activeElement?.getAttribute('contenteditable') === 'true';
1793
-
1794
- if (
1795
- (e.key === '/' && !e.ctrlKey && !e.metaKey && !isInput) ||
1796
- ((e.ctrlKey || e.metaKey) && e.key === 'k')
1797
- ) {
1798
- e.preventDefault();
1799
- document.getElementById('filter-search').focus();
1800
- }
1801
- });
1802
-
1803
- // ---- Toast ----
1804
-
1805
- function showToast(title, body, type) {
1806
- const container = document.getElementById('toast-container');
1807
- const el = document.createElement('div');
1808
- el.className = 'toast';
1809
-
1810
- const isError =
1811
- type === 'error' ||
1812
- title.toLowerCase().includes('fail') ||
1813
- title.toLowerCase().includes('error');
1814
- const iconName = isError ? 'error' : 'check_circle';
1815
- const iconClass = isError ? 'toast-icon-error' : 'toast-icon-success';
1816
-
1817
- el.innerHTML =
1818
- `<span class="material-symbols-outlined toast-icon ${iconClass}" aria-hidden="true">${iconName}</span>` +
1819
- `<div class="toast-content"><div class="toast-title">${esc(title)}</div><div class="toast-body">${esc(body)}</div></div>`;
1820
- container.appendChild(el);
1821
-
1822
- setTimeout(() => {
1823
- el.classList.add('fade-out');
1824
- el.addEventListener('animationend', () => el.remove(), { once: true });
1825
- setTimeout(() => el.remove(), 400);
1826
- }, 4000);
1827
- }
1828
-
1829
- // ---- Helpers ----
1830
-
1831
- function esc(str) {
1832
- if (!str) return '';
1833
- return String(str)
1834
- .replace(/&/g, '&amp;')
1835
- .replace(/</g, '&lt;')
1836
- .replace(/>/g, '&gt;')
1837
- .replace(/"/g, '&quot;');
1838
- }
1839
-
1840
- function formatDate(iso) {
1841
- if (!iso) return '\u2014';
1842
- try {
1843
- const d = new Date(iso + 'Z');
1844
- return d.toLocaleString(undefined, {
1845
- month: 'short',
1846
- day: 'numeric',
1847
- hour: '2-digit',
1848
- minute: '2-digit',
1849
- });
1850
- } catch {
1851
- return iso;
1852
- }
1853
- }
1854
-
1855
- // ---- Cleanup Dialog ----
1856
-
1857
- document.getElementById('cleanup-btn')?.addEventListener('click', () => {
1858
- document.getElementById('cleanup-modal').classList.remove('hidden');
1859
- });
1860
-
1861
- document.getElementById('cleanup-close-btn')?.addEventListener('click', () => {
1862
- document.getElementById('cleanup-modal').classList.add('hidden');
1863
- });
1864
-
1865
- document.getElementById('cleanup-modal')?.addEventListener('click', (e) => {
1866
- if (e.target === e.currentTarget) {
1867
- document.getElementById('cleanup-modal').classList.add('hidden');
1868
- }
1869
- });
1870
-
1871
- document.getElementById('cleanup-completed')?.addEventListener('click', () => {
1872
- document.getElementById('cleanup-modal').classList.add('hidden');
1873
- fetch('/api/cleanup', { method: 'POST' })
1874
- .then((r) => r.json())
1875
- .then((result) => {
1876
- showToast(
1877
- 'Cleanup complete',
1878
- `Purged ${result.purgedTasks} tasks, ${result.purgedComments} comments, ${result.purgedApprovals} approvals`,
1879
- 'success',
1880
- );
1881
- })
1882
- .catch(() => showToast('Cleanup failed', 'Network error', 'error'));
1883
- });
1884
-
1885
- document.getElementById('cleanup-all')?.addEventListener('click', () => {
1886
- if (!confirm('This will remove ALL completed and cancelled tasks. Continue?')) return;
1887
- document.getElementById('cleanup-modal').classList.add('hidden');
1888
- fetch('/api/cleanup', {
1889
- method: 'POST',
1890
- headers: { 'Content-Type': 'application/json' },
1891
- body: JSON.stringify({ force: true }),
1892
- })
1893
- .then((r) => r.json())
1894
- .then((result) => {
1895
- showToast(
1896
- 'Full cleanup complete',
1897
- `Purged ${result.purgedTasks} tasks, ${result.purgedComments} comments, ${result.purgedApprovals} approvals`,
1898
- 'success',
1899
- );
1900
- })
1901
- .catch(() => showToast('Cleanup failed', 'Network error', 'error'));
1902
- });
1903
-
1904
- document.getElementById('cleanup-everything')?.addEventListener('click', () => {
1905
- if (
1906
- !confirm(
1907
- 'This will remove ALL tasks — completed, in-progress, everything. This cannot be undone. Continue?',
1908
- )
1909
- )
1910
- return;
1911
- document.getElementById('cleanup-modal').classList.add('hidden');
1912
- fetch('/api/cleanup', {
1913
- method: 'POST',
1914
- headers: { 'Content-Type': 'application/json' },
1915
- body: JSON.stringify({ all: true }),
1916
- })
1917
- .then((r) => r.json())
1918
- .then((result) => {
1919
- showToast(
1920
- 'Everything purged',
1921
- `Purged ${result.purgedTasks} tasks, ${result.purgedComments} comments, ${result.purgedApprovals} approvals`,
1922
- 'success',
1923
- );
1924
- })
1925
- .catch(() => showToast('Cleanup failed', 'Network error', 'error'));
1926
- });
1927
-
1928
- // ---- Artifact interactions ----
1929
-
1930
- function toggleArtifact(id) {
1931
- const wrapper = document.getElementById(id);
1932
- if (!wrapper) return;
1933
- const isCollapsed = wrapper.classList.contains('artifact-collapsed');
1934
- wrapper.classList.toggle('artifact-collapsed', !isCollapsed);
1935
- wrapper.classList.toggle('artifact-expanded', isCollapsed);
1936
- const btn = wrapper.parentElement.querySelector('.artifact-toggle');
1937
- if (btn) {
1938
- const icon = btn.querySelector('.material-symbols-outlined');
1939
- if (isCollapsed) {
1940
- icon.textContent = 'expand_less';
1941
- btn.childNodes[btn.childNodes.length - 1].textContent = ' Show less';
1942
- } else {
1943
- icon.textContent = 'expand_more';
1944
- const codeEl = wrapper.querySelector('.artifact-code, .diff-viewer');
1945
- const count = codeEl ? codeEl.textContent.split('\n').length : 0;
1946
- btn.childNodes[btn.childNodes.length - 1].textContent = ' Show more (' + count + ' lines)';
1947
- }
1948
- }
1949
- }
1950
-
1951
- function copyArtifact(btn) {
1952
- const artifactEl = btn.closest('.panel-artifact');
1953
- if (!artifactEl) return;
1954
- const codeEl = artifactEl.querySelector('.artifact-code, .diff-viewer');
1955
- if (!codeEl) return;
1956
- const text = codeEl.textContent || '';
1957
- navigator.clipboard
1958
- .writeText(text)
1959
- .then(function () {
1960
- const origHtml = btn.innerHTML;
1961
- btn.innerHTML = '<span class="material-symbols-outlined">check</span> Copied';
1962
- setTimeout(function () {
1963
- btn.innerHTML = origHtml;
1964
- }, 1500);
1965
- })
1966
- .catch(function () {
1967
- /* fallback */
1968
- });
1969
- }
1970
-
1971
- // ---- Boot ----
1972
-
1973
- connect();