ai-agent-session-center 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. package/server/wsManager.js +83 -0
@@ -0,0 +1,383 @@
1
+ const SVG_NS = 'http://www.w3.org/2000/svg';
2
+
3
+ /**
4
+ * Draw a bar chart as SVG inside the given container.
5
+ * data = [{label, value, color?}]
6
+ */
7
+ export function drawBarChart(container, data, options = {}) {
8
+ const {
9
+ horizontal = true,
10
+ maxBars = 15,
11
+ barHeight = 20,
12
+ gap = 4,
13
+ showLabels = true,
14
+ showValues = true,
15
+ } = options;
16
+
17
+ container.innerHTML = '';
18
+ const items = data.slice(0, maxBars);
19
+ if (items.length === 0) return;
20
+
21
+ const maxVal = Math.max(...items.map(d => d.value), 1);
22
+ const labelWidth = showLabels ? 100 : 0;
23
+ const valueWidth = showValues ? 50 : 0;
24
+
25
+ if (horizontal) {
26
+ const totalHeight = items.length * (barHeight + gap) - gap;
27
+ const svgWidth = container.clientWidth || 400;
28
+ const barAreaWidth = svgWidth - labelWidth - valueWidth - 10;
29
+
30
+ const svg = createSvg(svgWidth, totalHeight);
31
+
32
+ items.forEach((d, i) => {
33
+ const y = i * (barHeight + gap);
34
+ const barW = Math.max(1, (d.value / maxVal) * barAreaWidth);
35
+ const color = d.color || 'var(--accent-cyan, #00e5ff)';
36
+
37
+ if (showLabels) {
38
+ const text = createSvgEl('text', {
39
+ x: labelWidth - 6,
40
+ y: y + barHeight / 2 + 4,
41
+ fill: '#8892b0',
42
+ 'font-size': '11',
43
+ 'text-anchor': 'end',
44
+ });
45
+ text.textContent = d.label;
46
+ svg.appendChild(text);
47
+ }
48
+
49
+ const rect = createSvgEl('rect', {
50
+ x: labelWidth,
51
+ y,
52
+ width: barW,
53
+ height: barHeight,
54
+ rx: 3,
55
+ fill: color,
56
+ opacity: 0.85,
57
+ });
58
+ svg.appendChild(rect);
59
+
60
+ if (showValues) {
61
+ const valText = createSvgEl('text', {
62
+ x: labelWidth + barW + 6,
63
+ y: y + barHeight / 2 + 4,
64
+ fill: '#ccd6f6',
65
+ 'font-size': '11',
66
+ });
67
+ valText.textContent = formatNumber(d.value);
68
+ svg.appendChild(valText);
69
+ }
70
+ });
71
+
72
+ container.appendChild(svg);
73
+ } else {
74
+ // Vertical bars
75
+ const svgHeight = 200;
76
+ const svgWidth = container.clientWidth || 400;
77
+ const barWidth = Math.max(4, (svgWidth - 40) / items.length - gap);
78
+ const chartHeight = svgHeight - 30;
79
+
80
+ const svg = createSvg(svgWidth, svgHeight);
81
+
82
+ items.forEach((d, i) => {
83
+ const x = 20 + i * (barWidth + gap);
84
+ const barH = Math.max(1, (d.value / maxVal) * chartHeight);
85
+ const y = chartHeight - barH;
86
+ const color = d.color || 'var(--accent-cyan, #00e5ff)';
87
+
88
+ const rect = createSvgEl('rect', {
89
+ x,
90
+ y,
91
+ width: barWidth,
92
+ height: barH,
93
+ rx: 2,
94
+ fill: color,
95
+ opacity: 0.85,
96
+ });
97
+ svg.appendChild(rect);
98
+
99
+ if (showLabels) {
100
+ const text = createSvgEl('text', {
101
+ x: x + barWidth / 2,
102
+ y: svgHeight - 4,
103
+ fill: '#8892b0',
104
+ 'font-size': '9',
105
+ 'text-anchor': 'middle',
106
+ });
107
+ text.textContent = d.label;
108
+ svg.appendChild(text);
109
+ }
110
+
111
+ if (showValues) {
112
+ const valText = createSvgEl('text', {
113
+ x: x + barWidth / 2,
114
+ y: y - 4,
115
+ fill: '#ccd6f6',
116
+ 'font-size': '9',
117
+ 'text-anchor': 'middle',
118
+ });
119
+ valText.textContent = formatNumber(d.value);
120
+ svg.appendChild(valText);
121
+ }
122
+ });
123
+
124
+ container.appendChild(svg);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Draw a line chart as SVG inside the given container.
130
+ * data = [{label, value}]
131
+ */
132
+ export function drawLineChart(container, data, options = {}) {
133
+ const {
134
+ color = '#00e5ff',
135
+ areaFill = false,
136
+ showDots = true,
137
+ height = 250,
138
+ } = options;
139
+
140
+ container.innerHTML = '';
141
+ if (data.length === 0) return;
142
+
143
+ const svgWidth = container.clientWidth || 500;
144
+ const paddingLeft = 45;
145
+ const paddingRight = 15;
146
+ const paddingTop = 15;
147
+ const paddingBottom = 30;
148
+ const chartW = svgWidth - paddingLeft - paddingRight;
149
+ const chartH = height - paddingTop - paddingBottom;
150
+
151
+ const maxVal = Math.max(...data.map(d => d.value), 1);
152
+ const svg = createSvg(svgWidth, height);
153
+
154
+ // Y-axis labels (5 ticks)
155
+ for (let i = 0; i <= 4; i++) {
156
+ const val = (maxVal / 4) * i;
157
+ const y = paddingTop + chartH - (i / 4) * chartH;
158
+ const text = createSvgEl('text', {
159
+ x: paddingLeft - 6,
160
+ y: y + 4,
161
+ fill: '#8892b0',
162
+ 'font-size': '10',
163
+ 'text-anchor': 'end',
164
+ });
165
+ text.textContent = formatNumber(val);
166
+ svg.appendChild(text);
167
+
168
+ // Grid line
169
+ const line = createSvgEl('line', {
170
+ x1: paddingLeft,
171
+ y1: y,
172
+ x2: svgWidth - paddingRight,
173
+ y2: y,
174
+ stroke: '#1e2a4a',
175
+ 'stroke-width': 1,
176
+ });
177
+ svg.appendChild(line);
178
+ }
179
+
180
+ // Build points
181
+ const points = data.map((d, i) => {
182
+ const x = paddingLeft + (i / Math.max(data.length - 1, 1)) * chartW;
183
+ const y = paddingTop + chartH - (d.value / maxVal) * chartH;
184
+ return { x, y, label: d.label, value: d.value };
185
+ });
186
+
187
+ // Area fill
188
+ if (areaFill && points.length > 1) {
189
+ const areaPoints = [
190
+ `${points[0].x},${paddingTop + chartH}`,
191
+ ...points.map(p => `${p.x},${p.y}`),
192
+ `${points[points.length - 1].x},${paddingTop + chartH}`,
193
+ ].join(' ');
194
+ const polygon = createSvgEl('polygon', {
195
+ points: areaPoints,
196
+ fill: color,
197
+ opacity: 0.1,
198
+ });
199
+ svg.appendChild(polygon);
200
+ }
201
+
202
+ // Line
203
+ if (points.length > 1) {
204
+ const polyline = createSvgEl('polyline', {
205
+ points: points.map(p => `${p.x},${p.y}`).join(' '),
206
+ fill: 'none',
207
+ stroke: color,
208
+ 'stroke-width': 2,
209
+ 'stroke-linejoin': 'round',
210
+ });
211
+ svg.appendChild(polyline);
212
+ }
213
+
214
+ // Dots
215
+ if (showDots) {
216
+ points.forEach(p => {
217
+ const circle = createSvgEl('circle', {
218
+ cx: p.x,
219
+ cy: p.y,
220
+ r: 3,
221
+ fill: color,
222
+ });
223
+ circle.addEventListener('mouseenter', (e) => showTooltip(`${p.label}: ${formatNumber(p.value)}`, e.pageX, e.pageY));
224
+ circle.addEventListener('mouseleave', hideTooltip);
225
+ svg.appendChild(circle);
226
+ });
227
+ }
228
+
229
+ // X-axis labels (show up to ~10 evenly spaced)
230
+ const labelStep = Math.max(1, Math.floor(data.length / 10));
231
+ points.forEach((p, i) => {
232
+ if (i % labelStep !== 0 && i !== points.length - 1) return;
233
+ const text = createSvgEl('text', {
234
+ x: p.x,
235
+ y: height - 6,
236
+ fill: '#8892b0',
237
+ 'font-size': '9',
238
+ 'text-anchor': 'middle',
239
+ });
240
+ text.textContent = p.label;
241
+ svg.appendChild(text);
242
+ });
243
+
244
+ container.appendChild(svg);
245
+ }
246
+
247
+ /**
248
+ * Draw a heatmap grid using CSS grid.
249
+ * data = [{row, col, value}] where row=0-6 (Mon-Sun), col=0-23 (hours)
250
+ */
251
+ export function drawHeatmapGrid(container, data, options = {}) {
252
+ const {
253
+ cellSize = 14,
254
+ gap = 2,
255
+ colorMin = '#12122a',
256
+ colorMax = '#00ff88',
257
+ } = options;
258
+
259
+ container.innerHTML = '';
260
+
261
+ const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
262
+ const maxVal = Math.max(...data.map(d => d.value), 1);
263
+
264
+ // Build value lookup
265
+ const valueMap = new Map();
266
+ data.forEach(d => valueMap.set(`${d.row}-${d.col}`, d.value));
267
+
268
+ const grid = document.createElement('div');
269
+ grid.style.display = 'grid';
270
+ grid.style.gridTemplateColumns = `40px repeat(24, ${cellSize}px)`;
271
+ grid.style.gridTemplateRows = `${cellSize}px repeat(7, ${cellSize}px)`;
272
+ grid.style.gap = `${gap}px`;
273
+ grid.style.alignItems = 'center';
274
+
275
+ // Top-left empty corner
276
+ const corner = document.createElement('div');
277
+ grid.appendChild(corner);
278
+
279
+ // Hour labels (top row)
280
+ for (let h = 0; h < 24; h++) {
281
+ const lbl = document.createElement('div');
282
+ lbl.textContent = h;
283
+ lbl.style.fontSize = '9px';
284
+ lbl.style.color = '#8892b0';
285
+ lbl.style.textAlign = 'center';
286
+ grid.appendChild(lbl);
287
+ }
288
+
289
+ // Rows
290
+ for (let r = 0; r < 7; r++) {
291
+ // Day label
292
+ const dayLbl = document.createElement('div');
293
+ dayLbl.textContent = dayLabels[r];
294
+ dayLbl.style.fontSize = '10px';
295
+ dayLbl.style.color = '#8892b0';
296
+ dayLbl.style.textAlign = 'right';
297
+ dayLbl.style.paddingRight = '4px';
298
+ grid.appendChild(dayLbl);
299
+
300
+ for (let c = 0; c < 24; c++) {
301
+ const val = valueMap.get(`${r}-${c}`) || 0;
302
+ const cell = document.createElement('div');
303
+ cell.style.width = `${cellSize}px`;
304
+ cell.style.height = `${cellSize}px`;
305
+ cell.style.borderRadius = '2px';
306
+ cell.style.backgroundColor = interpolateColor(val, 0, maxVal, colorMin, colorMax);
307
+ cell.style.cursor = 'pointer';
308
+ cell.addEventListener('mouseenter', (e) => showTooltip(`${dayLabels[r]} ${c}:00 - ${val}`, e.pageX, e.pageY));
309
+ cell.addEventListener('mouseleave', hideTooltip);
310
+ grid.appendChild(cell);
311
+ }
312
+ }
313
+
314
+ container.appendChild(grid);
315
+ }
316
+
317
+ /**
318
+ * Format a number for display: 0->"0", 999->"999", 1000->"1.0k", 1500->"1.5k", 1000000->"1.0M"
319
+ */
320
+ export function formatNumber(n) {
321
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
322
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
323
+ return String(Math.round(n));
324
+ }
325
+
326
+ /**
327
+ * Show a tooltip div at the given page coordinates.
328
+ */
329
+ export function showTooltip(text, x, y) {
330
+ let tip = document.querySelector('.chart-tooltip');
331
+ if (!tip) {
332
+ tip = document.createElement('div');
333
+ tip.className = 'chart-tooltip';
334
+ document.body.appendChild(tip);
335
+ }
336
+ tip.textContent = text;
337
+ tip.style.left = `${x + 10}px`;
338
+ tip.style.top = `${y - 28}px`;
339
+ tip.style.display = 'block';
340
+ }
341
+
342
+ /**
343
+ * Hide the tooltip.
344
+ */
345
+ export function hideTooltip() {
346
+ const tip = document.querySelector('.chart-tooltip');
347
+ if (tip) tip.style.display = 'none';
348
+ }
349
+
350
+ /**
351
+ * Interpolate a hex color between colorStart and colorEnd based on value's position in [min, max].
352
+ */
353
+ export function interpolateColor(value, min, max, colorStart = '#12122a', colorEnd = '#00ff88') {
354
+ const t = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)));
355
+ const r1 = parseInt(colorStart.slice(1, 3), 16);
356
+ const g1 = parseInt(colorStart.slice(3, 5), 16);
357
+ const b1 = parseInt(colorStart.slice(5, 7), 16);
358
+ const r2 = parseInt(colorEnd.slice(1, 3), 16);
359
+ const g2 = parseInt(colorEnd.slice(3, 5), 16);
360
+ const b2 = parseInt(colorEnd.slice(5, 7), 16);
361
+ const r = Math.round(r1 + (r2 - r1) * t);
362
+ const g = Math.round(g1 + (g2 - g1) * t);
363
+ const b = Math.round(b1 + (b2 - b1) * t);
364
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
365
+ }
366
+
367
+ // -- Internal helpers --
368
+
369
+ function createSvg(width, height) {
370
+ const svg = document.createElementNS(SVG_NS, 'svg');
371
+ svg.setAttribute('width', width);
372
+ svg.setAttribute('height', height);
373
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
374
+ return svg;
375
+ }
376
+
377
+ function createSvgEl(tag, attrs) {
378
+ const el = document.createElementNS(SVG_NS, tag);
379
+ for (const [k, v] of Object.entries(attrs)) {
380
+ el.setAttribute(k, v);
381
+ }
382
+ return el;
383
+ }
@@ -0,0 +1,298 @@
1
+ import * as db from './browserDb.js';
2
+
3
+ let currentPage = 1;
4
+ let debounceTimer = null;
5
+
6
+ export async function init() {
7
+ // Fetch projects for dropdown
8
+ const projects = await db.getDistinctProjects();
9
+ const select = document.getElementById('history-project-filter');
10
+ projects.forEach(p => {
11
+ const opt = document.createElement('option');
12
+ opt.value = p.project_path;
13
+ opt.textContent = p.project_name;
14
+ select.appendChild(opt);
15
+ });
16
+
17
+ // Wire up filter change events
18
+ document.getElementById('search-input').addEventListener('input', () => {
19
+ clearTimeout(debounceTimer);
20
+ debounceTimer = setTimeout(() => { currentPage = 1; loadSessions(); }, 300);
21
+ });
22
+ ['history-project-filter', 'history-status-filter', 'history-date-from', 'history-date-to', 'history-sort-by'].forEach(id => {
23
+ document.getElementById(id).addEventListener('change', () => { currentPage = 1; loadSessions(); });
24
+ });
25
+ document.getElementById('history-sort-dir').addEventListener('click', (e) => {
26
+ e.target.textContent = e.target.textContent === 'DESC' ? 'ASC' : 'DESC';
27
+ currentPage = 1;
28
+ loadSessions();
29
+ });
30
+ }
31
+
32
+ export async function refresh() {
33
+ await loadSessions();
34
+ }
35
+
36
+ async function loadSessions() {
37
+ const query = document.getElementById('search-input').value || undefined;
38
+ const project = document.getElementById('history-project-filter').value || undefined;
39
+ const statusVal = document.getElementById('history-status-filter').value;
40
+ let status, archived;
41
+ if (statusVal === 'archived') {
42
+ archived = 'true';
43
+ } else if (statusVal) {
44
+ status = statusVal;
45
+ }
46
+ const dateFromRaw = document.getElementById('history-date-from').value;
47
+ const dateFrom = dateFromRaw ? new Date(dateFromRaw).getTime() : undefined;
48
+ const dateToRaw = document.getElementById('history-date-to').value;
49
+ const dateTo = dateToRaw ? new Date(dateToRaw + 'T23:59:59').getTime() : undefined;
50
+ const sortByMap = { date: 'startedAt', duration: 'endedAt', prompts: 'totalPrompts', tools: 'totalToolCalls' };
51
+ const rawSort = document.getElementById('history-sort-by').value;
52
+ const sortBy = sortByMap[rawSort] || 'startedAt';
53
+ const sortDir = document.getElementById('history-sort-dir').textContent.toLowerCase();
54
+ const page = currentPage;
55
+ const pageSize = 50;
56
+
57
+ const result = await db.searchSessions({ query, project, status, dateFrom, dateTo, archived, sortBy, sortDir, page, pageSize });
58
+
59
+ // Map camelCase IndexedDB fields to snake_case expected by renderResults
60
+ const mapped = result.sessions.map(s => ({
61
+ id: s.id,
62
+ title: s.title || '',
63
+ project_name: s.projectName || '',
64
+ started_at: s.startedAt,
65
+ ended_at: s.endedAt,
66
+ status: s.status,
67
+ total_prompts: s.totalPrompts || 0,
68
+ total_tool_calls: s.totalToolCalls || 0,
69
+ git_branch: s.gitBranch || '',
70
+ }));
71
+ renderResults(mapped, result.total, result.page, result.pageSize);
72
+ }
73
+
74
+ function renderResults(sessions, total, page, pageSize) {
75
+ const container = document.getElementById('history-results');
76
+ if (sessions.length === 0) {
77
+ container.innerHTML = '<div class="tab-empty">No sessions found</div>';
78
+ document.getElementById('history-pagination').innerHTML = '';
79
+ return;
80
+ }
81
+
82
+ container.innerHTML = sessions.map(s => {
83
+ const duration = s.ended_at
84
+ ? formatDuration(s.ended_at - s.started_at)
85
+ : formatDuration(Date.now() - s.started_at);
86
+ const date = new Date(s.started_at).toLocaleString('en-US', {
87
+ year: 'numeric', month: 'short', day: 'numeric',
88
+ hour: '2-digit', minute: '2-digit', hour12: false,
89
+ });
90
+ return `<div class="history-row" data-session-id="${s.id}">
91
+ <span class="history-title">${escapeHtml(s.title)}</span>
92
+ <span class="history-project">${escapeHtml(s.project_name)}</span>
93
+ <span class="history-date">${date}</span>
94
+ <span class="history-duration">${duration}</span>
95
+ <span class="history-status ${s.status}">${s.status.toUpperCase()}</span>
96
+ <span class="history-prompts">${s.total_prompts} prompts</span>
97
+ <span class="history-tools">${s.total_tool_calls} tools</span>
98
+ <span class="history-branch">${escapeHtml(s.git_branch || '')}</span>
99
+ <button class="history-delete" title="Delete session">&times;</button>
100
+ </div>`;
101
+ }).join('');
102
+
103
+ // Click handler for rows
104
+ container.querySelectorAll('.history-row').forEach(row => {
105
+ row.addEventListener('click', (e) => {
106
+ if (e.target.closest('.history-delete')) return;
107
+ openHistoryDetail(row.dataset.sessionId);
108
+ });
109
+ });
110
+
111
+ // Delete button handler
112
+ container.querySelectorAll('.history-delete').forEach(btn => {
113
+ btn.addEventListener('click', async (e) => {
114
+ e.stopPropagation();
115
+ const row = btn.closest('.history-row');
116
+ const sid = row.dataset.sessionId;
117
+ if (!confirm('Delete this session from history? This cannot be undone.')) return;
118
+ await db.deleteSession(sid);
119
+ row.style.transition = 'opacity 0.3s';
120
+ row.style.opacity = '0';
121
+ setTimeout(() => {
122
+ row.remove();
123
+ // Reload if the page is now empty
124
+ if (container.querySelectorAll('.history-row').length === 0) loadSessions();
125
+ }, 300);
126
+ });
127
+ });
128
+
129
+ // Pagination
130
+ renderPagination(total, page, pageSize);
131
+ }
132
+
133
+ function renderPagination(total, page, pageSize) {
134
+ const container = document.getElementById('history-pagination');
135
+ const totalPages = Math.ceil(total / pageSize);
136
+ if (totalPages <= 1) {
137
+ container.innerHTML = '';
138
+ return;
139
+ }
140
+
141
+ const buttons = [];
142
+
143
+ // Previous button
144
+ buttons.push(
145
+ `<button class="page-btn${page <= 1 ? ' disabled' : ''}" data-page="${page - 1}"${page <= 1 ? ' disabled' : ''}>&laquo; Prev</button>`
146
+ );
147
+
148
+ // Page number buttons: show first, last, current +/- 2, with ellipses
149
+ const range = [];
150
+ for (let i = 1; i <= totalPages; i++) {
151
+ if (i === 1 || i === totalPages || (i >= page - 2 && i <= page + 2)) {
152
+ range.push(i);
153
+ }
154
+ }
155
+
156
+ let lastShown = 0;
157
+ range.forEach(i => {
158
+ if (lastShown && i - lastShown > 1) {
159
+ buttons.push('<span class="page-ellipsis">...</span>');
160
+ }
161
+ buttons.push(
162
+ `<button class="page-btn${i === page ? ' active' : ''}" data-page="${i}">${i}</button>`
163
+ );
164
+ lastShown = i;
165
+ });
166
+
167
+ // Next button
168
+ buttons.push(
169
+ `<button class="page-btn${page >= totalPages ? ' disabled' : ''}" data-page="${page + 1}"${page >= totalPages ? ' disabled' : ''}>Next &raquo;</button>`
170
+ );
171
+
172
+ container.innerHTML = buttons.join('');
173
+
174
+ // Wire click handlers
175
+ container.querySelectorAll('.page-btn:not(.disabled)').forEach(btn => {
176
+ btn.addEventListener('click', () => {
177
+ currentPage = parseInt(btn.dataset.page, 10);
178
+ loadSessions();
179
+ });
180
+ });
181
+ }
182
+
183
+ async function openHistoryDetail(sessionId) {
184
+ const data = await db.getSessionDetail(sessionId);
185
+ if (!data) return;
186
+ const s = {
187
+ session: data.session,
188
+ prompts: data.prompts,
189
+ responses: (data.responses || []).map(r => ({ ...r, text: r.textExcerpt || r.text || '' })),
190
+ tools: (data.tool_calls || []).map(t => ({ tool: t.toolName, input: t.toolInputSummary || '', timestamp: t.timestamp })),
191
+ events: data.events,
192
+ notes: data.notes,
193
+ };
194
+
195
+ // Populate header
196
+ const sess = s.session || s;
197
+ document.getElementById('detail-project-name').textContent = sess.projectName || sess.project_name || '';
198
+ const badge = document.getElementById('detail-status-badge');
199
+ badge.textContent = (sess.status || '').toUpperCase();
200
+ badge.className = `status-badge ${sess.status}`;
201
+ document.getElementById('detail-model').textContent = sess.model || '';
202
+ const startedAt = sess.startedAt || sess.started_at;
203
+ const endedAt = sess.endedAt || sess.ended_at;
204
+ document.getElementById('detail-duration').textContent = endedAt
205
+ ? formatDuration(endedAt - startedAt)
206
+ : formatDuration(Date.now() - startedAt);
207
+
208
+ // Character model selector + preview
209
+ const charSelect = document.getElementById('detail-char-model');
210
+ if (charSelect) {
211
+ charSelect.value = sess.characterModel || sess.character_model || '';
212
+ charSelect.dataset.sessionId = sessionId;
213
+ }
214
+ // Mini preview with session's accent color
215
+ const previewEl = document.getElementById('detail-char-preview');
216
+ if (previewEl) {
217
+ const model = sess.characterModel || sess.character_model || 'robot';
218
+ const accentColor = sess.accentColor || sess.accent_color || 'var(--accent-cyan)';
219
+ import('./robotManager.js').then(rm => {
220
+ previewEl.innerHTML = '';
221
+ const mini = document.createElement('div');
222
+ mini.className = `css-robot char-${model}`;
223
+ mini.dataset.status = sess.status || 'ended';
224
+ mini.style.setProperty('--robot-color', accentColor);
225
+ if (rm._getTemplates) {
226
+ const templates = rm._getTemplates();
227
+ if (templates[model]) mini.innerHTML = templates[model](accentColor);
228
+ }
229
+ previewEl.appendChild(mini);
230
+ });
231
+ }
232
+
233
+ // Conversation tab (interleaved prompts + responses, newest first)
234
+ const convoEl = document.getElementById('detail-conversation');
235
+ const allEntries = [
236
+ ...(s.prompts || []).map(p => ({ type: 'prompt', timestamp: p.timestamp, text: p.text })),
237
+ ...(s.responses || []).map(r => ({ type: 'response', timestamp: r.timestamp, text: r.text })),
238
+ ].sort((a, b) => a.timestamp - b.timestamp);
239
+ convoEl.innerHTML = allEntries.map(e => {
240
+ const cls = e.type === 'prompt' ? 'prompt-entry' : 'response-entry';
241
+ return `<div class="${cls}">
242
+ <span class="${e.type}-time">${formatTime(e.timestamp)}</span>
243
+ <div class="${e.type}-text">${escapeHtml(e.text)}</div>
244
+ </div>`;
245
+ }).join('');
246
+
247
+ // Activity tab (merged tool calls + events)
248
+ const histItems = [];
249
+ for (const t of (s.tools || [])) {
250
+ histItems.push({ kind: 'tool', tool: t.tool, input: t.input, timestamp: t.timestamp });
251
+ }
252
+ for (const e of (s.events || [])) {
253
+ histItems.push({ kind: 'event', type: e.type, detail: e.detail, timestamp: e.timestamp });
254
+ }
255
+ histItems.sort((a, b) => b.timestamp - a.timestamp);
256
+ const actEl = document.getElementById('detail-activity-log');
257
+ if (actEl) {
258
+ actEl.innerHTML = histItems.length > 0
259
+ ? histItems.map(item => {
260
+ if (item.kind === 'tool') {
261
+ return `<div class="activity-entry activity-tool">
262
+ <span class="activity-time">${formatTime(item.timestamp)}</span>
263
+ <span class="activity-badge activity-badge-tool">${escapeHtml(item.tool)}</span>
264
+ <span class="activity-detail">${escapeHtml(item.input)}</span>
265
+ </div>`;
266
+ } else {
267
+ return `<div class="activity-entry activity-event">
268
+ <span class="activity-time">${formatTime(item.timestamp)}</span>
269
+ <span class="activity-badge activity-badge-event">${escapeHtml(item.type)}</span>
270
+ <span class="activity-detail">${escapeHtml(item.detail)}</span>
271
+ </div>`;
272
+ }
273
+ }).join('')
274
+ : '<div class="tab-empty">No activity recorded</div>';
275
+ }
276
+
277
+ document.getElementById('session-detail-overlay').classList.remove('hidden');
278
+ }
279
+
280
+ // -- Helpers (same as sessionPanel.js) --
281
+
282
+ function formatDuration(ms) {
283
+ const s = Math.floor(ms / 1000);
284
+ const m = Math.floor(s / 60);
285
+ const h = Math.floor(m / 60);
286
+ if (h > 0) return `${h}h ${m % 60}m`;
287
+ if (m > 0) return `${m}m ${s % 60}s`;
288
+ return `${s}s`;
289
+ }
290
+
291
+ function formatTime(ts) {
292
+ return new Date(ts).toLocaleTimeString('en-US', { hour12: false });
293
+ }
294
+
295
+ function escapeHtml(str) {
296
+ if (!str) return '';
297
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
298
+ }