ccanalyzer 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.
@@ -0,0 +1,923 @@
1
+ /* ── State ── */
2
+ const state = {
3
+ projects: null,
4
+ stats: null,
5
+ currentProject: null,
6
+ currentSession: null,
7
+ activityChart: null,
8
+ msgFilter: 'all',
9
+ timelineOpen: false,
10
+ };
11
+
12
+ /* ── Utils ── */
13
+ const $ = id => document.getElementById(id);
14
+ const fmt = n => n == null ? '—' : n.toLocaleString('fr-FR');
15
+ const fmtCost = v => v < 0.001 ? '<$0.001' : '$' + v.toFixed(v < 0.01 ? 4 : v < 0.1 ? 3 : 2);
16
+ const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
17
+ const fmtTime = iso => iso ? new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : '';
18
+
19
+ function fmtRelative(iso) {
20
+ if (!iso) return '—';
21
+ const diff = (Date.now() - new Date(iso)) / 1000;
22
+ if (diff < 60) return 'à l\'instant';
23
+ if (diff < 3600) return `il y a ${Math.floor(diff / 60)}m`;
24
+ if (diff < 86400) return `il y a ${Math.floor(diff / 3600)}h`;
25
+ if (diff < 86400 * 7) return `il y a ${Math.floor(diff / 86400)}j`;
26
+ return fmtDate(iso);
27
+ }
28
+
29
+ function fmtDuration(ms) {
30
+ if (ms < 1000) return '<1s';
31
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
32
+ if (ms < 3600000) return `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`;
33
+ return `${(ms / 3600000).toFixed(1)}h`;
34
+ }
35
+
36
+ function fmtMillions(n) {
37
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
38
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + 'k';
39
+ return fmt(n);
40
+ }
41
+
42
+ function modelShort(model) {
43
+ if (!model || model === '<synthetic>') return '—';
44
+ return model.replace('claude-', '').replace(/-\d{8}$/, '');
45
+ }
46
+
47
+ function escHtml(s) {
48
+ if (s == null) return '';
49
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
50
+ }
51
+
52
+ function show(loading) {
53
+ $('loading').classList.toggle('hidden', !loading);
54
+ }
55
+
56
+ /* ── Navigation ── */
57
+ function showView(name) {
58
+ document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
59
+ $('view-' + name).classList.remove('hidden');
60
+ document.querySelectorAll('.nav-item').forEach(a => {
61
+ a.classList.toggle('active', a.dataset.view === name || a.dataset.view === name.split('-')[0]);
62
+ });
63
+ }
64
+
65
+ function setBreadcrumb(parts) {
66
+ $('breadcrumb').innerHTML = parts.map(p => `<div style="color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</div>`).join('');
67
+ }
68
+
69
+ /* ── API ── */
70
+ async function api(path) {
71
+ const r = await fetch(path);
72
+ if (!r.ok) throw new Error(await r.text());
73
+ return r.json();
74
+ }
75
+
76
+ /* ── Dashboard ── */
77
+ async function loadDashboard() {
78
+ showView('dashboard');
79
+ setBreadcrumb(['Dashboard']);
80
+ const container = $('view-dashboard');
81
+
82
+ if (!state.projects) {
83
+ show(true);
84
+ try {
85
+ [state.projects, state.stats] = await Promise.all([api('/api/projects'), api('/api/stats')]);
86
+ } finally { show(false); }
87
+ }
88
+
89
+ const projects = state.projects;
90
+ const totalSessions = projects.reduce((s, p) => s + p.sessionCount, 0);
91
+ const totalMsgs = projects.reduce((s, p) => s + p.totalMessages, 0);
92
+ const totalCost = projects.reduce((s, p) => s + p.totalCost, 0);
93
+ const totalInput = projects.reduce((s, p) => s + p.totalUsage.input, 0);
94
+ const totalOutput = projects.reduce((s, p) => s + p.totalUsage.output, 0);
95
+ const totalCacheW = projects.reduce((s, p) => s + p.totalUsage.cache_write, 0);
96
+ const totalCacheR = projects.reduce((s, p) => s + p.totalUsage.cache_read, 0);
97
+
98
+ container.innerHTML = `
99
+ <div class="page-header">
100
+ <h1>Dashboard</h1>
101
+ <div class="subtitle">Analyse de l'ensemble de vos sessions Claude Code</div>
102
+ </div>
103
+ <div class="stats-grid">
104
+ <div class="stat-card accent"><div class="label">Projets</div><div class="value">${fmt(projects.length)}</div></div>
105
+ <div class="stat-card"><div class="label">Sessions</div><div class="value">${fmt(totalSessions)}</div></div>
106
+ <div class="stat-card"><div class="label">Messages</div><div class="value">${fmt(totalMsgs)}</div></div>
107
+ <div class="stat-card green"><div class="label">Coût estimé</div><div class="value">${fmtCost(totalCost)}</div></div>
108
+ <div class="stat-card">
109
+ <div class="label">Tokens input</div>
110
+ <div class="value">${fmtMillions(totalInput)}</div>
111
+ <div class="sub">+ ${fmtMillions(totalCacheR)} cache lus</div>
112
+ </div>
113
+ <div class="stat-card">
114
+ <div class="label">Tokens output</div>
115
+ <div class="value">${fmtMillions(totalOutput)}</div>
116
+ <div class="sub">${fmtMillions(totalCacheW)} cache écrits</div>
117
+ </div>
118
+ </div>
119
+ <div class="chart-section" style="margin-bottom:28px">
120
+ <h2>Activité quotidienne</h2>
121
+ <div class="chart-container"><canvas id="activity-chart"></canvas></div>
122
+ </div>
123
+ <div class="section-title" style="margin-bottom:12px">Projets <span style="font-weight:400;color:var(--text3)">(${projects.length})</span></div>
124
+ <div class="table-wrap">
125
+ <table>
126
+ <thead><tr><th>Projet</th><th>Chemin</th><th>Sessions</th><th>Messages</th><th>Input</th><th>Output</th><th>Coût</th><th>Dernière activité</th></tr></thead>
127
+ <tbody>
128
+ ${projects.map(p => `
129
+ <tr onclick="loadSessions('${encodeURIComponent(p.dirName)}')">
130
+ <td class="td-name">${escHtml(p.name)}</td>
131
+ <td class="td-path" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p.path)}</td>
132
+ <td class="td-num">${fmt(p.sessionCount)}</td>
133
+ <td class="td-num">${fmt(p.totalMessages)}</td>
134
+ <td class="td-num" style="color:var(--accent)">${fmtMillions(p.totalUsage.input)}</td>
135
+ <td class="td-num" style="color:var(--green)">${fmtMillions(p.totalUsage.output)}</td>
136
+ <td class="td-cost">${fmtCost(p.totalCost)}</td>
137
+ <td class="td-date">${fmtRelative(p.lastActivity)}</td>
138
+ </tr>`).join('')}
139
+ </tbody>
140
+ </table>
141
+ </div>`;
142
+
143
+ initActivityChart();
144
+ }
145
+
146
+ function initActivityChart() {
147
+ const canvas = document.getElementById('activity-chart');
148
+ if (!canvas) return;
149
+ if (state.activityChart) { state.activityChart.destroy(); state.activityChart = null; }
150
+
151
+ const daily = state.stats?.dailyActivity || [];
152
+ if (!daily.length) return;
153
+
154
+ const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date));
155
+ state.activityChart = new Chart(canvas, {
156
+ type: 'bar',
157
+ data: {
158
+ labels: sorted.map(d => d.date.slice(5)),
159
+ datasets: [
160
+ { label: 'Messages', data: sorted.map(d => d.messageCount), backgroundColor: 'rgba(79,142,247,0.7)', borderRadius: 3 },
161
+ { label: 'Tool calls', data: sorted.map(d => d.toolCallCount || 0), backgroundColor: 'rgba(167,139,250,0.6)', borderRadius: 3 },
162
+ ],
163
+ },
164
+ options: {
165
+ responsive: true, maintainAspectRatio: false,
166
+ plugins: {
167
+ legend: { labels: { color: '#8892a4', font: { size: 11 } } },
168
+ tooltip: { backgroundColor: '#1a1e28', titleColor: '#e2e8f0', bodyColor: '#8892a4' },
169
+ },
170
+ scales: {
171
+ x: { ticks: { color: '#5a6478', font: { size: 10 } }, grid: { color: '#1f2433' } },
172
+ y: { ticks: { color: '#5a6478', font: { size: 10 } }, grid: { color: '#1f2433' } },
173
+ },
174
+ },
175
+ });
176
+ }
177
+
178
+ /* ── Sessions list ── */
179
+ async function loadSessions(dirNameEncoded) {
180
+ const dirName = decodeURIComponent(dirNameEncoded);
181
+ showView('sessions');
182
+
183
+ if (!state.projects) {
184
+ show(true);
185
+ try { state.projects = await api('/api/projects'); } finally { show(false); }
186
+ }
187
+
188
+ const project = state.projects.find(p => p.dirName === dirName);
189
+ if (!project) return;
190
+ state.currentProject = project;
191
+ setBreadcrumb([project.path, 'Sessions']);
192
+
193
+ const container = $('view-sessions');
194
+ container.innerHTML = `
195
+ <button class="back-btn" onclick="loadDashboard()">← Dashboard</button>
196
+ <div class="page-header">
197
+ <h1>${escHtml(project.name)}</h1>
198
+ <div class="subtitle">${escHtml(project.path)}</div>
199
+ </div>
200
+ <div class="stats-grid" style="grid-template-columns:repeat(auto-fill,minmax(160px,1fr))">
201
+ <div class="stat-card"><div class="label">Sessions</div><div class="value">${fmt(project.sessionCount)}</div></div>
202
+ <div class="stat-card"><div class="label">Messages</div><div class="value">${fmt(project.totalMessages)}</div></div>
203
+ <div class="stat-card green"><div class="label">Coût total</div><div class="value">${fmtCost(project.totalCost)}</div></div>
204
+ <div class="stat-card accent"><div class="label">Tokens input</div><div class="value">${fmtMillions(project.totalUsage.input)}</div></div>
205
+ </div>
206
+ <div class="section-title" style="margin-bottom:12px">Sessions</div>
207
+ <div class="table-wrap">
208
+ <table>
209
+ <thead><tr><th>Titre</th><th>Modèle</th><th>Messages</th><th>Agents</th><th>Input</th><th>Output</th><th>Coût</th><th>Date</th></tr></thead>
210
+ <tbody>
211
+ ${project.sessions.map(s => `
212
+ <tr onclick="loadSessionDetail('${encodeURIComponent(dirName)}','${encodeURIComponent(s.file)}')">
213
+ <td class="td-name" style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
214
+ ${s.hasSubagents ? '<span style="color:var(--purple);margin-right:6px">⬡</span>' : ''}${escHtml(s.title)}
215
+ </td>
216
+ <td><span class="badge badge-model">${escHtml(modelShort(s.model))}</span></td>
217
+ <td class="td-num">${fmt(s.messageCount)}</td>
218
+ <td class="td-num">${s.hasSubagents ? `<span style="color:var(--purple)">agents</span>` : '<span style="color:var(--text3)">—</span>'}</td>
219
+ <td class="td-num" style="color:var(--accent)">${fmtMillions(s.totalUsage.input)}</td>
220
+ <td class="td-num" style="color:var(--green)">${fmtMillions(s.totalUsage.output)}</td>
221
+ <td class="td-cost">${fmtCost(s.totalCost)}</td>
222
+ <td class="td-date">${fmtDate(s.lastTimestamp)} ${fmtTime(s.lastTimestamp)}</td>
223
+ </tr>`).join('')}
224
+ </tbody>
225
+ </table>
226
+ </div>`;
227
+ }
228
+
229
+ /* ── Session detail ── */
230
+ async function loadSessionDetail(dirNameEncoded, fileEncoded) {
231
+ const dirName = decodeURIComponent(dirNameEncoded);
232
+ const file = decodeURIComponent(fileEncoded);
233
+ showView('session-detail');
234
+ const container = $('view-session-detail');
235
+ container.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text3)">Chargement...</div>';
236
+ show(true);
237
+
238
+ let session;
239
+ try {
240
+ session = await api(`/api/projects/${encodeURIComponent(dirName)}/sessions/${encodeURIComponent(file)}`);
241
+ } catch (e) {
242
+ container.innerHTML = `<div style="padding:40px;color:var(--red)">Erreur: ${escHtml(e.message)}</div>`;
243
+ show(false);
244
+ return;
245
+ }
246
+ show(false);
247
+ state.currentSession = { session, dirName, file };
248
+ state.msgFilter = 'all';
249
+ state.timelineOpen = session.agents && session.agents.length > 0;
250
+
251
+ if (!state.currentProject && state.projects) {
252
+ state.currentProject = state.projects.find(p => p.dirName === dirName);
253
+ }
254
+
255
+ setBreadcrumb([state.currentProject?.path || dirName, session.title]);
256
+ renderSessionDetail(session, dirName, file);
257
+ }
258
+
259
+ function renderSessionDetail(session, dirName, file) {
260
+ const container = $('view-session-detail');
261
+ const { totalUsage, totalCost, model, cwd, gitBranch, firstTimestamp, lastTimestamp, mainAgentMessages, subAgentMessages, agents } = session;
262
+
263
+ const durationMs = firstTimestamp && lastTimestamp
264
+ ? new Date(lastTimestamp) - new Date(firstTimestamp) : null;
265
+ const hasAgents = agents && agents.length > 0;
266
+
267
+ container.innerHTML = `
268
+ <button class="back-btn" onclick="loadSessions('${encodeURIComponent(dirName)}')">← Sessions</button>
269
+ <div class="page-header">
270
+ <h1 style="font-size:18px">${escHtml(session.title)}</h1>
271
+ </div>
272
+ <div class="session-meta">
273
+ <div class="meta-item"><div class="meta-label">Modèle</div><div class="meta-value accent">${escHtml(modelShort(model))}</div></div>
274
+ <div class="meta-item"><div class="meta-label">Début</div><div class="meta-value">${fmtDate(firstTimestamp)} ${fmtTime(firstTimestamp)}</div></div>
275
+ <div class="meta-item"><div class="meta-label">Durée</div><div class="meta-value">${durationMs != null ? fmtDuration(durationMs) : '—'}</div></div>
276
+ <div class="meta-item"><div class="meta-label">Messages</div><div class="meta-value">${fmt(session.messageCount)}</div></div>
277
+ ${hasAgents ? `<div class="meta-item"><div class="meta-label">Agents</div><div class="meta-value" style="color:var(--purple)">${fmt(agents.length)}</div></div>` : ''}
278
+ ${cwd ? `<div class="meta-item"><div class="meta-label">Répertoire</div><div class="meta-value mono">${escHtml(cwd.replace('/home/olivier-j/', '~/'))}</div></div>` : ''}
279
+ ${gitBranch && gitBranch !== 'HEAD' ? `<div class="meta-item"><div class="meta-label">Branche</div><div class="meta-value mono" style="color:var(--green)">${escHtml(gitBranch)}</div></div>` : ''}
280
+ </div>
281
+
282
+ <div class="token-bar">
283
+ <div class="token-item"><div class="token-label">Input</div><div class="token-value input">${fmt(totalUsage.input)}</div></div>
284
+ <div class="token-item"><div class="token-label">Output</div><div class="token-value output">${fmt(totalUsage.output)}</div></div>
285
+ <div class="token-item"><div class="token-label">Cache écrit</div><div class="token-value cache-w">${fmt(totalUsage.cache_write)}</div></div>
286
+ <div class="token-item"><div class="token-label">Cache lu</div><div class="token-value cache-r">${fmt(totalUsage.cache_read)}</div></div>
287
+ <div class="token-item" style="margin-left:auto"><div class="token-label">Coût estimé</div><div class="token-value cost">${fmtCost(totalCost)}</div></div>
288
+ </div>
289
+
290
+ <!-- Timeline toggle -->
291
+ <div class="timeline-toggle${state.timelineOpen ? ' open' : ''}" onclick="toggleTimeline('${escHtml(dirName)}', '${escHtml(file)}')">
292
+ <span class="tl-icon">⬡</span>
293
+ <span>Timeline${hasAgents ? ` — ${agents.length} agent${agents.length > 1 ? 's' : ''}` : ''}</span>
294
+ <span id="tl-chevron" style="margin-left:auto;font-size:10px;transition:transform 0.2s">${state.timelineOpen ? '▲' : '▼'}</span>
295
+ </div>
296
+ <div id="timeline-section" style="display:${state.timelineOpen ? 'block' : 'none'}">
297
+ <div id="timeline-content" class="timeline-wrap">
298
+ ${buildGanttSVG(session)}
299
+ </div>
300
+ <div id="timeline-detail" class="tl-detail hidden"></div>
301
+ </div>
302
+
303
+ ${hasAgents || subAgentMessages > 0 ? `
304
+ <div class="filter-bar">
305
+ <span style="font-size:12px;color:var(--text3)">Messages :</span>
306
+ <button class="filter-btn active" data-filter="all" onclick="setMsgFilter('all','${encodeURIComponent(dirName)}','${encodeURIComponent(file)}')">Tous (${fmt(session.messageCount)})</button>
307
+ <button class="filter-btn" data-filter="main" onclick="setMsgFilter('main','${encodeURIComponent(dirName)}','${encodeURIComponent(file)}')">Thread principal (${fmt(mainAgentMessages)})</button>
308
+ </div>` : ''}
309
+
310
+ <div id="messages-list" class="messages-container">
311
+ ${renderMessages(session.messages)}
312
+ </div>`;
313
+
314
+ initGanttEvents(session, dirName, file);
315
+ }
316
+
317
+ /* ── Timeline / Gantt ── */
318
+ function toggleTimeline(dirName, file) {
319
+ state.timelineOpen = !state.timelineOpen;
320
+ const section = $('timeline-section');
321
+ const chevron = $('tl-chevron');
322
+ const toggle = document.querySelector('.timeline-toggle');
323
+ if (section) section.style.display = state.timelineOpen ? 'block' : 'none';
324
+ if (chevron) chevron.textContent = state.timelineOpen ? '▲' : '▼';
325
+ if (toggle) toggle.classList.toggle('open', state.timelineOpen);
326
+ }
327
+
328
+ const AGENT_COLORS = ['#a78bfa', '#2dd4bf', '#34d399', '#f97316', '#60a5fa', '#f87171', '#fbbf24', '#c084fc', '#38bdf8', '#4ade80'];
329
+
330
+ function buildGanttSVG(session) {
331
+ const { messages, agents } = session;
332
+ const timeMsgs = messages.filter(m => m.timestamp && (m.type === 'user' || m.type === 'assistant'));
333
+ if (timeMsgs.length === 0) return '<div style="padding:20px;color:var(--text3);text-align:center">Pas de données temporelles</div>';
334
+
335
+ const hasAgents = agents && agents.length > 0;
336
+
337
+ // Time bounds
338
+ let tMin = Math.min(...timeMsgs.map(m => new Date(m.timestamp).getTime()));
339
+ let tMax = Math.max(...timeMsgs.map(m => new Date(m.timestamp).getTime()));
340
+ if (hasAgents) {
341
+ for (const a of agents) {
342
+ if (a.firstTimestamp) tMin = Math.min(tMin, new Date(a.firstTimestamp).getTime());
343
+ if (a.lastTimestamp) tMax = Math.max(tMax, new Date(a.lastTimestamp).getTime());
344
+ }
345
+ }
346
+ tMax += Math.max(5000, (tMax - tMin) * 0.02);
347
+ const totalMs = tMax - tMin;
348
+ if (totalMs <= 0) return '<div style="padding:20px;color:var(--text3);text-align:center">Session instantanée</div>';
349
+
350
+ // SVG layout
351
+ const SVG_W = 1000;
352
+ const LABEL_W = 150;
353
+ const PAD_R = 16;
354
+ const CHART_W = SVG_W - LABEL_W - PAD_R;
355
+ const USER_H = 24;
356
+ const MAIN_H = 48;
357
+ const AGENT_H = 30;
358
+ const AXIS_H = 26;
359
+ const GAP = 6;
360
+
361
+ const numAgents = hasAgents ? agents.length : 0;
362
+ const SVG_H = 10 + USER_H + GAP + MAIN_H + (numAgents > 0 ? GAP + numAgents * (AGENT_H + 3) : 0) + AXIS_H;
363
+
364
+ const toX = ms => LABEL_W + ((ms - tMin) / totalMs) * CHART_W;
365
+
366
+ let parts = [];
367
+ let currentY = 10;
368
+
369
+ // ── Grid lines ──
370
+ const numTicks = 8;
371
+ for (let i = 0; i <= numTicks; i++) {
372
+ const x = LABEL_W + (i / numTicks) * CHART_W;
373
+ parts.push(`<line x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${SVG_H - AXIS_H}" stroke="#1a1e28" stroke-width="1"/>`);
374
+ }
375
+
376
+ // ── User track ──
377
+ const userMsgs = timeMsgs.filter(m => m.type === 'user');
378
+ parts.push(`<text x="${LABEL_W - 8}" y="${currentY + USER_H / 2 + 4}" text-anchor="end" fill="#5a6478" font-size="10" font-family="system-ui">👤 User</text>`);
379
+ for (const m of userMsgs) {
380
+ const x = toX(new Date(m.timestamp).getTime());
381
+ parts.push(`<rect x="${x - 1.5}" y="${currentY + 2}" width="3" height="${USER_H - 4}" fill="#fbbf24" rx="1" opacity="0.9" class="gantt-msg" data-uuid="${escHtml(m.uuid || '')}"/>`);
382
+ }
383
+ currentY += USER_H + GAP;
384
+
385
+ // ── Main AI track ──
386
+ const assistMsgs = timeMsgs.filter(m => m.type === 'assistant');
387
+ parts.push(`<text x="${LABEL_W - 8}" y="${currentY + MAIN_H / 2 + 4}" text-anchor="end" fill="#8892a4" font-size="11" font-family="system-ui">🤖 Main AI</text>`);
388
+ parts.push(`<rect x="${LABEL_W}" y="${currentY}" width="${CHART_W}" height="${MAIN_H}" fill="#4f8ef710" rx="3"/>`);
389
+
390
+ for (let i = 0; i < assistMsgs.length; i++) {
391
+ const m = assistMsgs[i];
392
+ const nextM = assistMsgs[i + 1];
393
+ const startMs = new Date(m.timestamp).getTime();
394
+ const endMs = nextM
395
+ ? new Date(nextM.timestamp).getTime()
396
+ : Math.min(startMs + 30000, tMin + totalMs);
397
+ const x = toX(startMs);
398
+ const w = Math.max(4, toX(endMs) - x - 1);
399
+ const barY = currentY + 8;
400
+ const barH = MAIN_H - 16;
401
+
402
+ const content = Array.isArray(m.content) ? m.content : [];
403
+ const tools = content.filter(c => c && c.type === 'tool_use');
404
+ const hasAgentTool = tools.some(t => t.name === 'Agent');
405
+ const color = hasAgentTool ? '#a78bfa' : '#4f8ef7';
406
+
407
+ parts.push(`<rect x="${x.toFixed(1)}" y="${barY}" width="${w.toFixed(1)}" height="${barH}" fill="${color}" rx="2" opacity="0.85" class="gantt-msg" data-uuid="${escHtml(m.uuid || '')}">
408
+ <title>${escHtml(tools.map(t => t.name).join(', ') || 'Réponse')}</title>
409
+ </rect>`);
410
+
411
+ if (w > 50 && tools.length > 0) {
412
+ const label = tools.slice(0, 2).map(t => t.name.replace(/([A-Z])/g, ' $1').trim()).join(', ');
413
+ parts.push(`<text x="${(x + 4).toFixed(1)}" y="${barY + barH - 4}" fill="white" font-size="8" opacity="0.85" pointer-events="none">${escHtml(label.slice(0, Math.floor(w / 6)))}</text>`);
414
+ }
415
+ }
416
+ currentY += MAIN_H + GAP;
417
+
418
+ // ── Agent tracks ──
419
+ for (let i = 0; i < agents.length; i++) {
420
+ const agent = agents[i];
421
+ const color = AGENT_COLORS[i % AGENT_COLORS.length];
422
+ const y = currentY + i * (AGENT_H + 3);
423
+ const cy = y + AGENT_H / 2;
424
+
425
+ const label = (agent.meta?.description || agent.agentId || '').slice(0, 22);
426
+ parts.push(`<text x="${LABEL_W - 8}" y="${cy + 4}" text-anchor="end" fill="#5a6478" font-size="9.5" font-family="system-ui">${escHtml(label)}</text>`);
427
+
428
+ // Track bg
429
+ parts.push(`<rect x="${LABEL_W}" y="${y}" width="${CHART_W}" height="${AGENT_H}" fill="${color}08" rx="2"/>`);
430
+
431
+ if (agent.firstTimestamp && agent.lastTimestamp) {
432
+ const startMs = new Date(agent.firstTimestamp).getTime();
433
+ const endMs = new Date(agent.lastTimestamp).getTime();
434
+ const x = toX(startMs);
435
+ const w = Math.max(6, toX(endMs) - x);
436
+ const barY = y + 5;
437
+ const barH = AGENT_H - 10;
438
+
439
+ // Spawn connector
440
+ if (agent.spawnedAt) {
441
+ const spawnX = toX(new Date(agent.spawnedAt).getTime());
442
+ const mainBottomY = 10 + USER_H + GAP + MAIN_H;
443
+ parts.push(`<line x1="${spawnX.toFixed(1)}" y1="${mainBottomY}" x2="${x.toFixed(1)}" y2="${cy.toFixed(1)}" stroke="${color}" stroke-width="1" stroke-dasharray="4,3" opacity="0.35"/>`);
444
+ }
445
+
446
+ const depth = agent.meta?.spawnDepth || 1;
447
+ const depthBorder = depth > 1 ? ` stroke="${color}" stroke-width="1.5"` : '';
448
+
449
+ parts.push(`<rect x="${x.toFixed(1)}" y="${barY}" width="${w.toFixed(1)}" height="${barH}" fill="${color}" rx="2" opacity="0.8" class="gantt-agent" data-agent-id="${escHtml(agent.agentId)}"${depthBorder} style="cursor:pointer">
450
+ <title>${escHtml(agent.meta?.description || agent.agentId)} | ${agent.messageCount} msgs | ${fmtCost(agent.totalCost)} | ${fmtDuration(endMs - startMs)}</title>
451
+ </rect>`);
452
+
453
+ if (w > 40) {
454
+ const info = `${fmtCost(agent.totalCost)} · ${agent.messageCount}msg`;
455
+ parts.push(`<text x="${(x + 4).toFixed(1)}" y="${barY + barH - 4}" fill="white" font-size="8" opacity="0.9" pointer-events="none">${escHtml(info.slice(0, Math.floor(w / 5)))}</text>`);
456
+ }
457
+ }
458
+ }
459
+
460
+ if (numAgents > 0) currentY += numAgents * (AGENT_H + 3) + GAP;
461
+
462
+ // ── Time axis ──
463
+ const axisY = SVG_H - AXIS_H + 2;
464
+ parts.push(`<line x1="${LABEL_W}" y1="${axisY}" x2="${SVG_W - PAD_R}" y2="${axisY}" stroke="#252b3a" stroke-width="1"/>`);
465
+ for (let i = 0; i <= numTicks; i++) {
466
+ const frac = i / numTicks;
467
+ const x = LABEL_W + frac * CHART_W;
468
+ const label = fmtDuration(frac * totalMs);
469
+ parts.push(`<line x1="${x.toFixed(1)}" y1="${axisY}" x2="${x.toFixed(1)}" y2="${axisY + 4}" stroke="#2e3650"/>`);
470
+ parts.push(`<text x="${x.toFixed(1)}" y="${axisY + 15}" text-anchor="middle" fill="#5a6478" font-size="9" font-family="system-ui">${label}</text>`);
471
+ }
472
+
473
+ return `
474
+ <div class="gantt-svg-wrap">
475
+ <svg id="gantt-svg" viewBox="0 0 ${SVG_W} ${SVG_H}" preserveAspectRatio="none" style="width:100%;height:${SVG_H}px;display:block" xmlns="http://www.w3.org/2000/svg">
476
+ ${parts.join('\n')}
477
+ </svg>
478
+ </div>
479
+ <div id="gantt-tip" class="gantt-tooltip hidden"></div>`;
480
+ }
481
+
482
+ function initGanttEvents(session, dirName, file) {
483
+ const svg = $('gantt-svg');
484
+ const tip = $('gantt-tip');
485
+ if (!svg || !tip) return;
486
+
487
+ svg.addEventListener('mousemove', e => {
488
+ const target = e.target.closest('[data-uuid],[data-agent-id]');
489
+ if (!target) { tip.classList.add('hidden'); return; }
490
+
491
+ if (target.dataset.uuid) {
492
+ const msg = session.messages.find(m => m.uuid === target.dataset.uuid);
493
+ if (msg) {
494
+ const ts = msg.timestamp ? fmtTime(msg.timestamp) : '';
495
+ const tools = Array.isArray(msg.content) ? msg.content.filter(c => c && c.type === 'tool_use') : [];
496
+ const toolStr = tools.map(t => t.name).join(', ');
497
+ tip.innerHTML = `<strong>${msg.type === 'user' ? 'User' : 'Assistant'}</strong> ${ts}${toolStr ? `<br><span style="color:var(--purple)">${escHtml(toolStr)}</span>` : ''}${msg.usage ? `<br>in:${fmt(msg.usage.input_tokens)} out:${fmt(msg.usage.output_tokens)}` : ''}`;
498
+ }
499
+ } else if (target.dataset.agentId) {
500
+ const agent = session.agents.find(a => a.agentId === target.dataset.agentId);
501
+ if (agent) {
502
+ const dur = agent.firstTimestamp && agent.lastTimestamp
503
+ ? fmtDuration(new Date(agent.lastTimestamp) - new Date(agent.firstTimestamp)) : '?';
504
+ tip.innerHTML = `<strong>${escHtml(agent.meta?.description || agent.agentId)}</strong><br>${agent.messageCount} msgs · ${fmtCost(agent.totalCost)} · ${dur}`;
505
+ }
506
+ }
507
+
508
+ // Tooltip fixed to viewport — avoids any container clip issues
509
+ tip.classList.remove('hidden');
510
+ tip.style.left = (e.clientX + 14) + 'px';
511
+ tip.style.top = (e.clientY - 10) + 'px';
512
+ });
513
+
514
+ svg.addEventListener('mouseleave', () => tip.classList.add('hidden'));
515
+
516
+ // Click → show inline detail panel below the timeline
517
+ svg.addEventListener('click', async e => {
518
+ const bar = e.target.closest('[data-agent-id],[data-uuid]');
519
+ if (!bar) return;
520
+ tip.classList.add('hidden');
521
+
522
+ if (bar.dataset.agentId) {
523
+ showTimelineAgentDetail(session, dirName, file, bar.dataset.agentId);
524
+ } else if (bar.dataset.uuid) {
525
+ showTimelineMessageDetail(session, bar.dataset.uuid);
526
+ }
527
+ });
528
+ }
529
+
530
+ /* ── Timeline detail panel ── */
531
+ function showTimelineMessageDetail(session, uuid) {
532
+ const panel = $('timeline-detail');
533
+ if (!panel) return;
534
+ const msg = session.messages.find(m => m.uuid === uuid);
535
+ if (!msg) return;
536
+
537
+ const isUser = msg.type === 'user';
538
+ const ts = msg.timestamp ? `${fmtDate(msg.timestamp)} ${fmtTime(msg.timestamp)}` : '';
539
+ const content = Array.isArray(msg.content) ? msg.content : (typeof msg.content === 'string' ? [{ type: 'text', text: msg.content }] : []);
540
+ const tools = content.filter(b => b?.type === 'tool_use');
541
+ const texts = content.filter(b => b?.type === 'text').map(b => b.text).join('\n');
542
+ const toolResults = content.filter(b => b?.type === 'tool_result');
543
+
544
+ let body = '';
545
+ if (isUser) {
546
+ if (toolResults.length > 0) {
547
+ body = toolResults.map(tr => {
548
+ const rc = Array.isArray(tr.content) ? tr.content.map(c => c.text || '').join('\n') : (tr.content || '');
549
+ return `<div class="tool-call" style="${tr.is_error ? 'border-color:var(--red)' : ''}">
550
+ <div class="tool-name" style="color:${tr.is_error ? 'var(--red)' : 'var(--teal)'}">↩ résultat${tr.is_error ? ' (erreur)' : ''}</div>
551
+ <div class="tool-input">${escHtml(rc.slice(0, 800))}</div>
552
+ </div>`;
553
+ }).join('');
554
+ } else if (texts) {
555
+ body = `<div class="msg-text">${escHtml(texts)}</div>`;
556
+ }
557
+ } else {
558
+ if (texts) body += `<div class="msg-text" style="margin-bottom:${tools.length ? '12px' : '0'}">${escHtml(texts.slice(0, 1000))}</div>`;
559
+ if (tools.length) {
560
+ body += tools.map(t => {
561
+ const preview = getToolInputPreview(t.name, t.input);
562
+ return `<div class="tool-call">
563
+ <div class="tool-name">⚙ ${escHtml(t.name)}${preview ? `<span style="color:var(--text3);font-weight:400;margin-left:8px">${escHtml(preview.slice(0, 80))}</span>` : ''}</div>
564
+ </div>`;
565
+ }).join('');
566
+ }
567
+ if (msg.usage) {
568
+ body += `<div class="usage-inline">
569
+ <span class="usage-chip">in: <span class="in">${fmt(msg.usage.input_tokens)}</span></span>
570
+ <span class="usage-chip">out: <span class="out">${fmt(msg.usage.output_tokens)}</span></span>
571
+ ${msg.usage.cache_read_input_tokens ? `<span class="usage-chip">cache↓: <span class="cr">${fmt(msg.usage.cache_read_input_tokens)}</span></span>` : ''}
572
+ </div>`;
573
+ }
574
+ }
575
+
576
+ panel.innerHTML = `
577
+ <div class="tl-detail-header">
578
+ <span class="msg-role ${isUser ? 'user' : 'assistant'}">${isUser ? '👤 User' : '🤖 Assistant'}</span>
579
+ <span style="color:var(--text3);font-size:11px;margin-left:8px">${ts}</span>
580
+ <button class="tl-detail-close" onclick="$('timeline-detail').classList.add('hidden')">×</button>
581
+ </div>
582
+ <div class="tl-detail-body">${body || '<span style="color:var(--text3)">Contenu vide</span>'}</div>`;
583
+ panel.classList.remove('hidden');
584
+ }
585
+
586
+ async function showTimelineAgentDetail(session, dirName, file, agentId) {
587
+ const agent = session.agents?.find(a => a.agentId === agentId);
588
+ if (!agent) return;
589
+
590
+ const dur = agent.firstTimestamp && agent.lastTimestamp
591
+ ? fmtDuration(new Date(agent.lastTimestamp) - new Date(agent.firstTimestamp)) : '—';
592
+
593
+ let modal = $('agent-modal');
594
+ if (!modal) {
595
+ modal = document.createElement('div');
596
+ modal.id = 'agent-modal';
597
+ modal.className = 'agent-modal';
598
+ modal.addEventListener('click', e => { if (e.target === modal) closeAgentModal(); });
599
+ document.body.appendChild(modal);
600
+ }
601
+
602
+ modal.innerHTML = `
603
+ <div class="agent-modal-box">
604
+ <div class="agent-modal-header">
605
+ <div style="min-width:0">
606
+ <div style="font-size:14px;font-weight:600;color:var(--purple);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
607
+ ⬡ ${escHtml(agent.meta?.description || agentId)}
608
+ </div>
609
+ <div style="font-size:11px;color:var(--text3);margin-top:3px">
610
+ ${escHtml(agent.meta?.agentType || '')} · depth ${agent.meta?.spawnDepth || 1} · ${agent.messageCount} msgs · ${dur} · ${fmtCost(agent.totalCost)}
611
+ </div>
612
+ </div>
613
+ <button onclick="closeAgentModal()" class="modal-close-btn">×</button>
614
+ </div>
615
+ <div class="agent-modal-loading" id="agent-modal-body">
616
+ <div style="text-align:center;color:var(--text3);padding:40px">Chargement…</div>
617
+ </div>
618
+ </div>`;
619
+ modal.style.display = 'flex';
620
+
621
+ try {
622
+ const detail = await api(`/api/projects/${encodeURIComponent(dirName)}/sessions/${encodeURIComponent(file)}/agents/${encodeURIComponent(agentId)}`);
623
+ $('agent-modal-body').className = 'agent-modal-body';
624
+ $('agent-modal-body').innerHTML = `
625
+ <div class="agent-modal-stats">
626
+ <div class="token-item"><div class="token-label">Input</div><div class="token-value input">${fmt(detail.totalUsage.input)}</div></div>
627
+ <div class="token-item"><div class="token-label">Output</div><div class="token-value output">${fmt(detail.totalUsage.output)}</div></div>
628
+ <div class="token-item"><div class="token-label">Cache lu</div><div class="token-value cache-r">${fmt(detail.totalUsage.cache_read)}</div></div>
629
+ <div class="token-item" style="margin-left:auto"><div class="token-label">Coût</div><div class="token-value cost">${fmtCost(detail.totalCost)}</div></div>
630
+ </div>
631
+ <div class="agent-modal-messages">
632
+ ${renderAgentMessages(detail.messages)}
633
+ </div>`;
634
+ } catch (e) {
635
+ $('agent-modal-body').innerHTML = `<div style="color:var(--red);padding:20px">Erreur : ${escHtml(e.message)}</div>`;
636
+ }
637
+ }
638
+
639
+ function renderAgentSummary(detail) {
640
+ const msgs = detail.messages;
641
+ const dur = detail.firstTimestamp && detail.lastTimestamp
642
+ ? fmtDuration(new Date(detail.lastTimestamp) - new Date(detail.firstTimestamp)) : '—';
643
+
644
+ // Extract: task prompt (first user text), tool calls sequence, final answer
645
+ const firstUserMsg = msgs.find(m => m.type === 'user');
646
+ const taskText = extractText(firstUserMsg?.content);
647
+
648
+ // Collect all tool_use calls in order
649
+ const toolCalls = [];
650
+ for (const m of msgs) {
651
+ if (m.type !== 'assistant') continue;
652
+ const content = Array.isArray(m.content) ? m.content : [];
653
+ for (const block of content) {
654
+ if (block?.type === 'tool_use') {
655
+ toolCalls.push({ name: block.name, input: block.input, ts: m.timestamp });
656
+ }
657
+ }
658
+ }
659
+
660
+ // Final assistant text response
661
+ let finalText = '';
662
+ for (let i = msgs.length - 1; i >= 0; i--) {
663
+ const m = msgs[i];
664
+ if (m.type !== 'assistant') continue;
665
+ const content = Array.isArray(m.content) ? m.content : [];
666
+ const text = content.find(b => b?.type === 'text')?.text || '';
667
+ if (text.trim()) { finalText = text; break; }
668
+ }
669
+
670
+ // Group tool calls by name for summary
671
+ const toolCount = {};
672
+ for (const t of toolCalls) toolCount[t.name] = (toolCount[t.name] || 0) + 1;
673
+
674
+ return `
675
+ <div class="token-bar" style="margin-bottom:16px">
676
+ <div class="token-item"><div class="token-label">Input</div><div class="token-value input">${fmt(detail.totalUsage.input)}</div></div>
677
+ <div class="token-item"><div class="token-label">Output</div><div class="token-value output">${fmt(detail.totalUsage.output)}</div></div>
678
+ <div class="token-item"><div class="token-label">Durée</div><div class="token-value" style="color:var(--text2)">${dur}</div></div>
679
+ <div class="token-item" style="margin-left:auto"><div class="token-label">Coût</div><div class="token-value cost">${fmtCost(detail.totalCost)}</div></div>
680
+ </div>
681
+
682
+ ${taskText ? `
683
+ <div class="agent-section">
684
+ <div class="agent-section-label">📋 Tâche</div>
685
+ <div class="agent-task-text">${escHtml(taskText.slice(0, 600))}${taskText.length > 600 ? '…' : ''}</div>
686
+ </div>` : ''}
687
+
688
+ <div class="agent-section">
689
+ <div class="agent-section-label">⚙ Outils utilisés — ${toolCalls.length} appels</div>
690
+ <div class="tool-summary-chips">
691
+ ${Object.entries(toolCount).sort((a,b) => b[1]-a[1]).map(([name, count]) =>
692
+ `<span class="tool-chip">${escHtml(name)}<span class="tool-chip-count">${count}</span></span>`
693
+ ).join('')}
694
+ </div>
695
+ </div>
696
+
697
+ <div class="agent-section">
698
+ <div class="agent-section-label">📜 Séquence des appels</div>
699
+ <div class="tool-sequence">
700
+ ${toolCalls.slice(0, 60).map((t, i) => {
701
+ const inputPreview = getToolInputPreview(t.name, t.input);
702
+ return `<div class="tool-seq-item">
703
+ <span class="tool-seq-num">${i + 1}</span>
704
+ <span class="tool-seq-name">${escHtml(t.name)}</span>
705
+ ${inputPreview ? `<span class="tool-seq-preview">${escHtml(inputPreview)}</span>` : ''}
706
+ </div>`;
707
+ }).join('')}
708
+ ${toolCalls.length > 60 ? `<div style="color:var(--text3);font-size:11px;padding:4px 0">… et ${toolCalls.length - 60} autres appels</div>` : ''}
709
+ </div>
710
+ </div>
711
+
712
+ ${finalText ? `
713
+ <div class="agent-section">
714
+ <div class="agent-section-label">✅ Résultat final</div>
715
+ <div class="agent-result-text">${escHtml(finalText.slice(0, 1200))}${finalText.length > 1200 ? '…' : ''}</div>
716
+ </div>` : ''}
717
+
718
+ <div style="padding:12px 0 4px">
719
+ <button class="filter-btn" onclick="showAllAgentMessages(${JSON.stringify(detail.messages.length)})" style="font-size:12px">
720
+ Voir tous les messages (${detail.messages.length})
721
+ </button>
722
+ </div>
723
+
724
+ <div id="agent-all-messages" class="hidden">
725
+ <div class="messages-container" style="margin-top:12px">
726
+ ${renderAgentMessages(msgs)}
727
+ </div>
728
+ </div>`;
729
+ }
730
+
731
+ function extractText(content) {
732
+ if (typeof content === 'string') return content;
733
+ if (Array.isArray(content)) {
734
+ return content.filter(b => b?.type === 'text').map(b => b.text).join('\n') ||
735
+ content.filter(b => typeof b === 'string').join('\n');
736
+ }
737
+ return '';
738
+ }
739
+
740
+ function getToolInputPreview(name, input) {
741
+ if (!input) return '';
742
+ if (name === 'Bash' || name === 'bash') return (input.command || '').slice(0, 60);
743
+ if (name === 'Read') return input.file_path || '';
744
+ if (name === 'Edit' || name === 'Write') return input.file_path || '';
745
+ if (name === 'WebSearch' || name === 'WebFetch') return input.query || input.url || '';
746
+ if (name === 'Agent') return (input.description || input.prompt || '').slice(0, 60);
747
+ if (name === 'Skill') return input.skill || '';
748
+ // Generic: first string value
749
+ const vals = Object.values(input).filter(v => typeof v === 'string');
750
+ return vals[0]?.slice(0, 60) || '';
751
+ }
752
+
753
+ function renderAgentMessages(msgs) {
754
+ // Show only "significant" messages: user text + assistant text (skip pure tool_result exchanges)
755
+ return msgs.map((m, i) => {
756
+ const isUser = m.type === 'user';
757
+ const content = Array.isArray(m.content) ? m.content : (typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : []);
758
+ const hasText = content.some(b => b?.type === 'text' && b.text?.trim());
759
+ const isToolResult = !isUser ? false : content.every(b => b?.type === 'tool_result' || !b);
760
+ const collapseByDefault = isToolResult || i > 2;
761
+
762
+ const bodyParts = [];
763
+ if (isUser) {
764
+ if (isToolResult) {
765
+ bodyParts.push(`<div style="color:var(--text3);font-size:12px">↩ ${content.length} résultat(s) d'outils</div>`);
766
+ } else {
767
+ const text = extractText(m.content);
768
+ if (text) bodyParts.push(`<div class="msg-text">${escHtml(text.slice(0, 500))}</div>`);
769
+ }
770
+ } else {
771
+ for (const block of content) {
772
+ if (!block) continue;
773
+ if (block.type === 'thinking') {
774
+ bodyParts.push(`<div class="thinking-block">🧠 réflexion (${block.thinking?.length || 0} chars)</div>`);
775
+ } else if (block.type === 'text' && block.text?.trim()) {
776
+ bodyParts.push(`<div class="msg-text">${escHtml(block.text.slice(0, 800))}</div>`);
777
+ } else if (block.type === 'tool_use') {
778
+ const preview = getToolInputPreview(block.name, block.input);
779
+ bodyParts.push(`<div class="tool-call" style="padding:6px 10px;margin:4px 0">
780
+ <span class="tool-name" style="font-size:11px">⚙ ${escHtml(block.name)}</span>
781
+ ${preview ? `<span style="color:var(--text3);font-size:11px;margin-left:8px">${escHtml(preview)}</span>` : ''}
782
+ </div>`);
783
+ }
784
+ }
785
+ }
786
+
787
+ const ts = m.timestamp ? fmtTime(m.timestamp) : '';
788
+ return `
789
+ <div class="message ${collapseByDefault ? 'collapsed' : ''}" style="margin-bottom:6px">
790
+ <div class="message-header" onclick="this.parentElement.classList.toggle('collapsed')" style="padding:7px 12px">
791
+ <span class="msg-role ${isUser ? 'user' : 'assistant'}" style="font-size:10px">${isUser ? '👤' : '🤖'} ${isUser ? (isToolResult ? 'tool result' : 'user') : 'assistant'}</span>
792
+ ${ts ? `<span style="font-size:10px;color:var(--text3);margin-left:8px">${ts}</span>` : ''}
793
+ <span class="msg-chevron" style="margin-left:auto">▼</span>
794
+ </div>
795
+ <div class="message-body" style="padding:10px 12px">${bodyParts.join('') || '<div style="color:var(--text3);font-size:11px">vide</div>'}</div>
796
+ </div>`;
797
+ }).join('');
798
+ }
799
+
800
+ function showAllAgentMessages(count) {
801
+ $('agent-all-messages')?.classList.toggle('hidden');
802
+ }
803
+
804
+ function closeAgentModal() {
805
+ const modal = $('agent-modal');
806
+ if (modal) modal.style.display = 'none';
807
+ }
808
+
809
+ /* ── Messages ── */
810
+ function setMsgFilter(filter, dirNameEnc, fileEnc) {
811
+ state.msgFilter = filter;
812
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === filter));
813
+ if (state.currentSession) {
814
+ $('messages-list').innerHTML = renderMessages(state.currentSession.session.messages);
815
+ }
816
+ }
817
+
818
+ function renderMessages(messages, ctx) {
819
+ const filtered = messages.filter(m => {
820
+ if (state.msgFilter === 'main') return !m.isSidechain;
821
+ return true;
822
+ });
823
+ if (!filtered.length) return '<div class="empty"><p>Aucun message</p></div>';
824
+ return filtered.map((m, i) => renderMessage(m, i, ctx)).join('');
825
+ }
826
+
827
+ function renderMessage(m, i, ctx) {
828
+ const isUser = m.type === 'user';
829
+ const isAgent = m.isSidechain;
830
+ const collapseByDefault = !isUser && i > 0;
831
+ const pfx = ctx === 'modal' ? 'modal-' : '';
832
+
833
+ let bodyHtml = '';
834
+ if (isUser) {
835
+ if (typeof m.content === 'string') {
836
+ bodyHtml = `<div class="msg-text">${escHtml(m.content)}</div>`;
837
+ } else if (Array.isArray(m.content)) {
838
+ const parts = [];
839
+ for (const block of m.content) {
840
+ if (!block) continue;
841
+ if (block.type === 'tool_result') {
842
+ const rc = Array.isArray(block.content) ? block.content.map(c => c.text || '').join('\n') : (block.content || '');
843
+ const isError = block.is_error;
844
+ parts.push(`<div class="tool-call" style="${isError ? 'border-color:var(--red)' : ''}">
845
+ <div class="tool-name" style="color:${isError ? 'var(--red)' : 'var(--teal)'}">↩ résultat${isError ? ' (erreur)' : ''}</div>
846
+ <div class="tool-input">${escHtml(rc.slice(0, 2000))}${rc.length > 2000 ? '\n…(tronqué)' : ''}</div>
847
+ </div>`);
848
+ } else if (block.type === 'text') {
849
+ parts.push(`<div class="msg-text">${escHtml(block.text)}</div>`);
850
+ }
851
+ }
852
+ bodyHtml = parts.join('') || '<div class="msg-text" style="color:var(--text3)">(contenu vide)</div>';
853
+ }
854
+ } else {
855
+ const content = Array.isArray(m.content) ? m.content : [];
856
+ const parts = [];
857
+ for (const block of content) {
858
+ if (!block) continue;
859
+ if (block.type === 'thinking') {
860
+ parts.push(`<div class="thinking-block">🧠 <em>Réflexion (${block.thinking ? block.thinking.length : 0} chars)</em></div>`);
861
+ } else if (block.type === 'text') {
862
+ parts.push(`<div class="msg-text">${escHtml(block.text)}</div>`);
863
+ } else if (block.type === 'tool_use') {
864
+ const inputStr = JSON.stringify(block.input || {}, null, 2);
865
+ parts.push(`<div class="tool-call">
866
+ <div class="tool-name">⚙ ${escHtml(block.name)}</div>
867
+ <div class="tool-input">${escHtml(inputStr.slice(0, 1000))}${inputStr.length > 1000 ? '\n…' : ''}</div>
868
+ </div>`);
869
+ }
870
+ }
871
+ bodyHtml = parts.join('') || '<div class="msg-text" style="color:var(--text3)">(contenu vide)</div>';
872
+ }
873
+
874
+ let usageHtml = '';
875
+ if (!isUser && m.usage) {
876
+ const u = m.usage;
877
+ usageHtml = `<div class="usage-inline">
878
+ <span class="usage-chip">in: <span class="in">${fmt(u.input_tokens)}</span></span>
879
+ <span class="usage-chip">out: <span class="out">${fmt(u.output_tokens)}</span></span>
880
+ ${u.cache_creation_input_tokens ? `<span class="usage-chip">cache↑: <span class="cw">${fmt(u.cache_creation_input_tokens)}</span></span>` : ''}
881
+ ${u.cache_read_input_tokens ? `<span class="usage-chip">cache↓: <span class="cr">${fmt(u.cache_read_input_tokens)}</span></span>` : ''}
882
+ ${m.model ? `<span class="usage-chip" style="color:var(--accent)">${escHtml(modelShort(m.model))}</span>` : ''}
883
+ </div>`;
884
+ }
885
+
886
+ const ts = m.timestamp ? fmtTime(m.timestamp) : '';
887
+ const msgId = `${pfx}msg-${i}`;
888
+
889
+ return `
890
+ <div class="message ${isAgent ? 'sidechain' : ''} ${collapseByDefault ? 'collapsed' : ''}" id="${msgId}" data-uuid="${escHtml(m.uuid || '')}">
891
+ <div class="message-header" onclick="this.parentElement.classList.toggle('collapsed')">
892
+ <span class="msg-role ${isUser ? 'user' : 'assistant'}">${isUser ? '👤 User' : '🤖 AI'}</span>
893
+ ${isAgent ? '<span class="badge badge-sidechain">agent</span>' : ''}
894
+ <div class="msg-meta">
895
+ ${ts ? `<span>${ts}</span>` : ''}
896
+ ${m.stopReason ? `<span style="color:var(--text3)">${escHtml(m.stopReason)}</span>` : ''}
897
+ </div>
898
+ <span class="msg-chevron">▼</span>
899
+ </div>
900
+ <div class="message-body">${bodyHtml}${usageHtml}</div>
901
+ </div>`;
902
+ }
903
+
904
+ /* ── Init ── */
905
+ document.querySelectorAll('.nav-item').forEach(a => {
906
+ a.addEventListener('click', e => {
907
+ e.preventDefault();
908
+ if (a.dataset.view === 'dashboard') loadDashboard();
909
+ });
910
+ });
911
+
912
+ document.addEventListener('keydown', e => {
913
+ if (e.key === 'Escape') closeAgentModal();
914
+ });
915
+
916
+ window.loadSessions = loadSessions;
917
+ window.loadSessionDetail = loadSessionDetail;
918
+ window.loadDashboard = loadDashboard;
919
+ window.setMsgFilter = setMsgFilter;
920
+ window.toggleTimeline = toggleTimeline;
921
+ window.closeAgentModal = closeAgentModal;
922
+
923
+ loadDashboard();