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,391 @@
1
+ // terminalManager.js — Frontend terminal module using xterm.js
2
+ // Manages terminal lifecycle, I/O relay through WebSocket, and tab attachment.
3
+ // Uses canvas renderer, Unicode11, WebLinks, and FitAddon (same stack as AWS/Azure Cloud Shell).
4
+
5
+ let ws = null;
6
+ let activeTerminal = null; // { terminalId, term, fitAddon, resizeObserver }
7
+ let terminalSessions = {}; // terminalId -> sessionId mapping
8
+ let terminalThemes = {}; // terminalId -> theme name
9
+ let pendingOutput = {}; // terminalId -> [base64Data] — buffer output before terminal is ready
10
+ let isFullscreen = false;
11
+
12
+ const THEMES = {
13
+ default: {
14
+ background: '#0a0a1a', foreground: '#e0e0e0', cursor: '#0a0a1a', cursorAccent: '#0a0a1a',
15
+ selectionBackground: 'rgba(0,229,255,0.3)', selectionForeground: '#ffffff',
16
+ black: '#0a0a1a', red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c',
17
+ blue: '#6272a4', magenta: '#ff79c6', cyan: '#00e5ff', white: '#e0e0e0',
18
+ brightBlack: '#555555', brightRed: '#ff6e6e', brightGreen: '#69ff94',
19
+ brightYellow: '#ffffa5', brightBlue: '#d6acff', brightMagenta: '#ff92df',
20
+ brightCyan: '#a4ffff', brightWhite: '#ffffff',
21
+ },
22
+ dark: {
23
+ background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#1e1e1e', cursorAccent: '#1e1e1e',
24
+ selectionBackground: 'rgba(255,255,255,0.15)', selectionForeground: '#ffffff',
25
+ black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510',
26
+ blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5',
27
+ brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b',
28
+ brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6',
29
+ brightCyan: '#29b8db', brightWhite: '#ffffff',
30
+ },
31
+ monokai: {
32
+ background: '#272822', foreground: '#f8f8f2', cursor: '#272822', cursorAccent: '#272822',
33
+ selectionBackground: 'rgba(73,72,62,0.6)', selectionForeground: '#ffffff',
34
+ black: '#272822', red: '#f92672', green: '#a6e22e', yellow: '#f4bf75',
35
+ blue: '#66d9ef', magenta: '#ae81ff', cyan: '#a1efe4', white: '#f8f8f2',
36
+ brightBlack: '#75715e', brightRed: '#f92672', brightGreen: '#a6e22e',
37
+ brightYellow: '#f4bf75', brightBlue: '#66d9ef', brightMagenta: '#ae81ff',
38
+ brightCyan: '#a1efe4', brightWhite: '#f9f8f5',
39
+ },
40
+ dracula: {
41
+ background: '#282a36', foreground: '#f8f8f2', cursor: '#282a36', cursorAccent: '#282a36',
42
+ selectionBackground: 'rgba(68,71,90,0.6)', selectionForeground: '#ffffff',
43
+ black: '#21222c', red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c',
44
+ blue: '#bd93f9', magenta: '#ff79c6', cyan: '#8be9fd', white: '#f8f8f2',
45
+ brightBlack: '#6272a4', brightRed: '#ff6e6e', brightGreen: '#69ff94',
46
+ brightYellow: '#ffffa5', brightBlue: '#d6acff', brightMagenta: '#ff92df',
47
+ brightCyan: '#a4ffff', brightWhite: '#ffffff',
48
+ },
49
+ 'solarized-dark': {
50
+ background: '#002b36', foreground: '#839496', cursor: '#002b36', cursorAccent: '#002b36',
51
+ selectionBackground: 'rgba(7,54,66,0.6)', selectionForeground: '#93a1a1',
52
+ black: '#073642', red: '#dc322f', green: '#859900', yellow: '#b58900',
53
+ blue: '#268bd2', magenta: '#d33682', cyan: '#2aa198', white: '#eee8d5',
54
+ brightBlack: '#586e75', brightRed: '#cb4b16', brightGreen: '#586e75',
55
+ brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4',
56
+ brightCyan: '#93a1a1', brightWhite: '#fdf6e3',
57
+ },
58
+ nord: {
59
+ background: '#2e3440', foreground: '#d8dee9', cursor: '#2e3440', cursorAccent: '#2e3440',
60
+ selectionBackground: 'rgba(67,76,94,0.6)', selectionForeground: '#eceff4',
61
+ black: '#3b4252', red: '#bf616a', green: '#a3be8c', yellow: '#ebcb8b',
62
+ blue: '#81a1c1', magenta: '#b48ead', cyan: '#88c0d0', white: '#e5e9f0',
63
+ brightBlack: '#4c566a', brightRed: '#bf616a', brightGreen: '#a3be8c',
64
+ brightYellow: '#ebcb8b', brightBlue: '#81a1c1', brightMagenta: '#b48ead',
65
+ brightCyan: '#8fbcbb', brightWhite: '#eceff4',
66
+ },
67
+ 'github-dark': {
68
+ background: '#0d1117', foreground: '#c9d1d9', cursor: '#0d1117', cursorAccent: '#0d1117',
69
+ selectionBackground: 'rgba(56,139,253,0.25)', selectionForeground: '#ffffff',
70
+ black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
71
+ blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4',
72
+ brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364',
73
+ brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
74
+ brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
75
+ },
76
+ };
77
+
78
+ export function setWs(websocket) {
79
+ ws = websocket;
80
+ // Re-subscribe active terminal after WS reconnect so output keeps flowing
81
+ if (activeTerminal && ws && ws.readyState === 1) {
82
+ ws.send(JSON.stringify({ type: 'terminal_subscribe', terminalId: activeTerminal.terminalId }));
83
+ }
84
+ }
85
+
86
+ export function setTerminalTheme(terminalId, themeName) {
87
+ terminalThemes[terminalId] = themeName;
88
+ }
89
+
90
+ function getTheme(terminalId) {
91
+ const name = terminalThemes[terminalId] || 'default';
92
+ return THEMES[name] || THEMES.default;
93
+ }
94
+
95
+ function sendResize(terminalId, cols, rows) {
96
+ if (ws && ws.readyState === 1 && cols > 0 && rows > 0) {
97
+ ws.send(JSON.stringify({ type: 'terminal_resize', terminalId, cols, rows }));
98
+ }
99
+ }
100
+
101
+ export function initTerminal(terminalId) {
102
+ detachTerminal();
103
+
104
+ const container = document.getElementById('terminal-container');
105
+ if (!container) return;
106
+ container.innerHTML = '';
107
+
108
+ // Subscribe early so output is buffered while we wait for container dimensions
109
+ if (ws && ws.readyState === 1) {
110
+ ws.send(JSON.stringify({ type: 'terminal_subscribe', terminalId }));
111
+ }
112
+
113
+ // Defer setup until container has real dimensions so fitAddon
114
+ // can calculate correct cols/rows.
115
+ function setupWhenReady(retries) {
116
+ if (container.offsetWidth > 0 && container.offsetHeight > 0) {
117
+ doSetup();
118
+ } else if (retries > 0) {
119
+ requestAnimationFrame(() => setTimeout(() => setupWhenReady(retries - 1), 50));
120
+ }
121
+ }
122
+
123
+ function doSetup() {
124
+ const term = new Terminal({
125
+ cursorBlink: false,
126
+ cursorStyle: 'bar',
127
+ fontSize: 14,
128
+ fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Menlo', monospace",
129
+ fontWeight: '400',
130
+ fontWeightBold: '700',
131
+ lineHeight: 1.15,
132
+ letterSpacing: 0,
133
+ theme: getTheme(terminalId),
134
+ allowProposedApi: true,
135
+ scrollback: 10000,
136
+ convertEol: false,
137
+ windowsMode: false,
138
+ drawBoldTextInBrightColors: true,
139
+ minimumContrastRatio: 1,
140
+ });
141
+
142
+ // Load FitAddon
143
+ const fitAddon = new FitAddon.FitAddon();
144
+ term.loadAddon(fitAddon);
145
+
146
+ // Load Unicode11 for proper wide character / emoji rendering
147
+ try {
148
+ const unicode11 = new Unicode11Addon.Unicode11Addon();
149
+ term.loadAddon(unicode11);
150
+ term.unicode.activeVersion = '11';
151
+ } catch (e) {
152
+ console.warn('[terminal] Unicode11 addon not available:', e.message);
153
+ }
154
+
155
+ // Load WebLinks for clickable URLs
156
+ try {
157
+ const webLinks = new WebLinksAddon.WebLinksAddon();
158
+ term.loadAddon(webLinks);
159
+ } catch (e) {
160
+ console.warn('[terminal] WebLinks addon not available:', e.message);
161
+ }
162
+
163
+ term.open(container);
164
+
165
+ // Canvas renderer (default) — same as AWS/Azure Cloud Shell.
166
+ // WebGL addon removed: it caused black screens, context loss on app switch,
167
+ // and required forced refresh hacks. Canvas is stable and performant enough.
168
+
169
+ // Container already has dimensions at this point — fit immediately
170
+ fitAddon.fit();
171
+ sendResize(terminalId, term.cols, term.rows);
172
+
173
+ // Send keystrokes to server
174
+ term.onData((data) => {
175
+ if (ws && ws.readyState === 1) {
176
+ ws.send(JSON.stringify({ type: 'terminal_input', terminalId, data }));
177
+ }
178
+ });
179
+
180
+ // Also handle binary data (for special keys)
181
+ term.onBinary((data) => {
182
+ if (ws && ws.readyState === 1) {
183
+ ws.send(JSON.stringify({ type: 'terminal_input', terminalId, data }));
184
+ }
185
+ });
186
+
187
+ // Handle resize — debounce to avoid flooding
188
+ let resizeTimer = null;
189
+ const resizeObserver = new ResizeObserver(() => {
190
+ clearTimeout(resizeTimer);
191
+ resizeTimer = setTimeout(() => {
192
+ fitAddon.fit();
193
+ sendResize(terminalId, term.cols, term.rows);
194
+ }, 50);
195
+ });
196
+ resizeObserver.observe(container);
197
+
198
+ activeTerminal = { terminalId, term, fitAddon, resizeObserver };
199
+
200
+ // Flush any buffered output
201
+ if (pendingOutput[terminalId]) {
202
+ for (const data of pendingOutput[terminalId]) {
203
+ const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
204
+ term.write(bytes);
205
+ }
206
+ delete pendingOutput[terminalId];
207
+ }
208
+
209
+ updateStatus('Connected', 'connected');
210
+ term.focus();
211
+ }
212
+
213
+ // Start polling — up to ~2s (40 × 50ms)
214
+ setupWhenReady(40);
215
+ }
216
+
217
+ export function onTerminalOutput(terminalId, base64Data) {
218
+ if (activeTerminal && activeTerminal.terminalId === terminalId) {
219
+ const bytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
220
+ activeTerminal.term.write(bytes);
221
+ } else {
222
+ // Buffer output for when terminal attaches
223
+ if (!pendingOutput[terminalId]) pendingOutput[terminalId] = [];
224
+ pendingOutput[terminalId].push(base64Data);
225
+ // Limit buffer to last 500 chunks
226
+ if (pendingOutput[terminalId].length > 500) pendingOutput[terminalId].shift();
227
+ }
228
+ }
229
+
230
+ export function onTerminalReady(terminalId) {
231
+ if (activeTerminal && activeTerminal.terminalId === terminalId) {
232
+ updateStatus('Terminal ready', 'connected');
233
+ // Re-fit and sync size now that server shell is ready
234
+ requestAnimationFrame(() => {
235
+ if (activeTerminal && activeTerminal.fitAddon) {
236
+ activeTerminal.fitAddon.fit();
237
+ sendResize(terminalId, activeTerminal.term.cols, activeTerminal.term.rows);
238
+ }
239
+ });
240
+ }
241
+ }
242
+
243
+ export function onTerminalClosed(terminalId, reason) {
244
+ if (activeTerminal && activeTerminal.terminalId === terminalId) {
245
+ activeTerminal.term.write(`\r\n\x1b[31m--- Terminal ${reason || 'closed'} ---\x1b[0m\r\n`);
246
+ updateStatus(`Disconnected (${reason || 'closed'})`, 'disconnected');
247
+ }
248
+ }
249
+
250
+ export function attachToSession(sessionId, terminalId) {
251
+ if (!terminalId) return;
252
+ terminalSessions[terminalId] = sessionId;
253
+ initTerminal(terminalId);
254
+ }
255
+
256
+ export function detachTerminal() {
257
+ if (isFullscreen) exitFullscreen();
258
+ if (activeTerminal) {
259
+ if (activeTerminal.resizeObserver) {
260
+ activeTerminal.resizeObserver.disconnect();
261
+ }
262
+ activeTerminal.term.dispose();
263
+ activeTerminal = null;
264
+ }
265
+ const container = document.getElementById('terminal-container');
266
+ if (container) container.innerHTML = '';
267
+ }
268
+
269
+ export function getActiveTerminalId() {
270
+ return activeTerminal ? activeTerminal.terminalId : null;
271
+ }
272
+
273
+ export function refitTerminal() {
274
+ if (activeTerminal && activeTerminal.fitAddon) {
275
+ activeTerminal.fitAddon.fit();
276
+ sendResize(activeTerminal.terminalId, activeTerminal.term.cols, activeTerminal.term.rows);
277
+ }
278
+ }
279
+
280
+ function getContainer() {
281
+ return isFullscreen
282
+ ? document.getElementById('terminal-fullscreen-container')
283
+ : document.getElementById('terminal-container');
284
+ }
285
+
286
+ export function toggleFullscreen() {
287
+ if (isFullscreen) {
288
+ exitFullscreen();
289
+ } else {
290
+ enterFullscreen();
291
+ }
292
+ }
293
+
294
+ export function enterFullscreen() {
295
+ if (isFullscreen || !activeTerminal) return;
296
+ isFullscreen = true;
297
+
298
+ const overlay = document.getElementById('terminal-fullscreen-overlay');
299
+ const fsContainer = document.getElementById('terminal-fullscreen-container');
300
+ if (!overlay || !fsContainer) return;
301
+
302
+ // Move the .xterm element into fullscreen container
303
+ const xtermEl = activeTerminal.term.element;
304
+ if (xtermEl) {
305
+ fsContainer.appendChild(xtermEl);
306
+ }
307
+
308
+ overlay.classList.remove('hidden');
309
+
310
+ // Observe fullscreen container for resize (e.g. window resize while fullscreen)
311
+ if (activeTerminal.resizeObserver) {
312
+ activeTerminal.resizeObserver.observe(fsContainer);
313
+ }
314
+
315
+ // Refit after DOM move
316
+ requestAnimationFrame(() => {
317
+ if (activeTerminal && activeTerminal.fitAddon) {
318
+ activeTerminal.fitAddon.fit();
319
+ sendResize(activeTerminal.terminalId, activeTerminal.term.cols, activeTerminal.term.rows);
320
+ activeTerminal.term.focus();
321
+ }
322
+ });
323
+ }
324
+
325
+ export function exitFullscreen() {
326
+ if (!isFullscreen) return;
327
+ isFullscreen = false;
328
+
329
+ const overlay = document.getElementById('terminal-fullscreen-overlay');
330
+ const fsContainer = document.getElementById('terminal-fullscreen-container');
331
+ const inlineContainer = document.getElementById('terminal-container');
332
+ if (!overlay || !inlineContainer) return;
333
+
334
+ overlay.classList.add('hidden');
335
+
336
+ // Stop observing fullscreen container
337
+ if (activeTerminal && activeTerminal.resizeObserver && fsContainer) {
338
+ activeTerminal.resizeObserver.unobserve(fsContainer);
339
+ }
340
+
341
+ // Move the .xterm element back to inline container
342
+ if (activeTerminal) {
343
+ const xtermEl = activeTerminal.term.element;
344
+ if (xtermEl) {
345
+ inlineContainer.appendChild(xtermEl);
346
+ }
347
+ // Refit after DOM move
348
+ requestAnimationFrame(() => {
349
+ if (activeTerminal && activeTerminal.fitAddon) {
350
+ activeTerminal.fitAddon.fit();
351
+ sendResize(activeTerminal.terminalId, activeTerminal.term.cols, activeTerminal.term.rows);
352
+ activeTerminal.term.focus();
353
+ }
354
+ });
355
+ }
356
+ }
357
+
358
+ function updateStatus(text, className) {
359
+ const status = document.getElementById('terminal-status');
360
+ if (status) {
361
+ status.textContent = text;
362
+ status.className = `terminal-status ${className}`;
363
+ }
364
+ }
365
+
366
+ // Refit terminal after tab/app switch
367
+ document.addEventListener('visibilitychange', () => {
368
+ if (document.visibilityState === 'visible' && activeTerminal) {
369
+ requestAnimationFrame(() => {
370
+ if (!activeTerminal) return;
371
+ activeTerminal.fitAddon.fit();
372
+ });
373
+ }
374
+ });
375
+
376
+ // F11 toggles fullscreen (no Escape — it's a valid terminal key)
377
+ document.addEventListener('keydown', (e) => {
378
+ if (e.key === 'F11' && activeTerminal) {
379
+ e.preventDefault();
380
+ toggleFullscreen();
381
+ }
382
+ });
383
+
384
+ // Wire up fullscreen buttons when DOM is ready
385
+ document.addEventListener('DOMContentLoaded', () => {
386
+ const fsBtn = document.getElementById('terminal-fullscreen-btn');
387
+ if (fsBtn) fsBtn.addEventListener('click', () => toggleFullscreen());
388
+
389
+ const exitBtn = document.getElementById('terminal-fullscreen-exit');
390
+ if (exitBtn) exitBtn.addEventListener('click', () => exitFullscreen());
391
+ });
@@ -0,0 +1,278 @@
1
+ import { formatNumber, showTooltip, hideTooltip } from './chartUtils.js';
2
+ import { getDistinctProjects, getTimeline } from './browserDb.js';
3
+
4
+ const SVG_NS = 'http://www.w3.org/2000/svg';
5
+
6
+ let initialized = false;
7
+
8
+ export async function init() {
9
+ if (initialized) return;
10
+ initialized = true;
11
+
12
+ // Populate project filter from IndexedDB
13
+ const projects = await getDistinctProjects();
14
+ const select = document.getElementById('timeline-project-filter');
15
+ projects.forEach(p => {
16
+ const opt = document.createElement('option');
17
+ opt.value = p.project_path;
18
+ opt.textContent = p.project_name;
19
+ select.appendChild(opt);
20
+ });
21
+
22
+ // Set default date range (last 30 days)
23
+ const now = new Date();
24
+ const thirtyDaysAgo = new Date(now - 30 * 24 * 60 * 60 * 1000);
25
+ document.getElementById('timeline-date-from').value = thirtyDaysAgo.toISOString().split('T')[0];
26
+ document.getElementById('timeline-date-to').value = now.toISOString().split('T')[0];
27
+
28
+ // Wire controls
29
+ ['timeline-granularity', 'timeline-project-filter', 'timeline-date-from', 'timeline-date-to'].forEach(id => {
30
+ document.getElementById(id).addEventListener('change', loadTimeline);
31
+ });
32
+
33
+ await loadTimeline();
34
+ }
35
+
36
+ export async function refresh() {
37
+ await init();
38
+ await loadTimeline();
39
+ }
40
+
41
+ async function loadTimeline() {
42
+ const granularity = document.getElementById('timeline-granularity').value || 'day';
43
+ const project = document.getElementById('timeline-project-filter').value;
44
+ const dateFrom = document.getElementById('timeline-date-from').value;
45
+ const dateTo = document.getElementById('timeline-date-to').value;
46
+
47
+ const data = await getTimeline({
48
+ granularity,
49
+ project: project || undefined,
50
+ dateFrom: dateFrom ? new Date(dateFrom).getTime() : undefined,
51
+ dateTo: dateTo ? new Date(dateTo + 'T23:59:59').getTime() : undefined,
52
+ });
53
+ const buckets = data.buckets || [];
54
+
55
+ const container = document.getElementById('timeline-chart');
56
+ container.innerHTML = '';
57
+
58
+ if (buckets.length === 0) {
59
+ container.innerHTML = '<div class="tab-empty">No data for this period</div>';
60
+ return;
61
+ }
62
+
63
+ renderTimelineChart(container, buckets, granularity);
64
+ }
65
+
66
+ function formatTimeLabel(bucket, granularity) {
67
+ const ts = bucket.period || bucket.timestamp || bucket.date || bucket.label;
68
+ if (!ts) return '';
69
+
70
+ // Week format: "2026-W06"
71
+ if (granularity === 'week') {
72
+ const weekMatch = String(ts).match(/^(\d{4})-W(\d{1,2})$/);
73
+ if (weekMatch) {
74
+ const year = parseInt(weekMatch[1], 10);
75
+ const week = parseInt(weekMatch[2], 10);
76
+ const jan1 = new Date(year, 0, 1);
77
+ const dayOffset = (week - 1) * 7 - jan1.getDay() + 1;
78
+ const weekStart = new Date(year, 0, 1 + dayOffset);
79
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
80
+ return months[weekStart.getMonth()] + ' ' + weekStart.getDate();
81
+ }
82
+ }
83
+
84
+ // Hour format: "2026-02-10 14:00"
85
+ if (granularity === 'hour' && typeof ts === 'string') {
86
+ const hourMatch = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):(\d{2})$/);
87
+ if (hourMatch) {
88
+ const date = new Date(hourMatch[1] + 'T' + hourMatch[2] + ':' + hourMatch[3] + ':00');
89
+ if (!isNaN(date.getTime())) {
90
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
91
+ return months[date.getMonth()] + ' ' + date.getDate() + ' ' + date.getHours().toString().padStart(2, '0') + ':00';
92
+ }
93
+ }
94
+ }
95
+
96
+ // Day format: "2026-02-10"
97
+ if (typeof ts === 'string') {
98
+ const date = new Date(ts + (ts.includes('T') ? '' : 'T00:00:00'));
99
+ if (!isNaN(date.getTime())) {
100
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
101
+ return months[date.getMonth()] + ' ' + date.getDate();
102
+ }
103
+ return ts;
104
+ }
105
+
106
+ // Numeric timestamp
107
+ const date = new Date(ts);
108
+ if (!isNaN(date.getTime())) {
109
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
110
+ return months[date.getMonth()] + ' ' + date.getDate();
111
+ }
112
+ return String(ts);
113
+ }
114
+
115
+ function renderTimelineChart(container, buckets, granularity) {
116
+ const svgWidth = container.clientWidth || 700;
117
+ const paddingLeft = 50;
118
+ const paddingRight = 15;
119
+ const paddingTop = 15;
120
+ const paddingBottom = granularity === 'hour' ? 70 : 50;
121
+ const svgHeight = 300 + (granularity === 'hour' ? 20 : 0);
122
+ const chartW = svgWidth - paddingLeft - paddingRight;
123
+ const chartH = svgHeight - paddingTop - paddingBottom;
124
+
125
+ // Max across all individual bar values (grouped, not stacked)
126
+ const maxVal = Math.max(
127
+ ...buckets.map(b => Math.max(
128
+ b.session_count || 0,
129
+ b.prompt_count || 0,
130
+ b.tool_call_count || 0
131
+ )),
132
+ 1
133
+ );
134
+
135
+ const svg = createEl('svg', {
136
+ width: '100%',
137
+ height: svgHeight,
138
+ viewBox: `0 0 ${svgWidth} ${svgHeight}`,
139
+ preserveAspectRatio: 'xMidYMid meet',
140
+ });
141
+
142
+ // Y-axis grid lines and labels (5 ticks)
143
+ for (let i = 0; i <= 4; i++) {
144
+ const val = (maxVal / 4) * i;
145
+ const y = paddingTop + chartH - (i / 4) * chartH;
146
+
147
+ const text = createEl('text', {
148
+ x: paddingLeft - 8,
149
+ y: y + 4,
150
+ fill: '#8892b0',
151
+ 'font-size': '10',
152
+ 'text-anchor': 'end',
153
+ });
154
+ text.textContent = formatNumber(val);
155
+ svg.appendChild(text);
156
+
157
+ const line = createEl('line', {
158
+ x1: paddingLeft,
159
+ y1: y,
160
+ x2: svgWidth - paddingRight,
161
+ y2: y,
162
+ stroke: '#1e2a4a',
163
+ 'stroke-width': 1,
164
+ });
165
+ svg.appendChild(line);
166
+ }
167
+
168
+ // Grouped bars: 3 bars per bucket
169
+ const groupCount = buckets.length;
170
+ const groupWidth = chartW / groupCount;
171
+ const barGap = 1;
172
+ const barWidth = Math.max(2, (groupWidth - 4 * barGap) / 3);
173
+
174
+ const colors = {
175
+ session: '#00e5ff',
176
+ prompt: '#00ff88',
177
+ tool: '#ff9800',
178
+ };
179
+
180
+ buckets.forEach((bucket, i) => {
181
+ const groupX = paddingLeft + i * groupWidth;
182
+ const sessions = bucket.session_count || 0;
183
+ const prompts = bucket.prompt_count || 0;
184
+ const tools = bucket.tool_call_count || 0;
185
+
186
+ const bars = [
187
+ { value: sessions, color: colors.session, label: 'Sessions' },
188
+ { value: prompts, color: colors.prompt, label: 'Prompts' },
189
+ { value: tools, color: colors.tool, label: 'Tool Calls' },
190
+ ];
191
+
192
+ const barsStartX = groupX + (groupWidth - 3 * barWidth - 2 * barGap) / 2;
193
+
194
+ bars.forEach((bar, j) => {
195
+ const barH = Math.max(0, (bar.value / maxVal) * chartH);
196
+ const x = barsStartX + j * (barWidth + barGap);
197
+ const y = paddingTop + chartH - barH;
198
+
199
+ if (barH > 0) {
200
+ const rect = createEl('rect', {
201
+ x, y,
202
+ width: barWidth,
203
+ height: barH,
204
+ rx: 2,
205
+ fill: bar.color,
206
+ opacity: 0.85,
207
+ });
208
+ rect.addEventListener('mouseenter', (e) => {
209
+ rect.setAttribute('opacity', '1');
210
+ showTooltip(
211
+ `${bar.label}: ${bar.value}\nSessions: ${sessions} | Prompts: ${prompts} | Tools: ${tools}`,
212
+ e.pageX, e.pageY
213
+ );
214
+ });
215
+ rect.addEventListener('mouseleave', () => {
216
+ rect.setAttribute('opacity', '0.85');
217
+ hideTooltip();
218
+ });
219
+ svg.appendChild(rect);
220
+ }
221
+ });
222
+
223
+ // X-axis label (show subset to avoid overlap)
224
+ const maxLabels = granularity === 'hour' ? 12 : granularity === 'week' ? 12 : 15;
225
+ const labelStep = Math.max(1, Math.ceil(groupCount / maxLabels));
226
+ if (i % labelStep === 0 || i === groupCount - 1) {
227
+ const label = formatTimeLabel(bucket, granularity);
228
+ const lx = groupX + groupWidth / 2;
229
+ const ly = paddingTop + chartH + 14;
230
+ // Rotate labels when there are many buckets to prevent overlap
231
+ const shouldRotate = groupCount > 10 || granularity === 'hour';
232
+ const text = createEl('text', {
233
+ x: lx,
234
+ y: ly,
235
+ fill: '#8892b0',
236
+ 'font-size': '9',
237
+ 'text-anchor': shouldRotate ? 'end' : 'middle',
238
+ ...(shouldRotate ? { transform: `rotate(-40 ${lx} ${ly})` } : {}),
239
+ });
240
+ text.textContent = label;
241
+ svg.appendChild(text);
242
+ }
243
+ });
244
+
245
+ // Legend
246
+ const legendY = svgHeight - 12;
247
+ const legendItems = [
248
+ { label: 'Sessions', color: colors.session },
249
+ { label: 'Prompts', color: colors.prompt },
250
+ { label: 'Tool Calls', color: colors.tool },
251
+ ];
252
+ legendItems.forEach((item, i) => {
253
+ const lx = paddingLeft + i * 100;
254
+ const rect = createEl('rect', {
255
+ x: lx, y: legendY - 8,
256
+ width: 8, height: 8,
257
+ rx: 1, fill: item.color,
258
+ opacity: 0.85,
259
+ });
260
+ svg.appendChild(rect);
261
+ const text = createEl('text', {
262
+ x: lx + 12, y: legendY,
263
+ fill: '#8892b0', 'font-size': '9',
264
+ });
265
+ text.textContent = item.label;
266
+ svg.appendChild(text);
267
+ });
268
+
269
+ container.appendChild(svg);
270
+ }
271
+
272
+ function createEl(tag, attrs) {
273
+ const el = document.createElementNS(SVG_NS, tag);
274
+ for (const [k, v] of Object.entries(attrs)) {
275
+ el.setAttribute(k, String(v));
276
+ }
277
+ return el;
278
+ }