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.
- 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 +14 -9
- package/dist/domain/tasks.d.ts.map +1 -1
- package/dist/domain/tasks.js +176 -109
- 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 +8 -2
- package/dist/transport/rest.js.map +1 -1
- package/dist/transport/ws.d.ts.map +1 -1
- package/dist/transport/ws.js +7 -4
- package/dist/transport/ws.js.map +1 -1
- package/dist/ui/app.js +188 -1554
- 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 +200 -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: {},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
var icon = document.querySelector('.theme-icon');
|
|
163
73
|
if (icon) icon.textContent = theme === 'dark' ? 'light_mode' : 'dark_mode';
|
|
164
74
|
}
|
|
165
75
|
|
|
166
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
+
var _lastStateFingerprint = '';
|
|
228
244
|
|
|
229
245
|
function handleFullState(data) {
|
|
230
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
for (
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
state.commentCounts[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
+
var action = e.target.closest('[data-action]');
|
|
986
402
|
|
|
987
403
|
if (action) {
|
|
988
|
-
|
|
404
|
+
var act = action.dataset.action;
|
|
989
405
|
|
|
990
406
|
if (act === 'toggle-collapse') {
|
|
991
407
|
e.stopPropagation();
|
|
992
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
+
var col = e.target.closest('.kanban-column.collapsed');
|
|
1053
476
|
if (col) {
|
|
1054
|
-
|
|
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 (
|
|
1768
|
-
dismissDropdown();
|
|
499
|
+
if (TaskBoard.getActiveDropdown()) {
|
|
500
|
+
TaskBoard.dismissDropdown();
|
|
1769
501
|
return;
|
|
1770
502
|
}
|
|
1771
|
-
if (
|
|
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
|
-
|
|
511
|
+
var modal = document.getElementById('task-modal');
|
|
1780
512
|
if (!modal.hidden) {
|
|
1781
513
|
closeModal();
|
|
1782
514
|
return;
|
|
1783
515
|
}
|
|
1784
|
-
|
|
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
|
-
|
|
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, '&')
|
|
1837
|
-
.replace(/</g, '<')
|
|
1838
|
-
.replace(/>/g, '>')
|
|
1839
|
-
.replace(/"/g, '"');
|
|
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();
|