ai-agent-session-center 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. package/server/wsManager.js +83 -0
@@ -0,0 +1,2477 @@
1
+ import * as soundManager from './soundManager.js';
2
+ import * as settingsManager from './settingsManager.js';
3
+ import * as db from './browserDb.js';
4
+
5
+ // Load muted sessions from localStorage
6
+ function loadMuted() {
7
+ try {
8
+ const saved = JSON.parse(localStorage.getItem('muted-sessions') || '[]');
9
+ return new Set(saved);
10
+ } catch { return new Set(); }
11
+ }
12
+ function saveMuted(muted) {
13
+ localStorage.setItem('muted-sessions', JSON.stringify([...muted]));
14
+ }
15
+
16
+ const mutedSessions = loadMuted();
17
+ let globalMuted = false;
18
+ export function isMuted(sessionId) { return globalMuted || mutedSessions.has(sessionId); }
19
+
20
+ export function toggleMuteAll() {
21
+ globalMuted = !globalMuted;
22
+ // Update all per-session mute buttons to reflect global state
23
+ document.querySelectorAll('.session-card .mute-btn').forEach(btn => {
24
+ if (globalMuted) {
25
+ btn.classList.add('muted');
26
+ btn.innerHTML = 'M';
27
+ } else {
28
+ // Restore per-session state
29
+ const card = btn.closest('.session-card');
30
+ const sid = card?.dataset?.sessionId;
31
+ if (sid && mutedSessions.has(sid)) {
32
+ btn.classList.add('muted');
33
+ btn.innerHTML = 'M';
34
+ } else {
35
+ btn.classList.remove('muted');
36
+ btn.innerHTML = '♫';
37
+ }
38
+ }
39
+ });
40
+ return globalMuted;
41
+ }
42
+
43
+ function clearAllDropIndicators() {
44
+ document.querySelectorAll('.session-card.drag-over-left, .session-card.drag-over-right').forEach(c => {
45
+ c.classList.remove('drag-over-left', 'drag-over-right');
46
+ });
47
+ document.querySelectorAll('.group-grid.drag-over').forEach(g => g.classList.remove('drag-over'));
48
+ document.getElementById('sessions-grid')?.classList.remove('drag-over');
49
+ }
50
+
51
+ // ---- Pinned Sessions (persisted in localStorage) ----
52
+ function loadPinned() {
53
+ try { return new Set(JSON.parse(localStorage.getItem('pinned-sessions') || '[]')); } catch { return new Set(); }
54
+ }
55
+ function savePinned(pinned) {
56
+ localStorage.setItem('pinned-sessions', JSON.stringify([...pinned]));
57
+ }
58
+ const pinnedSessions = loadPinned();
59
+
60
+ function reorderPinnedCards() {
61
+ // Move pinned cards to the front of their parent grid
62
+ for (const grid of [document.getElementById('sessions-grid'), ...document.querySelectorAll('.group-grid')]) {
63
+ if (!grid) continue;
64
+ const cards = [...grid.querySelectorAll('.session-card')];
65
+ const pinned = cards.filter(c => c.classList.contains('pinned'));
66
+ for (const card of pinned.reverse()) {
67
+ grid.insertBefore(card, grid.firstElementChild);
68
+ }
69
+ }
70
+ }
71
+
72
+ const sessionsData = new Map(); // sessionId -> session object (for duration updates + detail panel)
73
+ const teamsData = new Map(); // teamId -> team object (for team cards)
74
+ let selectedSessionId = null;
75
+ export function getSelectedSessionId() { return selectedSessionId; }
76
+ export function setSelectedSessionId(id) { selectedSessionId = id; }
77
+ export function getSessionsData() { return sessionsData; }
78
+ export function getTeamsData() { return teamsData; }
79
+ export { deselectSession };
80
+
81
+ export function pinSession(sessionId) {
82
+ if (pinnedSessions.has(sessionId)) return;
83
+ pinnedSessions.add(sessionId);
84
+ savePinned(pinnedSessions);
85
+ const card = document.querySelector(`.session-card[data-session-id="${sessionId}"]`);
86
+ if (card) {
87
+ card.classList.add('pinned');
88
+ const pinBtn = card.querySelector('.pin-btn');
89
+ if (pinBtn) { pinBtn.classList.add('active'); pinBtn.title = 'Unpin'; }
90
+ }
91
+ reorderPinnedCards();
92
+ }
93
+
94
+ // ---- Session Groups (persisted in localStorage) ----
95
+ // Structure: [{ id, name, sessionIds: [] }, ...]
96
+ function loadGroups() {
97
+ try { return JSON.parse(localStorage.getItem('session-groups') || '[]'); } catch { return []; }
98
+ }
99
+ function saveGroups(groups) {
100
+ localStorage.setItem('session-groups', JSON.stringify(groups));
101
+ }
102
+ function findGroupForSession(sessionId) {
103
+ return loadGroups().find(g => g.sessionIds.includes(sessionId));
104
+ }
105
+
106
+ export function createGroup(name) {
107
+ const groups = loadGroups();
108
+ const id = 'grp-' + Date.now();
109
+ groups.push({ id, name: name || 'New Group', sessionIds: [] });
110
+ saveGroups(groups);
111
+ renderGroups();
112
+ return id;
113
+ }
114
+
115
+ function renameGroup(groupId, newName) {
116
+ const groups = loadGroups();
117
+ const g = groups.find(g => g.id === groupId);
118
+ if (g) { g.name = newName; saveGroups(groups); }
119
+ }
120
+
121
+ function deleteGroup(groupId) {
122
+ const groups = loadGroups().filter(g => g.id !== groupId);
123
+ saveGroups(groups);
124
+ // Move cards back to ungrouped grid
125
+ const container = document.getElementById(groupId);
126
+ if (container) {
127
+ const grid = document.getElementById('sessions-grid');
128
+ container.querySelectorAll('.session-card').forEach(card => grid.appendChild(card));
129
+ container.remove();
130
+ }
131
+ refreshAllGroupSelects();
132
+ }
133
+
134
+ function addSessionToGroup(groupId, sessionId) {
135
+ const groups = loadGroups();
136
+ // Remove from any existing group first
137
+ for (const g of groups) {
138
+ g.sessionIds = g.sessionIds.filter(id => id !== sessionId);
139
+ }
140
+ const target = groups.find(g => g.id === groupId);
141
+ if (target) target.sessionIds.push(sessionId);
142
+ saveGroups(groups);
143
+ }
144
+
145
+ function removeSessionFromGroup(sessionId) {
146
+ const groups = loadGroups();
147
+ for (const g of groups) {
148
+ g.sessionIds = g.sessionIds.filter(id => id !== sessionId);
149
+ }
150
+ saveGroups(groups);
151
+ }
152
+
153
+ export function renderGroups() {
154
+ const container = document.getElementById('groups-container');
155
+ if (!container) return;
156
+ const groups = loadGroups();
157
+ // Remove stale group elements
158
+ container.querySelectorAll('.session-group').forEach(el => {
159
+ if (!groups.find(g => g.id === el.id)) el.remove();
160
+ });
161
+ for (const group of groups) {
162
+ let groupEl = document.getElementById(group.id);
163
+ if (!groupEl) {
164
+ groupEl = document.createElement('div');
165
+ groupEl.className = 'session-group';
166
+ groupEl.id = group.id;
167
+ groupEl.innerHTML = `
168
+ <div class="group-header">
169
+ <span class="group-collapse" title="Collapse/expand">&#9660;</span>
170
+ <span class="group-name">${group.name}</span>
171
+ <span class="group-count">0</span>
172
+ <button class="group-delete" title="Delete group">&times;</button>
173
+ </div>
174
+ <div class="group-grid"></div>
175
+ `;
176
+ // Collapse/expand
177
+ groupEl.querySelector('.group-collapse').addEventListener('click', () => {
178
+ groupEl.classList.toggle('collapsed');
179
+ groupEl.querySelector('.group-collapse').innerHTML =
180
+ groupEl.classList.contains('collapsed') ? '&#9654;' : '&#9660;';
181
+ });
182
+ // Rename on double-click
183
+ groupEl.querySelector('.group-name').addEventListener('dblclick', (e) => {
184
+ const nameEl = e.currentTarget;
185
+ nameEl.contentEditable = 'true';
186
+ nameEl.classList.add('editing');
187
+ nameEl.focus();
188
+ const range = document.createRange();
189
+ range.selectNodeContents(nameEl);
190
+ const sel = window.getSelection();
191
+ sel.removeAllRanges();
192
+ sel.addRange(range);
193
+ const save = () => {
194
+ nameEl.contentEditable = 'false';
195
+ nameEl.classList.remove('editing');
196
+ const newName = nameEl.textContent.trim();
197
+ if (newName) renameGroup(group.id, newName);
198
+ };
199
+ nameEl.addEventListener('blur', save, { once: true });
200
+ nameEl.addEventListener('keydown', (ke) => {
201
+ if (ke.key === 'Enter') { ke.preventDefault(); nameEl.blur(); }
202
+ if (ke.key === 'Escape') { nameEl.textContent = group.name; nameEl.blur(); }
203
+ });
204
+ });
205
+ // Delete group
206
+ groupEl.querySelector('.group-delete').addEventListener('click', () => {
207
+ deleteGroup(group.id);
208
+ });
209
+ // Drop zone: group grid accepts card drops (only when not dropped on a specific card)
210
+ const groupGrid = groupEl.querySelector('.group-grid');
211
+ groupGrid.addEventListener('dragover', (e) => {
212
+ e.preventDefault();
213
+ e.dataTransfer.dropEffect = 'move';
214
+ if (!e.target.closest('.session-card')) {
215
+ groupGrid.classList.add('drag-over');
216
+ }
217
+ });
218
+ groupGrid.addEventListener('dragleave', (e) => {
219
+ if (!groupGrid.contains(e.relatedTarget)) {
220
+ groupGrid.classList.remove('drag-over');
221
+ }
222
+ });
223
+ groupGrid.addEventListener('drop', (e) => {
224
+ if (e.target.closest('.session-card')) return; // card handles its own drop
225
+ e.preventDefault();
226
+ groupGrid.classList.remove('drag-over');
227
+ const draggedId = e.dataTransfer.getData('text/plain');
228
+ const card = document.querySelector(`.session-card[data-session-id="${draggedId}"]`);
229
+ if (card) {
230
+ groupGrid.appendChild(card);
231
+ addSessionToGroup(group.id, draggedId);
232
+ updateGroupCounts();
233
+ }
234
+ });
235
+ container.appendChild(groupEl);
236
+ }
237
+ // Move cards that belong to this group into it
238
+ const groupGrid = groupEl.querySelector('.group-grid');
239
+ for (const sid of group.sessionIds) {
240
+ const card = document.querySelector(`.session-card[data-session-id="${sid}"]`);
241
+ if (card && card.parentElement !== groupGrid) {
242
+ groupGrid.appendChild(card);
243
+ }
244
+ }
245
+ }
246
+ updateGroupCounts();
247
+ refreshAllGroupSelects();
248
+ }
249
+
250
+ function updateGroupCounts() {
251
+ document.querySelectorAll('.session-group').forEach(groupEl => {
252
+ const count = groupEl.querySelectorAll('.session-card').length;
253
+ const countEl = groupEl.querySelector('.group-count');
254
+ if (countEl) countEl.textContent = count;
255
+ });
256
+ }
257
+
258
+ function refreshAllGroupSelects() {
259
+ // Update the detail panel group select if it exists
260
+ const sel = document.getElementById('detail-group-select');
261
+ if (!sel) return;
262
+ const groups = loadGroups();
263
+ const sid = selectedSessionId;
264
+ const currentGroup = sid ? groups.find(g => g.sessionIds.includes(sid)) : null;
265
+ const currentValue = currentGroup ? currentGroup.id : '';
266
+ sel.innerHTML = '<option value="">No group</option>' +
267
+ groups.map(g => `<option value="${g.id}"${g.id === currentValue ? ' selected' : ''}>${g.name}</option>`).join('');
268
+ }
269
+
270
+ export function initGroups() {
271
+ // Make ungrouped grid a drop zone to pull cards out of groups
272
+ const grid = document.getElementById('sessions-grid');
273
+ grid.addEventListener('dragover', (e) => {
274
+ e.preventDefault();
275
+ e.dataTransfer.dropEffect = 'move';
276
+ grid.classList.add('drag-over');
277
+ });
278
+ grid.addEventListener('dragleave', (e) => {
279
+ if (!grid.contains(e.relatedTarget)) grid.classList.remove('drag-over');
280
+ });
281
+ grid.addEventListener('drop', (e) => {
282
+ // Only handle if dropped on the grid itself, not on a card (card has its own drop)
283
+ if (e.target.closest('.session-card')) return;
284
+ e.preventDefault();
285
+ grid.classList.remove('drag-over');
286
+ const draggedId = e.dataTransfer.getData('text/plain');
287
+ const card = document.querySelector(`.session-card[data-session-id="${draggedId}"]`);
288
+ if (card) {
289
+ grid.appendChild(card);
290
+ removeSessionFromGroup(draggedId);
291
+ updateGroupCounts();
292
+ }
293
+ });
294
+
295
+ // Auto-scroll while dragging near edges of the view panel
296
+ const viewPanel = document.getElementById('view-live');
297
+ if (viewPanel) {
298
+ let scrollRaf = null;
299
+ viewPanel.addEventListener('dragover', (e) => {
300
+ const rect = viewPanel.getBoundingClientRect();
301
+ const edgeZone = 60;
302
+ const topDist = e.clientY - rect.top;
303
+ const bottomDist = rect.bottom - e.clientY;
304
+ cancelAnimationFrame(scrollRaf);
305
+ if (topDist < edgeZone) {
306
+ const speed = ((edgeZone - topDist) / edgeZone) * 12;
307
+ scrollRaf = requestAnimationFrame(() => { viewPanel.scrollTop -= speed; });
308
+ } else if (bottomDist < edgeZone) {
309
+ const speed = ((edgeZone - bottomDist) / edgeZone) * 12;
310
+ scrollRaf = requestAnimationFrame(() => { viewPanel.scrollTop += speed; });
311
+ }
312
+ });
313
+ viewPanel.addEventListener('dragend', () => cancelAnimationFrame(scrollRaf));
314
+ }
315
+
316
+ // Wire up "New Group" button
317
+ const btn = document.getElementById('qa-new-group');
318
+ if (btn) btn.addEventListener('click', () => createGroup());
319
+
320
+ // Render existing groups from localStorage
321
+ renderGroups();
322
+ }
323
+
324
+ // ---- Team Card Functions ----
325
+
326
+ export function createOrUpdateTeamCard(team) {
327
+ if (!team || !team.teamId) return;
328
+ teamsData.set(team.teamId, team);
329
+
330
+ const allMemberIds = [team.parentSessionId, ...team.childSessionIds];
331
+
332
+ // Hide individual session cards for team members
333
+ for (const sid of allMemberIds) {
334
+ const card = document.querySelector(`.session-card[data-session-id="${sid}"]`);
335
+ if (card) card.classList.add('in-team');
336
+ }
337
+
338
+ let teamCard = document.querySelector(`.team-card[data-team-id="${team.teamId}"]`);
339
+ if (!teamCard) {
340
+ teamCard = document.createElement('div');
341
+ teamCard.className = 'team-card';
342
+ teamCard.dataset.teamId = team.teamId;
343
+ teamCard.innerHTML = `
344
+ <div class="team-card-header">
345
+ <span class="team-icon">&#9733;</span>
346
+ <span class="team-name"></span>
347
+ <span class="team-member-count"></span>
348
+ <span class="status-badge team-status-badge"></span>
349
+ </div>
350
+ <div class="team-characters"></div>
351
+ <div class="team-card-footer">
352
+ <span class="team-duration"></span>
353
+ <span class="team-tools"></span>
354
+ </div>
355
+ `;
356
+ teamCard.addEventListener('click', () => openTeamModal(team.teamId));
357
+ document.getElementById('sessions-grid').prepend(teamCard);
358
+ }
359
+
360
+ // Update team card info
361
+ teamCard.querySelector('.team-name').textContent = team.teamName || 'Team';
362
+ teamCard.querySelector('.team-member-count').textContent = `${allMemberIds.length} member${allMemberIds.length !== 1 ? 's' : ''}`;
363
+
364
+ // Compute aggregate status (worst wins)
365
+ const statusPriority = { approval: 6, input: 5, working: 4, prompting: 3, waiting: 2, idle: 1, ended: 0 };
366
+ let worstStatus = 'idle';
367
+ let totalTools = 0;
368
+ let earliestStart = Infinity;
369
+ for (const sid of allMemberIds) {
370
+ const s = sessionsData.get(sid);
371
+ if (!s) continue;
372
+ if ((statusPriority[s.status] || 0) > (statusPriority[worstStatus] || 0)) {
373
+ worstStatus = s.status;
374
+ }
375
+ totalTools += s.totalToolCalls || 0;
376
+ if (s.startedAt < earliestStart) earliestStart = s.startedAt;
377
+ }
378
+ teamCard.dataset.status = worstStatus;
379
+ const badge = teamCard.querySelector('.team-status-badge');
380
+ badge.textContent = worstStatus === 'approval' ? 'APPROVAL NEEDED'
381
+ : worstStatus === 'input' ? 'WAITING FOR INPUT'
382
+ : worstStatus.toUpperCase();
383
+ badge.className = `status-badge team-status-badge ${worstStatus}`;
384
+ teamCard.querySelector('.team-duration').textContent = earliestStart < Infinity ? formatDuration(Date.now() - earliestStart) : '';
385
+ teamCard.querySelector('.team-tools').textContent = `Tools: ${totalTools}`;
386
+
387
+ // Render mini character previews
388
+ renderTeamCharacters(teamCard, allMemberIds);
389
+ }
390
+
391
+ function renderTeamCharacters(teamCard, sessionIds) {
392
+ const container = teamCard.querySelector('.team-characters');
393
+ container.innerHTML = '';
394
+ const count = sessionIds.length;
395
+ // Layout: 1-3 single row, 4-6 two rows, 7+ three rows
396
+ const rows = count <= 3 ? 1 : count <= 6 ? 2 : 3;
397
+ const perRow = Math.ceil(count / rows);
398
+
399
+ sessionIds.forEach((sid, i) => {
400
+ const s = sessionsData.get(sid);
401
+ const row = Math.floor(i / perRow);
402
+ const col = i % perRow;
403
+ const itemsInRow = Math.min(perRow, count - row * perRow);
404
+
405
+ const charEl = document.createElement('div');
406
+ charEl.className = 'team-member-char';
407
+ charEl.dataset.status = s?.status || 'idle';
408
+
409
+ // Position in grid
410
+ const leftPct = itemsInRow > 1 ? (col / (itemsInRow - 1)) * 80 + 10 : 50;
411
+ const topPct = rows > 1 ? (row / (rows - 1)) * 60 + 10 : 30;
412
+ const scale = count > 4 ? 0.7 : count > 2 ? 0.85 : 1;
413
+ charEl.style.left = `${leftPct}%`;
414
+ charEl.style.top = `${topPct}%`;
415
+ charEl.style.transform = `translate(-50%, -50%) scale(${scale})`;
416
+
417
+ // Build mini character using robotManager templates
418
+ const model = s?.characterModel || 'robot';
419
+ const accentColor = s?.accentColor || 'var(--accent-cyan)';
420
+ charEl.innerHTML = `<div class="css-robot char-${model} mini" data-status="${s?.status || 'idle'}" style="--robot-color:${accentColor}"></div>`;
421
+
422
+ // Role badge
423
+ const badge = document.createElement('div');
424
+ badge.className = 'team-role-badge';
425
+ if (s?.teamRole === 'leader') {
426
+ badge.textContent = 'L';
427
+ badge.classList.add('leader');
428
+ } else {
429
+ badge.textContent = (s?.agentType || 'M').charAt(0).toUpperCase();
430
+ }
431
+ charEl.appendChild(badge);
432
+
433
+ container.appendChild(charEl);
434
+ });
435
+
436
+ // Also fill in the mini robot content from templates
437
+ import('./robotManager.js').then(rm => {
438
+ const templates = rm._getTemplates ? rm._getTemplates() : null;
439
+ if (!templates) return;
440
+ container.querySelectorAll('.css-robot').forEach(el => {
441
+ const model = [...el.classList].find(c => c.startsWith('char-'))?.replace('char-', '') || 'robot';
442
+ const color = el.style.getPropertyValue('--robot-color') || 'var(--accent-cyan)';
443
+ if (templates[model]) {
444
+ el.innerHTML = templates[model](color);
445
+ }
446
+ });
447
+ });
448
+ }
449
+
450
+ export function removeTeamCard(teamId) {
451
+ const card = document.querySelector(`.team-card[data-team-id="${teamId}"]`);
452
+ if (card) {
453
+ card.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
454
+ card.style.opacity = '0';
455
+ card.style.transform = 'scale(0.9)';
456
+ setTimeout(() => card.remove(), 500);
457
+ }
458
+ // Unhide member cards
459
+ const team = teamsData.get(teamId);
460
+ if (team) {
461
+ const allIds = [team.parentSessionId, ...team.childSessionIds];
462
+ for (const sid of allIds) {
463
+ const sCard = document.querySelector(`.session-card[data-session-id="${sid}"]`);
464
+ if (sCard) sCard.classList.remove('in-team');
465
+ }
466
+ }
467
+ teamsData.delete(teamId);
468
+ }
469
+
470
+ function openTeamModal(teamId) {
471
+ const team = teamsData.get(teamId);
472
+ if (!team) return;
473
+
474
+ const modal = document.getElementById('team-modal');
475
+ const nameEl = document.getElementById('team-modal-name');
476
+ const countEl = document.getElementById('team-modal-count');
477
+ const grid = document.getElementById('team-modal-grid');
478
+
479
+ const allMemberIds = [team.parentSessionId, ...team.childSessionIds];
480
+ nameEl.textContent = team.teamName || 'Team';
481
+ countEl.textContent = `${allMemberIds.length} member${allMemberIds.length !== 1 ? 's' : ''}`;
482
+
483
+ grid.innerHTML = '';
484
+ for (const sid of allMemberIds) {
485
+ const s = sessionsData.get(sid);
486
+ if (!s) continue;
487
+
488
+ const card = document.createElement('div');
489
+ card.className = 'team-modal-card';
490
+ card.dataset.status = s.status;
491
+
492
+ const roleLabel = s.teamRole === 'leader'
493
+ ? '<span class="team-role-label leader">TEAM LEAD</span>'
494
+ : `<span class="team-role-label">${escapeHtml(s.agentType || 'member').toUpperCase()}</span>`;
495
+
496
+ const prompt = s.currentPrompt || '';
497
+ const promptExcerpt = prompt.length > 100 ? prompt.substring(0, 100) + '...' : prompt;
498
+
499
+ card.innerHTML = `
500
+ <div class="team-modal-card-header">
501
+ <div class="team-modal-char-preview">
502
+ <div class="css-robot char-${s.characterModel || 'robot'} mini" data-status="${s.status}" style="--robot-color:${s.accentColor || 'var(--accent-cyan)'}"></div>
503
+ </div>
504
+ <div class="team-modal-card-info">
505
+ <div class="team-modal-card-title">${escapeHtml(s.projectName)}</div>
506
+ ${roleLabel}
507
+ <span class="status-badge ${s.status}">${s.status === 'approval' ? 'APPROVAL NEEDED' : s.status === 'input' ? 'WAITING FOR INPUT' : s.status.toUpperCase()}</span>
508
+ </div>
509
+ </div>
510
+ <div class="team-modal-card-prompt">${escapeHtml(promptExcerpt)}</div>
511
+ <div class="team-modal-card-stats">
512
+ <span>${formatDuration(Date.now() - s.startedAt)}</span>
513
+ <span>Tools: ${s.totalToolCalls || 0}</span>
514
+ </div>
515
+ `;
516
+
517
+ card.addEventListener('click', () => {
518
+ modal.classList.add('hidden');
519
+ // Toggle: close if already selected, otherwise open
520
+ if (selectedSessionId === sid) {
521
+ deselectSession();
522
+ } else {
523
+ selectSession(sid);
524
+ }
525
+ });
526
+
527
+ grid.appendChild(card);
528
+ }
529
+
530
+ // Fill in robot templates
531
+ import('./robotManager.js').then(rm => {
532
+ const templates = rm._getTemplates ? rm._getTemplates() : null;
533
+ if (!templates) return;
534
+ grid.querySelectorAll('.css-robot').forEach(el => {
535
+ const model = [...el.classList].find(c => c.startsWith('char-'))?.replace('char-', '') || 'robot';
536
+ const color = el.style.getPropertyValue('--robot-color') || 'var(--accent-cyan)';
537
+ if (templates[model]) {
538
+ el.innerHTML = templates[model](color);
539
+ }
540
+ });
541
+ });
542
+
543
+ modal.classList.remove('hidden');
544
+ }
545
+
546
+ // Wire up team modal close
547
+ document.getElementById('team-modal-close')?.addEventListener('click', () => {
548
+ document.getElementById('team-modal').classList.add('hidden');
549
+ });
550
+ document.getElementById('team-modal')?.addEventListener('click', (e) => {
551
+ if (e.target.id === 'team-modal') document.getElementById('team-modal').classList.add('hidden');
552
+ });
553
+
554
+ export function createOrUpdateCard(session) {
555
+ sessionsData.set(session.sessionId, session);
556
+
557
+ let card = document.querySelector(`.session-card[data-session-id="${session.sessionId}"]`);
558
+ if (!card) {
559
+ const isDisplayOnly = session.source && session.source !== 'ssh';
560
+ card = document.createElement('div');
561
+ card.className = 'session-card' + (isDisplayOnly ? ' display-only' : '');
562
+ card.dataset.sessionId = session.sessionId;
563
+ card.draggable = !isDisplayOnly;
564
+ card.innerHTML = `
565
+ <button class="close-btn" title="Dismiss card">&times;</button>
566
+ <button class="pin-btn" title="Pin to top">&#9650;</button>
567
+ <button class="summarize-card-btn" title="Summarize & Archive">&#8681;AI</button>
568
+ <button class="mute-btn" title="Mute sounds">&#9835;</button>
569
+ <div class="robot-viewport"></div>
570
+ <div class="card-info">
571
+ <div class="card-title" title="Double-click to rename"></div>
572
+ <div class="card-header">
573
+ <span class="project-name"></span>
574
+ <span class="card-label-badge"></span>
575
+ <span class="source-badge"></span>
576
+ <span class="status-badge"></span>
577
+ </div>
578
+ <div class="waiting-banner">NEEDS YOUR INPUT</div>
579
+ <div class="card-prompt"></div>
580
+ <div class="card-stats">
581
+ <span class="duration"></span>
582
+ <span class="tool-count"></span>
583
+ <span class="subagent-count" title="Active subagents"></span>
584
+ </div>
585
+ <div class="tool-bars"></div>
586
+ </div>
587
+ `;
588
+ // Only allow click-to-detail for SSH (manually created) sessions
589
+ if (!isDisplayOnly) {
590
+ card.addEventListener('click', (e) => {
591
+ // Toggle: close if already selected, otherwise open
592
+ if (selectedSessionId === session.sessionId) {
593
+ deselectSession();
594
+ } else {
595
+ selectSession(session.sessionId);
596
+ }
597
+ });
598
+ }
599
+
600
+ // Mute button toggle (works for all sessions)
601
+ card.querySelector('.mute-btn').addEventListener('click', (e) => {
602
+ e.stopPropagation();
603
+ const sid = session.sessionId;
604
+ const btn = e.currentTarget;
605
+ if (mutedSessions.has(sid)) {
606
+ mutedSessions.delete(sid);
607
+ btn.classList.remove('muted');
608
+ btn.innerHTML = '&#9835;';
609
+ btn.title = 'Mute sounds';
610
+ } else {
611
+ mutedSessions.add(sid);
612
+ btn.classList.add('muted');
613
+ btn.innerHTML = 'M';
614
+ btn.title = 'Unmute sounds';
615
+ }
616
+ saveMuted(mutedSessions);
617
+ });
618
+
619
+ // Close button — dismiss card from live view (works for all sessions)
620
+ card.querySelector('.close-btn').addEventListener('click', (e) => {
621
+ e.stopPropagation();
622
+ const sid = session.sessionId;
623
+ const sess = sessionsData.get(sid);
624
+ // Close associated SSH terminal if present
625
+ if (sess && sess.terminalId) {
626
+ fetch(`/api/terminals/${sess.terminalId}`, { method: 'DELETE' }).catch(() => {});
627
+ }
628
+ // Mark as ended in IndexedDB so it won't reload as a live card
629
+ db.get('sessions', sid).then(record => {
630
+ if (record && record.status !== 'ended') {
631
+ record.status = 'ended';
632
+ record.endedAt = record.endedAt || Date.now();
633
+ db.put('sessions', record);
634
+ }
635
+ }).catch(() => {});
636
+ // Remove from server memory so it won't come back on WS reconnect
637
+ fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).catch(() => {});
638
+ // Clean up runtime caches (IndexedDB data preserved for history)
639
+ mutedSessions.delete(sid);
640
+ saveMuted(mutedSessions);
641
+ pinnedSessions.delete(sid);
642
+ savePinned(pinnedSessions);
643
+ removeSessionFromGroup(sid);
644
+ updateGroupCounts();
645
+ card.style.transition = 'opacity 0.3s, transform 0.3s';
646
+ card.style.opacity = '0';
647
+ card.style.transform = 'scale(0.9)';
648
+ setTimeout(() => {
649
+ removeCard(sid);
650
+ const event = new CustomEvent('card-dismissed', { detail: { sessionId: sid } });
651
+ document.dispatchEvent(event);
652
+ }, 300);
653
+ });
654
+
655
+ // Only enable interactive buttons for SSH (manually created) sessions
656
+ if (!isDisplayOnly) {
657
+ // Pin button — pin card to top of its grid
658
+ const pinBtn = card.querySelector('.pin-btn');
659
+ if (pinnedSessions.has(session.sessionId)) {
660
+ card.classList.add('pinned');
661
+ pinBtn.classList.add('active');
662
+ pinBtn.title = 'Unpin';
663
+ }
664
+ pinBtn.addEventListener('click', (e) => {
665
+ e.stopPropagation();
666
+ const sid = session.sessionId;
667
+ if (pinnedSessions.has(sid)) {
668
+ pinnedSessions.delete(sid);
669
+ card.classList.remove('pinned');
670
+ pinBtn.classList.remove('active');
671
+ pinBtn.title = 'Pin to top';
672
+ } else {
673
+ pinnedSessions.add(sid);
674
+ card.classList.add('pinned');
675
+ pinBtn.classList.add('active');
676
+ pinBtn.title = 'Unpin';
677
+ }
678
+ savePinned(pinnedSessions);
679
+ reorderPinnedCards();
680
+ });
681
+
682
+ // Summarize & archive button on card
683
+ card.querySelector('.summarize-card-btn').addEventListener('click', async (e) => {
684
+ e.stopPropagation();
685
+ const btn = e.currentTarget;
686
+ const sid = session.sessionId;
687
+ btn.disabled = true;
688
+ btn.textContent = '...';
689
+ btn.classList.add('loading');
690
+ try {
691
+ // Build context client-side from IndexedDB
692
+ const detail = await db.getSessionDetail(sid);
693
+ let context = '';
694
+ if (detail) {
695
+ context += `Project: ${detail.session.projectName || detail.session.projectPath || 'Unknown'}\n`;
696
+ context += `Status: ${detail.session.status}\n`;
697
+ context += `Started: ${new Date(detail.session.startedAt).toISOString()}\n`;
698
+ if (detail.session.endedAt) context += `Ended: ${new Date(detail.session.endedAt).toISOString()}\n`;
699
+ context += `\n--- PROMPTS ---\n`;
700
+ for (const p of detail.prompts) {
701
+ context += `[${new Date(p.timestamp).toISOString()}] ${p.text}\n\n`;
702
+ }
703
+ context += `\n--- TOOL CALLS ---\n`;
704
+ for (const t of detail.tool_calls) {
705
+ context += `[${new Date(t.timestamp).toISOString()}] ${t.toolName}: ${t.toolInputSummary || ''}\n`;
706
+ }
707
+ context += `\n--- RESPONSES ---\n`;
708
+ for (const r of detail.responses) {
709
+ context += `[${new Date(r.timestamp).toISOString()}] ${r.textExcerpt || ''}\n\n`;
710
+ }
711
+ }
712
+ // Use default summary prompt template
713
+ const allPrompts = await db.getAll('summaryPrompts');
714
+ const defaultTmpl = allPrompts.find(p => p.isDefault);
715
+ const promptTemplate = defaultTmpl ? defaultTmpl.prompt : '';
716
+
717
+ const resp = await fetch(`/api/sessions/${sid}/summarize`, {
718
+ method: 'POST',
719
+ headers: { 'Content-Type': 'application/json' },
720
+ body: JSON.stringify({ context, promptTemplate })
721
+ });
722
+ const data = await resp.json();
723
+ if (data.ok) {
724
+ const s = sessionsData.get(sid);
725
+ if (s) { s.archived = 1; s.summary = data.summary; }
726
+ // Store in IndexedDB
727
+ const dbSession = await db.get('sessions', sid);
728
+ if (dbSession) { dbSession.summary = data.summary; dbSession.archived = 1; await db.put('sessions', dbSession); }
729
+ showToast('SUMMARIZED', 'Session summarized & archived');
730
+ btn.textContent = '\u2713';
731
+ btn.classList.remove('loading');
732
+ btn.classList.add('done');
733
+ } else {
734
+ showToast('SUMMARIZE FAILED', data.error || 'Unknown error');
735
+ btn.textContent = '\u2193AI';
736
+ btn.classList.remove('loading');
737
+ btn.disabled = false;
738
+ }
739
+ } catch(err) {
740
+ showToast('SUMMARIZE ERROR', err.message);
741
+ btn.textContent = '\u2193AI';
742
+ btn.classList.remove('loading');
743
+ btn.disabled = false;
744
+ }
745
+ });
746
+ } // End of !isDisplayOnly block
747
+
748
+ // Inline rename on double-click
749
+ card.querySelector('.card-title').addEventListener('dblclick', (e) => {
750
+ e.stopPropagation();
751
+ const titleEl = e.currentTarget;
752
+ if (titleEl.contentEditable === 'true') return;
753
+ titleEl.contentEditable = 'true';
754
+ titleEl.classList.add('editing');
755
+ titleEl.focus();
756
+ // Select all text
757
+ const range = document.createRange();
758
+ range.selectNodeContents(titleEl);
759
+ const sel = window.getSelection();
760
+ sel.removeAllRanges();
761
+ sel.addRange(range);
762
+
763
+ const save = async () => {
764
+ titleEl.contentEditable = 'false';
765
+ titleEl.classList.remove('editing');
766
+ const newTitle = titleEl.textContent.trim();
767
+ if (newTitle) {
768
+ fetch(`/api/sessions/${session.sessionId}/title`, {
769
+ method: 'PUT',
770
+ headers: { 'Content-Type': 'application/json' },
771
+ body: JSON.stringify({ title: newTitle })
772
+ });
773
+ // Also update IndexedDB
774
+ const s = await db.get('sessions', session.sessionId);
775
+ if (s) { s.title = newTitle; await db.put('sessions', s); }
776
+ }
777
+ };
778
+ titleEl.addEventListener('blur', save, { once: true });
779
+ titleEl.addEventListener('keydown', (ke) => {
780
+ if (ke.key === 'Enter') { ke.preventDefault(); titleEl.blur(); }
781
+ if (ke.key === 'Escape') { titleEl.textContent = session.title || ''; titleEl.blur(); }
782
+ });
783
+ });
784
+
785
+ // Drag-and-drop reordering
786
+ card.addEventListener('dragstart', (e) => {
787
+ e.dataTransfer.effectAllowed = 'move';
788
+ e.dataTransfer.setData('text/plain', session.sessionId);
789
+ // Use setTimeout so the browser captures the un-shrunk card as drag image
790
+ setTimeout(() => card.classList.add('dragging'), 0);
791
+ });
792
+ card.addEventListener('dragend', () => {
793
+ card.classList.remove('dragging');
794
+ clearAllDropIndicators();
795
+ });
796
+ card.addEventListener('dragover', (e) => {
797
+ e.preventDefault();
798
+ e.stopPropagation();
799
+ e.dataTransfer.dropEffect = 'move';
800
+ const dragging = document.querySelector('.session-card.dragging');
801
+ if (!dragging || dragging === card) return;
802
+ // Determine left vs right half
803
+ const rect = card.getBoundingClientRect();
804
+ const midX = rect.left + rect.width / 2;
805
+ if (e.clientX < midX) {
806
+ card.classList.add('drag-over-left');
807
+ card.classList.remove('drag-over-right');
808
+ } else {
809
+ card.classList.add('drag-over-right');
810
+ card.classList.remove('drag-over-left');
811
+ }
812
+ });
813
+ card.addEventListener('dragleave', () => {
814
+ card.classList.remove('drag-over-left', 'drag-over-right');
815
+ });
816
+ card.addEventListener('drop', (e) => {
817
+ e.preventDefault();
818
+ e.stopPropagation();
819
+ const dropLeft = card.classList.contains('drag-over-left');
820
+ card.classList.remove('drag-over-left', 'drag-over-right');
821
+ const draggedId = e.dataTransfer.getData('text/plain');
822
+ const draggedCard = document.querySelector(`.session-card[data-session-id="${draggedId}"]`);
823
+ if (!draggedCard || draggedCard === card) return;
824
+ // Insert into the same parent grid as the target card
825
+ const parentGrid = card.parentElement;
826
+ if (dropLeft) {
827
+ parentGrid.insertBefore(draggedCard, card);
828
+ } else {
829
+ parentGrid.insertBefore(draggedCard, card.nextSibling);
830
+ }
831
+ // Sync group membership
832
+ const groupEl = parentGrid.closest('.session-group');
833
+ if (groupEl) {
834
+ addSessionToGroup(groupEl.id, draggedId);
835
+ } else {
836
+ removeSessionFromGroup(draggedId);
837
+ }
838
+ updateGroupCounts();
839
+ });
840
+ // Place card into its group or ungrouped grid
841
+ const group = findGroupForSession(session.sessionId);
842
+ if (group) {
843
+ const groupGrid = document.querySelector(`#${group.id} .group-grid`);
844
+ if (groupGrid) { groupGrid.appendChild(card); updateGroupCounts(); }
845
+ else document.getElementById('sessions-grid').appendChild(card);
846
+ } else {
847
+ document.getElementById('sessions-grid').appendChild(card);
848
+ }
849
+ // Ensure pinned cards stay at top
850
+ if (pinnedSessions.has(session.sessionId)) {
851
+ reorderPinnedCards();
852
+ }
853
+ }
854
+
855
+ // Update status attribute — promote active cards to front
856
+ const prevStatus = card.dataset.status;
857
+ card.dataset.status = session.status;
858
+ const activeStatuses = new Set(['working', 'prompting', 'approval', 'input']);
859
+ if (activeStatuses.has(session.status) && prevStatus !== session.status) {
860
+ const grid = card.parentElement;
861
+ if (grid) {
862
+ // Insert after pinned cards
863
+ const firstUnpinned = [...grid.children].find(c => !c.classList.contains('pinned'));
864
+ if (firstUnpinned && firstUnpinned !== card) {
865
+ grid.insertBefore(card, firstUnpinned);
866
+ } else if (!firstUnpinned) {
867
+ grid.appendChild(card);
868
+ }
869
+ }
870
+ }
871
+
872
+ // Update fields
873
+ card.querySelector('.project-name').textContent = session.projectName;
874
+ const cardTitle = card.querySelector('.card-title');
875
+ if (cardTitle && cardTitle.contentEditable !== 'true') {
876
+ cardTitle.textContent = session.title || '';
877
+ cardTitle.style.display = session.title ? '' : 'none';
878
+ }
879
+ const badge = card.querySelector('.status-badge');
880
+ const isDisconnected = session.source === 'ssh' && session.status === 'ended';
881
+ const statusLabel = isDisconnected ? 'DISCONNECTED'
882
+ : session.status === 'approval' ? 'APPROVAL NEEDED'
883
+ : session.status === 'input' ? 'WAITING FOR INPUT'
884
+ : session.status === 'waiting' ? 'WAITING'
885
+ : session.status.toUpperCase();
886
+ badge.textContent = statusLabel;
887
+ badge.className = `status-badge ${isDisconnected ? 'disconnected' : session.status}`;
888
+ // Add/remove disconnected class on card for dimming
889
+ card.classList.toggle('disconnected', isDisconnected);
890
+
891
+ // Label badge
892
+ const labelBadge = card.querySelector('.card-label-badge');
893
+ if (labelBadge) {
894
+ const lbl = session.label || '';
895
+ labelBadge.textContent = lbl;
896
+ labelBadge.style.display = lbl ? '' : 'none';
897
+ }
898
+
899
+ // HEAVY card styling — bold highlighted frame
900
+ const isHeavy = (session.label || '').toUpperCase() === 'HEAVY';
901
+ card.classList.toggle('heavy-session', isHeavy);
902
+
903
+ // ONEOFF card styling
904
+ const isOneoff = (session.label || '').toUpperCase() === 'ONEOFF';
905
+ card.classList.toggle('oneoff-session', isOneoff);
906
+
907
+ // IMPORTANT card styling — purple bold frame
908
+ const isImportant = (session.label || '').toUpperCase() === 'IMPORTANT';
909
+ card.classList.toggle('important-session', isImportant);
910
+
911
+ // Apply card frame effect from label settings (persistent animated border)
912
+ const labelUpper = (session.label || '').toUpperCase();
913
+ if (labelUpper === 'ONEOFF' || labelUpper === 'HEAVY' || labelUpper === 'IMPORTANT') {
914
+ const labelCfg = settingsManager.getLabelSettings();
915
+ const frameName = labelCfg[labelUpper]?.frame || 'none';
916
+ if (frameName && frameName !== 'none') {
917
+ card.dataset.frame = frameName;
918
+ } else {
919
+ delete card.dataset.frame;
920
+ }
921
+ } else {
922
+ delete card.dataset.frame;
923
+ }
924
+
925
+ // Source badge — show for non-SSH (display-only) sessions
926
+ const sourceBadge = card.querySelector('.source-badge');
927
+ if (sourceBadge) {
928
+ const src = session.source || 'ssh';
929
+ if (src !== 'ssh') {
930
+ const sourceLabels = {
931
+ vscode: 'VS Code', jetbrains: 'JetBrains', iterm: 'iTerm',
932
+ warp: 'Warp', kitty: 'Kitty', ghostty: 'Ghostty',
933
+ alacritty: 'Alacritty', wezterm: 'WezTerm', hyper: 'Hyper',
934
+ terminal: 'Terminal', tmux: 'tmux',
935
+ };
936
+ sourceBadge.textContent = sourceLabels[src] || src;
937
+ sourceBadge.className = `source-badge source-${src}`;
938
+ } else {
939
+ sourceBadge.textContent = '';
940
+ sourceBadge.className = 'source-badge';
941
+ }
942
+ }
943
+
944
+
945
+ // Update approval banner with detail about what needs approval
946
+ const banner = card.querySelector('.waiting-banner');
947
+ if (banner) {
948
+ banner.textContent = session.waitingDetail
949
+ || (session.status === 'input' ? 'WAITING FOR YOUR ANSWER' : 'NEEDS YOUR APPROVAL');
950
+ }
951
+
952
+ const promptArr = session.promptHistory || [];
953
+ const prompt = session.currentPrompt || (promptArr.length > 0 ? promptArr[promptArr.length - 1].text : '');
954
+ card.querySelector('.card-prompt').textContent =
955
+ prompt.length > 120 ? prompt.substring(0, 120) + '...' : prompt;
956
+
957
+ const durText = formatDuration(Date.now() - session.startedAt);
958
+ const durCard = card.querySelector('.duration');
959
+ durCard.textContent = durText;
960
+ durCard.style.display = durText ? '' : 'none';
961
+ card.querySelector('.tool-count').textContent = `Tools: ${session.totalToolCalls}`;
962
+ card.querySelector('.subagent-count').textContent =
963
+ session.subagentCount > 0 ? `Agents: ${session.subagentCount}` : '';
964
+
965
+ card.querySelector('.tool-bars').innerHTML = renderToolBars(session.toolUsage);
966
+
967
+ card.classList.toggle('has-queue', (session.queueCount || 0) > 0);
968
+ card.classList.toggle('has-terminal', !!session.terminalId);
969
+
970
+ // If this session is selected, update the detail panel too
971
+ if (selectedSessionId === session.sessionId) {
972
+ populateDetailPanel(session);
973
+ }
974
+ }
975
+
976
+ export function removeCard(sessionId, animate = false) {
977
+ const card = document.querySelector(`.session-card[data-session-id="${sessionId}"]`);
978
+ if (!card) {
979
+ sessionsData.delete(sessionId);
980
+ return;
981
+ }
982
+ if (animate) {
983
+ card.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
984
+ card.style.opacity = '0';
985
+ card.style.transform = 'scale(0.9)';
986
+ setTimeout(() => {
987
+ card.remove();
988
+ sessionsData.delete(sessionId);
989
+ if (selectedSessionId === sessionId) deselectSession();
990
+ updateGroupCounts();
991
+ }, 500);
992
+ } else {
993
+ card.remove();
994
+ sessionsData.delete(sessionId);
995
+ if (selectedSessionId === sessionId) deselectSession();
996
+ updateGroupCounts();
997
+ }
998
+ }
999
+
1000
+ export function updateDurations() {
1001
+ for (const [sessionId, session] of sessionsData) {
1002
+ const card = document.querySelector(`.session-card[data-session-id="${sessionId}"] .duration`);
1003
+ if (card) {
1004
+ card.textContent = formatDuration(Date.now() - session.startedAt);
1005
+ }
1006
+ }
1007
+ // Also update detail panel duration if open
1008
+ if (selectedSessionId) {
1009
+ const session = sessionsData.get(selectedSessionId);
1010
+ if (session) {
1011
+ const el = document.getElementById('detail-duration');
1012
+ if (el) el.textContent = formatDuration(Date.now() - session.startedAt);
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ export function showToast(title, message) {
1018
+ const container = document.getElementById('toast-container');
1019
+ const toast = document.createElement('div');
1020
+ toast.className = 'toast';
1021
+ toast.innerHTML = `<button class="toast-close">&times;</button><div class="toast-title">${escapeHtml(title)}</div><div class="toast-msg">${escapeHtml(message)}</div>`;
1022
+ toast.querySelector('.toast-close').addEventListener('click', () => {
1023
+ toast.classList.add('fade-out');
1024
+ setTimeout(() => toast.remove(), 300);
1025
+ });
1026
+ container.appendChild(toast);
1027
+ setTimeout(() => { if (toast.parentNode) { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 300); } }, 5000);
1028
+ }
1029
+
1030
+ async function loadNotes(sessionId) {
1031
+ const list = document.getElementById('notes-list');
1032
+ try {
1033
+ const notes = await db.getNotes(sessionId);
1034
+ list.innerHTML = notes.map(n => `
1035
+ <div class="note-entry">
1036
+ <div class="note-meta">
1037
+ <span class="note-time">${formatTime(n.createdAt)}</span>
1038
+ <button class="note-delete" data-note-id="${n.id}">DELETE</button>
1039
+ </div>
1040
+ <div class="note-text">${escapeHtml(n.text)}</div>
1041
+ </div>
1042
+ `).join('') || '<div class="tab-empty">No notes yet</div>';
1043
+ } catch(e) {
1044
+ list.innerHTML = '<div class="tab-empty">Failed to load notes</div>';
1045
+ }
1046
+ }
1047
+
1048
+ // Track known queue item IDs to detect newly added items
1049
+ let _knownQueueIds = new Set();
1050
+
1051
+ export async function loadQueue(sessionId) {
1052
+ const list = document.getElementById('queue-list');
1053
+ const countBadge = document.getElementById('terminal-queue-count');
1054
+ try {
1055
+ const items = await db.getQueue(sessionId);
1056
+ if (countBadge) countBadge.textContent = items.length > 0 ? `(${items.length})` : '';
1057
+
1058
+ const newIds = new Set(items.map(item => item.id));
1059
+ list.innerHTML = items.map((item, i) => {
1060
+ const isNew = !_knownQueueIds.has(item.id);
1061
+ return `
1062
+ <div class="queue-item${isNew ? ' entering' : ''}" draggable="true" data-queue-id="${item.id}">
1063
+ <span class="queue-pos">${i + 1}</span>
1064
+ <div class="queue-text">${escapeHtml(item.text)}</div>
1065
+ <div class="queue-actions">
1066
+ <button class="queue-send" data-queue-id="${item.id}" title="Send to terminal">SEND</button>
1067
+ <button class="queue-edit" data-queue-id="${item.id}" title="Edit">EDIT</button>
1068
+ <button class="queue-delete" data-queue-id="${item.id}" title="Delete">DEL</button>
1069
+ </div>
1070
+ </div>`;
1071
+ }).join('') || '<div class="tab-empty">No prompts queued</div>';
1072
+ _knownQueueIds = newIds;
1073
+
1074
+ // Remove entering class after animation completes
1075
+ list.querySelectorAll('.queue-item.entering').forEach(el => {
1076
+ el.addEventListener('animationend', () => el.classList.remove('entering'), { once: true });
1077
+ });
1078
+
1079
+ // Wire up drag-to-reorder
1080
+ wireQueueDrag(sessionId);
1081
+ } catch(e) {
1082
+ list.innerHTML = '<div class="tab-empty">Failed to load queue</div>';
1083
+ }
1084
+ }
1085
+
1086
+ function wireQueueDrag(sessionId) {
1087
+ const list = document.getElementById('queue-list');
1088
+ let dragItem = null;
1089
+ list.querySelectorAll('.queue-item').forEach(item => {
1090
+ item.addEventListener('dragstart', (e) => {
1091
+ dragItem = item;
1092
+ item.classList.add('dragging');
1093
+ e.dataTransfer.effectAllowed = 'copyMove';
1094
+ // Set data for drag-to-terminal
1095
+ const text = item.querySelector('.queue-text')?.textContent || '';
1096
+ e.dataTransfer.setData('text/queue-prompt', text);
1097
+ e.dataTransfer.setData('text/queue-id', item.dataset.queueId);
1098
+ });
1099
+ item.addEventListener('dragend', async () => {
1100
+ item.classList.remove('dragging');
1101
+ dragItem = null;
1102
+ // Collect new order and persist to IndexedDB
1103
+ const orderedIds = [...list.querySelectorAll('.queue-item')].map(el => parseInt(el.dataset.queueId));
1104
+ await db.reorderQueue(sessionId, orderedIds);
1105
+ loadQueue(sessionId);
1106
+ });
1107
+ item.addEventListener('dragover', (e) => {
1108
+ e.preventDefault();
1109
+ if (!dragItem || dragItem === item) return;
1110
+ const rect = item.getBoundingClientRect();
1111
+ const midY = rect.top + rect.height / 2;
1112
+ if (e.clientY < midY) {
1113
+ list.insertBefore(dragItem, item);
1114
+ } else {
1115
+ list.insertBefore(dragItem, item.nextSibling);
1116
+ }
1117
+ });
1118
+ });
1119
+ }
1120
+
1121
+ function selectSession(sessionId) {
1122
+ selectedSessionId = sessionId;
1123
+ const session = sessionsData.get(sessionId);
1124
+ if (!session) return;
1125
+
1126
+ // Populate and show detail panel
1127
+ populateDetailPanel(session);
1128
+ const overlay = document.getElementById('session-detail-overlay');
1129
+ overlay.classList.remove('hidden');
1130
+ }
1131
+
1132
+ function deselectSession() {
1133
+ selectedSessionId = null;
1134
+ document.getElementById('session-detail-overlay').classList.add('hidden');
1135
+ // Keep terminal alive — content is preserved when panel reopens
1136
+ }
1137
+
1138
+ function populateDetailPanel(session) {
1139
+ document.getElementById('detail-project-name').textContent = session.projectName;
1140
+ const badge = document.getElementById('detail-status-badge');
1141
+ const detailLabel = session.status === 'approval' ? 'APPROVAL NEEDED'
1142
+ : session.status === 'input' ? 'WAITING FOR INPUT'
1143
+ : session.status === 'waiting' ? 'WAITING'
1144
+ : session.status.toUpperCase();
1145
+ badge.textContent = detailLabel;
1146
+ badge.className = `status-badge ${session.status}`;
1147
+ document.getElementById('detail-model').textContent = session.model || '';
1148
+ const durationText = formatDuration(Date.now() - session.startedAt);
1149
+ const durationEl = document.getElementById('detail-duration');
1150
+ durationEl.textContent = durationText;
1151
+ durationEl.style.display = durationText ? '' : 'none';
1152
+
1153
+ // Character model selector
1154
+ const charSelect = document.getElementById('detail-char-model');
1155
+ if (charSelect) {
1156
+ charSelect.value = session.characterModel || '';
1157
+ charSelect.dataset.sessionId = session.sessionId;
1158
+ }
1159
+
1160
+ // Mini character preview in header — use the session's actual accent color
1161
+ import('./robotManager.js').then(rm => {
1162
+ const color = rm.getSessionColor(session.sessionId) || session.accentColor || null;
1163
+ updateDetailCharPreview(session.characterModel || '', session.status, color);
1164
+ });
1165
+
1166
+ // Session title
1167
+ const titleInput = document.getElementById('detail-title');
1168
+ if (titleInput) {
1169
+ titleInput.value = session.title || '';
1170
+ titleInput.dataset.sessionId = session.sessionId;
1171
+ }
1172
+
1173
+ // Session label
1174
+ const labelInput = document.getElementById('detail-label');
1175
+ if (labelInput) {
1176
+ labelInput.value = session.label || '';
1177
+ labelInput.dataset.sessionId = session.sessionId;
1178
+ }
1179
+
1180
+ // Label quick-select chips
1181
+ populateDetailLabelChips(session);
1182
+
1183
+ // Prompt History tab — show only user prompts in chronological order
1184
+ const convContainer = document.getElementById('detail-conversation');
1185
+ const prompts = (session.promptHistory || []).slice().sort((a, b) => b.timestamp - a.timestamp);
1186
+ convContainer.innerHTML = prompts.length > 0
1187
+ ? prompts.map((p, i) => `<div class="conv-entry conv-user">
1188
+ <div class="conv-header"><span class="conv-role">#${prompts.length - i}</span><span class="conv-time">${formatTime(p.timestamp)}</span><button class="conv-copy" title="Copy">COPY</button></div>
1189
+ <div class="conv-text">${escapeHtml(p.text)}</div>
1190
+ </div>`).join('')
1191
+ : '<div class="tab-empty">No prompts yet</div>';
1192
+
1193
+ // Activity tab — merge events, tool calls, and responses chronologically
1194
+ const activityLog = document.getElementById('detail-activity-log');
1195
+ const activityItems = [];
1196
+ for (const e of (session.events || [])) {
1197
+ activityItems.push({ kind: 'event', type: e.type, detail: e.detail, timestamp: e.timestamp });
1198
+ }
1199
+ for (const t of (session.toolLog || [])) {
1200
+ activityItems.push({ kind: 'tool', tool: t.tool, input: t.input, timestamp: t.timestamp });
1201
+ }
1202
+ for (const r of (session.responseLog || [])) {
1203
+ activityItems.push({ kind: 'response', text: r.text, timestamp: r.timestamp });
1204
+ }
1205
+ activityItems.sort((a, b) => b.timestamp - a.timestamp);
1206
+ activityLog.innerHTML = activityItems.length > 0
1207
+ ? activityItems.map(item => {
1208
+ if (item.kind === 'tool') {
1209
+ return `<div class="activity-entry activity-tool">
1210
+ <span class="activity-time">${formatTime(item.timestamp)}</span>
1211
+ <span class="activity-badge activity-badge-tool">${escapeHtml(item.tool)}</span>
1212
+ <span class="activity-detail">${escapeHtml(item.input)}</span>
1213
+ </div>`;
1214
+ } else if (item.kind === 'response') {
1215
+ return `<div class="activity-entry activity-response">
1216
+ <span class="activity-time">${formatTime(item.timestamp)}</span>
1217
+ <span class="activity-badge activity-badge-response">RESPONSE</span>
1218
+ <span class="activity-detail">${escapeHtml(item.text)}</span>
1219
+ </div>`;
1220
+ } else {
1221
+ return `<div class="activity-entry activity-event">
1222
+ <span class="activity-time">${formatTime(item.timestamp)}</span>
1223
+ <span class="activity-badge activity-badge-event">${escapeHtml(item.type)}</span>
1224
+ <span class="activity-detail">${escapeHtml(item.detail)}</span>
1225
+ </div>`;
1226
+ }
1227
+ }).join('')
1228
+ : '<div class="tab-empty">No activity yet</div>';
1229
+
1230
+ // Summary tab
1231
+ const summaryEl = document.getElementById('summary-content');
1232
+ if (summaryEl) {
1233
+ if (session.summary) {
1234
+ summaryEl.innerHTML = `<div class="summary-text">${escapeHtml(session.summary).replace(/\n/g, '<br>')}</div>`;
1235
+ } else {
1236
+ summaryEl.innerHTML = '<div class="tab-empty">No summary yet — click SUMMARIZE to generate one with AI</div>';
1237
+ }
1238
+ }
1239
+
1240
+ // Update summarize button state
1241
+ const sumBtn = document.getElementById('ctrl-summarize');
1242
+ if (sumBtn) {
1243
+ sumBtn.disabled = false;
1244
+ sumBtn.textContent = session.summary ? 'RE-SUMMARIZE' : 'SUMMARIZE';
1245
+ }
1246
+
1247
+ // Group select — populate with all groups, highlight current
1248
+ refreshAllGroupSelects();
1249
+
1250
+ // Load notes & queue
1251
+ loadNotes(session.sessionId);
1252
+ loadQueue(session.sessionId);
1253
+ // Auto-attach terminal if Terminal tab is the default active tab
1254
+ const activeTab = document.querySelector('.detail-tabs .tab.active');
1255
+ if (activeTab && activeTab.dataset.tab === 'terminal' && session.terminalId) {
1256
+ import('./terminalManager.js').then(tm => {
1257
+ if (tm.getActiveTerminalId() === session.terminalId) {
1258
+ // Same terminal already active — just refit after panel becomes visible
1259
+ requestAnimationFrame(() => tm.refitTerminal());
1260
+ } else {
1261
+ tm.attachToSession(session.sessionId, session.terminalId);
1262
+ }
1263
+ });
1264
+ } else if (activeTab && activeTab.dataset.tab === 'terminal' && !session.terminalId) {
1265
+ // Switching to a session with no terminal — detach the old one
1266
+ import('./terminalManager.js').then(tm => tm.detachTerminal());
1267
+ }
1268
+
1269
+ }
1270
+
1271
+ function renderToolBars(toolUsage) {
1272
+ if (!toolUsage || Object.keys(toolUsage).length === 0) return '';
1273
+ const max = Math.max(...Object.values(toolUsage), 1);
1274
+ return Object.entries(toolUsage)
1275
+ .sort((a, b) => b[1] - a[1])
1276
+ .slice(0, 5)
1277
+ .map(([name, count]) =>
1278
+ `<div class="tool-bar">
1279
+ <span class="tool-name">${escapeHtml(name)}</span>
1280
+ <div class="tool-bar-fill" style="width:${(count / max) * 100}%"></div>
1281
+ <span class="tool-count">${count}</span>
1282
+ </div>`
1283
+ ).join('');
1284
+ }
1285
+
1286
+ function formatDuration(ms) {
1287
+ if (!ms || isNaN(ms) || ms < 0) return '';
1288
+ const s = Math.floor(ms / 1000);
1289
+ const m = Math.floor(s / 60);
1290
+ const h = Math.floor(m / 60);
1291
+ if (h > 0) return `${h}h ${m % 60}m`;
1292
+ if (m > 0) return `${m}m ${s % 60}s`;
1293
+ return `${s}s`;
1294
+ }
1295
+
1296
+ function formatTime(ts) {
1297
+ return new Date(ts).toLocaleTimeString('en-US', { hour12: false });
1298
+ }
1299
+
1300
+ function escapeHtml(str) {
1301
+ if (!str) return '';
1302
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1303
+ }
1304
+
1305
+ export async function openSessionDetailFromHistory(sessionId) {
1306
+ const data = await db.getSessionDetail(sessionId);
1307
+ if (!data) { showToast('ERROR', 'Session not found in local database'); return; }
1308
+
1309
+ // Populate detail header
1310
+ document.getElementById('detail-project-name').textContent = data.session.projectName || data.session.projectPath || 'Unknown';
1311
+ const badge = document.getElementById('detail-status-badge');
1312
+ badge.textContent = data.session.status.toUpperCase();
1313
+ badge.className = `status-badge ${data.session.status}`;
1314
+ document.getElementById('detail-model').textContent = data.session.model || '';
1315
+ const duration = data.session.endedAt
1316
+ ? formatDuration(data.session.endedAt - data.session.startedAt)
1317
+ : formatDuration(Date.now() - data.session.startedAt);
1318
+ const durEl = document.getElementById('detail-duration');
1319
+ durEl.textContent = duration;
1320
+ durEl.style.display = duration ? '' : 'none';
1321
+
1322
+ // Session title
1323
+ const titleInput = document.getElementById('detail-title');
1324
+ if (titleInput) {
1325
+ titleInput.value = data.session.title || '';
1326
+ titleInput.dataset.sessionId = sessionId;
1327
+ }
1328
+
1329
+ // Character model selector + preview
1330
+ const charSelect = document.getElementById('detail-char-model');
1331
+ if (charSelect) {
1332
+ charSelect.value = data.session.characterModel || '';
1333
+ charSelect.dataset.sessionId = sessionId;
1334
+ }
1335
+ updateDetailCharPreview(
1336
+ data.session.characterModel || '',
1337
+ data.session.status,
1338
+ data.session.accentColor || null
1339
+ );
1340
+
1341
+ // Populate conversation tab — interleave prompts, tool calls, and responses
1342
+ const histConvItems = [];
1343
+ for (const p of (data.prompts || [])) {
1344
+ histConvItems.push({ type: 'user', text: p.text, timestamp: p.timestamp });
1345
+ }
1346
+ for (const t of (data.tool_calls || [])) {
1347
+ histConvItems.push({ type: 'tool', tool: t.toolName, input: t.toolInputSummary, timestamp: t.timestamp });
1348
+ }
1349
+ for (const r of (data.responses || [])) {
1350
+ histConvItems.push({ type: 'claude', text: r.textExcerpt, timestamp: r.timestamp });
1351
+ }
1352
+ histConvItems.sort((a, b) => b.timestamp - a.timestamp);
1353
+ const histConvContainer = document.getElementById('detail-conversation');
1354
+ histConvContainer.innerHTML = histConvItems.length > 0
1355
+ ? histConvItems.map(item => {
1356
+ if (item.type === 'user') {
1357
+ return `<div class="conv-entry conv-user">
1358
+ <div class="conv-header"><span class="conv-role">USER</span><span class="conv-time">${formatTime(item.timestamp)}</span><button class="conv-copy" title="Copy">COPY</button></div>
1359
+ <div class="conv-text">${escapeHtml(item.text)}</div>
1360
+ </div>`;
1361
+ } else if (item.type === 'tool') {
1362
+ return `<div class="conv-entry conv-tool">
1363
+ <div class="conv-header"><span class="conv-role">TOOL</span><span class="conv-time">${formatTime(item.timestamp)}</span><button class="conv-copy" title="Copy">COPY</button></div>
1364
+ <span class="conv-tool-name">${escapeHtml(item.tool)}</span>
1365
+ <span class="conv-tool-input">${escapeHtml(item.input)}</span>
1366
+ </div>`;
1367
+ } else {
1368
+ return `<div class="conv-entry conv-claude">
1369
+ <div class="conv-header"><span class="conv-role">CLAUDE</span><span class="conv-time">${formatTime(item.timestamp)}</span><button class="conv-copy" title="Copy">COPY</button></div>
1370
+ <div class="conv-text">${escapeHtml(item.text)}</div>
1371
+ </div>`;
1372
+ }
1373
+ }).join('')
1374
+ : '<div class="tab-empty">No conversation recorded</div>';
1375
+
1376
+ // Populate activity tab (merged tool calls + events + responses)
1377
+ const histActivityItems = [];
1378
+ for (const t of (data.tool_calls || [])) {
1379
+ histActivityItems.push({ kind: 'tool', tool: t.toolName, input: t.toolInputSummary, timestamp: t.timestamp });
1380
+ }
1381
+ for (const e of (data.events || [])) {
1382
+ histActivityItems.push({ kind: 'event', type: e.eventType, detail: e.detail, timestamp: e.timestamp });
1383
+ }
1384
+ for (const r of (data.responses || [])) {
1385
+ histActivityItems.push({ kind: 'response', text: r.textExcerpt || r.text, timestamp: r.timestamp });
1386
+ }
1387
+ histActivityItems.sort((a, b) => b.timestamp - a.timestamp);
1388
+ const actEl = document.getElementById('detail-activity-log');
1389
+ if (actEl) {
1390
+ actEl.innerHTML = histActivityItems.length > 0
1391
+ ? histActivityItems.map(item => {
1392
+ if (item.kind === 'tool') {
1393
+ return `<div class="activity-entry activity-tool">
1394
+ <span class="activity-time">${formatTime(item.timestamp)}</span>
1395
+ <span class="activity-badge activity-badge-tool">${escapeHtml(item.tool)}</span>
1396
+ <span class="activity-detail">${escapeHtml(item.input)}</span>
1397
+ </div>`;
1398
+ } else if (item.kind === 'response') {
1399
+ return `<div class="activity-entry activity-response">
1400
+ <span class="activity-time">${formatTime(item.timestamp)}</span>
1401
+ <span class="activity-badge activity-badge-response">RESPONSE</span>
1402
+ <span class="activity-detail">${escapeHtml(item.text)}</span>
1403
+ </div>`;
1404
+ } else {
1405
+ return `<div class="activity-entry activity-event">
1406
+ <span class="activity-time">${formatTime(item.timestamp)}</span>
1407
+ <span class="activity-badge activity-badge-event">${escapeHtml(item.type)}</span>
1408
+ <span class="activity-detail">${escapeHtml(item.detail)}</span>
1409
+ </div>`;
1410
+ }
1411
+ }).join('')
1412
+ : '<div class="tab-empty">No activity recorded</div>';
1413
+ }
1414
+
1415
+ // Summary tab
1416
+ const summaryEl = document.getElementById('summary-content');
1417
+ if (summaryEl) {
1418
+ if (data.session.summary) {
1419
+ summaryEl.innerHTML = `<div class="summary-text">${escapeHtml(data.session.summary).replace(/\n/g, '<br>')}</div>`;
1420
+ } else {
1421
+ summaryEl.innerHTML = '<div class="tab-empty">No summary yet — click SUMMARIZE to generate one with AI</div>';
1422
+ }
1423
+ }
1424
+
1425
+ // Update summarize button state
1426
+ const sumBtn = document.getElementById('ctrl-summarize');
1427
+ if (sumBtn) {
1428
+ sumBtn.disabled = false;
1429
+ sumBtn.textContent = data.session.summary ? 'RE-SUMMARIZE' : 'SUMMARIZE';
1430
+ }
1431
+
1432
+ // Store sessionId for the summarize button handler
1433
+ selectedSessionId = sessionId;
1434
+
1435
+ // Group select — populate with all groups, highlight current
1436
+ refreshAllGroupSelects();
1437
+
1438
+ // Load notes for this session
1439
+ loadNotes(sessionId);
1440
+ // Show overlay
1441
+ document.getElementById('session-detail-overlay').classList.remove('hidden');
1442
+ }
1443
+
1444
+ // Character model mini preview in detail panel
1445
+ function updateDetailCharPreview(modelName, status, color) {
1446
+ const container = document.getElementById('detail-char-preview');
1447
+ if (!container) return;
1448
+ const model = modelName || 'robot';
1449
+ const accentColor = color || 'var(--accent-cyan)';
1450
+ // Dynamically import to get the template
1451
+ import('./robotManager.js').then(rm => {
1452
+ // Build a mini robot element
1453
+ container.innerHTML = '';
1454
+ const mini = document.createElement('div');
1455
+ mini.className = `css-robot char-${model}`;
1456
+ mini.dataset.status = status || 'idle';
1457
+ mini.style.setProperty('--robot-color', accentColor);
1458
+ // Use the template from robotManager
1459
+ const templates = rm._getTemplates ? rm._getTemplates() : null;
1460
+ if (templates && templates[model]) {
1461
+ mini.innerHTML = templates[model](accentColor);
1462
+ } else {
1463
+ // Fallback - just show model name
1464
+ mini.textContent = model;
1465
+ }
1466
+ container.appendChild(mini);
1467
+ });
1468
+ }
1469
+
1470
+ // Per-session character model change
1471
+ const charModelSelect = document.getElementById('detail-char-model');
1472
+ if (charModelSelect) {
1473
+ charModelSelect.addEventListener('change', async (e) => {
1474
+ const model = e.target.value;
1475
+ const sessionId = e.target.dataset.sessionId;
1476
+ if (!sessionId) return;
1477
+
1478
+ // Update in-memory session data
1479
+ const session = sessionsData.get(sessionId);
1480
+ if (session) session.characterModel = model;
1481
+
1482
+ // Save to IndexedDB
1483
+ try {
1484
+ const s = await db.get('sessions', sessionId);
1485
+ if (s) { s.characterModel = model; await db.put('sessions', s); }
1486
+ } catch(e) {
1487
+ console.error('[sessionPanel] Failed to save character model:', e.message);
1488
+ }
1489
+
1490
+ // Update the robot on the card
1491
+ import('./robotManager.js').then(rm => {
1492
+ rm.switchSessionCharacter(sessionId, model);
1493
+ });
1494
+
1495
+ // Update mini preview with session's accent color
1496
+ import('./robotManager.js').then(rm => {
1497
+ const color = rm.getSessionColor(sessionId) || session?.accentColor || null;
1498
+ updateDetailCharPreview(model, session?.status || 'idle', color);
1499
+ });
1500
+ });
1501
+ }
1502
+
1503
+ // Wire up close button and overlay backdrop click
1504
+ document.getElementById('close-detail').addEventListener('click', deselectSession);
1505
+ document.getElementById('session-detail-overlay').addEventListener('click', (e) => {
1506
+ if (e.target.id === 'session-detail-overlay') deselectSession();
1507
+ });
1508
+
1509
+ // Conversation copy button (event delegation)
1510
+ document.getElementById('detail-conversation').addEventListener('click', async (e) => {
1511
+ const btn = e.target.closest('.conv-copy');
1512
+ if (!btn) return;
1513
+ const entry = btn.closest('.conv-entry');
1514
+ if (!entry) return;
1515
+ // Extract text content from the entry (skip the header row)
1516
+ const textEl = entry.querySelector('.conv-text');
1517
+ const text = textEl
1518
+ ? textEl.textContent
1519
+ : (entry.querySelector('.conv-tool-name')?.textContent || '') + ' ' + (entry.querySelector('.conv-tool-input')?.textContent || '');
1520
+ try {
1521
+ await navigator.clipboard.writeText(text.trim());
1522
+ btn.textContent = 'COPIED';
1523
+ setTimeout(() => { btn.textContent = 'COPY'; }, 1500);
1524
+ } catch {
1525
+ showToast('COPY', 'Failed to copy to clipboard');
1526
+ }
1527
+ });
1528
+
1529
+ // Tab switching
1530
+ document.querySelector('.detail-tabs').addEventListener('click', (e) => {
1531
+ const btn = e.target.closest('.tab');
1532
+ if (!btn) return;
1533
+ const tabName = btn.dataset.tab;
1534
+
1535
+ // Toggle active on buttons
1536
+ document.querySelectorAll('.detail-tabs .tab').forEach(t => t.classList.remove('active'));
1537
+ btn.classList.add('active');
1538
+
1539
+ // Toggle active on content
1540
+ document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
1541
+ document.getElementById(`tab-${tabName}`).classList.add('active');
1542
+
1543
+ // Terminal tab: refit or attach; switching away just leaves terminal in background
1544
+ if (tabName === 'terminal' && selectedSessionId) {
1545
+ const session = sessionsData.get(selectedSessionId);
1546
+ if (session && session.terminalId) {
1547
+ import('./terminalManager.js').then(tm => {
1548
+ if (tm.getActiveTerminalId() === session.terminalId) {
1549
+ requestAnimationFrame(() => tm.refitTerminal());
1550
+ } else {
1551
+ tm.attachToSession(selectedSessionId, session.terminalId);
1552
+ }
1553
+ });
1554
+ }
1555
+ }
1556
+ });
1557
+
1558
+ // ---- Control Button Handlers ----
1559
+
1560
+ // Kill button
1561
+ document.getElementById('ctrl-kill').addEventListener('click', (e) => {
1562
+ e.stopPropagation();
1563
+ if (!selectedSessionId) return;
1564
+ const session = sessionsData.get(selectedSessionId);
1565
+ const msg = document.getElementById('kill-modal-msg');
1566
+ msg.textContent = `Kill session for "${session ? session.projectName : selectedSessionId}"? This will terminate the Claude process (SIGTERM → SIGKILL).`;
1567
+ document.getElementById('kill-modal').classList.remove('hidden');
1568
+ });
1569
+
1570
+ document.getElementById('kill-cancel').addEventListener('click', () => {
1571
+ document.getElementById('kill-modal').classList.add('hidden');
1572
+ });
1573
+
1574
+ document.getElementById('kill-confirm').addEventListener('click', async () => {
1575
+ document.getElementById('kill-modal').classList.add('hidden');
1576
+ if (!selectedSessionId) return;
1577
+ const session = sessionsData.get(selectedSessionId);
1578
+ try {
1579
+ const resp = await fetch(`/api/sessions/${selectedSessionId}/kill`, {
1580
+ method: 'POST',
1581
+ headers: { 'Content-Type': 'application/json' },
1582
+ body: JSON.stringify({ confirm: true })
1583
+ });
1584
+ const data = await resp.json();
1585
+ if (data.ok) {
1586
+ // Close associated SSH terminal if present
1587
+ if (session && session.terminalId) {
1588
+ fetch(`/api/terminals/${session.terminalId}`, { method: 'DELETE' }).catch(() => {});
1589
+ }
1590
+ showToast('PROCESS KILLED', `PID ${data.pid || 'N/A'} terminated`);
1591
+ soundManager.play('kill');
1592
+ // Auto-close detail panel and remove card
1593
+ deselectSession();
1594
+ } else {
1595
+ showToast('KILL FAILED', data.error || 'Unknown error');
1596
+ }
1597
+ } catch(e) {
1598
+ showToast('KILL ERROR', e.message);
1599
+ }
1600
+ });
1601
+
1602
+ // Group select (detail panel)
1603
+ document.getElementById('detail-group-select').addEventListener('change', (e) => {
1604
+ if (!selectedSessionId) return;
1605
+ const groupId = e.target.value;
1606
+ const card = document.querySelector(`.session-card[data-session-id="${selectedSessionId}"]`);
1607
+ if (!card) return;
1608
+ if (groupId) {
1609
+ const groupGrid = document.querySelector(`#${groupId} .group-grid`);
1610
+ if (groupGrid) {
1611
+ groupGrid.appendChild(card);
1612
+ addSessionToGroup(groupId, selectedSessionId);
1613
+ }
1614
+ } else {
1615
+ document.getElementById('sessions-grid').appendChild(card);
1616
+ removeSessionFromGroup(selectedSessionId);
1617
+ }
1618
+ updateGroupCounts();
1619
+ if (pinnedSessions.has(selectedSessionId)) reorderPinnedCards();
1620
+ showToast('GROUP', groupId ? `Moved to group` : 'Removed from group');
1621
+ });
1622
+
1623
+ // Archive button — move session from live dashboard to history
1624
+ document.getElementById('ctrl-archive').addEventListener('click', async (e) => {
1625
+ e.stopPropagation();
1626
+ if (!selectedSessionId) return;
1627
+ const sid = selectedSessionId;
1628
+ try {
1629
+ // Mark as ended + archived in IndexedDB
1630
+ const s = await db.get('sessions', sid);
1631
+ if (s) {
1632
+ s.status = 'ended';
1633
+ s.archived = 1;
1634
+ if (!s.endedAt) s.endedAt = Date.now();
1635
+ await db.put('sessions', s);
1636
+ }
1637
+ // Remove from server memory
1638
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).catch(() => {});
1639
+ // Close detail panel and remove live card
1640
+ deselectSession();
1641
+ removeCard(sid);
1642
+ sessionsData.delete(sid);
1643
+ import('./robotManager.js').then(rm => rm.removeRobot(sid));
1644
+ // Dispatch card-dismissed for group count update
1645
+ document.dispatchEvent(new CustomEvent('card-dismissed', { detail: { sessionId: sid } }));
1646
+ showToast('ARCHIVED', 'Session moved to history');
1647
+ } catch(err) {
1648
+ showToast('ARCHIVE ERROR', err.message);
1649
+ }
1650
+ });
1651
+
1652
+ // ---- Permanent Delete ----
1653
+ document.getElementById('ctrl-delete').addEventListener('click', async (e) => {
1654
+ e.stopPropagation();
1655
+ if (!selectedSessionId) return;
1656
+ const session = sessionsData.get(selectedSessionId);
1657
+ const label = session?.title || session?.projectName || selectedSessionId.slice(0, 8);
1658
+ if (!confirm(`Permanently delete session "${label}"?\nThis cannot be undone.`)) return;
1659
+ const sid = selectedSessionId;
1660
+ try {
1661
+ // Server-side removal (closes terminal if active, broadcasts session_removed)
1662
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
1663
+ // Also remove from browser IndexedDB
1664
+ await db.del('sessions', sid);
1665
+ deselectSession();
1666
+ showToast('DELETED', `Session "${label}" permanently removed`);
1667
+ } catch (err) {
1668
+ showToast('DELETE ERROR', err.message);
1669
+ }
1670
+ });
1671
+
1672
+ // ---- Summarize Prompt Selector Modal ----
1673
+
1674
+ let selectedPromptId = null;
1675
+ let summaryPromptsCache = [];
1676
+
1677
+ async function loadSummaryPrompts() {
1678
+ try {
1679
+ const prompts = await db.getAll('summaryPrompts');
1680
+ summaryPromptsCache = prompts || [];
1681
+ return summaryPromptsCache;
1682
+ } catch(e) {
1683
+ return [];
1684
+ }
1685
+ }
1686
+
1687
+ function renderSummaryPromptList(prompts) {
1688
+ const list = document.getElementById('summarize-prompt-list');
1689
+ if (!list) return;
1690
+ selectedPromptId = null;
1691
+ const runBtn = document.getElementById('summarize-run');
1692
+ if (runBtn) runBtn.disabled = true;
1693
+
1694
+ list.innerHTML = prompts.map(p => `
1695
+ <div class="summarize-prompt-item${p.isDefault ? ' default' : ''}" data-prompt-id="${p.id}">
1696
+ <div class="summarize-prompt-item-header">
1697
+ <span class="summarize-prompt-name">${escapeHtml(p.name)}</span>
1698
+ ${p.isDefault ? '<span class="summarize-prompt-default-badge">DEFAULT</span>' : ''}
1699
+ <div class="summarize-prompt-actions">
1700
+ <button class="summarize-prompt-default-btn" data-id="${p.id}" title="Set as default">&#9733;</button>
1701
+ <button class="summarize-prompt-edit-btn" data-id="${p.id}" title="Edit">&#9998;</button>
1702
+ <button class="summarize-prompt-delete-btn" data-id="${p.id}" title="Delete">&times;</button>
1703
+ </div>
1704
+ </div>
1705
+ <div class="summarize-prompt-preview">${escapeHtml(p.prompt).substring(0, 150)}${p.prompt.length > 150 ? '...' : ''}</div>
1706
+ </div>
1707
+ `).join('');
1708
+
1709
+ // Select handler
1710
+ list.querySelectorAll('.summarize-prompt-item').forEach(item => {
1711
+ item.addEventListener('click', (e) => {
1712
+ if (e.target.closest('.summarize-prompt-actions')) return;
1713
+ list.querySelectorAll('.summarize-prompt-item').forEach(i => i.classList.remove('selected'));
1714
+ item.classList.add('selected');
1715
+ selectedPromptId = parseInt(item.dataset.promptId, 10);
1716
+ if (runBtn) runBtn.disabled = false;
1717
+ });
1718
+ });
1719
+
1720
+ // Set default button
1721
+ list.querySelectorAll('.summarize-prompt-default-btn').forEach(btn => {
1722
+ btn.addEventListener('click', async (e) => {
1723
+ e.stopPropagation();
1724
+ const id = parseInt(btn.dataset.id, 10);
1725
+ // Clear isDefault on all prompts, then set on the chosen one
1726
+ const allPrompts = await db.getAll('summaryPrompts');
1727
+ for (const p of allPrompts) {
1728
+ if (p.isDefault && p.id !== id) {
1729
+ p.isDefault = 0;
1730
+ await db.put('summaryPrompts', p);
1731
+ }
1732
+ }
1733
+ const item = await db.get('summaryPrompts', id);
1734
+ if (item) {
1735
+ item.isDefault = 1;
1736
+ await db.put('summaryPrompts', item);
1737
+ }
1738
+ const prompts = await loadSummaryPrompts();
1739
+ renderSummaryPromptList(prompts);
1740
+ showToast('DEFAULT SET', 'Summary prompt set as default');
1741
+ });
1742
+ });
1743
+
1744
+ // Edit button
1745
+ list.querySelectorAll('.summarize-prompt-edit-btn').forEach(btn => {
1746
+ btn.addEventListener('click', (e) => {
1747
+ e.stopPropagation();
1748
+ const id = parseInt(btn.dataset.id, 10);
1749
+ const p = summaryPromptsCache.find(x => x.id === id);
1750
+ if (!p) return;
1751
+ const nameInput = document.getElementById('summarize-custom-name');
1752
+ const promptInput = document.getElementById('summarize-custom-prompt');
1753
+ const form = document.getElementById('summarize-custom-form');
1754
+ nameInput.value = p.name;
1755
+ promptInput.value = p.prompt;
1756
+ form.classList.remove('hidden');
1757
+ form.dataset.editId = id;
1758
+ });
1759
+ });
1760
+
1761
+ // Delete button
1762
+ list.querySelectorAll('.summarize-prompt-delete-btn').forEach(btn => {
1763
+ btn.addEventListener('click', async (e) => {
1764
+ e.stopPropagation();
1765
+ const id = parseInt(btn.dataset.id, 10);
1766
+ await db.del('summaryPrompts', id);
1767
+ const prompts = await loadSummaryPrompts();
1768
+ renderSummaryPromptList(prompts);
1769
+ showToast('DELETED', 'Prompt template removed');
1770
+ });
1771
+ });
1772
+
1773
+ // Auto-select the default prompt
1774
+ const defaultPrompt = prompts.find(p => p.isDefault);
1775
+ if (defaultPrompt) {
1776
+ const defaultItem = list.querySelector(`[data-prompt-id="${defaultPrompt.id}"]`);
1777
+ if (defaultItem) {
1778
+ defaultItem.classList.add('selected');
1779
+ selectedPromptId = defaultPrompt.id;
1780
+ if (runBtn) runBtn.disabled = false;
1781
+ }
1782
+ }
1783
+ }
1784
+
1785
+ async function openSummarizeModal() {
1786
+ const prompts = await loadSummaryPrompts();
1787
+ renderSummaryPromptList(prompts);
1788
+ // Reset custom form
1789
+ const form = document.getElementById('summarize-custom-form');
1790
+ form.classList.add('hidden');
1791
+ delete form.dataset.editId;
1792
+ document.getElementById('summarize-custom-name').value = '';
1793
+ document.getElementById('summarize-custom-prompt').value = '';
1794
+ document.getElementById('summarize-modal').classList.remove('hidden');
1795
+ }
1796
+
1797
+ async function runSummarize(promptId, customPrompt) {
1798
+ const modal = document.getElementById('summarize-modal');
1799
+ modal.classList.add('hidden');
1800
+ const btn = document.getElementById('ctrl-summarize');
1801
+ btn.disabled = true;
1802
+ btn.textContent = 'SUMMARIZING...';
1803
+
1804
+ try {
1805
+ // Build context client-side from IndexedDB
1806
+ const detail = await db.getSessionDetail(selectedSessionId);
1807
+ if (!detail) throw new Error('Session not found in local database');
1808
+
1809
+ // Build context string (same logic the server used)
1810
+ let context = `Project: ${detail.session.projectName || detail.session.projectPath || 'Unknown'}\n`;
1811
+ context += `Status: ${detail.session.status}\n`;
1812
+ context += `Started: ${new Date(detail.session.startedAt).toISOString()}\n`;
1813
+ if (detail.session.endedAt) context += `Ended: ${new Date(detail.session.endedAt).toISOString()}\n`;
1814
+ context += `\n--- PROMPTS ---\n`;
1815
+ for (const p of detail.prompts) {
1816
+ context += `[${new Date(p.timestamp).toISOString()}] ${p.text}\n\n`;
1817
+ }
1818
+ context += `\n--- TOOL CALLS ---\n`;
1819
+ for (const t of detail.tool_calls) {
1820
+ context += `[${new Date(t.timestamp).toISOString()}] ${t.toolName}: ${t.toolInputSummary || ''}\n`;
1821
+ }
1822
+ context += `\n--- RESPONSES ---\n`;
1823
+ for (const r of detail.responses) {
1824
+ context += `[${new Date(r.timestamp).toISOString()}] ${r.textExcerpt || ''}\n\n`;
1825
+ }
1826
+
1827
+ // Resolve prompt template
1828
+ let promptTemplate = customPrompt || '';
1829
+ if (!promptTemplate && promptId) {
1830
+ const tmpl = await db.get('summaryPrompts', promptId);
1831
+ if (tmpl) promptTemplate = tmpl.prompt;
1832
+ }
1833
+
1834
+ const resp = await fetch(`/api/sessions/${selectedSessionId}/summarize`, {
1835
+ method: 'POST',
1836
+ headers: { 'Content-Type': 'application/json' },
1837
+ body: JSON.stringify({ context, promptTemplate })
1838
+ });
1839
+ const data = await resp.json();
1840
+ if (data.ok) {
1841
+ const session = sessionsData.get(selectedSessionId);
1842
+ if (session) { session.archived = 1; session.summary = data.summary; }
1843
+ // Store summary in IndexedDB
1844
+ const s = await db.get('sessions', selectedSessionId);
1845
+ if (s) { s.summary = data.summary; s.archived = 1; await db.put('sessions', s); }
1846
+ const summaryEl = document.getElementById('summary-content');
1847
+ if (summaryEl) {
1848
+ summaryEl.innerHTML = `<div class="summary-text">${escapeHtml(data.summary).replace(/\n/g, '<br>')}</div>`;
1849
+ }
1850
+ document.querySelectorAll('.detail-tabs .tab').forEach(t => t.classList.remove('active'));
1851
+ document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
1852
+ const summaryTab = document.querySelector('.detail-tabs .tab[data-tab="summary"]');
1853
+ if (summaryTab) summaryTab.classList.add('active');
1854
+ document.getElementById('tab-summary').classList.add('active');
1855
+ btn.textContent = 'RE-SUMMARIZE';
1856
+ btn.disabled = false;
1857
+ showToast('SUMMARIZED', 'AI summary generated & session archived');
1858
+ } else {
1859
+ showToast('SUMMARIZE FAILED', data.error || 'Unknown error');
1860
+ btn.textContent = 'SUMMARIZE';
1861
+ btn.disabled = false;
1862
+ }
1863
+ } catch(err) {
1864
+ showToast('SUMMARIZE ERROR', err.message);
1865
+ btn.textContent = 'SUMMARIZE';
1866
+ btn.disabled = false;
1867
+ }
1868
+ }
1869
+
1870
+ // Summarize button → opens prompt selector modal
1871
+ document.getElementById('ctrl-summarize').addEventListener('click', (e) => {
1872
+ e.stopPropagation();
1873
+ if (!selectedSessionId) return;
1874
+ openSummarizeModal();
1875
+ });
1876
+
1877
+ // Modal close
1878
+ document.getElementById('summarize-modal-close')?.addEventListener('click', () => {
1879
+ document.getElementById('summarize-modal').classList.add('hidden');
1880
+ });
1881
+ document.getElementById('summarize-cancel')?.addEventListener('click', () => {
1882
+ document.getElementById('summarize-modal').classList.add('hidden');
1883
+ });
1884
+ document.getElementById('summarize-modal')?.addEventListener('click', (e) => {
1885
+ if (e.target.id === 'summarize-modal') document.getElementById('summarize-modal').classList.add('hidden');
1886
+ });
1887
+
1888
+ // Run summarize with selected prompt
1889
+ document.getElementById('summarize-run')?.addEventListener('click', () => {
1890
+ if (selectedPromptId) runSummarize(selectedPromptId, null);
1891
+ });
1892
+
1893
+ // Toggle custom prompt form
1894
+ document.getElementById('summarize-toggle-custom')?.addEventListener('click', () => {
1895
+ const form = document.getElementById('summarize-custom-form');
1896
+ form.classList.toggle('hidden');
1897
+ if (!form.classList.contains('hidden')) {
1898
+ delete form.dataset.editId;
1899
+ document.getElementById('summarize-custom-name').value = '';
1900
+ document.getElementById('summarize-custom-prompt').value = '';
1901
+ }
1902
+ });
1903
+
1904
+ // Save as template
1905
+ document.getElementById('summarize-save-template')?.addEventListener('click', async () => {
1906
+ const nameInput = document.getElementById('summarize-custom-name');
1907
+ const promptInput = document.getElementById('summarize-custom-prompt');
1908
+ const form = document.getElementById('summarize-custom-form');
1909
+ const name = nameInput.value.trim();
1910
+ const prompt = promptInput.value.trim();
1911
+ if (!name || !prompt) { showToast('MISSING', 'Name and prompt are required'); return; }
1912
+
1913
+ const editId = form.dataset.editId;
1914
+ if (editId) {
1915
+ const item = await db.get('summaryPrompts', parseInt(editId, 10));
1916
+ if (item) {
1917
+ item.name = name;
1918
+ item.prompt = prompt;
1919
+ item.updatedAt = Date.now();
1920
+ await db.put('summaryPrompts', item);
1921
+ }
1922
+ showToast('UPDATED', 'Template updated');
1923
+ } else {
1924
+ const now = Date.now();
1925
+ await db.put('summaryPrompts', { name, prompt, isDefault: 0, createdAt: now, updatedAt: now });
1926
+ showToast('SAVED', 'Template saved');
1927
+ }
1928
+ form.classList.add('hidden');
1929
+ delete form.dataset.editId;
1930
+ nameInput.value = '';
1931
+ promptInput.value = '';
1932
+ const prompts = await loadSummaryPrompts();
1933
+ renderSummaryPromptList(prompts);
1934
+ });
1935
+
1936
+ // Use once (custom prompt without saving)
1937
+ document.getElementById('summarize-use-once')?.addEventListener('click', () => {
1938
+ const prompt = document.getElementById('summarize-custom-prompt').value.trim();
1939
+ if (!prompt) { showToast('MISSING', 'Write a prompt first'); return; }
1940
+ runSummarize(null, prompt);
1941
+ });
1942
+
1943
+
1944
+ // Save note
1945
+ document.getElementById('save-note').addEventListener('click', async () => {
1946
+ if (!selectedSessionId) return;
1947
+ const textarea = document.getElementById('note-textarea');
1948
+ const text = textarea.value.trim();
1949
+ if (!text) return;
1950
+ try {
1951
+ await db.addNote(selectedSessionId, text);
1952
+ textarea.value = '';
1953
+ loadNotes(selectedSessionId);
1954
+ showToast('NOTE SAVED', 'Note added successfully');
1955
+ } catch(e) {
1956
+ showToast('NOTE ERROR', e.message);
1957
+ }
1958
+ });
1959
+
1960
+ // Delete note (event delegation)
1961
+ document.getElementById('notes-list').addEventListener('click', async (e) => {
1962
+ const btn = e.target.closest('.note-delete');
1963
+ if (!btn || !selectedSessionId) return;
1964
+ const noteId = btn.dataset.noteId;
1965
+ try {
1966
+ await db.del('notes', Number(noteId));
1967
+ loadNotes(selectedSessionId);
1968
+ } catch(e) {
1969
+ showToast('DELETE ERROR', e.message);
1970
+ }
1971
+ });
1972
+
1973
+ // ---- Prompt Queue Handlers ----
1974
+
1975
+ // Collapsible queue panel toggle
1976
+ document.getElementById('terminal-queue-toggle')?.addEventListener('click', () => {
1977
+ const panel = document.getElementById('terminal-queue-panel');
1978
+ if (panel) {
1979
+ panel.classList.toggle('collapsed');
1980
+ // Refit terminal after toggle since container height changes
1981
+ import('./terminalManager.js').then(tm => {
1982
+ requestAnimationFrame(() => tm.refitTerminal());
1983
+ });
1984
+ }
1985
+ });
1986
+
1987
+
1988
+ // Add to Queue
1989
+ document.getElementById('queue-add-btn')?.addEventListener('click', async () => {
1990
+ if (!selectedSessionId) return;
1991
+ const textarea = document.getElementById('queue-textarea');
1992
+ const text = textarea.value.trim();
1993
+ if (!text) return;
1994
+ try {
1995
+ await db.addToQueue(selectedSessionId, text);
1996
+ textarea.value = '';
1997
+ loadQueue(selectedSessionId);
1998
+ showToast('QUEUED', 'Prompt added to queue');
1999
+ } catch(e) {
2000
+ showToast('QUEUE ERROR', e.message);
2001
+ }
2002
+ });
2003
+
2004
+ // Send prompt text to terminal as input
2005
+ async function sendToTerminal(text) {
2006
+ const tm = await import('./terminalManager.js');
2007
+ const { getWs } = await import('./wsClient.js');
2008
+ const ws = getWs();
2009
+ const terminalId = tm.getActiveTerminalId();
2010
+ if (!terminalId || !ws || ws.readyState !== 1) {
2011
+ showToast('TERMINAL', 'No active terminal connection');
2012
+ return false;
2013
+ }
2014
+ // Send the text + Enter to the terminal
2015
+ ws.send(JSON.stringify({ type: 'terminal_input', terminalId, data: text + '\n' }));
2016
+ return true;
2017
+ }
2018
+
2019
+ // Delete / Edit / Send queue items (event delegation)
2020
+ document.getElementById('queue-list')?.addEventListener('click', async (e) => {
2021
+ const delBtn = e.target.closest('.queue-delete');
2022
+ const editBtn = e.target.closest('.queue-edit');
2023
+ const sendBtn = e.target.closest('.queue-send');
2024
+ if (!selectedSessionId) return;
2025
+
2026
+ if (sendBtn) {
2027
+ const itemId = sendBtn.dataset.queueId;
2028
+ const itemEl = sendBtn.closest('.queue-item');
2029
+ const text = itemEl?.querySelector('.queue-text')?.textContent;
2030
+ if (text) {
2031
+ const sent = await sendToTerminal(text);
2032
+ if (sent) {
2033
+ // Remove from queue after sending
2034
+ try {
2035
+ await db.del('promptQueue', Number(itemId));
2036
+ loadQueue(selectedSessionId);
2037
+ } catch(e) {}
2038
+ showToast('SENT', 'Prompt sent to terminal');
2039
+ }
2040
+ }
2041
+ }
2042
+
2043
+ if (delBtn) {
2044
+ const itemId = delBtn.dataset.queueId;
2045
+ try {
2046
+ await db.del('promptQueue', Number(itemId));
2047
+ loadQueue(selectedSessionId);
2048
+ } catch(e) {
2049
+ showToast('DELETE ERROR', e.message);
2050
+ }
2051
+ }
2052
+
2053
+ if (editBtn) {
2054
+ const itemId = editBtn.dataset.queueId;
2055
+ const itemEl = editBtn.closest('.queue-item');
2056
+ const textEl = itemEl?.querySelector('.queue-text');
2057
+ if (!textEl) return;
2058
+ const currentText = textEl.textContent;
2059
+ // Inline edit: replace text with textarea
2060
+ const ta = document.createElement('textarea');
2061
+ ta.className = 'queue-edit-textarea';
2062
+ ta.value = currentText;
2063
+ ta.rows = 3;
2064
+ textEl.replaceWith(ta);
2065
+ ta.focus();
2066
+ editBtn.textContent = 'SAVE';
2067
+ editBtn.classList.add('saving');
2068
+
2069
+ const saveEdit = async () => {
2070
+ const newText = ta.value.trim();
2071
+ if (newText && newText !== currentText) {
2072
+ try {
2073
+ const existing = await db.get('promptQueue', Number(itemId));
2074
+ if (existing) {
2075
+ existing.text = newText;
2076
+ await db.put('promptQueue', existing);
2077
+ }
2078
+ } catch(e) {
2079
+ showToast('EDIT ERROR', e.message);
2080
+ }
2081
+ }
2082
+ loadQueue(selectedSessionId);
2083
+ };
2084
+
2085
+ editBtn.onclick = (ev) => { ev.stopPropagation(); saveEdit(); };
2086
+ ta.addEventListener('keydown', (ev) => {
2087
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); saveEdit(); }
2088
+ if (ev.key === 'Escape') loadQueue(selectedSessionId);
2089
+ });
2090
+ }
2091
+ });
2092
+
2093
+ // Enable drag-to-terminal: drop a queue item onto the terminal to send it
2094
+ document.getElementById('terminal-container')?.addEventListener('dragover', (e) => {
2095
+ if (e.dataTransfer.types.includes('text/queue-prompt')) {
2096
+ e.preventDefault();
2097
+ e.dataTransfer.dropEffect = 'copy';
2098
+ e.currentTarget.classList.add('drop-target');
2099
+ }
2100
+ });
2101
+ document.getElementById('terminal-container')?.addEventListener('dragleave', (e) => {
2102
+ e.currentTarget.classList.remove('drop-target');
2103
+ });
2104
+ document.getElementById('terminal-container')?.addEventListener('drop', async (e) => {
2105
+ e.preventDefault();
2106
+ e.currentTarget.classList.remove('drop-target');
2107
+ const text = e.dataTransfer.getData('text/queue-prompt');
2108
+ const itemId = e.dataTransfer.getData('text/queue-id');
2109
+ if (text) {
2110
+ const sent = await sendToTerminal(text);
2111
+ if (sent && itemId && selectedSessionId) {
2112
+ try {
2113
+ await db.del('promptQueue', Number(itemId));
2114
+ loadQueue(selectedSessionId);
2115
+ } catch(e) {}
2116
+ showToast('SENT', 'Prompt dropped into terminal');
2117
+ }
2118
+ }
2119
+ });
2120
+
2121
+ // Alert button
2122
+ document.getElementById('ctrl-alert').addEventListener('click', (e) => {
2123
+ e.stopPropagation();
2124
+ if (!selectedSessionId) return;
2125
+ document.getElementById('alert-modal').classList.remove('hidden');
2126
+ });
2127
+
2128
+ document.getElementById('alert-cancel').addEventListener('click', () => {
2129
+ document.getElementById('alert-modal').classList.add('hidden');
2130
+ });
2131
+
2132
+ document.getElementById('alert-confirm').addEventListener('click', async () => {
2133
+ document.getElementById('alert-modal').classList.add('hidden');
2134
+ if (!selectedSessionId) return;
2135
+ const minutes = parseInt(document.getElementById('alert-minutes').value, 10);
2136
+ if (!minutes || minutes < 1) return;
2137
+ try {
2138
+ const now = Date.now();
2139
+ await db.put('alerts', {
2140
+ sessionId: selectedSessionId,
2141
+ thresholdMs: minutes * 60000,
2142
+ createdAt: now,
2143
+ triggerAt: now + minutes * 60000
2144
+ });
2145
+ showToast('ALERT SET', `Will alert after ${minutes} minutes`);
2146
+ soundManager.play('click');
2147
+ } catch(e) {
2148
+ showToast('ALERT ERROR', e.message);
2149
+ }
2150
+ });
2151
+
2152
+ // ---- Session Title Save (blur/Enter) ----
2153
+ const detailTitleInput = document.getElementById('detail-title');
2154
+ if (detailTitleInput) {
2155
+ let titleSaveTimeout = null;
2156
+ async function saveTitle() {
2157
+ const sessionId = detailTitleInput.dataset.sessionId;
2158
+ const title = detailTitleInput.value.trim();
2159
+ if (!sessionId) return;
2160
+ const session = sessionsData.get(sessionId);
2161
+ if (session) session.title = title;
2162
+ // Update card title display
2163
+ const card = document.querySelector(`.session-card[data-session-id="${sessionId}"] .card-title`);
2164
+ if (card) {
2165
+ card.textContent = title;
2166
+ card.style.display = title ? '' : 'none';
2167
+ }
2168
+ try {
2169
+ await fetch(`/api/sessions/${sessionId}/title`, {
2170
+ method: 'PUT',
2171
+ headers: { 'Content-Type': 'application/json' },
2172
+ body: JSON.stringify({ title })
2173
+ });
2174
+ // Also update IndexedDB
2175
+ const s = await db.get('sessions', sessionId);
2176
+ if (s) { s.title = title; await db.put('sessions', s); }
2177
+ } catch(e) {
2178
+ // silent fail
2179
+ }
2180
+ }
2181
+ detailTitleInput.addEventListener('blur', saveTitle);
2182
+ detailTitleInput.addEventListener('keydown', (e) => {
2183
+ if (e.key === 'Enter') {
2184
+ e.preventDefault();
2185
+ detailTitleInput.blur();
2186
+ }
2187
+ });
2188
+ }
2189
+
2190
+ // ---- Session Label Save (blur/Enter) ----
2191
+ const detailLabelInput = document.getElementById('detail-label');
2192
+
2193
+ async function saveDetailLabel() {
2194
+ if (!detailLabelInput) return;
2195
+ const sessionId = detailLabelInput.dataset.sessionId;
2196
+ const label = detailLabelInput.value.trim();
2197
+ if (!sessionId) return;
2198
+ const session = sessionsData.get(sessionId);
2199
+ if (session) session.label = label;
2200
+ // Update card label badge
2201
+ const badge = document.querySelector(`.session-card[data-session-id="${sessionId}"] .card-label-badge`);
2202
+ if (badge) {
2203
+ badge.textContent = label;
2204
+ badge.style.display = label ? '' : 'none';
2205
+ }
2206
+ // Update chip active states
2207
+ updateDetailLabelChipStates(label);
2208
+ try {
2209
+ await fetch(`/api/sessions/${sessionId}/label`, {
2210
+ method: 'PUT',
2211
+ headers: { 'Content-Type': 'application/json' },
2212
+ body: JSON.stringify({ label })
2213
+ });
2214
+ // Also update IndexedDB
2215
+ const s = await db.get('sessions', sessionId);
2216
+ if (s) { s.label = label; await db.put('sessions', s); }
2217
+ } catch(e) {
2218
+ // silent fail
2219
+ }
2220
+ // Save to global label list for suggestions
2221
+ if (label) {
2222
+ try {
2223
+ const labels = JSON.parse(localStorage.getItem('sessionLabels') || '[]');
2224
+ const idx = labels.indexOf(label);
2225
+ if (idx !== -1) labels.splice(idx, 1);
2226
+ labels.unshift(label);
2227
+ localStorage.setItem('sessionLabels', JSON.stringify(labels.slice(0, 30)));
2228
+ } catch(_) {}
2229
+ }
2230
+ }
2231
+
2232
+ if (detailLabelInput) {
2233
+ detailLabelInput.addEventListener('blur', saveDetailLabel);
2234
+ detailLabelInput.addEventListener('keydown', (e) => {
2235
+ if (e.key === 'Enter') {
2236
+ e.preventDefault();
2237
+ detailLabelInput.blur();
2238
+ }
2239
+ });
2240
+ // Populate datalist when input is focused
2241
+ detailLabelInput.addEventListener('focus', () => {
2242
+ const dl = document.getElementById('detail-label-suggestions');
2243
+ if (!dl) return;
2244
+ dl.innerHTML = '';
2245
+ try {
2246
+ const labels = JSON.parse(localStorage.getItem('sessionLabels') || '[]');
2247
+ for (const lbl of labels) {
2248
+ const opt = document.createElement('option');
2249
+ opt.value = lbl;
2250
+ dl.appendChild(opt);
2251
+ }
2252
+ } catch(_) {}
2253
+ });
2254
+ }
2255
+
2256
+ // ---- Detail Label Quick-Select Chips ----
2257
+ const DETAIL_LABEL_COLORS = { ONEOFF: '#ff9100', HEAVY: '#ff3355', IMPORTANT: '#aa66ff' };
2258
+ const DETAIL_LABEL_ICONS = { ONEOFF: '\u{1F525}', HEAVY: '\u2605', IMPORTANT: '\u26A0' };
2259
+
2260
+ function updateDetailLabelChipStates(currentLabel) {
2261
+ const container = document.getElementById('detail-label-chips');
2262
+ if (!container) return;
2263
+ container.querySelectorAll('.detail-label-chip').forEach(chip => {
2264
+ const isActive = chip.dataset.label === currentLabel;
2265
+ chip.classList.toggle('active', isActive);
2266
+ const color = DETAIL_LABEL_COLORS[chip.dataset.label];
2267
+ if (isActive && color) {
2268
+ chip.style.borderColor = color;
2269
+ chip.style.color = color;
2270
+ chip.style.background = color + '1a';
2271
+ } else {
2272
+ chip.style.borderColor = '';
2273
+ chip.style.color = '';
2274
+ chip.style.background = '';
2275
+ }
2276
+ });
2277
+ }
2278
+
2279
+ function populateDetailLabelChips(session) {
2280
+ const container = document.getElementById('detail-label-chips');
2281
+ if (!container) return;
2282
+ container.innerHTML = '';
2283
+
2284
+ // Built-in labels
2285
+ const builtins = ['ONEOFF', 'HEAVY', 'IMPORTANT'];
2286
+
2287
+ // Also include saved custom labels (up to 5)
2288
+ let customLabels = [];
2289
+ try {
2290
+ const saved = JSON.parse(localStorage.getItem('sessionLabels') || '[]');
2291
+ customLabels = saved.filter(l => !builtins.includes(l)).slice(0, 5);
2292
+ } catch(_) {}
2293
+
2294
+ const allLabels = [...builtins, ...customLabels];
2295
+ const currentLabel = session.label || '';
2296
+
2297
+ for (const label of allLabels) {
2298
+ const chip = document.createElement('button');
2299
+ chip.className = 'detail-label-chip';
2300
+ chip.dataset.label = label;
2301
+
2302
+ const icon = DETAIL_LABEL_ICONS[label];
2303
+ if (icon) {
2304
+ const iconSpan = document.createElement('span');
2305
+ iconSpan.className = 'chip-icon';
2306
+ iconSpan.textContent = icon;
2307
+ chip.appendChild(iconSpan);
2308
+ }
2309
+
2310
+ const text = document.createElement('span');
2311
+ text.textContent = label;
2312
+ chip.appendChild(text);
2313
+
2314
+ // Highlight if active
2315
+ const isActive = label === currentLabel;
2316
+ if (isActive) {
2317
+ chip.classList.add('active');
2318
+ const color = DETAIL_LABEL_COLORS[label];
2319
+ if (color) {
2320
+ chip.style.borderColor = color;
2321
+ chip.style.color = color;
2322
+ chip.style.background = color + '1a';
2323
+ }
2324
+ }
2325
+
2326
+ chip.addEventListener('click', () => {
2327
+ if (!detailLabelInput) return;
2328
+ // Toggle: clicking active chip clears the label
2329
+ if (detailLabelInput.value.trim() === label) {
2330
+ detailLabelInput.value = '';
2331
+ } else {
2332
+ detailLabelInput.value = label;
2333
+ }
2334
+ saveDetailLabel();
2335
+ });
2336
+
2337
+ container.appendChild(chip);
2338
+ }
2339
+ }
2340
+
2341
+ // ---- Detail Panel Resize Handle ----
2342
+ {
2343
+ const handle = document.getElementById('detail-resize-handle');
2344
+ const panel = document.getElementById('session-detail-panel');
2345
+ let startX = 0;
2346
+ let startWidth = 0;
2347
+
2348
+ handle.addEventListener('mousedown', (e) => {
2349
+ e.preventDefault();
2350
+ startX = e.clientX;
2351
+ startWidth = panel.offsetWidth;
2352
+ panel.classList.add('resizing');
2353
+ handle.classList.add('active');
2354
+ document.addEventListener('mousemove', onMouseMove);
2355
+ document.addEventListener('mouseup', onMouseUp);
2356
+ });
2357
+
2358
+ function onMouseMove(e) {
2359
+ const dx = startX - e.clientX;
2360
+ const newWidth = Math.max(320, Math.min(window.innerWidth * 0.95, startWidth + dx));
2361
+ panel.style.width = newWidth + 'px';
2362
+ }
2363
+
2364
+ function onMouseUp() {
2365
+ panel.classList.remove('resizing');
2366
+ handle.classList.remove('active');
2367
+ document.removeEventListener('mousemove', onMouseMove);
2368
+ document.removeEventListener('mouseup', onMouseUp);
2369
+ // Persist width preference
2370
+ try { localStorage.setItem('detail-panel-width', panel.style.width); } catch(e) {}
2371
+ }
2372
+
2373
+ // Restore saved width
2374
+ try {
2375
+ const saved = localStorage.getItem('detail-panel-width');
2376
+ if (saved) panel.style.width = saved;
2377
+ } catch(e) {}
2378
+ }
2379
+
2380
+ // ---- Live Search Filter ----
2381
+ const liveSearchInput = document.getElementById('live-search');
2382
+ if (liveSearchInput) {
2383
+ liveSearchInput.addEventListener('input', () => {
2384
+ const query = liveSearchInput.value.toLowerCase().trim();
2385
+ const cards = document.querySelectorAll('.session-card');
2386
+ cards.forEach(card => {
2387
+ const sid = card.dataset.sessionId;
2388
+ const projectName = card.querySelector('.project-name')?.textContent?.toLowerCase() || '';
2389
+ const cardTitle = card.querySelector('.card-title')?.textContent?.toLowerCase() || '';
2390
+
2391
+ // Also search prompts and responses
2392
+ let matchInContent = false;
2393
+ if (query && sid) {
2394
+ const session = sessionsData.get(sid);
2395
+ if (session) {
2396
+ matchInContent = (session.promptHistory || []).some(p => p.text?.toLowerCase().includes(query))
2397
+ || (session.responseLog || []).some(r => r.text?.toLowerCase().includes(query));
2398
+ }
2399
+ }
2400
+
2401
+ if (!query || projectName.includes(query) || cardTitle.includes(query) || matchInContent) {
2402
+ card.classList.remove('filtered');
2403
+ } else {
2404
+ card.classList.add('filtered');
2405
+ }
2406
+ });
2407
+
2408
+ // If detail panel is open, highlight matches inside it
2409
+ if (query && selectedSessionId) {
2410
+ highlightInDetailPanel(query);
2411
+ } else {
2412
+ clearDetailHighlights();
2413
+ }
2414
+ });
2415
+ }
2416
+
2417
+ function highlightInDetailPanel(query) {
2418
+ clearDetailHighlights();
2419
+ if (!query) return;
2420
+
2421
+ const tabContents = ['detail-conversation', 'detail-activity-log'];
2422
+ let firstMatch = null;
2423
+ let matchTab = null;
2424
+
2425
+ for (const containerId of tabContents) {
2426
+ const container = document.getElementById(containerId);
2427
+ if (!container) continue;
2428
+
2429
+ const entries = container.querySelectorAll('.conv-entry, .activity-entry');
2430
+ for (const entry of entries) {
2431
+ const text = entry.textContent.toLowerCase();
2432
+ if (text.includes(query)) {
2433
+ entry.classList.add('search-highlight');
2434
+ if (!firstMatch) {
2435
+ firstMatch = entry;
2436
+ matchTab = containerId === 'detail-conversation' ? 'conversation' : 'activity';
2437
+ }
2438
+ }
2439
+ }
2440
+ }
2441
+
2442
+ // Switch to the tab with the first match and scroll to it
2443
+ if (firstMatch && matchTab) {
2444
+ const tabBtn = document.querySelector(`.detail-tabs .tab[data-tab="${matchTab}"]`);
2445
+ if (tabBtn && !tabBtn.classList.contains('active')) {
2446
+ document.querySelectorAll('.detail-tabs .tab').forEach(t => t.classList.remove('active'));
2447
+ document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
2448
+ tabBtn.classList.add('active');
2449
+ document.getElementById(`tab-${matchTab}`).classList.add('active');
2450
+ }
2451
+ firstMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
2452
+ }
2453
+ }
2454
+
2455
+ function clearDetailHighlights() {
2456
+ document.querySelectorAll('.search-highlight').forEach(el => el.classList.remove('search-highlight'));
2457
+ }
2458
+
2459
+ // ---- Archive All Ended Sessions ----
2460
+ export async function archiveAllEnded() {
2461
+ let count = 0;
2462
+ for (const [sessionId, session] of sessionsData) {
2463
+ if (session.status === 'ended' && !session.archived) {
2464
+ try {
2465
+ session.archived = 1;
2466
+ const s = await db.get('sessions', sessionId);
2467
+ if (s) { s.archived = 1; await db.put('sessions', s); }
2468
+ count++;
2469
+ } catch(e) { /* continue */ }
2470
+ }
2471
+ }
2472
+ if (count > 0) {
2473
+ showToast('ARCHIVED', `Archived ${count} ended session${count > 1 ? 's' : ''}`);
2474
+ } else {
2475
+ showToast('ARCHIVE', 'No ended sessions to archive');
2476
+ }
2477
+ }