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