rubyrlm 0.1.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE +21 -0
  4. data/README.md +300 -0
  5. data/bin/rubyrlm +168 -0
  6. data/lib/rubyrlm/backends/base.rb +9 -0
  7. data/lib/rubyrlm/backends/gemini_rest.rb +317 -0
  8. data/lib/rubyrlm/client.rb +643 -0
  9. data/lib/rubyrlm/completion.rb +71 -0
  10. data/lib/rubyrlm/errors.rb +9 -0
  11. data/lib/rubyrlm/logger/jsonl_logger.rb +27 -0
  12. data/lib/rubyrlm/pricing.rb +88 -0
  13. data/lib/rubyrlm/prompts/system_prompt.rb +108 -0
  14. data/lib/rubyrlm/protocol/action_parser.rb +84 -0
  15. data/lib/rubyrlm/repl/code_validator.rb +113 -0
  16. data/lib/rubyrlm/repl/docker_repl/container_manager.rb +158 -0
  17. data/lib/rubyrlm/repl/docker_repl/host_rpc_server.rb +164 -0
  18. data/lib/rubyrlm/repl/docker_repl/protocol.rb +26 -0
  19. data/lib/rubyrlm/repl/docker_repl.rb +190 -0
  20. data/lib/rubyrlm/repl/execution_result.rb +41 -0
  21. data/lib/rubyrlm/repl/local_repl.rb +476 -0
  22. data/lib/rubyrlm/sub_call_cache.rb +47 -0
  23. data/lib/rubyrlm/version.rb +3 -0
  24. data/lib/rubyrlm/web/app.rb +41 -0
  25. data/lib/rubyrlm/web/public/css/components.css +649 -0
  26. data/lib/rubyrlm/web/public/css/design-system.css +1396 -0
  27. data/lib/rubyrlm/web/public/js/app.js +1016 -0
  28. data/lib/rubyrlm/web/public/js/components/charts.js +68 -0
  29. data/lib/rubyrlm/web/public/js/components/context-inspector.js +94 -0
  30. data/lib/rubyrlm/web/public/js/components/exec-chain.js +105 -0
  31. data/lib/rubyrlm/web/public/js/components/kpi-dashboard.js +187 -0
  32. data/lib/rubyrlm/web/public/js/components/query-panel.js +335 -0
  33. data/lib/rubyrlm/web/public/js/components/recursion-tree.js +83 -0
  34. data/lib/rubyrlm/web/public/js/components/session-list.js +160 -0
  35. data/lib/rubyrlm/web/public/js/components/step-navigator.js +129 -0
  36. data/lib/rubyrlm/web/public/js/components/timeline.js +281 -0
  37. data/lib/rubyrlm/web/public/js/lib/animation.js +46 -0
  38. data/lib/rubyrlm/web/public/js/lib/chart-renderer.js +116 -0
  39. data/lib/rubyrlm/web/public/js/lib/diagram-renderer.js +233 -0
  40. data/lib/rubyrlm/web/public/js/lib/sse-client.js +94 -0
  41. data/lib/rubyrlm/web/public/js/lib/theme-manager.js +39 -0
  42. data/lib/rubyrlm/web/public/js/utils.js +57 -0
  43. data/lib/rubyrlm/web/routes/api.rb +129 -0
  44. data/lib/rubyrlm/web/routes/pages.rb +365 -0
  45. data/lib/rubyrlm/web/routes/sse.rb +95 -0
  46. data/lib/rubyrlm/web/services/event_broadcaster.rb +36 -0
  47. data/lib/rubyrlm/web/services/export_service.rb +903 -0
  48. data/lib/rubyrlm/web/services/query_service.rb +221 -0
  49. data/lib/rubyrlm/web/services/session_loader.rb +356 -0
  50. data/lib/rubyrlm/web/services/streaming_logger.rb +22 -0
  51. data/lib/rubyrlm.rb +18 -0
  52. metadata +208 -0
@@ -0,0 +1,335 @@
1
+ // Query panel component - submit prompts and stream results
2
+
3
+ const QueryPanel = {
4
+ activeSSE: null,
5
+ activeRunId: null,
6
+ lastSessionId: null,
7
+ streamingCard: null,
8
+ retryNoticeEl: null,
9
+
10
+ init() {
11
+ const environmentSelect = document.getElementById('query-environment');
12
+ const allowNetworkCheckbox = document.getElementById('query-docker-network');
13
+ const keepAliveCheckbox = document.getElementById('query-docker-keep-alive');
14
+ const reuseSelect = document.getElementById('query-docker-reuse-id');
15
+ if (!environmentSelect || !allowNetworkCheckbox) return;
16
+
17
+ const syncEnvironmentState = () => {
18
+ const dockerSelected = environmentSelect.value === 'docker';
19
+ allowNetworkCheckbox.disabled = !dockerSelected;
20
+ if (keepAliveCheckbox) keepAliveCheckbox.disabled = !dockerSelected;
21
+ if (reuseSelect) reuseSelect.disabled = !dockerSelected;
22
+ if (!dockerSelected) {
23
+ allowNetworkCheckbox.checked = false;
24
+ if (keepAliveCheckbox) keepAliveCheckbox.checked = false;
25
+ if (reuseSelect) reuseSelect.value = '';
26
+ }
27
+ this.updateWarningBanner();
28
+ };
29
+
30
+ environmentSelect.addEventListener('change', syncEnvironmentState);
31
+ allowNetworkCheckbox.addEventListener('change', () => this.updateWarningBanner());
32
+ if (reuseSelect) {
33
+ reuseSelect.addEventListener('focus', () => this.refreshContainers());
34
+ }
35
+ syncEnvironmentState();
36
+ this.refreshContainers();
37
+ },
38
+
39
+ async refreshContainers() {
40
+ const reuseSelect = document.getElementById('query-docker-reuse-id');
41
+ if (!reuseSelect) return;
42
+ try {
43
+ const res = await fetch('/api/containers');
44
+ if (res.ok) {
45
+ const containers = await res.json();
46
+ const currentVal = reuseSelect.value;
47
+ reuseSelect.innerHTML = '<option value="">-- New Container --</option>';
48
+ containers.forEach(c => {
49
+ const opt = document.createElement('option');
50
+ opt.value = c.ID;
51
+ opt.textContent = `${c.ID.substring(0, 12)} - ${c.Status}`;
52
+ reuseSelect.appendChild(opt);
53
+ });
54
+ if (Array.from(reuseSelect.options).some(o => o.value === currentVal)) {
55
+ reuseSelect.value = currentVal;
56
+ }
57
+ }
58
+ } catch (_) { }
59
+ },
60
+
61
+ updateWarningBanner() {
62
+ const warningEl = document.getElementById('query-warning-banner');
63
+ if (!warningEl) return;
64
+
65
+ const { environment } = this.readEnvironmentConfig();
66
+ const message = environment === 'docker'
67
+ ? 'Prompts run inside Docker isolation. Enable network only when required.'
68
+ : 'Prompts execute arbitrary Ruby code locally. Run only trusted instructions.';
69
+
70
+ warningEl.textContent = '';
71
+ const icon = document.createElement('i');
72
+ icon.className = 'fa-solid fa-shield-halved';
73
+ warningEl.appendChild(icon);
74
+ warningEl.appendChild(document.createTextNode(' ' + message));
75
+ },
76
+
77
+ async submit() {
78
+ const prompt = document.getElementById('query-prompt').value.trim();
79
+ if (!prompt) return;
80
+
81
+ const config = {
82
+ model_name: document.getElementById('query-model').value,
83
+ max_iterations: parseInt(document.getElementById('query-max-iter').value) || 30,
84
+ iteration_timeout: parseInt(document.getElementById('query-timeout').value) || 60,
85
+ max_depth: parseInt(document.getElementById('query-max-depth').value) || 1,
86
+ temperature: parseFloat(document.getElementById('query-temp').value) || 0.5
87
+ };
88
+ const thinkingSelect = document.getElementById('query-thinking');
89
+ if (thinkingSelect && thinkingSelect.value) {
90
+ config.thinking_level = thinkingSelect.value;
91
+ }
92
+ const environmentConfig = this.readEnvironmentConfig();
93
+ config.environment = environmentConfig.environment;
94
+ config.environment_options = environmentConfig.environment_options;
95
+
96
+ // Clear previous results
97
+ document.getElementById('timeline').textContent = '';
98
+ this.clearRetryNotice();
99
+
100
+ // Toggle buttons
101
+ document.getElementById('query-run-btn').style.display = 'none';
102
+ document.getElementById('query-cancel-btn').style.display = 'inline-flex';
103
+
104
+ try {
105
+ const body = { prompt, ...config };
106
+ if (this.lastSessionId) {
107
+ body.session_id = this.lastSessionId;
108
+ }
109
+ const res = await fetch('/api/query', {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify(body)
113
+ });
114
+
115
+ if (!res.ok) {
116
+ let detail = '';
117
+ try {
118
+ const err = await res.json();
119
+ detail = err.error || err.message || '';
120
+ } catch (_) {
121
+ detail = (await res.text()) || '';
122
+ }
123
+ const suffix = detail ? (': ' + detail) : '';
124
+ throw new Error('Failed to start query: HTTP ' + res.status + suffix);
125
+ }
126
+
127
+ const data = await res.json();
128
+ this.activeRunId = data.run_id;
129
+
130
+ // PIVOT UI: Immediately show the user's prompt locked safely inside the session header.
131
+ if (typeof App !== 'undefined') {
132
+ App.showStreamingSession(this.activeRunId, prompt, config.model_name);
133
+ }
134
+
135
+ if (this.activeRunId) history.pushState(null, '', '#' + this.activeRunId);
136
+
137
+ // Connect SSE
138
+ this.activeSSE = new SSEClient('/api/query/' + data.run_id + '/stream', {
139
+ onChunk: (event) => this.onChunk(event),
140
+ onRetry: (event) => this.onRetry(event),
141
+ onIteration: (event) => this.onIteration(event),
142
+ onComplete: (event) => this.onComplete(event),
143
+ onError: (err) => this.onError(err),
144
+ onDisconnect: () => this.onDisconnect()
145
+ });
146
+ this.activeSSE.connect();
147
+ } catch (err) {
148
+ this.onError(err);
149
+ }
150
+ },
151
+
152
+ cancel() {
153
+ if (this.activeRunId) {
154
+ fetch('/api/query/' + this.activeRunId, { method: 'DELETE' }).catch(() => { });
155
+ }
156
+ if (this.activeSSE) {
157
+ this.activeSSE.close();
158
+ this.activeSSE = null;
159
+ }
160
+ this.resetButtons();
161
+ },
162
+
163
+ onChunk(data) {
164
+ if (!this.activeSSE) return;
165
+ const timeline = document.getElementById('timeline');
166
+ if (!this.streamingCard) {
167
+ this.streamingCard = Timeline.buildStreamingCard();
168
+ timeline.appendChild(this.streamingCard);
169
+ }
170
+ const textEl = this.streamingCard.querySelector('.streaming-card__text');
171
+ if (textEl) textEl.textContent = data.accumulated || '';
172
+ this.streamingCard.scrollIntoView({ behavior: 'smooth' });
173
+ },
174
+
175
+ onIteration(data) {
176
+ this.clearRetryNotice();
177
+ if (this.streamingCard) {
178
+ this.streamingCard.remove();
179
+ this.streamingCard = null;
180
+ }
181
+ const timeline = document.getElementById('timeline');
182
+ const d = data.data || data;
183
+ const isSubmit = d.action === 'final' || d.action === 'forced_final';
184
+ const isError = !isSubmit && d.execution && !d.execution.ok;
185
+
186
+ const card = Timeline.buildCard(d, isSubmit, isError, true, '?');
187
+ card.classList.add('animate-in');
188
+ timeline.appendChild(card);
189
+
190
+ if (typeof Prism !== 'undefined') Prism.highlightAll();
191
+ card.scrollIntoView({ behavior: 'smooth' });
192
+ },
193
+
194
+ async onComplete(data) {
195
+ this.clearRetryNotice();
196
+ const finishedRunId = this.activeRunId;
197
+ this.resetButtons();
198
+
199
+ const sessionId = data.session_id || finishedRunId;
200
+ this.lastSessionId = sessionId || null;
201
+
202
+ // Keep URL in sync
203
+ if (sessionId) {
204
+ history.replaceState(null, '', '#' + sessionId);
205
+ }
206
+
207
+ // Update placeholder to hint that the next query will continue this session
208
+ const textarea = document.getElementById('query-prompt');
209
+ if (textarea) {
210
+ textarea.value = '';
211
+ if (sessionId) {
212
+ textarea.placeholder = '>_ Continue session ' + shortId(sessionId) + '...';
213
+ }
214
+ }
215
+
216
+ // Silently refresh session list sidebar (no view switching, no re-selection)
217
+ if (sessionId) {
218
+ SessionList.activeId = sessionId;
219
+ }
220
+ SessionList.load({ preserveSelection: true, autoSelect: false });
221
+
222
+ // Pre-render the session in the Sessions view (background) so
223
+ // the Continue Session prompt is ready when the user switches views.
224
+ // Also render a ContinuePrompt in the Controller's timeline.
225
+ if (sessionId) {
226
+ try {
227
+ const session = await this.fetchSessionWithRetry(sessionId);
228
+ if (session) {
229
+ App.showSession(session, { pushState: false });
230
+ // Show Continue Session prompt inline in the generic timeline
231
+ const queryTimeline = document.getElementById('timeline');
232
+ if (queryTimeline) {
233
+ ContinuePrompt.render(session, queryTimeline);
234
+ }
235
+ }
236
+ } catch (_) { /* non-critical */ }
237
+ }
238
+ },
239
+
240
+ async fetchSessionWithRetry(sessionId, attempts = 6, delayMs = 150) {
241
+ for (let i = 0; i < attempts; i++) {
242
+ try {
243
+ const session = await fetchJSON('/api/sessions/' + sessionId);
244
+ if (session) return session;
245
+ } catch (_) {
246
+ // Session file may not be visible immediately after run completion.
247
+ }
248
+
249
+ if (i < attempts - 1) {
250
+ await new Promise(resolve => setTimeout(resolve, delayMs));
251
+ }
252
+ }
253
+ return null;
254
+ },
255
+
256
+ onError(err) {
257
+ this.clearRetryNotice();
258
+ this.resetButtons();
259
+ const timeline = document.getElementById('query-timeline');
260
+ const errDiv = document.createElement('div');
261
+ errDiv.className = 'node';
262
+ errDiv.style.borderColor = 'var(--color-error)';
263
+ errDiv.textContent = 'Error: ' + (err.message || err);
264
+ timeline.appendChild(errDiv);
265
+ },
266
+
267
+ onDisconnect() {
268
+ this.resetButtons();
269
+ },
270
+
271
+ onRetry(data) {
272
+ const timeline = document.getElementById('query-timeline');
273
+ if (!timeline) return;
274
+
275
+ if (!this.retryNoticeEl) {
276
+ this.retryNoticeEl = document.createElement('div');
277
+ this.retryNoticeEl.className = 'retry-notice';
278
+ timeline.appendChild(this.retryNoticeEl);
279
+ }
280
+ const attempt = Number(data.attempt || 0);
281
+ const nextAttempt = Number(data.next_attempt || (attempt + 1));
282
+ const totalAttempts = Number(data.max_retries || 0) + 1;
283
+ const backoff = Number(data.backoff_seconds || 0).toFixed(1);
284
+ const status = data.status_code ? (' [HTTP ' + data.status_code + ']') : '';
285
+ this.retryNoticeEl.textContent = 'Gemini temporary error' + status + '. Retrying attempt ' + nextAttempt + '/' + totalAttempts + ' in ' + backoff + 's...';
286
+ },
287
+
288
+ clearRetryNotice() {
289
+ if (this.retryNoticeEl) {
290
+ this.retryNoticeEl.remove();
291
+ this.retryNoticeEl = null;
292
+ }
293
+ },
294
+
295
+ readEnvironmentConfig() {
296
+ const environmentSelect = document.getElementById('query-environment');
297
+ const allowNetworkCheckbox = document.getElementById('query-docker-network');
298
+ const keepAliveCheckbox = document.getElementById('query-docker-keep-alive');
299
+ const reuseSelect = document.getElementById('query-docker-reuse-id');
300
+ const environment = (environmentSelect && environmentSelect.value) ? environmentSelect.value : 'local';
301
+ const environment_options = {};
302
+
303
+ if (environment === 'docker') {
304
+ if (allowNetworkCheckbox && allowNetworkCheckbox.checked) {
305
+ environment_options.allow_network = true;
306
+ }
307
+ if (keepAliveCheckbox && keepAliveCheckbox.checked) {
308
+ environment_options.keep_alive = true;
309
+ }
310
+ if (reuseSelect && reuseSelect.value) {
311
+ environment_options.reuse_container_id = reuseSelect.value;
312
+ }
313
+ }
314
+
315
+ return { environment, environment_options };
316
+ },
317
+
318
+ resetButtons() {
319
+ document.getElementById('query-run-btn').style.display = 'inline-flex';
320
+ document.getElementById('query-cancel-btn').style.display = 'none';
321
+ if (this.streamingCard) { this.streamingCard.remove(); this.streamingCard = null; }
322
+ this.clearRetryNotice();
323
+ this.activeSSE = null;
324
+ this.activeRunId = null;
325
+ }
326
+ };
327
+
328
+ function submitQuery() { QueryPanel.submit(); }
329
+ function cancelQuery() { QueryPanel.cancel(); }
330
+
331
+ if (document.readyState === 'loading') {
332
+ document.addEventListener('DOMContentLoaded', () => QueryPanel.init());
333
+ } else {
334
+ QueryPanel.init();
335
+ }
@@ -0,0 +1,83 @@
1
+ // Recursion tree Mermaid component
2
+
3
+ const RecursionTree = {
4
+ async render(sessionId) {
5
+ try {
6
+ const tree = await fetchJSON('/api/sessions/' + sessionId + '/tree');
7
+ if (!tree.children || tree.children.length === 0) {
8
+ document.getElementById('recursion-tree-container').style.display = 'none';
9
+ return;
10
+ }
11
+
12
+ document.getElementById('recursion-tree-container').style.display = 'block';
13
+ const graph = this.buildGraph(tree);
14
+ await DiagramRenderer.render('recursion-tree-diagram', graph.definition);
15
+ this.setupClickHandlers(graph.nodeMap);
16
+ } catch {
17
+ document.getElementById('recursion-tree-container').style.display = 'none';
18
+ }
19
+ },
20
+
21
+ buildGraph(tree) {
22
+ const lines = ['graph TD'];
23
+ const nodeMap = {};
24
+
25
+ const walk = (node, depth, path) => {
26
+ const nodeId = `R_${path}`;
27
+ const label = 'Run ' + this.escape((node.id || '').substring(0, 8)) +
28
+ (node.model ? '\\n' + this.escape(node.model) : '') +
29
+ (node.iterations ? '\\n' + node.iterations + ' steps' : '');
30
+
31
+ lines.push(' ' + nodeId + '["' + label + '"]');
32
+ nodeMap[nodeId] = node.id;
33
+
34
+ if (depth === 0) {
35
+ lines.push(' style ' + nodeId + ' fill:#0a2e1a,stroke:#10b981,color:#6ee7b7');
36
+ }
37
+
38
+ (node.children || []).forEach((child, idx) => {
39
+ const childPath = `${path}_${idx}`;
40
+ const childId = `R_${childPath}`;
41
+ walk(child, depth + 1, childPath);
42
+ lines.push(' ' + nodeId + ' --> ' + childId);
43
+ });
44
+ };
45
+
46
+ walk(tree, 0, '0');
47
+ return { definition: lines.join('\n'), nodeMap };
48
+ },
49
+
50
+ setupClickHandlers(nodeMap) {
51
+ const container = document.getElementById('recursion-tree-diagram');
52
+ if (!container) return;
53
+
54
+ const nodes = container.querySelectorAll('.node');
55
+ nodes.forEach(node => {
56
+ node.style.cursor = 'pointer';
57
+ node.addEventListener('click', async () => {
58
+ const id = node.id || '';
59
+ const match = id.match(/R_[0-9_]+/);
60
+ if (!match) return;
61
+
62
+ const runId = nodeMap[match[0]];
63
+ if (!runId) return;
64
+
65
+ try {
66
+ const session = await fetchJSON('/api/sessions/' + runId);
67
+ if (!session) return;
68
+
69
+ if (App.currentMode !== 'sessions') switchMode('sessions');
70
+ App.showSession(session, { pushState: true });
71
+ SessionList.activeId = runId;
72
+ SessionList.render();
73
+ } catch (error) {
74
+ console.error('Failed to load recursion node session:', error);
75
+ }
76
+ });
77
+ });
78
+ },
79
+
80
+ escape(str) {
81
+ return (str || '').replace(/"/g, "'").replace(/[<>{}|]/g, ' ');
82
+ }
83
+ };
@@ -0,0 +1,160 @@
1
+ // Session list component
2
+
3
+ const SessionList = {
4
+ sessions: [],
5
+ activeId: null,
6
+
7
+ async load(opts = {}) {
8
+ try {
9
+ this.sessions = await fetchJSON('/api/sessions');
10
+ this.render();
11
+ if (this.sessions.length === 0) {
12
+ this.activeId = null;
13
+ App.clearSessionView();
14
+ return;
15
+ }
16
+
17
+ // If there's a pending session from the URL hash, load that; otherwise load the first
18
+ const pending = App._pendingSessionId;
19
+ const preserveSelection = opts.preserveSelection === true;
20
+ const autoSelect = opts.autoSelect !== false;
21
+ const preferredFromOpts = opts.preferredSessionId;
22
+ const preferred = preferredFromOpts || App.currentSession?.run_start?.run_id || this.activeId;
23
+ if (pending && this.sessions.some(s => s.id === pending)) {
24
+ App._pendingSessionId = null;
25
+ this.select(pending);
26
+ } else if (preserveSelection && preferred && this.sessions.some(s => s.id === preferred)) {
27
+ // Just highlight the active item without re-selecting (which would re-render the view)
28
+ this.activeId = preferred;
29
+ document.querySelectorAll('.session-item').forEach(btn => {
30
+ btn.classList.toggle('session-item--active', btn.dataset.id === preferred);
31
+ });
32
+ } else if (!preserveSelection && autoSelect && this.sessions.length > 0) {
33
+ this.select(this.sessions[0].id);
34
+ } else if (preserveSelection && !preferred) {
35
+ this.activeId = null;
36
+ document.querySelectorAll('.session-item').forEach(btn => {
37
+ btn.classList.remove('session-item--active');
38
+ });
39
+ }
40
+ } catch (err) {
41
+ const list = document.getElementById('session-list');
42
+ list.textContent = '';
43
+ const errDiv = document.createElement('div');
44
+ errDiv.className = 'sidebar__loading';
45
+ errDiv.style.color = 'var(--color-error)';
46
+ errDiv.textContent = 'Error loading sessions';
47
+ list.appendChild(errDiv);
48
+ }
49
+ },
50
+
51
+ render() {
52
+ const list = document.getElementById('session-list');
53
+ list.textContent = '';
54
+
55
+ if (this.sessions.length === 0) {
56
+ const empty = document.createElement('div');
57
+ empty.className = 'sidebar__loading';
58
+ empty.textContent = 'No sessions found';
59
+ list.appendChild(empty);
60
+ return;
61
+ }
62
+
63
+ this.sessions.forEach((s, i) => {
64
+ const btn = document.createElement('button');
65
+ btn.className = 'session-item animate-in';
66
+ btn.style.setProperty('--i', i);
67
+ btn.dataset.id = s.id;
68
+ if (s.id === this.activeId) btn.classList.add('session-item--active');
69
+ btn.addEventListener('click', () => this.select(s.id));
70
+
71
+ const nameDiv = document.createElement('div');
72
+ nameDiv.className = 'session-item__name';
73
+ nameDiv.textContent = shortId(s.id);
74
+ btn.appendChild(nameDiv);
75
+
76
+ const metaDiv = document.createElement('div');
77
+ metaDiv.className = 'session-item__meta';
78
+
79
+ const dateSpan = document.createElement('span');
80
+ dateSpan.textContent = formatDate(s.timestamp);
81
+ metaDiv.appendChild(dateSpan);
82
+
83
+ if (s.iterations > 0) {
84
+ const iterSpan = document.createElement('span');
85
+ iterSpan.textContent = s.iterations + ' steps';
86
+ metaDiv.appendChild(iterSpan);
87
+ }
88
+
89
+ if (s.errors > 0) {
90
+ const errBadge = document.createElement('span');
91
+ errBadge.className = 'session-item__badge session-item__badge--err';
92
+ errBadge.textContent = s.errors + ' err';
93
+ metaDiv.appendChild(errBadge);
94
+ }
95
+
96
+ if (s.total_cost > 0) {
97
+ const costSpan = document.createElement('span');
98
+ costSpan.className = 'session-item__badge';
99
+ costSpan.textContent = '$' + s.total_cost.toFixed(4);
100
+ metaDiv.appendChild(costSpan);
101
+ }
102
+
103
+ btn.appendChild(metaDiv);
104
+ list.appendChild(btn);
105
+ });
106
+
107
+ // Populate comparison dropdowns
108
+ this.populateCompareDropdowns();
109
+ },
110
+
111
+ async select(sessionId) {
112
+ if (this.activeId === sessionId) {
113
+ this.clearSelection();
114
+ return;
115
+ }
116
+
117
+ this.activeId = sessionId;
118
+
119
+ // Update active state
120
+ document.querySelectorAll('.session-item').forEach(btn => {
121
+ btn.classList.toggle('session-item--active', btn.dataset.id === sessionId);
122
+ });
123
+
124
+ try {
125
+ const session = await fetchJSON('/api/sessions/' + sessionId);
126
+ App.showSession(session);
127
+ } catch (err) {
128
+ console.error('Failed to load session:', err);
129
+ }
130
+ },
131
+
132
+ clearSelection() {
133
+ this.activeId = null;
134
+ document.querySelectorAll('.session-item').forEach(btn => {
135
+ btn.classList.remove('session-item--active');
136
+ });
137
+ App.clearSessionView();
138
+ },
139
+
140
+ populateCompareDropdowns() {
141
+ ['compare-a', 'compare-b'].forEach(id => {
142
+ const select = document.getElementById(id);
143
+ if (!select) return;
144
+ const current = select.value;
145
+ select.textContent = '';
146
+ const defaultOpt = document.createElement('option');
147
+ defaultOpt.value = '';
148
+ defaultOpt.textContent = 'Select session';
149
+ select.appendChild(defaultOpt);
150
+
151
+ this.sessions.forEach(s => {
152
+ const opt = document.createElement('option');
153
+ opt.value = s.id;
154
+ opt.textContent = shortId(s.id) + ' (' + s.iterations + ' steps)';
155
+ select.appendChild(opt);
156
+ });
157
+ if (current) select.value = current;
158
+ });
159
+ }
160
+ };