claude-code-kanban 2.0.1 → 2.1.0-rc.2
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 +32 -0
- package/hooks/context-status.sh +18 -0
- package/install.js +36 -22
- package/lib/parsers.js +51 -0
- package/package.json +6 -3
- package/public/app.js +3979 -0
- package/public/index.html +267 -5979
- package/public/style.css +3062 -0
- package/public/sw.js +4 -3
- package/server.js +160 -9
package/public/app.js
ADDED
|
@@ -0,0 +1,3979 @@
|
|
|
1
|
+
//#region STATE
|
|
2
|
+
let sessions = [];
|
|
3
|
+
let currentSessionId = null;
|
|
4
|
+
let currentTasks = [];
|
|
5
|
+
let viewMode = 'session';
|
|
6
|
+
let sessionFilter = 'active';
|
|
7
|
+
let sessionLimit = '20';
|
|
8
|
+
let filterProject = '__recent__'; // null = all, '__recent__' = last 24h, or project path
|
|
9
|
+
let recentProjects = new Set();
|
|
10
|
+
let projectsCacheDirty = true;
|
|
11
|
+
const collapsedProjectGroups = new Set();
|
|
12
|
+
let stableGroupOrder = []; // cached project path order to prevent jumping
|
|
13
|
+
let searchQuery = ''; // Search query for fuzzy search
|
|
14
|
+
let allTasksCache = []; // Cache all tasks for search
|
|
15
|
+
let bulkDeleteSessionId = null; // Track session for bulk delete
|
|
16
|
+
let ownerFilter = '';
|
|
17
|
+
let currentAgents = [];
|
|
18
|
+
let currentWaiting = null;
|
|
19
|
+
let lastAgentsHash = '';
|
|
20
|
+
let messagePanelOpen = false;
|
|
21
|
+
let lastMessagesHash = '';
|
|
22
|
+
let currentMessages = [];
|
|
23
|
+
let agentDurationInterval = null;
|
|
24
|
+
let selectedTaskId = null;
|
|
25
|
+
let selectedSessionId = null;
|
|
26
|
+
let focusZone = 'board'; // 'board' | 'sidebar'
|
|
27
|
+
let selectedSessionIdx = -1;
|
|
28
|
+
let selectedSessionKbId = null;
|
|
29
|
+
let sessionJustSelected = false;
|
|
30
|
+
let agentLogMode = null;
|
|
31
|
+
let agentLogSSE = null;
|
|
32
|
+
|
|
33
|
+
function getUrlState() {
|
|
34
|
+
const params = new URLSearchParams(window.location.search);
|
|
35
|
+
return {
|
|
36
|
+
session: params.get('session'),
|
|
37
|
+
view: params.get('view'),
|
|
38
|
+
filter: params.get('filter'),
|
|
39
|
+
limit: params.get('limit'),
|
|
40
|
+
project: params.get('project'),
|
|
41
|
+
owner: params.get('owner'),
|
|
42
|
+
search: params.get('search'),
|
|
43
|
+
messages: params.get('messages') === '1',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function updateUrl() {
|
|
48
|
+
const params = new URLSearchParams();
|
|
49
|
+
if (viewMode === 'all') params.set('view', 'all');
|
|
50
|
+
if (currentSessionId) params.set('session', currentSessionId);
|
|
51
|
+
if (sessionFilter !== 'active') params.set('filter', sessionFilter);
|
|
52
|
+
if (sessionLimit !== '20') params.set('limit', sessionLimit);
|
|
53
|
+
if (filterProject && filterProject !== '__recent__') params.set('project', filterProject);
|
|
54
|
+
if (ownerFilter) params.set('owner', ownerFilter);
|
|
55
|
+
if (searchQuery) params.set('search', searchQuery);
|
|
56
|
+
if (messagePanelOpen) params.set('messages', '1');
|
|
57
|
+
const qs = params.toString();
|
|
58
|
+
const url = qs ? `?${qs}` : window.location.pathname;
|
|
59
|
+
history.replaceState(null, '', url);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
63
|
+
function resetState() {
|
|
64
|
+
history.replaceState(null, '', window.location.pathname);
|
|
65
|
+
sessionFilter = 'active';
|
|
66
|
+
sessionLimit = '20';
|
|
67
|
+
filterProject = '__recent__';
|
|
68
|
+
ownerFilter = '';
|
|
69
|
+
searchQuery = '';
|
|
70
|
+
viewMode = 'all';
|
|
71
|
+
if (agentLogMode) exitAgentLogMode();
|
|
72
|
+
currentSessionId = null;
|
|
73
|
+
const searchInput = document.getElementById('search-input');
|
|
74
|
+
if (searchInput) searchInput.value = '';
|
|
75
|
+
document.getElementById('search-clear-btn')?.classList.remove('visible');
|
|
76
|
+
loadPreferences();
|
|
77
|
+
fetchSessions().then(() => showAllTasks());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
//#endregion
|
|
81
|
+
|
|
82
|
+
//#region DOM
|
|
83
|
+
const sessionsList = document.getElementById('sessions-list');
|
|
84
|
+
const noSession = document.getElementById('no-session');
|
|
85
|
+
const sessionView = document.getElementById('session-view');
|
|
86
|
+
const sessionTitle = document.getElementById('session-title');
|
|
87
|
+
const sessionMeta = document.getElementById('session-meta');
|
|
88
|
+
const progressPercent = document.getElementById('progress-percent');
|
|
89
|
+
const progressBar = document.getElementById('progress-bar');
|
|
90
|
+
const pendingTasks = document.getElementById('pending-tasks');
|
|
91
|
+
const inProgressTasks = document.getElementById('in-progress-tasks');
|
|
92
|
+
const completedTasks = document.getElementById('completed-tasks');
|
|
93
|
+
const pendingCount = document.getElementById('pending-count');
|
|
94
|
+
const inProgressCount = document.getElementById('in-progress-count');
|
|
95
|
+
const completedCount = document.getElementById('completed-count');
|
|
96
|
+
const detailPanel = document.getElementById('detail-panel');
|
|
97
|
+
const detailContent = document.getElementById('detail-content');
|
|
98
|
+
const connectionStatus = document.getElementById('connection-status');
|
|
99
|
+
const CONTENT_TRUNCATE_MAX = 1500;
|
|
100
|
+
const COLUMNS = [{ el: pendingTasks }, { el: inProgressTasks }, { el: completedTasks }];
|
|
101
|
+
|
|
102
|
+
let lastSessionsHash = '';
|
|
103
|
+
let lastTasksHash = '';
|
|
104
|
+
|
|
105
|
+
//#endregion
|
|
106
|
+
|
|
107
|
+
//#region DATA_FETCHING
|
|
108
|
+
async function fetchSessions() {
|
|
109
|
+
console.log('[fetchSessions] Starting...');
|
|
110
|
+
try {
|
|
111
|
+
const pinnedParam = pinnedSessionIds.size > 0 ? `&pinned=${[...pinnedSessionIds].join(',')}` : '';
|
|
112
|
+
const res = await fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}`);
|
|
113
|
+
const newSessions = await res.json();
|
|
114
|
+
const tasksRes = await fetch('/api/tasks/all');
|
|
115
|
+
const newTasks = await tasksRes.json();
|
|
116
|
+
|
|
117
|
+
const sessionsHash = JSON.stringify(newSessions);
|
|
118
|
+
const tasksHash = JSON.stringify(newTasks);
|
|
119
|
+
if (sessionsHash === lastSessionsHash && tasksHash === lastTasksHash) {
|
|
120
|
+
console.log('[fetchSessions] No changes, skipping render');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
lastSessionsHash = sessionsHash;
|
|
124
|
+
lastTasksHash = tasksHash;
|
|
125
|
+
|
|
126
|
+
sessions = newSessions;
|
|
127
|
+
allTasksCache = newTasks;
|
|
128
|
+
console.log('[fetchSessions] Sessions loaded:', sessions.length);
|
|
129
|
+
renderSessions();
|
|
130
|
+
console.log('[fetchSessions] Render complete');
|
|
131
|
+
renderLiveUpdatesFromCache();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error('Failed to fetch sessions:', error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
138
|
+
function handleSearch(query) {
|
|
139
|
+
searchQuery = query.toLowerCase().trim();
|
|
140
|
+
|
|
141
|
+
// Show/hide clear button
|
|
142
|
+
const clearBtn = document.getElementById('search-clear-btn');
|
|
143
|
+
if (searchQuery) {
|
|
144
|
+
clearBtn.classList.add('visible');
|
|
145
|
+
} else {
|
|
146
|
+
clearBtn.classList.remove('visible');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
updateUrl();
|
|
150
|
+
renderSessions();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
154
|
+
function clearSearch() {
|
|
155
|
+
const searchInput = document.getElementById('search-input');
|
|
156
|
+
searchInput.value = '';
|
|
157
|
+
searchQuery = '';
|
|
158
|
+
document.getElementById('search-clear-btn').classList.remove('visible');
|
|
159
|
+
updateUrl();
|
|
160
|
+
renderSessions();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
164
|
+
function deleteAllSessionTasks(sessionId) {
|
|
165
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
166
|
+
if (!session) return;
|
|
167
|
+
|
|
168
|
+
// When viewing a single session, currentTasks already contains only that session's tasks
|
|
169
|
+
// When viewing "All Tasks", tasks have sessionId property, so we filter
|
|
170
|
+
const sessionTasks =
|
|
171
|
+
currentSessionId === sessionId ? currentTasks : currentTasks.filter((t) => t.sessionId === sessionId);
|
|
172
|
+
|
|
173
|
+
if (sessionTasks.length === 0) {
|
|
174
|
+
alert('No tasks to delete in this session');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
bulkDeleteSessionId = sessionId;
|
|
179
|
+
|
|
180
|
+
const displayName = session.name || sessionId;
|
|
181
|
+
const message = `Delete all ${sessionTasks.length} task(s) from session "${displayName}"?`;
|
|
182
|
+
|
|
183
|
+
document.getElementById('delete-session-tasks-message').textContent = message;
|
|
184
|
+
|
|
185
|
+
const modal = document.getElementById('delete-session-tasks-modal');
|
|
186
|
+
modal.classList.add('visible');
|
|
187
|
+
|
|
188
|
+
// Handle ESC key
|
|
189
|
+
const keyHandler = (e) => {
|
|
190
|
+
if (e.key === 'Escape') {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
closeDeleteSessionTasksModal();
|
|
193
|
+
document.removeEventListener('keydown', keyHandler);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
document.addEventListener('keydown', keyHandler);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function closeDeleteSessionTasksModal() {
|
|
200
|
+
const modal = document.getElementById('delete-session-tasks-modal');
|
|
201
|
+
modal.classList.remove('visible');
|
|
202
|
+
bulkDeleteSessionId = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
206
|
+
async function confirmDeleteSessionTasks() {
|
|
207
|
+
if (!bulkDeleteSessionId) return;
|
|
208
|
+
|
|
209
|
+
const sessionId = bulkDeleteSessionId;
|
|
210
|
+
closeDeleteSessionTasksModal();
|
|
211
|
+
|
|
212
|
+
// Get tasks to delete
|
|
213
|
+
const sessionTasks =
|
|
214
|
+
currentSessionId === sessionId ? currentTasks : currentTasks.filter((t) => t.sessionId === sessionId);
|
|
215
|
+
|
|
216
|
+
// Sort tasks by dependency order (blocked tasks first, then blockers)
|
|
217
|
+
const sortedTasks = topologicalSort(sessionTasks);
|
|
218
|
+
|
|
219
|
+
let successCount = 0;
|
|
220
|
+
let failedCount = 0;
|
|
221
|
+
const failedTasks = [];
|
|
222
|
+
|
|
223
|
+
for (const task of sortedTasks) {
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetch(`/api/tasks/${sessionId}/${task.id}`, {
|
|
226
|
+
method: 'DELETE',
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (res.ok) {
|
|
230
|
+
successCount++;
|
|
231
|
+
} else {
|
|
232
|
+
failedCount++;
|
|
233
|
+
const error = await res.json();
|
|
234
|
+
failedTasks.push({ id: task.id, subject: task.subject, error: error.error });
|
|
235
|
+
console.error(`Failed to delete task ${task.id}:`, error);
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
failedCount++;
|
|
239
|
+
failedTasks.push({ id: task.id, subject: task.subject, error: 'Network error' });
|
|
240
|
+
console.error(`Error deleting task ${task.id}:`, error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Show result modal
|
|
245
|
+
showDeleteResultModal(successCount, failedCount, failedTasks);
|
|
246
|
+
|
|
247
|
+
// Close detail panel if open
|
|
248
|
+
closeDetailPanel();
|
|
249
|
+
|
|
250
|
+
// Refresh the view
|
|
251
|
+
await refreshCurrentView();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
//#endregion
|
|
255
|
+
|
|
256
|
+
//#region BULK_DELETE
|
|
257
|
+
// Topological sort for task deletion order
|
|
258
|
+
function topologicalSort(tasks) {
|
|
259
|
+
const result = [];
|
|
260
|
+
const visited = new Set();
|
|
261
|
+
const visiting = new Set();
|
|
262
|
+
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
|
263
|
+
|
|
264
|
+
function visit(taskId) {
|
|
265
|
+
if (visited.has(taskId)) return;
|
|
266
|
+
if (visiting.has(taskId)) return; // Cycle - skip
|
|
267
|
+
|
|
268
|
+
visiting.add(taskId);
|
|
269
|
+
const task = taskMap.get(taskId);
|
|
270
|
+
|
|
271
|
+
if (task?.blocks && task.blocks.length > 0) {
|
|
272
|
+
// Visit all tasks that this task blocks (dependencies first)
|
|
273
|
+
for (const blockedId of task.blocks) {
|
|
274
|
+
if (taskMap.has(blockedId)) {
|
|
275
|
+
visit(blockedId);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
visiting.delete(taskId);
|
|
281
|
+
visited.add(taskId);
|
|
282
|
+
if (task) result.push(task);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Visit all tasks
|
|
286
|
+
for (const task of tasks) {
|
|
287
|
+
visit(task.id);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function showDeleteResultModal(successCount, failedCount, failedTasks) {
|
|
294
|
+
const modal = document.getElementById('delete-result-modal');
|
|
295
|
+
const messageEl = document.getElementById('delete-result-message');
|
|
296
|
+
const detailsEl = document.getElementById('delete-result-details');
|
|
297
|
+
|
|
298
|
+
if (failedCount === 0) {
|
|
299
|
+
messageEl.textContent = `Successfully deleted all ${successCount} task(s).`;
|
|
300
|
+
detailsEl.style.display = 'none';
|
|
301
|
+
} else {
|
|
302
|
+
messageEl.textContent = `Deleted ${successCount} task(s). Failed to delete ${failedCount} task(s).`;
|
|
303
|
+
|
|
304
|
+
const failedList = failedTasks
|
|
305
|
+
.map((t) => `<li><strong>${escapeHtml(t.subject)}</strong> (#${escapeHtml(t.id)}): ${escapeHtml(t.error)}</li>`)
|
|
306
|
+
.join('');
|
|
307
|
+
detailsEl.innerHTML = `<ul style="margin: 8px 0 0 0; padding-left: 20px;">${failedList}</ul>`;
|
|
308
|
+
detailsEl.style.display = 'block';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
modal.classList.add('visible');
|
|
312
|
+
|
|
313
|
+
// Handle ESC key
|
|
314
|
+
const keyHandler = (e) => {
|
|
315
|
+
if (e.key === 'Escape') {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
closeDeleteResultModal();
|
|
318
|
+
document.removeEventListener('keydown', keyHandler);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
document.addEventListener('keydown', keyHandler);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function closeDeleteResultModal() {
|
|
325
|
+
const modal = document.getElementById('delete-result-modal');
|
|
326
|
+
modal.classList.remove('visible');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function fuzzyMatch(text, query) {
|
|
330
|
+
if (!query) return true;
|
|
331
|
+
if (!text) return false;
|
|
332
|
+
|
|
333
|
+
text = text.toLowerCase();
|
|
334
|
+
query = query.toLowerCase();
|
|
335
|
+
|
|
336
|
+
// Prioritize exact substring match
|
|
337
|
+
if (text.includes(query)) return true;
|
|
338
|
+
|
|
339
|
+
// Split by common delimiters to search in individual words
|
|
340
|
+
const words = text.split(/[\s\-_/.]+/);
|
|
341
|
+
|
|
342
|
+
// Check if query matches start of any word
|
|
343
|
+
for (const word of words) {
|
|
344
|
+
if (word.startsWith(query)) return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Check if any word contains the query
|
|
348
|
+
for (const word of words) {
|
|
349
|
+
if (word.includes(query)) return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
//#endregion
|
|
356
|
+
|
|
357
|
+
//#region LIVE_UPDATES
|
|
358
|
+
function renderLiveUpdatesFromCache() {
|
|
359
|
+
let activeTasks = allTasksCache.filter((t) => t.status === 'in_progress' && !isInternalTask(t));
|
|
360
|
+
if (filterProject) {
|
|
361
|
+
activeTasks = activeTasks.filter((t) => matchesProjectFilter(t.project));
|
|
362
|
+
}
|
|
363
|
+
renderLiveUpdates(activeTasks);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function toggleSection(containerId, chevronId) {
|
|
367
|
+
const container = document.getElementById(containerId);
|
|
368
|
+
const chevron = document.getElementById(chevronId);
|
|
369
|
+
const collapsed = container.classList.toggle('collapsed');
|
|
370
|
+
chevron.classList.toggle('rotated', collapsed);
|
|
371
|
+
localStorage.setItem(`${containerId}Collapsed`, collapsed);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
375
|
+
function toggleLiveUpdates() {
|
|
376
|
+
toggleSection('live-updates', 'live-updates-chevron');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function renderLiveUpdates(activeTasks) {
|
|
380
|
+
const container = document.getElementById('live-updates');
|
|
381
|
+
|
|
382
|
+
if (activeTasks.length === 0) {
|
|
383
|
+
container.innerHTML = '<div class="live-empty">No active tasks</div>';
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
container.innerHTML = activeTasks
|
|
388
|
+
.map(
|
|
389
|
+
(task) => `
|
|
390
|
+
<div class="live-item" onclick="openLiveTask('${task.sessionId}', '${task.id}')">
|
|
391
|
+
<span class="pulse"></span>
|
|
392
|
+
<div class="live-item-content">
|
|
393
|
+
<div class="live-item-action" title="${escapeHtml(task.activeForm || task.subject)}">${escapeHtml(task.activeForm || task.subject)}</div>
|
|
394
|
+
<div class="live-item-session" title="${escapeHtml(task.sessionName || task.sessionId)}">${escapeHtml(task.sessionName || task.sessionId)}</div>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
`,
|
|
398
|
+
)
|
|
399
|
+
.join('');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
403
|
+
async function openLiveTask(sessionId, taskId) {
|
|
404
|
+
await fetchTasks(sessionId);
|
|
405
|
+
showTaskDetail(taskId, sessionId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let lastCurrentTasksHash = '';
|
|
409
|
+
|
|
410
|
+
async function fetchTasks(sessionId) {
|
|
411
|
+
try {
|
|
412
|
+
viewMode = 'session';
|
|
413
|
+
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
414
|
+
|
|
415
|
+
let newTasks;
|
|
416
|
+
if (res.ok) {
|
|
417
|
+
newTasks = await res.json();
|
|
418
|
+
} else if (res.status === 404) {
|
|
419
|
+
newTasks = [];
|
|
420
|
+
} else {
|
|
421
|
+
throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const hash = JSON.stringify(newTasks);
|
|
425
|
+
if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
|
|
426
|
+
console.log('[fetchTasks] No changes, skipping render');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
lastCurrentTasksHash = hash;
|
|
430
|
+
|
|
431
|
+
currentTasks = newTasks;
|
|
432
|
+
if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
|
|
433
|
+
if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
|
|
434
|
+
closeScratchpad();
|
|
435
|
+
currentSessionId = sessionId;
|
|
436
|
+
currentPins = loadPins(sessionId);
|
|
437
|
+
ownerFilter = '';
|
|
438
|
+
lastMessagesHash = '';
|
|
439
|
+
sessionJustSelected = true;
|
|
440
|
+
updateUrl();
|
|
441
|
+
renderSession();
|
|
442
|
+
fetchAgents(sessionId);
|
|
443
|
+
if (!agentLogMode) fetchMessages(sessionId);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.error('Failed to fetch tasks:', error);
|
|
446
|
+
currentTasks = [];
|
|
447
|
+
currentSessionId = sessionId;
|
|
448
|
+
lastCurrentTasksHash = '';
|
|
449
|
+
updateUrl();
|
|
450
|
+
renderSession();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const _AGENT_COOLDOWN_MS = 3 * 60 * 1000;
|
|
455
|
+
const _AGENT_STALE_MS = 5 * 60 * 1000; // kept for reference; no longer used for force-stopping
|
|
456
|
+
const WAITING_TTL_MS = 30 * 60 * 1000;
|
|
457
|
+
const AGENT_LOG_MAX = 8;
|
|
458
|
+
|
|
459
|
+
async function fetchAgents(sessionId) {
|
|
460
|
+
try {
|
|
461
|
+
const res = await fetch(`/api/sessions/${sessionId}/agents`);
|
|
462
|
+
if (!res.ok) {
|
|
463
|
+
currentAgents = [];
|
|
464
|
+
currentWaiting = null;
|
|
465
|
+
renderAgentFooter();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const data = await res.json();
|
|
469
|
+
const agents = Array.isArray(data) ? data : data.agents || [];
|
|
470
|
+
currentWaiting = data.waitingForUser || null;
|
|
471
|
+
const hash = JSON.stringify(data);
|
|
472
|
+
if (hash === lastAgentsHash) return;
|
|
473
|
+
lastAgentsHash = hash;
|
|
474
|
+
currentAgents = agents;
|
|
475
|
+
renderAgentFooter();
|
|
476
|
+
} catch (e) {
|
|
477
|
+
console.error('[fetchAgents]', e);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
//#endregion
|
|
482
|
+
|
|
483
|
+
//#region MESSAGE_PANEL
|
|
484
|
+
function toggleMessagePanel() {
|
|
485
|
+
const panel = document.getElementById('message-panel');
|
|
486
|
+
messagePanelOpen = !messagePanelOpen;
|
|
487
|
+
panel.classList.toggle('visible', messagePanelOpen);
|
|
488
|
+
document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
|
|
489
|
+
if (messagePanelOpen && currentSessionId) {
|
|
490
|
+
if (currentMessages.length) renderMessages(currentMessages);
|
|
491
|
+
fetchMessages(currentSessionId);
|
|
492
|
+
}
|
|
493
|
+
updateUrl();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
497
|
+
function viewAgentLog(agentId) {
|
|
498
|
+
const agent = currentAgents.find((a) => a.agentId === agentId);
|
|
499
|
+
if (!agent) return;
|
|
500
|
+
const shortId = agentId.length > 8 ? agentId.slice(0, 8) : agentId;
|
|
501
|
+
agentLogMode = { agentId, sessionId: currentSessionId, agentType: agent.type || 'unknown' };
|
|
502
|
+
closeAgentModal();
|
|
503
|
+
if (!messagePanelOpen) toggleMessagePanel();
|
|
504
|
+
const header = document.querySelector('.message-panel-header h3');
|
|
505
|
+
if (header) {
|
|
506
|
+
header.innerHTML = `<span class="agent-log-title"><button class="agent-log-back" onclick="exitAgentLogMode()" title="Back to session log">←</button> ${escapeHtml(agent.type || 'unknown')} <code class="agent-log-id">(${escapeHtml(shortId)})</code></span>`;
|
|
507
|
+
}
|
|
508
|
+
fetchAgentMessages();
|
|
509
|
+
if (agentLogSSE) {
|
|
510
|
+
agentLogSSE.close();
|
|
511
|
+
agentLogSSE = null;
|
|
512
|
+
}
|
|
513
|
+
agentLogSSE = new EventSource(`/api/sessions/${agentLogMode.sessionId}/agents/${agentId}/messages/stream`);
|
|
514
|
+
agentLogSSE.addEventListener('agent-log-update', (e) => {
|
|
515
|
+
if (!agentLogMode || agentLogMode.agentId !== agentId) return;
|
|
516
|
+
try {
|
|
517
|
+
const data = JSON.parse(e.data);
|
|
518
|
+
currentMessages = data.messages;
|
|
519
|
+
if (messagePanelOpen) renderMessages(data.messages);
|
|
520
|
+
} catch (_) {}
|
|
521
|
+
});
|
|
522
|
+
agentLogSSE.onerror = () => {};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function exitAgentLogMode() {
|
|
526
|
+
agentLogMode = null;
|
|
527
|
+
if (agentLogSSE) {
|
|
528
|
+
agentLogSSE.close();
|
|
529
|
+
agentLogSSE = null;
|
|
530
|
+
}
|
|
531
|
+
const header = document.querySelector('.message-panel-header h3');
|
|
532
|
+
if (header) header.textContent = 'Session Log';
|
|
533
|
+
lastMessagesHash = '';
|
|
534
|
+
if (currentSessionId) fetchMessages(currentSessionId);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function fetchAgentMessages() {
|
|
538
|
+
if (!agentLogMode) return;
|
|
539
|
+
const { sessionId, agentId } = agentLogMode;
|
|
540
|
+
try {
|
|
541
|
+
const res = await fetch(`/api/sessions/${sessionId}/agents/${agentId}/messages?limit=100`);
|
|
542
|
+
if (!res.ok || !agentLogMode || agentLogMode.agentId !== agentId) return;
|
|
543
|
+
const data = await res.json();
|
|
544
|
+
if (!agentLogMode || agentLogMode.agentId !== agentId) return;
|
|
545
|
+
currentMessages = data.messages;
|
|
546
|
+
if (messagePanelOpen) renderMessages(data.messages);
|
|
547
|
+
} catch (e) {
|
|
548
|
+
console.error('[fetchAgentMessages]', e);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
553
|
+
function openLiveLatestMessage() {
|
|
554
|
+
if (currentMessages.length) {
|
|
555
|
+
msgDetailFollowLatest = true;
|
|
556
|
+
showMsgDetail(currentMessages.length - 1);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function fetchMessages(sessionId) {
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch(`/api/sessions/${sessionId}/messages?limit=15`);
|
|
563
|
+
if (!res.ok) return;
|
|
564
|
+
const data = await res.json();
|
|
565
|
+
const hash = JSON.stringify(data.messages);
|
|
566
|
+
if (hash === lastMessagesHash) return;
|
|
567
|
+
lastMessagesHash = hash;
|
|
568
|
+
let agentEnriched = false;
|
|
569
|
+
for (const m of data.messages) {
|
|
570
|
+
if (m.agentId && m.agentPrompt) {
|
|
571
|
+
const agent = currentAgents.find((a) => a.agentId === m.agentId);
|
|
572
|
+
if (agent && !agent.prompt) {
|
|
573
|
+
agent.prompt = m.agentPrompt;
|
|
574
|
+
agentEnriched = true;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (agentEnriched) renderAgentFooter();
|
|
579
|
+
if (agentLogMode) return;
|
|
580
|
+
currentMessages = data.messages;
|
|
581
|
+
if (messagePanelOpen) renderMessages(data.messages);
|
|
582
|
+
if (msgDetailFollowLatest && data.messages.length) {
|
|
583
|
+
showMsgDetail(data.messages.length - 1);
|
|
584
|
+
}
|
|
585
|
+
} catch (e) {
|
|
586
|
+
console.error('[fetchMessages]', e);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function parseCommandMessage(text) {
|
|
591
|
+
const nameMatch = text.match(/<command-name>([^<]+)<\/command-name>/);
|
|
592
|
+
if (nameMatch) return nameMatch[1].trim();
|
|
593
|
+
const msgMatch = text.match(/<command-message>([^<]+)<\/command-message>/);
|
|
594
|
+
if (msgMatch) return `/${msgMatch[1].trim()}`;
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function cleanMessageText(text) {
|
|
599
|
+
const cmd = parseCommandMessage(text);
|
|
600
|
+
if (cmd) return cmd;
|
|
601
|
+
return stripAnsi(text)
|
|
602
|
+
.replace(/<[^>]+>/g, '')
|
|
603
|
+
.replace(/\*\*/g, '')
|
|
604
|
+
.replace(/^#+\s*/gm, '')
|
|
605
|
+
.replace(/\n/g, ' ')
|
|
606
|
+
.replace(/\s+/g, ' ')
|
|
607
|
+
.trim();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function renderMsgPinBtn(m, i) {
|
|
611
|
+
const pinned = isPinned(m);
|
|
612
|
+
return `<button class="msg-pin-btn${pinned ? ' pinned' : ''}" onclick="event.stopPropagation();togglePin(${i})" title="${pinned ? 'Unpin' : 'Pin'} message">${PIN_SVG}</button>`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function renderPinnedSection() {
|
|
616
|
+
if (!currentPins.length) return '';
|
|
617
|
+
const chevron =
|
|
618
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M6 9l6 6 6-6"/></svg>';
|
|
619
|
+
const items = currentPins
|
|
620
|
+
.map((p, pi) => {
|
|
621
|
+
const click = `onclick="showPinnedMsgDetail(${pi})" style="cursor:pointer"`;
|
|
622
|
+
const unpin = `<button class="pinned-item-unpin" onclick="event.stopPropagation();unpinById(${pi})" title="Unpin">${PIN_SVG}</button>`;
|
|
623
|
+
if (p.type === 'user') {
|
|
624
|
+
const text = escapeHtml(cleanMessageText(p.text || ''));
|
|
625
|
+
return `<div class="msg-item msg-user" ${click}>
|
|
626
|
+
${MSG_ICON_USER}
|
|
627
|
+
<div class="msg-body"><div class="msg-text">${text}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${unpin}
|
|
628
|
+
</div>`;
|
|
629
|
+
} else if (p.type === 'assistant') {
|
|
630
|
+
return `<div class="msg-item msg-assistant" ${click}>
|
|
631
|
+
${MSG_ICON_ASSISTANT}
|
|
632
|
+
<div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(p.text || ''))}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${unpin}
|
|
633
|
+
</div>`;
|
|
634
|
+
} else if (p.type === 'tool_use') {
|
|
635
|
+
const toolDetail = p.detail ? ` <span style="color:var(--text-muted)">${escapeHtml(p.detail)}</span>` : '';
|
|
636
|
+
const pinnedAgentLogBtn = p.tool === 'Agent' && p.agentId ? agentLogButton(p.agentId) : '';
|
|
637
|
+
return `<div class="msg-item msg-tool" ${click}>
|
|
638
|
+
${MSG_ICON_TOOL}
|
|
639
|
+
<div class="msg-body"><div class="msg-text">${escapeHtml(p.tool || '')}${toolDetail}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${pinnedAgentLogBtn}${unpin}
|
|
640
|
+
</div>`;
|
|
641
|
+
} else if (p.type === 'agent') {
|
|
642
|
+
const agentClick = `onclick="showAgentModal('${escapeHtml(p.agentId)}')" style="cursor:pointer"`;
|
|
643
|
+
const agentLogBtn = agentLogButton(p.agentId);
|
|
644
|
+
const msgTrunc = p.lastMessage
|
|
645
|
+
? escapeHtml(
|
|
646
|
+
stripAnsi(p.lastMessage.trim())
|
|
647
|
+
.replace(/[\r\n]+/g, ' ')
|
|
648
|
+
.slice(0, 60),
|
|
649
|
+
)
|
|
650
|
+
: '';
|
|
651
|
+
const agentDetail = msgTrunc ? ` <span style="color:var(--text-muted)">${msgTrunc}</span>` : '';
|
|
652
|
+
return `<div class="msg-item msg-tool" ${agentClick}>
|
|
653
|
+
${MSG_ICON_TOOL}
|
|
654
|
+
<div class="msg-body"><div class="msg-text">${escapeHtml(p.agentType || 'Agent')}${agentDetail}</div><div class="msg-time">${formatDate(p.timestamp)}</div></div>${agentLogBtn}${unpin}
|
|
655
|
+
</div>`;
|
|
656
|
+
}
|
|
657
|
+
return '';
|
|
658
|
+
})
|
|
659
|
+
.join('');
|
|
660
|
+
const label = `Pinned (${currentPins.length})`;
|
|
661
|
+
const hasItems = currentPins.length > 0;
|
|
662
|
+
return `<div class="pinned-section">
|
|
663
|
+
<div class="pinned-header${pinnedCollapsed ? ' collapsed' : ''}${hasItems ? '' : ' empty'}" ${hasItems ? 'onclick="togglePinnedCollapse()"' : ''}>
|
|
664
|
+
<span>${label}</span>${hasItems ? chevron : ''}
|
|
665
|
+
</div>
|
|
666
|
+
${hasItems ? `<div class="pinned-items${pinnedCollapsed ? ' collapsed' : ''}">${items}</div>` : ''}
|
|
667
|
+
</div>`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function renderMessages(messages) {
|
|
671
|
+
const container = document.getElementById('message-panel-content');
|
|
672
|
+
const pinnedContainer = document.getElementById('message-panel-pinned');
|
|
673
|
+
pinnedContainer.innerHTML = agentLogMode ? '' : renderPinnedSection();
|
|
674
|
+
if (!messages.length) {
|
|
675
|
+
container.innerHTML = '<div class="msg-empty">No messages found for this session</div>';
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const msgsHtml = messages
|
|
679
|
+
.map((m, i) => {
|
|
680
|
+
const pinBtn = renderMsgPinBtn(m, i);
|
|
681
|
+
const clickable = `onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
|
|
682
|
+
if (m.type === 'user') {
|
|
683
|
+
if (m.systemLabel) {
|
|
684
|
+
return `<div class="msg-item msg-system" ${clickable}>
|
|
685
|
+
${MSG_ICON_SYSTEM}
|
|
686
|
+
<div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
|
|
687
|
+
</div>`;
|
|
688
|
+
}
|
|
689
|
+
const cmd = parseCommandMessage(m.text);
|
|
690
|
+
const displayText = cmd ? cmd : escapeHtml(cleanMessageText(m.text));
|
|
691
|
+
const isCmd = !!cmd;
|
|
692
|
+
return `<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
|
|
693
|
+
${MSG_ICON_USER}
|
|
694
|
+
<div class="msg-body"><div class="msg-text">${isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
|
|
695
|
+
</div>`;
|
|
696
|
+
} else if (m.type === 'assistant') {
|
|
697
|
+
return `<div class="msg-item msg-assistant" ${clickable}>
|
|
698
|
+
${MSG_ICON_ASSISTANT}
|
|
699
|
+
<div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div><div class="msg-time">${m.model ? `${escapeHtml(m.model)} · ` : ''}${formatDate(m.timestamp)}</div></div>${pinBtn}
|
|
700
|
+
</div>`;
|
|
701
|
+
} else if (m.type === 'tool_use') {
|
|
702
|
+
const toolDetail = m.detail ? ` <span style="color:var(--text-muted)">${escapeHtml(m.detail)}</span>` : '';
|
|
703
|
+
const agentLink =
|
|
704
|
+
m.tool === 'Agent' && m.agentId
|
|
705
|
+
? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
|
|
706
|
+
: '';
|
|
707
|
+
const agentLogBtn = m.tool === 'Agent' && m.agentId ? agentLogButton(m.agentId) : '';
|
|
708
|
+
const itemClick =
|
|
709
|
+
m.tool === 'Agent' && m.agentId
|
|
710
|
+
? `onclick="showAgentModal('${escapeHtml(m.agentId)}')" style="cursor:pointer"`
|
|
711
|
+
: clickable;
|
|
712
|
+
return `<div class="msg-item msg-tool" ${itemClick}>
|
|
713
|
+
${MSG_ICON_TOOL}
|
|
714
|
+
<div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
|
|
715
|
+
</div>`;
|
|
716
|
+
} else if (m.type === 'teammate') {
|
|
717
|
+
const nameSpan = `<span class="teammate-name" style="${m.color ? `color:${escapeHtml(m.color)}` : ''}">${escapeHtml(m.teammateId || 'teammate')}</span>`;
|
|
718
|
+
if (m.isIdle) {
|
|
719
|
+
return `<div class="msg-item msg-teammate msg-idle" ${clickable}>
|
|
720
|
+
${MSG_ICON_IDLE}
|
|
721
|
+
<div class="msg-body"><div class="msg-text">${nameSpan} <span class="idle-label">${escapeHtml(m.protocolLabel || 'idle')}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>
|
|
722
|
+
</div>`;
|
|
723
|
+
}
|
|
724
|
+
if (m.isProtocol) {
|
|
725
|
+
return `<div class="msg-item msg-teammate msg-protocol" ${clickable}>
|
|
726
|
+
${MSG_ICON_TEAMMATE}
|
|
727
|
+
<div class="msg-body"><div class="msg-text">${nameSpan} <span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>
|
|
728
|
+
</div>`;
|
|
729
|
+
}
|
|
730
|
+
const summaryText = m.summary ? escapeHtml(m.summary) : escapeHtml((m.text || '').slice(0, 80));
|
|
731
|
+
return `<div class="msg-item msg-teammate" ${clickable}>
|
|
732
|
+
${MSG_ICON_TEAMMATE}
|
|
733
|
+
<div class="msg-body"><div class="msg-text">${nameSpan} ${summaryText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
|
|
734
|
+
</div>`;
|
|
735
|
+
}
|
|
736
|
+
return '';
|
|
737
|
+
})
|
|
738
|
+
.join('');
|
|
739
|
+
container.innerHTML = msgsHtml;
|
|
740
|
+
container.scrollTop = container.scrollHeight;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
let currentMsgDetailIdx = null;
|
|
744
|
+
let msgDetailFollowLatest = false;
|
|
745
|
+
let currentPins = [];
|
|
746
|
+
let pinnedCollapsed = false;
|
|
747
|
+
|
|
748
|
+
const PIN_SVG =
|
|
749
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
|
|
750
|
+
const MSG_ICON_USER =
|
|
751
|
+
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
|
752
|
+
const MSG_ICON_ASSISTANT =
|
|
753
|
+
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="9" cy="16" r="1.5"/><circle cx="15" cy="16" r="1.5"/><path d="M12 2v4M8 7h8"/></svg>';
|
|
754
|
+
const MSG_ICON_TOOL =
|
|
755
|
+
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>';
|
|
756
|
+
const MSG_ICON_SYSTEM =
|
|
757
|
+
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
|
|
758
|
+
const MSG_ICON_TEAMMATE =
|
|
759
|
+
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
|
|
760
|
+
const MSG_ICON_IDLE =
|
|
761
|
+
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6"/></svg>';
|
|
762
|
+
const AGENT_LOG_ICON =
|
|
763
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
|
764
|
+
function agentLogButton(agentId) {
|
|
765
|
+
return `<button class="msg-agent-log-btn" onclick="event.stopPropagation();viewAgentLog('${escapeHtml(agentId)}')" title="View agent log">${AGENT_LOG_ICON}</button>`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function getPinId(m) {
|
|
769
|
+
const content = m.type === 'tool_use' ? `${m.tool}:${(m.detail || '').slice(0, 100)}` : (m.text || '').slice(0, 100);
|
|
770
|
+
return `${m.type}|${m.timestamp}|${content}`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function loadPins(sessionId) {
|
|
774
|
+
try {
|
|
775
|
+
return JSON.parse(localStorage.getItem(`pinned-messages-${sessionId}`)) || [];
|
|
776
|
+
} catch {
|
|
777
|
+
return [];
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function savePins(sessionId, pins) {
|
|
782
|
+
localStorage.setItem(`pinned-messages-${sessionId}`, JSON.stringify(pins));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function isPinned(m) {
|
|
786
|
+
return currentPins.some((p) => p.id === getPinId(m));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function isAgentPinned(agentId) {
|
|
790
|
+
return currentPins.some((p) => p.id === `agent|${agentId}`);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function toggleAgentPin(agentId) {
|
|
794
|
+
const agent = currentAgents.find((a) => a.agentId === agentId);
|
|
795
|
+
if (!agent || !currentSessionId) return;
|
|
796
|
+
const id = `agent|${agentId}`;
|
|
797
|
+
const idx = currentPins.findIndex((p) => p.id === id);
|
|
798
|
+
if (idx >= 0) {
|
|
799
|
+
currentPins.splice(idx, 1);
|
|
800
|
+
} else {
|
|
801
|
+
pinnedCollapsed = false;
|
|
802
|
+
currentPins.push({
|
|
803
|
+
id,
|
|
804
|
+
type: 'agent',
|
|
805
|
+
agentId: agent.agentId,
|
|
806
|
+
agentType: agent.type || 'unknown',
|
|
807
|
+
lastMessage: agent.lastMessage || null,
|
|
808
|
+
timestamp: agent.startedAt || agent.updatedAt,
|
|
809
|
+
pinnedAt: new Date().toISOString(),
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
savePins(currentSessionId, currentPins);
|
|
813
|
+
renderMessages(currentMessages);
|
|
814
|
+
renderAgentFooter();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function togglePin(msgIndex) {
|
|
818
|
+
const m = currentMessages[msgIndex];
|
|
819
|
+
if (!m || !currentSessionId) return;
|
|
820
|
+
const id = getPinId(m);
|
|
821
|
+
const idx = currentPins.findIndex((p) => p.id === id);
|
|
822
|
+
if (idx >= 0) {
|
|
823
|
+
currentPins.splice(idx, 1);
|
|
824
|
+
} else {
|
|
825
|
+
pinnedCollapsed = false;
|
|
826
|
+
currentPins.push({
|
|
827
|
+
id,
|
|
828
|
+
type: m.type,
|
|
829
|
+
text: m.text || null,
|
|
830
|
+
fullText: m.fullText || null,
|
|
831
|
+
tool: m.tool || null,
|
|
832
|
+
detail: m.detail || null,
|
|
833
|
+
fullDetail: m.fullDetail || null,
|
|
834
|
+
description: m.description || null,
|
|
835
|
+
timestamp: m.timestamp,
|
|
836
|
+
model: m.model || null,
|
|
837
|
+
agentId: m.agentId || null,
|
|
838
|
+
agentPrompt: m.agentPrompt || null,
|
|
839
|
+
agentLastMessage: m.agentLastMessage || null,
|
|
840
|
+
pinnedAt: new Date().toISOString(),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
savePins(currentSessionId, currentPins);
|
|
844
|
+
renderMessages(currentMessages);
|
|
845
|
+
updateMsgDetailPinState();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function unpinById(pinIdx) {
|
|
849
|
+
if (!currentSessionId || pinIdx < 0 || pinIdx >= currentPins.length) return;
|
|
850
|
+
const wasAgent = currentPins[pinIdx].type === 'agent';
|
|
851
|
+
currentPins.splice(pinIdx, 1);
|
|
852
|
+
savePins(currentSessionId, currentPins);
|
|
853
|
+
renderMessages(currentMessages);
|
|
854
|
+
if (wasAgent) renderAgentFooter();
|
|
855
|
+
updateMsgDetailPinState();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
859
|
+
function togglePinFromModal() {
|
|
860
|
+
if (currentMsgDetailIdx != null && currentMessages[currentMsgDetailIdx]) {
|
|
861
|
+
togglePin(currentMsgDetailIdx);
|
|
862
|
+
} else if (currentPinDetailId != null) {
|
|
863
|
+
const pinIdx = currentPins.findIndex((p) => p.id === currentPinDetailId);
|
|
864
|
+
if (pinIdx >= 0) unpinById(pinIdx);
|
|
865
|
+
currentPinDetailId = null;
|
|
866
|
+
closeMsgDetailModal();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
let currentPinDetailId = null;
|
|
871
|
+
|
|
872
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
873
|
+
function showPinnedMsgDetail(pinIdx) {
|
|
874
|
+
const pin = currentPins[pinIdx];
|
|
875
|
+
if (!pin) return;
|
|
876
|
+
const idx = currentMessages.findIndex((m) => getPinId(m) === pin.id);
|
|
877
|
+
if (idx >= 0) {
|
|
878
|
+
currentPinDetailId = null;
|
|
879
|
+
showMsgDetail(idx);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
currentMsgDetailIdx = null;
|
|
883
|
+
currentPinDetailId = pin.id;
|
|
884
|
+
const body = document.getElementById('msg-detail-body');
|
|
885
|
+
const agentBtn = document.getElementById('msg-detail-agent-btn');
|
|
886
|
+
if (pin.type === 'tool_use') {
|
|
887
|
+
document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
|
|
888
|
+
const fullText = pin.fullDetail || pin.detail || '';
|
|
889
|
+
const pinParamsHtml = renderToolParamsHtml(pin.params);
|
|
890
|
+
const pinResultHtml = renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull);
|
|
891
|
+
const pinDetailEscaped = escapeHtml(fullText);
|
|
892
|
+
const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
|
|
893
|
+
body.innerHTML =
|
|
894
|
+
(fullText ? `<pre class="msg-detail-pre">${pinDetailRendered}</pre>` : '<em>No details</em>') +
|
|
895
|
+
pinParamsHtml +
|
|
896
|
+
pinResultHtml;
|
|
897
|
+
agentBtn.style.display = 'none';
|
|
898
|
+
} else {
|
|
899
|
+
const text = stripAnsi(pin.fullText || pin.text || '');
|
|
900
|
+
document.getElementById('msg-detail-title').textContent = pin.type === 'assistant' ? 'Claude' : 'User';
|
|
901
|
+
agentBtn.style.display = 'none';
|
|
902
|
+
body.innerHTML = renderMarkdown(text);
|
|
903
|
+
}
|
|
904
|
+
document.getElementById('msg-detail-meta').textContent = formatDate(pin.timestamp);
|
|
905
|
+
const pinModal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
906
|
+
autoSizeModal(pinModal, body);
|
|
907
|
+
const pinBtn = document.getElementById('msg-detail-pin-btn');
|
|
908
|
+
if (pinBtn) pinBtn.classList.add('active');
|
|
909
|
+
document.getElementById('msg-detail-modal').classList.add('visible');
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function updateMsgDetailPinState() {
|
|
913
|
+
const pinBtn = document.getElementById('msg-detail-pin-btn');
|
|
914
|
+
if (!pinBtn) return;
|
|
915
|
+
if (currentMsgDetailIdx != null && currentMessages[currentMsgDetailIdx]) {
|
|
916
|
+
pinBtn.classList.toggle('active', isPinned(currentMessages[currentMsgDetailIdx]));
|
|
917
|
+
} else if (currentPinDetailId) {
|
|
918
|
+
pinBtn.classList.toggle(
|
|
919
|
+
'active',
|
|
920
|
+
currentPins.some((p) => p.id === currentPinDetailId),
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
926
|
+
function togglePinnedCollapse() {
|
|
927
|
+
pinnedCollapsed = !pinnedCollapsed;
|
|
928
|
+
const header = document.querySelector('.pinned-header');
|
|
929
|
+
const items = document.querySelector('.pinned-items');
|
|
930
|
+
if (header) header.classList.toggle('collapsed', pinnedCollapsed);
|
|
931
|
+
if (items) items.classList.toggle('collapsed', pinnedCollapsed);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
//#endregion
|
|
935
|
+
|
|
936
|
+
//#region PINNING
|
|
937
|
+
let pinnedSessionIds = new Set();
|
|
938
|
+
|
|
939
|
+
function loadPinnedSessions() {
|
|
940
|
+
try {
|
|
941
|
+
return new Set(JSON.parse(localStorage.getItem('pinned-sessions')) || []);
|
|
942
|
+
} catch {
|
|
943
|
+
return new Set();
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function savePinnedSessions() {
|
|
948
|
+
localStorage.setItem('pinned-sessions', JSON.stringify([...pinnedSessionIds]));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
952
|
+
function toggleSessionPin(sessionId) {
|
|
953
|
+
if (pinnedSessionIds.has(sessionId)) pinnedSessionIds.delete(sessionId);
|
|
954
|
+
else pinnedSessionIds.add(sessionId);
|
|
955
|
+
savePinnedSessions();
|
|
956
|
+
renderSessions();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const SESSION_PIN_SVG = PIN_SVG.replace('width="14" height="14"', 'width="12" height="12"');
|
|
960
|
+
|
|
961
|
+
//#endregion
|
|
962
|
+
|
|
963
|
+
//#region MODALS
|
|
964
|
+
function showMsgDetail(idx) {
|
|
965
|
+
currentMsgDetailIdx = idx;
|
|
966
|
+
const m = currentMessages[idx];
|
|
967
|
+
if (!m) return;
|
|
968
|
+
const body = document.getElementById('msg-detail-body');
|
|
969
|
+
if (m.type === 'tool_use') {
|
|
970
|
+
document.getElementById('msg-detail-title').textContent = m.tool;
|
|
971
|
+
const fullText = m.fullDetail || m.detail || '';
|
|
972
|
+
const descHtml =
|
|
973
|
+
m.description && m.description !== fullText
|
|
974
|
+
? `<div style="margin-bottom:8px;color:var(--text-secondary);font-size:0.85rem">${escapeHtml(m.description)}</div>`
|
|
975
|
+
: '';
|
|
976
|
+
let agentExtraHtml = '';
|
|
977
|
+
const agentBtn = document.getElementById('msg-detail-agent-btn');
|
|
978
|
+
if (m.tool === 'Agent' && m.agentId) {
|
|
979
|
+
const agentRespText = m.agentLastMessage ? stripAnsi(m.agentLastMessage.trim()) : null;
|
|
980
|
+
const agentPromptText = m.agentPrompt || null;
|
|
981
|
+
const respHtml = agentRespText ? renderMarkdown(agentRespText) : null;
|
|
982
|
+
const promptHtml = agentPromptText ? renderMarkdown(agentPromptText) : null;
|
|
983
|
+
agentExtraHtml += renderAgentTabs(promptHtml, respHtml, agentPromptText, agentRespText);
|
|
984
|
+
agentBtn.style.display = '';
|
|
985
|
+
agentBtn.dataset.agentId = m.agentId;
|
|
986
|
+
} else {
|
|
987
|
+
agentBtn.style.display = 'none';
|
|
988
|
+
}
|
|
989
|
+
const toolParamsHtml = renderToolParamsHtml(m.params);
|
|
990
|
+
const toolResultHtml = renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull);
|
|
991
|
+
const hasAgentTabs = m.tool === 'Agent' && m.agentId && (m.agentLastMessage || m.agentPrompt);
|
|
992
|
+
let mainHtml;
|
|
993
|
+
if (hasAgentTabs) {
|
|
994
|
+
mainHtml = descHtml || '';
|
|
995
|
+
} else if (fullText) {
|
|
996
|
+
const detailEscaped = escapeHtml(fullText);
|
|
997
|
+
const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
|
|
998
|
+
mainHtml = `${descHtml}<pre class="msg-detail-pre">${detailRendered}</pre>`;
|
|
999
|
+
} else {
|
|
1000
|
+
mainHtml = '<em>No details</em>';
|
|
1001
|
+
}
|
|
1002
|
+
body.innerHTML = mainHtml + toolParamsHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
|
|
1003
|
+
} else if (m.type === 'teammate') {
|
|
1004
|
+
document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
|
|
1005
|
+
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
1006
|
+
if (m.isProtocol) {
|
|
1007
|
+
body.innerHTML = `<div class="teammate-idle-detail"><span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div>`;
|
|
1008
|
+
} else {
|
|
1009
|
+
const text = stripAnsi(m.fullText || m.text || '');
|
|
1010
|
+
body.innerHTML = renderMarkdown(text);
|
|
1011
|
+
}
|
|
1012
|
+
} else {
|
|
1013
|
+
const text = stripAnsi(m.fullText || m.text);
|
|
1014
|
+
document.getElementById('msg-detail-title').textContent =
|
|
1015
|
+
m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
|
|
1016
|
+
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
1017
|
+
if (m.compactSummary) {
|
|
1018
|
+
body.innerHTML = renderMarkdown(m.compactSummary);
|
|
1019
|
+
} else {
|
|
1020
|
+
body.innerHTML = renderMarkdown(text);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
1024
|
+
autoSizeModal(modal, body);
|
|
1025
|
+
modal.classList.toggle('live', msgDetailFollowLatest);
|
|
1026
|
+
const overlay = document.getElementById('msg-detail-modal');
|
|
1027
|
+
overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
|
|
1028
|
+
|
|
1029
|
+
const meta = [formatDate(m.timestamp)];
|
|
1030
|
+
if (m.model) meta.unshift(m.model);
|
|
1031
|
+
meta.push(`${idx + 1} of ${currentMessages.length}`);
|
|
1032
|
+
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
1033
|
+
currentPinDetailId = null;
|
|
1034
|
+
updateMsgDetailPinState();
|
|
1035
|
+
overlay.classList.add('visible');
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function closeMsgDetailModal() {
|
|
1039
|
+
resetModalFullscreen('msg-detail-modal');
|
|
1040
|
+
msgDetailFollowLatest = false;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1044
|
+
function toggleModalFullscreen(modalId) {
|
|
1045
|
+
const modal = document.querySelector(`#${modalId} .modal`);
|
|
1046
|
+
const isFs = modal.classList.toggle('fullscreen');
|
|
1047
|
+
updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, isFs);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function resetModalFullscreen(modalId) {
|
|
1051
|
+
const modal = document.getElementById(modalId);
|
|
1052
|
+
modal.classList.remove('visible');
|
|
1053
|
+
modal.querySelector('.modal').classList.remove('fullscreen');
|
|
1054
|
+
updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, false);
|
|
1055
|
+
return modal;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function updateFullscreenBtnIcon(btnId, isFullscreen) {
|
|
1059
|
+
const btn = document.getElementById(btnId);
|
|
1060
|
+
if (!btn) return;
|
|
1061
|
+
btn.innerHTML = isFullscreen
|
|
1062
|
+
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
|
|
1063
|
+
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
let _toastTimer = null;
|
|
1067
|
+
//#endregion
|
|
1068
|
+
|
|
1069
|
+
//#region TOAST
|
|
1070
|
+
function showToast(msg) {
|
|
1071
|
+
const el = document.getElementById('toast');
|
|
1072
|
+
clearTimeout(_toastTimer);
|
|
1073
|
+
el.style.transition = 'none';
|
|
1074
|
+
el.classList.remove('visible');
|
|
1075
|
+
void el.offsetHeight;
|
|
1076
|
+
el.style.transition = '';
|
|
1077
|
+
el.textContent = msg;
|
|
1078
|
+
el.classList.add('visible');
|
|
1079
|
+
_toastTimer = setTimeout(() => el.classList.remove('visible'), 2000);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function copyWithFeedback(text, btn) {
|
|
1083
|
+
if (btn.dataset.copying) return;
|
|
1084
|
+
try {
|
|
1085
|
+
await navigator.clipboard.writeText(text);
|
|
1086
|
+
btn.dataset.copying = '1';
|
|
1087
|
+
const svg = btn.innerHTML;
|
|
1088
|
+
btn.innerHTML =
|
|
1089
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 6L9 17l-5-5"/></svg>';
|
|
1090
|
+
setTimeout(() => {
|
|
1091
|
+
btn.innerHTML = svg;
|
|
1092
|
+
delete btn.dataset.copying;
|
|
1093
|
+
}, 1500);
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
console.error('Failed to copy:', e);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
//#endregion
|
|
1100
|
+
|
|
1101
|
+
//#region TOOL_RENDERING
|
|
1102
|
+
function renderToolParamsHtml(params) {
|
|
1103
|
+
if (!params) return '';
|
|
1104
|
+
const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'plan']);
|
|
1105
|
+
const badges = [],
|
|
1106
|
+
blocks = [];
|
|
1107
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1108
|
+
if (BLOCK_KEYS.has(k)) continue;
|
|
1109
|
+
const display = typeof v === 'boolean' ? (v ? 'yes' : 'no') : String(v);
|
|
1110
|
+
if (display.length > 60) {
|
|
1111
|
+
blocks.push({ k, display });
|
|
1112
|
+
} else {
|
|
1113
|
+
badges.push(
|
|
1114
|
+
`<span style="display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;background:var(--bg-secondary);font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> ${escapeHtml(display)}</span>`,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
let html = '';
|
|
1119
|
+
if (badges.length) html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px">${badges.join('')}</div>`;
|
|
1120
|
+
for (const { k, display } of blocks) {
|
|
1121
|
+
html += `<div style="margin-top:6px;font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> <span style="word-break:break-all">${escapeHtml(display)}</span></div>`;
|
|
1122
|
+
}
|
|
1123
|
+
if (params.old_string || params.new_string) {
|
|
1124
|
+
html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
|
|
1125
|
+
if (params.old_string) {
|
|
1126
|
+
html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">old_string</div>
|
|
1127
|
+
<pre class="msg-detail-pre" style="max-height:200px;overflow:auto;border-left:3px solid #e55;padding-left:8px">${escapeHtml(params.old_string)}</pre>`;
|
|
1128
|
+
}
|
|
1129
|
+
if (params.new_string) {
|
|
1130
|
+
html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px;margin-top:6px">new_string</div>
|
|
1131
|
+
<pre class="msg-detail-pre" style="max-height:200px;overflow:auto;border-left:3px solid #5b5;padding-left:8px">${escapeHtml(params.new_string)}</pre>`;
|
|
1132
|
+
}
|
|
1133
|
+
html += `</div>`;
|
|
1134
|
+
}
|
|
1135
|
+
if (params.content) {
|
|
1136
|
+
const contentTruncated = params.content.length > CONTENT_TRUNCATE_MAX;
|
|
1137
|
+
const truncContent = contentTruncated
|
|
1138
|
+
? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}\n... (truncated)`
|
|
1139
|
+
: params.content;
|
|
1140
|
+
let writeMoreBtn = '',
|
|
1141
|
+
fullBlock = '';
|
|
1142
|
+
if (contentTruncated) {
|
|
1143
|
+
const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(params.content), {
|
|
1144
|
+
fontSize: '0.75rem',
|
|
1145
|
+
maxHeight: '500px',
|
|
1146
|
+
});
|
|
1147
|
+
writeMoreBtn = ` ${toggle.btn}`;
|
|
1148
|
+
fullBlock = toggle.full;
|
|
1149
|
+
}
|
|
1150
|
+
html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
|
|
1151
|
+
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">content${writeMoreBtn}</div>
|
|
1152
|
+
<pre class="msg-detail-pre" style="max-height:300px;overflow:auto">${escapeHtml(truncContent)}</pre>
|
|
1153
|
+
${fullBlock}
|
|
1154
|
+
</div>`;
|
|
1155
|
+
}
|
|
1156
|
+
if (params.plan) {
|
|
1157
|
+
html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
|
|
1158
|
+
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:4px">Plan</div>
|
|
1159
|
+
<div class="markdown-body">${renderMarkdown(params.plan)}</div>
|
|
1160
|
+
</div>`;
|
|
1161
|
+
}
|
|
1162
|
+
return html;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Strip cat -n style line number prefix (e.g. " 1→" or " 1\t") from tool output
|
|
1166
|
+
function stripLineNumbers(text) {
|
|
1167
|
+
return text.replace(/^ *\d+[→\t]/gm, '');
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function highlightBash(escaped) {
|
|
1171
|
+
return escaped
|
|
1172
|
+
.replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
|
|
1173
|
+
.replace(/('[\s\S]*?'|"[\s\S]*?")/g, '<span style="color:#ce9178">$1</span>')
|
|
1174
|
+
.replace(
|
|
1175
|
+
/\b(if|then|else|elif|fi|for|do|done|while|until|case|esac|function|return|in|select)\b/g,
|
|
1176
|
+
'<span style="color:#c586c0">$1</span>',
|
|
1177
|
+
)
|
|
1178
|
+
.replace(
|
|
1179
|
+
/\b(echo|cd|ls|cat|grep|awk|sed|rm|cp|mv|mkdir|chmod|chown|export|source|exit|test|read|printf|set|unset|eval|exec|trap|wait|kill|sudo|apt|npm|npx|git|docker|curl|wget|pip|python|node|make|dotnet)\b/g,
|
|
1180
|
+
'<span style="color:#569cd6">$1</span>',
|
|
1181
|
+
)
|
|
1182
|
+
.replace(/(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/g, '<span style="color:#9cdcfe">$1</span>')
|
|
1183
|
+
.replace(/((?:^|\s)(?:&&|\|\||[|;])(?:\s|$))/g, '<span style="color:#d4d4d4;font-weight:bold">$1</span>');
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
let _expandIdCounter = 0;
|
|
1187
|
+
function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
|
|
1188
|
+
const id = `expand-${++_expandIdCounter}`;
|
|
1189
|
+
const fontSize = opts.fontSize || '0.8rem';
|
|
1190
|
+
const maxHeight = opts.maxHeight || '';
|
|
1191
|
+
const btn = `<button onclick="var f=document.getElementById('${id}'),t=this.parentElement.nextElementSibling,expand=f.style.display==='none';f.style.display=expand?'block':'none';t.style.display=expand?'none':'block';this.textContent=expand?'Show less':'Show more'" style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:${fontSize};text-decoration:underline;margin-left:6px">Show more</button>`;
|
|
1192
|
+
const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
|
|
1193
|
+
const full = `<pre id="${id}" class="msg-detail-pre" style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
|
|
1194
|
+
return { btn, full };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function autoSizeModal(modal, body) {
|
|
1198
|
+
const hasTable = body.querySelector('table') !== null;
|
|
1199
|
+
const hasPre = body.querySelector('pre') !== null;
|
|
1200
|
+
const desired = hasTable ? 1100 : body.textContent.length > 2000 || hasPre ? 960 : 860;
|
|
1201
|
+
const current = parseFloat(getComputedStyle(modal).maxWidth) || 0;
|
|
1202
|
+
if (desired > current) modal.style.maxWidth = `${desired}px`;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function renderToolResultHtml(toolResult, isTruncated, fullResult) {
|
|
1206
|
+
if (!toolResult) return '';
|
|
1207
|
+
const stripped = stripLineNumbers(toolResult);
|
|
1208
|
+
const escaped = escapeHtml(stripped);
|
|
1209
|
+
let truncLabel = '',
|
|
1210
|
+
fullBlock = '';
|
|
1211
|
+
if (isTruncated && fullResult) {
|
|
1212
|
+
const toggle = makeExpandToggle(escaped, escapeHtml(stripLineNumbers(fullResult)));
|
|
1213
|
+
truncLabel = toggle.btn;
|
|
1214
|
+
fullBlock = toggle.full;
|
|
1215
|
+
} else if (isTruncated) {
|
|
1216
|
+
truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
|
|
1217
|
+
}
|
|
1218
|
+
return `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
|
|
1219
|
+
<div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:4px">Output${truncLabel}</div>
|
|
1220
|
+
<pre class="msg-detail-pre" style="overflow:auto">${escaped}</pre>
|
|
1221
|
+
${fullBlock}
|
|
1222
|
+
</div>`;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function buildToolContent(m) {
|
|
1226
|
+
let content = m.fullDetail || m.detail || '';
|
|
1227
|
+
if (m.toolResult) content += `\n\n--- Output ---\n\n${m.toolResultFull || m.toolResult}`;
|
|
1228
|
+
return content;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function getDetailMsg() {
|
|
1232
|
+
if (currentMsgDetailIdx != null) return currentMessages[currentMsgDetailIdx];
|
|
1233
|
+
if (currentPinDetailId) return currentPins.find((p) => p.id === currentPinDetailId);
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1238
|
+
async function copyMsgToClipboard(btn) {
|
|
1239
|
+
const m = getDetailMsg();
|
|
1240
|
+
if (!m) return;
|
|
1241
|
+
const content = m.type === 'tool_use' ? buildToolContent(m) : stripAnsi(m.fullText || m.text);
|
|
1242
|
+
copyWithFeedback(content, btn);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function postAndToast(url, body, label) {
|
|
1246
|
+
try {
|
|
1247
|
+
const r = await fetch(url, {
|
|
1248
|
+
method: 'POST',
|
|
1249
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1250
|
+
body: JSON.stringify(body),
|
|
1251
|
+
});
|
|
1252
|
+
showToast(r.ok ? `Opened ${label}` : `Failed to open ${label}`);
|
|
1253
|
+
} catch (_e) {
|
|
1254
|
+
showToast(`Failed to open ${label}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1259
|
+
async function openMsgInEditor() {
|
|
1260
|
+
const m = getDetailMsg();
|
|
1261
|
+
if (!m) return;
|
|
1262
|
+
const content = m.type === 'tool_use' ? buildToolContent(m) : stripAnsi(m.fullText || m.text);
|
|
1263
|
+
const title = m.type === 'tool_use' ? m.tool : m.type;
|
|
1264
|
+
postAndToast('/api/open-in-editor', { content, title }, 'in editor');
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function formatDuration(ms) {
|
|
1268
|
+
if (!ms) return '0s';
|
|
1269
|
+
const s = Math.floor(ms / 1000);
|
|
1270
|
+
if (s < 60) return `${s}s`;
|
|
1271
|
+
const m = Math.floor(s / 60);
|
|
1272
|
+
if (m < 60) return `${m}m ${s % 60}s`;
|
|
1273
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
//#endregion
|
|
1277
|
+
|
|
1278
|
+
//#region AGENTS
|
|
1279
|
+
function renderAgentFooter() {
|
|
1280
|
+
const footer = document.getElementById('agent-footer');
|
|
1281
|
+
const content = document.getElementById('agent-footer-content');
|
|
1282
|
+
const label = document.getElementById('agent-footer-label');
|
|
1283
|
+
const now = Date.now();
|
|
1284
|
+
|
|
1285
|
+
const agents = currentAgents;
|
|
1286
|
+
// Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
|
|
1287
|
+
// or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
|
|
1288
|
+
const byType = {};
|
|
1289
|
+
for (const a of agents) {
|
|
1290
|
+
if (!byType[a.type]) byType[a.type] = [];
|
|
1291
|
+
byType[a.type].push(a);
|
|
1292
|
+
}
|
|
1293
|
+
const filtered = [];
|
|
1294
|
+
for (const group of Object.values(byType)) {
|
|
1295
|
+
group.sort((a, b) => new Date(a.startedAt || 0) - new Date(b.startedAt || 0));
|
|
1296
|
+
filtered.push(group[0]);
|
|
1297
|
+
for (let i = 1; i < group.length; i++) {
|
|
1298
|
+
const prev = group[i - 1];
|
|
1299
|
+
const prevStop = prev.stoppedAt ? new Date(prev.stoppedAt).getTime() : Infinity;
|
|
1300
|
+
const curStart = new Date(group[i].startedAt || 0).getTime();
|
|
1301
|
+
const overlapped = curStart < prevStop;
|
|
1302
|
+
const reSpawn = curStart - prevStop > 30000;
|
|
1303
|
+
const isActive = group[i].status === 'active' || group[i].status === 'idle';
|
|
1304
|
+
if (overlapped || reSpawn || isActive) filtered.push(group[i]);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
// Sort by updatedAt desc, keep up to 7 most recent
|
|
1308
|
+
const visible = filtered
|
|
1309
|
+
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))
|
|
1310
|
+
.slice(0, AGENT_LOG_MAX);
|
|
1311
|
+
|
|
1312
|
+
const permFresh = currentWaiting?.timestamp && now - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
1313
|
+
|
|
1314
|
+
if (visible.length === 0 && !permFresh) {
|
|
1315
|
+
footer.classList.remove('visible');
|
|
1316
|
+
clearInterval(agentDurationInterval);
|
|
1317
|
+
agentDurationInterval = null;
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
footer.classList.add('visible');
|
|
1322
|
+
label.textContent = `Agents Log (${visible.length})`;
|
|
1323
|
+
|
|
1324
|
+
const collapsed = localStorage.getItem('agentFooterCollapsed') === 'true';
|
|
1325
|
+
footer.classList.toggle('collapsed', collapsed);
|
|
1326
|
+
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
1327
|
+
|
|
1328
|
+
const permHtml = permFresh
|
|
1329
|
+
? `<div class="permission-badge">${currentWaiting.kind === 'question' ? '❓ Question pending' : `⏳ Awaiting: ${escapeHtml(currentWaiting.toolName || 'unknown')}`}</div>`
|
|
1330
|
+
: '';
|
|
1331
|
+
|
|
1332
|
+
content.innerHTML =
|
|
1333
|
+
permHtml +
|
|
1334
|
+
visible
|
|
1335
|
+
.map((a) => {
|
|
1336
|
+
const elapsed =
|
|
1337
|
+
a.status === 'stopped' && a.stoppedAt
|
|
1338
|
+
? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
|
|
1339
|
+
: now - new Date(a.startedAt || a.updatedAt).getTime();
|
|
1340
|
+
const statusText =
|
|
1341
|
+
a.status === 'stopped'
|
|
1342
|
+
? `stopped · ${formatDuration(elapsed)}`
|
|
1343
|
+
: a.status === 'idle'
|
|
1344
|
+
? `idle · ${formatDuration(elapsed)}`
|
|
1345
|
+
: `active · ${formatDuration(elapsed)}`;
|
|
1346
|
+
const promptTrimmed = stripAnsi((a.prompt || '').trim()).replace(/[\r\n]+/g, ' ');
|
|
1347
|
+
const promptTrunc = promptTrimmed.length > 60 ? `${promptTrimmed.substring(0, 60)}…` : promptTrimmed;
|
|
1348
|
+
const msgHtml = promptTrunc
|
|
1349
|
+
? `<div class="agent-message" title="${escapeHtml(promptTrimmed)}">${escapeHtml(promptTrunc)}</div>`
|
|
1350
|
+
: '';
|
|
1351
|
+
const rawType = a.type || 'unknown';
|
|
1352
|
+
const colonIdx = rawType.indexOf(':');
|
|
1353
|
+
const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
|
|
1354
|
+
const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
|
|
1355
|
+
return `<div class="agent-card" onclick="showAgentModal('${a.agentId}')">
|
|
1356
|
+
<div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span></div>
|
|
1357
|
+
<div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
|
|
1358
|
+
${msgHtml}
|
|
1359
|
+
</div>`;
|
|
1360
|
+
})
|
|
1361
|
+
.join('');
|
|
1362
|
+
|
|
1363
|
+
clearInterval(agentDurationInterval);
|
|
1364
|
+
if (visible.some((a) => a.status === 'active' || a.status === 'idle')) {
|
|
1365
|
+
agentDurationInterval = setInterval(() => renderAgentFooter(), 1000);
|
|
1366
|
+
} else {
|
|
1367
|
+
agentDurationInterval = setInterval(() => renderAgentFooter(), 10000);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1372
|
+
function toggleAgentFooter() {
|
|
1373
|
+
const footer = document.getElementById('agent-footer');
|
|
1374
|
+
const collapsed = !footer.classList.contains('collapsed');
|
|
1375
|
+
footer.classList.toggle('collapsed', collapsed);
|
|
1376
|
+
localStorage.setItem('agentFooterCollapsed', collapsed);
|
|
1377
|
+
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
let _agentModalPromptText = null;
|
|
1381
|
+
let _agentModalResponseText = null;
|
|
1382
|
+
|
|
1383
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1384
|
+
async function copyAgentModalAll(btn) {
|
|
1385
|
+
const parts = [];
|
|
1386
|
+
if (_agentModalPromptText) parts.push(`## Prompt\n${_agentModalPromptText}`);
|
|
1387
|
+
if (_agentModalResponseText) parts.push(`## Response\n${_agentModalResponseText}`);
|
|
1388
|
+
if (!parts.length) return;
|
|
1389
|
+
copyWithFeedback(parts.join('\n\n'), btn);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
let currentAgentModalId = null;
|
|
1393
|
+
|
|
1394
|
+
function updateAgentModalPinState() {
|
|
1395
|
+
const btn = document.getElementById('agent-modal-pin-btn');
|
|
1396
|
+
if (!btn || !currentAgentModalId) return;
|
|
1397
|
+
btn.classList.toggle('active', isAgentPinned(currentAgentModalId));
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1401
|
+
function togglePinFromAgentModal() {
|
|
1402
|
+
if (!currentAgentModalId) return;
|
|
1403
|
+
toggleAgentPin(currentAgentModalId);
|
|
1404
|
+
updateAgentModalPinState();
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1408
|
+
async function dismissAgent(agentId) {
|
|
1409
|
+
if (!currentSessionId || !agentId) return;
|
|
1410
|
+
try {
|
|
1411
|
+
const res = await fetch(`/api/sessions/${currentSessionId}/agents/${agentId}/stop`, { method: 'POST' });
|
|
1412
|
+
if (res.ok) {
|
|
1413
|
+
currentWaiting = null;
|
|
1414
|
+
fetchAgents(currentSessionId);
|
|
1415
|
+
}
|
|
1416
|
+
} catch (e) {
|
|
1417
|
+
console.error('[dismissAgent]', e);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1422
|
+
function showAgentModal(agentId) {
|
|
1423
|
+
const agent = currentAgents.find((a) => a.agentId === agentId);
|
|
1424
|
+
if (!agent) return;
|
|
1425
|
+
currentAgentModalId = agentId;
|
|
1426
|
+
const modal = document.getElementById('agent-modal');
|
|
1427
|
+
const title = document.getElementById('agent-modal-title');
|
|
1428
|
+
const body = document.getElementById('agent-modal-body');
|
|
1429
|
+
const now = Date.now();
|
|
1430
|
+
const started = agent.startedAt ? new Date(agent.startedAt) : null;
|
|
1431
|
+
const stopped = agent.stoppedAt ? new Date(agent.stoppedAt) : null;
|
|
1432
|
+
const elapsed = stopped && started ? stopped.getTime() - started.getTime() : started ? now - started.getTime() : 0;
|
|
1433
|
+
|
|
1434
|
+
const statusDot = `<span class="agent-dot ${agent.status}" style="display:inline-block;vertical-align:middle;margin-right:6px;"></span>`;
|
|
1435
|
+
title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}`;
|
|
1436
|
+
|
|
1437
|
+
const rows = [
|
|
1438
|
+
['Status', agent.status],
|
|
1439
|
+
['Agent ID', `<code style="font-size:12px;color:var(--text-tertiary)">${escapeHtml(agent.agentId)}</code>`],
|
|
1440
|
+
['Duration', formatDuration(elapsed)],
|
|
1441
|
+
];
|
|
1442
|
+
if (started) rows.push(['Started', started.toLocaleTimeString()]);
|
|
1443
|
+
if (stopped) rows.push(['Stopped', stopped.toLocaleTimeString()]);
|
|
1444
|
+
|
|
1445
|
+
const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
|
|
1446
|
+
|
|
1447
|
+
let html =
|
|
1448
|
+
`<table style="width:100%;border-collapse:collapse;">` +
|
|
1449
|
+
rows
|
|
1450
|
+
.map(
|
|
1451
|
+
([k, v]) =>
|
|
1452
|
+
`<tr><td style="padding:6px 12px 6px 0;color:var(--text-tertiary);white-space:nowrap;vertical-align:top;">${k}</td><td style="padding:6px 0;color:var(--text-primary);">${v}</td></tr>`,
|
|
1453
|
+
)
|
|
1454
|
+
.join('') +
|
|
1455
|
+
`</table>`;
|
|
1456
|
+
|
|
1457
|
+
const promptText = agentMsg?.agentPrompt || agent.prompt || null;
|
|
1458
|
+
const responseText = agent.lastMessage ? stripAnsi(agent.lastMessage.trim()) : null;
|
|
1459
|
+
_agentModalPromptText = promptText;
|
|
1460
|
+
_agentModalResponseText = responseText;
|
|
1461
|
+
const promptHtml = promptText ? renderMarkdown(promptText) : null;
|
|
1462
|
+
const responseHtml = responseText ? renderMarkdown(responseText) : null;
|
|
1463
|
+
html += renderAgentTabs(promptHtml, responseHtml, promptText, responseText);
|
|
1464
|
+
|
|
1465
|
+
body.innerHTML = html;
|
|
1466
|
+
updateAgentModalPinState();
|
|
1467
|
+
autoSizeModal(modal.querySelector('.modal'), body);
|
|
1468
|
+
const dismissBtn = document.getElementById('agent-modal-dismiss-btn');
|
|
1469
|
+
dismissBtn.style.display = agent.status === 'active' || agent.status === 'idle' ? '' : 'none';
|
|
1470
|
+
modal.classList.add('visible');
|
|
1471
|
+
const keyHandler = (e) => {
|
|
1472
|
+
if (e.key === 'Escape') {
|
|
1473
|
+
e.preventDefault();
|
|
1474
|
+
closeAgentModal();
|
|
1475
|
+
document.removeEventListener('keydown', keyHandler);
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
document.addEventListener('keydown', keyHandler);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function closeAgentModal() {
|
|
1482
|
+
resetModalFullscreen('agent-modal');
|
|
1483
|
+
currentAgentModalId = null;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
//#endregion
|
|
1487
|
+
|
|
1488
|
+
//#region RENDERING
|
|
1489
|
+
async function showAllTasks() {
|
|
1490
|
+
try {
|
|
1491
|
+
viewMode = 'all';
|
|
1492
|
+
if (agentLogMode) exitAgentLogMode();
|
|
1493
|
+
currentSessionId = null;
|
|
1494
|
+
ownerFilter = '';
|
|
1495
|
+
currentAgents = [];
|
|
1496
|
+
currentWaiting = null;
|
|
1497
|
+
lastAgentsHash = '';
|
|
1498
|
+
renderAgentFooter();
|
|
1499
|
+
const res = await fetch('/api/tasks/all');
|
|
1500
|
+
allTasksCache = await res.json();
|
|
1501
|
+
let tasks = allTasksCache;
|
|
1502
|
+
if (filterProject) {
|
|
1503
|
+
tasks = tasks.filter((t) => matchesProjectFilter(t.project));
|
|
1504
|
+
}
|
|
1505
|
+
currentTasks = tasks;
|
|
1506
|
+
updateUrl();
|
|
1507
|
+
renderAllTasks();
|
|
1508
|
+
renderSessions();
|
|
1509
|
+
renderLiveUpdatesFromCache();
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
console.error('Failed to fetch all tasks:', error);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function renderAllTasks() {
|
|
1516
|
+
noSession.style.display = 'none';
|
|
1517
|
+
sessionView.classList.add('visible');
|
|
1518
|
+
document.getElementById('owner-filter-bar').classList.remove('visible');
|
|
1519
|
+
|
|
1520
|
+
const visibleTasks = currentTasks.filter((t) => !isInternalTask(t));
|
|
1521
|
+
const totalTasks = visibleTasks.length;
|
|
1522
|
+
const completed = visibleTasks.filter((t) => t.status === 'completed').length;
|
|
1523
|
+
const percent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
|
1524
|
+
|
|
1525
|
+
const isFiltered = filterProject && filterProject !== '__recent__';
|
|
1526
|
+
const projectName = isFiltered ? filterProject.split(/[/\\]/).pop() : null;
|
|
1527
|
+
sessionTitle.textContent = isFiltered
|
|
1528
|
+
? `Tasks: ${projectName}`
|
|
1529
|
+
: filterProject === '__recent__'
|
|
1530
|
+
? 'Recent Tasks'
|
|
1531
|
+
: 'All Tasks';
|
|
1532
|
+
sessionMeta.textContent = isFiltered
|
|
1533
|
+
? `${totalTasks} tasks in this project`
|
|
1534
|
+
: `${totalTasks} tasks across ${sessions.length} sessions`;
|
|
1535
|
+
progressPercent.textContent = `${percent}%`;
|
|
1536
|
+
progressBar.style.width = `${percent}%`;
|
|
1537
|
+
|
|
1538
|
+
renderKanban();
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function renderSessions() {
|
|
1542
|
+
// Update project dropdown
|
|
1543
|
+
updateProjectDropdown();
|
|
1544
|
+
|
|
1545
|
+
const LIVE_INDICATOR_MS = 10 * 1000;
|
|
1546
|
+
let filteredSessions = sessions;
|
|
1547
|
+
if (sessionFilter === 'active') {
|
|
1548
|
+
const ACTIVE_PLAN_MS = 15 * 60 * 1000;
|
|
1549
|
+
const RECENTLY_MODIFIED_MS = 5 * 60 * 1000;
|
|
1550
|
+
const now = Date.now();
|
|
1551
|
+
const activeSessionIds = new Set();
|
|
1552
|
+
filteredSessions = filteredSessions.filter((s) => {
|
|
1553
|
+
const isActive =
|
|
1554
|
+
s.hasMessages &&
|
|
1555
|
+
(s.pending > 0 ||
|
|
1556
|
+
s.inProgress > 0 ||
|
|
1557
|
+
s.hasActiveAgents ||
|
|
1558
|
+
s.hasWaitingForUser ||
|
|
1559
|
+
s.hasRecentLog ||
|
|
1560
|
+
(s.hasPlan && !s.planImplementationSessionId && now - new Date(s.modifiedAt).getTime() <= ACTIVE_PLAN_MS) ||
|
|
1561
|
+
now - new Date(s.modifiedAt).getTime() <= RECENTLY_MODIFIED_MS);
|
|
1562
|
+
if (isActive) activeSessionIds.add(s.id);
|
|
1563
|
+
return isActive;
|
|
1564
|
+
});
|
|
1565
|
+
// Include plan sessions whose implementation is active
|
|
1566
|
+
const planSessions = sessions.filter(
|
|
1567
|
+
(s) =>
|
|
1568
|
+
s.planImplementationSessionId &&
|
|
1569
|
+
activeSessionIds.has(s.planImplementationSessionId) &&
|
|
1570
|
+
!activeSessionIds.has(s.id),
|
|
1571
|
+
);
|
|
1572
|
+
if (planSessions.length) {
|
|
1573
|
+
filteredSessions = filteredSessions.concat(planSessions);
|
|
1574
|
+
filteredSessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (filterProject) {
|
|
1578
|
+
filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project));
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// Apply search filter
|
|
1582
|
+
if (searchQuery) {
|
|
1583
|
+
filteredSessions = filteredSessions.filter((session) => {
|
|
1584
|
+
// Search in session name and ID
|
|
1585
|
+
if (session.name && fuzzyMatch(session.name, searchQuery)) return true;
|
|
1586
|
+
if (session.id && fuzzyMatch(session.id, searchQuery)) return true;
|
|
1587
|
+
|
|
1588
|
+
// Search in project path
|
|
1589
|
+
if (session.project && fuzzyMatch(session.project, searchQuery)) return true;
|
|
1590
|
+
|
|
1591
|
+
// Search in description
|
|
1592
|
+
if (session.description && fuzzyMatch(session.description, searchQuery)) return true;
|
|
1593
|
+
|
|
1594
|
+
// Search in tasks for this session
|
|
1595
|
+
const sessionTasks = allTasksCache.filter((t) => t.sessionId === session.id);
|
|
1596
|
+
return sessionTasks.some(
|
|
1597
|
+
(task) =>
|
|
1598
|
+
(task.subject && fuzzyMatch(task.subject, searchQuery)) ||
|
|
1599
|
+
(task.description && fuzzyMatch(task.description, searchQuery)) ||
|
|
1600
|
+
(task.activeForm && fuzzyMatch(task.activeForm, searchQuery)),
|
|
1601
|
+
);
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// Always include pinned sessions even if they don't match filters
|
|
1606
|
+
if (pinnedSessionIds.size > 0 && !searchQuery) {
|
|
1607
|
+
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
1608
|
+
const missingPinned = sessions.filter((s) => pinnedSessionIds.has(s.id) && !filteredIds.has(s.id));
|
|
1609
|
+
if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (filteredSessions.length === 0) {
|
|
1613
|
+
let emptyMsg = 'No sessions found';
|
|
1614
|
+
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
1615
|
+
|
|
1616
|
+
if (searchQuery) {
|
|
1617
|
+
emptyMsg = `No results for "${searchQuery}"`;
|
|
1618
|
+
emptyHint = 'Try a different search term or clear the search';
|
|
1619
|
+
} else if (filterProject && sessionFilter === 'active') {
|
|
1620
|
+
emptyMsg = 'No active sessions for this project';
|
|
1621
|
+
emptyHint = 'Try "All Sessions" or "All Projects"';
|
|
1622
|
+
} else if (filterProject) {
|
|
1623
|
+
emptyMsg = 'No sessions for this project';
|
|
1624
|
+
emptyHint = 'Select "All Projects" to see all';
|
|
1625
|
+
} else if (sessionFilter === 'active') {
|
|
1626
|
+
emptyMsg = 'No active sessions';
|
|
1627
|
+
emptyHint = 'Select "All Sessions" to see all';
|
|
1628
|
+
}
|
|
1629
|
+
sessionsList.innerHTML = `
|
|
1630
|
+
<div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
|
1631
|
+
<p>${emptyMsg}</p>
|
|
1632
|
+
<p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
|
|
1633
|
+
</div>
|
|
1634
|
+
`;
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Helper to render a single session card
|
|
1639
|
+
const renderSessionCard = (session) => {
|
|
1640
|
+
const total = session.taskCount;
|
|
1641
|
+
const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
|
|
1642
|
+
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
1643
|
+
const hasInProgress = session.inProgress > 0;
|
|
1644
|
+
const isLive =
|
|
1645
|
+
hasInProgress || (session.modifiedAt && Date.now() - new Date(session.modifiedAt).getTime() <= LIVE_INDICATOR_MS);
|
|
1646
|
+
const sessionName = session.name || session.id;
|
|
1647
|
+
const useGrouped = sessionFilter === 'active' && session.project;
|
|
1648
|
+
const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
|
|
1649
|
+
const secondaryName = useGrouped ? null : session.project ? sessionName : null;
|
|
1650
|
+
|
|
1651
|
+
const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
|
|
1652
|
+
const createdDisplay = session.createdAt ? formatDate(session.createdAt) : '';
|
|
1653
|
+
const modifiedDisplay = formatDate(session.modifiedAt);
|
|
1654
|
+
const timeDisplay =
|
|
1655
|
+
session.createdAt && createdDisplay !== modifiedDisplay
|
|
1656
|
+
? `Created ${createdDisplay} · Modified ${modifiedDisplay}`
|
|
1657
|
+
: modifiedDisplay;
|
|
1658
|
+
const tooltip = [session.id, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : ''].filter(Boolean).join(' | ');
|
|
1659
|
+
const isTeam = session.isTeam;
|
|
1660
|
+
const memberCount = session.memberCount || 0;
|
|
1661
|
+
|
|
1662
|
+
const isSessionPinned = pinnedSessionIds.has(session.id);
|
|
1663
|
+
const showCtx = !!session.contextStatus;
|
|
1664
|
+
return `
|
|
1665
|
+
<button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${!session.hasRecentLog && !session.inProgress && !session.hasWaitingForUser ? 'stale' : ''} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
|
|
1666
|
+
<span class="session-pin-btn${isSessionPinned ? ' pinned' : ''}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${isSessionPinned ? 'Unpin' : 'Pin'} session">${SESSION_PIN_SVG}</span>
|
|
1667
|
+
<div class="session-name">${escapeHtml(primaryName)}</div>
|
|
1668
|
+
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
1669
|
+
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
1670
|
+
${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
|
|
1671
|
+
<div class="session-progress">
|
|
1672
|
+
<span class="session-indicators">
|
|
1673
|
+
${isTeam ? `<span class="team-badge" title="${memberCount} team members"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
|
|
1674
|
+
${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
|
|
1675
|
+
${session.hasPlan ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
|
|
1676
|
+
${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
|
|
1677
|
+
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to view plan session" onclick="event.stopPropagation(); fetchTasks('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
|
|
1678
|
+
${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
|
|
1679
|
+
${isLive ? '<span class="pulse"></span>' : ''}
|
|
1680
|
+
</span>
|
|
1681
|
+
<div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
|
|
1682
|
+
<span class="progress-text">${session.completed}/${total}</span>
|
|
1683
|
+
</div>
|
|
1684
|
+
${showCtx ? renderContextBar(session.contextStatus) : ''}
|
|
1685
|
+
<div class="session-time">${formatDate(session.modifiedAt)}</div>
|
|
1686
|
+
</button>
|
|
1687
|
+
`;
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
// Group active sessions by project
|
|
1691
|
+
if (sessionFilter === 'active') {
|
|
1692
|
+
const groups = new Map();
|
|
1693
|
+
const ungrouped = [];
|
|
1694
|
+
for (const session of filteredSessions) {
|
|
1695
|
+
if (session.project) {
|
|
1696
|
+
if (!groups.has(session.project)) groups.set(session.project, []);
|
|
1697
|
+
groups.get(session.project).push(session);
|
|
1698
|
+
} else {
|
|
1699
|
+
ungrouped.push(session);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
if (pinnedSessionIds.size > 0) {
|
|
1703
|
+
const pinSort = (a, b) => (pinnedSessionIds.has(b.id) ? 1 : 0) - (pinnedSessionIds.has(a.id) ? 1 : 0);
|
|
1704
|
+
for (const [, arr] of groups) arr.sort(pinSort);
|
|
1705
|
+
ungrouped.sort(pinSort);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Stable group order: preserve existing order, append new groups sorted by recency
|
|
1709
|
+
const currentPaths = new Set(groups.keys());
|
|
1710
|
+
const knownPaths = new Set(stableGroupOrder);
|
|
1711
|
+
const keptOrder = stableGroupOrder.filter((p) => currentPaths.has(p));
|
|
1712
|
+
const newPaths = [...currentPaths].filter((p) => !knownPaths.has(p));
|
|
1713
|
+
if (newPaths.length > 1) {
|
|
1714
|
+
const maxTime = new Map(
|
|
1715
|
+
newPaths.map((p) => [p, Math.max(...groups.get(p).map((s) => new Date(s.modifiedAt).getTime()))]),
|
|
1716
|
+
);
|
|
1717
|
+
newPaths.sort((a, b) => maxTime.get(b) - maxTime.get(a));
|
|
1718
|
+
}
|
|
1719
|
+
stableGroupOrder = [...keptOrder, ...newPaths];
|
|
1720
|
+
const sortedGroups = stableGroupOrder.map((p) => [p, groups.get(p)]);
|
|
1721
|
+
|
|
1722
|
+
let html = '';
|
|
1723
|
+
for (const [projectPath, projectSessions] of sortedGroups) {
|
|
1724
|
+
const folderName = projectPath.split(/[/\\]/).pop();
|
|
1725
|
+
const isCollapsed = collapsedProjectGroups.has(projectPath);
|
|
1726
|
+
const escapedPath = escapeHtml(projectPath);
|
|
1727
|
+
const breadcrumbParts = projectPath
|
|
1728
|
+
.replace(/^\/home\/[^/]+/, '~')
|
|
1729
|
+
.split(/[/\\]/)
|
|
1730
|
+
.filter(Boolean);
|
|
1731
|
+
const breadcrumbHtml = breadcrumbParts
|
|
1732
|
+
.map((p, i) => (i < breadcrumbParts.length - 1 ? `${escapeHtml(p)}<span class="sep">/</span>` : escapeHtml(p)))
|
|
1733
|
+
.join('');
|
|
1734
|
+
|
|
1735
|
+
html += `
|
|
1736
|
+
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="${escapedPath}">
|
|
1737
|
+
<svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
1738
|
+
<span class="group-name">${escapeHtml(folderName)}</span>
|
|
1739
|
+
<span class="group-count">${projectSessions.length}</span>
|
|
1740
|
+
<span class="group-path-toggle" data-group-action="toggle-path" title="Show full path">
|
|
1741
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
1742
|
+
</span>
|
|
1743
|
+
</div>
|
|
1744
|
+
<div class="project-group-breadcrumb" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
1745
|
+
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
1746
|
+
${projectSessions.map(renderSessionCard).join('')}
|
|
1747
|
+
</div>
|
|
1748
|
+
`;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
if (ungrouped.length > 0 && sortedGroups.length > 0) {
|
|
1752
|
+
const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
|
|
1753
|
+
html += `
|
|
1754
|
+
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
|
|
1755
|
+
<svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
1756
|
+
<span class="group-name">Ungrouped</span>
|
|
1757
|
+
<span class="group-count">${ungrouped.length}</span>
|
|
1758
|
+
</div>
|
|
1759
|
+
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
1760
|
+
${ungrouped.map(renderSessionCard).join('')}
|
|
1761
|
+
</div>
|
|
1762
|
+
`;
|
|
1763
|
+
} else {
|
|
1764
|
+
html += ungrouped.map(renderSessionCard).join('');
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
sessionsList.innerHTML = html;
|
|
1768
|
+
} else {
|
|
1769
|
+
const pinned = filteredSessions.filter((s) => pinnedSessionIds.has(s.id));
|
|
1770
|
+
const rest = filteredSessions.filter((s) => !pinnedSessionIds.has(s.id));
|
|
1771
|
+
let html = '';
|
|
1772
|
+
if (pinned.length > 0) {
|
|
1773
|
+
const isCollapsed = collapsedProjectGroups.has('__pinned__');
|
|
1774
|
+
html += `
|
|
1775
|
+
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__pinned__">
|
|
1776
|
+
<svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
1777
|
+
<span class="group-name">Pinned</span>
|
|
1778
|
+
<span class="group-count">${pinned.length}</span>
|
|
1779
|
+
</div>
|
|
1780
|
+
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
1781
|
+
${pinned.map(renderSessionCard).join('')}
|
|
1782
|
+
</div>
|
|
1783
|
+
`;
|
|
1784
|
+
}
|
|
1785
|
+
html += rest.map(renderSessionCard).join('');
|
|
1786
|
+
sessionsList.innerHTML = html;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const navItems = getNavigableItems();
|
|
1790
|
+
const allSessions = getSessionItems();
|
|
1791
|
+
const activeIdx = allSessions.findIndex((el) => el.classList.contains('active'));
|
|
1792
|
+
if (activeIdx >= 0 && (selectedSessionIdx < 0 || sessionJustSelected)) {
|
|
1793
|
+
const navIdx = navItems.indexOf(allSessions[activeIdx]);
|
|
1794
|
+
selectedSessionIdx = navIdx >= 0 ? navIdx : 0;
|
|
1795
|
+
selectedSessionKbId = allSessions[activeIdx].dataset.sessionId || null;
|
|
1796
|
+
sessionJustSelected = false;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
if (selectedSessionKbId && focusZone === 'sidebar') {
|
|
1800
|
+
const restoredIdx = navItems.findIndex((el) => getKbId(el) === selectedSessionKbId);
|
|
1801
|
+
if (restoredIdx >= 0) {
|
|
1802
|
+
selectedSessionIdx = restoredIdx;
|
|
1803
|
+
navItems[restoredIdx].classList.add('kb-selected');
|
|
1804
|
+
} else {
|
|
1805
|
+
selectedSessionIdx = -1;
|
|
1806
|
+
selectedSessionKbId = null;
|
|
1807
|
+
}
|
|
1808
|
+
} else if (focusZone === 'sidebar' && selectedSessionIdx >= 0) {
|
|
1809
|
+
if (navItems.length > 0) {
|
|
1810
|
+
const clamped = Math.min(selectedSessionIdx, navItems.length - 1);
|
|
1811
|
+
selectedSessionIdx = clamped;
|
|
1812
|
+
const el = navItems[clamped];
|
|
1813
|
+
selectedSessionKbId = getKbId(el);
|
|
1814
|
+
el.classList.add('kb-selected');
|
|
1815
|
+
} else {
|
|
1816
|
+
selectedSessionIdx = -1;
|
|
1817
|
+
selectedSessionKbId = null;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function renderSession() {
|
|
1823
|
+
noSession.style.display = 'none';
|
|
1824
|
+
sessionView.classList.add('visible');
|
|
1825
|
+
|
|
1826
|
+
const session = sessions.find((s) => s.id === currentSessionId);
|
|
1827
|
+
if (!session) return;
|
|
1828
|
+
|
|
1829
|
+
const displayName =
|
|
1830
|
+
session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
|
|
1831
|
+
|
|
1832
|
+
sessionTitle.textContent = displayName;
|
|
1833
|
+
|
|
1834
|
+
// Build meta text with project path and description
|
|
1835
|
+
const projectName = session.project ? session.project.split('/').pop() : null;
|
|
1836
|
+
const metaParts = [`${currentTasks.length} tasks`];
|
|
1837
|
+
if (projectName) {
|
|
1838
|
+
metaParts.push(projectName);
|
|
1839
|
+
}
|
|
1840
|
+
if (session.description && session.description !== displayName) {
|
|
1841
|
+
metaParts.push(session.description);
|
|
1842
|
+
}
|
|
1843
|
+
metaParts.push(formatDate(session.modifiedAt));
|
|
1844
|
+
sessionMeta.textContent = metaParts.join(' · ');
|
|
1845
|
+
|
|
1846
|
+
const completed = currentTasks.filter((t) => t.status === 'completed').length;
|
|
1847
|
+
const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
|
|
1848
|
+
|
|
1849
|
+
progressPercent.textContent = `${percent}%`;
|
|
1850
|
+
progressBar.style.width = `${percent}%`;
|
|
1851
|
+
const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
|
|
1852
|
+
progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
|
|
1853
|
+
|
|
1854
|
+
updateOwnerFilter();
|
|
1855
|
+
renderKanban();
|
|
1856
|
+
renderSessions();
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function renderTaskCard(task) {
|
|
1860
|
+
const isBlocked = task.blockedBy && task.blockedBy.length > 0;
|
|
1861
|
+
const taskId = viewMode === 'all' ? `${task.sessionId?.slice(0, 4)}-${task.id}` : task.id;
|
|
1862
|
+
const sessionLabel = viewMode === 'all' && task.sessionName ? task.sessionName : null;
|
|
1863
|
+
const statusClass = task.status.replace('_', '-');
|
|
1864
|
+
const actualSessionId = task.sessionId || currentSessionId;
|
|
1865
|
+
|
|
1866
|
+
return `
|
|
1867
|
+
<div
|
|
1868
|
+
role="listitem"
|
|
1869
|
+
tabindex="0"
|
|
1870
|
+
data-task-id="${task.id}"
|
|
1871
|
+
data-session-id="${actualSessionId}"
|
|
1872
|
+
onclick="showTaskDetail('${task.id}', '${actualSessionId}')"
|
|
1873
|
+
draggable="true"
|
|
1874
|
+
ondragstart="onCardDragStart(event)"
|
|
1875
|
+
ondragend="onCardDragEnd(event)"
|
|
1876
|
+
class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''}"
|
|
1877
|
+
aria-label="${escapeHtml(task.subject)} — ${task.status.replace('_', ' ')}">
|
|
1878
|
+
<div class="task-id">
|
|
1879
|
+
<span>#${taskId}</span>
|
|
1880
|
+
${isBlocked ? '<span class="task-badge blocked">Blocked</span>' : ''}
|
|
1881
|
+
${
|
|
1882
|
+
task.owner
|
|
1883
|
+
? (
|
|
1884
|
+
() => {
|
|
1885
|
+
const c = getOwnerColor(task.owner);
|
|
1886
|
+
return `<span class="task-owner-badge" style="background:${c.bg};color:${c.color}">${escapeHtml(task.owner)}</span>`;
|
|
1887
|
+
}
|
|
1888
|
+
)()
|
|
1889
|
+
: ''
|
|
1890
|
+
}
|
|
1891
|
+
</div>
|
|
1892
|
+
<div class="task-title">${escapeHtml(task.subject)}</div>
|
|
1893
|
+
${sessionLabel ? `<div class="task-session">${escapeHtml(sessionLabel)}</div>` : ''}
|
|
1894
|
+
${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
|
|
1895
|
+
${isBlocked ? `<div class="task-blocked">Waiting on ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>` : ''}
|
|
1896
|
+
${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
|
|
1897
|
+
</div>
|
|
1898
|
+
`;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
//#endregion
|
|
1902
|
+
|
|
1903
|
+
//#region KANBAN
|
|
1904
|
+
function renderKanban() {
|
|
1905
|
+
let filtered = currentTasks.filter((t) => !isInternalTask(t));
|
|
1906
|
+
if (ownerFilter) {
|
|
1907
|
+
filtered = filtered.filter((t) => t.owner === ownerFilter);
|
|
1908
|
+
}
|
|
1909
|
+
const pending = filtered.filter((t) => t.status === 'pending');
|
|
1910
|
+
const inProgress = filtered.filter((t) => t.status === 'in_progress');
|
|
1911
|
+
const completed = filtered.filter((t) => t.status === 'completed');
|
|
1912
|
+
|
|
1913
|
+
pendingCount.textContent = pending.length;
|
|
1914
|
+
inProgressCount.textContent = inProgress.length;
|
|
1915
|
+
completedCount.textContent = completed.length;
|
|
1916
|
+
|
|
1917
|
+
const emptyIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>`;
|
|
1918
|
+
|
|
1919
|
+
pendingTasks.innerHTML =
|
|
1920
|
+
pending.length > 0
|
|
1921
|
+
? pending.map(renderTaskCard).join('')
|
|
1922
|
+
: `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`;
|
|
1923
|
+
|
|
1924
|
+
inProgressTasks.innerHTML =
|
|
1925
|
+
inProgress.length > 0
|
|
1926
|
+
? inProgress.map(renderTaskCard).join('')
|
|
1927
|
+
: `<div class="column-empty">${emptyIcon}<div>No active tasks</div></div>`;
|
|
1928
|
+
|
|
1929
|
+
completedTasks.innerHTML =
|
|
1930
|
+
completed.length > 0
|
|
1931
|
+
? completed.map(renderTaskCard).join('')
|
|
1932
|
+
: `<div class="column-empty">${emptyIcon}<div>No completed tasks</div></div>`;
|
|
1933
|
+
|
|
1934
|
+
if (selectedTaskId) {
|
|
1935
|
+
const card =
|
|
1936
|
+
document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`) ||
|
|
1937
|
+
document.querySelector(`.task-card[data-task-id="${selectedTaskId}"]`);
|
|
1938
|
+
if (card) {
|
|
1939
|
+
if (focusZone === 'board') card.classList.add('selected');
|
|
1940
|
+
} else {
|
|
1941
|
+
selectedTaskId = null;
|
|
1942
|
+
selectedSessionId = null;
|
|
1943
|
+
}
|
|
1944
|
+
if (selectedTaskId && detailPanel.classList.contains('visible')) {
|
|
1945
|
+
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
//#endregion
|
|
1951
|
+
|
|
1952
|
+
//#region DRAG_DROP
|
|
1953
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1954
|
+
function onCardDragStart(e) {
|
|
1955
|
+
const card = e.target.closest('.task-card');
|
|
1956
|
+
if (!card) return;
|
|
1957
|
+
card.classList.add('dragging');
|
|
1958
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1959
|
+
e.dataTransfer.setData(
|
|
1960
|
+
'text/plain',
|
|
1961
|
+
JSON.stringify({
|
|
1962
|
+
taskId: card.dataset.taskId,
|
|
1963
|
+
sessionId: card.dataset.sessionId,
|
|
1964
|
+
}),
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1969
|
+
function onCardDragEnd(e) {
|
|
1970
|
+
const card = e.target.closest('.task-card');
|
|
1971
|
+
if (card) card.classList.remove('dragging');
|
|
1972
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
1973
|
+
document.querySelectorAll('.column-tasks.drag-over').forEach((el) => el.classList.remove('drag-over'));
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1977
|
+
function onColumnDragOver(e) {
|
|
1978
|
+
e.preventDefault();
|
|
1979
|
+
e.dataTransfer.dropEffect = 'move';
|
|
1980
|
+
e.currentTarget.classList.add('drag-over');
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1984
|
+
function onColumnDragLeave(e) {
|
|
1985
|
+
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
1986
|
+
e.currentTarget.classList.remove('drag-over');
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1991
|
+
async function onColumnDrop(e) {
|
|
1992
|
+
e.preventDefault();
|
|
1993
|
+
e.currentTarget.classList.remove('drag-over');
|
|
1994
|
+
const newStatus = e.currentTarget.dataset.status;
|
|
1995
|
+
let data;
|
|
1996
|
+
try {
|
|
1997
|
+
data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
|
1998
|
+
} catch (_) {
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
const { taskId, sessionId } = data;
|
|
2002
|
+
const task = currentTasks.find((t) => t.id === taskId && (t.sessionId || currentSessionId) === sessionId);
|
|
2003
|
+
if (!task || task.status === newStatus) return;
|
|
2004
|
+
try {
|
|
2005
|
+
const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
|
|
2006
|
+
method: 'PUT',
|
|
2007
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2008
|
+
body: JSON.stringify({ status: newStatus }),
|
|
2009
|
+
});
|
|
2010
|
+
if (res.ok) {
|
|
2011
|
+
task.status = newStatus;
|
|
2012
|
+
renderKanban();
|
|
2013
|
+
}
|
|
2014
|
+
} catch (_) {}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
//#endregion
|
|
2018
|
+
|
|
2019
|
+
//#region KEYBOARD_NAV
|
|
2020
|
+
function selectTask(taskId, sessionId) {
|
|
2021
|
+
const prev = document.querySelector('.task-card.selected');
|
|
2022
|
+
if (prev) prev.classList.remove('selected');
|
|
2023
|
+
selectedTaskId = taskId;
|
|
2024
|
+
selectedSessionId = sessionId;
|
|
2025
|
+
if (!taskId) return;
|
|
2026
|
+
const card =
|
|
2027
|
+
document.querySelector(`.task-card[data-task-id="${taskId}"][data-session-id="${sessionId}"]`) ||
|
|
2028
|
+
document.querySelector(`.task-card[data-task-id="${taskId}"]`);
|
|
2029
|
+
if (card) {
|
|
2030
|
+
card.classList.add('selected');
|
|
2031
|
+
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function getSelectedCardInfo() {
|
|
2036
|
+
if (!selectedTaskId) return null;
|
|
2037
|
+
for (let ci = 0; ci < COLUMNS.length; ci++) {
|
|
2038
|
+
const cards = Array.from(COLUMNS[ci].el.querySelectorAll('.task-card'));
|
|
2039
|
+
for (let i = 0; i < cards.length; i++) {
|
|
2040
|
+
if (cards[i].dataset.taskId === selectedTaskId) {
|
|
2041
|
+
return { colIndex: ci, cardIndex: i, card: cards[i] };
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function navigateVertical(direction) {
|
|
2049
|
+
const info = getSelectedCardInfo();
|
|
2050
|
+
if (!info) {
|
|
2051
|
+
for (const col of COLUMNS) {
|
|
2052
|
+
const cards = Array.from(col.el.querySelectorAll('.task-card'));
|
|
2053
|
+
if (cards.length > 0) {
|
|
2054
|
+
selectTask(cards[0].dataset.taskId, cards[0].dataset.sessionId);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
const cards = Array.from(COLUMNS[info.colIndex].el.querySelectorAll('.task-card'));
|
|
2061
|
+
const newIndex = info.cardIndex + direction;
|
|
2062
|
+
if (newIndex >= 0 && newIndex < cards.length) {
|
|
2063
|
+
selectTask(cards[newIndex].dataset.taskId, cards[newIndex].dataset.sessionId);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function navigateHorizontal(direction) {
|
|
2068
|
+
const info = getSelectedCardInfo();
|
|
2069
|
+
if (!info) {
|
|
2070
|
+
navigateVertical(1);
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
let newColIndex = info.colIndex + direction;
|
|
2074
|
+
while (newColIndex >= 0 && newColIndex < COLUMNS.length) {
|
|
2075
|
+
const cards = Array.from(COLUMNS[newColIndex].el.querySelectorAll('.task-card'));
|
|
2076
|
+
if (cards.length > 0) {
|
|
2077
|
+
const clampedIndex = Math.min(info.cardIndex, cards.length - 1);
|
|
2078
|
+
selectTask(cards[clampedIndex].dataset.taskId, cards[clampedIndex].dataset.sessionId);
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
newColIndex += direction;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function getKbId(el) {
|
|
2086
|
+
return el.dataset.sessionId || el.dataset.groupPath || null;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function getGroupSessionsContainer(header) {
|
|
2090
|
+
let el = header.nextElementSibling;
|
|
2091
|
+
while (el && !el.classList.contains('project-group-sessions')) el = el.nextElementSibling;
|
|
2092
|
+
return el;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function getNavigableItems() {
|
|
2096
|
+
const items = [];
|
|
2097
|
+
for (const el of sessionsList.children) {
|
|
2098
|
+
if (el.classList.contains('project-group-header')) {
|
|
2099
|
+
items.push(el);
|
|
2100
|
+
if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
|
|
2101
|
+
const container = getGroupSessionsContainer(el);
|
|
2102
|
+
if (container) {
|
|
2103
|
+
for (const s of container.querySelectorAll('.session-item')) items.push(s);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
} else if (el.classList.contains('session-item')) {
|
|
2107
|
+
items.push(el);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
return items;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
function getSessionItems() {
|
|
2114
|
+
return Array.from(sessionsList.querySelectorAll('.session-item'));
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function clearKbSelection() {
|
|
2118
|
+
const prev = sessionsList.querySelector('.kb-selected');
|
|
2119
|
+
if (prev) prev.classList.remove('kb-selected');
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function selectSessionByIndex(idx, items) {
|
|
2123
|
+
items = items || getNavigableItems();
|
|
2124
|
+
if (items.length === 0) return;
|
|
2125
|
+
clearKbSelection();
|
|
2126
|
+
selectedSessionIdx = Math.max(0, Math.min(idx, items.length - 1));
|
|
2127
|
+
const el = items[selectedSessionIdx];
|
|
2128
|
+
selectedSessionKbId = getKbId(el);
|
|
2129
|
+
el.classList.add('kb-selected');
|
|
2130
|
+
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function navigateSession(direction, items) {
|
|
2134
|
+
items = items || getNavigableItems();
|
|
2135
|
+
if (items.length === 0) return;
|
|
2136
|
+
if (selectedSessionIdx < 0) {
|
|
2137
|
+
selectSessionByIndex(0, items);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const currentEl = items[selectedSessionIdx];
|
|
2141
|
+
let newIdx = selectedSessionIdx + direction;
|
|
2142
|
+
if (!currentEl || !currentEl.isConnected) {
|
|
2143
|
+
const restoredIdx = selectedSessionKbId ? items.findIndex((el) => getKbId(el) === selectedSessionKbId) : -1;
|
|
2144
|
+
newIdx = restoredIdx >= 0 ? restoredIdx : 0;
|
|
2145
|
+
}
|
|
2146
|
+
if (newIdx >= 0 && newIdx < items.length) {
|
|
2147
|
+
selectSessionByIndex(newIdx, items);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function setGroupCollapsed(header, collapsed) {
|
|
2152
|
+
if (!header) return;
|
|
2153
|
+
const projectPath = header.dataset.groupPath;
|
|
2154
|
+
if (collapsed === collapsedProjectGroups.has(projectPath)) return;
|
|
2155
|
+
if (collapsed) collapsedProjectGroups.add(projectPath);
|
|
2156
|
+
else collapsedProjectGroups.delete(projectPath);
|
|
2157
|
+
header.classList.toggle('collapsed', collapsed);
|
|
2158
|
+
const container = getGroupSessionsContainer(header);
|
|
2159
|
+
if (container) container.classList.toggle('collapsed', collapsed);
|
|
2160
|
+
try {
|
|
2161
|
+
localStorage.setItem('collapsedGroups', JSON.stringify([...collapsedProjectGroups]));
|
|
2162
|
+
} catch (_) {}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function handleSidebarHorizontal(direction) {
|
|
2166
|
+
const items = getNavigableItems();
|
|
2167
|
+
if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
|
|
2168
|
+
const el = items[selectedSessionIdx];
|
|
2169
|
+
const isHeader = el.classList.contains('project-group-header');
|
|
2170
|
+
const collapse = direction < 0;
|
|
2171
|
+
|
|
2172
|
+
if (isHeader) {
|
|
2173
|
+
const groupPath = el.dataset.groupPath;
|
|
2174
|
+
const isCollapsed = collapsedProjectGroups.has(groupPath);
|
|
2175
|
+
if (collapse) {
|
|
2176
|
+
if (!isCollapsed) setGroupCollapsed(el, true);
|
|
2177
|
+
} else {
|
|
2178
|
+
if (isCollapsed) {
|
|
2179
|
+
setGroupCollapsed(el, false);
|
|
2180
|
+
} else {
|
|
2181
|
+
navigateSession(1);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
} else {
|
|
2185
|
+
if (collapse) {
|
|
2186
|
+
const container = el.closest('.project-group-sessions');
|
|
2187
|
+
if (container) {
|
|
2188
|
+
let header = container.previousElementSibling;
|
|
2189
|
+
while (header && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
|
|
2190
|
+
if (header) {
|
|
2191
|
+
const headerIdx = items.indexOf(header);
|
|
2192
|
+
if (headerIdx >= 0) selectSessionByIndex(headerIdx, items);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
} else {
|
|
2196
|
+
activateSelectedSession(items);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
function activateSelectedSession(items) {
|
|
2202
|
+
items = items || getNavigableItems();
|
|
2203
|
+
if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
|
|
2204
|
+
const el = items[selectedSessionIdx];
|
|
2205
|
+
if (el.classList.contains('project-group-header')) {
|
|
2206
|
+
const groupPath = el.dataset.groupPath;
|
|
2207
|
+
setGroupCollapsed(el, !collapsedProjectGroups.has(groupPath));
|
|
2208
|
+
} else {
|
|
2209
|
+
el.click();
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function setFocusZone(zone) {
|
|
2214
|
+
const sidebar = document.querySelector('.sidebar');
|
|
2215
|
+
// Clear all zone visuals
|
|
2216
|
+
sidebar.classList.remove('sidebar-focused');
|
|
2217
|
+
clearKbSelection();
|
|
2218
|
+
const selCard = document.querySelector('.task-card.selected');
|
|
2219
|
+
if (selCard) selCard.classList.remove('selected');
|
|
2220
|
+
|
|
2221
|
+
focusZone = zone;
|
|
2222
|
+
if (zone === 'sidebar') {
|
|
2223
|
+
if (sidebar.classList.contains('collapsed')) {
|
|
2224
|
+
sidebar.classList.remove('collapsed');
|
|
2225
|
+
localStorage.setItem('sidebar-collapsed', false);
|
|
2226
|
+
}
|
|
2227
|
+
sidebar.classList.add('sidebar-focused');
|
|
2228
|
+
const items = getNavigableItems();
|
|
2229
|
+
if (items.length > 0) {
|
|
2230
|
+
const activeIdx = items.findIndex((el) => el.classList.contains('active'));
|
|
2231
|
+
if (activeIdx >= 0) {
|
|
2232
|
+
selectSessionByIndex(activeIdx);
|
|
2233
|
+
} else if (selectedSessionKbId) {
|
|
2234
|
+
const restoredIdx = items.findIndex((el) => getKbId(el) === selectedSessionKbId);
|
|
2235
|
+
selectSessionByIndex(restoredIdx >= 0 ? restoredIdx : 0);
|
|
2236
|
+
} else {
|
|
2237
|
+
selectSessionByIndex(0);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
} else {
|
|
2241
|
+
// Session changed while in sidebar — reset stale selection
|
|
2242
|
+
if (selectedSessionId && selectedSessionId !== currentSessionId) {
|
|
2243
|
+
selectedTaskId = null;
|
|
2244
|
+
selectedSessionId = null;
|
|
2245
|
+
}
|
|
2246
|
+
if (selectedTaskId) {
|
|
2247
|
+
const card = document.querySelector(
|
|
2248
|
+
`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`,
|
|
2249
|
+
);
|
|
2250
|
+
if (card) card.classList.add('selected');
|
|
2251
|
+
} else {
|
|
2252
|
+
navigateVertical(1);
|
|
2253
|
+
}
|
|
2254
|
+
if (selectedTaskId && detailPanel.classList.contains('visible')) {
|
|
2255
|
+
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
2261
|
+
function getAvailableTasksOptions(currentTaskId = null) {
|
|
2262
|
+
const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
|
|
2263
|
+
const inProgress = currentTasks.filter((t) => t.status === 'in_progress' && t.id !== currentTaskId);
|
|
2264
|
+
const completed = currentTasks.filter((t) => t.status === 'completed' && t.id !== currentTaskId);
|
|
2265
|
+
|
|
2266
|
+
// Build options grouped by status
|
|
2267
|
+
let options = '';
|
|
2268
|
+
|
|
2269
|
+
if (pending.length > 0) {
|
|
2270
|
+
options += '<optgroup label="Pending">';
|
|
2271
|
+
pending.forEach((t, _idx) => {
|
|
2272
|
+
options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
2273
|
+
});
|
|
2274
|
+
options += '</optgroup>';
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
if (inProgress.length > 0) {
|
|
2278
|
+
options += '<optgroup label="In Progress">';
|
|
2279
|
+
inProgress.forEach((t, _idx) => {
|
|
2280
|
+
options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
2281
|
+
});
|
|
2282
|
+
options += '</optgroup>';
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
if (completed.length > 0) {
|
|
2286
|
+
options += '<optgroup label="Completed">';
|
|
2287
|
+
completed.forEach((t, _idx) => {
|
|
2288
|
+
options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
2289
|
+
});
|
|
2290
|
+
options += '</optgroup>';
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
return options;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
//#endregion
|
|
2297
|
+
|
|
2298
|
+
//#region TASK_DETAIL
|
|
2299
|
+
async function showTaskDetail(taskId, sessionId = null) {
|
|
2300
|
+
let task = currentTasks.find((t) => t.id === taskId && (!sessionId || t.sessionId === sessionId));
|
|
2301
|
+
|
|
2302
|
+
// If task not found in currentTasks, fetch it from the session
|
|
2303
|
+
if (!task && sessionId && sessionId !== 'undefined') {
|
|
2304
|
+
try {
|
|
2305
|
+
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
2306
|
+
const tasks = await res.json();
|
|
2307
|
+
task = tasks.find((t) => t.id === taskId);
|
|
2308
|
+
if (!task) return;
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
console.error('Failed to fetch task:', error);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
if (!task) return;
|
|
2316
|
+
|
|
2317
|
+
const actualSid = task.sessionId || sessionId || currentSessionId;
|
|
2318
|
+
selectTask(taskId, actualSid);
|
|
2319
|
+
detailPanel.classList.add('visible');
|
|
2320
|
+
|
|
2321
|
+
const statusLabels = {
|
|
2322
|
+
completed: '<span class="detail-status completed"><span class="dot"></span>Completed</span>',
|
|
2323
|
+
in_progress: '<span class="detail-status in_progress"><span class="dot"></span>In Progress</span>',
|
|
2324
|
+
pending: '<span class="detail-status pending"><span class="dot"></span>Pending</span>',
|
|
2325
|
+
};
|
|
2326
|
+
|
|
2327
|
+
const isBlocked = task.blockedBy && task.blockedBy.length > 0;
|
|
2328
|
+
const actualSessionId = task.sessionId || sessionId || currentSessionId;
|
|
2329
|
+
|
|
2330
|
+
detailContent.innerHTML = `
|
|
2331
|
+
<div class="detail-section">
|
|
2332
|
+
<div class="detail-label">Task #${task.id}</div>
|
|
2333
|
+
<h2 class="detail-title">${escapeHtml(task.subject)}</h2>
|
|
2334
|
+
</div>
|
|
2335
|
+
|
|
2336
|
+
<div class="detail-section" style="display: flex; gap: 12px; align-items: center;">
|
|
2337
|
+
<div>${statusLabels[task.status] || ''}</div>
|
|
2338
|
+
${task.owner ? `<div style="font-size: 13px; color: ${getOwnerColor(task.owner).color}; font-weight: 500;">${escapeHtml(task.owner)}</div>` : ''}
|
|
2339
|
+
${isBlocked && task.status !== 'in_progress' ? '<div style="font-size: 10px; color: var(--warning);">Blocked</div>' : ''}
|
|
2340
|
+
</div>
|
|
2341
|
+
|
|
2342
|
+
<div class="detail-section">
|
|
2343
|
+
<div class="detail-label">Description</div>
|
|
2344
|
+
<div class="detail-desc">${task.description ? renderMarkdown(task.description) : '<em style="color: var(--text-muted);">No description</em>'}</div>
|
|
2345
|
+
</div>
|
|
2346
|
+
|
|
2347
|
+
${
|
|
2348
|
+
task.activeForm && task.status === 'in_progress'
|
|
2349
|
+
? `
|
|
2350
|
+
<div class="detail-section">
|
|
2351
|
+
<div class="detail-box active">
|
|
2352
|
+
<strong>Currently:</strong> ${escapeHtml(task.activeForm)}
|
|
2353
|
+
</div>
|
|
2354
|
+
</div>
|
|
2355
|
+
`
|
|
2356
|
+
: ''
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
${
|
|
2360
|
+
task.blockedBy && task.blockedBy.length > 0
|
|
2361
|
+
? `
|
|
2362
|
+
<div class="detail-section">
|
|
2363
|
+
<div class="detail-label">Blocked By</div>
|
|
2364
|
+
<div class="detail-deps">
|
|
2365
|
+
<div class="detail-box blocked"><strong>Blocked by:</strong> ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>
|
|
2366
|
+
</div>
|
|
2367
|
+
</div>`
|
|
2368
|
+
: ''
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
${
|
|
2372
|
+
task.blocks && task.blocks.length > 0
|
|
2373
|
+
? `
|
|
2374
|
+
<div class="detail-section">
|
|
2375
|
+
<div class="detail-label">Blocks</div>
|
|
2376
|
+
<div class="detail-deps">
|
|
2377
|
+
<div class="detail-box blocks"><strong>Blocks:</strong> ${task.blocks.map((id) => `#${id}`).join(', ')}</div>
|
|
2378
|
+
</div>
|
|
2379
|
+
</div>`
|
|
2380
|
+
: ''
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
<div class="detail-section note-section">
|
|
2384
|
+
<label for="note-input" class="detail-label">Add Note</label>
|
|
2385
|
+
<form class="note-form" onsubmit="addNote(event, '${task.id}', '${actualSessionId}')">
|
|
2386
|
+
<textarea id="note-input" class="note-input" placeholder="Add a note for Claude..." rows="3"></textarea>
|
|
2387
|
+
<button type="submit" class="note-submit">Add Note</button>
|
|
2388
|
+
</form>
|
|
2389
|
+
</div>
|
|
2390
|
+
`;
|
|
2391
|
+
|
|
2392
|
+
// Setup button handlers
|
|
2393
|
+
const deleteBtn = document.getElementById('delete-task-btn');
|
|
2394
|
+
deleteBtn.style.display = '';
|
|
2395
|
+
deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
|
|
2396
|
+
|
|
2397
|
+
// Setup inline editing
|
|
2398
|
+
const titleEl = detailContent.querySelector('.detail-title');
|
|
2399
|
+
if (titleEl) {
|
|
2400
|
+
titleEl.onclick = () => editTitle(titleEl, task, actualSessionId);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const descEl = detailContent.querySelector('.detail-desc');
|
|
2404
|
+
if (descEl) {
|
|
2405
|
+
descEl.onclick = () => editDescription(descEl, task, actualSessionId);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function editTitle(titleEl, task, sessionId) {
|
|
2410
|
+
if (titleEl.querySelector('input')) return;
|
|
2411
|
+
const input = document.createElement('input');
|
|
2412
|
+
input.type = 'text';
|
|
2413
|
+
input.className = 'detail-title-input';
|
|
2414
|
+
input.value = task.subject;
|
|
2415
|
+
|
|
2416
|
+
titleEl.replaceWith(input);
|
|
2417
|
+
input.focus();
|
|
2418
|
+
input.select();
|
|
2419
|
+
|
|
2420
|
+
const save = async () => {
|
|
2421
|
+
const val = input.value.trim();
|
|
2422
|
+
if (val && val !== task.subject) {
|
|
2423
|
+
await saveTaskField(task.id, sessionId, 'subject', val);
|
|
2424
|
+
} else {
|
|
2425
|
+
showTaskDetail(task.id, sessionId);
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
|
|
2429
|
+
input.onkeydown = (e) => {
|
|
2430
|
+
if (e.key === 'Enter') {
|
|
2431
|
+
e.preventDefault();
|
|
2432
|
+
save();
|
|
2433
|
+
}
|
|
2434
|
+
if (e.key === 'Escape') showTaskDetail(task.id, sessionId);
|
|
2435
|
+
};
|
|
2436
|
+
input.onblur = () => save();
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function editDescription(descEl, task, sessionId) {
|
|
2440
|
+
if (descEl.querySelector('textarea')) return;
|
|
2441
|
+
const wrapper = document.createElement('div');
|
|
2442
|
+
const textarea = document.createElement('textarea');
|
|
2443
|
+
textarea.className = 'detail-desc-textarea';
|
|
2444
|
+
textarea.value = task.description || '';
|
|
2445
|
+
textarea.rows = Math.max(5, (task.description || '').split('\n').length + 2);
|
|
2446
|
+
|
|
2447
|
+
const actions = document.createElement('div');
|
|
2448
|
+
actions.className = 'edit-actions';
|
|
2449
|
+
|
|
2450
|
+
const saveBtn = document.createElement('button');
|
|
2451
|
+
saveBtn.className = 'edit-save';
|
|
2452
|
+
saveBtn.textContent = 'Save';
|
|
2453
|
+
|
|
2454
|
+
const cancelBtn = document.createElement('button');
|
|
2455
|
+
cancelBtn.className = 'edit-cancel';
|
|
2456
|
+
cancelBtn.textContent = 'Cancel';
|
|
2457
|
+
|
|
2458
|
+
actions.append(cancelBtn, saveBtn);
|
|
2459
|
+
wrapper.append(textarea, actions);
|
|
2460
|
+
descEl.replaceWith(wrapper);
|
|
2461
|
+
textarea.focus();
|
|
2462
|
+
|
|
2463
|
+
const save = async () => {
|
|
2464
|
+
const val = textarea.value;
|
|
2465
|
+
if (val !== (task.description || '')) {
|
|
2466
|
+
await saveTaskField(task.id, sessionId, 'description', val);
|
|
2467
|
+
} else {
|
|
2468
|
+
showTaskDetail(task.id, sessionId);
|
|
2469
|
+
}
|
|
2470
|
+
};
|
|
2471
|
+
|
|
2472
|
+
saveBtn.onclick = save;
|
|
2473
|
+
cancelBtn.onclick = () => showTaskDetail(task.id, sessionId);
|
|
2474
|
+
textarea.onkeydown = (e) => {
|
|
2475
|
+
if (e.key === 'Escape') showTaskDetail(task.id, sessionId);
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
async function saveTaskField(taskId, sessionId, field, value) {
|
|
2480
|
+
try {
|
|
2481
|
+
const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
|
|
2482
|
+
method: 'PUT',
|
|
2483
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2484
|
+
body: JSON.stringify({ [field]: value }),
|
|
2485
|
+
});
|
|
2486
|
+
|
|
2487
|
+
if (res.ok) {
|
|
2488
|
+
lastCurrentTasksHash = null;
|
|
2489
|
+
if (viewMode === 'all') {
|
|
2490
|
+
const tasksRes = await fetch('/api/tasks/all');
|
|
2491
|
+
currentTasks = await tasksRes.json();
|
|
2492
|
+
renderKanban();
|
|
2493
|
+
} else {
|
|
2494
|
+
await fetchTasks(sessionId);
|
|
2495
|
+
}
|
|
2496
|
+
showTaskDetail(taskId, sessionId);
|
|
2497
|
+
}
|
|
2498
|
+
} catch (error) {
|
|
2499
|
+
console.error('Failed to update task:', error);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
2504
|
+
async function addNote(event, taskId, sessionId) {
|
|
2505
|
+
event.preventDefault();
|
|
2506
|
+
const input = document.getElementById('note-input');
|
|
2507
|
+
const note = input.value.trim();
|
|
2508
|
+
if (!note) return;
|
|
2509
|
+
|
|
2510
|
+
try {
|
|
2511
|
+
const res = await fetch(`/api/tasks/${sessionId}/${taskId}/note`, {
|
|
2512
|
+
method: 'POST',
|
|
2513
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2514
|
+
body: JSON.stringify({ note }),
|
|
2515
|
+
});
|
|
2516
|
+
|
|
2517
|
+
if (res.ok) {
|
|
2518
|
+
input.value = '';
|
|
2519
|
+
// Refresh to show updated description
|
|
2520
|
+
if (viewMode === 'all') {
|
|
2521
|
+
const tasksRes = await fetch('/api/tasks/all');
|
|
2522
|
+
currentTasks = await tasksRes.json();
|
|
2523
|
+
} else {
|
|
2524
|
+
await fetchTasks(sessionId);
|
|
2525
|
+
}
|
|
2526
|
+
showTaskDetail(taskId, sessionId);
|
|
2527
|
+
}
|
|
2528
|
+
} catch (error) {
|
|
2529
|
+
console.error('Failed to add note:', error);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function closeDetailPanel() {
|
|
2534
|
+
detailPanel.classList.remove('visible');
|
|
2535
|
+
document.getElementById('delete-task-btn').style.display = 'none';
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
let deleteTaskId = null;
|
|
2539
|
+
let deleteSessionId = null;
|
|
2540
|
+
let deleteModalKeyHandler = null;
|
|
2541
|
+
|
|
2542
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
2543
|
+
function showBlockedTaskModal(task) {
|
|
2544
|
+
const messageDiv = document.getElementById('blocked-task-message');
|
|
2545
|
+
|
|
2546
|
+
const blockedByList = task.blockedBy
|
|
2547
|
+
.map((id) => {
|
|
2548
|
+
const blockingTask = currentTasks.find((t) => t.id === id);
|
|
2549
|
+
if (blockingTask) {
|
|
2550
|
+
return `<li><strong>#${blockingTask.id}</strong> - ${escapeHtml(blockingTask.subject)}</li>`;
|
|
2551
|
+
}
|
|
2552
|
+
return `<li><strong>#${id}</strong></li>`;
|
|
2553
|
+
})
|
|
2554
|
+
.join('');
|
|
2555
|
+
|
|
2556
|
+
messageDiv.innerHTML = `
|
|
2557
|
+
<p style="margin-bottom: 12px;">Task <strong>#${task.id}</strong> - ${escapeHtml(task.subject)} is currently blocked by:</p>
|
|
2558
|
+
<ul style="margin: 0 0 16px 20px; padding: 0;">${blockedByList}</ul>
|
|
2559
|
+
<p style="margin: 0; color: var(--text-secondary); font-size: 13px;">
|
|
2560
|
+
Please resolve these dependencies before moving this task to <strong>In Progress</strong>.
|
|
2561
|
+
</p>
|
|
2562
|
+
`;
|
|
2563
|
+
|
|
2564
|
+
const modal = document.getElementById('blocked-task-modal');
|
|
2565
|
+
modal.classList.add('visible');
|
|
2566
|
+
|
|
2567
|
+
// Handle ESC key
|
|
2568
|
+
const keyHandler = (e) => {
|
|
2569
|
+
if (e.key === 'Escape') {
|
|
2570
|
+
e.preventDefault();
|
|
2571
|
+
closeBlockedTaskModal();
|
|
2572
|
+
document.removeEventListener('keydown', keyHandler);
|
|
2573
|
+
}
|
|
2574
|
+
};
|
|
2575
|
+
document.addEventListener('keydown', keyHandler);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function closeBlockedTaskModal() {
|
|
2579
|
+
const modal = document.getElementById('blocked-task-modal');
|
|
2580
|
+
modal.classList.remove('visible');
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
//#endregion
|
|
2584
|
+
|
|
2585
|
+
//#region DELETE_TASK
|
|
2586
|
+
function deleteTask(taskId, sessionId) {
|
|
2587
|
+
const task = currentTasks.find((t) => t.id === taskId);
|
|
2588
|
+
if (!task) return;
|
|
2589
|
+
|
|
2590
|
+
deleteTaskId = taskId;
|
|
2591
|
+
deleteSessionId = sessionId;
|
|
2592
|
+
|
|
2593
|
+
const message = document.getElementById('delete-confirm-message');
|
|
2594
|
+
message.textContent = `Delete task "${task.subject}"? This cannot be undone.`;
|
|
2595
|
+
|
|
2596
|
+
const modal = document.getElementById('delete-confirm-modal');
|
|
2597
|
+
modal.classList.add('visible');
|
|
2598
|
+
|
|
2599
|
+
const buttons = [document.getElementById('delete-cancel-btn'), document.getElementById('delete-confirm-btn')];
|
|
2600
|
+
let focusIdx = 1;
|
|
2601
|
+
buttons[focusIdx].focus();
|
|
2602
|
+
|
|
2603
|
+
deleteModalKeyHandler = (e) => {
|
|
2604
|
+
if (e.key === 'Escape') {
|
|
2605
|
+
e.preventDefault();
|
|
2606
|
+
closeDeleteConfirmModal();
|
|
2607
|
+
} else if (matchKey(e, 'ArrowLeft', 'KeyH')) {
|
|
2608
|
+
e.preventDefault();
|
|
2609
|
+
focusIdx = 0;
|
|
2610
|
+
buttons[focusIdx].focus();
|
|
2611
|
+
} else if (matchKey(e, 'ArrowRight', 'KeyL')) {
|
|
2612
|
+
e.preventDefault();
|
|
2613
|
+
focusIdx = 1;
|
|
2614
|
+
buttons[focusIdx].focus();
|
|
2615
|
+
} else if (e.key === 'Enter') {
|
|
2616
|
+
e.preventDefault();
|
|
2617
|
+
buttons[focusIdx].click();
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
document.addEventListener('keydown', deleteModalKeyHandler);
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
function closeDeleteConfirmModal() {
|
|
2624
|
+
const modal = document.getElementById('delete-confirm-modal');
|
|
2625
|
+
modal.classList.remove('visible');
|
|
2626
|
+
deleteTaskId = null;
|
|
2627
|
+
deleteSessionId = null;
|
|
2628
|
+
if (deleteModalKeyHandler) {
|
|
2629
|
+
document.removeEventListener('keydown', deleteModalKeyHandler);
|
|
2630
|
+
deleteModalKeyHandler = null;
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
2635
|
+
async function confirmDelete() {
|
|
2636
|
+
if (!deleteTaskId || !deleteSessionId) return;
|
|
2637
|
+
|
|
2638
|
+
const taskId = deleteTaskId;
|
|
2639
|
+
const sessionId = deleteSessionId;
|
|
2640
|
+
|
|
2641
|
+
closeDeleteConfirmModal();
|
|
2642
|
+
|
|
2643
|
+
try {
|
|
2644
|
+
const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
|
|
2645
|
+
method: 'DELETE',
|
|
2646
|
+
});
|
|
2647
|
+
|
|
2648
|
+
if (res.ok) {
|
|
2649
|
+
closeDetailPanel();
|
|
2650
|
+
await refreshCurrentView();
|
|
2651
|
+
} else {
|
|
2652
|
+
const error = await res.json();
|
|
2653
|
+
alert(`Failed to delete task: ${error.error || 'Unknown error'}`);
|
|
2654
|
+
}
|
|
2655
|
+
} catch (error) {
|
|
2656
|
+
console.error('Failed to delete task:', error);
|
|
2657
|
+
alert('Failed to delete task');
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
//#endregion
|
|
2662
|
+
|
|
2663
|
+
//#region HELP
|
|
2664
|
+
function showHelpModal() {
|
|
2665
|
+
const modal = document.getElementById('help-modal');
|
|
2666
|
+
modal.classList.add('visible');
|
|
2667
|
+
|
|
2668
|
+
// Handle keyboard shortcuts
|
|
2669
|
+
const keyHandler = (e) => {
|
|
2670
|
+
if (e.key === 'Escape' || e.key === '?') {
|
|
2671
|
+
e.preventDefault();
|
|
2672
|
+
closeHelpModal();
|
|
2673
|
+
document.removeEventListener('keydown', keyHandler);
|
|
2674
|
+
}
|
|
2675
|
+
};
|
|
2676
|
+
document.addEventListener('keydown', keyHandler);
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function closeHelpModal() {
|
|
2680
|
+
const modal = document.getElementById('help-modal');
|
|
2681
|
+
modal.classList.remove('visible');
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
async function refreshCurrentView() {
|
|
2685
|
+
if (viewMode === 'all') {
|
|
2686
|
+
await showAllTasks();
|
|
2687
|
+
} else if (currentSessionId) {
|
|
2688
|
+
await fetchTasks(currentSessionId);
|
|
2689
|
+
renderLiveUpdatesFromCache();
|
|
2690
|
+
} else {
|
|
2691
|
+
await fetchSessions();
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
document.getElementById('close-detail').onclick = closeDetailPanel;
|
|
2696
|
+
|
|
2697
|
+
//#endregion
|
|
2698
|
+
|
|
2699
|
+
//#region SCRATCHPAD
|
|
2700
|
+
let _scratchpadSaveTimer = null;
|
|
2701
|
+
const _scratchpadModal = document.getElementById('scratchpad-modal');
|
|
2702
|
+
const _scratchpadTextarea = document.getElementById('scratchpad-textarea');
|
|
2703
|
+
const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
|
|
2704
|
+
|
|
2705
|
+
function toggleScratchpad() {
|
|
2706
|
+
if (_scratchpadModal.classList.contains('visible')) {
|
|
2707
|
+
closeScratchpad();
|
|
2708
|
+
} else {
|
|
2709
|
+
showScratchpad();
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function showScratchpad() {
|
|
2714
|
+
if (!currentSessionId) return;
|
|
2715
|
+
_scratchpadTextarea.value = localStorage.getItem(`scratchpad-${currentSessionId}`) || '';
|
|
2716
|
+
_scratchpadCharcount.textContent = `${_scratchpadTextarea.value.length} chars`;
|
|
2717
|
+
_scratchpadModal.classList.add('visible');
|
|
2718
|
+
_scratchpadTextarea.focus();
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
function closeScratchpad() {
|
|
2722
|
+
if (_scratchpadSaveTimer) {
|
|
2723
|
+
clearTimeout(_scratchpadSaveTimer);
|
|
2724
|
+
_scratchpadSaveTimer = null;
|
|
2725
|
+
}
|
|
2726
|
+
saveScratchpad();
|
|
2727
|
+
_scratchpadModal.classList.remove('visible');
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function saveScratchpad() {
|
|
2731
|
+
if (!currentSessionId) return;
|
|
2732
|
+
localStorage.setItem(`scratchpad-${currentSessionId}`, _scratchpadTextarea.value);
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
_scratchpadTextarea.addEventListener('input', () => {
|
|
2736
|
+
_scratchpadCharcount.textContent = `${_scratchpadTextarea.value.length} chars`;
|
|
2737
|
+
if (_scratchpadSaveTimer) clearTimeout(_scratchpadSaveTimer);
|
|
2738
|
+
_scratchpadSaveTimer = setTimeout(() => {
|
|
2739
|
+
saveScratchpad();
|
|
2740
|
+
_scratchpadSaveTimer = null;
|
|
2741
|
+
}, 500);
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2744
|
+
//#endregion
|
|
2745
|
+
|
|
2746
|
+
//#region KEYBOARD_SHORTCUTS
|
|
2747
|
+
function matchKey(e, ...keys) {
|
|
2748
|
+
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false;
|
|
2749
|
+
return keys.some((k) => e.key === k || e.code === k);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
document.addEventListener('keydown', (e) => {
|
|
2753
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// Modal guard — only Escape, Shift+M, and msg-detail J/K navigation pass through
|
|
2758
|
+
if (document.querySelector('.modal-overlay.visible')) {
|
|
2759
|
+
if (e.key === 'Escape') {
|
|
2760
|
+
if (_scratchpadModal.classList.contains('visible')) {
|
|
2761
|
+
closeScratchpad();
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
2765
|
+
document.querySelectorAll('.modal-overlay.visible').forEach((m) => m.classList.remove('visible'));
|
|
2766
|
+
msgDetailFollowLatest = false;
|
|
2767
|
+
} else if (
|
|
2768
|
+
e.code === 'KeyM' &&
|
|
2769
|
+
e.shiftKey &&
|
|
2770
|
+
document.getElementById('msg-detail-modal').classList.contains('visible')
|
|
2771
|
+
) {
|
|
2772
|
+
e.preventDefault();
|
|
2773
|
+
closeMsgDetailModal();
|
|
2774
|
+
} else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
2775
|
+
if (matchKey(e, 'ArrowDown', 'KeyJ')) {
|
|
2776
|
+
e.preventDefault();
|
|
2777
|
+
if (currentMsgDetailIdx < currentMessages.length - 1) {
|
|
2778
|
+
msgDetailFollowLatest = false;
|
|
2779
|
+
showMsgDetail(currentMsgDetailIdx + 1);
|
|
2780
|
+
} else if (currentMsgDetailIdx === currentMessages.length - 1) {
|
|
2781
|
+
msgDetailFollowLatest = true;
|
|
2782
|
+
showMsgDetail(currentMsgDetailIdx);
|
|
2783
|
+
}
|
|
2784
|
+
} else if (matchKey(e, 'ArrowUp', 'KeyK')) {
|
|
2785
|
+
e.preventDefault();
|
|
2786
|
+
if (currentMsgDetailIdx > 0) {
|
|
2787
|
+
msgDetailFollowLatest = false;
|
|
2788
|
+
showMsgDetail(currentMsgDetailIdx - 1);
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// Global shortcuts
|
|
2796
|
+
if (e.key === '[') {
|
|
2797
|
+
e.preventDefault();
|
|
2798
|
+
toggleSidebar();
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
if (e.code === 'KeyL' && e.shiftKey) {
|
|
2802
|
+
e.preventDefault();
|
|
2803
|
+
toggleMessagePanel();
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
if (e.code === 'KeyM' && e.shiftKey) {
|
|
2807
|
+
e.preventDefault();
|
|
2808
|
+
const msgDetailModal = document.getElementById('msg-detail-modal');
|
|
2809
|
+
if (msgDetailModal.classList.contains('visible')) {
|
|
2810
|
+
closeMsgDetailModal();
|
|
2811
|
+
} else if (currentMessages.length) {
|
|
2812
|
+
msgDetailFollowLatest = true;
|
|
2813
|
+
showMsgDetail(currentMessages.length - 1);
|
|
2814
|
+
}
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
// Tab toggles focus zone
|
|
2819
|
+
if (e.key === 'Tab') {
|
|
2820
|
+
e.preventDefault();
|
|
2821
|
+
if (focusZone === 'sidebar') {
|
|
2822
|
+
const hasCards = document.querySelector('.task-card');
|
|
2823
|
+
if (!hasCards) return;
|
|
2824
|
+
}
|
|
2825
|
+
setFocusZone(focusZone === 'board' ? 'sidebar' : 'board');
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// Sidebar navigation
|
|
2830
|
+
if (focusZone === 'sidebar') {
|
|
2831
|
+
if (matchKey(e, 'ArrowDown', 'KeyJ')) {
|
|
2832
|
+
e.preventDefault();
|
|
2833
|
+
navigateSession(1);
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
if (matchKey(e, 'ArrowUp', 'KeyK')) {
|
|
2837
|
+
e.preventDefault();
|
|
2838
|
+
navigateSession(-1);
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
if (matchKey(e, 'ArrowLeft', 'KeyH')) {
|
|
2842
|
+
e.preventDefault();
|
|
2843
|
+
handleSidebarHorizontal(-1);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
if (matchKey(e, 'ArrowRight', 'KeyL')) {
|
|
2847
|
+
e.preventDefault();
|
|
2848
|
+
handleSidebarHorizontal(1);
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2852
|
+
e.preventDefault();
|
|
2853
|
+
activateSelectedSession();
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
if (e.key === 'Escape') {
|
|
2857
|
+
setFocusZone('board');
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
// Board navigation
|
|
2863
|
+
if (focusZone === 'board') {
|
|
2864
|
+
if (matchKey(e, 'ArrowDown', 'KeyJ', 'ArrowUp', 'KeyK', 'ArrowLeft', 'KeyH', 'ArrowRight', 'KeyL')) {
|
|
2865
|
+
e.preventDefault();
|
|
2866
|
+
if (!selectedTaskId && !document.querySelector('.task-card.selected')) {
|
|
2867
|
+
setFocusZone('sidebar');
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
if (matchKey(e, 'ArrowDown', 'KeyJ')) navigateVertical(1);
|
|
2871
|
+
else if (matchKey(e, 'ArrowUp', 'KeyK')) navigateVertical(-1);
|
|
2872
|
+
else if (matchKey(e, 'ArrowLeft', 'KeyH')) navigateHorizontal(-1);
|
|
2873
|
+
else if (matchKey(e, 'ArrowRight', 'KeyL')) navigateHorizontal(1);
|
|
2874
|
+
|
|
2875
|
+
if (selectedTaskId && detailPanel.classList.contains('visible')) {
|
|
2876
|
+
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
2877
|
+
}
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
if ((e.key === 'Enter' || e.key === ' ') && selectedTaskId && e.target.tagName !== 'BUTTON') {
|
|
2882
|
+
e.preventDefault();
|
|
2883
|
+
if (detailPanel.classList.contains('visible')) {
|
|
2884
|
+
const labelEl = document.querySelector('.detail-label');
|
|
2885
|
+
const shownId = labelEl?.textContent.match(/\d+/)?.[0];
|
|
2886
|
+
if (shownId === selectedTaskId) {
|
|
2887
|
+
closeDetailPanel();
|
|
2888
|
+
} else {
|
|
2889
|
+
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
2890
|
+
}
|
|
2891
|
+
} else {
|
|
2892
|
+
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
2893
|
+
}
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
if (matchKey(e, 'KeyD') && selectedTaskId) {
|
|
2898
|
+
e.preventDefault();
|
|
2899
|
+
deleteTask(selectedTaskId, selectedSessionId || currentSessionId);
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
if (e.key === 'Escape') {
|
|
2905
|
+
if (detailPanel.classList.contains('visible')) closeDetailPanel();
|
|
2906
|
+
else if (agentLogMode) exitAgentLogMode();
|
|
2907
|
+
else if (messagePanelOpen) toggleMessagePanel();
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
// Shared actions — work in both sidebar and board
|
|
2912
|
+
const contextSid =
|
|
2913
|
+
focusZone === 'sidebar'
|
|
2914
|
+
? sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId
|
|
2915
|
+
: selectedSessionId || currentSessionId;
|
|
2916
|
+
if (matchKey(e, 'KeyP') && !e.shiftKey) {
|
|
2917
|
+
e.preventDefault();
|
|
2918
|
+
if (contextSid) openPlanForSession(contextSid);
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
if (matchKey(e, 'KeyI') && !e.shiftKey) {
|
|
2922
|
+
e.preventDefault();
|
|
2923
|
+
if (contextSid) showSessionInfoModal(contextSid);
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2926
|
+
if (matchKey(e, 'KeyN') && !e.shiftKey) {
|
|
2927
|
+
e.preventDefault();
|
|
2928
|
+
toggleScratchpad();
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
|
|
2932
|
+
e.preventDefault();
|
|
2933
|
+
showHelpModal();
|
|
2934
|
+
}
|
|
2935
|
+
});
|
|
2936
|
+
|
|
2937
|
+
//#endregion
|
|
2938
|
+
|
|
2939
|
+
//#region SSE
|
|
2940
|
+
function setupEventSource() {
|
|
2941
|
+
let retryDelay = 1000;
|
|
2942
|
+
let eventSource;
|
|
2943
|
+
let wasConnected = false;
|
|
2944
|
+
let failCount = 0;
|
|
2945
|
+
const offlineOverlay = document.getElementById('offline-overlay');
|
|
2946
|
+
const offlineStatus = document.getElementById('offline-status');
|
|
2947
|
+
|
|
2948
|
+
function showOffline() {
|
|
2949
|
+
offlineOverlay.classList.add('visible');
|
|
2950
|
+
offlineStatus.textContent = 'Attempting to reconnect...';
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
function hideOffline() {
|
|
2954
|
+
offlineOverlay.classList.remove('visible');
|
|
2955
|
+
failCount = 0;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
function connect() {
|
|
2959
|
+
eventSource = new EventSource('/api/events');
|
|
2960
|
+
|
|
2961
|
+
eventSource.onopen = () => {
|
|
2962
|
+
if (wasConnected) {
|
|
2963
|
+
console.warn('[SSE] Reconnected after drop — forcing full refresh');
|
|
2964
|
+
fetchSessions().catch(() => {});
|
|
2965
|
+
if (currentSessionId) fetchTasks(currentSessionId);
|
|
2966
|
+
}
|
|
2967
|
+
wasConnected = true;
|
|
2968
|
+
retryDelay = 1000;
|
|
2969
|
+
hideOffline();
|
|
2970
|
+
connectionStatus.innerHTML = `
|
|
2971
|
+
<span class="connection-dot live"></span>
|
|
2972
|
+
<span>Connected</span>
|
|
2973
|
+
`;
|
|
2974
|
+
};
|
|
2975
|
+
|
|
2976
|
+
eventSource.onerror = () => {
|
|
2977
|
+
eventSource.close();
|
|
2978
|
+
failCount++;
|
|
2979
|
+
console.warn('[SSE] Connection lost, retrying in', retryDelay, 'ms');
|
|
2980
|
+
connectionStatus.innerHTML = `
|
|
2981
|
+
<span class="connection-dot error"></span>
|
|
2982
|
+
<span>Reconnecting...</span>
|
|
2983
|
+
`;
|
|
2984
|
+
if (failCount >= 2) showOffline();
|
|
2985
|
+
setTimeout(connect, retryDelay);
|
|
2986
|
+
retryDelay = Math.min(retryDelay * 2, 30000);
|
|
2987
|
+
};
|
|
2988
|
+
|
|
2989
|
+
let taskRefreshTimer = null;
|
|
2990
|
+
let metadataRefreshTimer = null;
|
|
2991
|
+
const pendingTaskSessionIds = new Set();
|
|
2992
|
+
|
|
2993
|
+
function debouncedRefresh(sessionId, isMetadata) {
|
|
2994
|
+
if (isMetadata) {
|
|
2995
|
+
clearTimeout(metadataRefreshTimer);
|
|
2996
|
+
metadataRefreshTimer = setTimeout(() => {
|
|
2997
|
+
fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
2998
|
+
if (currentSessionId && !agentLogMode) fetchMessages(currentSessionId);
|
|
2999
|
+
}, 2000);
|
|
3000
|
+
} else {
|
|
3001
|
+
pendingTaskSessionIds.add(sessionId);
|
|
3002
|
+
clearTimeout(taskRefreshTimer);
|
|
3003
|
+
taskRefreshTimer = setTimeout(async () => {
|
|
3004
|
+
await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
3005
|
+
if (viewMode === 'all') {
|
|
3006
|
+
currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
|
|
3007
|
+
renderAllTasks();
|
|
3008
|
+
renderLiveUpdatesFromCache();
|
|
3009
|
+
} else if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
|
|
3010
|
+
fetchTasks(currentSessionId);
|
|
3011
|
+
}
|
|
3012
|
+
pendingTaskSessionIds.clear();
|
|
3013
|
+
}, 500);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
eventSource.onmessage = (event) => {
|
|
3018
|
+
const data = JSON.parse(event.data);
|
|
3019
|
+
console.log('[SSE] Event received:', data);
|
|
3020
|
+
if (data.type === 'update' || data.type === 'metadata-update') {
|
|
3021
|
+
if (data.type === 'metadata-update') projectsCacheDirty = true;
|
|
3022
|
+
debouncedRefresh(data.sessionId, data.type === 'metadata-update');
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
if (data.type === 'plan-update') {
|
|
3026
|
+
refreshOpenPlan();
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
if (data.type === 'agent-update') {
|
|
3030
|
+
fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
3031
|
+
if (currentSessionId && data.sessionId === currentSessionId) {
|
|
3032
|
+
fetchAgents(currentSessionId);
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
if (data.type === 'context-update') {
|
|
3037
|
+
debouncedRefresh(data.sessionId, true);
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
if (data.type === 'team-update') {
|
|
3041
|
+
console.log('[SSE] Team update:', data.teamName);
|
|
3042
|
+
debouncedRefresh(data.teamName, false);
|
|
3043
|
+
}
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// Fallback poll every 30s in case SSE silently drops
|
|
3048
|
+
setInterval(() => {
|
|
3049
|
+
fetchSessions().catch(() => {});
|
|
3050
|
+
}, 30000);
|
|
3051
|
+
|
|
3052
|
+
connect();
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
const CONTEXT_COLORS = { green: '#5b9a6b', yellow: '#b8a63e', orange: '#c07840', red: '#b85555' };
|
|
3056
|
+
const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
|
|
3057
|
+
const MODEL_THRESHOLDS = [
|
|
3058
|
+
{ match: /sonnet|haiku/i, yellow: 100000, orange: 130000, red: 150000 },
|
|
3059
|
+
{ match: /opus/i, yellow: 100000, orange: 200000, red: 700000 },
|
|
3060
|
+
];
|
|
3061
|
+
const DEFAULT_THRESHOLDS = { yellow: 100000, orange: 130000, red: 150000 };
|
|
3062
|
+
|
|
3063
|
+
//#endregion
|
|
3064
|
+
|
|
3065
|
+
//#region CONTEXT_WINDOW
|
|
3066
|
+
function getModelThresholds(modelName) {
|
|
3067
|
+
if (!modelName) return DEFAULT_THRESHOLDS;
|
|
3068
|
+
for (const t of MODEL_THRESHOLDS) {
|
|
3069
|
+
if (t.match.test(modelName)) return t;
|
|
3070
|
+
}
|
|
3071
|
+
return DEFAULT_THRESHOLDS;
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
function getContextColor(usedTokens, modelName) {
|
|
3075
|
+
const t = getModelThresholds(modelName);
|
|
3076
|
+
if (usedTokens < t.yellow) return CONTEXT_COLORS.green;
|
|
3077
|
+
if (usedTokens < t.orange) return CONTEXT_COLORS.yellow;
|
|
3078
|
+
if (usedTokens < t.red) return CONTEXT_COLORS.orange;
|
|
3079
|
+
return CONTEXT_COLORS.red;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
function getCostColor(usd) {
|
|
3083
|
+
const val = usd || 0;
|
|
3084
|
+
if (val < COST_THRESHOLDS.green) return CONTEXT_COLORS.green;
|
|
3085
|
+
if (val < COST_THRESHOLDS.yellow) return CONTEXT_COLORS.yellow;
|
|
3086
|
+
if (val < COST_THRESHOLDS.orange) return CONTEXT_COLORS.orange;
|
|
3087
|
+
return CONTEXT_COLORS.red;
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
function renderMarkers(markers) {
|
|
3091
|
+
return markers
|
|
3092
|
+
.map(
|
|
3093
|
+
(m) =>
|
|
3094
|
+
`<div class="context-bar-marker" style="left:${m.pct}%;background:${m.color}" title="${formatTokens(m.tokens / 1000)}"></div>`,
|
|
3095
|
+
)
|
|
3096
|
+
.join('');
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
function formatTokens(k) {
|
|
3100
|
+
if (k >= 1000) return `${(k / 1000).toFixed(1)}M`;
|
|
3101
|
+
if (k < 1) return (k * 1000).toFixed(0);
|
|
3102
|
+
return `${Math.round(k)}K`;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
function getCtx(raw) {
|
|
3106
|
+
if (!raw) return null;
|
|
3107
|
+
const cw = raw.context_window || {};
|
|
3108
|
+
const size = cw.context_window_size || 0;
|
|
3109
|
+
const pct = cw.used_percentage || 0;
|
|
3110
|
+
const model = raw.model || {};
|
|
3111
|
+
const modelName = model.display_name || model.id || '';
|
|
3112
|
+
const thresholds = getModelThresholds(modelName);
|
|
3113
|
+
const usedTokens = size > 0 ? (pct / 100) * size : 0;
|
|
3114
|
+
const markers =
|
|
3115
|
+
size > 0
|
|
3116
|
+
? [
|
|
3117
|
+
{ tokens: thresholds.yellow, pct: (thresholds.yellow / size) * 100, color: CONTEXT_COLORS.yellow },
|
|
3118
|
+
{ tokens: thresholds.orange, pct: (thresholds.orange / size) * 100, color: CONTEXT_COLORS.orange },
|
|
3119
|
+
{ tokens: thresholds.red, pct: (thresholds.red / size) * 100, color: CONTEXT_COLORS.red },
|
|
3120
|
+
].filter((m) => m.pct > 0 && m.pct < 100)
|
|
3121
|
+
: [];
|
|
3122
|
+
return {
|
|
3123
|
+
pct,
|
|
3124
|
+
remaining: cw.remaining_percentage || 100 - pct,
|
|
3125
|
+
size,
|
|
3126
|
+
usedTokens,
|
|
3127
|
+
modelName,
|
|
3128
|
+
inputTokens: cw.total_input_tokens || 0,
|
|
3129
|
+
outputTokens: cw.total_output_tokens || 0,
|
|
3130
|
+
markers,
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
function renderContextBar(raw) {
|
|
3135
|
+
const ctx = getCtx(raw);
|
|
3136
|
+
if (!ctx) return '';
|
|
3137
|
+
const color = getContextColor(ctx.usedTokens, ctx.modelName);
|
|
3138
|
+
return `
|
|
3139
|
+
<div class="context-bar" style="display:block">
|
|
3140
|
+
<div class="context-bar-track">
|
|
3141
|
+
<div class="context-bar-fill" style="width:${ctx.pct}%;background:${color}"></div>
|
|
3142
|
+
${renderMarkers(ctx.markers)}
|
|
3143
|
+
</div>
|
|
3144
|
+
<div class="context-bar-labels">
|
|
3145
|
+
<span style="color:${color}">${Math.round(ctx.pct)}% (${formatTokens(ctx.usedTokens / 1000)})</span>
|
|
3146
|
+
<span>${Math.round(ctx.remaining)}% free</span>
|
|
3147
|
+
</div>
|
|
3148
|
+
</div>`;
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
function formatCost(usd) {
|
|
3152
|
+
if (!usd) return '$0.00';
|
|
3153
|
+
return `$${usd.toFixed(2)}`;
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
function renderContextDetail(raw) {
|
|
3157
|
+
const ctx = getCtx(raw);
|
|
3158
|
+
if (!ctx) return '';
|
|
3159
|
+
const totalK = ctx.size / 1000;
|
|
3160
|
+
const color = getContextColor(ctx.usedTokens, ctx.modelName);
|
|
3161
|
+
|
|
3162
|
+
const cw = raw.context_window || {};
|
|
3163
|
+
const usage = cw.current_usage || {};
|
|
3164
|
+
const cost = raw.cost || {};
|
|
3165
|
+
|
|
3166
|
+
return `
|
|
3167
|
+
<div class="detail-context">
|
|
3168
|
+
<div class="detail-context-title">${ctx.modelName ? escapeHtml(ctx.modelName) : 'Context Window'}</div>
|
|
3169
|
+
<div class="detail-context-bar">
|
|
3170
|
+
<div class="context-bar-track">
|
|
3171
|
+
<div class="context-bar-fill" style="width:${ctx.pct}%;background:${color}"></div>
|
|
3172
|
+
${renderMarkers(ctx.markers)}
|
|
3173
|
+
</div>
|
|
3174
|
+
</div>
|
|
3175
|
+
<div class="detail-context-summary">
|
|
3176
|
+
<span style="color:${color}">${Math.round(ctx.pct)}% used</span>
|
|
3177
|
+
<span>${formatTokens((ctx.pct / 100) * totalK)} / ${formatTokens(totalK)}</span>
|
|
3178
|
+
</div>
|
|
3179
|
+
<div class="detail-context-stats">
|
|
3180
|
+
<div class="stat-item"><span class="stat-label">Cache read</span><span class="stat-value">${formatTokens((usage.cache_read_input_tokens || 0) / 1000)}</span></div>
|
|
3181
|
+
<div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens((usage.cache_creation_input_tokens || 0) / 1000)}</span></div>
|
|
3182
|
+
<div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
|
|
3183
|
+
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
|
|
3184
|
+
<div class="stat-divider"></div>
|
|
3185
|
+
<div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
|
|
3186
|
+
<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
|
|
3187
|
+
<div class="stat-divider"></div>
|
|
3188
|
+
<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
|
|
3189
|
+
<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>
|
|
3190
|
+
<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>
|
|
3191
|
+
<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>
|
|
3192
|
+
</div>
|
|
3193
|
+
</div>`;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
//#endregion
|
|
3197
|
+
|
|
3198
|
+
//#region UTILS
|
|
3199
|
+
function formatDate(dateStr) {
|
|
3200
|
+
const date = new Date(dateStr);
|
|
3201
|
+
const now = new Date();
|
|
3202
|
+
const diff = now - date;
|
|
3203
|
+
|
|
3204
|
+
if (diff < 60000) return 'just now';
|
|
3205
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
3206
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
3207
|
+
return date.toLocaleDateString();
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
function stripAnsi(text) {
|
|
3211
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: \x1b is intentional for ANSI escape sequence stripping
|
|
3212
|
+
return typeof text === 'string' ? text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') : text;
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
function escapeHtml(text) {
|
|
3216
|
+
const div = document.createElement('div');
|
|
3217
|
+
div.textContent = text;
|
|
3218
|
+
return div.innerHTML;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
function renderMarkdown(text) {
|
|
3222
|
+
if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
|
|
3223
|
+
return DOMPurify.sanitize(marked.parse(text));
|
|
3224
|
+
}
|
|
3225
|
+
return `<pre style="white-space:pre-wrap;margin:0;">${escapeHtml(text)}</pre>`;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
const _agentTabTexts = {};
|
|
3229
|
+
|
|
3230
|
+
function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
|
|
3231
|
+
for (const k in _agentTabTexts) delete _agentTabTexts[k];
|
|
3232
|
+
const tabs = [];
|
|
3233
|
+
const panels = [];
|
|
3234
|
+
const id = `at-${Math.random().toString(36).slice(2, 8)}`;
|
|
3235
|
+
if (promptHtml) {
|
|
3236
|
+
tabs.push({ key: 'prompt', label: 'Prompt' });
|
|
3237
|
+
panels.push({ key: 'prompt', html: promptHtml });
|
|
3238
|
+
if (promptText) _agentTabTexts[`${id}-prompt`] = promptText;
|
|
3239
|
+
}
|
|
3240
|
+
if (responseHtml) {
|
|
3241
|
+
tabs.push({ key: 'response', label: 'Response' });
|
|
3242
|
+
panels.push({ key: 'response', html: responseHtml });
|
|
3243
|
+
if (responseText) _agentTabTexts[`${id}-response`] = responseText;
|
|
3244
|
+
}
|
|
3245
|
+
if (!tabs.length) return '';
|
|
3246
|
+
const defaultTab = responseHtml ? 'response' : tabs[0].key;
|
|
3247
|
+
const copyBtnHtml = `<button class="agent-tab-copy" title="Copy" onclick="copyAgentTabActive('${id}',this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>`;
|
|
3248
|
+
const tabsHtml = tabs
|
|
3249
|
+
.map(
|
|
3250
|
+
(t) =>
|
|
3251
|
+
`<div class="agent-tab${t.key === defaultTab ? ' active' : ''}" data-tab-group="${id}" data-tab-key="${t.key}" onclick="document.querySelectorAll('[data-tab-group=\\'${id}\\']').forEach(el=>{el.classList.toggle('active',el.dataset.tabKey==='${t.key}')})">${t.label}</div>`,
|
|
3252
|
+
)
|
|
3253
|
+
.join('');
|
|
3254
|
+
const panelsHtml = panels
|
|
3255
|
+
.map(
|
|
3256
|
+
(p) =>
|
|
3257
|
+
`<div class="agent-tab-panel${p.key === defaultTab ? ' active' : ''}" data-tab-group="${id}" data-tab-key="${p.key}"><div class="detail-desc rendered-md" style="font-size:13px;">${p.html}</div></div>`,
|
|
3258
|
+
)
|
|
3259
|
+
.join('');
|
|
3260
|
+
return `<div class="agent-tabs">${tabsHtml}${copyBtnHtml}</div>${panelsHtml}`;
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
async function copyAgentTab(key, btn) {
|
|
3264
|
+
const text = _agentTabTexts[key];
|
|
3265
|
+
if (!text) return;
|
|
3266
|
+
copyWithFeedback(text, btn);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3270
|
+
async function copyAgentTabActive(groupId, btn) {
|
|
3271
|
+
const activePanel = document.querySelector(`.agent-tab-panel.active[data-tab-group="${groupId}"]`);
|
|
3272
|
+
if (!activePanel) return;
|
|
3273
|
+
const key = `${groupId}-${activePanel.dataset.tabKey}`;
|
|
3274
|
+
copyAgentTab(key, btn);
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
const ownerColors = [
|
|
3278
|
+
{ bg: 'rgba(37, 99, 235, 0.14)', color: '#1d5bbf' }, // blue
|
|
3279
|
+
{ bg: 'rgba(168, 85, 247, 0.14)', color: '#7c3aed' }, // purple
|
|
3280
|
+
{ bg: 'rgba(14, 165, 133, 0.14)', color: '#0d7d65' }, // teal
|
|
3281
|
+
{ bg: 'rgba(220, 80, 30, 0.14)', color: '#c04a1a' }, // red-orange
|
|
3282
|
+
{ bg: 'rgba(202, 138, 4, 0.14)', color: '#92700c' }, // amber
|
|
3283
|
+
{ bg: 'rgba(219, 39, 119, 0.14)', color: '#b5246a' }, // pink
|
|
3284
|
+
{ bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' }, // green
|
|
3285
|
+
{ bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' }, // indigo
|
|
3286
|
+
];
|
|
3287
|
+
const ownerColorCache = {};
|
|
3288
|
+
function isInternalTask(task) {
|
|
3289
|
+
return task.metadata && task.metadata._internal === true;
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
function getOwnerColor(name) {
|
|
3293
|
+
if (ownerColorCache[name]) return ownerColorCache[name];
|
|
3294
|
+
let hash = 5381;
|
|
3295
|
+
for (let i = 0; i < name.length; i++) {
|
|
3296
|
+
hash = ((hash * 33) ^ name.charCodeAt(i)) | 0;
|
|
3297
|
+
}
|
|
3298
|
+
const c = ownerColors[Math.abs(hash) % ownerColors.length];
|
|
3299
|
+
ownerColorCache[name] = c;
|
|
3300
|
+
return c;
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
//#endregion
|
|
3304
|
+
|
|
3305
|
+
//#region FILTERS
|
|
3306
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3307
|
+
function filterBySessions(value) {
|
|
3308
|
+
sessionFilter = value;
|
|
3309
|
+
updateUrl();
|
|
3310
|
+
renderSessions();
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3314
|
+
function changeSessionLimit(value) {
|
|
3315
|
+
sessionLimit = value;
|
|
3316
|
+
updateUrl();
|
|
3317
|
+
fetchSessions();
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
function matchesProjectFilter(project) {
|
|
3321
|
+
if (!filterProject) return true;
|
|
3322
|
+
if (filterProject === '__recent__') return recentProjects.has(project);
|
|
3323
|
+
return project === filterProject;
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
//#endregion
|
|
3327
|
+
|
|
3328
|
+
//#region EVENT_DELEGATION
|
|
3329
|
+
document.addEventListener('click', (e) => {
|
|
3330
|
+
const pathToggle = e.target.closest('[data-group-action="toggle-path"]');
|
|
3331
|
+
if (pathToggle) {
|
|
3332
|
+
e.stopPropagation();
|
|
3333
|
+
const header = pathToggle.closest('.project-group-header');
|
|
3334
|
+
let el = header?.nextElementSibling;
|
|
3335
|
+
while (el && !el.classList.contains('project-group-breadcrumb')) el = el.nextElementSibling;
|
|
3336
|
+
if (el) el.classList.toggle('expanded');
|
|
3337
|
+
return;
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
const breadcrumb = e.target.closest('.project-group-breadcrumb');
|
|
3341
|
+
if (breadcrumb) {
|
|
3342
|
+
e.stopPropagation();
|
|
3343
|
+
const path = breadcrumb.dataset.fullPath;
|
|
3344
|
+
if (path) navigator.clipboard.writeText(path).catch(() => {});
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
const header = e.target.closest('.project-group-header');
|
|
3349
|
+
if (header) {
|
|
3350
|
+
setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
|
|
3351
|
+
}
|
|
3352
|
+
});
|
|
3353
|
+
|
|
3354
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3355
|
+
function filterByProject(project) {
|
|
3356
|
+
filterProject = project || null;
|
|
3357
|
+
updateUrl();
|
|
3358
|
+
renderSessions();
|
|
3359
|
+
showAllTasks();
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
let projectsCache = null;
|
|
3363
|
+
|
|
3364
|
+
async function updateProjectDropdown() {
|
|
3365
|
+
const dropdown = document.getElementById('project-filter');
|
|
3366
|
+
|
|
3367
|
+
if (!projectsCacheDirty && projectsCache) {
|
|
3368
|
+
renderProjectDropdown(dropdown, projectsCache);
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
let projects;
|
|
3373
|
+
try {
|
|
3374
|
+
const res = await fetch('/api/projects');
|
|
3375
|
+
projects = await res.json();
|
|
3376
|
+
} catch (_e) {
|
|
3377
|
+
projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))]
|
|
3378
|
+
.sort()
|
|
3379
|
+
.map((p) => ({ path: p, modifiedAt: null }));
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
projectsCache = projects;
|
|
3383
|
+
projectsCacheDirty = false;
|
|
3384
|
+
|
|
3385
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
3386
|
+
recentProjects = new Set(
|
|
3387
|
+
projects.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map((p) => p.path),
|
|
3388
|
+
);
|
|
3389
|
+
|
|
3390
|
+
renderProjectDropdown(dropdown, projects);
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
function renderProjectDropdown(dropdown, projects) {
|
|
3394
|
+
const recentSelected = filterProject === '__recent__' ? ' selected' : '';
|
|
3395
|
+
dropdown.innerHTML =
|
|
3396
|
+
'<option value="">All Projects</option>' +
|
|
3397
|
+
`<option value="__recent__"${recentSelected}>Recent (24h)</option>` +
|
|
3398
|
+
projects
|
|
3399
|
+
.map((p) => {
|
|
3400
|
+
const name = p.path.split(/[/\\]/).pop();
|
|
3401
|
+
const selected = p.path === filterProject ? ' selected' : '';
|
|
3402
|
+
return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
|
|
3403
|
+
})
|
|
3404
|
+
.join('');
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
function updateThemeColor(isLight) {
|
|
3408
|
+
document.querySelectorAll('meta[name="theme-color"]').forEach((m) => {
|
|
3409
|
+
m.setAttribute('content', isLight ? '#e8e6e3' : '#101114');
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
//#endregion
|
|
3414
|
+
|
|
3415
|
+
//#region THEME
|
|
3416
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3417
|
+
function toggleTheme() {
|
|
3418
|
+
const isCurrentlyLight = document.body.classList.contains('light');
|
|
3419
|
+
if (isCurrentlyLight) {
|
|
3420
|
+
document.body.classList.remove('light');
|
|
3421
|
+
document.body.classList.add('dark-forced');
|
|
3422
|
+
localStorage.setItem('theme', 'dark');
|
|
3423
|
+
} else {
|
|
3424
|
+
document.body.classList.add('light');
|
|
3425
|
+
document.body.classList.remove('dark-forced');
|
|
3426
|
+
localStorage.setItem('theme', 'light');
|
|
3427
|
+
}
|
|
3428
|
+
updateThemeIcon();
|
|
3429
|
+
updateThemeColor(!isCurrentlyLight);
|
|
3430
|
+
syncHljsTheme();
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
function syncHljsTheme() {
|
|
3434
|
+
const isLight = document.body.classList.contains('light');
|
|
3435
|
+
const dark = document.getElementById('hljs-theme-dark');
|
|
3436
|
+
const light = document.getElementById('hljs-theme-light');
|
|
3437
|
+
if (dark) dark.disabled = isLight;
|
|
3438
|
+
if (light) light.disabled = !isLight;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
function updateThemeIcon() {
|
|
3442
|
+
const saved = localStorage.getItem('theme');
|
|
3443
|
+
const isLight =
|
|
3444
|
+
document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches);
|
|
3445
|
+
document.getElementById('theme-icon-dark').style.display = isLight ? 'none' : 'block';
|
|
3446
|
+
document.getElementById('theme-icon-light').style.display = isLight ? 'block' : 'none';
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
function loadTheme() {
|
|
3450
|
+
const saved = localStorage.getItem('theme');
|
|
3451
|
+
if (saved === 'light') {
|
|
3452
|
+
document.body.classList.add('light');
|
|
3453
|
+
document.body.classList.remove('dark-forced');
|
|
3454
|
+
} else if (saved === 'dark') {
|
|
3455
|
+
document.body.classList.remove('light');
|
|
3456
|
+
document.body.classList.add('dark-forced');
|
|
3457
|
+
}
|
|
3458
|
+
// If no saved preference, system prefers-color-scheme CSS handles it
|
|
3459
|
+
updateThemeIcon();
|
|
3460
|
+
updateThemeColor(document.body.classList.contains('light'));
|
|
3461
|
+
syncHljsTheme();
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
//#endregion
|
|
3465
|
+
|
|
3466
|
+
//#region SIDEBAR_LAYOUT
|
|
3467
|
+
function toggleSidebar() {
|
|
3468
|
+
const sidebar = document.querySelector('.sidebar');
|
|
3469
|
+
const collapsed = sidebar.classList.toggle('collapsed');
|
|
3470
|
+
localStorage.setItem('sidebar-collapsed', collapsed);
|
|
3471
|
+
if (collapsed) {
|
|
3472
|
+
sidebar.style.width = '';
|
|
3473
|
+
if (focusZone === 'sidebar') setFocusZone('board');
|
|
3474
|
+
} else {
|
|
3475
|
+
const w = getComputedStyle(sidebar).getPropertyValue('--sidebar-width');
|
|
3476
|
+
if (w) sidebar.style.width = w;
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
function loadSidebarState() {
|
|
3481
|
+
const sidebar = document.querySelector('.sidebar');
|
|
3482
|
+
if (localStorage.getItem('sidebar-collapsed') === 'true') {
|
|
3483
|
+
sidebar.classList.add('collapsed');
|
|
3484
|
+
}
|
|
3485
|
+
const w = localStorage.getItem('sidebar-width');
|
|
3486
|
+
if (w) {
|
|
3487
|
+
sidebar.style.setProperty('--sidebar-width', w);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
function initSidebarResize() {
|
|
3492
|
+
const sidebar = document.querySelector('.sidebar');
|
|
3493
|
+
const handle = document.getElementById('sidebar-resize');
|
|
3494
|
+
let startX, startWidth;
|
|
3495
|
+
|
|
3496
|
+
handle.addEventListener('mousedown', (e) => {
|
|
3497
|
+
if (sidebar.classList.contains('collapsed')) return;
|
|
3498
|
+
startX = e.clientX;
|
|
3499
|
+
startWidth = sidebar.offsetWidth;
|
|
3500
|
+
sidebar.classList.add('resizing');
|
|
3501
|
+
handle.classList.add('dragging');
|
|
3502
|
+
document.body.style.userSelect = 'none';
|
|
3503
|
+
document.addEventListener('mousemove', onMove);
|
|
3504
|
+
document.addEventListener('mouseup', onUp);
|
|
3505
|
+
e.preventDefault();
|
|
3506
|
+
});
|
|
3507
|
+
|
|
3508
|
+
function onMove(e) {
|
|
3509
|
+
const w = Math.min(600, Math.max(200, startWidth + e.clientX - startX));
|
|
3510
|
+
sidebar.style.setProperty('--sidebar-width', `${w}px`);
|
|
3511
|
+
sidebar.style.width = `${w}px`;
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
function onUp() {
|
|
3515
|
+
sidebar.classList.remove('resizing');
|
|
3516
|
+
handle.classList.remove('dragging');
|
|
3517
|
+
document.body.style.userSelect = '';
|
|
3518
|
+
document.removeEventListener('mousemove', onMove);
|
|
3519
|
+
document.removeEventListener('mouseup', onUp);
|
|
3520
|
+
localStorage.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
3525
|
+
const panel = document.getElementById(panelId);
|
|
3526
|
+
const handle = document.getElementById(handleId);
|
|
3527
|
+
let startX, startWidth;
|
|
3528
|
+
|
|
3529
|
+
handle.addEventListener('mousedown', (e) => {
|
|
3530
|
+
startX = e.clientX;
|
|
3531
|
+
startWidth = panel.offsetWidth;
|
|
3532
|
+
panel.classList.add('resizing');
|
|
3533
|
+
handle.classList.add('dragging');
|
|
3534
|
+
document.body.style.userSelect = 'none';
|
|
3535
|
+
document.addEventListener('mousemove', onMove);
|
|
3536
|
+
document.addEventListener('mouseup', onUp);
|
|
3537
|
+
e.preventDefault();
|
|
3538
|
+
});
|
|
3539
|
+
|
|
3540
|
+
function onMove(e) {
|
|
3541
|
+
const w = Math.min(900, Math.max(320, startWidth - (e.clientX - startX)));
|
|
3542
|
+
panel.style.setProperty(cssVar, `${w}px`);
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
function onUp() {
|
|
3546
|
+
panel.classList.remove('resizing');
|
|
3547
|
+
handle.classList.remove('dragging');
|
|
3548
|
+
document.body.style.userSelect = '';
|
|
3549
|
+
document.removeEventListener('mousemove', onMove);
|
|
3550
|
+
document.removeEventListener('mouseup', onUp);
|
|
3551
|
+
localStorage.setItem(storageKey, panel.style.getPropertyValue(cssVar));
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
function loadPanelWidths() {
|
|
3556
|
+
[
|
|
3557
|
+
['detail-panel', '--detail-panel-width'],
|
|
3558
|
+
['message-panel', '--message-panel-width'],
|
|
3559
|
+
].forEach(([id, cssVar]) => {
|
|
3560
|
+
const w = localStorage.getItem(`${id}-width`);
|
|
3561
|
+
if (w) document.getElementById(id).style.setProperty(cssVar, w);
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
//#endregion
|
|
3566
|
+
|
|
3567
|
+
//#region PREFERENCES
|
|
3568
|
+
function loadPreferences() {
|
|
3569
|
+
document.getElementById('session-filter').value = sessionFilter;
|
|
3570
|
+
document.getElementById('session-limit').value = sessionLimit;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
//#endregion
|
|
3574
|
+
|
|
3575
|
+
//#region SESSION_INFO
|
|
3576
|
+
async function showSessionInfoModal(sessionId) {
|
|
3577
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
3578
|
+
if (!session) return;
|
|
3579
|
+
|
|
3580
|
+
const promises = [];
|
|
3581
|
+
|
|
3582
|
+
// Fetch team config
|
|
3583
|
+
let teamConfig = null;
|
|
3584
|
+
if (session.isTeam) {
|
|
3585
|
+
promises.push(
|
|
3586
|
+
fetch(`/api/teams/${sessionId}`)
|
|
3587
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
3588
|
+
.catch(() => null)
|
|
3589
|
+
.then((data) => {
|
|
3590
|
+
teamConfig = data;
|
|
3591
|
+
}),
|
|
3592
|
+
);
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
// Fetch plan
|
|
3596
|
+
let planContent = null;
|
|
3597
|
+
promises.push(
|
|
3598
|
+
fetch(`/api/sessions/${sessionId}/plan`)
|
|
3599
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
3600
|
+
.catch(() => null)
|
|
3601
|
+
.then((data) => {
|
|
3602
|
+
planContent = data?.content || null;
|
|
3603
|
+
}),
|
|
3604
|
+
);
|
|
3605
|
+
|
|
3606
|
+
await Promise.all(promises);
|
|
3607
|
+
|
|
3608
|
+
let tasks = currentSessionId === sessionId ? currentTasks : [];
|
|
3609
|
+
if (tasks.length === 0) {
|
|
3610
|
+
try {
|
|
3611
|
+
const r = await fetch(`/api/sessions/${sessionId}`);
|
|
3612
|
+
if (r.ok) tasks = await r.json();
|
|
3613
|
+
} catch {}
|
|
3614
|
+
}
|
|
3615
|
+
_planSessionId = sessionId;
|
|
3616
|
+
showInfoModal(session, teamConfig, tasks, planContent);
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
let _pendingPlanContent = null;
|
|
3620
|
+
|
|
3621
|
+
function showInfoModal(session, teamConfig, tasks, planContent) {
|
|
3622
|
+
const modal = document.getElementById('team-modal');
|
|
3623
|
+
const titleEl = document.getElementById('team-modal-title');
|
|
3624
|
+
const bodyEl = document.getElementById('team-modal-body');
|
|
3625
|
+
|
|
3626
|
+
const titleText = teamConfig
|
|
3627
|
+
? `Team: ${teamConfig.team_name || teamConfig.name || 'Unknown'}`
|
|
3628
|
+
: session.name || session.slug || session.id;
|
|
3629
|
+
titleEl.innerHTML =
|
|
3630
|
+
escapeHtml(titleText) +
|
|
3631
|
+
(session.modifiedAt
|
|
3632
|
+
? `<div style="font-size: 12px; font-weight: 400; color: var(--text-tertiary); margin-top: 2px;">${formatDate(session.modifiedAt)} (${new Date(session.modifiedAt).toLocaleString()})</div>`
|
|
3633
|
+
: '');
|
|
3634
|
+
|
|
3635
|
+
let html = '';
|
|
3636
|
+
|
|
3637
|
+
// Session & project details as compact key-value rows
|
|
3638
|
+
// Each row: [label, displayValue, { openPath?, copyValue? }]
|
|
3639
|
+
const infoRows = [];
|
|
3640
|
+
infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
|
|
3641
|
+
if (session.slug && session.hasPlan) {
|
|
3642
|
+
infoRows.push(['Slug', session.slug, { openClaudeDir: true, openFile: session.planPath }]);
|
|
3643
|
+
}
|
|
3644
|
+
if (session.project) {
|
|
3645
|
+
const projectName = session.project.split(/[/\\]/).pop();
|
|
3646
|
+
infoRows.push(['Project', projectName, { openPath: session.projectDir }]);
|
|
3647
|
+
infoRows.push(['Path', session.project, { openPath: session.project }]);
|
|
3648
|
+
if (session.gitBranch) {
|
|
3649
|
+
infoRows.push(['Branch', session.gitBranch]);
|
|
3650
|
+
}
|
|
3651
|
+
if (session.description) {
|
|
3652
|
+
infoRows.push(['Description', session.description]);
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
if (session.tasksDir) {
|
|
3656
|
+
infoRows.push(['Tasks Dir', session.tasksDir, { openPath: session.tasksDir }]);
|
|
3657
|
+
}
|
|
3658
|
+
const clickableStyle =
|
|
3659
|
+
"font-family: 'IBM Plex Mono', monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; color: var(--accent-text); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px;";
|
|
3660
|
+
const plainStyle =
|
|
3661
|
+
"font-family: 'IBM Plex Mono', monospace; font-size: 12px; user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
|
|
3662
|
+
html += `<div class="team-modal-meta" style="margin-bottom: 16px; display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: center;">`;
|
|
3663
|
+
infoRows.forEach(([label, value, opts]) => {
|
|
3664
|
+
const copyVal = escapeHtml(value).replace(/"/g, '"');
|
|
3665
|
+
html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span>`;
|
|
3666
|
+
if (opts?.openClaudeDir || opts?.openPath) {
|
|
3667
|
+
const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '"');
|
|
3668
|
+
const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '"') : '';
|
|
3669
|
+
html += `<span data-folder="${folder}" data-file="${file}" data-claude-dir="${opts.openClaudeDir ? '1' : ''}" onclick="openFolderInEditor(this.dataset.claudeDir ? undefined : this.dataset.folder, this.dataset.file || undefined)" style="${clickableStyle}" title="Open in editor">${escapeHtml(value)}</span>`;
|
|
3670
|
+
} else {
|
|
3671
|
+
html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
|
|
3672
|
+
}
|
|
3673
|
+
const jsCopyVal = copyVal.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
3674
|
+
html += `<button onclick="navigator.clipboard.writeText('${jsCopyVal}'); this.textContent='✓'; setTimeout(() => this.textContent='Copy', 1000)" style="padding: 2px 8px; font-size: 11px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; color: var(--text-secondary); cursor: pointer; white-space: nowrap;">Copy</button>`;
|
|
3675
|
+
});
|
|
3676
|
+
html += `</div>`;
|
|
3677
|
+
|
|
3678
|
+
if (session.contextStatus) {
|
|
3679
|
+
html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
|
|
3680
|
+
html += renderContextDetail(session.contextStatus);
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
if (planContent) {
|
|
3684
|
+
_pendingPlanContent = planContent;
|
|
3685
|
+
const titleMatch = planContent.match(/^#\s+(.+)$/m);
|
|
3686
|
+
const planTitle = titleMatch ? titleMatch[1].trim() : null;
|
|
3687
|
+
html += `<div onclick="openPlanModal()" style="margin-bottom: 16px; padding: 10px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s ease;" onmouseover="this.style.borderColor='var(--accent)';this.style.background='var(--bg-hover)'" onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg-elevated)'">
|
|
3688
|
+
<span style="font-size: 14px;">📋</span>
|
|
3689
|
+
<div style="flex: 1; min-width: 0;">
|
|
3690
|
+
<div style="font-size: 11px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Plan</div>
|
|
3691
|
+
${planTitle ? `<div style="font-size: 13px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(planTitle)}</div>` : ''}
|
|
3692
|
+
</div>
|
|
3693
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" style="width: 16px; height: 16px; flex-shrink: 0;"><path d="M9 18l6-6-6-6"/></svg>
|
|
3694
|
+
</div>`;
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
// Team info section
|
|
3698
|
+
if (teamConfig) {
|
|
3699
|
+
const ownerCounts = {};
|
|
3700
|
+
const memberDescriptions = {};
|
|
3701
|
+
tasks.forEach((t) => {
|
|
3702
|
+
if (isInternalTask(t) && t.subject) {
|
|
3703
|
+
memberDescriptions[t.subject] = t.description;
|
|
3704
|
+
} else if (t.owner) {
|
|
3705
|
+
ownerCounts[t.owner] = (ownerCounts[t.owner] || 0) + 1;
|
|
3706
|
+
}
|
|
3707
|
+
});
|
|
3708
|
+
|
|
3709
|
+
const members = teamConfig.members || [];
|
|
3710
|
+
const description = teamConfig.description || '';
|
|
3711
|
+
const lead = members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead');
|
|
3712
|
+
|
|
3713
|
+
if (description) {
|
|
3714
|
+
html += `<div class="team-modal-desc">"${escapeHtml(description)}"</div>`;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
html += `<div style="font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 10px;">Members (${members.length})</div>`;
|
|
3718
|
+
|
|
3719
|
+
members.forEach((member) => {
|
|
3720
|
+
const taskCount = ownerCounts[member.name] || 0;
|
|
3721
|
+
const memberDesc = memberDescriptions[member.name];
|
|
3722
|
+
html += `
|
|
3723
|
+
<div class="team-member-card">
|
|
3724
|
+
<div class="member-name">🟢 ${escapeHtml(member.name)}</div>
|
|
3725
|
+
<div class="member-detail">Role: ${escapeHtml(member.agentType || 'unknown')}</div>
|
|
3726
|
+
${member.model ? `<div class="member-detail">Model: ${escapeHtml(member.model)}</div>` : ''}
|
|
3727
|
+
${memberDesc ? `<div class="member-detail" style="margin-top: 4px; font-style: italic; color: var(--text-secondary);">${escapeHtml(memberDesc.split('\n')[0])}</div>` : ''}
|
|
3728
|
+
<div class="member-tasks">Tasks: ${taskCount} assigned</div>
|
|
3729
|
+
</div>
|
|
3730
|
+
`;
|
|
3731
|
+
});
|
|
3732
|
+
|
|
3733
|
+
const metaParts = [];
|
|
3734
|
+
if (teamConfig.created_at) {
|
|
3735
|
+
metaParts.push(`Created: ${new Date(teamConfig.created_at).toLocaleString()}`);
|
|
3736
|
+
}
|
|
3737
|
+
if (lead) {
|
|
3738
|
+
metaParts.push(`Lead: ${lead.name}`);
|
|
3739
|
+
}
|
|
3740
|
+
if (teamConfig.working_dir) {
|
|
3741
|
+
metaParts.push(`Working dir: ${teamConfig.working_dir}`);
|
|
3742
|
+
}
|
|
3743
|
+
if (metaParts.length > 0) {
|
|
3744
|
+
html += `<div class="team-modal-meta">${metaParts.map((p) => escapeHtml(p)).join('<br>')}</div>`;
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
bodyEl.innerHTML = html;
|
|
3749
|
+
modal.classList.add('visible');
|
|
3750
|
+
|
|
3751
|
+
const keyHandler = (e) => {
|
|
3752
|
+
if (e.key === 'Escape') {
|
|
3753
|
+
if (document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
3754
|
+
e.preventDefault();
|
|
3755
|
+
closeTeamModal();
|
|
3756
|
+
document.removeEventListener('keydown', keyHandler);
|
|
3757
|
+
}
|
|
3758
|
+
};
|
|
3759
|
+
document.addEventListener('keydown', keyHandler);
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
function closeTeamModal() {
|
|
3763
|
+
document.getElementById('team-modal').classList.remove('visible');
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
let _planSessionId = null;
|
|
3767
|
+
|
|
3768
|
+
//#endregion
|
|
3769
|
+
|
|
3770
|
+
//#region PLAN
|
|
3771
|
+
function refreshOpenPlan() {
|
|
3772
|
+
if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
3773
|
+
fetch(`/api/sessions/${_planSessionId}/plan`)
|
|
3774
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
3775
|
+
.then((data) => {
|
|
3776
|
+
if (data?.content) {
|
|
3777
|
+
_pendingPlanContent = data.content;
|
|
3778
|
+
const body = document.getElementById('plan-modal-body');
|
|
3779
|
+
body.innerHTML = renderMarkdown(_pendingPlanContent);
|
|
3780
|
+
}
|
|
3781
|
+
})
|
|
3782
|
+
.catch(() => {});
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
function openPlanForSession(sid) {
|
|
3786
|
+
fetch(`/api/sessions/${sid}/plan`)
|
|
3787
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
3788
|
+
.catch(() => null)
|
|
3789
|
+
.then((data) => {
|
|
3790
|
+
if (data?.content) {
|
|
3791
|
+
_pendingPlanContent = data.content;
|
|
3792
|
+
_planSessionId = sid;
|
|
3793
|
+
openPlanModal();
|
|
3794
|
+
}
|
|
3795
|
+
});
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
function openPlanModal() {
|
|
3799
|
+
if (!_pendingPlanContent) return;
|
|
3800
|
+
const body = document.getElementById('plan-modal-body');
|
|
3801
|
+
body.innerHTML = renderMarkdown(_pendingPlanContent);
|
|
3802
|
+
document.getElementById('plan-modal').classList.add('visible');
|
|
3803
|
+
const keyHandler = (e) => {
|
|
3804
|
+
if (e.key === 'Escape') {
|
|
3805
|
+
e.preventDefault();
|
|
3806
|
+
e.stopPropagation();
|
|
3807
|
+
closePlanModal();
|
|
3808
|
+
document.removeEventListener('keydown', keyHandler, true);
|
|
3809
|
+
}
|
|
3810
|
+
};
|
|
3811
|
+
document.addEventListener('keydown', keyHandler, true);
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
function closePlanModal() {
|
|
3815
|
+
resetModalFullscreen('plan-modal');
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3819
|
+
function openPlanInEditor() {
|
|
3820
|
+
if (!_planSessionId) return;
|
|
3821
|
+
postAndToast(`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3825
|
+
function openFolderInEditor(folder, file) {
|
|
3826
|
+
const body = {};
|
|
3827
|
+
if (folder) body.folder = folder;
|
|
3828
|
+
if (file) body.file = file;
|
|
3829
|
+
postAndToast('/api/open-folder', body, 'folder');
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
//#endregion
|
|
3833
|
+
|
|
3834
|
+
//#region OWNER_FILTER
|
|
3835
|
+
function updateOwnerFilter() {
|
|
3836
|
+
const bar = document.getElementById('owner-filter-bar');
|
|
3837
|
+
const select = document.getElementById('owner-filter');
|
|
3838
|
+
|
|
3839
|
+
const session = sessions.find((s) => s.id === currentSessionId);
|
|
3840
|
+
if (!session || !session.isTeam) {
|
|
3841
|
+
bar.classList.remove('visible');
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
bar.classList.add('visible');
|
|
3846
|
+
const owners = [
|
|
3847
|
+
...new Set(
|
|
3848
|
+
currentTasks
|
|
3849
|
+
.filter((t) => !isInternalTask(t))
|
|
3850
|
+
.map((t) => t.owner)
|
|
3851
|
+
.filter(Boolean),
|
|
3852
|
+
),
|
|
3853
|
+
].sort();
|
|
3854
|
+
select.innerHTML =
|
|
3855
|
+
'<option value="">All Members</option>' +
|
|
3856
|
+
owners
|
|
3857
|
+
.map((o) => {
|
|
3858
|
+
const c = getOwnerColor(o);
|
|
3859
|
+
return `<option value="${escapeHtml(o)}" style="color:${c.color};background:${c.bg}"${o === ownerFilter ? ' selected' : ''}>${escapeHtml(o)}</option>`;
|
|
3860
|
+
})
|
|
3861
|
+
.join('');
|
|
3862
|
+
const current = ownerFilter ? getOwnerColor(ownerFilter) : null;
|
|
3863
|
+
select.style.color = current ? current.color : '';
|
|
3864
|
+
select.style.backgroundColor = current ? current.bg : '';
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3868
|
+
function filterByOwner(value) {
|
|
3869
|
+
ownerFilter = value;
|
|
3870
|
+
const select = document.getElementById('owner-filter');
|
|
3871
|
+
const c = value ? getOwnerColor(value) : null;
|
|
3872
|
+
select.style.color = c ? c.color : '';
|
|
3873
|
+
select.style.backgroundColor = c ? c.bg : '';
|
|
3874
|
+
updateUrl();
|
|
3875
|
+
renderKanban();
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
//#endregion
|
|
3879
|
+
|
|
3880
|
+
//#region LAYOUT_SYNC
|
|
3881
|
+
const sidebarHeader = document.querySelector('.sidebar-header');
|
|
3882
|
+
const viewHeader = document.querySelector('.view-header');
|
|
3883
|
+
new ResizeObserver(() => {
|
|
3884
|
+
sidebarHeader.style.height = `${viewHeader.offsetHeight}px`;
|
|
3885
|
+
}).observe(viewHeader);
|
|
3886
|
+
|
|
3887
|
+
//#endregion
|
|
3888
|
+
|
|
3889
|
+
//#region PWA
|
|
3890
|
+
if ('serviceWorker' in navigator) {
|
|
3891
|
+
navigator.serviceWorker.register('/sw.js');
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
//#endregion
|
|
3895
|
+
|
|
3896
|
+
//#region INIT
|
|
3897
|
+
loadTheme();
|
|
3898
|
+
['live-updates', 'sessions-filters'].forEach((id) => {
|
|
3899
|
+
if (localStorage.getItem(`${id}Collapsed`) === 'true') {
|
|
3900
|
+
document.getElementById(id).classList.add('collapsed');
|
|
3901
|
+
document
|
|
3902
|
+
.getElementById(id === 'live-updates' ? 'live-updates-chevron' : 'sessions-chevron')
|
|
3903
|
+
.classList.add('rotated');
|
|
3904
|
+
}
|
|
3905
|
+
});
|
|
3906
|
+
|
|
3907
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
3908
|
+
if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
|
|
3909
|
+
const renderer = new marked.Renderer();
|
|
3910
|
+
renderer.code = ({ text, lang }) => {
|
|
3911
|
+
let highlighted;
|
|
3912
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
3913
|
+
highlighted = hljs.highlight(text, { language: lang }).value;
|
|
3914
|
+
} else {
|
|
3915
|
+
highlighted = hljs.highlightAuto(text).value;
|
|
3916
|
+
}
|
|
3917
|
+
return `<pre><code class="hljs language-${escapeHtml(lang || '')}">${highlighted}</code></pre>`;
|
|
3918
|
+
};
|
|
3919
|
+
marked.use({ renderer });
|
|
3920
|
+
}
|
|
3921
|
+
});
|
|
3922
|
+
|
|
3923
|
+
loadSidebarState();
|
|
3924
|
+
try {
|
|
3925
|
+
const cg = JSON.parse(localStorage.getItem('collapsedGroups') || '[]');
|
|
3926
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
3927
|
+
cg.forEach((p) => collapsedProjectGroups.add(p));
|
|
3928
|
+
} catch (_) {}
|
|
3929
|
+
initSidebarResize();
|
|
3930
|
+
loadPanelWidths();
|
|
3931
|
+
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
|
|
3932
|
+
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
|
|
3933
|
+
fetch('/api/version')
|
|
3934
|
+
.then((r) => r.json())
|
|
3935
|
+
.then((d) => {
|
|
3936
|
+
document.getElementById('sidebar-footer').textContent = `v${d.version}`;
|
|
3937
|
+
})
|
|
3938
|
+
.catch(() => {});
|
|
3939
|
+
|
|
3940
|
+
const urlState = getUrlState();
|
|
3941
|
+
sessionFilter = urlState.filter || 'active';
|
|
3942
|
+
sessionLimit = urlState.limit || '20';
|
|
3943
|
+
filterProject = urlState.project || '__recent__';
|
|
3944
|
+
ownerFilter = urlState.owner || '';
|
|
3945
|
+
searchQuery = urlState.search || '';
|
|
3946
|
+
|
|
3947
|
+
loadPreferences();
|
|
3948
|
+
pinnedSessionIds = loadPinnedSessions();
|
|
3949
|
+
setupEventSource();
|
|
3950
|
+
|
|
3951
|
+
if (urlState.search) {
|
|
3952
|
+
document.getElementById('search-input').value = urlState.search;
|
|
3953
|
+
document.getElementById('search-clear-btn').classList.add('visible');
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
fetchSessions().then(async () => {
|
|
3957
|
+
if (urlState.session) {
|
|
3958
|
+
await fetchTasks(urlState.session);
|
|
3959
|
+
} else {
|
|
3960
|
+
showAllTasks();
|
|
3961
|
+
}
|
|
3962
|
+
if (urlState.messages && currentSessionId) {
|
|
3963
|
+
toggleMessagePanel();
|
|
3964
|
+
}
|
|
3965
|
+
});
|
|
3966
|
+
|
|
3967
|
+
window.addEventListener('popstate', () => {
|
|
3968
|
+
const s = getUrlState();
|
|
3969
|
+
sessionFilter = s.filter || 'active';
|
|
3970
|
+
sessionLimit = s.limit || '20';
|
|
3971
|
+
filterProject = s.project || '__recent__';
|
|
3972
|
+
ownerFilter = s.owner || '';
|
|
3973
|
+
searchQuery = s.search || '';
|
|
3974
|
+
loadPreferences();
|
|
3975
|
+
if (s.session) fetchTasks(s.session);
|
|
3976
|
+
else showAllTasks();
|
|
3977
|
+
if (s.messages !== messagePanelOpen) toggleMessagePanel();
|
|
3978
|
+
});
|
|
3979
|
+
//#endregion
|