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/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">&larr;</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(/(&#x27;[\s\S]*?&#x27;|&quot;[\s\S]*?&quot;)/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)(?:&amp;&amp;|\|\||[|;])(?:\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 ? '&#x25B4;' : '&#x25BE;';
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 ? '&#x25B4;' : '&#x25BE;';
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, '&quot;');
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, '&quot;');
3668
+ const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
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