@hsupu/copilot-api 0.7.17-beta.0 → 0.7.18-beta

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,1799 @@
1
+ // State
2
+ let currentSessionId = null
3
+ let currentEntryId = null
4
+ let currentEntry = null
5
+ let currentPage = 1
6
+ let totalPages = 1
7
+ let listSearchTimer = null
8
+ let detailSearchTimer = null
9
+ let detailSearchQuery = ""
10
+
11
+ // SVG icons (Ionicons5 style, consistent with v2)
12
+ const ICON_EXPAND =
13
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><polyline points="432 368 432 432 368 432"/><line x1="432" y1="432" x2="336" y2="336"/><polyline points="80 144 80 80 144 80"/><line x1="80" y1="80" x2="176" y2="176"/><polyline points="368 80 432 80 432 144"/><line x1="432" y1="80" x2="336" y2="176"/><polyline points="144 432 80 432 80 368"/><line x1="80" y1="432" x2="176" y2="336"/></svg>'
14
+ const ICON_CONTRACT =
15
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><polyline points="336 368 336 432 400 432"/><line x1="336" y1="432" x2="432" y2="336"/><polyline points="176 144 176 80 112 80"/><line x1="176" y1="80" x2="80" y2="176"/><polyline points="400 80 336 80 336 144"/><line x1="336" y1="80" x2="432" y2="176"/><polyline points="112 432 176 432 176 368"/><line x1="176" y1="432" x2="80" y2="336"/></svg>'
16
+ // Copy - Ionicons CopyOutline
17
+ const ICON_COPY =
18
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linejoin="round"><rect x="128" y="128" width="336" height="336" rx="57" ry="57"/><path d="M383.5 128l.5-24a56.16 56.16 0 00-56-56H112a64.19 64.19 0 00-64 64v216a56.16 56.16 0 0056 56h24" stroke-linecap="round"/></svg>'
19
+ // Code view - Ionicons CodeSlashOutline
20
+ const ICON_CODE =
21
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><path d="M160 368L32 256l128-112"/><path d="M352 368l128-112-128-112"/><path d="M304 96l-96 320"/></svg>'
22
+ // Close - Ionicons CloseOutline
23
+ const ICON_CLOSE =
24
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><path d="M368 368L144 144"/><path d="M368 144L144 368"/></svg>'
25
+ // Search - Ionicons SearchOutline
26
+ const ICON_SEARCH =
27
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><path d="M221.09 64a157.09 157.09 0 10157.09 157.09A157.1 157.1 0 00221.09 64z"/><path d="M338.29 338.29L448 448"/></svg>'
28
+ // Refresh - Ionicons RefreshOutline
29
+ const ICON_REFRESH =
30
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><path d="M320 146s24.36-12-64-12a160 160 0 10160 160" /><polyline points="256 58 336 138 256 218"/></svg>'
31
+ // Download - Ionicons DownloadOutline
32
+ const ICON_DOWNLOAD =
33
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><path d="M336 176h40a40 40 0 0140 40v208a40 40 0 01-40 40H136a40 40 0 01-40-40V216a40 40 0 0140-40h40"/><polyline points="176 272 256 352 336 272"/><line x1="256" y1="48" x2="256" y2="336"/></svg>'
34
+ // Trash - Ionicons TrashOutline
35
+ const ICON_TRASH =
36
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><path d="M112 112l20 320c.95 18.49 14.4 32 32 32h184c17.67 0 30.87-13.51 32-32l20-320"/><line x1="80" y1="112" x2="432" y2="112"/><path d="M192 112V72h0a23.93 23.93 0 0124-24h80a23.93 23.93 0 0124 24h0v40" stroke-linecap="round"/></svg>'
37
+
38
+ // Collapse state for sections
39
+ let sectionCollapseState = { meta: false, request: false, response: false }
40
+
41
+ // Raw data registry - stores data for showRaw to avoid inline JSON in onclick
42
+ let rawDataRegistry = []
43
+ function registerRawData(data) {
44
+ const index = rawDataRegistry.length
45
+ rawDataRegistry.push(data)
46
+ return index
47
+ }
48
+
49
+ // Formatting utilities
50
+ function formatTime(ts) {
51
+ const d = new Date(ts)
52
+ const h = String(d.getHours()).padStart(2, "0")
53
+ const m = String(d.getMinutes()).padStart(2, "0")
54
+ const s = String(d.getSeconds()).padStart(2, "0")
55
+ return h + ":" + m + ":" + s
56
+ }
57
+
58
+ function formatDate(ts) {
59
+ const d = new Date(ts)
60
+ const today = new Date()
61
+ if (d.toDateString() === today.toDateString()) {
62
+ return formatTime(ts)
63
+ }
64
+ const y = d.getFullYear()
65
+ const mo = String(d.getMonth() + 1).padStart(2, "0")
66
+ const day = String(d.getDate()).padStart(2, "0")
67
+ return y + "-" + mo + "-" + day + " " + formatTime(ts)
68
+ }
69
+
70
+ function formatNumber(n) {
71
+ if (n === undefined || n === null) return "-"
72
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"
73
+ if (n >= 1000) return (n / 1000).toFixed(1) + "K"
74
+ return n.toString()
75
+ }
76
+
77
+ function formatDuration(ms) {
78
+ if (!ms) return "-"
79
+ if (ms < 1000) return ms + "ms"
80
+ return (ms / 1000).toFixed(1) + "s"
81
+ }
82
+
83
+ function escapeHtml(text) {
84
+ if (!text) return ""
85
+ const div = document.createElement("div")
86
+ div.textContent = text
87
+ return div.innerHTML
88
+ }
89
+
90
+ // JSON Tree view builder (pure JS, no external deps)
91
+ function buildJsonTree(data, depth) {
92
+ if (depth === undefined) depth = 3
93
+ const container = document.createElement("div")
94
+ container.className = "json-tree"
95
+ const ul = document.createElement("ul")
96
+ buildNode(ul, null, data, depth, 0, false)
97
+ container.append(ul)
98
+ return container
99
+ }
100
+
101
+ function buildNode(parent, key, value, maxDepth, currentDepth, isLast) {
102
+ const li = document.createElement("li")
103
+
104
+ if (value !== null && typeof value === "object") {
105
+ const isArray = Array.isArray(value)
106
+ const entries = isArray ? value : Object.entries(value)
107
+ const len = isArray ? value.length : Object.keys(value).length
108
+ const openBracket = isArray ? "[" : "{"
109
+ const closeBracket = isArray ? "]" : "}"
110
+
111
+ const toggle = document.createElement("span")
112
+ toggle.className = "jt-toggle"
113
+ toggle.textContent = currentDepth < maxDepth ? "\u25BE" : "\u25B8"
114
+ li.append(toggle)
115
+
116
+ if (key !== null) {
117
+ const keySpan = document.createElement("span")
118
+ keySpan.className = "jt-key"
119
+ keySpan.textContent = '"' + key + '"'
120
+ li.append(keySpan)
121
+ li.append(document.createTextNode(": "))
122
+ }
123
+
124
+ const bracket = document.createElement("span")
125
+ bracket.className = "jt-bracket"
126
+ bracket.textContent = openBracket
127
+ li.append(bracket)
128
+
129
+ // Collapsed info
130
+ const collapsedInfo = document.createElement("span")
131
+ collapsedInfo.className = "jt-collapsed-info"
132
+ collapsedInfo.textContent = " " + len + (len === 1 ? " item" : " items") + " "
133
+ collapsedInfo.style.display = currentDepth < maxDepth ? "none" : "inline"
134
+ li.append(collapsedInfo)
135
+
136
+ // Children
137
+ const childUl = document.createElement("ul")
138
+ childUl.style.display = currentDepth < maxDepth ? "block" : "none"
139
+
140
+ if (isArray) {
141
+ for (let i = 0; i < value.length; i++) {
142
+ buildNode(childUl, null, value[i], maxDepth, currentDepth + 1, i === value.length - 1)
143
+ }
144
+ } else {
145
+ const keys = Object.keys(value)
146
+ for (let i = 0; i < keys.length; i++) {
147
+ buildNode(childUl, keys[i], value[keys[i]], maxDepth, currentDepth + 1, i === keys.length - 1)
148
+ }
149
+ }
150
+ li.append(childUl)
151
+
152
+ // Close bracket
153
+ const closeBr = document.createElement("span")
154
+ closeBr.className = "jt-bracket"
155
+ closeBr.textContent = closeBracket
156
+ li.append(closeBr)
157
+
158
+ if (!isLast) {
159
+ const comma = document.createElement("span")
160
+ comma.className = "jt-comma"
161
+ comma.textContent = ","
162
+ li.append(comma)
163
+ }
164
+
165
+ // Toggle handler
166
+ toggle.addEventListener("click", function () {
167
+ const isOpen = childUl.style.display !== "none"
168
+ childUl.style.display = isOpen ? "none" : "block"
169
+ collapsedInfo.style.display = isOpen ? "inline" : "none"
170
+ toggle.textContent = isOpen ? "\u25B8" : "\u25BE"
171
+ })
172
+ } else {
173
+ // Leaf: spacer for alignment
174
+ const spacer = document.createElement("span")
175
+ spacer.style.display = "inline-block"
176
+ spacer.style.width = "14px"
177
+ spacer.style.marginRight = "2px"
178
+ li.append(spacer)
179
+
180
+ if (key !== null) {
181
+ const keySpan = document.createElement("span")
182
+ keySpan.className = "jt-key"
183
+ keySpan.textContent = '"' + key + '"'
184
+ li.append(keySpan)
185
+ li.append(document.createTextNode(": "))
186
+ }
187
+
188
+ const valSpan = document.createElement("span")
189
+ if (value === null) {
190
+ valSpan.className = "jt-null"
191
+ valSpan.textContent = "null"
192
+ } else if (typeof value === "string") {
193
+ valSpan.className = "jt-string"
194
+ if (value.length > 300) {
195
+ // Long string: show truncated with expand toggle
196
+ const previewText = value.slice(0, 300)
197
+ valSpan.textContent = '"' + previewText + '..."'
198
+ valSpan.classList.add("jt-string-truncated")
199
+
200
+ const expandBtn = document.createElement("span")
201
+ expandBtn.className = "jt-expand-string"
202
+ expandBtn.textContent = "(" + value.length.toLocaleString() + " chars - click to expand)"
203
+ let expanded = false
204
+ expandBtn.addEventListener("click", function (e) {
205
+ e.stopPropagation()
206
+ expanded = !expanded
207
+ if (expanded) {
208
+ valSpan.textContent = '"' + value + '"'
209
+ expandBtn.textContent = "(click to collapse)"
210
+ } else {
211
+ valSpan.textContent = '"' + previewText + '..."'
212
+ expandBtn.textContent = "(" + value.length.toLocaleString() + " chars - click to expand)"
213
+ }
214
+ })
215
+ li.append(valSpan)
216
+ li.append(expandBtn)
217
+ if (!isLast) {
218
+ const comma = document.createElement("span")
219
+ comma.className = "jt-comma"
220
+ comma.textContent = ","
221
+ li.append(comma)
222
+ }
223
+ parent.append(li)
224
+ return // Early return since we handled appending
225
+ }
226
+ valSpan.textContent = '"' + value + '"'
227
+ } else if (typeof value === "number") {
228
+ valSpan.className = "jt-number"
229
+ valSpan.textContent = String(value)
230
+ } else if (typeof value === "boolean") {
231
+ valSpan.className = "jt-boolean"
232
+ valSpan.textContent = String(value)
233
+ } else {
234
+ valSpan.textContent = String(value)
235
+ }
236
+ li.append(valSpan)
237
+
238
+ if (!isLast) {
239
+ const comma = document.createElement("span")
240
+ comma.className = "jt-comma"
241
+ comma.textContent = ","
242
+ li.append(comma)
243
+ }
244
+ }
245
+
246
+ parent.append(li)
247
+ }
248
+
249
+ function highlightSearch(text, query) {
250
+ if (!query || !text) return escapeHtml(text)
251
+ const escaped = escapeHtml(text)
252
+ const regex = new RegExp("(" + query.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`) + ")", "gi")
253
+ return escaped.replace(regex, '<span class="search-highlight">$1</span>')
254
+ }
255
+
256
+ // Get preview text from messages
257
+ function getPreviewText(request) {
258
+ if (!request || !request.request) return ""
259
+ const messages = request.request.messages || []
260
+ for (let i = messages.length - 1; i >= 0; i--) {
261
+ const msg = messages[i]
262
+ if (msg.role === "user") {
263
+ const content = msg.content
264
+ if (typeof content === "string") {
265
+ return content.replaceAll(/<[^>]+>/g, "").slice(0, 100)
266
+ }
267
+ if (Array.isArray(content)) {
268
+ for (const block of content) {
269
+ if (block.type === "text" && block.text) {
270
+ return block.text.replaceAll(/<[^>]+>/g, "").slice(0, 100)
271
+ }
272
+ if (block.type === "tool_result") {
273
+ return "[tool_result: " + (block.tool_use_id || "").slice(0, 8) + "]"
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
279
+ return ""
280
+ }
281
+
282
+ function getMessageSummary(content) {
283
+ if (typeof content === "string") {
284
+ return content.length > 80 ? content.slice(0, 80) + "..." : content
285
+ }
286
+ if (Array.isArray(content)) {
287
+ if (content.length === 1 && content[0].type === "text") {
288
+ const text = content[0].text || ""
289
+ return text.length > 80 ? text.slice(0, 80) + "..." : text
290
+ }
291
+ const counts = {}
292
+ for (const b of content) {
293
+ counts[b.type] = (counts[b.type] || 0) + 1
294
+ }
295
+ return Object.entries(counts)
296
+ .map(function (e) {
297
+ return e[1] + " " + e[0]
298
+ })
299
+ .join(", ")
300
+ }
301
+ return ""
302
+ }
303
+
304
+ function getContentBlockSummary(block) {
305
+ if (block.type === "text") {
306
+ const text = block.text || ""
307
+ return text.length > 60 ? text.slice(0, 60) + "..." : text
308
+ }
309
+ if (block.type === "thinking") {
310
+ const text = block.thinking || ""
311
+ return text.length > 60 ? text.slice(0, 60) + "..." : text
312
+ }
313
+ if (block.type === "tool_use") return block.name || ""
314
+ if (block.type === "tool_result") return "for " + (block.tool_use_id || "")
315
+ return block.type
316
+ }
317
+
318
+ // Load sessions for dropdown
319
+ async function loadSessions() {
320
+ try {
321
+ const response = await fetch("/history/api/sessions")
322
+ const data = await response.json()
323
+ const select = document.querySelector("#session-select")
324
+ select.innerHTML = '<option value="">All Sessions</option>'
325
+ for (const session of data.sessions || []) {
326
+ const opt = document.createElement("option")
327
+ opt.value = session.id
328
+ const time = formatDate(session.startTime)
329
+ opt.textContent = time + " (" + session.requestCount + " reqs)"
330
+ select.append(opt)
331
+ }
332
+ } catch (e) {
333
+ console.error("Failed to load sessions:", e)
334
+ }
335
+ }
336
+
337
+ // Load stats
338
+ async function loadStats() {
339
+ try {
340
+ const response = await fetch("/history/api/stats")
341
+ const stats = await response.json()
342
+ document.querySelector("#stat-total").textContent = formatNumber(stats.totalRequests)
343
+ document.querySelector("#stat-success").textContent = formatNumber(stats.successfulRequests)
344
+ document.querySelector("#stat-failed").textContent = formatNumber(stats.failedRequests)
345
+ document.querySelector("#stat-input").textContent = formatNumber(stats.totalInputTokens)
346
+ document.querySelector("#stat-output").textContent = formatNumber(stats.totalOutputTokens)
347
+ } catch (e) {
348
+ console.error("Failed to load stats:", e)
349
+ }
350
+ }
351
+
352
+ // Load entries
353
+ async function loadEntries() {
354
+ const listEl = document.querySelector("#request-list")
355
+ const search = document.querySelector("#list-search").value
356
+ const endpoint = document.querySelector("#filter-endpoint").value
357
+ const success = document.querySelector("#filter-status").value
358
+
359
+ const params = new URLSearchParams()
360
+ params.set("page", currentPage)
361
+ params.set("limit", 20)
362
+ if (currentSessionId) params.set("sessionId", currentSessionId)
363
+ if (search) params.set("search", search)
364
+ if (endpoint) params.set("endpoint", endpoint)
365
+ if (success) params.set("success", success)
366
+
367
+ try {
368
+ const response = await fetch("/history/api/entries?" + params.toString())
369
+ const data = await response.json()
370
+ totalPages = data.totalPages || 1
371
+
372
+ if (!data.entries || data.entries.length === 0) {
373
+ listEl.innerHTML = '<div class="empty-state"><h3>No requests found</h3><p>Try adjusting your filters</p></div>'
374
+ updateSearchCount(0, search)
375
+ renderPagination()
376
+ return
377
+ }
378
+
379
+ updateSearchCount(data.total, search)
380
+
381
+ let html = ""
382
+ for (const entry of data.entries) {
383
+ const isSelected = entry.id === currentEntryId
384
+ const statusClass =
385
+ !entry.response ? "pending"
386
+ : entry.response.success !== false ? "success"
387
+ : "error"
388
+ const model = entry.response?.model || entry.request?.model || "unknown"
389
+ const endpoint = entry.endpoint || "unknown"
390
+ const inputTokens = entry.response?.usage?.input_tokens
391
+ const outputTokens = entry.response?.usage?.output_tokens
392
+ const preview = getPreviewText(entry)
393
+ const isStream = entry.request?.stream
394
+
395
+ html +=
396
+ '<div class="request-item'
397
+ + (isSelected ? " selected" : "")
398
+ + '" data-id="'
399
+ + entry.id
400
+ + '" onclick="selectEntry(\''
401
+ + entry.id
402
+ + "')\">"
403
+ html += '<div class="request-item-header">'
404
+ html += '<span class="request-status ' + statusClass + '"></span>'
405
+ html += '<span class="request-time">' + formatDate(entry.timestamp) + "</span>"
406
+ html += "</div>"
407
+ html += '<div class="request-item-body">'
408
+ html += '<span class="request-model">' + escapeHtml(model) + "</span>"
409
+ html += '<span class="badge ' + endpoint + '">' + endpoint + "</span>"
410
+ if (isStream) html += '<span class="badge stream">stream</span>'
411
+ html += "</div>"
412
+ html += '<div class="request-item-meta">'
413
+ html += "<span>\u2193" + formatNumber(inputTokens) + "</span>"
414
+ html += "<span>\u2191" + formatNumber(outputTokens) + "</span>"
415
+ html += "<span>" + formatDuration(entry.durationMs) + "</span>"
416
+ html += "</div>"
417
+ if (preview) {
418
+ html += '<div class="request-preview">' + escapeHtml(preview) + "</div>"
419
+ }
420
+ html += "</div>"
421
+ }
422
+
423
+ listEl.innerHTML = html
424
+ renderPagination()
425
+
426
+ // Auto-select the first (newest) entry if nothing is selected
427
+ if (!currentEntryId && data.entries.length > 0) {
428
+ selectEntry(data.entries[0].id)
429
+ }
430
+ } catch (e) {
431
+ console.error("Failed to load entries:", e)
432
+ listEl.innerHTML =
433
+ '<div class="empty-state"><h3>Error loading requests</h3><p>' + escapeHtml(e.message) + "</p></div>"
434
+ }
435
+ }
436
+
437
+ function updateSearchCount(total, search) {
438
+ let countEl = document.querySelector("#search-count")
439
+ if (!countEl) {
440
+ const searchInput = document.querySelector("#list-search")
441
+ countEl = document.createElement("span")
442
+ countEl.id = "search-count"
443
+ countEl.style.cssText = "font-size:11px;color:var(--text-secondary);margin-left:8px;"
444
+ searchInput.parentElement.append(countEl)
445
+ }
446
+ countEl.textContent = search ? `${total} hit${total !== 1 ? "s" : ""}` : ""
447
+ }
448
+
449
+ function renderPagination() {
450
+ const el = document.querySelector("#list-pagination")
451
+ if (totalPages <= 1) {
452
+ el.innerHTML = ""
453
+ return
454
+ }
455
+
456
+ let html = ""
457
+ html +=
458
+ "<button " + (currentPage <= 1 ? "disabled" : "") + ' onclick="goToPage(' + (currentPage - 1) + ')">\u25C0</button>'
459
+
460
+ const maxVisible = 5
461
+ let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2))
462
+ let endPage = Math.min(totalPages, startPage + maxVisible - 1)
463
+ if (endPage - startPage < maxVisible - 1) {
464
+ startPage = Math.max(1, endPage - maxVisible + 1)
465
+ }
466
+
467
+ if (startPage > 1) {
468
+ html += '<button onclick="goToPage(1)">1</button>'
469
+ if (startPage > 2) html += "<span>...</span>"
470
+ }
471
+
472
+ for (let i = startPage; i <= endPage; i++) {
473
+ html +=
474
+ '<button class="' + (i === currentPage ? "active" : "") + '" onclick="goToPage(' + i + ')">' + i + "</button>"
475
+ }
476
+
477
+ if (endPage < totalPages) {
478
+ if (endPage < totalPages - 1) html += "<span>...</span>"
479
+ html += '<button onclick="goToPage(' + totalPages + ')">' + totalPages + "</button>"
480
+ }
481
+
482
+ html +=
483
+ "<button "
484
+ + (currentPage >= totalPages ? "disabled" : "")
485
+ + ' onclick="goToPage('
486
+ + (currentPage + 1)
487
+ + ')">\u25B6</button>'
488
+ el.innerHTML = html
489
+ }
490
+
491
+ function goToPage(page) {
492
+ currentPage = page
493
+ loadEntries()
494
+ }
495
+
496
+ // Select entry
497
+ async function selectEntry(id) {
498
+ currentEntryId = id
499
+
500
+ // Update selection UI
501
+ for (const el of document.querySelectorAll(".request-item")) {
502
+ el.classList.toggle("selected", el.dataset.id === id)
503
+ }
504
+
505
+ try {
506
+ const response = await fetch("/history/api/entries/" + id)
507
+ currentEntry = await response.json()
508
+ sectionCollapseState = { meta: false, request: false, response: false }
509
+ showDetailView()
510
+ renderDetail()
511
+ // Scroll detail panel to bottom so the latest messages/response are visible
512
+ const detailContent = document.querySelector("#detail-content")
513
+ if (detailContent) detailContent.scrollTop = detailContent.scrollHeight
514
+ } catch (e) {
515
+ console.error("Failed to load entry:", e)
516
+ }
517
+ }
518
+
519
+ function showDetailView() {
520
+ document.querySelector("#detail-empty").style.display = "none"
521
+ const detailView = document.querySelector("#detail-view")
522
+ detailView.style.display = "flex"
523
+ }
524
+
525
+ function hideDetailView() {
526
+ document.querySelector("#detail-empty").style.display = "flex"
527
+ document.querySelector("#detail-view").style.display = "none"
528
+ currentEntry = null
529
+ currentEntryId = null
530
+ }
531
+
532
+ // Toggle a section collapse
533
+ function toggleSection(section) {
534
+ sectionCollapseState[section] = !sectionCollapseState[section]
535
+ renderDetail()
536
+ }
537
+
538
+ // Toggle message collapse
539
+ function toggleMessage(msgId) {
540
+ const block = document.getElementById(msgId)
541
+ if (!block) return
542
+ const bodies = block.querySelectorAll(".message-body")
543
+ const summary = block.querySelector(".collapsed-summary")
544
+ const icon = block.querySelector(".collapse-icon")
545
+ // Check if currently collapsed (first body hidden)
546
+ const isCollapsed = bodies.length > 0 && bodies[0].dataset.collapsed === "true"
547
+ if (isCollapsed) {
548
+ for (const b of bodies) {
549
+ // Restore previous display state (only the active view should be visible)
550
+ b.style.display = b.dataset.prevDisplay || ""
551
+ delete b.dataset.collapsed
552
+ delete b.dataset.prevDisplay
553
+ }
554
+ if (summary) summary.style.display = "none"
555
+ if (icon) icon.textContent = "\u25BE"
556
+ } else {
557
+ for (const b of bodies) {
558
+ b.dataset.prevDisplay = b.style.display
559
+ b.dataset.collapsed = "true"
560
+ b.style.display = "none"
561
+ }
562
+ if (summary) summary.style.display = ""
563
+ if (icon) icon.textContent = "\u25B8"
564
+ }
565
+ }
566
+
567
+ // Toggle content block collapse
568
+ function toggleContentBlock(blockId) {
569
+ const block = document.getElementById(blockId)
570
+ if (!block) return
571
+ const body = block.querySelector(".content-block-body")
572
+ const summary = block.querySelector(".collapsed-summary")
573
+ const icon = block.querySelector(".collapse-icon")
574
+ if (body.style.display === "none") {
575
+ body.style.display = ""
576
+ if (summary) summary.style.display = "none"
577
+ if (icon) icon.textContent = "\u25BE"
578
+ } else {
579
+ body.style.display = "none"
580
+ if (summary) summary.style.display = ""
581
+ if (icon) icon.textContent = "\u25B8"
582
+ }
583
+ }
584
+
585
+ // Render detail view
586
+ function renderDetail() {
587
+ if (!currentEntry) return
588
+
589
+ // Clear raw data registry and reset ID counters for fresh render
590
+ rawDataRegistry = []
591
+ msgIdCounter = 0
592
+ blockIdCounter = 0
593
+
594
+ const content = document.querySelector("#detail-content")
595
+ const filterRole = document.querySelector("#filter-role").value
596
+ const filterType = document.querySelector("#filter-type").value
597
+ const aggregateTools = document.querySelector("#toggle-aggregate").checked
598
+ const rewrites = currentEntry.rewrites
599
+ const truncation = rewrites?.truncation
600
+
601
+ // Build tool result map for aggregation
602
+ const toolResultMap = {}
603
+ // Build tool_use_id → tool name map for tool_result headers
604
+ const toolUseNameMap = {}
605
+ if (aggregateTools) {
606
+ const messages = currentEntry.request?.messages || []
607
+ for (const msg of messages) {
608
+ if (Array.isArray(msg.content)) {
609
+ for (const block of msg.content) {
610
+ if (block.type === "tool_result" && block.tool_use_id) {
611
+ toolResultMap[block.tool_use_id] = block
612
+ }
613
+ if (block.type === "tool_use" && block.id) {
614
+ toolUseNameMap[block.id] = block.name || ""
615
+ }
616
+ }
617
+ }
618
+ }
619
+ } else {
620
+ const messages = currentEntry.request?.messages || []
621
+ for (const msg of messages) {
622
+ if (Array.isArray(msg.content)) {
623
+ for (const block of msg.content) {
624
+ if (block.type === "tool_use" && block.id) {
625
+ toolUseNameMap[block.id] = block.name || ""
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+
632
+ let html = '<div class="conversation">'
633
+
634
+ // REQUEST section
635
+ const fullEntryRawIdx = registerRawData(currentEntry)
636
+ const reqIcon = sectionCollapseState.request ? "\u25B8" : "\u25BE"
637
+ const msgCount = (currentEntry.request?.messages || []).length
638
+ const reqRawIdx = registerRawData(currentEntry.request)
639
+ html += '<div class="section-block">'
640
+ html += '<div class="section-header">'
641
+ html += '<div class="section-header-left" onclick="toggleSection(\'request\')">'
642
+ html += '<span class="collapse-icon">' + reqIcon + "</span>"
643
+ html += "REQUEST"
644
+ html += '<span class="section-badge">' + msgCount + " messages</span>"
645
+ html += "</div>"
646
+ html += '<div class="section-header-actions">'
647
+ html +=
648
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Request\', '
649
+ + reqRawIdx
650
+ + ')" title="View Raw">'
651
+ + ICON_CODE
652
+ + "Raw</button>"
653
+ html += "</div>"
654
+ html += "</div>"
655
+
656
+ if (!sectionCollapseState.request) {
657
+ html += '<div class="section-body">'
658
+
659
+ // System message
660
+ const system = currentEntry.request?.system
661
+ const rewrittenSystem = rewrites?.rewrittenSystem
662
+ if (system && (!filterRole || filterRole === "system")) {
663
+ html += renderSystemMessage(system, rewrittenSystem)
664
+ }
665
+
666
+ // Messages
667
+ const messages = currentEntry.request?.messages || []
668
+ const rewrittenMessages = rewrites?.rewrittenMessages
669
+ const messageMapping = rewrites?.messageMapping
670
+ const removedCount = truncation ? truncation.removedMessageCount : 0
671
+
672
+ // Build reverse lookup: origIdx → rwIdx (first occurrence)
673
+ var origToRwIdx = {}
674
+ if (messageMapping && Array.isArray(messageMapping)) {
675
+ for (var [mi, oi] of messageMapping.entries()) {
676
+ if (oi >= 0 && !(oi in origToRwIdx)) {
677
+ origToRwIdx[oi] = mi
678
+ }
679
+ }
680
+ }
681
+
682
+ for (const [i, msg] of messages.entries()) {
683
+ if (filterRole && msg.role !== filterRole) continue
684
+ const isTruncated = i < removedCount
685
+
686
+ // Find corresponding rewritten message via mapping
687
+ let rewrittenMsg = null
688
+ if (!isTruncated && rewrittenMessages) {
689
+ if (messageMapping && i in origToRwIdx) {
690
+ rewrittenMsg = rewrittenMessages[origToRwIdx[i]]
691
+ } else if (!messageMapping) {
692
+ // Fallback for old data without mapping: use offset
693
+ const rwIdx = i - removedCount
694
+ if (rwIdx >= 0 && rwIdx < rewrittenMessages.length) {
695
+ rewrittenMsg = rewrittenMessages[rwIdx]
696
+ }
697
+ }
698
+ }
699
+
700
+ // Determine if content was actually rewritten by comparing text
701
+ const isRewritten = rewrittenMsg != null && !messagesContentEqual(msg, rewrittenMsg)
702
+
703
+ html += renderMessage(
704
+ msg,
705
+ filterType,
706
+ aggregateTools,
707
+ toolResultMap,
708
+ toolUseNameMap,
709
+ isTruncated,
710
+ isRewritten,
711
+ rewrittenMsg,
712
+ )
713
+ // Insert truncation divider after the last removed message
714
+ if (isTruncated && i === removedCount - 1) {
715
+ const pct = Math.round((1 - truncation.compactedTokens / truncation.originalTokens) * 100)
716
+ html += '<div class="truncation-divider">'
717
+ html += '<span class="truncation-divider-line"></span>'
718
+ html +=
719
+ '<span class="truncation-divider-label">'
720
+ + removedCount
721
+ + " messages truncated ("
722
+ + formatNumber(truncation.originalTokens)
723
+ + " \u2192 "
724
+ + formatNumber(truncation.compactedTokens)
725
+ + " tokens, -"
726
+ + pct
727
+ + "%)</span>"
728
+ html += '<span class="truncation-divider-line"></span>'
729
+ html += "</div>"
730
+ }
731
+ }
732
+
733
+ html += "</div>"
734
+ }
735
+ html += "</div>"
736
+
737
+ // RESPONSE section
738
+ const resIcon = sectionCollapseState.response ? "\u25B8" : "\u25BE"
739
+ const resRawIdx = registerRawData(currentEntry.response)
740
+ html += '<div class="section-block">'
741
+ html += '<div class="section-header">'
742
+ html += '<div class="section-header-left" onclick="toggleSection(\'response\')">'
743
+ html += '<span class="collapse-icon">' + resIcon + "</span>"
744
+ html += "RESPONSE"
745
+ html += "</div>"
746
+ html += '<div class="section-header-actions">'
747
+ html +=
748
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Response\', '
749
+ + resRawIdx
750
+ + ')" title="View Raw">'
751
+ + ICON_CODE
752
+ + "Raw</button>"
753
+ html += "</div>"
754
+ html += "</div>"
755
+
756
+ if (!sectionCollapseState.response) {
757
+ html += '<div class="section-body">'
758
+
759
+ // Response content (assistant message)
760
+ const responseContent = currentEntry.response?.content
761
+ if (responseContent && (!filterRole || filterRole === "assistant")) {
762
+ // Use full message object if it has role (preserves tool_calls for OpenAI format)
763
+ const responseMsg = responseContent.role
764
+ ? responseContent
765
+ : { role: "assistant", content: responseContent.content ?? responseContent }
766
+ html += renderMessage(responseMsg, filterType, false, null, toolUseNameMap)
767
+ }
768
+
769
+ // Error message
770
+ if (currentEntry.response?.error) {
771
+ html +=
772
+ '<div class="error-block"><strong>Error:</strong> ' + escapeHtml(String(currentEntry.response.error)) + "</div>"
773
+ }
774
+
775
+ html += "</div>"
776
+ }
777
+ html += "</div>"
778
+
779
+ // META INFO section (after response for better reading flow)
780
+ const metaIcon = sectionCollapseState.meta ? "\u25B8" : "\u25BE"
781
+ html += '<div class="section-block">'
782
+ html += '<div class="section-header">'
783
+ html += '<div class="section-header-left" onclick="toggleSection(\'meta\')">'
784
+ html += '<span class="collapse-icon">' + metaIcon + "</span>"
785
+ html += "META INFO"
786
+ html += "</div>"
787
+ html += '<div class="section-header-actions">'
788
+ html +=
789
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Full Entry\', '
790
+ + fullEntryRawIdx
791
+ + ')" title="View Raw">'
792
+ + ICON_CODE
793
+ + "Raw</button>"
794
+ html += "</div>"
795
+ html += "</div>"
796
+
797
+ if (!sectionCollapseState.meta) {
798
+ // Collect all meta items into a flat array
799
+ const items = []
800
+ items.push(
801
+ '<div class="info-item"><div class="info-label">Time</div><div class="info-value">'
802
+ + formatDate(currentEntry.timestamp)
803
+ + "</div></div>",
804
+ )
805
+ items.push(
806
+ '<div class="info-item"><div class="info-label">Model</div><div class="info-value">'
807
+ + escapeHtml(currentEntry.request?.model || "-")
808
+ + "</div></div>",
809
+ '<div class="info-item"><div class="info-label">Endpoint</div><div class="info-value"><span class="badge '
810
+ + currentEntry.endpoint
811
+ + '">'
812
+ + currentEntry.endpoint
813
+ + "</span></div></div>",
814
+ )
815
+ if (currentEntry.request?.stream) {
816
+ items.push(
817
+ '<div class="info-item"><div class="info-label">Stream</div><div class="info-value"><span class="badge stream">yes</span></div></div>',
818
+ )
819
+ }
820
+ if (currentEntry.request?.max_tokens) {
821
+ items.push(
822
+ '<div class="info-item"><div class="info-label">Max Tokens</div><div class="info-value">'
823
+ + currentEntry.request.max_tokens
824
+ + "</div></div>",
825
+ )
826
+ }
827
+ if (currentEntry.request?.temperature != null) {
828
+ items.push(
829
+ '<div class="info-item"><div class="info-label">Temperature</div><div class="info-value">'
830
+ + currentEntry.request.temperature
831
+ + "</div></div>",
832
+ )
833
+ }
834
+ if (currentEntry.request?.tools?.length) {
835
+ items.push(
836
+ '<div class="info-item"><div class="info-label">Tools</div><div class="info-value">'
837
+ + currentEntry.request.tools.length
838
+ + " defined</div></div>",
839
+ )
840
+ }
841
+ if (currentEntry.response?.stop_reason) {
842
+ items.push(
843
+ '<div class="info-item"><div class="info-label">Stop Reason</div><div class="info-value">'
844
+ + escapeHtml(currentEntry.response.stop_reason)
845
+ + "</div></div>",
846
+ )
847
+ }
848
+ if (currentEntry.response?.success !== undefined) {
849
+ items.push(
850
+ '<div class="info-item"><div class="info-label">Status</div><div class="info-value '
851
+ + (currentEntry.response.success !== false ? "status-ok" : "status-fail")
852
+ + '">'
853
+ + (currentEntry.response.success !== false ? "OK" : "Failed")
854
+ + "</div></div>",
855
+ )
856
+ }
857
+
858
+ // Usage items
859
+ const usage = currentEntry.response?.usage
860
+ if (usage) {
861
+ items.push(
862
+ '<div class="info-item"><div class="info-label">Input Tokens</div><div class="info-value number">'
863
+ + formatNumber(usage.input_tokens)
864
+ + "</div></div>",
865
+ )
866
+ items.push(
867
+ '<div class="info-item"><div class="info-label">Output Tokens</div><div class="info-value number">'
868
+ + formatNumber(usage.output_tokens)
869
+ + "</div></div>",
870
+ )
871
+ if (usage.cache_read_input_tokens) {
872
+ items.push(
873
+ '<div class="info-item"><div class="info-label">Cache Read</div><div class="info-value number">'
874
+ + formatNumber(usage.cache_read_input_tokens)
875
+ + "</div></div>",
876
+ )
877
+ }
878
+ if (usage.cache_creation_input_tokens) {
879
+ items.push(
880
+ '<div class="info-item"><div class="info-label">Cache Create</div><div class="info-value number">'
881
+ + formatNumber(usage.cache_creation_input_tokens)
882
+ + "</div></div>",
883
+ )
884
+ }
885
+ }
886
+
887
+ // Duration
888
+ if (currentEntry.durationMs) {
889
+ items.push(
890
+ '<div class="info-item"><div class="info-label">Duration</div><div class="info-value">'
891
+ + formatDuration(currentEntry.durationMs)
892
+ + "</div></div>",
893
+ )
894
+ }
895
+
896
+ // Truncation info
897
+ if (truncation) {
898
+ const pct = Math.round((1 - truncation.compactedTokens / truncation.originalTokens) * 100)
899
+ items.push(
900
+ '<div class="info-item"><div class="info-label">Truncated</div><div class="info-value truncation-value">'
901
+ + truncation.removedMessageCount
902
+ + " msgs removed ("
903
+ + pct
904
+ + "%)</div></div>",
905
+ )
906
+ }
907
+
908
+ // Sanitization info
909
+ if (rewrites?.sanitization) {
910
+ const s = rewrites.sanitization
911
+ if (s.totalBlocksRemoved > 0) {
912
+ items.push(
913
+ '<div class="info-item"><div class="info-label">Orphaned</div><div class="info-value truncation-value">'
914
+ + s.totalBlocksRemoved
915
+ + " blocks removed</div></div>",
916
+ )
917
+ }
918
+ if (s.systemReminderRemovals > 0) {
919
+ items.push(
920
+ '<div class="info-item"><div class="info-label">Reminders</div><div class="info-value">'
921
+ + s.systemReminderRemovals
922
+ + " tags filtered</div></div>",
923
+ )
924
+ }
925
+ }
926
+
927
+ // Render as a single grid with two equal rows
928
+ const cols = Math.ceil(items.length / 2)
929
+ html += '<div class="info-card" style="grid-template-columns: repeat(' + cols + ', 1fr)">'
930
+ html += items.join("")
931
+ html += "</div>"
932
+ }
933
+ html += "</div>"
934
+
935
+ html += "</div>"
936
+ content.innerHTML = html
937
+
938
+ // Show expand buttons for blocks that actually overflow
939
+ updateExpandButtons()
940
+
941
+ // Apply search highlighting if needed
942
+ if (detailSearchQuery) {
943
+ applySearchHighlight()
944
+ }
945
+ }
946
+
947
+ let msgIdCounter = 0
948
+ function renderSystemMessage(system, rewrittenSystem) {
949
+ let text = ""
950
+ if (typeof system === "string") {
951
+ text = system
952
+ } else if (Array.isArray(system)) {
953
+ text = system.map((b) => b.text || "").join("\n")
954
+ }
955
+
956
+ const isRewritten = rewrittenSystem != null && rewrittenSystem !== text
957
+
958
+ const msgId = "msg-sys-" + msgIdCounter++
959
+ const summary = escapeHtml(text.length > 80 ? text.slice(0, 80) + "..." : text)
960
+ const displayText = detailSearchQuery ? highlightSearch(text, detailSearchQuery) : escapeHtml(text)
961
+ const sysRawIdx = registerRawData(system)
962
+
963
+ let classes = "message-block"
964
+ if (isRewritten) classes += " rewritten"
965
+ let html = '<div class="' + classes + '" id="' + msgId + '">'
966
+ html += '<div class="message-header">'
967
+ html += '<div class="message-header-left">'
968
+ html += '<span class="collapse-icon" onclick="toggleMessage(\'' + msgId + "')\">\u25BE</span>"
969
+ html += '<span class="message-role system">SYSTEM</span>'
970
+ if (isRewritten) {
971
+ html += '<span class="rewrite-badge rewritten">(rewritten)</span>'
972
+ }
973
+ html += '<span class="collapsed-summary" style="display:none">' + summary + "</span>"
974
+ html += "</div>"
975
+ html += '<div class="message-header-actions">'
976
+
977
+ if (isRewritten) {
978
+ html += '<div class="rewrite-toggle">'
979
+ html +=
980
+ '<button class="rewrite-tab active" data-mode="original" onclick="event.stopPropagation();switchRewriteView(\''
981
+ + msgId
982
+ + "','original')\">Original</button>"
983
+ html +=
984
+ '<button class="rewrite-tab" data-mode="rewritten" onclick="event.stopPropagation();switchRewriteView(\''
985
+ + msgId
986
+ + "','rewritten')\">Rewritten</button>"
987
+ html +=
988
+ '<button class="rewrite-tab" data-mode="diff" onclick="event.stopPropagation();switchRewriteView(\''
989
+ + msgId
990
+ + "','diff')\">Diff</button>"
991
+ html += "</div>"
992
+ }
993
+
994
+ html +=
995
+ '<button class="action-btn expand-toggle" data-target="'
996
+ + msgId
997
+ + '" onclick="event.stopPropagation();toggleBodyExpand(this)" style="display:none">'
998
+ + ICON_EXPAND
999
+ + "Expand</button>"
1000
+ html +=
1001
+ '<button class="action-btn" onclick="event.stopPropagation();copyText(this)" data-text="'
1002
+ + escapeHtml(text).replaceAll('"', "&quot;")
1003
+ + '" title="Copy">'
1004
+ + ICON_COPY
1005
+ + "Copy</button>"
1006
+ html +=
1007
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'System\', '
1008
+ + sysRawIdx
1009
+ + ')" title="View Raw">'
1010
+ + ICON_CODE
1011
+ + "Raw</button>"
1012
+ html += "</div>"
1013
+ html += "</div>"
1014
+
1015
+ if (isRewritten) {
1016
+ html += '<div class="message-body message-body-original body-collapsed">'
1017
+ html += '<div class="content-text">' + displayText + "</div>"
1018
+ html += "</div>"
1019
+
1020
+ const rewrittenDisplay =
1021
+ detailSearchQuery ? highlightSearch(rewrittenSystem, detailSearchQuery) : escapeHtml(rewrittenSystem)
1022
+ html += '<div class="message-body message-body-rewritten body-collapsed" style="display:none">'
1023
+ html += '<div class="content-text">' + rewrittenDisplay + "</div>"
1024
+ html += "</div>"
1025
+
1026
+ html += '<div class="message-body message-body-diff body-collapsed" style="display:none">'
1027
+ var sysDiffHtml = computeTextDiff(text, rewrittenSystem)
1028
+ html += sysDiffHtml || '<div class="diff-no-changes">No differences</div>'
1029
+ html += "</div>"
1030
+ } else {
1031
+ html += '<div class="message-body body-collapsed">'
1032
+ html += '<div class="content-text">' + displayText + "</div>"
1033
+ html += "</div>"
1034
+ }
1035
+
1036
+ html += "</div>"
1037
+ return html
1038
+ }
1039
+
1040
+ /**
1041
+ * Compare two messages' content for equality.
1042
+ * Handles both Anthropic format (content as array of blocks) and
1043
+ * OpenAI format (content as string). Returns true if content is equivalent.
1044
+ */
1045
+ function messagesContentEqual(msgA, msgB) {
1046
+ var a = msgA.content
1047
+ var b = msgB.content
1048
+ // Both strings: direct compare
1049
+ if (typeof a === "string" && typeof b === "string") return a === b
1050
+ // Both arrays: compare JSON (handles tool_use, tool_result, etc.)
1051
+ if (Array.isArray(a) && Array.isArray(b)) return JSON.stringify(a) === JSON.stringify(b)
1052
+ // Mixed formats (Anthropic vs OpenAI): compare extracted text
1053
+ return extractMessageText(msgA) === extractMessageText(msgB)
1054
+ }
1055
+
1056
+ function renderMessageContent(content, filterType, aggregateTools, toolResultMap, toolUseNameMap, msg) {
1057
+ let html = ""
1058
+ if (typeof content === "string") {
1059
+ if (!filterType || filterType === "text") {
1060
+ html += renderTextBlock(content, null)
1061
+ }
1062
+ } else if (Array.isArray(content)) {
1063
+ let hasVisibleBlocks = false
1064
+ for (const block of content) {
1065
+ if (aggregateTools && block.type === "tool_result") continue
1066
+ if (filterType && block.type !== filterType) continue
1067
+ hasVisibleBlocks = true
1068
+ switch (block.type) {
1069
+ case "text": {
1070
+ html += renderTextBlock(block.text, block)
1071
+
1072
+ break
1073
+ }
1074
+ case "tool_use": {
1075
+ html += renderToolUseBlock(block, aggregateTools, toolResultMap)
1076
+
1077
+ break
1078
+ }
1079
+ case "tool_result": {
1080
+ html += renderToolResultBlock(block, toolUseNameMap)
1081
+
1082
+ break
1083
+ }
1084
+ case "image":
1085
+ case "image_url": {
1086
+ html += renderImageBlock(block)
1087
+
1088
+ break
1089
+ }
1090
+ case "thinking": {
1091
+ html += renderThinkingBlock(block)
1092
+
1093
+ break
1094
+ }
1095
+ default: {
1096
+ html += renderGenericBlock(block)
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ if (!hasVisibleBlocks && aggregateTools) {
1102
+ const toolIds = content
1103
+ .filter(function (b) {
1104
+ return b.type === "tool_result" && b.tool_use_id
1105
+ })
1106
+ .map(function (b) {
1107
+ return b.tool_use_id
1108
+ })
1109
+ if (toolIds.length > 0) {
1110
+ html += '<div class="aggregated-links">'
1111
+ html += '<span class="aggregated-label">Tool results aggregated to:</span>'
1112
+ for (const id of toolIds) {
1113
+ html += ' <a class="tool-link" onclick="scrollToToolUse(\'' + id + "')\">\u2190 " + escapeHtml(id) + "</a>"
1114
+ }
1115
+ html += "</div>"
1116
+ }
1117
+ }
1118
+ }
1119
+ // OpenAI format: render tool_calls from the message object
1120
+ if (msg && msg.tool_calls && Array.isArray(msg.tool_calls)) {
1121
+ for (const tc of msg.tool_calls) {
1122
+ if (filterType && filterType !== "tool_use") continue
1123
+ // Convert OpenAI tool_call to Anthropic-like tool_use block for rendering
1124
+ var toolUseBlock = {
1125
+ type: "tool_use",
1126
+ id: tc.id,
1127
+ name: tc.function ? tc.function.name : "unknown",
1128
+ input: tc.function ? tc.function.arguments : "{}",
1129
+ }
1130
+ // Try to parse arguments as JSON for prettier display
1131
+ try { toolUseBlock.input = JSON.parse(toolUseBlock.input) } catch (e) { /* keep as string */ }
1132
+ html += renderToolUseBlock(toolUseBlock, aggregateTools, toolResultMap)
1133
+ }
1134
+ }
1135
+ // OpenAI format: tool response message (role=tool with tool_call_id)
1136
+ if (msg && msg.role === "tool" && msg.tool_call_id && typeof content === "string") {
1137
+ if (!filterType || filterType === "tool_result") {
1138
+ var toolResultBlock = {
1139
+ type: "tool_result",
1140
+ tool_use_id: msg.tool_call_id,
1141
+ content: content,
1142
+ }
1143
+ html += renderToolResultBlock(toolResultBlock, toolUseNameMap)
1144
+ }
1145
+ }
1146
+ return html
1147
+ }
1148
+
1149
+ function extractMessageText(msg) {
1150
+ const content = msg.content
1151
+ if (typeof content === "string") return content
1152
+ if (Array.isArray(content)) {
1153
+ var parts = []
1154
+ for (var b of content) {
1155
+ if (b.type === "text" && b.text) {
1156
+ parts.push(b.text)
1157
+ } else if (b.type === "tool_use") {
1158
+ var input = typeof b.input === "string" ? b.input : JSON.stringify(b.input || {}, null, 2)
1159
+ parts.push("[tool_use: " + (b.name || "") + "]\n" + input)
1160
+ } else if (b.type === "tool_result") {
1161
+ var rc = typeof b.content === "string" ? b.content : JSON.stringify(b.content, null, 2)
1162
+ parts.push("[tool_result: " + (b.tool_use_id || "") + "]\n" + (rc || ""))
1163
+ }
1164
+ }
1165
+ return parts.join("\n")
1166
+ }
1167
+ return ""
1168
+ }
1169
+
1170
+ function computeTextDiff(oldText, newText) {
1171
+ if (oldText === newText) return ""
1172
+ // Use jsdiff to create unified diff, then diff2html to render side-by-side
1173
+ var diffStr = Diff.createPatch("message", oldText, newText, "original", "rewritten", { context: 3 })
1174
+ return Diff2Html.html(diffStr, {
1175
+ outputFormat: "side-by-side",
1176
+ drawFileList: false,
1177
+ matching: "words",
1178
+ diffStyle: "word",
1179
+ })
1180
+ }
1181
+
1182
+ function switchRewriteView(msgId, mode) {
1183
+ const block = document.getElementById(msgId)
1184
+ if (!block) return
1185
+
1186
+ // Update tab buttons
1187
+ const tabs = block.querySelectorAll(".rewrite-tab")
1188
+ for (const tab of tabs) {
1189
+ tab.classList.toggle("active", tab.dataset.mode === mode)
1190
+ }
1191
+
1192
+ // Show/hide body containers
1193
+ const original = block.querySelector(".message-body-original")
1194
+ const rewritten = block.querySelector(".message-body-rewritten")
1195
+ const diff = block.querySelector(".message-body-diff")
1196
+ if (original) original.style.display = mode === "original" ? "" : "none"
1197
+ if (rewritten) rewritten.style.display = mode === "rewritten" ? "" : "none"
1198
+ if (diff) diff.style.display = mode === "diff" ? "" : "none"
1199
+
1200
+ // Reset message-level expand toggle text to match newly visible body's state
1201
+ var expandToggle = block.querySelector(":scope > .message-header .expand-toggle")
1202
+ if (expandToggle) {
1203
+ var visibleBody =
1204
+ mode === "original" ? original
1205
+ : mode === "rewritten" ? rewritten
1206
+ : diff
1207
+ if (visibleBody) {
1208
+ var isCollapsed = visibleBody.classList.contains("body-collapsed")
1209
+ expandToggle.innerHTML = isCollapsed ? ICON_EXPAND + "Expand" : ICON_CONTRACT + "Collapse"
1210
+ }
1211
+ }
1212
+
1213
+ // Re-check expand buttons for newly visible content
1214
+ updateExpandButtons()
1215
+ }
1216
+
1217
+ function renderMessage(
1218
+ msg,
1219
+ filterType,
1220
+ aggregateTools,
1221
+ toolResultMap,
1222
+ toolUseNameMap,
1223
+ isTruncated,
1224
+ isRewritten,
1225
+ rewrittenMsg,
1226
+ ) {
1227
+ const role = msg.role || "unknown"
1228
+ const content = msg.content
1229
+
1230
+ const msgId = "msg-" + role + "-" + msgIdCounter++
1231
+ const summary = escapeHtml(getMessageSummary(content))
1232
+ const msgRawIdx = registerRawData(msg)
1233
+
1234
+ let classes = "message-block"
1235
+ if (isTruncated) classes += " truncated"
1236
+ if (isRewritten && !isTruncated) classes += " rewritten"
1237
+ let html = '<div class="' + classes + '" id="' + msgId + '">'
1238
+ html += '<div class="message-header">'
1239
+ html += '<div class="message-header-left">'
1240
+ html += '<span class="collapse-icon" onclick="toggleMessage(\'' + msgId + "')\">\u25BE</span>"
1241
+ html += '<span class="message-role ' + role + '">' + role.toUpperCase() + "</span>"
1242
+ if (isTruncated) {
1243
+ html += '<span class="rewrite-badge deleted">(deleted)</span>'
1244
+ } else if (isRewritten) {
1245
+ html += '<span class="rewrite-badge rewritten">(rewritten)</span>'
1246
+ }
1247
+ html += '<span class="collapsed-summary" style="display:none">' + summary + "</span>"
1248
+ html += "</div>"
1249
+ html += '<div class="message-header-actions">'
1250
+
1251
+ // Rewrite toggle buttons (only for rewritten messages with available rewritten data)
1252
+ if (isRewritten && !isTruncated && rewrittenMsg) {
1253
+ html += '<div class="rewrite-toggle">'
1254
+ html +=
1255
+ '<button class="rewrite-tab active" data-mode="original" onclick="event.stopPropagation();switchRewriteView(\''
1256
+ + msgId
1257
+ + "','original')\">Original</button>"
1258
+ html +=
1259
+ '<button class="rewrite-tab" data-mode="rewritten" onclick="event.stopPropagation();switchRewriteView(\''
1260
+ + msgId
1261
+ + "','rewritten')\">Rewritten</button>"
1262
+ html +=
1263
+ '<button class="rewrite-tab" data-mode="diff" onclick="event.stopPropagation();switchRewriteView(\''
1264
+ + msgId
1265
+ + "','diff')\">Diff</button>"
1266
+ html += "</div>"
1267
+ }
1268
+
1269
+ html +=
1270
+ '<button class="action-btn expand-toggle" data-target="'
1271
+ + msgId
1272
+ + '" onclick="event.stopPropagation();toggleBodyExpand(this)" style="display:none">'
1273
+ + ICON_EXPAND
1274
+ + "Expand</button>"
1275
+ html +=
1276
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\''
1277
+ + role
1278
+ + "', "
1279
+ + msgRawIdx
1280
+ + ')" title="View Raw">'
1281
+ + ICON_CODE
1282
+ + "Raw</button>"
1283
+ html += "</div>"
1284
+ html += "</div>"
1285
+
1286
+ if (isRewritten && !isTruncated && rewrittenMsg) {
1287
+ // Three switchable body containers
1288
+ html += '<div class="message-body message-body-original">'
1289
+ html += renderMessageContent(content, filterType, aggregateTools, toolResultMap, toolUseNameMap, msg)
1290
+ html += "</div>"
1291
+
1292
+ html += '<div class="message-body message-body-rewritten" style="display:none">'
1293
+ html += renderMessageContent(rewrittenMsg.content, filterType, aggregateTools, toolResultMap, toolUseNameMap, rewrittenMsg)
1294
+ html += "</div>"
1295
+
1296
+ html += '<div class="message-body message-body-diff body-collapsed" style="display:none">'
1297
+ var diffHtml = computeTextDiff(extractMessageText(msg), extractMessageText(rewrittenMsg))
1298
+ html += diffHtml || '<div class="diff-no-changes">No differences</div>'
1299
+ html += "</div>"
1300
+ } else {
1301
+ // Standard single body
1302
+ html += '<div class="message-body">'
1303
+ html += renderMessageContent(content, filterType, aggregateTools, toolResultMap, toolUseNameMap, msg)
1304
+ html += "</div>"
1305
+ }
1306
+
1307
+ html += "</div>"
1308
+ return html
1309
+ }
1310
+
1311
+ let blockIdCounter = 0
1312
+ function renderTextBlock(text, block) {
1313
+ const blockId = "cb-" + blockIdCounter++
1314
+ const summary = escapeHtml(getContentBlockSummary(block || { type: "text", text: text || "" }))
1315
+ const displayText = detailSearchQuery ? highlightSearch(text, detailSearchQuery) : escapeHtml(text)
1316
+ const blockData = block || { type: "text", text: text || "" }
1317
+ const blockRawIdx = registerRawData(blockData)
1318
+
1319
+ let html = '<div class="content-block" id="' + blockId + '">'
1320
+ html += '<div class="content-block-header">'
1321
+ html += '<div class="content-block-header-left">'
1322
+ html += '<span class="collapse-icon" onclick="toggleContentBlock(\'' + blockId + "')\">\u25BE</span>"
1323
+ html += '<span class="content-type text">TEXT</span>'
1324
+ html += '<span class="collapsed-summary" style="display:none">' + summary + "</span>"
1325
+ html += "</div>"
1326
+ html += '<div class="content-block-actions">'
1327
+ html +=
1328
+ '<button class="action-btn expand-toggle" data-target="'
1329
+ + blockId
1330
+ + '" onclick="event.stopPropagation();toggleBodyExpand(this)" style="display:none">'
1331
+ + ICON_EXPAND
1332
+ + "Expand</button>"
1333
+ html +=
1334
+ '<button class="action-btn" onclick="event.stopPropagation();copyText(this)" data-text="'
1335
+ + escapeHtml(text || "").replaceAll('"', "&quot;")
1336
+ + '" title="Copy">'
1337
+ + ICON_COPY
1338
+ + "Copy</button>"
1339
+ html +=
1340
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Text\', '
1341
+ + blockRawIdx
1342
+ + ')" title="View Raw">'
1343
+ + ICON_CODE
1344
+ + "Raw</button>"
1345
+ html += "</div>"
1346
+ html += "</div>"
1347
+ html += '<div class="content-block-body body-collapsed">'
1348
+ html += '<div class="content-text">' + displayText + "</div>"
1349
+ html += "</div>"
1350
+ html += "</div>"
1351
+ return html
1352
+ }
1353
+
1354
+ function renderToolUseBlock(block, aggregateTools, toolResultMap) {
1355
+ const blockId = "cb-" + blockIdCounter++
1356
+ const summary = escapeHtml(block.name || "")
1357
+ const blockRawIdx = registerRawData(block)
1358
+ const inputJson = typeof block.input === "string" ? block.input : JSON.stringify(block.input || {}, null, 2)
1359
+
1360
+ let html = '<div class="content-block" id="tool-' + block.id + '">'
1361
+ html += '<div class="content-block-header">'
1362
+ html += '<div class="content-block-header-left">'
1363
+ html += '<span class="collapse-icon" onclick="toggleContentBlock(\'tool-' + block.id + "')\">\u25BE</span>"
1364
+ html += '<span class="content-type tool_use">TOOL USE</span>'
1365
+ html += '<span class="tool-name">' + escapeHtml(block.name) + "</span>"
1366
+ html += '<span class="tool-id">' + escapeHtml(block.id) + "</span>"
1367
+ html += '<span class="collapsed-summary" style="display:none">' + summary + "</span>"
1368
+ html += "</div>"
1369
+ html += '<div class="content-block-actions">'
1370
+ html +=
1371
+ '<button class="action-btn expand-toggle" data-target="tool-'
1372
+ + block.id
1373
+ + '" onclick="event.stopPropagation();toggleBodyExpand(this)" style="display:none">'
1374
+ + ICON_EXPAND
1375
+ + "Expand</button>"
1376
+ html +=
1377
+ '<button class="action-btn" onclick="event.stopPropagation();copyText(this)" data-text="'
1378
+ + escapeHtml(inputJson).replaceAll('"', "&quot;")
1379
+ + '" title="Copy">'
1380
+ + ICON_COPY
1381
+ + "Copy</button>"
1382
+ html +=
1383
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Tool Use\', '
1384
+ + blockRawIdx
1385
+ + ')" title="View Raw">'
1386
+ + ICON_CODE
1387
+ + "Raw</button>"
1388
+ html += "</div>"
1389
+ html += "</div>"
1390
+ html += '<div class="content-block-body body-collapsed">'
1391
+ html += '<div class="tool-input">' + escapeHtml(inputJson) + "</div>"
1392
+
1393
+ if (aggregateTools && toolResultMap[block.id]) {
1394
+ const result = toolResultMap[block.id]
1395
+ const resultContent = typeof result.content === "string" ? result.content : JSON.stringify(result.content, null, 2)
1396
+ const resultRawIdx = registerRawData(result)
1397
+ html += '<div class="tool-result-inline">'
1398
+ html += '<div class="tool-result-inline-header">'
1399
+ html += "<span>RESULT</span>"
1400
+ html +=
1401
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Tool Result\', '
1402
+ + resultRawIdx
1403
+ + ')" title="View Raw">'
1404
+ + ICON_CODE
1405
+ + "Raw</button>"
1406
+ html += "</div>"
1407
+ html += '<div class="tool-result-inline-body">' + escapeHtml(resultContent) + "</div>"
1408
+ html += "</div>"
1409
+ } else if (!aggregateTools && toolResultMap) {
1410
+ // Only show "Jump to result" when tool results exist in the conversation
1411
+ // (response section passes null toolResultMap since responses never contain tool_result)
1412
+ html += '<a class="tool-link" onclick="scrollToToolResult(\'' + block.id + "')\">\u2192 Jump to result</a>"
1413
+ }
1414
+
1415
+ html += "</div></div>"
1416
+ return html
1417
+ }
1418
+
1419
+ function renderToolResultBlock(block, toolUseNameMap) {
1420
+ const blockRawIdx = registerRawData(block)
1421
+ const content = typeof block.content === "string" ? block.content : JSON.stringify(block.content, null, 2)
1422
+ const toolName = toolUseNameMap && block.tool_use_id ? toolUseNameMap[block.tool_use_id] : ""
1423
+ const summary = escapeHtml("for " + (toolName || block.tool_use_id || ""))
1424
+
1425
+ let html = '<div class="content-block" id="result-' + block.tool_use_id + '">'
1426
+ html += '<div class="content-block-header">'
1427
+ html += '<div class="content-block-header-left">'
1428
+ html += '<span class="collapse-icon" onclick="toggleContentBlock(\'result-' + block.tool_use_id + "')\">\u25BE</span>"
1429
+ html += '<span class="content-type tool_result">TOOL RESULT</span>'
1430
+ if (toolName) {
1431
+ html += '<span class="tool-name">' + escapeHtml(toolName) + "</span>"
1432
+ }
1433
+ html += '<span class="tool-id">for ' + escapeHtml(block.tool_use_id) + "</span>"
1434
+ html += '<span class="collapsed-summary" style="display:none">' + summary + "</span>"
1435
+ html += "</div>"
1436
+ html += '<div class="content-block-actions">'
1437
+ html +=
1438
+ '<button class="action-btn expand-toggle" data-target="result-'
1439
+ + block.tool_use_id
1440
+ + '" onclick="event.stopPropagation();toggleBodyExpand(this)" style="display:none">'
1441
+ + ICON_EXPAND
1442
+ + "Expand</button>"
1443
+ html +=
1444
+ '<button class="action-btn" onclick="event.stopPropagation();copyText(this)" data-text="'
1445
+ + escapeHtml(content).replaceAll('"', "&quot;")
1446
+ + '" title="Copy">'
1447
+ + ICON_COPY
1448
+ + "Copy</button>"
1449
+ html +=
1450
+ '<a class="tool-link" onclick="event.stopPropagation();scrollToToolUse(\''
1451
+ + block.tool_use_id
1452
+ + "')\">\u2190 Jump to call</a>"
1453
+ html +=
1454
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Tool Result\', '
1455
+ + blockRawIdx
1456
+ + ')" title="View Raw">'
1457
+ + ICON_CODE
1458
+ + "Raw</button>"
1459
+ html += "</div>"
1460
+ html += "</div>"
1461
+ html += '<div class="content-block-body body-collapsed">'
1462
+ html += '<div class="content-text">' + escapeHtml(content) + "</div>"
1463
+ html += "</div>"
1464
+ html += "</div>"
1465
+ return html
1466
+ }
1467
+
1468
+ function renderImageBlock(block) {
1469
+ const blockRawIdx = registerRawData(block)
1470
+ const mediaType = block.source?.media_type || block.media_type || "unknown"
1471
+
1472
+ let html = '<div class="content-block">'
1473
+ html += '<div class="content-block-header">'
1474
+ html += '<div class="content-block-header-left">'
1475
+ html += '<span class="content-type image">IMAGE</span>'
1476
+ html += '<span style="font-size:10px;color:var(--text-dim);">' + escapeHtml(mediaType) + "</span>"
1477
+ html += "</div>"
1478
+ html += '<div class="content-block-actions">'
1479
+ html +=
1480
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Image\', '
1481
+ + blockRawIdx
1482
+ + ')" title="View Raw">'
1483
+ + ICON_CODE
1484
+ + "Raw</button>"
1485
+ html += "</div>"
1486
+ html += "</div>"
1487
+ html += '<div class="content-block-body">'
1488
+ html += '<div style="color:var(--text-muted);font-size:12px;">[Image content - base64 encoded]</div>'
1489
+ html += "</div>"
1490
+ html += "</div>"
1491
+ return html
1492
+ }
1493
+
1494
+ function renderThinkingBlock(block) {
1495
+ const blockId = "cb-" + blockIdCounter++
1496
+ const blockRawIdx = registerRawData(block)
1497
+ const text = block.thinking || ""
1498
+ const summary = escapeHtml(text.length > 60 ? text.slice(0, 60) + "..." : text)
1499
+
1500
+ let html = '<div class="content-block" id="' + blockId + '">'
1501
+ html += '<div class="content-block-header">'
1502
+ html += '<div class="content-block-header-left">'
1503
+ html += '<span class="collapse-icon" onclick="toggleContentBlock(\'' + blockId + "')\">\u25BE</span>"
1504
+ html += '<span class="content-type thinking">THINKING</span>'
1505
+ html += '<span class="collapsed-summary" style="display:none">' + summary + "</span>"
1506
+ html += "</div>"
1507
+ html += '<div class="content-block-actions">'
1508
+ html +=
1509
+ '<button class="action-btn expand-toggle" data-target="'
1510
+ + blockId
1511
+ + '" onclick="event.stopPropagation();toggleBodyExpand(this)" style="display:none">'
1512
+ + ICON_EXPAND
1513
+ + "Expand</button>"
1514
+ html +=
1515
+ '<button class="action-btn" onclick="event.stopPropagation();copyText(this)" data-text="'
1516
+ + escapeHtml(text).replaceAll('"', "&quot;")
1517
+ + '" title="Copy">'
1518
+ + ICON_COPY
1519
+ + "Copy</button>"
1520
+ html +=
1521
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Thinking\', '
1522
+ + blockRawIdx
1523
+ + ')" title="View Raw">'
1524
+ + ICON_CODE
1525
+ + "Raw</button>"
1526
+ html += "</div>"
1527
+ html += "</div>"
1528
+ html += '<div class="content-block-body body-collapsed">'
1529
+ html += '<div class="content-text">' + escapeHtml(text) + "</div>"
1530
+ html += "</div>"
1531
+ html += "</div>"
1532
+ return html
1533
+ }
1534
+
1535
+ function renderGenericBlock(block) {
1536
+ const blockRawIdx = registerRawData(block)
1537
+
1538
+ let html = '<div class="content-block">'
1539
+ html += '<div class="content-block-header">'
1540
+ html +=
1541
+ '<div class="content-block-header-left"><span class="content-type text">'
1542
+ + escapeHtml(block.type || "UNKNOWN")
1543
+ + "</span></div>"
1544
+ html += '<div class="content-block-actions">'
1545
+ html +=
1546
+ '<button class="action-btn" onclick="event.stopPropagation();showRaw(\'Block\', '
1547
+ + blockRawIdx
1548
+ + ')" title="View Raw">'
1549
+ + ICON_CODE
1550
+ + "Raw</button>"
1551
+ html += "</div>"
1552
+ html += "</div>"
1553
+ html += '<div class="content-block-body">'
1554
+ html += '<pre style="font-size:11px;margin:0;">' + escapeHtml(JSON.stringify(block, null, 2)) + "</pre>"
1555
+ html += "</div>"
1556
+ html += "</div>"
1557
+ return html
1558
+ }
1559
+
1560
+ function applySearchHighlight() {
1561
+ const firstMatch = document.querySelector(".search-highlight")
1562
+ if (firstMatch) {
1563
+ firstMatch.scrollIntoView({ behavior: "smooth", block: "center" })
1564
+ }
1565
+ }
1566
+
1567
+ // UI Actions
1568
+ function toggleBodyExpand(toggleEl) {
1569
+ var targetId = toggleEl.dataset.target
1570
+ var container = document.getElementById(targetId)
1571
+ if (!container) return
1572
+
1573
+ var body
1574
+ if (container.classList.contains("message-block")) {
1575
+ // Message-level: find the currently visible direct message-body
1576
+ var bodies = container.querySelectorAll(":scope > .message-body")
1577
+ body = null
1578
+ for (const body_ of bodies) {
1579
+ if (body_.style.display !== "none") {
1580
+ body = body_
1581
+ break
1582
+ }
1583
+ }
1584
+ } else {
1585
+ // Content-block level: find the content-block-body
1586
+ body = container.querySelector(".content-block-body")
1587
+ }
1588
+
1589
+ if (!body) return
1590
+ var isCollapsed = body.classList.toggle("body-collapsed")
1591
+ toggleEl.innerHTML = isCollapsed ? ICON_EXPAND + "Expand" : ICON_CONTRACT + "Collapse"
1592
+ }
1593
+
1594
+ function updateExpandButtons() {
1595
+ // Handle content-block level expand buttons
1596
+ var contentBlocks = document.querySelectorAll(".content-block")
1597
+ for (var i = 0; i < contentBlocks.length; i++) {
1598
+ var toggle = contentBlocks[i].querySelector(".expand-toggle")
1599
+ if (!toggle) continue
1600
+ var body = contentBlocks[i].querySelector(".content-block-body")
1601
+ toggle.style.display = body && body.classList.contains("body-collapsed") && body.scrollHeight > 200 ? "" : "none"
1602
+ }
1603
+
1604
+ // Handle message-block level expand buttons
1605
+ var msgBlocks = document.querySelectorAll(".message-block")
1606
+ for (var i = 0; i < msgBlocks.length; i++) {
1607
+ // Only look at direct expand-toggle in message-header (not nested in content-blocks)
1608
+ var toggle = msgBlocks[i].querySelector(":scope > .message-header .expand-toggle")
1609
+ if (!toggle) continue
1610
+ // Find the currently visible message-body
1611
+ var bodies = msgBlocks[i].querySelectorAll(":scope > .message-body")
1612
+ var visibleBody = null
1613
+ for (const body_ of bodies) {
1614
+ if (body_.style.display !== "none") {
1615
+ visibleBody = body_
1616
+ break
1617
+ }
1618
+ }
1619
+ toggle.style.display =
1620
+ visibleBody && visibleBody.classList.contains("body-collapsed") && visibleBody.scrollHeight > 200 ? "" : "none"
1621
+ }
1622
+ }
1623
+
1624
+ function scrollToToolUse(id) {
1625
+ const el = document.getElementById("tool-" + id)
1626
+ if (el) {
1627
+ el.scrollIntoView({ behavior: "smooth", block: "center" })
1628
+ highlightBlock(el)
1629
+ }
1630
+ }
1631
+
1632
+ function scrollToToolResult(id) {
1633
+ const el = document.getElementById("result-" + id)
1634
+ if (el) {
1635
+ el.scrollIntoView({ behavior: "smooth", block: "center" })
1636
+ highlightBlock(el)
1637
+ }
1638
+ }
1639
+
1640
+ function highlightBlock(el) {
1641
+ el.classList.remove("highlight-flash")
1642
+ void el.offsetWidth
1643
+ el.classList.add("highlight-flash")
1644
+ }
1645
+
1646
+ function copyText(btn) {
1647
+ const text = btn.dataset.text
1648
+ navigator.clipboard.writeText(text).then(() => {
1649
+ const origHtml = btn.innerHTML
1650
+ btn.innerHTML = "\u2713 Copied"
1651
+ setTimeout(() => (btn.innerHTML = origHtml), 1000)
1652
+ })
1653
+ }
1654
+
1655
+ function showRaw(title, data) {
1656
+ const resolvedData = typeof data === "number" ? rawDataRegistry[data] : data
1657
+ document.querySelector("#raw-modal-title").textContent = title + " - Raw JSON"
1658
+
1659
+ // Use JSON tree view
1660
+ const modalBody = document.querySelector("#raw-modal .modal-body")
1661
+ modalBody.innerHTML = ""
1662
+ const tree = buildJsonTree(resolvedData, 3)
1663
+ modalBody.append(tree)
1664
+
1665
+ document.querySelector("#raw-modal").classList.add("open")
1666
+ // Store the data for copy
1667
+ modalBody.dataset.rawJson = JSON.stringify(resolvedData, null, 2)
1668
+ }
1669
+
1670
+ function closeRawModal(event) {
1671
+ if (!event || event.target === event.currentTarget) {
1672
+ document.querySelector("#raw-modal").classList.remove("open")
1673
+ }
1674
+ }
1675
+
1676
+ function copyRawContent() {
1677
+ const modalBody = document.querySelector("#raw-modal .modal-body")
1678
+ navigator.clipboard.writeText(modalBody.dataset.rawJson || "")
1679
+ }
1680
+
1681
+ function toggleExportMenu() {
1682
+ document.querySelector("#export-menu").classList.toggle("open")
1683
+ }
1684
+
1685
+ function closeExportMenu() {
1686
+ document.querySelector("#export-menu").classList.remove("open")
1687
+ }
1688
+
1689
+ function exportData(format) {
1690
+ globalThis.location.href = "/history/api/export?format=" + format
1691
+ closeExportMenu()
1692
+ }
1693
+
1694
+ function onSessionChange() {
1695
+ currentSessionId = document.querySelector("#session-select").value || null
1696
+ currentPage = 1
1697
+ loadEntries()
1698
+ }
1699
+
1700
+ function debounceListSearch() {
1701
+ clearTimeout(listSearchTimer)
1702
+ listSearchTimer = setTimeout(() => {
1703
+ currentPage = 1
1704
+ loadEntries()
1705
+ }, 300)
1706
+ }
1707
+
1708
+ function debounceDetailSearch() {
1709
+ clearTimeout(detailSearchTimer)
1710
+ detailSearchTimer = setTimeout(() => {
1711
+ detailSearchQuery = document.querySelector("#detail-search").value
1712
+ renderDetail()
1713
+ }, 300)
1714
+ }
1715
+
1716
+ async function clearAll() {
1717
+ if (!confirm("Are you sure you want to clear all history?")) return
1718
+ try {
1719
+ await fetch("/history/api/entries", { method: "DELETE" })
1720
+ hideDetailView()
1721
+ loadSessions()
1722
+ loadStats()
1723
+ loadEntries()
1724
+ } catch (e) {
1725
+ console.error("Failed to clear history:", e)
1726
+ }
1727
+ }
1728
+
1729
+ function refresh() {
1730
+ const listEl = document.querySelector("#request-list")
1731
+ const prevOpacity = listEl.style.opacity
1732
+ listEl.style.opacity = "0.5"
1733
+ listEl.style.transition = "opacity 0.15s"
1734
+
1735
+ Promise.all([loadSessions(), loadStats(), loadEntries()]).then(() => {
1736
+ listEl.style.opacity = prevOpacity || ""
1737
+ if (currentEntryId) {
1738
+ selectEntry(currentEntryId)
1739
+ }
1740
+ })
1741
+ }
1742
+
1743
+ // Keyboard navigation
1744
+ document.addEventListener("keydown", (e) => {
1745
+ if (e.key === "Escape") {
1746
+ closeRawModal()
1747
+ closeExportMenu()
1748
+ }
1749
+
1750
+ if (document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "SELECT") {
1751
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1752
+ e.preventDefault()
1753
+ const items = document.querySelectorAll(".request-item")
1754
+ if (items.length === 0) return
1755
+
1756
+ let currentIndex = -1
1757
+ for (const [index, item] of items.entries()) {
1758
+ if (item.classList.contains("selected")) currentIndex = index
1759
+ }
1760
+
1761
+ let newIndex
1762
+ if (e.key === "ArrowDown") {
1763
+ newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
1764
+ } else {
1765
+ newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
1766
+ }
1767
+
1768
+ const newItem = items[newIndex]
1769
+ selectEntry(newItem.dataset.id)
1770
+ newItem.scrollIntoView({ block: "nearest" })
1771
+ }
1772
+
1773
+ if (e.key === "/") {
1774
+ e.preventDefault()
1775
+ document.querySelector("#list-search").focus()
1776
+ }
1777
+ }
1778
+ })
1779
+
1780
+ // Initialize
1781
+ loadSessions()
1782
+ loadStats()
1783
+ loadEntries()
1784
+
1785
+ // Inject SVG icons into static elements
1786
+ document.querySelector("#list-search-icon").innerHTML = ICON_SEARCH
1787
+ document.querySelector("#detail-search-icon").innerHTML = ICON_SEARCH
1788
+ document.querySelector("#btn-close-raw").innerHTML = ICON_CLOSE
1789
+ document.querySelector("#btn-refresh").innerHTML = ICON_REFRESH + "Refresh"
1790
+ document.querySelector("#btn-export").innerHTML = ICON_DOWNLOAD + "Export"
1791
+ document.querySelector("#btn-clear").innerHTML = ICON_TRASH + "Clear"
1792
+
1793
+ // Close export menu on click outside
1794
+ document.addEventListener("click", function (e) {
1795
+ var wrapper = document.querySelector(".export-wrapper")
1796
+ if (wrapper && !wrapper.contains(e.target)) {
1797
+ closeExportMenu()
1798
+ }
1799
+ })