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.
- package/README.md +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- 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">▼</span>
|
|
170
|
+
<span class="group-name">${group.name}</span>
|
|
171
|
+
<span class="group-count">0</span>
|
|
172
|
+
<button class="group-delete" title="Delete group">×</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') ? '▶' : '▼';
|
|
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">★</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">×</button>
|
|
566
|
+
<button class="pin-btn" title="Pin to top">▲</button>
|
|
567
|
+
<button class="summarize-card-btn" title="Summarize & Archive">⇩AI</button>
|
|
568
|
+
<button class="mute-btn" title="Mute sounds">♫</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 = '♫';
|
|
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">×</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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">★</button>
|
|
1701
|
+
<button class="summarize-prompt-edit-btn" data-id="${p.id}" title="Edit">✎</button>
|
|
1702
|
+
<button class="summarize-prompt-delete-btn" data-id="${p.id}" title="Delete">×</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
|
+
}
|