agent-tasks 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/dist/context.d.ts +17 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/context.js +37 -0
  6. package/dist/context.js.map +1 -0
  7. package/dist/db.d.ts +10 -0
  8. package/dist/db.d.ts.map +1 -0
  9. package/dist/db.js +112 -0
  10. package/dist/db.js.map +1 -0
  11. package/dist/domain/agent-bridge.d.ts +13 -0
  12. package/dist/domain/agent-bridge.d.ts.map +1 -0
  13. package/dist/domain/agent-bridge.js +99 -0
  14. package/dist/domain/agent-bridge.js.map +1 -0
  15. package/dist/domain/approvals.d.ts +18 -0
  16. package/dist/domain/approvals.d.ts.map +1 -0
  17. package/dist/domain/approvals.js +89 -0
  18. package/dist/domain/approvals.js.map +1 -0
  19. package/dist/domain/cleanup.d.ts +28 -0
  20. package/dist/domain/cleanup.d.ts.map +1 -0
  21. package/dist/domain/cleanup.js +68 -0
  22. package/dist/domain/cleanup.js.map +1 -0
  23. package/dist/domain/collaborators.d.ts +14 -0
  24. package/dist/domain/collaborators.d.ts.map +1 -0
  25. package/dist/domain/collaborators.js +59 -0
  26. package/dist/domain/collaborators.js.map +1 -0
  27. package/dist/domain/comments.d.ts +14 -0
  28. package/dist/domain/comments.d.ts.map +1 -0
  29. package/dist/domain/comments.js +63 -0
  30. package/dist/domain/comments.js.map +1 -0
  31. package/dist/domain/events.d.ts +9 -0
  32. package/dist/domain/events.d.ts.map +1 -0
  33. package/dist/domain/events.js +52 -0
  34. package/dist/domain/events.js.map +1 -0
  35. package/dist/domain/rules.d.ts +2 -0
  36. package/dist/domain/rules.d.ts.map +1 -0
  37. package/dist/domain/rules.js +67 -0
  38. package/dist/domain/rules.js.map +1 -0
  39. package/dist/domain/tasks.d.ts +60 -0
  40. package/dist/domain/tasks.d.ts.map +1 -0
  41. package/dist/domain/tasks.js +616 -0
  42. package/dist/domain/tasks.js.map +1 -0
  43. package/dist/domain/validate.d.ts +14 -0
  44. package/dist/domain/validate.d.ts.map +1 -0
  45. package/dist/domain/validate.js +29 -0
  46. package/dist/domain/validate.js.map +1 -0
  47. package/dist/event-bus.d.ts +10 -0
  48. package/dist/event-bus.d.ts.map +1 -0
  49. package/dist/event-bus.js +38 -0
  50. package/dist/event-bus.js.map +1 -0
  51. package/dist/index.d.ts +3 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +121 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/server.d.ts +10 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +95 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/session.d.ts +7 -0
  60. package/dist/session.d.ts.map +1 -0
  61. package/dist/session.js +11 -0
  62. package/dist/session.js.map +1 -0
  63. package/dist/storage/database.d.ts +15 -0
  64. package/dist/storage/database.d.ts.map +1 -0
  65. package/dist/storage/database.js +215 -0
  66. package/dist/storage/database.js.map +1 -0
  67. package/dist/tasks.d.ts +32 -0
  68. package/dist/tasks.d.ts.map +1 -0
  69. package/dist/tasks.js +410 -0
  70. package/dist/tasks.js.map +1 -0
  71. package/dist/transport/mcp.d.ts +6 -0
  72. package/dist/transport/mcp.d.ts.map +1 -0
  73. package/dist/transport/mcp.js +573 -0
  74. package/dist/transport/mcp.js.map +1 -0
  75. package/dist/transport/rest.d.ts +4 -0
  76. package/dist/transport/rest.d.ts.map +1 -0
  77. package/dist/transport/rest.js +382 -0
  78. package/dist/transport/rest.js.map +1 -0
  79. package/dist/transport/ws.d.ts +10 -0
  80. package/dist/transport/ws.d.ts.map +1 -0
  81. package/dist/transport/ws.js +177 -0
  82. package/dist/transport/ws.js.map +1 -0
  83. package/dist/types.d.ts +146 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +35 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/ui/app.js +648 -0
  88. package/dist/ui/index.html +82 -0
  89. package/dist/ui/morphdom.min.js +1 -0
  90. package/dist/ui/styles.css +805 -0
  91. package/package.json +78 -0
package/dist/ui/app.js ADDED
@@ -0,0 +1,648 @@
1
+ // =============================================================================
2
+ // agent-tasks — Pipeline dashboard client
3
+ //
4
+ // Kanban board with real-time WebSocket updates, drag-and-drop,
5
+ // filters, comments, subtask progress, and keyboard navigation.
6
+ // =============================================================================
7
+
8
+ // ---- State ----
9
+
10
+ const state = {
11
+ tasks: [],
12
+ dependencies: [],
13
+ artifactCounts: {},
14
+ commentCounts: {},
15
+ subtaskProgress: {},
16
+ stages: ['backlog', 'spec', 'plan', 'implement', 'test', 'review', 'done', 'cancelled'],
17
+ };
18
+
19
+ const filters = {
20
+ search: '',
21
+ project: '',
22
+ assignee: '',
23
+ minPriority: 0,
24
+ };
25
+
26
+ let ws = null;
27
+ let reconnectTimer = null;
28
+ let searchDebounce = null;
29
+ let draggedTaskId = null;
30
+
31
+ // ---- Theme ----
32
+
33
+ function updateThemeIcon(theme) {
34
+ const icon = document.querySelector('.theme-icon');
35
+ if (icon) icon.textContent = theme === 'dark' ? 'light_mode' : 'dark_mode';
36
+ }
37
+
38
+ const savedTheme = localStorage.getItem('agent-tasks-theme');
39
+ if (savedTheme === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
40
+ updateThemeIcon(savedTheme || 'light');
41
+
42
+ document.getElementById('theme-toggle').addEventListener('click', () => {
43
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
44
+ const next = isDark ? 'light' : 'dark';
45
+ if (isDark) {
46
+ document.documentElement.removeAttribute('data-theme');
47
+ } else {
48
+ document.documentElement.setAttribute('data-theme', 'dark');
49
+ }
50
+ localStorage.setItem('agent-tasks-theme', next);
51
+ updateThemeIcon(next);
52
+ });
53
+
54
+ // ---- WebSocket ----
55
+
56
+ function connect() {
57
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
58
+ ws = new WebSocket(`${proto}//${location.host}`);
59
+ setConnectionStatus('connecting');
60
+
61
+ ws.onopen = () => setConnectionStatus('connected');
62
+
63
+ ws.onmessage = (evt) => {
64
+ let data;
65
+ try {
66
+ data = JSON.parse(evt.data);
67
+ } catch {
68
+ return;
69
+ }
70
+
71
+ if (data.type === 'reload') {
72
+ location.reload();
73
+ return;
74
+ } else if (data.type === 'state') {
75
+ handleFullState(data);
76
+ } else if (data.type && data.data) {
77
+ handleEvent(data);
78
+ }
79
+ };
80
+
81
+ ws.onclose = () => {
82
+ setConnectionStatus('disconnected');
83
+ ws = null;
84
+ reconnectTimer = setTimeout(connect, 3000);
85
+ };
86
+
87
+ ws.onerror = () => {};
88
+ }
89
+
90
+ function setConnectionStatus(status) {
91
+ const el = document.getElementById('connection-status');
92
+ el.textContent =
93
+ status === 'connected' ? 'Connected' : status === 'connecting' ? 'Connecting' : 'Disconnected';
94
+ el.className = 'status-badge ' + status;
95
+ }
96
+
97
+ // ---- State handlers ----
98
+
99
+ function handleFullState(data) {
100
+ state.tasks = data.tasks || [];
101
+ state.dependencies = data.dependencies || [];
102
+ state.artifactCounts = data.artifactCounts || {};
103
+ state.commentCounts = data.commentCounts || {};
104
+ state.subtaskProgress = data.subtaskProgress || {};
105
+ if (data.stages) state.stages = data.stages;
106
+ if (data.version) {
107
+ document.getElementById('version').textContent = 'v' + data.version;
108
+ }
109
+ updateFilterDropdowns();
110
+ render();
111
+ }
112
+
113
+ function handleEvent(event) {
114
+ const d = event.data || {};
115
+
116
+ switch (event.type) {
117
+ case 'task:created': {
118
+ if (d.task) state.tasks.unshift(d.task);
119
+ showToast('Task created', d.task?.title || '');
120
+ break;
121
+ }
122
+ case 'task:updated':
123
+ case 'task:claimed':
124
+ case 'task:advanced':
125
+ case 'task:regressed':
126
+ case 'task:completed':
127
+ case 'task:failed':
128
+ case 'task:cancelled': {
129
+ if (d.task) {
130
+ const idx = state.tasks.findIndex((t) => t.id === d.task.id);
131
+ if (idx >= 0) state.tasks[idx] = d.task;
132
+ else state.tasks.unshift(d.task);
133
+ }
134
+ break;
135
+ }
136
+ case 'task:deleted': {
137
+ if (d.task) {
138
+ state.tasks = state.tasks.filter((t) => t.id !== d.task.id);
139
+ }
140
+ break;
141
+ }
142
+ case 'artifact:created': {
143
+ if (d.artifact) {
144
+ const tid = d.artifact.task_id;
145
+ state.artifactCounts[tid] = (state.artifactCounts[tid] || 0) + 1;
146
+ }
147
+ break;
148
+ }
149
+ case 'comment:created': {
150
+ if (d.comment) {
151
+ const tid = d.comment.task_id;
152
+ state.commentCounts[tid] = (state.commentCounts[tid] || 0) + 1;
153
+ }
154
+ break;
155
+ }
156
+ case 'dependency:added': {
157
+ if (d.task_id !== undefined && d.depends_on !== undefined) {
158
+ state.dependencies.push({ task_id: d.task_id, depends_on: d.depends_on });
159
+ }
160
+ break;
161
+ }
162
+ case 'dependency:removed': {
163
+ state.dependencies = state.dependencies.filter(
164
+ (dep) => !(dep.task_id === d.task_id && dep.depends_on === d.depends_on),
165
+ );
166
+ break;
167
+ }
168
+ case 'pipeline:configured': {
169
+ if (d.stages) state.stages = d.stages;
170
+ break;
171
+ }
172
+ }
173
+
174
+ render();
175
+ }
176
+
177
+ // ---- Filters ----
178
+
179
+ function getFilteredTasks() {
180
+ return state.tasks.filter((t) => {
181
+ if (filters.project && t.project !== filters.project) return false;
182
+ if (filters.assignee && t.assigned_to !== filters.assignee) return false;
183
+ if (filters.minPriority && t.priority < filters.minPriority) return false;
184
+ if (filters.search) {
185
+ const q = filters.search.toLowerCase();
186
+ const inTitle = t.title.toLowerCase().includes(q);
187
+ const inDesc = (t.description || '').toLowerCase().includes(q);
188
+ const inId = `#${t.id}`.includes(q);
189
+ if (!inTitle && !inDesc && !inId) return false;
190
+ }
191
+ return true;
192
+ });
193
+ }
194
+
195
+ function updateFilterDropdowns() {
196
+ const projects = [...new Set(state.tasks.map((t) => t.project).filter(Boolean))].sort();
197
+ const assignees = [...new Set(state.tasks.map((t) => t.assigned_to).filter(Boolean))].sort();
198
+
199
+ const projectSelect = document.getElementById('filter-project');
200
+ const currentProject = projectSelect.value;
201
+ projectSelect.innerHTML =
202
+ '<option value="">All projects</option>' +
203
+ projects.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('');
204
+ projectSelect.value = currentProject;
205
+
206
+ const assigneeSelect = document.getElementById('filter-assignee');
207
+ const currentAssignee = assigneeSelect.value;
208
+ assigneeSelect.innerHTML =
209
+ '<option value="">All assignees</option>' +
210
+ assignees.map((a) => `<option value="${esc(a)}">${esc(a)}</option>`).join('');
211
+ assigneeSelect.value = currentAssignee;
212
+ }
213
+
214
+ document.getElementById('filter-search').addEventListener('input', (e) => {
215
+ clearTimeout(searchDebounce);
216
+ searchDebounce = setTimeout(() => {
217
+ filters.search = e.target.value;
218
+ render();
219
+ }, 200);
220
+ });
221
+
222
+ document.getElementById('filter-project').addEventListener('change', (e) => {
223
+ filters.project = e.target.value;
224
+ render();
225
+ });
226
+
227
+ document.getElementById('filter-assignee').addEventListener('change', (e) => {
228
+ filters.assignee = e.target.value;
229
+ render();
230
+ });
231
+
232
+ document.getElementById('filter-priority').addEventListener('change', (e) => {
233
+ filters.minPriority = parseInt(e.target.value) || 0;
234
+ render();
235
+ });
236
+
237
+ // ---- Blocked tasks ----
238
+
239
+ function getBlockedTaskIds() {
240
+ const blocked = new Set();
241
+ const doneOrCancelled = new Set(
242
+ state.tasks.filter((t) => t.stage === 'done' || t.stage === 'cancelled').map((t) => t.id),
243
+ );
244
+ for (const dep of state.dependencies) {
245
+ if (!doneOrCancelled.has(dep.depends_on)) {
246
+ blocked.add(dep.task_id);
247
+ }
248
+ }
249
+ return blocked;
250
+ }
251
+
252
+ // ---- Rendering ----
253
+
254
+ function render() {
255
+ renderBoard();
256
+ renderStats();
257
+ }
258
+
259
+ function renderStats() {
260
+ const total = state.tasks.length;
261
+ const active = state.tasks.filter((t) => t.status === 'in_progress').length;
262
+ const pending = state.tasks.filter((t) => t.status === 'pending').length;
263
+ const done = state.tasks.filter((t) => t.status === 'completed').length;
264
+
265
+ document.getElementById('stats').innerHTML =
266
+ `<span class="stat">Total <span class="stat-value">${total}</span></span>` +
267
+ `<span class="stat">Active <span class="stat-value">${active}</span></span>` +
268
+ `<span class="stat">Pending <span class="stat-value">${pending}</span></span>` +
269
+ `<span class="stat">Done <span class="stat-value">${done}</span></span>`;
270
+ }
271
+
272
+ function renderBoard() {
273
+ const board = document.getElementById('board');
274
+ const blocked = getBlockedTaskIds();
275
+ const filtered = getFilteredTasks();
276
+ const visibleStages = state.stages.filter((s) => s !== 'cancelled');
277
+
278
+ if (state.tasks.length === 0) {
279
+ board.innerHTML = `
280
+ <div class="board-empty">
281
+ <span class="material-symbols-outlined">assignment</span>
282
+ <h3>No tasks yet</h3>
283
+ <p>Create tasks via MCP tools (task_create) or the REST API (POST /api/tasks)</p>
284
+ </div>`;
285
+ return;
286
+ }
287
+
288
+ const byStage = {};
289
+ for (const s of state.stages) byStage[s] = [];
290
+ for (const t of filtered) {
291
+ if (byStage[t.stage]) byStage[t.stage].push(t);
292
+ else byStage[t.stage] = [t];
293
+ }
294
+
295
+ for (const s of Object.keys(byStage)) {
296
+ byStage[s].sort((a, b) => b.priority - a.priority);
297
+ }
298
+
299
+ const columnsToShow = [...visibleStages];
300
+ if (byStage['cancelled']?.length > 0 && !columnsToShow.includes('cancelled')) {
301
+ columnsToShow.push('cancelled');
302
+ }
303
+
304
+ board.innerHTML = columnsToShow
305
+ .map((stage) => {
306
+ const tasks = byStage[stage] || [];
307
+ return `
308
+ <div class="kanban-column" data-stage="${esc(stage)}"
309
+ ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)">
310
+ <div class="column-header">
311
+ <h3>${esc(stage)}</h3>
312
+ <span class="column-count">${tasks.length}</span>
313
+ </div>
314
+ <div class="column-body">
315
+ ${tasks.map((t) => renderCard(t, blocked.has(t.id))).join('')}
316
+ </div>
317
+ </div>`;
318
+ })
319
+ .join('');
320
+ }
321
+
322
+ function renderCard(task, isBlocked) {
323
+ const tags = [];
324
+
325
+ if (task.project) {
326
+ tags.push(`<span class="task-tag tag-project">${esc(task.project)}</span>`);
327
+ }
328
+ if (task.assigned_to) {
329
+ tags.push(`<span class="task-tag tag-assignee">${esc(task.assigned_to)}</span>`);
330
+ }
331
+ if (task.priority > 0) {
332
+ tags.push(`<span class="task-tag tag-priority">P${task.priority}</span>`);
333
+ }
334
+ const artCount = state.artifactCounts[task.id];
335
+ if (artCount) {
336
+ tags.push(`<span class="task-tag tag-artifacts">${artCount} art.</span>`);
337
+ }
338
+ const cmtCount = state.commentCounts[task.id];
339
+ if (cmtCount) {
340
+ tags.push(`<span class="task-tag tag-comments">${cmtCount} cmt.</span>`);
341
+ }
342
+ if (isBlocked) {
343
+ tags.push(`<span class="task-tag tag-blocked">blocked</span>`);
344
+ }
345
+
346
+ const progress = state.subtaskProgress[task.id];
347
+ let progressBar = '';
348
+ if (progress && progress.total > 0) {
349
+ const pct = Math.round((progress.done / progress.total) * 100);
350
+ tags.push(`<span class="task-tag tag-subtasks">${progress.done}/${progress.total}</span>`);
351
+ progressBar = `<div class="subtask-progress"><div class="subtask-progress-fill" style="width:${pct}%"></div></div>`;
352
+ }
353
+
354
+ const priorityClass =
355
+ task.priority >= 5
356
+ ? ' priority-high'
357
+ : task.priority >= 3
358
+ ? ' priority-medium'
359
+ : task.priority >= 1
360
+ ? ' priority-low'
361
+ : '';
362
+
363
+ return `
364
+ <div class="task-card${priorityClass}" tabindex="0" draggable="true"
365
+ data-task-id="${task.id}"
366
+ onclick="openTask(${task.id})"
367
+ onkeydown="if(event.key==='Enter')openTask(${task.id})"
368
+ ondragstart="onDragStart(event, ${task.id})"
369
+ ondragend="onDragEnd(event)">
370
+ <div class="task-card-id">#${task.id}</div>
371
+ <div class="task-card-title">${esc(task.title)}</div>
372
+ ${tags.length ? `<div class="task-card-meta">${tags.join('')}</div>` : ''}
373
+ ${progressBar}
374
+ </div>`;
375
+ }
376
+
377
+ // ---- Drag and Drop ----
378
+
379
+ function onDragStart(e, taskId) {
380
+ draggedTaskId = taskId;
381
+ e.dataTransfer.effectAllowed = 'move';
382
+ e.target.classList.add('dragging');
383
+ }
384
+
385
+ function onDragEnd(e) {
386
+ e.target.classList.remove('dragging');
387
+ draggedTaskId = null;
388
+ document
389
+ .querySelectorAll('.kanban-column.drag-over')
390
+ .forEach((c) => c.classList.remove('drag-over'));
391
+ }
392
+
393
+ function onDragOver(e) {
394
+ e.preventDefault();
395
+ e.dataTransfer.dropEffect = 'move';
396
+ const col = e.currentTarget;
397
+ if (!col.classList.contains('drag-over')) {
398
+ col.classList.add('drag-over');
399
+ }
400
+ }
401
+
402
+ function onDragLeave(e) {
403
+ const col = e.currentTarget;
404
+ if (!col.contains(e.relatedTarget)) {
405
+ col.classList.remove('drag-over');
406
+ }
407
+ }
408
+
409
+ function onDrop(e) {
410
+ e.preventDefault();
411
+ const col = e.currentTarget;
412
+ col.classList.remove('drag-over');
413
+
414
+ if (!draggedTaskId) return;
415
+ const targetStage = col.dataset.stage;
416
+ const task = state.tasks.find((t) => t.id === draggedTaskId);
417
+ if (!task || task.stage === targetStage) return;
418
+
419
+ fetch(`/api/tasks/${draggedTaskId}/stage`, {
420
+ method: 'PUT',
421
+ headers: { 'Content-Type': 'application/json' },
422
+ body: JSON.stringify({ stage: targetStage }),
423
+ })
424
+ .then((r) => r.json())
425
+ .then((result) => {
426
+ if (result.error) {
427
+ showToast('Move failed', result.error);
428
+ }
429
+ })
430
+ .catch(() => showToast('Move failed', 'Network error'));
431
+ }
432
+
433
+ // ---- Modal ----
434
+
435
+ function openTask(id) {
436
+ const task = state.tasks.find((t) => t.id === id);
437
+ if (!task) return;
438
+
439
+ document.getElementById('modal-title').textContent = `#${task.id} — ${task.title}`;
440
+
441
+ const deps = state.dependencies.filter((d) => d.task_id === task.id);
442
+ const blocking = state.dependencies.filter((d) => d.depends_on === task.id);
443
+
444
+ let html = '<div class="detail-rows">';
445
+
446
+ const rows = [
447
+ ['Status', task.status],
448
+ ['Stage', task.stage],
449
+ ['Priority', task.priority],
450
+ ['Created by', task.created_by],
451
+ ['Assigned to', task.assigned_to || '\u2014'],
452
+ ['Project', task.project || '\u2014'],
453
+ ['Created', formatDate(task.created_at)],
454
+ ['Updated', formatDate(task.updated_at)],
455
+ ];
456
+
457
+ if (task.parent_id) {
458
+ const parent = state.tasks.find((t) => t.id === task.parent_id);
459
+ rows.push(['Parent', parent ? `#${parent.id} ${parent.title}` : `#${task.parent_id}`]);
460
+ }
461
+
462
+ if (task.tags) {
463
+ try {
464
+ const parsed = JSON.parse(task.tags);
465
+ if (Array.isArray(parsed) && parsed.length) {
466
+ rows.push(['Tags', parsed.join(', ')]);
467
+ }
468
+ } catch {
469
+ /* ignore */
470
+ }
471
+ }
472
+
473
+ if (task.description) {
474
+ rows.push(['Description', task.description]);
475
+ }
476
+ if (task.result) {
477
+ rows.push(['Result', task.result]);
478
+ }
479
+
480
+ for (const [label, value] of rows) {
481
+ html += `<div class="detail-row"><span class="detail-label">${esc(label)}</span><span class="detail-value">${esc(String(value))}</span></div>`;
482
+ }
483
+
484
+ if (deps.length) {
485
+ const depNames = deps.map((d) => {
486
+ const t = state.tasks.find((x) => x.id === d.depends_on);
487
+ return t ? `#${t.id} ${t.title}` : `#${d.depends_on}`;
488
+ });
489
+ html += `<div class="detail-row"><span class="detail-label">Depends on</span><span class="detail-value">${depNames.map(esc).join('<br>')}</span></div>`;
490
+ }
491
+
492
+ if (blocking.length) {
493
+ const blockNames = blocking.map((d) => {
494
+ const t = state.tasks.find((x) => x.id === d.task_id);
495
+ return t ? `#${t.id} ${t.title}` : `#${d.task_id}`;
496
+ });
497
+ html += `<div class="detail-row"><span class="detail-label">Blocks</span><span class="detail-value">${blockNames.map(esc).join('<br>')}</span></div>`;
498
+ }
499
+
500
+ html += '</div>';
501
+
502
+ const modalBody = document.getElementById('modal-body');
503
+ modalBody.innerHTML = html;
504
+ document.getElementById('task-modal').hidden = false;
505
+
506
+ Promise.all([
507
+ fetch(`/api/tasks/${task.id}/artifacts`)
508
+ .then((r) => r.json())
509
+ .catch(() => []),
510
+ fetch(`/api/tasks/${task.id}/comments`)
511
+ .then((r) => r.json())
512
+ .catch(() => []),
513
+ fetch(`/api/tasks/${task.id}/subtasks`)
514
+ .then((r) => r.json())
515
+ .catch(() => []),
516
+ ]).then(([artifacts, comments, subtasks]) => {
517
+ let extra = '';
518
+
519
+ if (subtasks.length) {
520
+ extra +=
521
+ '<div class="artifact-list"><h3 style="margin-bottom:8px;font-size:13px;">Subtasks</h3>';
522
+ for (const s of subtasks) {
523
+ extra += `<div class="artifact-item" style="cursor:pointer" onclick="openTask(${s.id})">
524
+ <h4>#${s.id} ${esc(s.title)} <span style="color:var(--text-dim);font-weight:400">(${esc(s.stage)})</span></h4>
525
+ </div>`;
526
+ }
527
+ extra += '</div>';
528
+ }
529
+
530
+ if (artifacts.length) {
531
+ extra +=
532
+ '<div class="artifact-list"><h3 style="margin-bottom:8px;font-size:13px;">Artifacts</h3>';
533
+ for (const a of artifacts) {
534
+ const vLabel = a.version > 1 ? ` v${a.version}` : '';
535
+ extra += `<div class="artifact-item">
536
+ <h4>${esc(a.name)}${vLabel} <span style="color:var(--text-dim);font-weight:400">(${esc(a.stage)}, ${esc(a.created_by)})</span></h4>
537
+ <pre>${esc(a.content)}</pre>
538
+ </div>`;
539
+ }
540
+ extra += '</div>';
541
+ }
542
+
543
+ if (comments.length || true) {
544
+ extra += `<div class="comments-section">
545
+ <h3><span class="material-symbols-outlined" style="font-size:16px">chat</span> Comments (${comments.length})</h3>`;
546
+ for (const c of comments) {
547
+ const isReply = c.parent_comment_id ? ' reply' : '';
548
+ extra += `<div class="comment-item${isReply}">
549
+ <div class="comment-header">
550
+ <span class="comment-agent">${esc(c.agent_id)}</span>
551
+ <span class="comment-time">${formatDate(c.created_at)}</span>
552
+ </div>
553
+ <div class="comment-body">${esc(c.content)}</div>
554
+ </div>`;
555
+ }
556
+ extra += `<div class="comment-form">
557
+ <textarea id="comment-input" placeholder="Add a comment..." rows="1"></textarea>
558
+ <button onclick="submitComment(${task.id})">Send</button>
559
+ </div></div>`;
560
+ }
561
+
562
+ modalBody.innerHTML = html + extra;
563
+ });
564
+ }
565
+
566
+ function submitComment(taskId) {
567
+ const input = document.getElementById('comment-input');
568
+ const content = input?.value?.trim();
569
+ if (!content) return;
570
+
571
+ fetch(`/api/tasks/${taskId}/comments`, {
572
+ method: 'POST',
573
+ headers: { 'Content-Type': 'application/json' },
574
+ body: JSON.stringify({ content, agent_id: 'dashboard' }),
575
+ })
576
+ .then((r) => r.json())
577
+ .then(() => {
578
+ openTask(taskId);
579
+ })
580
+ .catch(() => showToast('Error', 'Failed to post comment'));
581
+ }
582
+
583
+ function closeModal() {
584
+ document.getElementById('task-modal').hidden = true;
585
+ }
586
+
587
+ document.getElementById('task-modal').addEventListener('click', (e) => {
588
+ if (e.target === e.currentTarget) closeModal();
589
+ });
590
+
591
+ // ---- Keyboard Navigation ----
592
+
593
+ document.addEventListener('keydown', (e) => {
594
+ if (e.key === 'Escape') {
595
+ closeModal();
596
+ return;
597
+ }
598
+ if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
599
+ const active = document.activeElement;
600
+ if (active?.tagName !== 'INPUT' && active?.tagName !== 'TEXTAREA') {
601
+ e.preventDefault();
602
+ document.getElementById('filter-search').focus();
603
+ }
604
+ }
605
+ });
606
+
607
+ // ---- Toast ----
608
+
609
+ function showToast(title, body) {
610
+ const container = document.getElementById('toast-container');
611
+ const el = document.createElement('div');
612
+ el.className = 'toast';
613
+ el.innerHTML = `<div class="toast-title">${esc(title)}</div><div class="toast-body">${esc(body)}</div>`;
614
+ container.appendChild(el);
615
+ setTimeout(() => {
616
+ el.remove();
617
+ }, 4000);
618
+ }
619
+
620
+ // ---- Helpers ----
621
+
622
+ function esc(str) {
623
+ if (!str) return '';
624
+ return String(str)
625
+ .replace(/&/g, '&amp;')
626
+ .replace(/</g, '&lt;')
627
+ .replace(/>/g, '&gt;')
628
+ .replace(/"/g, '&quot;');
629
+ }
630
+
631
+ function formatDate(iso) {
632
+ if (!iso) return '\u2014';
633
+ try {
634
+ const d = new Date(iso + 'Z');
635
+ return d.toLocaleString(undefined, {
636
+ month: 'short',
637
+ day: 'numeric',
638
+ hour: '2-digit',
639
+ minute: '2-digit',
640
+ });
641
+ } catch {
642
+ return iso;
643
+ }
644
+ }
645
+
646
+ // ---- Boot ----
647
+
648
+ connect();