agent-tasks 1.7.1 → 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.
- package/README.md +17 -15
- package/dist/domain/agent-bridge.d.ts.map +1 -1
- package/dist/domain/agent-bridge.js +22 -2
- package/dist/domain/agent-bridge.js.map +1 -1
- package/dist/domain/approvals.d.ts.map +1 -1
- package/dist/domain/approvals.js +4 -1
- package/dist/domain/approvals.js.map +1 -1
- package/dist/domain/cleanup.d.ts.map +1 -1
- package/dist/domain/cleanup.js +8 -3
- package/dist/domain/cleanup.js.map +1 -1
- package/dist/domain/rules.js +11 -10
- package/dist/domain/rules.js.map +1 -1
- package/dist/domain/task-validator.d.ts +9 -0
- package/dist/domain/task-validator.d.ts.map +1 -0
- package/dist/domain/task-validator.js +70 -0
- package/dist/domain/task-validator.js.map +1 -0
- package/dist/domain/tasks.d.ts +13 -9
- package/dist/domain/tasks.d.ts.map +1 -1
- package/dist/domain/tasks.js +165 -111
- package/dist/domain/tasks.js.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/storage/database.d.ts.map +1 -1
- package/dist/storage/database.js +4 -2
- package/dist/storage/database.js.map +1 -1
- package/dist/transport/mcp-handlers.d.ts +30 -0
- package/dist/transport/mcp-handlers.d.ts.map +1 -0
- package/dist/transport/mcp-handlers.js +408 -0
- package/dist/transport/mcp-handlers.js.map +1 -0
- package/dist/transport/mcp.d.ts.map +1 -1
- package/dist/transport/mcp.js +196 -656
- package/dist/transport/mcp.js.map +1 -1
- package/dist/transport/rest.d.ts.map +1 -1
- package/dist/transport/rest.js +4 -1
- package/dist/transport/rest.js.map +1 -1
- package/dist/transport/ws.d.ts.map +1 -1
- package/dist/transport/ws.js +6 -4
- package/dist/transport/ws.js.map +1 -1
- package/dist/ui/app.js +186 -1608
- package/dist/ui/board.js +401 -0
- package/dist/ui/drag.js +143 -0
- package/dist/ui/index.html +5 -0
- package/dist/ui/inline-edit.js +242 -0
- package/dist/ui/panel.js +574 -0
- package/dist/ui/styles.css +109 -0
- package/dist/ui/ui-utils.js +323 -0
- package/package.json +1 -1
- package/dist/db.d.ts +0 -10
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js +0 -112
- package/dist/db.js.map +0 -1
- package/dist/event-bus.d.ts +0 -10
- package/dist/event-bus.d.ts.map +0 -1
- package/dist/event-bus.js +0 -38
- package/dist/event-bus.js.map +0 -1
- package/dist/session.d.ts +0 -7
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js +0 -11
- package/dist/session.js.map +0 -1
- package/dist/tasks.d.ts +0 -32
- package/dist/tasks.d.ts.map +0 -1
- package/dist/tasks.js +0 -410
- 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
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
var icon = document.querySelector('.theme-icon');
|
|
164
73
|
if (icon) icon.textContent = theme === 'dark' ? 'light_mode' : 'dark_mode';
|
|
165
74
|
}
|
|
166
75
|
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
+
var _lastStateFingerprint = '';
|
|
229
244
|
|
|
230
245
|
function handleFullState(data) {
|
|
231
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
for (
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
state.commentCounts[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
+
var action = e.target.closest('[data-action]');
|
|
1030
402
|
|
|
1031
403
|
if (action) {
|
|
1032
|
-
|
|
404
|
+
var act = action.dataset.action;
|
|
1033
405
|
|
|
1034
406
|
if (act === 'toggle-collapse') {
|
|
1035
407
|
e.stopPropagation();
|
|
1036
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
+
var col = e.target.closest('.kanban-column.collapsed');
|
|
1097
476
|
if (col) {
|
|
1098
|
-
|
|
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 (
|
|
1824
|
-
dismissDropdown();
|
|
499
|
+
if (TaskBoard.getActiveDropdown()) {
|
|
500
|
+
TaskBoard.dismissDropdown();
|
|
1825
501
|
return;
|
|
1826
502
|
}
|
|
1827
|
-
if (
|
|
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
|
-
|
|
511
|
+
var modal = document.getElementById('task-modal');
|
|
1836
512
|
if (!modal.hidden) {
|
|
1837
513
|
closeModal();
|
|
1838
514
|
return;
|
|
1839
515
|
}
|
|
1840
|
-
|
|
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
|
-
|
|
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, '&')
|
|
1893
|
-
.replace(/</g, '<')
|
|
1894
|
-
.replace(/>/g, '>')
|
|
1895
|
-
.replace(/"/g, '"');
|
|
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();
|