@opensassi/opencode 0.1.3 → 0.1.4

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 (37) hide show
  1. package/dashboard/dashboard.e2e.test.ts +247 -0
  2. package/dashboard/dist/index.d.ts +9 -0
  3. package/dashboard/dist/index.js +36 -0
  4. package/dashboard/dist/routes/api.d.ts +2 -0
  5. package/dashboard/dist/routes/api.js +215 -0
  6. package/dashboard/dist/services/cache.d.ts +13 -0
  7. package/dashboard/dist/services/cache.js +29 -0
  8. package/dashboard/dist/services/experiments.d.ts +11 -0
  9. package/dashboard/dist/services/experiments.js +108 -0
  10. package/dashboard/dist/services/git.d.ts +12 -0
  11. package/dashboard/dist/services/git.js +149 -0
  12. package/dashboard/dist/services/sessions.d.ts +25 -0
  13. package/dashboard/dist/services/sessions.js +208 -0
  14. package/dashboard/dist/services/specs.d.ts +9 -0
  15. package/dashboard/dist/services/specs.js +102 -0
  16. package/dashboard/dist/types.d.ts +173 -0
  17. package/dashboard/dist/types.js +1 -0
  18. package/dashboard/opencode.e2e.test.ts +100 -0
  19. package/dashboard/playwright.config.ts +11 -0
  20. package/dashboard/public/app.js +961 -0
  21. package/dashboard/public/index.html +29 -0
  22. package/dashboard/public/style.css +231 -0
  23. package/dashboard/src/index.ts +53 -0
  24. package/dashboard/src/routes/api.ts +235 -0
  25. package/dashboard/src/services/cache.ts +38 -0
  26. package/dashboard/src/services/experiments.ts +117 -0
  27. package/dashboard/src/services/git.ts +139 -0
  28. package/dashboard/src/services/sessions.ts +216 -0
  29. package/dashboard/src/services/specs.ts +95 -0
  30. package/dashboard/src/types.ts +168 -0
  31. package/dashboard/technical-specification.md +414 -0
  32. package/dashboard/test-api.sh +127 -0
  33. package/dashboard/tsconfig.json +16 -0
  34. package/lib/util/paths.js +9 -1
  35. package/package.json +9 -1
  36. package/scripts/dashboard.js +17 -0
  37. package/scripts/generate-daily-summaries.js +190 -0
@@ -0,0 +1,961 @@
1
+ const chartFont = '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif';
2
+ const chartColor = '#b1bac4';
3
+ const chartGrid = '#21262d';
4
+
5
+ const scaleFont = { size: 13, family: chartFont };
6
+ const legendFont = { size: 13, family: chartFont, weight: '500' };
7
+
8
+ function scaleOpts(title) {
9
+ return {
10
+ ticks: { font: scaleFont, color: chartColor, padding: 6 },
11
+ grid: { color: chartGrid },
12
+ title: title ? { display: true, text: title, color: chartColor, font: { size: 13, family: chartFont } } : undefined,
13
+ };
14
+ }
15
+
16
+ function legendOpts(pos) {
17
+ return {
18
+ display: true,
19
+ position: pos || 'top',
20
+ labels: { font: legendFont, color: chartColor, padding: 16, usePointStyle: true },
21
+ };
22
+ }
23
+
24
+ let charts = [];
25
+
26
+ function destroyCharts() {
27
+ charts.forEach(c => c.destroy());
28
+ charts = [];
29
+ }
30
+
31
+ function newChart(canvas, config) {
32
+ const c = new Chart(canvas, config);
33
+ charts.push(c);
34
+ return c;
35
+ }
36
+
37
+ async function api(path) {
38
+ const res = await fetch(path);
39
+ if (!res.ok) {
40
+ const body = await res.json().catch(() => ({ error: res.statusText }));
41
+ throw new Error(body.error || `HTTP ${res.status}`);
42
+ }
43
+ return res.json();
44
+ }
45
+
46
+ function $(sel, parent) { return (parent || document).querySelector(sel); }
47
+
48
+ function $$(sel, parent) { return Array.from((parent || document).querySelectorAll(sel)); }
49
+
50
+ function html(strings, ...vals) {
51
+ let out = '';
52
+ for (let i = 0; i < strings.length; i++) {
53
+ out += strings[i];
54
+ if (i < vals.length) out += String(vals[i] ?? '');
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function escape(s) {
60
+ const d = document.createElement('div');
61
+ d.textContent = s;
62
+ return d.innerHTML;
63
+ }
64
+
65
+ function fmtHours(h) {
66
+ if (h >= 1000) return (h / 1000).toFixed(1) + 'k';
67
+ if (h >= 1) return h.toFixed(1);
68
+ return (h * 60).toFixed(0) + 'm';
69
+ }
70
+
71
+ function fmtMinutes(m) {
72
+ if (m >= 60) return (m / 60).toFixed(1) + 'h';
73
+ return m + 'm';
74
+ }
75
+
76
+ function navLink(text, hash) {
77
+ return html`<a href="#${escape(hash)}" class="session-card">${escape(text)}</a>`;
78
+ }
79
+
80
+ function renderMarkdown(md) {
81
+ let out = '';
82
+ const lines = md.split('\n');
83
+ let inList = false;
84
+ let inMermaid = false;
85
+ let mermaidCode = '';
86
+ let mermaidIdx = 0;
87
+ const mermaidBlocks = [];
88
+
89
+ function flushMermaid() {
90
+ if (mermaidCode) {
91
+ const id = 'mermaid-md-' + (mermaidIdx++);
92
+ out += html`<iframe class="mermaid" id="${id}" style="width:100%;min-height:300px;border:1px solid var(--border);border-radius:6px"></iframe>\n`;
93
+ mermaidBlocks.push({ id, code: mermaidCode });
94
+ mermaidCode = '';
95
+ }
96
+ }
97
+
98
+ for (let i = 0; i < lines.length; i++) {
99
+ let line = lines[i];
100
+ if (/^```mermaid/i.test(line.trim())) {
101
+ flushMermaid();
102
+ inMermaid = true;
103
+ continue;
104
+ }
105
+ if (inMermaid) {
106
+ if (/^```/.test(line.trim())) {
107
+ inMermaid = false;
108
+ flushMermaid();
109
+ continue;
110
+ }
111
+ mermaidCode += line + '\n';
112
+ continue;
113
+ }
114
+ if (line.startsWith('**') && line.endsWith('**')) {
115
+ out += html`<h3 style="margin:12px 0 6px">${escape(line.slice(2, -2))}</h3>\n`;
116
+ continue;
117
+ }
118
+ if (/^### /.test(line)) {
119
+ out += html`<h3 style="margin:12px 0 6px;font-size:15px">${escape(line.slice(4))}</h3>\n`;
120
+ continue;
121
+ }
122
+ if (/^## /.test(line)) {
123
+ out += html`<h2 style="margin:16px 0 8px;font-size:17px">${escape(line.slice(3))}</h2>\n`;
124
+ continue;
125
+ }
126
+ if (/^# /.test(line)) {
127
+ out += html`<h1 style="margin:16px 0 8px;font-size:19px">${escape(line.slice(2))}</h1>\n`;
128
+ continue;
129
+ }
130
+ if (/^- /.test(line)) {
131
+ if (!inList) { out += '<ul style="margin:4px 0 8px;padding-left:20px">'; inList = true; }
132
+ out += html`<li style="margin:2px 0">${escape(line.slice(2))}</li>\n`;
133
+ continue;
134
+ }
135
+ if (inList) { out += '</ul>\n'; inList = false; }
136
+ if (/^\d+\. /.test(line)) {
137
+ out += html`<li style="margin:2px 0">${escape(line.replace(/^\d+\. /, ''))}</li>\n`;
138
+ continue;
139
+ }
140
+ if (line.trim() === '') {
141
+ out += '<br>\n';
142
+ continue;
143
+ }
144
+ let processed = escape(line);
145
+ processed = processed.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
146
+ processed = processed.replace(/\*(.+?)\*/g, '<em>$1</em>');
147
+ processed = processed.replace(/`([^`]+)`/g, '<code style="background:var(--bg3);padding:1px 5px;border-radius:3px;font-size:13px">$1</code>');
148
+ out += html`<div style="margin:2px 0">${processed}</div>\n`;
149
+ }
150
+ flushMermaid();
151
+ if (inList) out += '</ul>\n';
152
+ return { html: out, mermaidBlocks };
153
+ }
154
+
155
+ function renderMermaidBlocks(blocks) {
156
+ for (const { id, code } of blocks) {
157
+ const el = document.getElementById(id);
158
+ if (el) {
159
+ const doc = el.contentDocument || el.contentWindow.document;
160
+ const mmdCode = JSON.stringify(code);
161
+ doc.open();
162
+ doc.write('<!DOCTYPE html><html><head><script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"><'+'/script><style>body{margin:0;padding:12px;background:#0d1117}</style></head><body><pre class="mermaid" style="margin:0"></pre><script>mermaid.initialize({startOnLoad:false,theme:"dark"});var e=document.querySelector(".mermaid");e.textContent='+mmdCode+';mermaid.run({nodes:[e]});<'+'/script></body></html>');
163
+ doc.close();
164
+ }
165
+ }
166
+ }
167
+
168
+ function renderPartText(parts) {
169
+ let texts = [];
170
+ for (const p of parts) {
171
+ if (p.type === 'text' && p.text) {
172
+ texts.push(escape(p.text.slice(0, 600)));
173
+ } else if (p.type === 'reasoning' && p.text) {
174
+ texts.push(html`<details style="margin:2px 0"><summary style="cursor:pointer;color:var(--text2);font-size:12px">reasoning</summary><div style="color:var(--text2);font-size:12px;margin-top:4px">${escape(p.text.slice(0, 600))}</div></details>`);
175
+ } else if (p.type === 'tool') {
176
+ const input = p.state?.input || {};
177
+ const keys = Object.keys(input).slice(0, 3);
178
+ const args = keys.map(k => `${k}=${escape(String(input[k]).slice(0, 60))}`).join(', ');
179
+ texts.push(html`<span style="color:var(--orange);font-size:12px">🔧 ${escape(p.tool)}(${args})</span>`);
180
+ } else if (p.type === 'patch') {
181
+ const files = p.files || [];
182
+ texts.push(html`<span style="color:var(--green);font-size:12px">📁 patch: ${files.length} file(s) changed</span>`);
183
+ }
184
+ }
185
+ return texts.join('<br>') || '<span style="color:var(--text2);font-size:12px">(metadata — no text content)</span>';
186
+ }
187
+
188
+ // ---------- Router ----------
189
+
190
+ function route() {
191
+ const hash = location.hash.slice(1) || '/';
192
+ destroyCharts();
193
+ $$('.nav-link').forEach(l => l.classList.toggle('active', l.getAttribute('href') === '#' + hash.split('?')[0].split('/')[0]));
194
+
195
+ if (hash === '/' || hash === '') renderOverview();
196
+ else if (hash.startsWith('/daily/')) renderDaily(hash.split('/')[2]);
197
+ else if (hash === '/daily') renderDailyList();
198
+ else if (hash.startsWith('/session/')) renderSession(hash.split('/')[2]);
199
+ else if (hash === '/sessions') renderSessionsList();
200
+ else if (hash === '/git') renderGit();
201
+ else if (hash.startsWith('/commit/')) renderCommit(hash.split('/')[2]);
202
+ else if (hash === '/experiments') renderExperimentsList();
203
+ else if (hash.startsWith('/experiment/')) renderExperiment(hash.split('/')[2]);
204
+ else if (hash === '/specs') renderSpecsTree();
205
+ else if (hash.startsWith('/spec/')) renderSpecFile(decodeURIComponent(hash.slice(6)));
206
+ else if (hash.startsWith('/search')) renderSearch(new URLSearchParams(hash.split('?')[1] || '').get('q') || '');
207
+ else renderOverview();
208
+ }
209
+
210
+ window.addEventListener('hashchange', route);
211
+
212
+ // ---------- Overview ----------
213
+
214
+ async function renderOverview() {
215
+ const el = $('#content');
216
+ el.innerHTML = '<div class="spinner">Loading...</div>';
217
+ try {
218
+ const [health, stats] = await Promise.all([api('/api/health'), api('/api/stats')]);
219
+ const days = stats.per_day || [];
220
+ const last = days[days.length - 1] || null;
221
+
222
+ el.innerHTML = html`
223
+ <h1 class="page-title">Overview</h1>
224
+ <div class="stats-grid">
225
+ <div class="card">
226
+ <div class="card-title">Session Days</div>
227
+ <div class="card-value">${stats.total_days}</div>
228
+ </div>
229
+ <div class="card">
230
+ <div class="card-title">Total Sessions</div>
231
+ <div class="card-value">${stats.total_sessions}</div>
232
+ </div>
233
+ <div class="card">
234
+ <div class="card-title">Prompter Time</div>
235
+ <div class="card-value">${fmtHours(stats.total_prompter_time_hours)}h</div>
236
+ </div>
237
+ <div class="card">
238
+ <div class="card-title">SME Time</div>
239
+ <div class="card-value">${fmtHours(stats.total_sme_time_hours)}h</div>
240
+ </div>
241
+ <div class="card">
242
+ <div class="card-title">Avg AI Multiplier</div>
243
+ <div class="card-value">${stats.avg_multiplier}x</div>
244
+ </div>
245
+ <div class="card">
246
+ <div class="card-title">Latest Day</div>
247
+ <div class="card-value small">${last ? escape(last.date) : 'N/A'}</div>
248
+ </div>
249
+ </div>
250
+ ${last ? html`
251
+ <div class="two-col">
252
+ <div class="chart-container">
253
+ <h3 class="card-title">Latest Day: ${escape(last.date)} — Top Subject Areas by SME Time</h3>
254
+ <canvas id="chart-top-subjects"></canvas>
255
+ </div>
256
+ <div class="chart-container">
257
+ <h3 class="card-title">Session Durations (minutes)</h3>
258
+ <canvas id="chart-session-durs"></canvas>
259
+ </div>
260
+ </div>
261
+ ` : ''}
262
+ <div class="chart-container">
263
+ <h3 class="card-title">AI Multiplier Trend</h3>
264
+ <canvas id="chart-trend"></canvas>
265
+ </div>
266
+ <div class="chart-container">
267
+ <h3 class="card-title">Daily Prompter vs SME Hours</h3>
268
+ <canvas id="chart-daily-bars"></canvas>
269
+ </div>
270
+ ${last ? html`
271
+ <h3 class="page-title" style="font-size:18px">Latest Sessions</h3>
272
+ ${last.session_breakdown.slice(-10).reverse().map(s => html`
273
+ <a href="#/session/${escape(s.session_id)}" class="session-card" style="display:block">
274
+ <div class="session-card-title">${escape(s.session_id)}</div>
275
+ <div class="session-card-meta">${s.duration_minutes}m · ${s.prompter_time_minutes}m prompter · ${(s.sme_time_minutes / 60).toFixed(1)}h SME</div>
276
+ <div>${escape(s.top_component_summary)}</div>
277
+ <div style="margin-top:6px">${s.tags.slice(0, 8).map(t => html`<span class="tag">${escape(t)}</span>`).join('')}</div>
278
+ </a>
279
+ `).join('')}
280
+ ` : ''}
281
+ `;
282
+
283
+ // Charts
284
+ if (last) {
285
+ const top15 = last.top_subject_areas.slice(0, 15);
286
+ newChart($('#chart-top-subjects'), {
287
+ type: 'bar',
288
+ data: {
289
+ labels: top15.map(s => s.name),
290
+ datasets: [{
291
+ label: 'SME hours',
292
+ data: top15.map(s => s.sme_time_hours),
293
+ backgroundColor: '#58a6ff',
294
+ }],
295
+ },
296
+ options: {
297
+ indexAxis: 'y',
298
+ responsive: true,
299
+ plugins: { legend: { display: false } },
300
+ scales: { x: scaleOpts('Hours'), y: scaleOpts() },
301
+ },
302
+ });
303
+
304
+ newChart($('#chart-session-durs'), {
305
+ type: 'bar',
306
+ data: {
307
+ labels: last.session_breakdown.map(s => s.session_id.replace(/^[\d-]+-/, '').slice(0, 20)),
308
+ datasets: [{
309
+ label: 'Duration (min)',
310
+ data: last.session_breakdown.map(s => s.duration_minutes),
311
+ backgroundColor: '#3fb950',
312
+ }],
313
+ },
314
+ options: {
315
+ responsive: true,
316
+ plugins: { legend: { display: false } },
317
+ scales: { x: scaleOpts(), y: scaleOpts('Minutes') },
318
+ },
319
+ });
320
+ }
321
+
322
+ if (days.length > 1) {
323
+ newChart($('#chart-trend'), {
324
+ type: 'line',
325
+ data: {
326
+ labels: days.map(d => d.date),
327
+ datasets: [{
328
+ label: 'AI Multiplier',
329
+ data: days.map(d => d.ai_multiplier),
330
+ borderColor: '#d29922',
331
+ backgroundColor: 'rgba(210,153,34,0.1)',
332
+ fill: true,
333
+ tension: 0.3,
334
+ }],
335
+ },
336
+ options: {
337
+ responsive: true,
338
+ plugins: { legend: legendOpts() },
339
+ scales: { x: scaleOpts(), y: scaleOpts('Multiplier') },
340
+ },
341
+ });
342
+
343
+ newChart($('#chart-daily-bars'), {
344
+ type: 'bar',
345
+ data: {
346
+ labels: days.map(d => d.date),
347
+ datasets: [
348
+ { label: 'Prompter', data: days.map(d => d.total_prompter_time_hours), backgroundColor: '#58a6ff' },
349
+ { label: 'SME', data: days.map(d => d.total_sme_time_hours), backgroundColor: '#3fb950' },
350
+ ],
351
+ },
352
+ options: {
353
+ responsive: true,
354
+ plugins: { legend: legendOpts() },
355
+ scales: { x: scaleOpts(), y: scaleOpts('Hours') },
356
+ },
357
+ });
358
+ }
359
+ } catch (e) {
360
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
361
+ }
362
+ }
363
+
364
+ // ---------- Daily List ----------
365
+
366
+ async function renderDailyList() {
367
+ const el = $('#content');
368
+ el.innerHTML = '<div class="spinner">Loading...</div>';
369
+ try {
370
+ const data = await api('/api/days');
371
+ el.innerHTML = html`
372
+ <h1 class="page-title">Daily Reports</h1>
373
+ ${data.days.map(d => html`
374
+ <a href="#/daily/${escape(d)}" class="session-card" style="display:block">
375
+ <div class="session-card-title">${escape(d)}</div>
376
+ </a>
377
+ `).join('')}
378
+ `;
379
+ } catch (e) {
380
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
381
+ }
382
+ }
383
+
384
+ // ---------- Daily Detail ----------
385
+
386
+ async function renderDaily(date) {
387
+ const el = $('#content');
388
+ el.innerHTML = '<div class="spinner">Loading...</div>';
389
+ try {
390
+ const day = await api(`/api/days/${date}`);
391
+ el.innerHTML = html`
392
+ <h1 class="page-title">${escape(day.date)}</h1>
393
+ <div class="stats-grid">
394
+ <div class="card"><div class="card-title">Sessions</div><div class="card-value">${day.total_sessions}</div></div>
395
+ <div class="card"><div class="card-title">Prompter</div><div class="card-value">${day.total_prompter_time_hours}h</div></div>
396
+ <div class="card"><div class="card-title">SME</div><div class="card-value">${day.total_sme_time_hours}h</div></div>
397
+ <div class="card"><div class="card-title">AI Multiplier</div><div class="card-value">${day.ai_multiplier}x</div></div>
398
+ </div>
399
+ ${day.metadata ? html`<div class="card"><em>${escape(day.metadata.audit_note)}</em></div>` : ''}
400
+
401
+ <div class="two-col">
402
+ <div class="chart-container">
403
+ <h3 class="card-title">Top Subject Areas</h3>
404
+ <canvas id="chart-subjects"></canvas>
405
+ </div>
406
+ <div class="chart-container">
407
+ <h3 class="card-title">Session Time Distribution</h3>
408
+ <canvas id="chart-session-pie"></canvas>
409
+ </div>
410
+ </div>
411
+
412
+ <h3 style="margin: 16px 0 8px; font-size:16px">Sessions</h3>
413
+ ${day.session_breakdown.map(s => html`
414
+ <a href="#/session/${escape(s.session_id)}" class="session-card" style="display:block">
415
+ <div class="session-card-title">${escape(s.session_id)}</div>
416
+ <div class="session-card-meta">${s.duration_minutes}m total · ${s.prompter_time_minutes}m prompter · ${fmtMinutes(s.sme_time_minutes)} SME · confidence: ${s.human_confidence}</div>
417
+ <div>${escape(s.top_component_summary)}</div>
418
+ <div style="margin-top:6px">${s.tags.map(t => html`<span class="tag">${escape(t)}</span>`).join('')}</div>
419
+ </a>
420
+ `).join('')}
421
+ `;
422
+
423
+ const top10 = day.top_subject_areas.slice(0, 10);
424
+ newChart($('#chart-subjects'), {
425
+ type: 'bar',
426
+ data: {
427
+ labels: top10.map(s => s.name),
428
+ datasets: [{
429
+ label: 'SME hours',
430
+ data: top10.map(s => s.sme_time_hours),
431
+ backgroundColor: '#58a6ff',
432
+ }],
433
+ },
434
+ options: {
435
+ indexAxis: 'y',
436
+ responsive: true,
437
+ plugins: { legend: { display: false } },
438
+ scales: { x: scaleOpts('Hours'), y: scaleOpts() },
439
+ },
440
+ });
441
+
442
+ const sessions = day.session_breakdown;
443
+ newChart($('#chart-session-pie'), {
444
+ type: 'doughnut',
445
+ data: {
446
+ labels: sessions.map(s => s.session_id.replace(/^[\d-]+-/, '').slice(0, 15)),
447
+ datasets: [{
448
+ data: sessions.map(s => s.duration_minutes),
449
+ backgroundColor: ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#79c0ff', '#56d364', '#e3b341'],
450
+ }],
451
+ },
452
+ options: { responsive: true, plugins: { legend: legendOpts('right') } },
453
+ });
454
+ } catch (e) {
455
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
456
+ }
457
+ }
458
+
459
+ // ---------- Sessions List ----------
460
+
461
+ async function renderSessionsList() {
462
+ const el = $('#content');
463
+ el.innerHTML = '<div class="spinner">Loading...</div>';
464
+ try {
465
+ const data = await api('/api/sessions?limit=200');
466
+ el.innerHTML = html`
467
+ <h1 class="page-title">All Sessions (${data.total})</h1>
468
+ ${data.sessions.map(s => html`
469
+ <a href="#/session/${escape(s.entry.session_id)}" class="session-card" style="display:block">
470
+ <div class="session-card-title">${escape(s.entry.session_id)}</div>
471
+ <div class="session-card-meta">${s.date} · ${s.entry.duration_minutes}m · ${s.entry.human_confidence}</div>
472
+ <div>${escape(s.entry.top_component_summary)}</div>
473
+ <div style="margin-top:6px">${s.entry.tags.slice(0, 6).map(t => html`<span class="tag">${escape(t)}</span>`).join('')}</div>
474
+ </a>
475
+ `).join('')}
476
+ `;
477
+ } catch (e) {
478
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
479
+ }
480
+ }
481
+
482
+ // ---------- Session Detail ----------
483
+
484
+ async function renderSession(id) {
485
+ const el = $('#content');
486
+ el.innerHTML = '<div class="spinner">Loading...</div>';
487
+
488
+ let summaryData, fullData;
489
+ try {
490
+ [summaryData, fullData] = await Promise.all([
491
+ api(`/api/sessions/${encodeURIComponent(id)}/summary`).catch(() => null),
492
+ api(`/api/sessions/${encodeURIComponent(id)}`).catch(() => null),
493
+ ]);
494
+ } catch {
495
+ el.innerHTML = html`<div class="error">Failed to load session data</div>`;
496
+ return;
497
+ }
498
+
499
+ const s = (summaryData || fullData)?.summary;
500
+ if (!s) {
501
+ el.innerHTML = html`<div class="error">Session not found: ${escape(id)}</div>`;
502
+ return;
503
+ }
504
+
505
+ const md = summaryData?.markdown || null;
506
+ const d = fullData?.detail || null;
507
+
508
+ let hasTranscript = !!d;
509
+
510
+ el.innerHTML = html`
511
+ <a href="#/daily/${escape(s.date)}" style="font-size:13px">&larr; Back to ${escape(s.date)}</a>
512
+ <h1 class="page-title">${escape(s.entry.session_id)}</h1>
513
+ <div class="stats-grid">
514
+ <div class="card"><div class="card-title">Duration</div><div class="card-value">${s.entry.duration_minutes}m</div></div>
515
+ <div class="card"><div class="card-title">Prompter</div><div class="card-value">${s.entry.prompter_time_minutes}m</div></div>
516
+ <div class="card"><div class="card-title">SME</div><div class="card-value">${fmtMinutes(s.entry.sme_time_minutes)}</div></div>
517
+ <div class="card"><div class="card-title">Confidence</div><div class="card-value small">${s.entry.human_confidence}</div></div>
518
+ </div>
519
+ <div class="card">
520
+ <div class="card-title">Description</div>
521
+ <div>${escape(s.entry.top_component_summary)}</div>
522
+ </div>
523
+ <div class="card">
524
+ <div class="card-title">Tags</div>
525
+ <div>${s.entry.tags.map(t => html`<span class="tag">${escape(t)}</span>`).join('')}</div>
526
+ </div>
527
+ ${d ? html`
528
+ <div class="card">
529
+ <div class="card-title">Session Info</div>
530
+ <table>
531
+ <tr><th>Title</th><td>${escape(d.info.title)}</td></tr>
532
+ <tr><th>Agent</th><td>${escape(d.info.agent)}</td></tr>
533
+ <tr><th>Model</th><td>${escape(d.info.model.id)} (${escape(d.info.model.providerID)})</td></tr>
534
+ <tr><th>Files</th><td>${d.info.summary.files} changed (+${d.info.summary.additions}/-${d.info.summary.deletions})</td></tr>
535
+ </table>
536
+ </div>
537
+ ` : ''}
538
+ ${md ? html`
539
+ <div class="card" id="summary-md">
540
+ <div class="card-title">Session Summary</div>
541
+ <div style="font-size:14px;line-height:1.7;color:var(--text)">${renderMarkdown(md)}</div>
542
+ </div>
543
+ ` : html`<div class="card"><em>No summary .md file for this session</em></div>`}
544
+ <div id="transcript-section">
545
+ ${hasTranscript ? html`
546
+ <div class="card" style="margin-top:16px">
547
+ <div class="card-title">Full Transcript (${d.messages.length} messages)</div>
548
+ ${buildTranscript(d.messages)}
549
+ </div>
550
+ ` : html`<div class="card"><em>Full transcript not available (no .json.bz2 file found)</em></div>`}
551
+ </div>
552
+ `;
553
+ }
554
+
555
+ function buildTranscript(messages) {
556
+ let out = '';
557
+ let i = 0;
558
+ while (i < messages.length) {
559
+ const msg = messages[i];
560
+ if (msg.info.role === 'user') {
561
+ out += html`
562
+ <div style="padding:10px 12px;margin-bottom:6px;border:1px solid var(--border);border-radius:6px;background:var(--bg2)">
563
+ <div style="font-size:12px;color:var(--accent);margin-bottom:4px;font-weight:500">user · ${new Date(msg.info.time.created).toLocaleString()}</div>
564
+ <div style="font-size:13px;line-height:1.5;word-break:break-word">${renderPartText(msg.parts)}</div>
565
+ </div>
566
+ `;
567
+ i++;
568
+ const assistantMsgs = [];
569
+ while (i < messages.length && messages[i].info.role !== 'user') {
570
+ assistantMsgs.push(messages[i]);
571
+ i++;
572
+ }
573
+ if (assistantMsgs.length > 0) {
574
+ const toolCount = assistantMsgs.filter(m => m.parts.some(p => p.type === 'tool')).length;
575
+ out += html`
576
+ <details style="margin:0 0 10px 0">
577
+ <summary style="cursor:pointer;padding:8px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg3);color:var(--text2);font-size:13px;font-weight:500">
578
+ Assistant Response (${assistantMsgs.length} messages${toolCount ? ', ' + toolCount + ' tool call(s)' : ''})
579
+ </summary>
580
+ <div style="padding:8px 12px 4px 20px;border-left:2px solid var(--border);margin-top:4px">
581
+ ${assistantMsgs.map(am => {
582
+ const hasTool = am.parts.some(p => p.type === 'tool');
583
+ const color = hasTool ? 'var(--orange)' : 'var(--green)';
584
+ return html`
585
+ <div style="padding:6px 0;border-bottom:1px solid var(--border)">
586
+ <div style="font-size:11px;color:${color};margin-bottom:2px;font-weight:500">assistant · ${new Date(am.info.time.created).toLocaleString()}</div>
587
+ <div style="font-size:13px;line-height:1.5;word-break:break-word">${renderPartText(am.parts)}</div>
588
+ </div>
589
+ `;
590
+ }).join('')}
591
+ </div>
592
+ </details>
593
+ `;
594
+ }
595
+ } else {
596
+ i++;
597
+ }
598
+ }
599
+ return out;
600
+ }
601
+
602
+ // ---------- Git ----------
603
+
604
+ async function renderGit() {
605
+ const el = $('#content');
606
+ el.innerHTML = '<div class="spinner">Loading...</div>';
607
+ try {
608
+ const [stats, log] = await Promise.all([api('/api/git/stats'), api('/api/git/log')]);
609
+ const dates = Object.keys(stats.per_date).sort();
610
+ el.innerHTML = html`
611
+ <h1 class="page-title">Git Activity</h1>
612
+ <div class="stats-grid">
613
+ <div class="card"><div class="card-title">Total Commits</div><div class="card-value">${stats.total_commits}</div></div>
614
+ <div class="card"><div class="card-title">Files Changed</div><div class="card-value">${stats.total_files_changed}</div></div>
615
+ <div class="card"><div class="card-title">Insertions</div><div class="card-value">${stats.total_insertions.toLocaleString()}</div></div>
616
+ <div class="card"><div class="card-title">Deletions</div><div class="card-value">${stats.total_deletions.toLocaleString()}</div></div>
617
+ </div>
618
+ <div class="chart-container">
619
+ <h3 class="card-title">Commits Per Day</h3>
620
+ <canvas id="chart-commits"></canvas>
621
+ </div>
622
+ <h3 style="margin:16px 0 8px;font-size:16px">Recent Commits</h3>
623
+ ${log.commits.slice(0, 50).map(c => html`
624
+ <a href="#/commit/${escape(c.commit)}" class="session-card" style="display:block">
625
+ <div class="session-card-title"><code>${escape(c.commit.slice(0, 8))}</code> ${escape(c.message)}</div>
626
+ <div class="session-card-meta">${escape(c.author)} · ${escape(c.date)} · +${c.insertions}/-${c.deletions} · ${c.files_changed} files</div>
627
+ </a>
628
+ `).join('')}
629
+ `;
630
+
631
+ newChart($('#chart-commits'), {
632
+ type: 'bar',
633
+ data: {
634
+ labels: dates,
635
+ datasets: [{
636
+ label: 'Commits',
637
+ data: dates.map(d => stats.per_date[d].commits),
638
+ backgroundColor: '#58a6ff',
639
+ }],
640
+ },
641
+ options: {
642
+ responsive: true,
643
+ plugins: { legend: { display: false } },
644
+ scales: { x: scaleOpts(), y: scaleOpts('Commits') },
645
+ },
646
+ });
647
+ } catch (e) {
648
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
649
+ }
650
+ }
651
+
652
+ // ---------- Commit Detail ----------
653
+
654
+ async function renderCommit(hash) {
655
+ const el = $('#content');
656
+ el.innerHTML = '<div class="spinner">Loading...</div>';
657
+ try {
658
+ const data = await api(`/api/git/commit/${encodeURIComponent(hash)}`);
659
+ el.innerHTML = html`
660
+ <a href="#/git" style="font-size:13px">&larr; Back to Git</a>
661
+ <h1 class="page-title">Commit ${escape(hash.slice(0, 8))}</h1>
662
+ <pre style="background:var(--bg3);padding:16px;border-radius:8px;overflow:auto;font-size:12px;font-family:var(--mono);max-height:80vh"><code class="language-diff">${escape(data.diff)}</code></pre>
663
+ `;
664
+ if (typeof hljs !== 'undefined') {
665
+ hljs.highlightElement(el.querySelector('code'));
666
+ }
667
+ } catch (e) {
668
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
669
+ }
670
+ }
671
+
672
+ // ---------- Search ----------
673
+
674
+ async function renderSearch(query) {
675
+ const el = $('#content');
676
+ el.innerHTML = html`
677
+ <h1 class="page-title">Search</h1>
678
+ <input type="text" class="search-box" placeholder="Search sessions..." value="${escape(query)}" id="search-input">
679
+ <div id="search-results"></div>
680
+ `;
681
+
682
+ const input = $('#search-input');
683
+ let timeout;
684
+
685
+ async function doSearch(q) {
686
+ if (!q.trim()) { $('#search-results').innerHTML = ''; return; }
687
+ $('#search-results').innerHTML = '<div class="spinner">Searching...</div>';
688
+ try {
689
+ const data = await api(`/api/search?q=${encodeURIComponent(q)}`);
690
+ if (data.results.length === 0) {
691
+ $('#search-results').innerHTML = '<div class="card"><em>No results found</em></div>';
692
+ return;
693
+ }
694
+ $('#search-results').innerHTML = data.results.map(r => html`
695
+ <a href="#/session/${escape(r.session_id)}" class="session-card" style="display:block">
696
+ <div class="session-card-title">${escape(r.session_id)}</div>
697
+ <div class="session-card-meta">Match: ${r.match_type} · ${escape(r.date)}</div>
698
+ <div>${escape(r.match_snippet)}</div>
699
+ </a>
700
+ `).join('');
701
+ location.hash = '#/search?q=' + encodeURIComponent(q);
702
+ } catch (e) {
703
+ $('#search-results').innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
704
+ }
705
+ }
706
+
707
+ input.addEventListener('input', () => {
708
+ clearTimeout(timeout);
709
+ timeout = setTimeout(() => doSearch(input.value), 300);
710
+ });
711
+
712
+ if (query) {
713
+ input.value = query;
714
+ doSearch(query);
715
+ }
716
+ }
717
+
718
+ // ---------- Experiments ----------
719
+
720
+ async function renderExperimentsList() {
721
+ const el = $('#content');
722
+ el.innerHTML = '<div class="spinner">Loading...</div>';
723
+ try {
724
+ const data = await api('/api/experiments');
725
+ el.innerHTML = html`
726
+ <h1 class="page-title">Experiments</h1>
727
+ ${data.experiments.length === 0 ? html`<div class="card"><em>No experiments found</em></div>` : ''}
728
+ ${data.experiments.map(exp => {
729
+ const outcomeClass = exp.outcome.toLowerCase().includes('accepted') ? 'badge-accepted' :
730
+ exp.outcome.toLowerCase().includes('archived') ? 'badge-archived' :
731
+ exp.outcome.toLowerCase().includes('todo') ? 'badge-todo' :
732
+ exp.outcome.toLowerCase().includes('wip') ? 'badge-wip' : '';
733
+ const shortOutcome = exp.outcome.split('——')[0]?.trim() || exp.outcome;
734
+ return html`
735
+ <a href="#/experiment/${encodeURIComponent(exp.directory)}" class="session-card" style="display:block">
736
+ <div style="display:flex;justify-content:space-between;align-items:center">
737
+ <div class="session-card-title">${escape(exp.description)}</div>
738
+ <span class="badge ${outcomeClass}">${escape(shortOutcome)}</span>
739
+ </div>
740
+ <div class="session-card-meta">${escape(exp.date)} · ${escape(exp.agent)} · ${escape(exp.directory)}</div>
741
+ </a>
742
+ `;
743
+ }).join('')}
744
+ `;
745
+ } catch (e) {
746
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
747
+ }
748
+ }
749
+
750
+ async function renderExperiment(name) {
751
+ const el = $('#content');
752
+ el.innerHTML = '<div class="spinner">Loading...</div>';
753
+ try {
754
+ const exp = await api(`/api/experiments/${encodeURIComponent(name)}`);
755
+ const outcomeClass = exp.entry.outcome.toLowerCase().includes('accepted') ? 'badge-accepted' :
756
+ exp.entry.outcome.toLowerCase().includes('archived') ? 'badge-archived' : '';
757
+
758
+ el.innerHTML = html`
759
+ <a href="#/experiments" style="font-size:13px">&larr; Back to Experiments</a>
760
+ <h1 class="page-title">${escape(exp.entry.description)}</h1>
761
+
762
+ <div class="stats-grid">
763
+ <div class="card"><div class="card-title">Date</div><div class="card-value small">${escape(exp.entry.date)}</div></div>
764
+ <div class="card"><div class="card-title">Agent</div><div class="card-value small">${escape(exp.entry.agent)}</div></div>
765
+ <div class="card"><div class="card-title">Outcome</div><div><span class="badge ${outcomeClass}" style="font-size:15px">${escape(exp.entry.outcome)}</span></div></div>
766
+ </div>
767
+
768
+ <div class="card">
769
+ <div class="card-title">Directory</div>
770
+ <code style="font-size:13px;background:var(--bg3);padding:2px 6px;border-radius:4px">${escape(exp.entry.directory)}</code>
771
+ </div>
772
+
773
+ ${exp.readme ? (() => {
774
+ const md = renderMarkdown(exp.readme);
775
+ setTimeout(() => renderMermaidBlocks(md.mermaidBlocks), 0);
776
+ return html`
777
+ <div class="card" id="exp-readme">
778
+ <div class="card-title">README.md</div>
779
+ <div style="font-size:14px;line-height:1.7;color:var(--text)">${md.html}</div>
780
+ </div>
781
+ `;
782
+ })() : html`<div class="card"><em>No README.md in this experiment</em></div>`}
783
+
784
+ ${exp.subdirs.length > 0 ? html`
785
+ <div class="card">
786
+ <div class="card-title">Files</div>
787
+ ${exp.subdirs.map(sd => html`
788
+ <div style="margin:8px 0">
789
+ <div style="font-weight:600;font-size:14px;margin-bottom:4px;color:var(--accent)}">${escape(sd.name)}/</div>
790
+ ${sd.files.map(f => html`
791
+ <div style="padding:2px 0 2px 16px;font-size:13px;font-family:var(--mono)">
792
+ <a href="javascript:void(0)" class="exp-file-link" data-path="${escape(f.path)}">${escape(f.name)}</a>
793
+ <span style="color:var(--text2);font-size:11px"> (${f.size} B)</span>
794
+ </div>
795
+ `).join('')}
796
+ </div>
797
+ `).join('')}
798
+ </div>
799
+ ` : ''}
800
+
801
+ <div id="exp-file-viewer"></div>
802
+ `;
803
+
804
+ $$('.exp-file-link').forEach(link => {
805
+ link.addEventListener('click', async () => {
806
+ const filePath = link.getAttribute('data-path');
807
+ const viewer = $('#exp-file-viewer');
808
+ viewer.innerHTML = '<div class="spinner">Loading...</div>';
809
+ const ext = (filePath.match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
810
+ const baseUrl = `/api/experiments/${encodeURIComponent(name)}/read?path=${encodeURIComponent(filePath)}`;
811
+ try {
812
+ if (ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif' || ext === 'svg') {
813
+ viewer.innerHTML = html`
814
+ <div class="card">
815
+ <div class="card-title" style="display:flex;justify-content:space-between">
816
+ <span>${escape(filePath)}</span>
817
+ <span style="cursor:pointer;color:var(--text2);font-size:12px" onclick="this.parentElement.parentElement.parentElement.innerHTML=''">[close]</span>
818
+ </div>
819
+ <div style="text-align:center;padding:12px"><img src="${baseUrl}&raw=true" style="max-width:100%;border-radius:6px"></div>
820
+ </div>
821
+ `;
822
+ } else if (ext === 'html') {
823
+ const rawUrl = baseUrl + '&raw=true';
824
+ viewer.innerHTML = html`
825
+ <div class="card">
826
+ <div class="card-title" style="display:flex;justify-content:space-between">
827
+ <span>${escape(filePath)}</span>
828
+ <span style="cursor:pointer;color:var(--text2);font-size:12px" onclick="this.parentElement.parentElement.parentElement.innerHTML=''">[close]</span>
829
+ </div>
830
+ <iframe src="${rawUrl}" style="width:100%;min-height:500px;border:1px solid var(--border);border-radius:6px;background:#fff"></iframe>
831
+ </div>
832
+ `;
833
+ } else if (ext === 'mmd') {
834
+ const data = await api(baseUrl);
835
+ const iframeId = 'mermaid-frame-' + Date.now();
836
+ viewer.innerHTML = html`
837
+ <div class="card">
838
+ <div class="card-title" style="display:flex;justify-content:space-between">
839
+ <span>${escape(filePath)}</span>
840
+ <span style="cursor:pointer;color:var(--text2);font-size:12px" onclick="this.parentElement.parentElement.parentElement.innerHTML=''">[close]</span>
841
+ </div>
842
+ <iframe id="${iframeId}" style="width:100%;min-height:500px;border:none;border-radius:6px"></iframe>
843
+ </div>
844
+ `;
845
+ const iframe = document.getElementById(iframeId);
846
+ const doc = iframe.contentDocument || iframe.contentWindow.document;
847
+ const mmdCode = JSON.stringify(data.content);
848
+ doc.open();
849
+ doc.write('<!DOCTYPE html><html><head><script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"><'+'/script><style>body{margin:0;padding:12px;background:#0d1117}</style></head><body><pre class="mermaid" style="margin:0"></pre><script>mermaid.initialize({startOnLoad:false,theme:"dark"});var e=document.querySelector(".mermaid");e.textContent='+mmdCode+';mermaid.run({nodes:[e]});<'+'/script></body></html>');
850
+ doc.close();
851
+ } else if (ext === 'cpp' || ext === 'h' || ext === 'asm' || ext === 'sh' || ext === 'js' || ext === 'ts' || ext === 'json') {
852
+ const data = await api(baseUrl);
853
+ const lang = ext === 'h' ? 'cpp' : ext === 'asm' ? 'asm' : ext === 'sh' ? 'bash' : ext;
854
+ viewer.innerHTML = html`
855
+ <div class="card">
856
+ <div class="card-title" style="display:flex;justify-content:space-between">
857
+ <span>${escape(filePath)}</span>
858
+ <span style="cursor:pointer;color:var(--text2);font-size:12px" onclick="this.parentElement.parentElement.parentElement.innerHTML=''">[close]</span>
859
+ </div>
860
+ <pre style="background:var(--bg3);padding:12px;border-radius:6px;overflow:auto;font-size:12px;font-family:var(--mono);max-height:60vh;margin-top:8px"><code class="language-${lang}">${escape(data.content)}</code></pre>
861
+ </div>
862
+ `;
863
+ const code = viewer.querySelector('code');
864
+ if (code && typeof hljs !== 'undefined') {
865
+ hljs.highlightElement(code);
866
+ }
867
+ } else if (ext === 'md') {
868
+ const data = await api(baseUrl);
869
+ const md = renderMarkdown(data.content);
870
+ viewer.innerHTML = html`
871
+ <div class="card">
872
+ <div class="card-title" style="display:flex;justify-content:space-between">
873
+ <span>${escape(filePath)}</span>
874
+ <span style="cursor:pointer;color:var(--text2);font-size:12px" onclick="this.parentElement.parentElement.parentElement.innerHTML=''">[close]</span>
875
+ </div>
876
+ <div style="font-size:14px;line-height:1.7;color:var(--text)">${md.html}</div>
877
+ </div>
878
+ `;
879
+ renderMermaidBlocks(md.mermaidBlocks);
880
+ } else {
881
+ const data = await api(baseUrl);
882
+ viewer.innerHTML = html`
883
+ <div class="card">
884
+ <div class="card-title" style="display:flex;justify-content:space-between">
885
+ <span>${escape(filePath)}</span>
886
+ <span style="cursor:pointer;color:var(--text2);font-size:12px" onclick="this.parentElement.parentElement.parentElement.innerHTML=''">[close]</span>
887
+ </div>
888
+ <pre style="background:var(--bg3);padding:12px;border-radius:6px;overflow:auto;font-size:12px;font-family:var(--mono);max-height:60vh;margin-top:8px">${escape(data.content)}</pre>
889
+ </div>
890
+ `;
891
+ }
892
+ } catch (e) {
893
+ viewer.innerHTML = html`<div class="error">Error loading file: ${escape(e.message)}</div>`;
894
+ }
895
+ });
896
+ });
897
+ } catch (e) {
898
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
899
+ }
900
+ }
901
+
902
+ // ---------- Tech Specs ----------
903
+
904
+ async function renderSpecsTree() {
905
+ const el = $('#content');
906
+ el.innerHTML = '<div class="spinner">Loading...</div>';
907
+ try {
908
+ const tree = await api('/api/specs');
909
+ el.innerHTML = html`
910
+ <h1 class="page-title">Technical Specifications</h1>
911
+ <div class="card" style="padding:8px 16px">
912
+ ${renderSpecNode(tree, 0)}
913
+ </div>
914
+ `;
915
+ } catch (e) {
916
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
917
+ }
918
+ }
919
+
920
+ function renderSpecNode(node, depth) {
921
+ if (node.isDir) {
922
+ const visible = depth <= 1 ? 'open' : '';
923
+ return html`
924
+ <details ${visible} style="margin-left:${depth > 0 ? 16 : 0}px">
925
+ <summary style="cursor:pointer;padding:3px 0;font-weight:${depth === 0 ? 600 : 500};color:var(--text)">${node.name}</summary>
926
+ <div style="border-left:1px solid var(--border);padding-left:12px;margin-left:4px">
927
+ ${(node.children || []).map(c => renderSpecNode(c, depth + 1)).join('')}
928
+ </div>
929
+ </details>
930
+ `;
931
+ }
932
+ const isRoot = node.name === 'technical-specification.md';
933
+ return html`
934
+ <div style="margin-left:${depth * 16}px;padding:2px 0">
935
+ <a href="#/spec/${encodeURIComponent(node.path)}" style="font-size:14px;${isRoot ? 'font-weight:600' : ''}">${escape(node.name)}</a>
936
+ </div>
937
+ `;
938
+ }
939
+
940
+ async function renderSpecFile(path) {
941
+ const el = $('#content');
942
+ el.innerHTML = '<div class="spinner">Loading...</div>';
943
+ try {
944
+ const data = await api(`/api/specs/read?path=${encodeURIComponent(path)}`);
945
+ const md = renderMarkdown(data.content);
946
+ el.innerHTML = html`
947
+ <a href="#/specs" style="font-size:13px">&larr; Back to Specs</a>
948
+ <h1 class="page-title">${escape(data.path)}</h1>
949
+ <div class="card">
950
+ <div style="font-size:14px;line-height:1.7;color:var(--text)">${md.html}</div>
951
+ </div>
952
+ `;
953
+ renderMermaidBlocks(md.mermaidBlocks);
954
+ } catch (e) {
955
+ el.innerHTML = html`<div class="error">Error: ${escape(e.message)}</div>`;
956
+ }
957
+ }
958
+
959
+ // ---------- Init ----------
960
+
961
+ document.addEventListener('DOMContentLoaded', route);