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