Created
June 25, 2026 08:15
-
-
Save av/11d7925e79a8a5fac3d724e8f9353e14 to your computer and use it in GitHub Desktop.
grok session — Explain me the tracequest share feature (grok-composer-2.5-fast, 11s)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>tracequest — 019efdd4</title> | |
| <style> | |
| :root { | |
| --bg: #111113; | |
| --surface: #19191c; | |
| --surface2: #222226; | |
| --fg: #e4e4e7; | |
| --fg2: #8b8b92; | |
| --fg3: #5c5c63; | |
| --border: rgba(255, 255, 255, 0.06); | |
| --accent: #8b7cf6; | |
| --red: #f07070; | |
| --green: #4ade80; | |
| --orange: #e8a44c; | |
| --sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| --mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', monospace; | |
| --green-dim: rgba(74, 222, 128, 0.1); | |
| --red-dim: rgba(240, 112, 112, 0.1); | |
| --orange-dim: rgba(232, 164, 76, 0.1); | |
| --radius: 10px; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| background: var(--bg); | |
| color: var(--fg); | |
| font-family: var(--sans); | |
| font-size: 14px; | |
| line-height: 1.6; | |
| overflow-x: hidden; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| #app { max-width: 960px; margin: 0 auto; padding: 40px 24px; } | |
| .header { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 24px 28px; | |
| margin-bottom: 12px; | |
| } | |
| .header-title { | |
| font-size: 15px; | |
| font-weight: 600; | |
| color: var(--fg); | |
| letter-spacing: -0.2px; | |
| margin-bottom: 16px; | |
| display: flex; | |
| align-items: baseline; | |
| gap: 8px; | |
| } | |
| .header-title span { | |
| color: var(--fg3); | |
| font-weight: 400; | |
| font-size: 13px; | |
| font-family: var(--mono); | |
| } | |
| .header-top { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| margin-bottom: 16px; | |
| } | |
| .header-top .header-title { margin-bottom: 0; min-width: 0; } | |
| .export-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--fg2); | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| padding: 5px 10px; | |
| cursor: pointer; | |
| text-decoration: none; | |
| transition: all 0.12s; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| } | |
| .export-btn:hover { | |
| color: var(--fg); | |
| border-color: rgba(255, 255, 255, 0.14); | |
| background: rgba(139, 124, 246, 0.08); | |
| } | |
| .md-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--fg2); | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| padding: 5px 10px; | |
| cursor: pointer; | |
| text-decoration: none; | |
| transition: all 0.12s; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| } | |
| .md-btn:hover { | |
| color: var(--fg); | |
| border-color: rgba(255, 255, 255, 0.14); | |
| background: rgba(139, 124, 246, 0.08); | |
| } | |
| .export-btn svg, .print-btn svg, .md-btn svg, .hf-btn svg { | |
| width: 12px; | |
| height: 12px; | |
| stroke: currentColor; | |
| fill: none; | |
| stroke-width: 2; | |
| } | |
| .print-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--fg2); | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| padding: 5px 10px; | |
| cursor: pointer; | |
| transition: all 0.12s; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| } | |
| .print-btn:hover { | |
| color: var(--fg); | |
| border-color: rgba(255, 255, 255, 0.14); | |
| background: rgba(139, 124, 246, 0.08); | |
| } | |
| .hf-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--fg2); | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| padding: 5px 10px; | |
| cursor: pointer; | |
| transition: all 0.12s; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| } | |
| .hf-btn:hover { | |
| color: var(--fg); | |
| border-color: rgba(255, 255, 255, 0.14); | |
| background: rgba(139, 124, 246, 0.08); | |
| } | |
| .hf-modal-overlay { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0, 0, 0, 0.6); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| } | |
| .hf-modal { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 24px; | |
| width: 400px; | |
| max-width: 90vw; | |
| } | |
| .hf-modal-title { | |
| font-size: 15px; | |
| font-weight: 600; | |
| color: var(--fg); | |
| margin-bottom: 16px; | |
| } | |
| .hf-modal-label { | |
| display: block; | |
| font-size: 12px; | |
| color: var(--fg2); | |
| margin-bottom: 4px; | |
| margin-top: 12px; | |
| } | |
| .hf-modal-input { | |
| width: 100%; | |
| box-sizing: border-box; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--fg); | |
| font-family: var(--mono); | |
| font-size: 13px; | |
| padding: 8px 10px; | |
| } | |
| .hf-modal-input:focus { | |
| outline: none; | |
| border-color: rgba(139, 124, 246, 0.5); | |
| } | |
| .hf-modal-hint { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| margin-top: 4px; | |
| } | |
| .hf-modal-status { | |
| font-size: 12px; | |
| color: var(--fg2); | |
| margin-top: 12px; | |
| min-height: 18px; | |
| word-break: break-all; | |
| } | |
| .hf-modal-status.hf-error { color: var(--red, #f07070); } | |
| .hf-modal-status.hf-success { color: var(--green, #59d4a0); } | |
| .hf-modal-status a { color: inherit; text-decoration: underline; } | |
| .hf-modal-actions { | |
| display: flex; | |
| gap: 8px; | |
| justify-content: flex-end; | |
| margin-top: 16px; | |
| } | |
| .hf-modal-share, .hf-modal-cancel { | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| padding: 6px 16px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| border: 1px solid var(--border); | |
| transition: all 0.12s; | |
| } | |
| .hf-modal-cancel { | |
| background: var(--surface2); | |
| color: var(--fg2); | |
| } | |
| .hf-modal-share { | |
| background: rgba(139, 124, 246, 0.15); | |
| color: var(--fg); | |
| border-color: rgba(139, 124, 246, 0.3); | |
| } | |
| .hf-modal-share:hover { background: rgba(139, 124, 246, 0.25); } | |
| .hf-modal-share:disabled { opacity: 0.5; cursor: default; } | |
| .header-actions { | |
| display: flex; | |
| gap: 6px; | |
| flex-shrink: 0; | |
| } | |
| .meta-grid { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px 20px; | |
| } | |
| .meta-item { display: flex; gap: 6px; align-items: baseline; } | |
| .meta-label { color: var(--fg3); font-size: 12px; font-weight: 500; } | |
| .meta-value { | |
| color: var(--fg2); | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .stats-bar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0; | |
| margin-bottom: 12px; | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 0; | |
| } | |
| .stat { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 0 12px; | |
| border-right: 1px solid var(--border); | |
| } | |
| .stat:last-child { border-right: none; } | |
| .stats-note { | |
| width: 100%; | |
| text-align: center; | |
| font-size: 11px; | |
| color: var(--fg3); | |
| padding-top: 10px; | |
| border-top: 1px solid var(--border); | |
| margin-top: 10px; | |
| } | |
| .stat-value { | |
| font-size: 22px; | |
| font-weight: 600; | |
| color: var(--fg); | |
| letter-spacing: -0.5px; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .stat-value.red { color: var(--red); } | |
| .stat-value.green { color: var(--green); } | |
| .stat-label { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| margin-top: 4px; | |
| letter-spacing: 0.3px; | |
| } | |
| .chapters { display: flex; flex-direction: column; gap: 2px; } | |
| .chapter { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| } | |
| .chapter:hover { background: var(--surface2); } | |
| .chapter.expanded { background: var(--surface2); } | |
| .chapter-head { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 12px; | |
| padding: 14px 20px; | |
| } | |
| .chapter-num { | |
| font-size: 12px; | |
| color: var(--fg3); | |
| min-width: 20px; | |
| padding-top: 2px; | |
| font-variant-numeric: tabular-nums; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .chapter-outcome-dot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .chapter-outcome-dot.dot-clean { background: var(--green); opacity: 0.6; } | |
| .chapter-outcome-dot.dot-corrected { background: var(--orange); opacity: 0.8; } | |
| .chapter-outcome-dot.dot-struggling { background: var(--red); opacity: 0.9; } | |
| .chapter-body { flex: 1; min-width: 0; } | |
| .chapter-prompt { | |
| font-size: 14px; | |
| color: var(--fg); | |
| line-height: 1.5; | |
| margin-bottom: 8px; | |
| } | |
| .chapter-prompt-text { | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| .chapter.expanded .chapter-prompt-text { | |
| display: block; | |
| -webkit-line-clamp: unset; | |
| } | |
| .chapter-meta { | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .chapter-tools { | |
| display: flex; | |
| gap: 4px; | |
| flex-wrap: wrap; | |
| } | |
| .ch-tool { | |
| padding: 2px 7px; | |
| border-radius: 5px; | |
| font-size: 11px; | |
| font-weight: 500; | |
| font-family: var(--mono); | |
| background: rgba(255, 255, 255, 0.04); | |
| color: var(--fg2); | |
| } | |
| .chapter-outcome { | |
| font-size: 11px; | |
| font-weight: 500; | |
| padding: 2px 7px; | |
| border-radius: 5px; | |
| } | |
| .chapter-outcome.clean { background: var(--green-dim); color: var(--green); } | |
| .chapter-outcome.error { background: var(--red-dim); color: var(--red); } | |
| .chapter-outcome.corrected { background: var(--orange-dim); color: var(--orange); } | |
| .chapter-patterns { | |
| display: inline-flex; | |
| gap: 4px; | |
| align-items: center; | |
| } | |
| .chapter-pattern-badge { | |
| font-size: 10px; | |
| font-weight: 500; | |
| padding: 1px 6px; | |
| border-radius: 4px; | |
| font-family: var(--mono); | |
| } | |
| .chapter-pattern-badge.retry { | |
| background: var(--red-dim); | |
| color: var(--red); | |
| } | |
| .chapter-pattern-badge.correction { | |
| background: var(--green-dim); | |
| color: var(--green); | |
| } | |
| .chapter-right { | |
| text-align: right; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| padding-top: 2px; | |
| } | |
| .chapter-time { | |
| font-size: 12px; | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .chapter-turns { font-size: 11px; color: var(--fg3); margin-top: 2px; } | |
| .chapter-tokens { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| font-variant-numeric: tabular-nums; | |
| margin-top: 2px; | |
| } | |
| .chapter-thinking-badge { | |
| font-size: 10px; | |
| color: var(--accent); | |
| font-family: var(--mono); | |
| opacity: 0.8; | |
| margin-top: 1px; | |
| } | |
| .chapter-token-detail { margin-bottom: 10px; } | |
| .chapter-token-breakdown { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| padding: 2px 0; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .chapter-token-cost { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--accent); | |
| margin-top: 2px; | |
| } | |
| .chapter-thinking { | |
| margin-bottom: 10px; | |
| border-left: 2px solid rgba(167, 139, 250, 0.3); | |
| padding-left: 10px; | |
| } | |
| .chapter-thinking-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| margin-bottom: 6px; | |
| } | |
| .chapter-thinking-icon { | |
| font-size: 13px; | |
| line-height: 1; | |
| } | |
| .chapter-thinking-count { | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| margin-left: auto; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .chapter-thinking-block { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| font-style: italic; | |
| color: var(--fg3); | |
| background: rgba(167, 139, 250, 0.04); | |
| border-radius: 4px; | |
| padding: 6px 8px; | |
| margin-bottom: 4px; | |
| max-height: 72px; | |
| overflow: hidden; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| line-height: 1.4; | |
| position: relative; | |
| } | |
| .chapter-thinking-block.expanded { | |
| max-height: none; | |
| overflow: visible; | |
| } | |
| .chapter-thinking-more { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| font-style: italic; | |
| padding: 2px 0; | |
| } | |
| .chapter-permalink { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 4px; | |
| color: var(--fg3); | |
| opacity: 0; | |
| transition: opacity 0.15s, background 0.15s, color 0.15s; | |
| cursor: pointer; | |
| font-size: 12px; | |
| margin-top: 2px; | |
| } | |
| .chapter:hover .chapter-permalink, | |
| .chapter.expanded .chapter-permalink { opacity: 1; } | |
| .chapter-permalink:hover { | |
| background: rgba(139, 124, 246, 0.12); | |
| color: var(--accent); | |
| } | |
| .chapter-permalink.copied { | |
| opacity: 1; | |
| color: var(--green); | |
| } | |
| .chapter-detail { | |
| padding: 0 20px 16px 52px; | |
| display: none; | |
| } | |
| .chapter.expanded .chapter-detail { display: block; } | |
| .chapter-quality { | |
| margin-bottom: 12px; | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| background: var(--surface); | |
| border-left: 3px solid var(--fg3); | |
| } | |
| .chapter-quality-label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.3px; | |
| margin-bottom: 4px; | |
| } | |
| .chapter-quality-label.corrected { color: var(--orange); border-color: var(--orange); } | |
| .chapter-quality-label.struggling { color: var(--red); border-color: var(--red); } | |
| .chapter-quality-label.clean { color: var(--green); } | |
| .chapter-quality .chapter-quality-label.corrected ~ * { border-color: var(--orange); } | |
| .chapter-quality:has(.chapter-quality-label.corrected) { border-left-color: var(--orange); } | |
| .chapter-quality:has(.chapter-quality-label.struggling) { border-left-color: var(--red); } | |
| .chapter-quality-retries { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px; | |
| margin-top: 4px; | |
| } | |
| .chapter-retry-chip { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| background: rgba(240, 112, 112, 0.06); | |
| padding: 2px 7px; | |
| border-radius: 4px; | |
| } | |
| .chapter-quality-corrections { | |
| font-size: 11px; | |
| color: var(--green); | |
| margin-top: 4px; | |
| } | |
| .chapter-waste-badge { | |
| font-size: 10px; | |
| font-weight: 500; | |
| padding: 1px 6px; | |
| border-radius: 4px; | |
| font-family: var(--mono); | |
| background: var(--orange-dim); | |
| color: var(--orange); | |
| } | |
| .chapter-efficiency { | |
| margin-bottom: 12px; | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| background: var(--surface); | |
| border-left: 3px solid var(--orange); | |
| } | |
| .chapter-efficiency-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| margin-bottom: 6px; | |
| } | |
| .chapter-efficiency-label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.3px; | |
| color: var(--orange); | |
| } | |
| .chapter-efficiency-score { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| margin-left: auto; | |
| } | |
| .chapter-efficiency-bar-wrap { | |
| height: 4px; | |
| background: rgba(255,255,255,0.06); | |
| border-radius: 2px; | |
| margin-bottom: 8px; | |
| } | |
| .chapter-efficiency-bar { | |
| height: 100%; | |
| border-radius: 2px; | |
| transition: width 0.3s; | |
| } | |
| .chapter-efficiency-bar.eff-good { background: var(--green); } | |
| .chapter-efficiency-bar.eff-ok { background: var(--orange); } | |
| .chapter-efficiency-bar.eff-bad { background: var(--red); } | |
| .chapter-efficiency-metrics { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px 16px; | |
| } | |
| .chapter-efficiency-metric { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| } | |
| .chapter-efficiency-metric-value { | |
| font-weight: 600; | |
| color: var(--fg); | |
| } | |
| .chapter-efficiency-metric-value.val-good { color: var(--green); } | |
| .chapter-efficiency-metric-value.val-bad { color: var(--red); } | |
| .chapter-efficiency-reasons { | |
| margin-top: 6px; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px; | |
| } | |
| .chapter-efficiency-reason { | |
| font-size: 10px; | |
| padding: 2px 7px; | |
| border-radius: 4px; | |
| background: rgba(232,164,76,0.08); | |
| color: var(--orange); | |
| } | |
| .chapter-files { margin-bottom: 10px; } | |
| .chapter-files-label { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-weight: 500; | |
| margin-bottom: 4px; | |
| } | |
| .chapter-file { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| padding: 2px 0; | |
| display: flex; | |
| gap: 8px; | |
| align-items: baseline; | |
| } | |
| .chapter-file-ops { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| } | |
| .chapter-response { | |
| font-size: 13px; | |
| color: var(--fg3); | |
| line-height: 1.6; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| border-top: 1px solid var(--border); | |
| padding-top: 10px; | |
| margin-top: 10px; | |
| } | |
| .chapter-commands { margin-bottom: 10px; } | |
| .chapter-cmd { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| padding: 2px 0; | |
| display: flex; | |
| gap: 6px; | |
| align-items: baseline; | |
| } | |
| .chapter-cmd-status { font-size: 11px; } | |
| .chapter-cmd-status.ok { color: var(--green); } | |
| .chapter-cmd-status.fail { color: var(--red); } | |
| .chapter-output { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| background: rgba(255, 255, 255, 0.02); | |
| border-radius: 4px; | |
| padding: 4px 8px; | |
| margin: 2px 0 6px 18px; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| line-height: 1.5; | |
| max-height: 80px; | |
| overflow: hidden; | |
| border-left: 2px solid rgba(255, 255, 255, 0.06); | |
| position: relative; | |
| } | |
| .chapter-output.expanded, .chapter-diff-del.expanded, | |
| .chapter-diff-add.expanded, .chapter-agent-prompt.expanded, | |
| .chapter-mcp-output.expanded, | |
| .chapter-thinking-block.expanded { | |
| max-height: none; | |
| overflow: visible; | |
| } | |
| .expand-toggle { | |
| display: block; | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| color: var(--accent); | |
| cursor: pointer; | |
| padding: 2px 8px; | |
| margin: -2px 0 6px 18px; | |
| user-select: none; | |
| opacity: 0.8; | |
| transition: opacity 0.15s; | |
| } | |
| .expand-toggle:hover { opacity: 1; } | |
| .expand-toggle.in-diff { | |
| margin: -2px 0 2px 0; | |
| padding: 2px 8px; | |
| } | |
| .chapter-search-count { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| margin-left: 8px; | |
| white-space: nowrap; | |
| } | |
| .chapter-web { margin-bottom: 10px; } | |
| .chapter-web-label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: var(--fg3); | |
| margin-bottom: 6px; | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .chapter-web-label::before { | |
| content: '\1F310'; | |
| font-size: 12px; | |
| } | |
| .chapter-web-op { | |
| font-size: 12px; | |
| color: var(--fg2); | |
| padding: 6px 8px; | |
| margin-bottom: 6px; | |
| background: var(--surface2); | |
| border-radius: 6px; | |
| border-left: 3px solid #6ba4e8; | |
| } | |
| .chapter-web-op.error { border-left-color: var(--red); } | |
| .chapter-web-type { | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| padding: 1px 5px; | |
| border-radius: 3px; | |
| margin-right: 6px; | |
| } | |
| .chapter-web-type.fetch { | |
| color: #6ba4e8; | |
| background: rgba(107, 164, 232, 0.12); | |
| } | |
| .chapter-web-type.search { | |
| color: #a78bfa; | |
| background: rgba(167, 139, 250, 0.12); | |
| } | |
| .chapter-web-url { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: #6ba4e8; | |
| text-decoration: none; | |
| word-break: break-all; | |
| } | |
| .chapter-web-url:hover { text-decoration: underline; } | |
| .chapter-web-query { | |
| font-weight: 500; | |
| color: var(--fg); | |
| } | |
| .chapter-web-prompt { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| margin-top: 3px; | |
| font-style: italic; | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| .chapter-web-preview { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| margin-top: 4px; | |
| padding: 4px 6px; | |
| background: rgba(255, 255, 255, 0.02); | |
| border-radius: 4px; | |
| max-height: 48px; | |
| overflow: hidden; | |
| white-space: pre-wrap; | |
| position: relative; | |
| } | |
| .chapter-web-results { | |
| margin-top: 4px; | |
| padding-left: 4px; | |
| } | |
| .chapter-web-result { | |
| font-size: 11px; | |
| padding: 1px 0; | |
| display: flex; | |
| align-items: baseline; | |
| gap: 6px; | |
| } | |
| .chapter-web-result-title { | |
| color: var(--fg); | |
| font-weight: 400; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| max-width: 300px; | |
| } | |
| .chapter-web-result-url { | |
| font-family: var(--mono); | |
| font-size: 10px; | |
| color: var(--fg3); | |
| text-decoration: none; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| max-width: 200px; | |
| } | |
| .chapter-web-result-url:hover { color: #6ba4e8; } | |
| .chapter-web-count { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| margin-left: auto; | |
| white-space: nowrap; | |
| } | |
| .chapter-web-title { | |
| font-size: 11px; | |
| color: var(--fg); | |
| font-weight: 500; | |
| margin-top: 2px; | |
| } | |
| .chapter-agents { margin-bottom: 10px; } | |
| .chapter-agent { | |
| font-size: 12px; | |
| color: var(--fg2); | |
| padding: 2px 0; | |
| display: flex; | |
| gap: 6px; | |
| align-items: baseline; | |
| } | |
| .chapter-agent-status { font-size: 11px; } | |
| .chapter-agent-status.ok { color: var(--green); } | |
| .chapter-agent-status.fail { color: var(--red); } | |
| .chapter-agent-status.pending { color: var(--fg3); } | |
| .chapter-agent-desc { | |
| font-size: 12px; | |
| color: var(--fg); | |
| font-weight: 500; | |
| } | |
| .chapter-agent-type { | |
| font-size: 10px; | |
| padding: 1px 5px; | |
| border-radius: 4px; | |
| background: rgba(167, 139, 250, 0.12); | |
| color: var(--accent); | |
| font-family: var(--mono); | |
| } | |
| .chapter-agent-prompt { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| padding: 3px 8px; | |
| margin: 2px 0 4px 18px; | |
| line-height: 1.5; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| border-left: 2px solid rgba(167, 139, 250, 0.2); | |
| max-height: 60px; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .chapter-mcp { margin-bottom: 10px; } | |
| .chapter-mcp-label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| color: var(--fg3); | |
| margin-bottom: 6px; | |
| } | |
| .chapter-mcp-label::before { | |
| content: '\2699 '; | |
| font-size: 12px; | |
| } | |
| .chapter-mcp-op { | |
| font-size: 12px; | |
| padding: 6px 8px; | |
| margin-bottom: 4px; | |
| background: var(--surface2); | |
| border-radius: 6px; | |
| border-left: 3px solid #5dadec; | |
| } | |
| .chapter-mcp-op.mcp-error { border-left-color: var(--red); } | |
| .chapter-mcp-header { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 6px; | |
| flex-wrap: wrap; | |
| } | |
| .chapter-mcp-server { | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| font-weight: 600; | |
| color: #5dadec; | |
| background: rgba(93, 173, 236, 0.12); | |
| padding: 1px 6px; | |
| border-radius: 4px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.3px; | |
| } | |
| .chapter-mcp-tool { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg); | |
| font-weight: 500; | |
| } | |
| .chapter-mcp-status { | |
| font-size: 11px; | |
| margin-left: auto; | |
| } | |
| .chapter-mcp-status.ok { color: var(--green); } | |
| .chapter-mcp-status.fail { color: var(--red); } | |
| .chapter-mcp-params { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px 10px; | |
| margin-top: 4px; | |
| font-size: 11px; | |
| } | |
| .chapter-mcp-param-key { | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| } | |
| .chapter-mcp-param-val { | |
| color: var(--fg2); | |
| font-family: var(--mono); | |
| word-break: break-all; | |
| } | |
| .chapter-mcp-output { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--fg3); | |
| background: rgba(255,255,255,0.02); | |
| border-radius: 4px; | |
| padding: 4px 6px; | |
| margin-top: 4px; | |
| max-height: 48px; | |
| overflow: hidden; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| line-height: 1.5; | |
| position: relative; | |
| } | |
| .chapter-mcp-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 3px; | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| color: #5dadec; | |
| background: rgba(93, 173, 236, 0.08); | |
| border-radius: 4px; | |
| padding: 1px 6px; | |
| margin-left: 4px; | |
| white-space: nowrap; | |
| } | |
| .chapter-diffs { margin-bottom: 10px; } | |
| .chapter-diff-header { | |
| display: flex; | |
| gap: 8px; | |
| align-items: baseline; | |
| margin-top: 6px; | |
| margin-bottom: 2px; | |
| } | |
| .chapter-diff-path { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| } | |
| .chapter-diff-op { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| } | |
| .chapter-diff-block { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| margin: 2px 0 6px 0; | |
| border: 1px solid var(--border); | |
| line-height: 1.5; | |
| } | |
| .chapter-diff-del { | |
| background: var(--red-dim); | |
| color: var(--red); | |
| padding: 4px 8px; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| border-left: 3px solid var(--red); | |
| max-height: 60px; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .chapter-diff-add { | |
| background: var(--green-dim); | |
| color: var(--green); | |
| padding: 4px 8px; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| border-left: 3px solid var(--green); | |
| max-height: 60px; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .waveform-wrap { | |
| margin-bottom: 12px; | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 20px 12px; | |
| position: relative; | |
| } | |
| .waveform-label { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| margin-bottom: 8px; | |
| font-weight: 500; | |
| } | |
| .waveform-canvas { | |
| width: 100%; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| display: block; | |
| } | |
| .waveform-tooltip { | |
| position: absolute; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| font-size: 12px; | |
| color: var(--fg); | |
| pointer-events: none; | |
| z-index: 20; | |
| white-space: nowrap; | |
| display: none; | |
| line-height: 1.5; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.4); | |
| } | |
| .waveform-legend { | |
| display: flex; | |
| gap: 14px; | |
| margin-top: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| font-size: 11px; | |
| color: var(--fg3); | |
| } | |
| .legend-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 2px; | |
| } | |
| .waveform-axis { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 11px; | |
| color: var(--fg3); | |
| margin-top: 4px; | |
| } | |
| html { scroll-behavior: smooth; } | |
| .waveform-cursor { | |
| position: absolute; | |
| width: 1px; | |
| background: rgba(255,255,255,0.25); | |
| pointer-events: none; | |
| z-index: 10; | |
| display: none; | |
| } | |
| .chapter.highlight { | |
| animation: ch-flash 1.5s ease-out; | |
| } | |
| @keyframes ch-flash { | |
| 0% { background: rgba(139, 124, 246, 0.15); } | |
| 100% { background: var(--surface); } | |
| } | |
| .chapter.wf-hover { | |
| background: var(--surface2); | |
| transition: background 0.1s; | |
| } | |
| .filter-bar { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 12px 16px; | |
| margin-bottom: 12px; | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .filter-tools { | |
| display: flex; | |
| gap: 4px; | |
| flex-wrap: wrap; | |
| } | |
| .filter-chip { | |
| padding: 4px 10px; | |
| border-radius: 6px; | |
| font-size: 11px; | |
| font-weight: 500; | |
| font-family: var(--mono); | |
| background: rgba(255, 255, 255, 0.04); | |
| color: var(--fg3); | |
| cursor: pointer; | |
| user-select: none; | |
| transition: background 0.12s, color 0.12s; | |
| border: 1px solid transparent; | |
| } | |
| .filter-chip:hover { | |
| background: rgba(255, 255, 255, 0.08); | |
| color: var(--fg2); | |
| } | |
| .filter-chip.active { | |
| background: rgba(139, 124, 246, 0.15); | |
| color: var(--accent); | |
| border-color: rgba(139, 124, 246, 0.3); | |
| } | |
| .filter-search { | |
| flex: 1; | |
| min-width: 140px; | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 5px 10px; | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg); | |
| outline: none; | |
| transition: border-color 0.15s; | |
| } | |
| .filter-search::placeholder { color: var(--fg3); } | |
| .filter-search:focus { border-color: rgba(139, 124, 246, 0.4); } | |
| .filter-count { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| white-space: nowrap; | |
| } | |
| .chapter.filter-hidden { display: none; } | |
| .chapter.kb-focused { | |
| outline: none; | |
| border-left: 2px solid rgba(139, 124, 246, 0.5); | |
| background: rgba(139, 124, 246, 0.04); | |
| } | |
| .chapter.kb-focused.expanded { | |
| border-left: 2px solid rgba(139, 124, 246, 0.5); | |
| background: var(--surface2); | |
| } | |
| .error-summary { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 20px; | |
| margin-bottom: 12px; | |
| border-left: 3px solid var(--red); | |
| } | |
| .error-summary-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 10px; | |
| } | |
| .error-summary-title { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--red); | |
| letter-spacing: 0.2px; | |
| } | |
| .error-summary-count { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| } | |
| .error-summary-body { | |
| display: flex; | |
| gap: 20px; | |
| flex-wrap: wrap; | |
| align-items: flex-start; | |
| } | |
| .error-tools-list { | |
| display: flex; | |
| gap: 6px; | |
| flex-wrap: wrap; | |
| } | |
| .error-tool-chip { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| padding: 2px 8px; | |
| border-radius: 5px; | |
| background: var(--red-dim); | |
| color: var(--red); | |
| font-weight: 500; | |
| } | |
| .error-streaks { | |
| display: flex; | |
| gap: 6px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .error-streak-badge { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| padding: 2px 8px; | |
| border-radius: 5px; | |
| background: var(--orange-dim); | |
| color: var(--orange); | |
| cursor: pointer; | |
| transition: background 0.12s; | |
| } | |
| .error-streak-badge:hover { | |
| background: rgba(232, 164, 76, 0.2); | |
| } | |
| .error-timeline { | |
| display: flex; | |
| gap: 2px; | |
| align-items: center; | |
| margin-top: 8px; | |
| } | |
| .error-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: transform 0.12s, box-shadow 0.12s; | |
| flex-shrink: 0; | |
| } | |
| .error-dot:hover { | |
| transform: scale(1.5); | |
| box-shadow: 0 0 6px var(--red); | |
| } | |
| .error-dot.has-error { | |
| background: var(--red); | |
| } | |
| .error-dot.no-error { | |
| background: rgba(255, 255, 255, 0.06); | |
| } | |
| .error-dot.streak { | |
| background: var(--orange); | |
| box-shadow: 0 0 4px rgba(232, 164, 76, 0.4); | |
| } | |
| .chapter.error-streak-hl { | |
| border-left: 2px solid var(--orange); | |
| } | |
| /* Tool flow visualization */ | |
| .tool-flow { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 20px; | |
| margin-bottom: 12px; | |
| } | |
| .tool-flow-header { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--fg2); | |
| margin-bottom: 12px; | |
| letter-spacing: 0.2px; | |
| } | |
| .tool-flow-sequence { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 1px; | |
| margin-bottom: 14px; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .tool-flow-cell { | |
| width: 6px; | |
| height: 18px; | |
| flex-shrink: 0; | |
| opacity: 0.85; | |
| transition: opacity 0.1s, transform 0.1s; | |
| cursor: default; | |
| } | |
| .tool-flow-cell:hover { | |
| opacity: 1; | |
| transform: scaleY(1.4); | |
| z-index: 2; | |
| position: relative; | |
| } | |
| .tool-flow-divider { | |
| width: 2px; | |
| height: 18px; | |
| flex-shrink: 0; | |
| background: var(--border); | |
| margin: 0 1px; | |
| } | |
| .tool-flow-summary { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| } | |
| .tool-flow-stat { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 10px; | |
| border-radius: 6px; | |
| background: rgba(255, 255, 255, 0.02); | |
| } | |
| .tool-flow-stat-dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 3px; | |
| flex-shrink: 0; | |
| } | |
| .tool-flow-stat-name { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| flex: 1; | |
| } | |
| .tool-flow-stat-count { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg); | |
| font-weight: 600; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .tool-flow-stat-avg { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| } | |
| .tool-flow-transitions { | |
| margin-top: 12px; | |
| border-top: 1px solid var(--border); | |
| padding-top: 12px; | |
| } | |
| .tool-flow-transitions-label { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-weight: 500; | |
| margin-bottom: 8px; | |
| } | |
| .tool-flow-transition-list { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| } | |
| .tool-flow-transition { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| padding: 3px 8px; | |
| border-radius: 5px; | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid var(--border); | |
| } | |
| .tool-flow-transition-arrow { | |
| color: var(--fg3); | |
| font-size: 10px; | |
| } | |
| .tool-flow-transition-count { | |
| color: var(--fg3); | |
| font-size: 10px; | |
| margin-left: 2px; | |
| } | |
| /* Tool performance metrics */ | |
| .tool-perf { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 20px; | |
| margin-bottom: 12px; | |
| } | |
| .tool-perf-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .tool-perf-title { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--fg2); | |
| letter-spacing: 0.2px; | |
| } | |
| .tool-perf-toggle { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| } | |
| .tool-perf-body { | |
| display: none; | |
| margin-top: 14px; | |
| } | |
| .tool-perf.expanded .tool-perf-body { display: block; } | |
| .tool-perf-sort { | |
| display: flex; | |
| gap: 6px; | |
| margin-bottom: 12px; | |
| } | |
| .tool-perf-sort-btn { | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| background: none; | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| padding: 2px 8px; | |
| cursor: pointer; | |
| transition: color 0.15s, border-color 0.15s; | |
| } | |
| .tool-perf-sort-btn:hover { color: var(--fg2); border-color: var(--fg3); } | |
| .tool-perf-sort-btn.active { color: var(--accent); border-color: var(--accent); } | |
| .tool-perf-table { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .tool-perf-row { | |
| display: grid; | |
| grid-template-columns: 10px 90px 50px 1fr 50px 50px; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 10px; | |
| border-radius: 6px; | |
| background: rgba(255, 255, 255, 0.02); | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| } | |
| .tool-perf-row:hover { background: rgba(255, 255, 255, 0.05); } | |
| .tool-perf-row.active { background: rgba(139, 124, 246, 0.08); outline: 1px solid rgba(139, 124, 246, 0.3); } | |
| .tool-perf-dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 3px; | |
| flex-shrink: 0; | |
| } | |
| .tool-perf-name { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .tool-perf-calls { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg); | |
| font-weight: 600; | |
| text-align: right; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .tool-perf-bar-wrap { | |
| height: 14px; | |
| border-radius: 3px; | |
| background: rgba(255, 255, 255, 0.04); | |
| overflow: hidden; | |
| display: flex; | |
| } | |
| .tool-perf-bar-ok { | |
| height: 100%; | |
| background: var(--green); | |
| opacity: 0.7; | |
| transition: width 0.3s; | |
| } | |
| .tool-perf-bar-err { | |
| height: 100%; | |
| background: var(--red); | |
| opacity: 0.7; | |
| transition: width 0.3s; | |
| } | |
| .tool-perf-rate { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| font-variant-numeric: tabular-nums; | |
| text-align: right; | |
| } | |
| .tool-perf-rate.perfect { color: var(--green); } | |
| .tool-perf-rate.good { color: var(--fg2); } | |
| .tool-perf-rate.warn { color: var(--orange); } | |
| .tool-perf-rate.bad { color: var(--red); } | |
| .tool-perf-errs { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| font-variant-numeric: tabular-nums; | |
| color: var(--fg3); | |
| text-align: right; | |
| } | |
| .tool-perf-errs.has-errors { color: var(--red); } | |
| .tool-perf-retries { | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| margin-top: 2px; | |
| grid-column: 3 / -1; | |
| } | |
| /* Cost chart */ | |
| .cost-chart { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 20px 12px; | |
| margin-bottom: 12px; | |
| position: relative; | |
| } | |
| .cost-chart-header { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--fg2); | |
| margin-bottom: 4px; | |
| letter-spacing: 0.2px; | |
| display: flex; | |
| align-items: baseline; | |
| gap: 10px; | |
| } | |
| .cost-chart-total { | |
| font-size: 11px; | |
| color: var(--orange); | |
| font-weight: 500; | |
| } | |
| .cost-chart-canvas { | |
| width: 100%; | |
| height: 100px; | |
| border-radius: 6px; | |
| cursor: crosshair; | |
| display: block; | |
| } | |
| .cost-chart-tooltip { | |
| position: absolute; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 6px 10px; | |
| font-size: 11px; | |
| color: var(--fg); | |
| pointer-events: none; | |
| z-index: 20; | |
| white-space: nowrap; | |
| display: none; | |
| line-height: 1.4; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.4); | |
| } | |
| .cost-chart-axis { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 10px; | |
| color: var(--fg3); | |
| margin-top: 4px; | |
| } | |
| /* File hotspot visualization */ | |
| .file-hotspot { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 20px; | |
| margin-bottom: 12px; | |
| } | |
| .file-hotspot-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .file-hotspot-title { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--fg2); | |
| letter-spacing: 0.2px; | |
| } | |
| .file-hotspot-toggle { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| } | |
| .file-hotspot-count { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| margin-left: auto; | |
| } | |
| .file-hotspot-body { | |
| margin-top: 12px; | |
| display: none; | |
| } | |
| .file-hotspot.expanded .file-hotspot-body { | |
| display: block; | |
| } | |
| .file-hotspot-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 3px; | |
| max-height: 360px; | |
| overflow-y: auto; | |
| } | |
| .file-hotspot-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 6px 10px; | |
| border-radius: 6px; | |
| background: rgba(255, 255, 255, 0.02); | |
| cursor: pointer; | |
| transition: background 0.12s, border-color 0.12s; | |
| border: 1px solid transparent; | |
| } | |
| .file-hotspot-item:hover { | |
| background: rgba(255, 255, 255, 0.05); | |
| } | |
| .file-hotspot-item.active { | |
| background: rgba(139, 124, 246, 0.08); | |
| border-color: rgba(139, 124, 246, 0.25); | |
| } | |
| .file-hotspot-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| transition: width 0.15s, height 0.15s; | |
| } | |
| .file-hotspot-path { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| flex: 1; | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .file-hotspot-ops { | |
| display: flex; | |
| gap: 4px; | |
| flex-shrink: 0; | |
| } | |
| .file-hotspot-op { | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| padding: 1px 5px; | |
| border-radius: 3px; | |
| font-weight: 500; | |
| } | |
| .file-hotspot-op.read { background: rgba(107, 164, 232, 0.12); color: #6ba4e8; } | |
| .file-hotspot-op.edit { background: rgba(224, 196, 94, 0.12); color: #e0c45e; } | |
| .file-hotspot-op.write { background: rgba(216, 150, 96, 0.12); color: #d89660; } | |
| .file-hotspot-touch { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| font-variant-numeric: tabular-nums; | |
| min-width: 24px; | |
| text-align: right; | |
| flex-shrink: 0; | |
| } | |
| .file-hotspot-chapters { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| font-family: var(--mono); | |
| flex-shrink: 0; | |
| max-width: 100px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .file-hotspot-bar { | |
| height: 3px; | |
| border-radius: 2px; | |
| margin-top: 8px; | |
| display: flex; | |
| gap: 1px; | |
| overflow: hidden; | |
| } | |
| .file-hotspot-bar-seg { | |
| height: 100%; | |
| flex-shrink: 0; | |
| } | |
| /* Git timeline panel */ | |
| .git-timeline { | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 20px; | |
| margin-bottom: 12px; | |
| border-left: 3px solid var(--orange); | |
| } | |
| .git-timeline-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| } | |
| .git-timeline-title { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--orange); | |
| letter-spacing: 0.2px; | |
| } | |
| .git-timeline-count { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| } | |
| .git-timeline-ops { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .git-op { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 10px; | |
| border-radius: 6px; | |
| background: rgba(255, 255, 255, 0.02); | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| } | |
| .git-op-icon { | |
| font-size: 13px; | |
| flex-shrink: 0; | |
| width: 18px; | |
| text-align: center; | |
| } | |
| .git-op-icon.commit { color: var(--green); } | |
| .git-op-icon.branch { color: var(--accent); } | |
| .git-op-icon.push { color: var(--orange); } | |
| .git-op-icon.merge { color: #c88abd; } | |
| .git-op-icon.other { color: var(--fg3); } | |
| .git-op-detail { | |
| flex: 1; | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .git-op-hash { | |
| color: var(--orange); | |
| font-size: 11px; | |
| margin-right: 6px; | |
| } | |
| .git-op-msg { | |
| color: var(--fg); | |
| font-size: 12px; | |
| } | |
| .git-op-branch-name { | |
| color: var(--accent); | |
| font-weight: 500; | |
| } | |
| .git-op-chapter { | |
| font-size: 10px; | |
| color: var(--fg3); | |
| flex-shrink: 0; | |
| cursor: pointer; | |
| } | |
| .git-op-chapter:hover { | |
| color: var(--accent); | |
| } | |
| /* Git badge in chapter header */ | |
| .chapter-git-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 3px; | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| padding: 1px 6px; | |
| border-radius: 4px; | |
| background: var(--orange-dim); | |
| color: var(--orange); | |
| font-weight: 500; | |
| } | |
| /* Git section in chapter detail */ | |
| .chapter-git-ops { margin-bottom: 10px; } | |
| .chapter-git-op { | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| color: var(--fg2); | |
| padding: 3px 0; | |
| display: flex; | |
| gap: 6px; | |
| align-items: baseline; | |
| } | |
| .chapter-git-op-icon { font-size: 11px; } | |
| .chapter-git-op-icon.commit { color: var(--green); } | |
| .chapter-git-op-icon.branch { color: var(--accent); } | |
| .chapter-git-op-icon.push { color: var(--orange); } | |
| .chapter-git-op-icon.merge { color: #c88abd; } | |
| .session-summary { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px 16px; | |
| margin-bottom: 12px; | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 14px 20px; | |
| align-items: center; | |
| } | |
| .session-summary-item { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| font-size: 13px; | |
| font-family: var(--mono); | |
| font-variant-numeric: tabular-nums; | |
| color: var(--fg2); | |
| } | |
| .session-summary-item .ss-label { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-weight: 500; | |
| } | |
| .session-summary-item .ss-value { | |
| font-weight: 600; | |
| color: var(--fg); | |
| } | |
| .session-summary-item.ss-cost .ss-value { color: var(--orange); } | |
| .session-summary-item.ss-errors .ss-value { color: var(--red); } | |
| .session-summary-item.ss-commits .ss-value { color: var(--green); } | |
| .session-summary-item.ss-duration .ss-value { color: var(--accent); } | |
| .session-summary-item.ss-quality-mixed .ss-value { color: var(--orange); } | |
| .session-summary-item.ss-waste .ss-value { color: var(--orange); } | |
| .session-summary-sep { | |
| width: 1px; | |
| height: 18px; | |
| background: var(--border); | |
| flex-shrink: 0; | |
| } | |
| .session-grade { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| margin-right: 6px; | |
| } | |
| .session-grade-letter { | |
| font-family: var(--mono); | |
| font-size: 28px; | |
| font-weight: 700; | |
| line-height: 1; | |
| min-width: 32px; | |
| text-align: center; | |
| } | |
| .session-grade-letter.grade-a { color: var(--green); } | |
| .session-grade-letter.grade-b { color: var(--green); opacity: 0.8; } | |
| .session-grade-letter.grade-c { color: var(--orange); } | |
| .session-grade-letter.grade-d { color: #d97740; } | |
| .session-grade-letter.grade-f { color: var(--red); } | |
| .session-grade-detail { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| } | |
| .session-grade-score { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .session-grade-breakdown { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px 8px; | |
| } | |
| .session-grade-factor { | |
| font-family: var(--mono); | |
| font-size: 10px; | |
| color: var(--fg3); | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 3px; | |
| } | |
| .session-grade-factor-bar { | |
| display: inline-block; | |
| width: 28px; | |
| height: 4px; | |
| border-radius: 2px; | |
| background: var(--border); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .session-grade-factor-fill { | |
| position: absolute; | |
| left: 0; | |
| top: 0; | |
| height: 100%; | |
| border-radius: 2px; | |
| } | |
| .session-grade-factor-fill.fill-good { background: var(--green); } | |
| .session-grade-factor-fill.fill-ok { background: var(--orange); } | |
| .session-grade-factor-fill.fill-bad { background: var(--red); } | |
| .session-grade-note { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| font-style: italic; | |
| margin-top: 1px; | |
| } | |
| .activity-timeline { | |
| margin-bottom: 12px; | |
| background: var(--surface); | |
| border-radius: var(--radius); | |
| padding: 16px 20px 12px; | |
| position: relative; | |
| z-index: 10; | |
| } | |
| .activity-timeline-label { | |
| font-size: 11px; | |
| color: var(--fg3); | |
| margin-bottom: 8px; | |
| font-weight: 500; | |
| } | |
| .activity-timeline-canvas { | |
| width: 100%; | |
| border-radius: 6px; | |
| display: block; | |
| cursor: crosshair; | |
| } | |
| .activity-timeline-times { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-top: 6px; | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| color: var(--fg3); | |
| } | |
| .activity-timeline-legend { | |
| display: flex; | |
| gap: 14px; | |
| margin-top: 8px; | |
| font-size: 10px; | |
| color: var(--fg3); | |
| } | |
| .activity-timeline-legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .activity-timeline-legend-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 2px; | |
| } | |
| .activity-timeline-tooltip { | |
| position: absolute; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 6px 10px; | |
| font-size: 11px; | |
| color: var(--fg); | |
| pointer-events: none; | |
| z-index: 20; | |
| white-space: nowrap; | |
| display: none; | |
| line-height: 1.5; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.4); | |
| } | |
| /* Chapter hover tooltip */ | |
| .chapter-tooltip { | |
| position: fixed; | |
| z-index: 9999; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 12px 14px; | |
| min-width: 220px; | |
| max-width: 340px; | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.15s; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.5); | |
| font-size: 12px; | |
| line-height: 1.5; | |
| } | |
| .chapter-tooltip.visible { opacity: 1; } | |
| .chapter-tooltip-prompt { | |
| color: var(--fg); | |
| font-weight: 500; | |
| margin-bottom: 8px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 310px; | |
| } | |
| .chapter-tooltip-tools { | |
| display: flex; | |
| gap: 4px; | |
| flex-wrap: wrap; | |
| margin-bottom: 6px; | |
| } | |
| .chapter-tooltip-tool { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .chapter-tooltip-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| color: var(--fg2); | |
| margin-bottom: 3px; | |
| } | |
| .chapter-tooltip-row:last-child { margin-bottom: 0; } | |
| .chapter-tooltip-label { | |
| color: var(--fg3); | |
| min-width: 48px; | |
| } | |
| .chapter-tooltip-value { | |
| color: var(--fg); | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .chapter-tooltip-value.errors { color: var(--red); } | |
| .chapter-tooltip-value.clean { color: var(--green); } | |
| .chapter-tooltip-value.corrected { color: var(--orange); } | |
| .chapter-tooltip-value.struggling { color: var(--red); } | |
| .chapter-tooltip-files { | |
| color: var(--fg2); | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| margin-top: 2px; | |
| } | |
| .chapter-tooltip-file { | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 300px; | |
| } | |
| /* Session mini-map / navigation sidebar */ | |
| .minimap { | |
| position: fixed; | |
| right: 8px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 28px; | |
| z-index: 500; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 4px 3px; | |
| max-height: 80vh; | |
| overflow: hidden; | |
| opacity: 0.6; | |
| transition: opacity 0.2s; | |
| } | |
| .minimap:hover { opacity: 1; } | |
| .minimap-block { | |
| width: 22px; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| position: relative; | |
| transition: outline 0.15s; | |
| outline: 1.5px solid transparent; | |
| flex-shrink: 0; | |
| } | |
| .minimap-block:hover { outline-color: var(--fg3); } | |
| .minimap-block.mm-clean { background: var(--green); opacity: 0.45; } | |
| .minimap-block.mm-corrected { background: var(--orange); opacity: 0.6; } | |
| .minimap-block.mm-struggling { background: var(--red); opacity: 0.65; } | |
| .minimap-block.mm-visible { | |
| outline-color: var(--accent); | |
| outline-width: 2px; | |
| opacity: 1; | |
| } | |
| .minimap-marker { | |
| position: absolute; | |
| width: 5px; | |
| height: 5px; | |
| border-radius: 50%; | |
| right: -1px; | |
| } | |
| .minimap-marker.mm-error { background: var(--red); top: 1px; } | |
| .minimap-marker.mm-commit { background: var(--green); bottom: 1px; } | |
| .minimap-tooltip { | |
| position: fixed; | |
| pointer-events: none; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 6px 10px; | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--fg); | |
| white-space: nowrap; | |
| z-index: 9999; | |
| opacity: 0; | |
| transition: opacity 0.12s; | |
| } | |
| .minimap-tooltip.visible { opacity: 1; } | |
| .minimap-viewport { | |
| position: absolute; | |
| left: 0; | |
| width: 100%; | |
| border: 1.5px solid var(--accent); | |
| border-radius: 3px; | |
| background: rgba(139, 124, 246, 0.08); | |
| pointer-events: none; | |
| transition: top 0.1s ease-out, height 0.1s ease-out; | |
| z-index: 1; | |
| } | |
| /* Chapter dependency / relationship indicators */ | |
| .chapter-dep-badge { | |
| font-size: 10px; | |
| font-weight: 500; | |
| padding: 1px 6px; | |
| border-radius: 4px; | |
| font-family: var(--mono); | |
| background: rgba(139, 124, 246, 0.1); | |
| color: var(--accent); | |
| cursor: pointer; | |
| transition: background 0.12s; | |
| } | |
| .chapter-dep-badge:hover { | |
| background: rgba(139, 124, 246, 0.2); | |
| } | |
| .chapter-dep-badge.dep-fix { | |
| background: rgba(74, 222, 128, 0.1); | |
| color: var(--green); | |
| } | |
| .chapter-dep-badge.dep-fix:hover { | |
| background: rgba(74, 222, 128, 0.18); | |
| } | |
| .chapter-dep-connector { | |
| position: absolute; | |
| left: 27px; | |
| width: 2px; | |
| background: rgba(139, 124, 246, 0.15); | |
| z-index: 0; | |
| pointer-events: none; | |
| } | |
| .chapter { position: relative; } | |
| .chapter-dep-section { | |
| padding: 8px 14px; | |
| margin-bottom: 8px; | |
| border-left: 2px solid rgba(139, 124, 246, 0.25); | |
| background: rgba(139, 124, 246, 0.03); | |
| border-radius: 0 6px 6px 0; | |
| font-size: 12px; | |
| color: var(--fg2); | |
| } | |
| .chapter-dep-section-title { | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| color: var(--fg3); | |
| margin-bottom: 4px; | |
| } | |
| .chapter-dep-link { | |
| display: inline-block; | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--accent); | |
| cursor: pointer; | |
| margin-right: 8px; | |
| padding: 1px 5px; | |
| border-radius: 3px; | |
| transition: background 0.12s; | |
| } | |
| .chapter-dep-link:hover { | |
| background: rgba(139, 124, 246, 0.12); | |
| } | |
| .chapter-dep-shared-files { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--fg3); | |
| margin-left: 4px; | |
| } | |
| @media (max-width: 768px) { | |
| #app { padding: 16px 12px; } | |
| .stats-bar { flex-wrap: wrap; } | |
| .stat { min-width: 33%; border-right: none; padding: 8px 12px; } | |
| .filter-bar { flex-direction: column; align-items: stretch; } | |
| .filter-search { min-width: 100%; } | |
| .tool-flow-summary { grid-template-columns: 1fr; } | |
| .tool-flow-cell { width: 4px; height: 14px; } | |
| .tool-perf-row { grid-template-columns: 10px 70px 40px 1fr 40px 40px; gap: 4px; padding: 4px 6px; } | |
| .tool-perf-name { font-size: 11px; } | |
| .tool-perf-retries { display: none; } | |
| .file-hotspot-ops { display: none; } | |
| .file-hotspot-chapters { display: none; } | |
| .session-summary { gap: 4px 12px; } | |
| .session-summary-sep { display: none; } | |
| .session-grade-letter { font-size: 22px; } | |
| .session-grade-breakdown { display: none; } | |
| .activity-timeline-legend { flex-wrap: wrap; gap: 8px; } | |
| .activity-timeline-times { font-size: 9px; } | |
| .chapter-tooltip { display: none; } | |
| .minimap { display: none; } | |
| .minimap-tooltip { display: none; } | |
| } | |
| /* Print / PDF-friendly stylesheet */ | |
| @media print { | |
| :root { | |
| --bg: #ffffff; | |
| --surface: #ffffff; | |
| --surface2: #f5f5f7; | |
| --fg: #111113; | |
| --fg2: #444449; | |
| --fg3: #6b6b73; | |
| --border: rgba(0, 0, 0, 0.1); | |
| --accent: #6d5cce; | |
| --green: #1a8a42; | |
| --green-dim: rgba(26, 138, 66, 0.08); | |
| --red: #c43838; | |
| --red-dim: rgba(196, 56, 56, 0.08); | |
| --orange: #b57820; | |
| --orange-dim: rgba(181, 120, 32, 0.08); | |
| } | |
| * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } | |
| body { | |
| background: #fff; | |
| color: #111; | |
| font-size: 11px; | |
| line-height: 1.5; | |
| } | |
| #app { | |
| max-width: 100%; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| @page { | |
| margin: 1.5cm 1.2cm; | |
| size: auto; | |
| } | |
| /* Remove interactive / decorative elements */ | |
| .filter-bar, | |
| .filter-chip, | |
| .filter-search, | |
| .filter-count, | |
| .expand-toggle, | |
| .chapter-tooltip, | |
| .chapter-permalink, | |
| .export-btn, | |
| .print-btn, | |
| .md-btn, | |
| .hf-btn, | |
| .hf-modal-overlay, | |
| .waveform-tooltip, | |
| .waveform-cursor, | |
| .cost-chart-tooltip, | |
| .activity-timeline-tooltip, | |
| .error-streak-badge, | |
| .error-dot, | |
| .minimap, | |
| .minimap-tooltip { display: none !important; } | |
| /* Remove hover/transition effects */ | |
| .chapter { transition: none; } | |
| .chapter:hover { background: var(--surface); } | |
| /* Expand all chapters — show all detail */ | |
| .chapter-detail { display: block !important; } | |
| .chapter-prompt-text { | |
| display: block !important; | |
| -webkit-line-clamp: unset !important; | |
| } | |
| .chapter.filter-hidden { display: block !important; } | |
| /* Remove max-height constraints on all content blocks */ | |
| .chapter-output, | |
| .chapter-diff-del, | |
| .chapter-diff-add, | |
| .chapter-agent-prompt, | |
| .chapter-thinking-block, | |
| .chapter-mcp-output { | |
| max-height: none !important; | |
| overflow: visible !important; | |
| } | |
| /* Keep canvas visualizations visible */ | |
| .waveform-canvas, | |
| .cost-chart-canvas, | |
| .activity-timeline-canvas { | |
| max-width: 100%; | |
| } | |
| /* Panel backgrounds — light borders instead of colored surfaces */ | |
| .header, | |
| .stats-bar, | |
| .session-summary, | |
| .activity-timeline, | |
| .error-summary, | |
| .waveform-wrap, | |
| .tool-flow, | |
| .tool-perf, | |
| .cost-chart, | |
| .file-hotspot, | |
| .git-timeline, | |
| .chapter { | |
| background: #fff; | |
| border: 1px solid #ddd; | |
| box-shadow: none; | |
| } | |
| /* Avoid page breaks inside chapters */ | |
| .chapter { | |
| break-inside: avoid; | |
| page-break-inside: avoid; | |
| } | |
| /* Also avoid breaks inside key panels */ | |
| .session-summary, | |
| .stats-bar, | |
| .git-timeline, | |
| .error-summary { | |
| break-inside: avoid; | |
| page-break-inside: avoid; | |
| } | |
| /* Reduce spacing for print density */ | |
| .chapters { gap: 4px; } | |
| .header { padding: 16px 20px; margin-bottom: 8px; } | |
| .stats-bar { margin-bottom: 8px; } | |
| .session-summary { margin-bottom: 8px; } | |
| /* Sensible font sizes */ | |
| .stat-value { font-size: 18px; } | |
| .stat-label { font-size: 10px; } | |
| .chapter-prompt { font-size: 12px; } | |
| .chapter-response { font-size: 11px; } | |
| .chapter-cmd { font-size: 11px; } | |
| .chapter-output { font-size: 10px; } | |
| .chapter-file { font-size: 11px; } | |
| /* Remove decorative colored left borders — use thin gray */ | |
| .error-summary { border-left-color: #aaa; } | |
| .git-timeline { border-left-color: #aaa; } | |
| .chapter-quality { border-left-color: #aaa; } | |
| .chapter-efficiency { border-left-color: #aaa; } | |
| .chapter-thinking { border-left-color: #aaa; } | |
| /* Tool performance — expand body for print */ | |
| .tool-perf-body { display: block !important; } | |
| .tool-perf-toggle { display: none; } | |
| .tool-perf-sort { display: none; } | |
| .tool-perf-row { cursor: default; } | |
| /* File hotspot — expand body for print */ | |
| .file-hotspot-body { display: block !important; } | |
| .file-hotspot-list { max-height: none !important; overflow: visible !important; } | |
| .file-hotspot-toggle { display: none; } | |
| /* Keyboard focus indicator — not relevant in print */ | |
| .chapter.kb-focused { border-left: none; background: #fff; } | |
| /* Links — show URL for context */ | |
| a[href] { color: var(--accent); text-decoration: underline; } | |
| /* Tool flow cells — slightly larger for print legibility */ | |
| .tool-flow-cell { width: 8px; height: 20px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"></div> | |
| <script> | |
| const SESSION = {"sessionId":"019efdd4-fc58-7a20-baf4-5ffed8e5f99d","source":"grok","cwd":"/home/everlier/code/tracequest","model":"grok-composer-2.5-fast","gitBranch":null,"startTime":"2026-06-25T08:11:04.224Z","endTime":"2026-06-25T08:11:15.505Z","durationMs":11281,"eventCount":29,"events":[{"type":"user","timestamp":"2026-06-25T08:11:04.224Z","text":"Explain me the tracequest share feature","uuid":"grok-user-0"},{"type":"tool_result","timestamp":"2026-06-25T08:11:06.504Z","toolUseId":"call-f796ee2c-05e8-49eb-8a48-d85c77abc194-composer_call_Y3P6k","text":"<workspace_result workspace_path=\"/home/everlier/code/tracequest\">\nsidecar.facts\n.facts\ntests/integration.md\nREADME.md\ntests/sharing-integration.md\ntests/index-search-integration.md\ntests/large-session-integration.md\ntests/edge-integration.md\ntests/opencode-integration.md\ntests/serve-integration.md\ntests/agent-history-integration.md\ntest/share/fetch-retry.test.js\ntests/multi-source-integration.md\ntest/share/pipeline.test.js\ntests/README.md\ntest/dead-code-sweep.test.js\ntest/share/share-bundle.test.js\ntest/share/scanner.test.js\ntest/render-css.test.js\ntest/route-handlers-api.test.js\ntest/share/adapters.test.js\ntest/render-chapters.test.js\ntest/share/cli-share.test.js\ntest/share/metadata.test.js\ntest/chapter-patterns.test.js\ntest/filter-expr.test.js\nsidecar/src/indexer.rs\ntest/index-writers.test.js\ntest/sidecar-incremental-invalidation-parity.test.js\ntest/render-ui-chapters.test.js\nbin/tracequest.js\nsidecar/src/indexer_tests.rs\ntest/sessions.test.js\ntest/server/server-helpers-stats.test.j","isError":false,"uuid":"grok-result-1"},{"type":"tool_result","timestamp":"2026-06-25T08:11:06.520Z","toolUseId":"call-f796ee2c-05e8-49eb-8a48-d85c77abc194-composer_call_wh5HK","text":"Result of search in '' (total 8 files):\n- ./src/share/share-bundle.js\n- ./test/share/share-bundle.test.js\n- ./test/browser/share-modal-hf.playwright.test.js\n- ./test/browser/share-modal.playwright.test.js\n- ./test/share/cli-share.test.js\n- ./src/render/render-share.js\n- ./test/browser/shared-css-tokens.test.js\n- ./src/browser/shared-css-tokens.js\n","isError":false,"uuid":"grok-result-2"},{"type":"tool_result","timestamp":"2026-06-25T08:11:06.537Z","toolUseId":"call-f796ee2c-05e8-49eb-8a48-d85c77abc194-composer_call_ADQJ9","text":"Exit code: 0\n\nCommand output:\n\n```\nk8o ✓ share > share > flow > the share pipeline is: parse session → scan for secrets → redact findings in a session copy → render HTML from the clean copy → upload; secrets never leave the local machine @share @spec @implemented\ncof ✓ share > share > flow > redaction replaces each matched secret in every string-bearing field of the redacted session copy with [REDACTED] — including event.text, toolCalls[].input, thinking blocks, diffInfo, mcpInfo, and agentHistory records that renderHTML embeds; the redacted session is a deep copy — the original session data is never mutated @share @spec @implemented\nb8p ✓ share > share > flow > the share command is a new CLI subcommand alongside render, list, latest, find, search, messages, serve @share @spec @implemented\nio7 ? share > share > flow > both GitHub Gist and Hugging Face Hub APIs support browser-origin requests via CORS (GitHub returns access-control-allow-origin: *; HF reflects the request Ori","isError":false,"uuid":"grok-result-3"},{"type":"assistant","timestamp":"2026-06-25T08:11:04.224Z","text":"I'll search the codebase and fact sheet for how the share feature is implemented and documented.\n","toolCalls":[{"id":"call-f796ee2c-05e8-49eb-8a48-d85c77abc194-composer_call_Y3P6k","name":"Grep","input":"share "},{"id":"call-f796ee2c-05e8-49eb-8a48-d85c77abc194-composer_call_ADQJ9","name":"Bash","input":"facts list --section \"share\" 2>/dev/null || facts list 2>/dev/null | rg -i share | head -40"},{"id":"call-f796ee2c-05e8-49eb-8a48-d85c77abc194-composer_call_wh5HK","name":"Glob","input":""}],"tokens":{"input":3934,"output":67,"cacheHit":0,"cacheWrite":0,"estimated":true},"stopReason":"tool_use","uuid":"grok-asst-4"},{"type":"tool_result","timestamp":"2026-06-25T08:11:08.506Z","toolUseId":"call-fe7c6a62-8d27-44e6-bc5a-c07ac26f8ebb-composer_call_by04n","text":"# Sharing Integration Tests\n\nTests the share pipeline (CLI and browser UI) defined in `.facts` under the `# share` section. The feature is currently spec'd as `@draft`; these tests verify whether the implementation meets the spec.\n\n## Integration test principles\n\n1. **Real workflows** — invoke `bin/tracequest.js` end-to-end (parse → scan → redact → render → upload), not isolated unit mocks of internal helpers.\n2. **Verifiable expectations** — each test states observable pass/fail criteria (exit codes, JSON fields, HTML script markers, mock-server request counts) before execution.\n3. **Isolated fixtures** — synthetic sessions under temp dirs; `HOME` override for discovery tests; mock Gist/HF servers via `GITHUB_API_URL` / `HF_API_URL` so uploads never hit production APIs.\n 10|4. **Stdout/stderr contract** — `--json` stdout must be parseable JSON only; progress/diagnostics (parsing, scanning, index build) belong on stderr.\n\nAutomated harness: `test/bin/tracequest-sharing-integration.t","isError":false,"uuid":"grok-result-5"},{"type":"tool_result","timestamp":"2026-06-25T08:11:08.519Z","toolUseId":"call-fe7c6a62-8d27-44e6-bc5a-c07ac26f8ebb-composer_call_MimB9","text":"Result of search in '' (total 10 files):\n- ./src/share/scanner.js\n- ./src/share/share-bundle.js\n- ./src/share/hf-adapter.js\n- ./src/share/gist-adapter.js\n- ./src/share/fetch-retry.js\n- ./src/share/pipeline.js\n- ./src/share/metadata.js\n- ./src/share/redactor.js\n- ./src/share/auth.js\n- ./src/share/secret-rules.js\n","isError":false,"uuid":"grok-result-6"},{"type":"tool_result","timestamp":"2026-06-25T08:11:08.530Z","toolUseId":"call-fe7c6a62-8d27-44e6-bc5a-c07ac26f8ebb-composer_call_N4b9D","text":"<workspace_result workspace_path=\"/home/everlier/code/tracequest\">\nsrc/cli/cli-die.js\nsrc/cli/cli-commands.js\n<\/workspace_result>","isError":false,"uuid":"grok-result-7"},{"type":"assistant","timestamp":"2026-06-25T08:11:04.224Z","text":"","toolCalls":[{"id":"call-fe7c6a62-8d27-44e6-bc5a-c07ac26f8ebb-composer_call_MimB9","name":"Glob","input":""},{"id":"call-fe7c6a62-8d27-44e6-bc5a-c07ac26f8ebb-composer_call_N4b9D","name":"Grep","input":"cmdShare|share "},{"id":"call-fe7c6a62-8d27-44e6-bc5a-c07ac26f8ebb-composer_call_by04n","name":"Read","input":"/home/everlier/code/tracequest/tests/sharing-integration.md"}],"tokens":{"input":3934,"output":37,"cacheHit":0,"cacheWrite":0,"estimated":true},"stopReason":"tool_use","uuid":"grok-asst-8"},{"type":"tool_result","timestamp":"2026-06-25T08:11:10.072Z","toolUseId":"call-fbfe1288-8eac-4735-a692-63022479fb27-composer_call_N4b9D","text":"<workspace_result workspace_path=\"/home/everlier/code/tracequest\">\nsrc/cli/cli-commands.js\n 487:export async function cmdShare(positionals, values) {\n 488- if (values.help) showShareHelp();\n 489-\n 490- const target = values.target || \"gist\";\n<\/workspace_result>","isError":false,"uuid":"grok-result-9"},{"type":"tool_result","timestamp":"2026-06-25T08:11:10.090Z","toolUseId":"call-fbfe1288-8eac-4735-a692-63022479fb27-composer_call_fyBcC","text":"import { readSync } from \"node:fs\";\nimport { parseSession } from \"../parse.js\";\nimport { renderHTML } from \"../render.js\";\nimport { loadSecretRules } from \"./secret-rules.js\";\nimport { scanSessionForSecrets } from \"./scanner.js\";\nimport { redactSession } from \"./redactor.js\";\nimport { buildShareMetadata, publicFindings } from \"./metadata.js\";\nimport { shareGist } from \"./gist-adapter.js\";\nimport { shareHf, buildHfSidecar } from \"./hf-adapter.js\";\n 10|import { resolveGhToken, resolveHfToken } from \"./auth.js\";\n\n/**\n * Run the share pipeline: scan → redact → render → upload.\n * Secrets never leave the machine in plaintext.\n */\nexport async function runSharePipeline({\n sessionPath,\n session: parsedSession,\n findings: preFindings = null,\n 20| discovery = null,\n target = \"gist\",\n isPrivate = false,\n hfRepo = null,\n token = null,\n fetchImpl = fetch,\n}) {\n const session = parsedSession || parseSession(sessionPath);\n const rules = loadSecretRules();\n const findings = preFindi","isError":false,"uuid":"grok-result-10"},{"type":"tool_result","timestamp":"2026-06-25T08:11:10.108Z","toolUseId":"call-fbfe1288-8eac-4735-a692-63022479fb27-composer_call_by04n","text":"import { buildSessionChapters } from \"../chapters/session-chapters.js\";\nimport { enrichChaptersForRender } from \"../chapters/chapter-render-enrich.js\";\nimport { chapterFileKeys } from \"../chapters/chapter-keys.js\";\nimport { FIRST_PROMPT_MAX_LEN, safeSlice, sumToolCounts } from \"../parse/parse-utils.js\";\nimport { formatDuration, estimateParsedStatsCost } from \"../filter/filter-formats.js\";\nimport { normalizeProjectFolder } from \"../server/server-session-path.js\";\nimport { buildIndex, peekSession } from \"../sessions.js\";\nimport { getSessionMeta } from \"../server/server-helpers.js\";\n\n 10|function aggregateChapterMetrics(chapters) {\n let totalErrors = 0;\n let totalRetries = 0;\n let totalSelfCorrections = 0;\n let cleanCount = 0;\n let correctedCount = 0;\n for (const ch of chapters) {\n totalErrors += ch.errors || 0;\n totalRetries += ch.retries || 0;\n totalSelfCorrections += ch.selfCorrections || 0;\n 20| if (ch.outcome === \"clean\") cleanCount++;\n else if (ch.outcome ","isError":false,"uuid":"grok-result-11"},{"type":"tool_result","timestamp":"2026-06-25T08:11:10.126Z","toolUseId":"call-fbfe1288-8eac-4735-a692-63022479fb27-composer_call_SXlHb","text":"<workspace_result workspace_path=\"/home/everlier/code/tracequest\">\nsrc/render/render-css.js\nsrc/render/render-share.js\nsrc/render/render-assemble.js\nsrc/render/render-core-shell.js\nsrc/render/render-session-css.js\nsrc/render/render-ui-detail.js\nsrc/render/render-session-rules.js\n<\/workspace_result>","isError":false,"uuid":"grok-result-12"},{"type":"assistant","timestamp":"2026-06-25T08:11:04.224Z","text":"","toolCalls":[{"id":"call-fbfe1288-8eac-4735-a692-63022479fb27-composer_call_fyBcC","name":"Read","input":"/home/everlier/code/tracequest/src/share/pipeline.js"},{"id":"call-fbfe1288-8eac-4735-a692-63022479fb27-composer_call_N4b9D","name":"Grep","input":"cmdShare|function.*share "},{"id":"call-fbfe1288-8eac-4735-a692-63022479fb27-composer_call_by04n","name":"Read","input":"/home/everlier/code/tracequest/src/share/metadata.js"},{"id":"call-fbfe1288-8eac-4735-a692-63022479fb27-composer_call_SXlHb","name":"Grep","input":"share|Share "}],"tokens":{"input":3934,"output":59,"cacheHit":0,"cacheWrite":0,"estimated":true},"stopReason":"tool_use","uuid":"grok-asst-13"},{"type":"tool_result","timestamp":"2026-06-25T08:11:11.980Z","toolUseId":"call-9d8e02dd-3977-4777-80bb-9cf5366ef309-composer_call_2f3Y7","text":"import { shareFetch } from \"./fetch-retry.js\";\n\nconst DEFAULT_HF_API_URL = \"https://huggingface.co\";\n/** HF inline commit size limit; larger files use preupload. */\nexport const HF_PREUPLOAD_THRESHOLD = 1_048_576;\n\nexport function hfApiUrl() {\n return (process.env.HF_API_URL || DEFAULT_HF_API_URL).replace(/\\/$/, \"\");\n}\n 10|\nconst README_CARD = `---\nlicense: mit\ntags:\n- agent-traces\n- tracequest\ntask_categories:\n- text-generation\n---\n\n 20|# tracequest sessions\n\nAgent session traces shared via [tracequest](https://github.com/av/tracequest).\n`;\n\n/** Sanitize a path segment (alphanumeric, hyphen, underscore only). */\nexport function sanitizePathSegment(seg) {\n const s = String(seg || \"unknown\").replace(/[^a-zA-Z0-9_-]/g, \"-\");\n return s || \"unknown\";\n}\n 30|\nfunction parseRepo(repo) {\n const parts = String(repo || \"\").split(\"/\").filter(Boolean);\n if (parts.length !== 2) throw new Error(\"HF repo must be namespace/repo (e.g. user/tracequest-sessions)\");\n return { namespace: par","isError":false,"uuid":"grok-result-14"},{"type":"tool_result","timestamp":"2026-06-25T08:11:11.997Z","toolUseId":"call-9d8e02dd-3977-4777-80bb-9cf5366ef309-composer_call_by04n","text":"import { sanitizePathSegment } from \"./hf-adapter.js\";\nimport { shareFetch } from \"./fetch-retry.js\";\n\nconst DEFAULT_GITHUB_API_URL = \"https://api.github.com\";\nconst GISTHOST_BASE = \"https://gisthost.github.io/?\";\n\nexport function gistApiUrl() {\n return (process.env.GITHUB_API_URL || DEFAULT_GITHUB_API_URL).replace(/\\/$/, \"\");\n}\n 10|\n/** Build a gisthost preview URL for a gist id and optional filename. */\nexport function buildGistHostPreviewUrl(gistId, filename) {\n if (!gistId) return null;\n const base = `${GISTHOST_BASE}${gistId}`;\n if (filename && filename !== \"index.html\") return `${base}/${filename}`;\n return base;\n}\n\n/**\n 20| * Upload rendered HTML to a GitHub Gist.\n * @returns {Promise<{url: string, id: string, previewUrl: string, filename: string}>}\n * `url` — raw gist.github.com HTML page (not gisthost preview)\n * `previewUrl` — gisthost rendered preview URL (primary share link)\n * `filename` — uploaded gist file name\n */\nexport async function shareGist({ html,","isError":false,"uuid":"grok-result-15"},{"type":"tool_result","timestamp":"2026-06-25T08:11:12.014Z","toolUseId":"call-9d8e02dd-3977-4777-80bb-9cf5366ef309-composer_call_fyBcC","text":"... 486 lines not shown ...\nexport async function cmdShare(positionals, values) {\n if (values.help) showShareHelp();\n\n 490| const target = values.target || \"gist\";\n if (target !== \"gist\" && target !== \"hf\") {\n die(`Invalid --target: ${target}\\nUse gist or hf.`);\n }\n\n const { path: sessionPath, discovery } = resolveShareSession(positionals, values);\n\n console.error(k.dim(\"Parsing session...\"));\n let session;\n try {\n 500| session = parseSession(sessionPath);\n } catch (err) {\n die(`Failed to parse session ${sessionPath}: ${err.message}`);\n }\n\n const sidecars = discoverAgentSidecarPaths(sessionPath);\n if (sidecars.length) {\n const agentHistory = loadAgentHistoryForSession(sessionPath, sidecars);\n if (agentHistory) {\n session.agentHistory = agentHistory;\n 510| session.agentSidecarCount = sidecars.length;\n }\n }\n\n const rules = loadSecretRules();\n const findings = scanSessionForSecrets(session, rules);\n if (findings.length) {\n console.error","isError":false,"uuid":"grok-result-16"},{"type":"tool_result","timestamp":"2026-06-25T08:11:12.028Z","toolUseId":"call-9d8e02dd-3977-4777-80bb-9cf5366ef309-composer_call_SBQJo","text":"import { secretRulesJson } from \"../share/secret-rules.js\";\nimport { buildScannerRedactorSrc } from \"../share/share-bundle.js\";\n\nconst SCANNER_REDACTOR_SRC = buildScannerRedactorSrc();\n\nexport const SHARE_JS = `\n const SECRET_RULES = ${secretRulesJson()};\n\n ${SCANNER_REDACTOR_SRC}\n 10|\n const GH_LS_KEY = 'tracequest-gh-token';\n const HF_LS_KEY = 'tracequest-hf-token';\n const HF_REPO_LS_KEY = 'tracequest-hf-repo';\n const HF_PREUPLOAD_THRESHOLD = 1048576;\n\n function sanitizePathSegment(seg) {\n var s = String(seg || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '-');\n return s || 'unknown';\n }\n 20|\n function escHtml(s) {\n return String(s).replace(/&/g, '&').replace(/<\/g, '<').replace(/>/g, '>').replace(/\"/g, '"');\n }\n\n function isAllowedApexHost(hostname, apex) {\n if (hostname === apex) return true;\n var suffix = '.' + apex;\n if (hostname.slice(-suffix.length) !== suffix) return false;\n var sub = hostname.slice(0, -suffix.length);\n 30| re","isError":false,"uuid":"grok-result-17"},{"type":"assistant","timestamp":"2026-06-25T08:11:04.224Z","text":"","toolCalls":[{"id":"call-9d8e02dd-3977-4777-80bb-9cf5366ef309-composer_call_fyBcC","name":"Read","input":"/home/everlier/code/tracequest/src/cli/cli-commands.js"},{"id":"call-9d8e02dd-3977-4777-80bb-9cf5366ef309-composer_call_SBQJo","name":"Read","input":"/home/everlier/code/tracequest/src/render/render-share.js"},{"id":"call-9d8e02dd-3977-4777-80bb-9cf5366ef309-composer_call_by04n","name":"Read","input":"/home/everlier/code/tracequest/src/share/gist-adapter.js"},{"id":"call-9d8e02dd-3977-4777-80bb-9cf5366ef309-composer_call_2f3Y7","name":"Read","input":"/home/everlier/code/tracequest/src/share/hf-adapter.js"}],"tokens":{"input":3934,"output":79,"cacheHit":0,"cacheWrite":0,"estimated":true},"stopReason":"tool_use","uuid":"grok-asst-18"},{"type":"tool_result","timestamp":"2026-06-25T08:11:13.677Z","toolUseId":"call-9c03b1ab-b92a-4d23-8314-0384c2941364-composer_call_SBQJo","text":"const PEM_BLOCK_RE = /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g;\n\nexport function redactString(str, secrets) {\n if (!str || typeof str !== \"string\") return str;\n let out = str;\n for (const secret of secrets) {\n if (!secret || !out.includes(secret)) continue;\n out = out.split(secret).join(\"[REDACTED]\");\n }\n 10| return out;\n}\n\nexport function redactPemBlocks(str) {\n if (!str || typeof str !== \"string\") return str;\n return str.replace(PEM_BLOCK_RE, \"[REDACTED]\");\n}\n\nexport function redactValue(val, secrets) {\n if (typeof val === \"string\") return redactPemBlocks(redactString(val, secrets));\n 20| if (Array.isArray(val)) return val.map((item) => redactValue(item, secrets));\n if (val && typeof val === \"object\") {\n for (const key of Object.keys(val)) {\n val[key] = redactValue(val[key], secrets);\n }\n return val;\n }\n return val;\n}\n\n 30|/**\n * Deep-copy a session and replace matched secrets w","isError":false,"uuid":"grok-result-19"},{"type":"tool_result","timestamp":"2026-06-25T08:11:13.697Z","toolUseId":"call-9c03b1ab-b92a-4d23-8314-0384c2941364-composer_call_by04n","text":"import { readFileSync, existsSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { execFileSync } from \"node:child_process\";\n\n/** Resolve GitHub token: GITHUB_TOKEN → gh auth token → error. */\nexport function resolveGhToken() {\n if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN.trim();\n try {\n 10| return execFileSync(\"gh\", [\"auth\", \"token\"], { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] }).trim();\n } catch {\n return null;\n }\n}\n\n/** Resolve Hugging Face token: HF_TOKEN → ~/.cache/huggingface/token → error. */\nexport function resolveHfToken() {\n if (process.env.HF_TOKEN) return process.env.HF_TOKEN.trim();\n const cachePath = join(homedir(), \".cache\", \"huggingface\", \"token\");\n 20| if (existsSync(cachePath)) {\n try {\n return readFileSync(cachePath, \"utf8\").trim();\n } catch {\n return null;\n }\n }\n return null;\n}\n","isError":false,"uuid":"grok-result-20"},{"type":"tool_result","timestamp":"2026-06-25T08:11:13.716Z","toolUseId":"call-9c03b1ab-b92a-4d23-8314-0384c2941364-composer_call_fyBcC","text":"/** Shannon entropy for gitleaks-style rule checks. */\nexport function shannonEntropy(str) {\n if (!str || str.length === 0) return 0;\n const freq = new Map();\n for (const c of str) freq.set(c, (freq.get(c) || 0) + 1);\n let entropy = 0;\n const len = str.length;\n for (const count of freq.values()) {\n const p = count / len;\n 10| entropy -= p * Math.log2(p);\n }\n return entropy;\n}\n\n/** Redact a matched secret for display: first 4 + last 4 chars. */\nexport function redactMatchDisplay(match) {\n if (!match) return \"****\";\n if (match.length <= 8) return \"****\";\n return match.slice(0, 4) + \"…\" + match.slice(-4);\n 20|}\n\n/** Map gitleaks PCRE inline flag letters to JavaScript RegExp flags. */\nconst PCRE_INLINE_FLAG_MAP = {\n i: \"i\",\n m: \"m\",\n s: \"s\",\n u: \"u\",\n};\n\n 30|/** Normalize gitleaks PCRE inline flags (e.g. `(?i)`, `(?is)`) for JavaScript RegExp. */\nexport function normalizeRuleRegex(pattern, flags = \"g\") {\n let source = String(pattern);\n let outFlags = flags.i","isError":false,"uuid":"grok-result-21"},{"type":"tool_result","timestamp":"2026-06-25T08:11:13.733Z","toolUseId":"call-9c03b1ab-b92a-4d23-8314-0384c2941364-composer_call_SXlHb","text":"<workspace_result workspace_path=\"/home/everlier/code/tracequest\">\nsrc/render/render-core-shell.js\n 112-\n 113: const shareBtn = h('button', { className: 'hf-btn', title: 'Share session to Gist or Hugging Face' });\n 114- shareBtn.appendChild(shareIcon);\n 115: shareBtn.appendChild(document.createTextNode('Share'));\n 116- shareBtn.addEventListener('click', function(ev) {\n 117- ev.stopPropagation();\n 118: if (typeof openShareModal === 'function') openShareModal();\n 119- });\n 120-\n<\/workspace_result>","isError":false,"uuid":"grok-result-22"},{"type":"assistant","timestamp":"2026-06-25T08:11:04.224Z","text":"","toolCalls":[{"id":"call-9c03b1ab-b92a-4d23-8314-0384c2941364-composer_call_fyBcC","name":"Read","input":"/home/everlier/code/tracequest/src/share/scanner.js"},{"id":"call-9c03b1ab-b92a-4d23-8314-0384c2941364-composer_call_SBQJo","name":"Read","input":"/home/everlier/code/tracequest/src/share/redactor.js"},{"id":"call-9c03b1ab-b92a-4d23-8314-0384c2941364-composer_call_by04n","name":"Read","input":"/home/everlier/code/tracequest/src/share/auth.js"},{"id":"call-9c03b1ab-b92a-4d23-8314-0384c2941364-composer_call_SXlHb","name":"Grep","input":"Share|share-modal|hf-btn "}],"tokens":{"input":3934,"output":68,"cacheHit":0,"cacheWrite":0,"estimated":true},"stopReason":"tool_use","uuid":"grok-asst-23"},{"type":"tool_result","timestamp":"2026-06-25T08:11:15.472Z","toolUseId":"call-988fe07c-aad0-4c9a-857f-abd5336cca4a-composer_call_SBQJo","text":"# tracequest\n\n[](https://github.com/av/tracequest/actions/workflows/ci.yml)\n\nStop reading agent logs. Watch them.\n\n`tracequest` is a zero-dependency Node.js CLI that transforms agent session logs into self-contained, interactive HTML visualizations. It discovers sessions across multiple AI coding agents, renders detailed per-session views (with waveforms, chapters, tool usage, and stats), and provides a live web browser for exploring and searching your entire history.\n\n## Installation\n 10|\nInstall globally so the `tracequest` command is available everywhere:\n\n```bash\nnpm install -g tracequest\n```\n\nRun via npx without a permanent install:\n\n```bash\n 20|npx tracequest --help\n```\n\nAfter installation, run `tracequest --help` to see the built-in usage summary.\n\n## CLI Usage\n\ntracequest uses subcommands. Run `tracequest --help` for the built-in reference.\n\n```\n 30|tracequest — stop reading agent logs. watch the","isError":false,"uuid":"grok-result-24"},{"type":"tool_result","timestamp":"2026-06-25T08:11:15.490Z","toolUseId":"call-988fe07c-aad0-4c9a-857f-abd5336cca4a-composer_call_Y3P6k","text":"<workspace_result workspace_path=\"/home/everlier/code/tracequest\">\nsrc/cli/cli-commands.js\n 448:export function showShareHelp() {\n 449- console.log(`${k.bold(\"tracequest share\")} — share a session to Gist or Hugging Face\n 450-\n 451-${k.bold(\"Usage:\")}\n 452- tracequest share <session.jsonl> [options]\n 453- tracequest share --latest [project] [options]\n 454- tracequest share --expr <expression> [options]\n 455-\n 456-${SHARE_OPTIONS_HELP}\n 457-\n 458-${k.bold(\"Examples:\")}\n 459- tracequest share session.jsonl --target gist --open\n 460- tracequest share session.jsonl --target hf --hf-repo user/tracequest-sessions --json\n 461- tracequest share --latest myproject --target gist\n 462- tracequest share --expr \"model:sonnet\" --force\n 463-\n 464-${k.dim(\"Secrets are scanned locally; matched values are redacted to [REDACTED] before upload.\")}`);\n 465- process.exit(0);\n 466-}\n 467-\n 468:function resolveShareSession(positionals, values) {\n 469- if (values.latest || value","isError":false,"uuid":"grok-result-25"},{"type":"tool_result","timestamp":"2026-06-25T08:11:15.505Z","toolUseId":"call-988fe07c-aad0-4c9a-857f-abd5336cca4a-composer_call_ghtyx","text":"<workspace_result workspace_path=\"/home/everlier/code/tracequest\">\nREADME.md\n 96-- **Droid/Factory** — `~/.factory/sessions/`\n 97:- **OpenCode** — `~/.local/share/opencode/opencode.db`\n 98-- **Grok CLI** — `~/.grok/sessions/`\n 99-\n 100-Use `tracequest list` or `tracequest serve` (or the library `findSessions()`) to aggregate across every supported Source. Source is shown with color-coded badges in the UI and HTML (claude=purple, codex=teal, factory=yellow, opencode=blue, grok=red).\n 101-\n 102-## Next Steps\n\ntests/README.md\n 36-| Core CLI | [`integration.md`](integration.md) | 15 | help, version, render, messages, list, find/search/latest expr, serve smoke |\n 37:| Sharing | [`sharing-integration.md`](sharing-integration.md) | 19 | `share` CLI, bundle output, HF upload mocks, secret scan/redaction, full rule compilation audit |\n 38-| Serve HTTP | [`serve-integration.md`](serve-integration.md) | 42 | `/api/sessions`, `/api/search`, `serve --filter`, `?expr=`, `/view`, `/export`,","isError":false,"uuid":"grok-result-26"},{"type":"assistant","timestamp":"2026-06-25T08:11:04.224Z","text":"","toolCalls":[{"id":"call-988fe07c-aad0-4c9a-857f-abd5336cca4a-composer_call_Y3P6k","name":"Grep","input":"resolveShareSession|showShareHelp "},{"id":"call-988fe07c-aad0-4c9a-857f-abd5336cca4a-composer_call_SBQJo","name":"Read","input":"/home/everlier/code/tracequest/README.md"},{"id":"call-988fe07c-aad0-4c9a-857f-abd5336cca4a-composer_call_ghtyx","name":"Grep","input":"share "}],"tokens":{"input":3934,"output":38,"cacheHit":0,"cacheWrite":0,"estimated":true},"stopReason":"tool_use","uuid":"grok-asst-27"},{"type":"assistant","timestamp":"2026-06-25T08:11:04.224Z","text":"The **share** feature lets you publish an agent session as a self-contained HTML page to the web — after scanning and redacting secrets locally. Raw session data never leaves your machine in plaintext.\n\n## What it does\n\nTracequest already renders sessions as interactive, standalone HTML. Share takes that one step further: it runs a safety pipeline, then uploads the cleaned HTML to either **GitHub Gist** (default) or **Hugging Face Hub**. The result is a public URL anyone can open in a browser — ","toolCalls":[],"tokens":{"input":3934,"output":125,"cacheHit":0,"cacheWrite":0,"estimated":true},"stopReason":"end_turn","uuid":"grok-asst-28"}],"stats":{"userMessages":1,"assistantTurns":7,"totalInputTokens":27538,"totalOutputTokens":473,"totalCacheHit":0,"errors":0,"toolCounts":{"Grep":7,"Bash":1,"Glob":2,"Read":11},"tokensEstimated":true}}; | |
| (function() { | |
| var LOWER_NATIVE_THRESHOLD = 4096; | |
| function needsLocaleFold(s) { | |
| for (let i = 0; i < s.length; i++) { | |
| if (s.charCodeAt(i) > 0x7f) return true; | |
| } | |
| return false; | |
| } | |
| function indexOfLower(haystack, needleLower, fromIndex = 0) { | |
| if (!needleLower) { | |
| const hlen = haystack?.length ?? 0; | |
| return fromIndex >= 0 && fromIndex <= hlen ? fromIndex : -1; | |
| } | |
| if (!haystack) return -1; | |
| if ( | |
| haystack.length > LOWER_NATIVE_THRESHOLD || | |
| needsLocaleFold(haystack) || | |
| needsLocaleFold(needleLower) | |
| ) { | |
| return haystack.toLowerCase().indexOf(needleLower, fromIndex); | |
| } | |
| const hlen = haystack.length; | |
| const nlen = needleLower.length; | |
| const start = fromIndex < 0 ? 0 : fromIndex; | |
| if (!nlen) return start < hlen ? start : hlen; | |
| if (start + nlen > hlen) return -1; | |
| outer: for (let i = start; i <= hlen - nlen; i++) { | |
| for (let j = 0; j < nlen; j++) { | |
| let hc = haystack.charCodeAt(i + j); | |
| if (hc >= 0x41 && hc <= 0x5a) hc += 0x20; | |
| if (hc !== needleLower.charCodeAt(j)) continue outer; | |
| } | |
| return i; | |
| } | |
| return -1; | |
| } | |
| function includesLower(haystack, needleLower) { | |
| return indexOfLower(haystack, needleLower) !== -1; | |
| } | |
| function getModelRates(model) { | |
| const isOpus = includesLower(model || '', 'opus'); | |
| return { | |
| inRate: isOpus ? 15 : 3, | |
| outRate: isOpus ? 75 : 15, | |
| cacheReadRate: isOpus ? 1.5 : 0.3, | |
| }; | |
| } | |
| function estimateParsedStatsCost(model, stats) { | |
| if (!stats) return 0; | |
| return estimateChapterTokenCost(model, { | |
| input: stats.totalInputTokens || 0, | |
| cacheHit: stats.totalCacheHit || 0, | |
| output: stats.totalOutputTokens || 0, | |
| }); | |
| } | |
| function estimateChapterTokenCost(model, tokens) { | |
| if (!tokens) return 0; | |
| const { inRate, outRate, cacheReadRate } = getModelRates(model); | |
| const input = tokens.input || 0; | |
| const cacheHit = tokens.cacheHit || 0; | |
| const output = tokens.output || 0; | |
| const uncachedInput = Math.max(0, input - cacheHit); | |
| return (uncachedInput * inRate + cacheHit * cacheReadRate + output * outRate) / 1e6; | |
| } | |
| function fmtTokens(n, opts) { | |
| const zeroLabel = opts && opts.zeroLabel !== undefined ? opts.zeroLabel : ''; | |
| if (!n) return zeroLabel; | |
| if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; | |
| if (n >= 1e3) return (n / 1e3).toFixed(0) + 'K'; | |
| return String(n); | |
| } | |
| function fmtCost(c, opts = {}) { | |
| const zeroLabel = opts.zeroLabel !== undefined ? opts.zeroLabel : ""; | |
| const min = opts.min !== undefined ? opts.min : 0.01; | |
| const prefix = opts.prefix !== undefined ? opts.prefix : "~$"; | |
| const tinyMin = opts.tinyMin !== undefined ? opts.tinyMin : 0.001; | |
| const tinyLabel = opts.tinyLabel; | |
| if (c == null || Number.isNaN(c)) return zeroLabel; | |
| if (c < min) { | |
| if (tinyLabel && c >= tinyMin) return tinyLabel; | |
| return zeroLabel; | |
| } | |
| const dec = | |
| opts.decimals !== undefined | |
| ? opts.decimals | |
| : opts.fine && c < 0.01 | |
| ? 4 | |
| : c >= 1 | |
| ? 2 | |
| : 3; | |
| return prefix + Number(c).toFixed(dec); | |
| } | |
| function fmtPct(n, opts = {}) { | |
| const zeroLabel = opts.zeroLabel !== undefined ? opts.zeroLabel : "--"; | |
| const decimals = opts.decimals !== undefined ? opts.decimals : 0; | |
| if (n == null || Number.isNaN(n)) return zeroLabel; | |
| if (opts.hideZero && n <= 0) return zeroLabel; | |
| return Number(n).toFixed(decimals) + "%"; | |
| } | |
| function formatDuration(ms, opts = {}) { | |
| const { | |
| floorSeconds = false, | |
| includeSeconds = false, | |
| alwaysShowMinutes = false, | |
| subSecondLabel = '', | |
| zeroLabel, | |
| } = opts; | |
| const emptyLabel = zeroLabel !== undefined ? zeroLabel : subSecondLabel; | |
| if (!ms || ms <= 0) return emptyLabel; | |
| const s = floorSeconds ? Math.floor(ms / 1000) : Math.round(ms / 1000); | |
| if (s < 60) { | |
| if (s === 0) return subSecondLabel || '0s'; | |
| return s + 's'; | |
| } | |
| const m = Math.floor(s / 60); | |
| if (m < 60) { | |
| return m + 'm' + (includeSeconds && s % 60 > 0 ? ' ' + (s % 60) + 's' : ''); | |
| } | |
| const h = Math.floor(m / 60); | |
| const rm = m % 60; | |
| if (rm === 0 && !alwaysShowMinutes) return h + 'h'; | |
| return h + 'h ' + rm + 'm'; | |
| } | |
| function fmtMcpName(name) { | |
| if (typeof name !== "string" || !name.startsWith("mcp__")) return name; | |
| const rest = name.slice(5); | |
| const idx = rest.indexOf("__"); | |
| if (idx < 0) return rest; | |
| return rest.slice(0, idx).replace(/_/g, " ") + ": " + rest.slice(idx + 2).replace(/_/g, " "); | |
| } | |
| const app = document.getElementById('app'); | |
| const session = SESSION; | |
| const events = session.events; | |
| let expandedSet = new Set(); | |
| function jumpToChapter(idx) { | |
| var target = document.getElementById('chapter-' + idx); | |
| if (!target) return; | |
| target.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| target.classList.remove('highlight'); | |
| void target.offsetWidth; | |
| target.classList.add('highlight'); | |
| } | |
| function setupHiDpiCanvas(canvas, logicalWidth, logicalHeight) { | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = logicalWidth * dpr; | |
| canvas.height = logicalHeight * dpr; | |
| canvas.style.height = logicalHeight + 'px'; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.setTransform(1, 0, 0, 1, 0, 0); | |
| ctx.scale(dpr, dpr); | |
| return ctx; | |
| } | |
| function formatTime(iso) { | |
| return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); | |
| } | |
| function h(tag, attrs, ...children) { | |
| const el = document.createElement(tag); | |
| if (attrs) { | |
| for (const k in attrs) { | |
| if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue; | |
| const v = attrs[k]; | |
| if (k === 'className') el.className = v; | |
| else if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); | |
| else el.setAttribute(k, v); | |
| } | |
| } | |
| children.flat().forEach(c => { | |
| if (c == null) return; | |
| el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); | |
| }); | |
| return el; | |
| } | |
| function esc(s) { | |
| return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| } | |
| function sumToolCounts(counts = {}) { | |
| let n = 0; | |
| for (const k in counts) { | |
| if (Object.prototype.hasOwnProperty.call(counts, k)) n += counts[k]; | |
| } | |
| return n; | |
| } | |
| function readUrlState() { | |
| var params = new URLSearchParams(window.location.search); | |
| var hash = window.location.hash; | |
| var toolParam = params.get('tool'); | |
| if (toolParam) { | |
| toolParam.split(',').forEach(function(t) { if (t.trim()) activeToolFilters.add(t.trim()); }); | |
| } | |
| var qParam = params.get('q'); | |
| if (qParam) searchQuery = qParam; | |
| if (hash && hash.match(/^#chapter-(\d+)$/)) { | |
| var chIdx = parseInt(hash.slice(9), 10); | |
| if (!isNaN(chIdx) && chIdx >= 0) { | |
| expandedSet.add('ch' + chIdx); | |
| requestAnimationFrame(function() { | |
| requestAnimationFrame(function() { | |
| var el = document.getElementById('chapter-' + chIdx); | |
| if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| }); | |
| }); | |
| } | |
| } | |
| } | |
| function buildFilterSearchParams() { | |
| var params = new URLSearchParams(window.location.search); | |
| var base = new URLSearchParams(); | |
| if (params.get('path')) base.set('path', params.get('path')); | |
| if (params.get('source')) base.set('source', params.get('source')); | |
| if (activeToolFilters.size > 0) base.set('tool', Array.from(activeToolFilters).join(',')); | |
| if (searchQuery) base.set('q', searchQuery); | |
| return base; | |
| } | |
| function updateUrl() { | |
| var base = buildFilterSearchParams(); | |
| var hash = window.location.hash || ''; | |
| var url = window.location.pathname + '?' + base.toString() + hash; | |
| history.replaceState(null, '', url); | |
| } | |
| function setChapterHash(idx) { | |
| var base = buildFilterSearchParams(); | |
| var url = window.location.pathname + '?' + base.toString() + '#chapter-' + idx; | |
| history.replaceState(null, '', url); | |
| } | |
| function clearChapterHash() { | |
| var base = buildFilterSearchParams(); | |
| var url = window.location.pathname + '?' + base.toString(); | |
| history.replaceState(null, '', url); | |
| } | |
| function getPermalink(chapterIdx) { | |
| var base = new URL(window.location.pathname, window.location.origin); | |
| var params = buildFilterSearchParams(); | |
| base.search = params.toString() ? '?' + params.toString() : ''; | |
| base.hash = 'chapter-' + chapterIdx; | |
| return base.toString(); | |
| } | |
| function renderHeader() { | |
| const s = session.stats; | |
| const sourceColors = { claude: '#a78bfa', codex: '#59d4a0', factory: '#e0c45e', opencode: '#6ba4e8', grok: '#f07070' }; | |
| const srcColor = sourceColors[session.source] || '#888'; | |
| const srcBadge = session.source && session.source !== 'claude' | |
| ? h('span', { style: 'background:' + srcColor + ';color:#111;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:2px 7px;border-radius:4px;margin-left:8px;' }, session.source) | |
| : null; | |
| const params = new URLSearchParams(window.location.search); | |
| const exportUrl = '/export?' + params.toString(); | |
| const downloadIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| downloadIcon.setAttribute('viewBox', '0 0 24 24'); | |
| downloadIcon.innerHTML = '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>'; | |
| const exportBtn = h('a', { className: 'export-btn', href: exportUrl, title: 'Download as standalone HTML file' }); | |
| exportBtn.appendChild(downloadIcon); | |
| exportBtn.appendChild(document.createTextNode('Export')); | |
| const printIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| printIcon.setAttribute('viewBox', '0 0 24 24'); | |
| printIcon.innerHTML = '<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>'; | |
| const printBtn = h('button', { className: 'print-btn', title: 'Print or save as PDF' }); | |
| printBtn.appendChild(printIcon); | |
| printBtn.appendChild(document.createTextNode('Print')); | |
| printBtn.addEventListener('click', function(ev) { | |
| ev.stopPropagation(); | |
| expandAllChapterDetails(); | |
| window.print(); | |
| }); | |
| const mdUrl = '/markdown?' + params.toString(); | |
| const mdIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| mdIcon.setAttribute('viewBox', '0 0 24 24'); | |
| mdIcon.innerHTML = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>'; | |
| const mdBtn = h('a', { className: 'md-btn', href: mdUrl, title: 'Download as markdown document' }); | |
| mdBtn.appendChild(mdIcon); | |
| mdBtn.appendChild(document.createTextNode('Markdown')); | |
| const shareIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| shareIcon.setAttribute('viewBox', '0 0 24 24'); | |
| shareIcon.innerHTML = '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>'; | |
| const shareBtn = h('button', { className: 'hf-btn', title: 'Share session to Gist or Hugging Face' }); | |
| shareBtn.appendChild(shareIcon); | |
| shareBtn.appendChild(document.createTextNode('Share')); | |
| shareBtn.addEventListener('click', function(ev) { | |
| ev.stopPropagation(); | |
| if (typeof openShareModal === 'function') openShareModal(); | |
| }); | |
| return h('div', { className: 'header' }, | |
| h('div', { className: 'header-top' }, | |
| h('div', { className: 'header-title' }, | |
| 'tracequest', | |
| h('span', null, session.sessionId?.slice(0, 8) || 'session'), | |
| srcBadge | |
| ), | |
| h('div', { className: 'header-actions' }, printBtn, mdBtn, exportBtn, shareBtn) | |
| ), | |
| h('div', { className: 'meta-grid' }, | |
| metaItem('model', session.model || '—'), | |
| metaItem('cwd', session.cwd || '—'), | |
| metaItem('branch', session.gitBranch || '—'), | |
| metaItem('duration', session.durationMs > 0 ? formatDuration(session.durationMs) : '—'), | |
| metaItem('started', session.startTime && session.eventCount > 0 ? new Date(session.startTime).toLocaleString() : '—'), | |
| ) | |
| ); | |
| } | |
| function metaItem(label, value) { | |
| return h('div', { className: 'meta-item' }, | |
| h('span', { className: 'meta-label' }, label), | |
| h('span', { className: 'meta-value' }, value) | |
| ); | |
| } | |
| function renderStats() { | |
| const s = session.stats; | |
| const hasTokens = s.totalInputTokens > 0 || s.totalOutputTokens > 0; | |
| const est = s.tokensEstimated ? '~' : ''; | |
| const bar = h('div', { className: 'stats-bar' }, | |
| stat(s.userMessages, 'prompts', ''), | |
| stat(s.assistantTurns, 'turns', ''), | |
| stat(sumToolCounts(s.toolCounts), 'tool calls', ''), | |
| stat(hasTokens ? est + fmtTokens(s.totalInputTokens) : '—', 'input tok', ''), | |
| stat(hasTokens ? est + fmtTokens(s.totalOutputTokens) : '—', 'output tok', ''), | |
| stat(s.totalCacheHit && s.totalInputTokens ? ((s.totalCacheHit / s.totalInputTokens * 100) | 0) + '%' : '—', 'cache hit', 'green'), | |
| stat(s.errors, 'errors', s.errors > 0 ? 'red' : ''), | |
| ); | |
| if (s.tokensEstimated) { | |
| bar.appendChild(h('div', { className: 'stats-note' }, '~ token counts estimated from content length')); | |
| } | |
| return bar; | |
| } | |
| function stat(value, label, cls) { | |
| return h('div', { className: 'stat' }, | |
| h('div', { className: 'stat-value' + (cls ? ' ' + cls : '') }, String(value)), | |
| h('div', { className: 'stat-label' }, label), | |
| ); | |
| } | |
| function aggregateChapterMetrics(chapters) { | |
| var cleanCount = 0; | |
| var correctedCount = 0; | |
| var strugglingCount = 0; | |
| var totalErrors = 0; | |
| var totalRetries = 0; | |
| var totalSelfCorrections = 0; | |
| var totalCommits = 0; | |
| var wastefulCount = 0; | |
| var totalWasteTokens = 0; | |
| var allFiles = new Set(); | |
| for (var i = 0; i < chapters.length; i++) { | |
| var ch = chapters[i]; | |
| var outcome = ch.outcome; | |
| if (outcome === 'clean') cleanCount++; | |
| else if (outcome === 'corrected') correctedCount++; | |
| else if (outcome === 'struggling') strugglingCount++; | |
| totalErrors += ch.errors; | |
| totalRetries += ch.retries || 0; | |
| totalSelfCorrections += ch.selfCorrections || 0; | |
| var gitOps = ch.gitOps; | |
| for (var j = 0; j < gitOps.length; j++) { | |
| if (gitOps[j].type === 'commit') totalCommits++; | |
| } | |
| var fileKeys = chapterFileKeys(ch); | |
| for (var k = 0; k < fileKeys.length; k++) allFiles.add(fileKeys[k]); | |
| var eff = ch.efficiency; | |
| if (eff && eff.isWasteful) { | |
| wastefulCount++; | |
| totalWasteTokens += eff.wasteTokens || 0; | |
| } | |
| } | |
| return { | |
| cleanCount: cleanCount, | |
| correctedCount: correctedCount, | |
| strugglingCount: strugglingCount, | |
| totalErrors: totalErrors, | |
| totalRetries: totalRetries, | |
| totalSelfCorrections: totalSelfCorrections, | |
| totalCommits: totalCommits, | |
| wastefulCount: wastefulCount, | |
| totalWasteTokens: totalWasteTokens, | |
| allFiles: allFiles, | |
| }; | |
| } | |
| function computeSessionGrade(chapters, metrics) { | |
| const s = session.stats; | |
| const totalChapters = chapters.length; | |
| if (totalChapters === 0) return null; | |
| const m = metrics || aggregateChapterMetrics(chapters); | |
| const totalToolCalls = sumToolCounts(s.toolCounts); | |
| const totalErrors = m.totalErrors; | |
| const totalRetries = m.totalRetries; | |
| const totalSelfCorrections = m.totalSelfCorrections; | |
| const cleanCount = m.cleanCount; | |
| const correctedCount = m.correctedCount; | |
| const isSubagent = totalChapters === 1; | |
| // Factor 1: Error rate (35%) — gentler curve, 5% errors is still good | |
| // 0% = 100, 5% = 88, 10% = 75, 20% = 50, 40%+ = 0 | |
| const errorRatio = totalToolCalls > 0 ? totalErrors / totalToolCalls : 0; | |
| const errorScore = Math.max(0, 100 - errorRatio * 250); | |
| // Factor 2: Chapter quality (20%) — count corrected chapters as partial credit | |
| // Subagent sessions (1 chapter): corrected = 80% credit to avoid 0 score | |
| // Multi-chapter: clean = 100%, corrected = 60%, struggling = 0% | |
| const qualityNumerator = cleanCount + correctedCount * (isSubagent ? 0.8 : 0.6); | |
| const qualityRatio = qualityNumerator / totalChapters; | |
| const qualityScore = Math.min(100, qualityRatio * 100); | |
| // Factor 3: Retry ratio (10%) — only penalize excessive retries (>20% of tool calls) | |
| // Normal iteration is expected; only flag when >20% of calls are retries | |
| // <20% = 100, 30% = 67, 50% = 0 | |
| const retryRatio = totalToolCalls > 0 ? totalRetries / totalToolCalls : 0; | |
| const retryScore = retryRatio <= 0.20 ? 100 | |
| : Math.max(0, 100 - (retryRatio - 0.20) * 333); | |
| // Factor 4: Self-correction rate (15%) — higher is better when errors exist | |
| const correctionScore = totalErrors === 0 ? 100 | |
| : Math.min(100, (totalSelfCorrections / totalErrors) * 100); | |
| // Factor 5: Cache efficiency (20%) | |
| // Neutral (75) when insufficient input data (<10K tokens) to judge cache usage | |
| const cacheHit = s.totalCacheHit || 0; | |
| const totalInput = s.totalInputTokens || 0; | |
| const cacheRatio = totalInput > 0 ? cacheHit / totalInput : 0; | |
| const cacheScore = totalInput < 10000 ? 75 : Math.min(100, cacheRatio * 125); | |
| // 80%+ cache hit = 100, 0% = 0 | |
| const score = Math.round( | |
| errorScore * 0.35 + | |
| qualityScore * 0.20 + | |
| retryScore * 0.10 + | |
| correctionScore * 0.15 + | |
| cacheScore * 0.20 | |
| ); | |
| const clampedScore = Math.max(0, Math.min(100, score)); | |
| let letter, letterCls; | |
| if (clampedScore >= 90) { letter = 'A'; letterCls = 'grade-a'; } | |
| else if (clampedScore >= 80) { letter = 'B'; letterCls = 'grade-b'; } | |
| else if (clampedScore >= 70) { letter = 'C'; letterCls = 'grade-c'; } | |
| else if (clampedScore >= 60) { letter = 'D'; letterCls = 'grade-d'; } | |
| else { letter = 'F'; letterCls = 'grade-f'; } | |
| const notes = []; | |
| if (errorScore >= 90) notes.push('low error rate'); | |
| else if (errorScore < 50) notes.push('high error rate'); | |
| if (qualityRatio >= 0.8) notes.push('mostly clean'); | |
| else if (qualityRatio < 0.4 && totalChapters > 2) notes.push('many troubled chapters'); | |
| if (cacheScore >= 80 && totalInput > 0) notes.push('good cache usage'); | |
| else if (cacheScore < 40 && totalInput > 0) notes.push('low cache efficiency'); | |
| if (correctionScore >= 80 && totalErrors > 0) notes.push('good recovery'); | |
| else if (correctionScore < 30 && totalErrors > 2) notes.push('poor error recovery'); | |
| if (retryScore >= 90) notes.push('few retries'); | |
| else if (retryScore < 50) notes.push('many retries'); | |
| return { | |
| score: clampedScore, | |
| letter, | |
| letterCls, | |
| note: notes.slice(0, 3).join(', '), | |
| factors: [ | |
| { name: 'errors', score: Math.round(errorScore), weight: 35 }, | |
| { name: 'quality', score: Math.round(qualityScore), weight: 20 }, | |
| { name: 'retries', score: Math.round(retryScore), weight: 10 }, | |
| { name: 'recovery', score: Math.round(correctionScore), weight: 15 }, | |
| { name: 'cache', score: Math.round(cacheScore), weight: 20 }, | |
| ], | |
| }; | |
| } | |
| function renderSessionSummary() { | |
| const s = session.stats; | |
| const chapters = getChapters(); | |
| const m = aggregateChapterMetrics(chapters); | |
| const wrap = h('div', { className: 'session-summary' }); | |
| const items = []; | |
| if (session.durationMs > 0) { | |
| items.push(summaryItem('duration', formatDuration(session.durationMs), 'ss-duration')); | |
| } | |
| const totalCost = estimateParsedStatsCost(session.model, s); | |
| const costLabel = fmtCost(totalCost); | |
| if (costLabel) { | |
| items.push(summaryItem('cost', costLabel, 'ss-cost')); | |
| } | |
| const totalTok = s.totalInputTokens + s.totalOutputTokens; | |
| if (totalTok > 0) { | |
| items.push(summaryItem('tokens', fmtTokens(totalTok), '')); | |
| } | |
| if (s.errors > 0) { | |
| items.push(summaryItem('errors', String(s.errors), 'ss-errors')); | |
| } | |
| if (m.totalCommits > 0) { | |
| items.push(summaryItem('commits', String(m.totalCommits), 'ss-commits')); | |
| } | |
| if (m.allFiles.size > 0) { | |
| items.push(summaryItem('files', String(m.allFiles.size), '')); | |
| } | |
| const cleanCount = m.cleanCount; | |
| const correctedCount = m.correctedCount; | |
| const strugglingCount = m.strugglingCount; | |
| if (chapters.length > 1) { | |
| const qualityParts = []; | |
| if (cleanCount > 0) qualityParts.push(cleanCount + ' clean'); | |
| if (correctedCount > 0) qualityParts.push(correctedCount + ' corrected'); | |
| if (strugglingCount > 0) qualityParts.push(strugglingCount + ' struggling'); | |
| if (qualityParts.length > 0 && (correctedCount > 0 || strugglingCount > 0)) { | |
| const qualityCls = strugglingCount > correctedCount ? 'ss-errors' | |
| : correctedCount > 0 ? 'ss-quality-mixed' : ''; | |
| items.push(summaryItem('quality', qualityParts.join(', '), qualityCls)); | |
| } | |
| } | |
| if (chapters._depSummary && chapters._depSummary.chains > 0) { | |
| const ds = chapters._depSummary; | |
| const chainLabel = ds.chains + ' workflow chain' + (ds.chains > 1 ? 's' : '') + | |
| ' (' + ds.chaptersInChains + ' of ' + chapters.length + ' ch)'; | |
| items.push(summaryItem('workflows', chainLabel, '')); | |
| } | |
| if (m.wastefulCount > 0) { | |
| // Use session's actual cost-per-token ratio instead of raw rate average | |
| // (averaging in/out rates wildly overestimates because most tokens are cheap cached input) | |
| const costPerToken = totalTok > 0 && totalCost > 0 ? totalCost / totalTok : 0; | |
| const wasteCost = m.totalWasteTokens * costPerToken; | |
| let wasteLabel = m.wastefulCount + ' wasteful ch'; | |
| if (m.totalWasteTokens > 0) wasteLabel += ' · ~' + fmtTokens(m.totalWasteTokens) + ' wasted'; | |
| const wasteCostStr = fmtCost(wasteCost); | |
| if (wasteCostStr) wasteLabel += ' (' + wasteCostStr + ')'; | |
| items.push(summaryItem('waste', wasteLabel, 'ss-waste')); | |
| } | |
| const grade = computeSessionGrade(chapters, m); | |
| if (grade) { | |
| const gradeEl = h('div', { className: 'session-grade' }); | |
| gradeEl.appendChild(h('span', { className: 'session-grade-letter ' + grade.letterCls }, grade.letter)); | |
| const detailEl = h('div', { className: 'session-grade-detail' }); | |
| detailEl.appendChild(h('span', { className: 'session-grade-score' }, grade.score + '/100')); | |
| const breakdownEl = h('div', { className: 'session-grade-breakdown' }); | |
| for (const f of grade.factors) { | |
| const factorEl = h('span', { className: 'session-grade-factor' }); | |
| factorEl.textContent = f.name + ' '; | |
| const barWrap = h('span', { className: 'session-grade-factor-bar' }); | |
| const fillCls = f.score >= 70 ? 'fill-good' : f.score >= 40 ? 'fill-ok' : 'fill-bad'; | |
| const fill = h('span', { className: 'session-grade-factor-fill ' + fillCls }); | |
| fill.style.width = Math.max(2, f.score) + '%'; | |
| barWrap.appendChild(fill); | |
| factorEl.appendChild(barWrap); | |
| breakdownEl.appendChild(factorEl); | |
| } | |
| detailEl.appendChild(breakdownEl); | |
| if (grade.note) { | |
| detailEl.appendChild(h('span', { className: 'session-grade-note' }, grade.note)); | |
| } | |
| gradeEl.appendChild(detailEl); | |
| wrap.appendChild(gradeEl); | |
| if (items.length > 0) { | |
| wrap.appendChild(h('div', { className: 'session-summary-sep' })); | |
| } | |
| } | |
| for (let i = 0; i < items.length; i++) { | |
| wrap.appendChild(items[i]); | |
| if (i < items.length - 1) { | |
| wrap.appendChild(h('div', { className: 'session-summary-sep' })); | |
| } | |
| } | |
| return items.length > 0 || grade ? wrap : h('div'); | |
| } | |
| function summaryItem(label, value, cls) { | |
| return h('div', { className: 'session-summary-item' + (cls ? ' ' + cls : '') }, | |
| h('span', { className: 'ss-label' }, label), | |
| h('span', { className: 'ss-value' }, value) | |
| ); | |
| } | |
| function renderActivityTimeline() { | |
| const timed = []; | |
| for (const e of events) { | |
| if (!e.timestamp) continue; | |
| const ms = new Date(e.timestamp).getTime(); | |
| if (isNaN(ms)) continue; | |
| timed.push({ ms, type: e.type }); | |
| } | |
| if (timed.length < 3) return h('div'); | |
| timed.sort(function(a, b) { return a.ms - b.ms; }); | |
| const sessionStart = timed[0].ms; | |
| const sessionEnd = timed[timed.length - 1].ms; | |
| const totalSpan = sessionEnd - sessionStart; | |
| if (totalSpan < 10000) return h('div'); // less than 10s — not useful | |
| const chapters = getChapters(); | |
| const chapterRanges = []; | |
| for (const ch of chapters) { | |
| const s = ch.timestamp ? new Date(ch.timestamp).getTime() : 0; | |
| const e = ch.endTimestamp ? new Date(ch.endTimestamp).getTime() : s; | |
| if (s > 0) chapterRanges.push({ start: s, end: e || s }); | |
| } | |
| const GAP_THRESHOLD = 120000; // 2 minutes | |
| const IDLE_LABEL_THRESHOLD = 300000; // 5 minutes — label these | |
| const gaps = []; | |
| for (let i = 1; i < timed.length; i++) { | |
| const delta = timed[i].ms - timed[i - 1].ms; | |
| if (delta >= GAP_THRESHOLD) { | |
| gaps.push({ start: timed[i - 1].ms, end: timed[i].ms, duration: delta }); | |
| } | |
| } | |
| const segments = []; | |
| let segStart = sessionStart; | |
| for (const gap of gaps) { | |
| if (gap.start > segStart) { | |
| segments.push({ start: segStart, end: gap.start, type: 'active' }); | |
| } | |
| segments.push({ start: gap.start, end: gap.end, type: 'idle', duration: gap.duration }); | |
| segStart = gap.end; | |
| } | |
| if (segStart < sessionEnd) { | |
| segments.push({ start: segStart, end: sessionEnd, type: 'active' }); | |
| } | |
| function formatClockTime(ms) { | |
| const d = new Date(ms); | |
| let h = d.getHours(); | |
| const m = d.getMinutes(); | |
| const ampm = h >= 12 ? 'PM' : 'AM'; | |
| h = h % 12 || 12; | |
| return h + ':' + (m < 10 ? '0' : '') + m + ' ' + ampm; | |
| } | |
| const wrap = h('div', { className: 'activity-timeline' }); | |
| wrap.appendChild(h('div', { className: 'activity-timeline-label' }, | |
| 'activity timeline — colored segments show active work, dim gaps show idle periods')); | |
| const canvas = document.createElement('canvas'); | |
| canvas.className = 'activity-timeline-canvas'; | |
| const HEIGHT = 48; | |
| wrap.appendChild(canvas); | |
| const tooltip = h('div', { className: 'activity-timeline-tooltip' }); | |
| wrap.appendChild(tooltip); | |
| function drawTimeline() { | |
| const W = canvas.parentElement?.clientWidth || 800; | |
| const ctx = setupHiDpiCanvas(canvas, W, HEIGHT); | |
| ctx.fillStyle = '#111113'; | |
| ctx.fillRect(0, 0, W, HEIGHT); | |
| function timeToX(ms) { | |
| return ((ms - sessionStart) / totalSpan) * W; | |
| } | |
| const barY = 12; | |
| const barH = 20; | |
| for (const seg of segments) { | |
| const x1 = timeToX(seg.start); | |
| const x2 = timeToX(seg.end); | |
| const w = Math.max(1, x2 - x1); | |
| if (seg.type === 'active') { | |
| const grad = ctx.createLinearGradient(x1, barY, x1, barY + barH); | |
| grad.addColorStop(0, 'rgba(139, 124, 246, 0.7)'); | |
| grad.addColorStop(1, 'rgba(139, 124, 246, 0.35)'); | |
| ctx.fillStyle = grad; | |
| ctx.beginPath(); | |
| if (ctx.roundRect) { ctx.roundRect(x1, barY, w, barH, 3); } | |
| else { ctx.rect(x1, barY, w, barH); } | |
| ctx.fill(); | |
| } else { | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.02)'; | |
| ctx.fillRect(x1, barY, w, barH); | |
| ctx.save(); | |
| ctx.setLineDash([3, 4]); | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(x1, barY + barH / 2); | |
| ctx.lineTo(x1 + w, barY + barH / 2); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| if (seg.duration >= IDLE_LABEL_THRESHOLD && w > 30) { | |
| var gapMinutes = Math.round(seg.duration / 60000); | |
| var gapLabel = gapMinutes >= 60 ? (gapMinutes / 60).toFixed(1) + 'h' : gapMinutes + 'm'; | |
| ctx.font = '9px ' + getComputedStyle(document.body).getPropertyValue('--mono').trim().split(',')[0].replace(/'/g, ''); | |
| ctx.fillStyle = 'rgba(232, 164, 76, 0.8)'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(gapLabel + ' idle', x1 + w / 2, barY + barH / 2 + 3); | |
| } | |
| } | |
| } | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < chapterRanges.length; i++) { | |
| const x = timeToX(chapterRanges[i].start); | |
| ctx.beginPath(); | |
| ctx.moveTo(x, barY - 2); | |
| ctx.lineTo(x, barY + barH + 2); | |
| ctx.stroke(); | |
| } | |
| ctx.font = '8px ' + getComputedStyle(document.body).getPropertyValue('--mono').trim().split(',')[0].replace(/'/g, ''); | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; | |
| ctx.textAlign = 'center'; | |
| // Only label if chapters won't overlap (min 20px apart) | |
| var prevLabelX = -30; | |
| for (let i = 0; i < chapterRanges.length; i++) { | |
| const x = timeToX(chapterRanges[i].start); | |
| if (x - prevLabelX >= 20) { | |
| ctx.fillText(String(i + 1), x, barY - 4); | |
| prevLabelX = x; | |
| } | |
| } | |
| const dotY = barY + barH + 6; | |
| const bucketCount = Math.min(W, 200); | |
| const bucketW = totalSpan / bucketCount; | |
| const buckets = new Array(bucketCount).fill(0); | |
| for (const t of timed) { | |
| const idx = Math.min(bucketCount - 1, Math.floor((t.ms - sessionStart) / bucketW)); | |
| buckets[idx]++; | |
| } | |
| const maxBucket = Math.max(...buckets, 1); | |
| for (let i = 0; i < bucketCount; i++) { | |
| if (buckets[i] === 0) continue; | |
| const alpha = 0.2 + 0.8 * (buckets[i] / maxBucket); | |
| const x = (i / bucketCount) * W; | |
| const w = Math.max(1, W / bucketCount - 0.5); | |
| ctx.fillStyle = 'rgba(139, 124, 246, ' + alpha.toFixed(2) + ')'; | |
| ctx.fillRect(x, dotY, w, 3); | |
| } | |
| } | |
| drawTimeline(); | |
| window.addEventListener('resize', drawTimeline); | |
| canvas.addEventListener('mousemove', function(ev) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = ev.clientX - rect.left; | |
| const W = rect.width; | |
| const ratio = x / W; | |
| const hoverMs = sessionStart + ratio * totalSpan; | |
| const hoverTime = formatClockTime(hoverMs); | |
| var segInfo = ''; | |
| for (const seg of segments) { | |
| if (hoverMs >= seg.start && hoverMs <= seg.end) { | |
| if (seg.type === 'idle') { | |
| var gapMin = Math.round(seg.duration / 60000); | |
| segInfo = ' \u2014 idle gap (' + (gapMin >= 60 ? (gapMin / 60).toFixed(1) + 'h' : gapMin + 'm') + ')'; | |
| } else { | |
| segInfo = ' \u2014 active'; | |
| } | |
| break; | |
| } | |
| } | |
| var chLabel = ''; | |
| for (let i = 0; i < chapterRanges.length; i++) { | |
| var cr = chapterRanges[i]; | |
| var nextStart = i + 1 < chapterRanges.length ? chapterRanges[i + 1].start : sessionEnd; | |
| if (hoverMs >= cr.start && hoverMs < nextStart) { | |
| chLabel = ' \u2014 ch ' + (i + 1); | |
| break; | |
| } | |
| } | |
| tooltip.style.display = 'block'; | |
| tooltip.textContent = hoverTime + chLabel + segInfo; | |
| var left = ev.clientX - rect.left + 12; | |
| if (left + 160 > W) left = ev.clientX - rect.left - 160; | |
| tooltip.style.left = left + 'px'; | |
| tooltip.style.top = '-28px'; | |
| }); | |
| canvas.addEventListener('mouseleave', function() { | |
| tooltip.style.display = 'none'; | |
| }); | |
| const timesRow = h('div', { className: 'activity-timeline-times' }); | |
| const labelCount = Math.min(8, Math.max(2, Math.floor(totalSpan / 300000))); // ~5min minimum spacing | |
| for (let i = 0; i <= labelCount; i++) { | |
| const t = sessionStart + (i / labelCount) * totalSpan; | |
| timesRow.appendChild(h('span', null, formatClockTime(t))); | |
| } | |
| wrap.appendChild(timesRow); | |
| const legend = h('div', { className: 'activity-timeline-legend' }); | |
| function legendItem(color, text) { | |
| const dot = h('span', { className: 'activity-timeline-legend-dot' }); | |
| dot.style.background = color; | |
| return h('span', { className: 'activity-timeline-legend-item' }, dot, text); | |
| } | |
| legend.appendChild(legendItem('rgba(139, 124, 246, 0.7)', 'active')); | |
| legend.appendChild(legendItem('rgba(232, 164, 76, 0.6)', 'idle >5m')); | |
| legend.appendChild(legendItem('rgba(255, 255, 255, 0.15)', 'chapter boundary')); | |
| var totalIdleMs = gaps.reduce(function(s, g) { return s + g.duration; }, 0); | |
| var activeMs = totalSpan - totalIdleMs; | |
| if (totalIdleMs > 0) { | |
| var activeStr = formatDuration(activeMs); | |
| var idleStr = formatDuration(totalIdleMs); | |
| legend.appendChild(h('span', { className: 'activity-timeline-legend-item', style: 'margin-left: auto; color: var(--fg2);' }, | |
| 'active: ' + activeStr + ' · idle: ' + idleStr + ' (' + Math.round(totalIdleMs / totalSpan * 100) + '%)')); | |
| } | |
| wrap.appendChild(legend); | |
| return wrap; | |
| } | |
| const TOOL_COLORS = { | |
| Bash: '#59d4a0', | |
| Edit: '#e0c45e', | |
| Write: '#d89660', | |
| Read: '#6ba4e8', | |
| Agent: '#a78bfa', | |
| Grep: '#7a7a85', | |
| Glob: '#7a7a85', | |
| Skill: '#c88abd', | |
| WebFetch: '#6ba4e8', | |
| WebSearch: '#6ba4e8', | |
| ToolSearch: '#7a7a85', | |
| _mcp: '#5dadec', | |
| _text: '#a78bfa', | |
| _user: '#6ba4e8', | |
| _error: '#f07070', | |
| }; | |
| function getToolColor(name) { | |
| if (TOOL_COLORS[name]) return TOOL_COLORS[name]; | |
| if (typeof name === 'string' && name.startsWith('mcp__')) return TOOL_COLORS._mcp; | |
| return TOOL_COLORS._text; | |
| } | |
| function renderErrorSummary() { | |
| const chapters = getChapters(); | |
| const totalErrors = chapters.reduce((sum, ch) => sum + ch.errors, 0); | |
| if (totalErrors === 0) return h('div'); | |
| const toolErrors = {}; | |
| for (const ch of chapters) { | |
| for (const tool in ch.errorTools) { | |
| if (!Object.prototype.hasOwnProperty.call(ch.errorTools, tool)) continue; | |
| const count = ch.errorTools[tool]; | |
| toolErrors[tool] = (toolErrors[tool] || 0) + count; | |
| } | |
| } | |
| const streaks = []; | |
| let streakStart = -1; | |
| let errorChapterCount = 0; | |
| for (let i = 0; i < chapters.length; i++) { | |
| if (chapters[i].errors > 0) { | |
| errorChapterCount++; | |
| if (streakStart < 0) streakStart = i; | |
| } else { | |
| if (streakStart >= 0 && i - streakStart >= 2) { | |
| streaks.push({ start: streakStart, end: i - 1, length: i - streakStart }); | |
| } | |
| streakStart = -1; | |
| } | |
| } | |
| if (streakStart >= 0 && chapters.length - streakStart >= 2) { | |
| streaks.push({ start: streakStart, end: chapters.length - 1, length: chapters.length - streakStart }); | |
| } | |
| const inStreak = new Set(); | |
| for (const s of streaks) { | |
| for (let i = s.start; i <= s.end; i++) inStreak.add(i); | |
| } | |
| const wrap = h('div', { className: 'error-summary' }); | |
| const header = h('div', { className: 'error-summary-header' }); | |
| header.appendChild(h('span', { className: 'error-summary-title' }, 'errors')); | |
| header.appendChild(h('span', { className: 'error-summary-count' }, | |
| totalErrors + ' error' + (totalErrors !== 1 ? 's' : '') + | |
| ' across ' + errorChapterCount + ' chapter' + (errorChapterCount !== 1 ? 's' : '') | |
| )); | |
| wrap.appendChild(header); | |
| const body = h('div', { className: 'error-summary-body' }); | |
| const toolNames = []; | |
| for (const tool in toolErrors) { | |
| if (Object.prototype.hasOwnProperty.call(toolErrors, tool)) toolNames.push(tool); | |
| } | |
| toolNames.sort((a, b) => toolErrors[b] - toolErrors[a]); | |
| if (toolNames.length > 0) { | |
| const toolList = h('div', { className: 'error-tools-list' }); | |
| for (let ti = 0; ti < Math.min(toolNames.length, 6); ti++) { | |
| const tool = toolNames[ti]; | |
| const count = toolErrors[tool]; | |
| var errToolLabel = fmtMcpName(tool); | |
| toolList.appendChild(h('span', { className: 'error-tool-chip' }, errToolLabel + ' ' + count)); | |
| } | |
| body.appendChild(toolList); | |
| } | |
| if (streaks.length > 0) { | |
| const streakWrap = h('div', { className: 'error-streaks' }); | |
| for (const s of streaks) { | |
| const badge = h('span', { | |
| className: 'error-streak-badge', | |
| onClick: (ev) => { | |
| ev.stopPropagation(); | |
| jumpToChapter(s.start); | |
| } | |
| }, 'streak: ch ' + (s.start + 1) + '-' + (s.end + 1) + ' (' + s.length + ' chapters)'); | |
| streakWrap.appendChild(badge); | |
| } | |
| body.appendChild(streakWrap); | |
| } | |
| wrap.appendChild(body); | |
| const timeline = h('div', { className: 'error-timeline' }); | |
| for (let i = 0; i < chapters.length; i++) { | |
| const ch = chapters[i]; | |
| const hasErr = ch.errors > 0; | |
| const isInStreak = inStreak.has(i); | |
| const dotCls = hasErr ? (isInStreak ? 'error-dot streak' : 'error-dot has-error') : 'error-dot no-error'; | |
| const dot = h('div', { | |
| className: dotCls, | |
| title: 'Ch ' + (i + 1) + (hasErr ? ': ' + ch.errors + ' error' + (ch.errors > 1 ? 's' : '') : ''), | |
| onClick: (ev) => { | |
| ev.stopPropagation(); | |
| jumpToChapter(i); | |
| } | |
| }); | |
| if (hasErr && ch.errors > 1) { | |
| const size = Math.min(14, 8 + ch.errors); | |
| dot.style.width = size + 'px'; | |
| dot.style.height = size + 'px'; | |
| } | |
| timeline.appendChild(dot); | |
| } | |
| wrap.appendChild(timeline); | |
| return wrap; | |
| } | |
| function chapterFileKeys(ch) { | |
| return ch._fileKeys || Object.keys(ch.files); | |
| } | |
| function chapterToolKeys(ch) { | |
| return ch._toolKeys || Object.keys(ch.toolCounts); | |
| } | |
| const FIRST_PROMPT_MAX_LEN = 200; | |
| function safeSlice(str, maxLen) { | |
| if (typeof str !== "string") str = String(str ?? ""); | |
| if (str.length <= maxLen) return str; | |
| const sliced = str.slice(0, maxLen); | |
| // If we cut in the middle of a surrogate pair, drop the lone high surrogate | |
| const last = sliced.charCodeAt(maxLen - 1); | |
| if (last >= 0xD800 && last <= 0xDBFF) return sliced.slice(0, -1); | |
| return sliced; | |
| } | |
| function shortToolPath(input) { | |
| if (!input || typeof input !== "string") return ""; | |
| const last = input.lastIndexOf("/"); | |
| if (last < 0) return input; | |
| const prev = last > 0 ? input.lastIndexOf("/", last - 1) : -1; | |
| return prev < 0 ? input : input.slice(prev + 1); | |
| } | |
| function parseWebSearchLinks(output) { | |
| if (!output) return []; | |
| const linkRe = /\{"title":"([^"]*?)","url":"([^"]*?)"\}/g; | |
| const parsed = []; | |
| let m; | |
| while ((m = linkRe.exec(output)) !== null) { | |
| parsed.push({ title: m[1], url: m[2] }); | |
| } | |
| return parsed; | |
| } | |
| function countNonEmptyLines(text) { | |
| if (!text) return 0; | |
| let n = 0; | |
| let start = 0; | |
| const len = text.length; | |
| for (let i = 0; i <= len; i++) { | |
| if (i === len || text[i] === "\n") { | |
| if (text.slice(start, i).trim()) n++; | |
| start = i + 1; | |
| } | |
| } | |
| return n; | |
| } | |
| function joinFirstLines(text, maxLines) { | |
| if (!text || maxLines <= 0) return ""; | |
| let lines = 0; | |
| const len = text.length; | |
| for (let i = 0; i < len; i++) { | |
| if (text[i] === "\n") { | |
| lines++; | |
| if (lines >= maxLines) return text.slice(0, i); | |
| } | |
| } | |
| return text; | |
| } | |
| function joinFirstNonEmptyLines(text, maxLines) { | |
| if (!text || maxLines <= 0) return ""; | |
| const parts = []; | |
| let start = 0; | |
| const len = text.length; | |
| for (let i = 0; i <= len && parts.length < maxLines; i++) { | |
| if (i === len || text[i] === "\n") { | |
| const line = text.slice(start, i); | |
| if (line.trim()) parts.push(line); | |
| start = i + 1; | |
| } | |
| } | |
| return parts.join("\n"); | |
| } | |
| function countWords(text) { | |
| if (!text) return 0; | |
| let n = 0; | |
| let i = 0; | |
| const len = text.length; | |
| while (i < len) { | |
| while (i < len && /\s/.test(text[i])) i++; | |
| if (i >= len) break; | |
| n++; | |
| while (i < len && !/\s/.test(text[i])) i++; | |
| } | |
| return n; | |
| } | |
| function countWordsInStrings(strings) { | |
| let total = 0; | |
| for (let i = 0; i < strings.length; i++) total += countWords(strings[i]); | |
| return total; | |
| } | |
| function extractCommitMessageFromMFlag(gitPart) { | |
| var mMatch = gitPart.match(/-m\s+["']([^"']+)["']/); | |
| if (!mMatch) mMatch = gitPart.match(/-m\s+"([^"]+)"/); | |
| if (!mMatch) mMatch = gitPart.match(/-m\s+'([^']+)'/); | |
| if (!mMatch) mMatch = gitPart.match(/-m\s+(\S+)/); | |
| return mMatch ? mMatch[1] : null; | |
| } | |
| function detectGitOp(cmd, output) { | |
| if (!cmd) return null; | |
| var c = cmd.trim(); | |
| // Fast reject: virtually all Bash tool calls are non-git (no split/regex work). | |
| if (c.indexOf("git ") < 0) return null; | |
| // Single command — skip chained split when there is no && or ; | |
| var gitPart; | |
| var chainAt = c.indexOf("&&"); | |
| if (chainAt < 0) chainAt = c.indexOf(";"); | |
| if (chainAt < 0) { | |
| if (!c.startsWith("git ")) return null; | |
| gitPart = c; | |
| } else { | |
| var parts = c.split(/&&|;/); | |
| gitPart = null; | |
| for (var pi = 0; pi < parts.length; pi++) { | |
| var p = parts[pi].trim(); | |
| if (p.startsWith("git ")) { | |
| gitPart = p; | |
| break; | |
| } | |
| } | |
| if (!gitPart) return null; | |
| } | |
| // Strip git -C <path> prefix so the rest of the regex patterns work | |
| gitPart = gitPart.replace(/^git\s+(-C\s+\S+\s+)+/, 'git '); | |
| // git commit | |
| if (/\bgit\s+commit\b/.test(gitPart)) { | |
| var op = { type: 'commit', cmd: gitPart }; | |
| // Try to extract commit hash and message from output | |
| // Typical output: "[branch abc1234] commit message here" | |
| if (output) { | |
| var commitMatch = output.match(/\[([^\s\]]+)\s+([a-f0-9]{7,})\]\s*(.*)/) || | |
| output.match(/([a-f0-9]{7,40})\]\s*(.*)/) || | |
| output.match(/\[\S+\s+([a-f0-9]{7,})\]\s*(.*)/); | |
| if (commitMatch) { | |
| // Format: [branch hash] message | |
| op.hash = commitMatch[2] || commitMatch[1]; | |
| op.message = safeSlice((commitMatch[3] || commitMatch[2] || '').trim(), 120); | |
| } | |
| // Also try: "create mode" pattern or "-m" flag extraction | |
| if (!op.hash) { | |
| var hashLine = output.match(/([a-f0-9]{7,40})/); | |
| if (hashLine) op.hash = hashLine[1].slice(0, 7); | |
| } | |
| } | |
| if (!op.message) { | |
| var heredocMatch = cmd.match(/<<['"]?EOF['"]?\n([\s\S]*?)\nEOF/); | |
| if (heredocMatch) { | |
| op.message = safeSlice(heredocMatch[1].split('\n')[0].trim(), 120); | |
| } | |
| } | |
| if (!op.message) { | |
| var msgFromFlag = extractCommitMessageFromMFlag(gitPart); | |
| if (msgFromFlag) op.message = safeSlice(msgFromFlag, 120); | |
| } | |
| return op; | |
| } | |
| // git push | |
| if (/\bgit\s+push\b/.test(gitPart)) { | |
| var op = { type: 'push', cmd: gitPart }; | |
| op.tags = /--tags/.test(gitPart); | |
| // Extract remote and branch (skip flags like --tags, -u, --force, etc.) | |
| var pushMatch = gitPart.match(/git\s+push\s+(?:(?:--?[\w-]+)\s+)*([^\s-][\w./-]*)(?:\s+([\w./-]+))?/); | |
| if (pushMatch) { | |
| op.remote = pushMatch[1]; | |
| if (pushMatch[2]) op.branch = pushMatch[2]; | |
| } else { | |
| // Try simpler: git push <remote> — scan tokens without split+filter alloc | |
| var pi = 0; | |
| var plen = gitPart.length; | |
| var pn = 0; | |
| while (pi < plen && pn < 2) { | |
| while (pi < plen && gitPart.charAt(pi) <= " ") pi++; | |
| if (pi >= plen) break; | |
| var pstart = pi; | |
| while (pi < plen && gitPart.charAt(pi) > " ") pi++; | |
| var pt = gitPart.slice(pstart, pi); | |
| if (pt.charAt(0) !== "-" && pt !== "git" && pt !== "push") { | |
| if (pn === 0) op.remote = pt; | |
| else op.branch = pt; | |
| pn++; | |
| } | |
| } | |
| } | |
| return op; | |
| } | |
| // git checkout -b (create branch) | |
| if (/\bgit\s+checkout\s+-b\b/.test(gitPart)) { | |
| var branchMatch = gitPart.match(/git\s+checkout\s+-b\s+([\w./-]+)/); | |
| return { type: 'branch-create', cmd: gitPart, branch: branchMatch ? branchMatch[1] : '' }; | |
| } | |
| // git switch -c (create branch) | |
| if (/\bgit\s+switch\s+-c\b/.test(gitPart) || /\bgit\s+switch\s+--create\b/.test(gitPart)) { | |
| var branchMatch = gitPart.match(/git\s+switch\s+(?:-c|--create)\s+([\w./-]+)/); | |
| return { type: 'branch-create', cmd: gitPart, branch: branchMatch ? branchMatch[1] : '' }; | |
| } | |
| // git branch (create) | |
| if (/\bgit\s+branch\s+[^-]/.test(gitPart) && !/\bgit\s+branch\s+-[dD]/.test(gitPart)) { | |
| var branchMatch = gitPart.match(/git\s+branch\s+([\w./-]+)/); | |
| if (branchMatch) return { type: 'branch-create', cmd: gitPart, branch: branchMatch[1] }; | |
| } | |
| // git checkout (switch branch) | |
| if (/\bgit\s+checkout\s+(?!--)\S/.test(gitPart) && !/\bgit\s+checkout\s+-b\b/.test(gitPart)) { | |
| var branchMatch = gitPart.match(/git\s+checkout\s+([\w./-]+)/); | |
| if (branchMatch && !branchMatch[1].includes('.')) { | |
| return { type: 'branch-switch', cmd: gitPart, branch: branchMatch[1] }; | |
| } | |
| } | |
| // git switch (switch branch) | |
| if (/\bgit\s+switch\s+(?!-c|--create)\S/.test(gitPart)) { | |
| var branchMatch = gitPart.match(/git\s+switch\s+([\w./-]+)/); | |
| if (branchMatch) return { type: 'branch-switch', cmd: gitPart, branch: branchMatch[1] }; | |
| } | |
| // git merge | |
| if (/\bgit\s+merge\b/.test(gitPart)) { | |
| var mergeMatch = gitPart.match(/git\s+merge\s+(?:--[^\s]+\s+)*([\w./-]+)/); | |
| return { type: 'merge', cmd: gitPart, branch: mergeMatch ? mergeMatch[1] : '' }; | |
| } | |
| // git rebase | |
| if (/\bgit\s+rebase\b/.test(gitPart)) { | |
| var rebaseMatch = gitPart.match(/git\s+rebase\s+(?:--[^\s]+\s+)*([\w./-]+)/); | |
| return { type: 'rebase', cmd: gitPart, branch: rebaseMatch ? rebaseMatch[1] : '' }; | |
| } | |
| // git stash | |
| if (/\bgit\s+stash\b/.test(gitPart)) { | |
| return { type: 'stash', cmd: gitPart }; | |
| } | |
| // git tag | |
| if (/\bgit\s+tag\b/.test(gitPart) && !/\bgit\s+tag\s+-d/.test(gitPart)) { | |
| var tagMatch = gitPart.match(/git\s+tag\s+(?:-[^\s]+\s+)*([\w./-]+)/); | |
| return { type: 'tag', cmd: gitPart, tag: tagMatch ? tagMatch[1] : '' }; | |
| } | |
| return null; | |
| } | |
| function buildChapterEventMaps(events) { | |
| const resultMap = {}; | |
| const toolNameMap = {}; | |
| for (const e of events) { | |
| if (e.type === "tool_result") { | |
| resultMap[e.toolUseId] = e; | |
| } else if (e.type === "assistant" && e.toolCalls) { | |
| for (const tc of e.toolCalls) { | |
| toolNameMap[tc.id] = tc.name; | |
| } | |
| } | |
| } | |
| return { resultMap, toolNameMap }; | |
| } | |
| function accumulateAssistantToolCall(chapter, tc, resultMap, opts = {}) { | |
| const trackCallSeq = !!opts.trackCallSeq; | |
| const searchOutput = !!opts.searchOutput; | |
| const agentResult = !!opts.agentResult; | |
| const webFetchPreview = !!opts.webFetchPreview; | |
| chapter.toolCounts[tc.name] = (chapter.toolCounts[tc.name] || 0) + 1; | |
| const result = resultMap[tc.id]; | |
| const output = result ? result.text || "" : ""; | |
| const isError = result ? result.isError : false; | |
| let filePath; | |
| if ((tc.name === "Read" || tc.name === "Edit" || tc.name === "Write") && tc.input) { | |
| filePath = shortToolPath(tc.input); | |
| if (!chapter.files[filePath]) chapter.files[filePath] = { ops: [], output: "" }; | |
| chapter.files[filePath].ops.push(tc.name); | |
| if (tc.name === "Read" && output && !chapter.files[filePath].output) { | |
| chapter.files[filePath].output = safeSlice(joinFirstLines(output, 3), FIRST_PROMPT_MAX_LEN); | |
| } | |
| if (tc.diffInfo && chapter.diffs.length < 12) { | |
| chapter.diffs.push({ path: filePath, name: tc.name, diffInfo: tc.diffInfo }); | |
| } | |
| } | |
| if (trackCallSeq) { | |
| const entry = { name: tc.name, input: tc.input || "", isError, id: tc.id }; | |
| if (filePath) entry._path = filePath; | |
| chapter._callSeq.push(entry); | |
| } | |
| if (tc.name === "Bash" && tc.input) { | |
| chapter.commands.push({ cmd: tc.input, ok: !isError, output: safeSlice(output, 300) }); | |
| const gitOp = detectGitOp(tc.input, output); | |
| if (gitOp && chapter.gitOps.length < 20) chapter.gitOps.push(gitOp); | |
| } | |
| if (tc.name === "Grep" && tc.input) { | |
| const matchCount = countNonEmptyLines(output); | |
| const entry = { query: tc.input, matches: matchCount, ok: !isError }; | |
| if (searchOutput) entry.output = safeSlice(output, FIRST_PROMPT_MAX_LEN); | |
| chapter.searches.push(entry); | |
| } | |
| if (tc.webInfo && chapter.webOps.length < 10) { | |
| const webOp = { | |
| type: tc.webInfo.type, | |
| url: tc.webInfo.url || "", | |
| query: tc.webInfo.query || "", | |
| prompt: tc.webInfo.prompt || "", | |
| ok: !isError, | |
| output: safeSlice(output, 500), | |
| }; | |
| if (tc.webInfo.type === "search" && output) { | |
| const parsedLinks = parseWebSearchLinks(output); | |
| if (parsedLinks.length) { | |
| webOp.results = parsedLinks.slice(0, 5); | |
| webOp.resultCount = parsedLinks.length; | |
| } | |
| } | |
| if (tc.webInfo.type === "fetch" && output) { | |
| if (webFetchPreview) { | |
| webOp.preview = safeSlice(joinFirstNonEmptyLines(output, 4), 300); | |
| } | |
| const titleMatch = output.match(/^#\s+(.+)/m); | |
| if (titleMatch) webOp.pageTitle = safeSlice(titleMatch[1], 100); | |
| } | |
| chapter.webOps.push(webOp); | |
| } | |
| if (tc.agentInfo && chapter.agents.length < 10) { | |
| const agent = { | |
| description: tc.agentInfo.description || "", | |
| prompt: tc.agentInfo.prompt || "", | |
| subagentType: tc.agentInfo.subagentType || "", | |
| toolName: tc.agentInfo.toolName || tc.name, | |
| completed: !!result, | |
| isError, | |
| }; | |
| if (agentResult) agent.result = safeSlice(output, 400); | |
| chapter.agents.push(agent); | |
| } | |
| if (tc.mcpInfo && chapter.mcpOps.length < 15) { | |
| chapter.mcpOps.push({ | |
| server: tc.mcpInfo.server || "", | |
| tool: tc.mcpInfo.tool || "", | |
| rawName: tc.mcpInfo.rawName || tc.name, | |
| params: tc.mcpInfo.params || {}, | |
| ok: !isError, | |
| output: safeSlice(output, 400), | |
| }); | |
| } | |
| } | |
| function newChapter(prompt, timestamp, variant = "html") { | |
| const ch = { | |
| prompt, | |
| timestamp, | |
| endTimestamp: timestamp, | |
| turns: 0, | |
| toolCounts: {}, | |
| files: {}, | |
| commands: [], | |
| searches: [], | |
| webOps: [], | |
| mcpOps: [], | |
| diffs: [], | |
| agents: [], | |
| errors: 0, | |
| corrected: false, | |
| lastAssistantText: "", | |
| gitOps: [], | |
| thinking: [], | |
| }; | |
| if (variant === "markdown") { | |
| ch.outcome = "clean"; | |
| } else { | |
| ch.errorTools = {}; | |
| ch.tokens = { input: 0, output: 0, cacheHit: 0, cacheWrite: 0 }; | |
| ch._callSeq = []; | |
| } | |
| return ch; | |
| } | |
| function bashSplitFirstToken(s) { | |
| if (!s) return ""; | |
| let i = 0; | |
| const len = s.length; | |
| while (i < len) { | |
| const c = s.charCodeAt(i); | |
| if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) break; | |
| i++; | |
| } | |
| if (i > 0) return ""; | |
| const start = i; | |
| while (i < len) { | |
| const c = s.charCodeAt(i); | |
| if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) break; | |
| i++; | |
| } | |
| return s.slice(start, i); | |
| } | |
| function bashFirstTwoTokens(s) { | |
| if (!s) return ""; | |
| let i = 0; | |
| const len = s.length; | |
| while (i < len) { | |
| const c = s.charCodeAt(i); | |
| if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) break; | |
| i++; | |
| } | |
| if (i >= len) return ""; | |
| const start0 = i; | |
| while (i < len) { | |
| const c = s.charCodeAt(i); | |
| if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) break; | |
| i++; | |
| } | |
| const t0 = s.slice(start0, i); | |
| while (i < len) { | |
| const c = s.charCodeAt(i); | |
| if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) break; | |
| i++; | |
| } | |
| if (i >= len) return t0; | |
| const start1 = i; | |
| while (i < len) { | |
| const c = s.charCodeAt(i); | |
| if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) break; | |
| i++; | |
| } | |
| return t0 + " " + s.slice(start1, i); | |
| } | |
| function compareToolTargets(input1, input2, toolName, mode, keys) { | |
| const minLen = mode === "same" ? 0 : 1; | |
| if (toolName === "Edit" || toolName === "Write" || toolName === "Read") { | |
| const p1 = keys?.path1 ?? shortToolPath(input1); | |
| const p2 = keys?.path2 ?? shortToolPath(input2); | |
| return p1 === p2 && p1.length > minLen; | |
| } | |
| if (toolName === "Bash") { | |
| const cmd1 = keys?.bash1 ?? (mode === "same" ? bashSplitFirstToken(input1) : bashFirstTwoTokens(input1)); | |
| const cmd2 = keys?.bash2 ?? (mode === "same" ? bashSplitFirstToken(input2) : bashFirstTwoTokens(input2)); | |
| return cmd1 === cmd2 && cmd1.length > minLen; | |
| } | |
| const limit = mode === "same" ? 30 : 40; | |
| const s1 = input1.slice(0, limit); | |
| const s2 = input2.slice(0, limit); | |
| if (mode === "same") return s1 === s2; | |
| let matches = 0; | |
| for (let i = 0; i < Math.min(s1.length, s2.length); i++) { | |
| if (s1[i] === s2[i]) matches++; | |
| } | |
| return matches / Math.max(s1.length, s2.length) > 0.5; | |
| } | |
| function isSameTarget(input1, input2, toolName) { | |
| if (!input1 || !input2) return false; | |
| return compareToolTargets(input1, input2, toolName, "same"); | |
| } | |
| function isSimilarInput(input1, input2, toolName) { | |
| if (!input1 && !input2) return true; | |
| if (!input1 || !input2) return false; | |
| if (input1 === input2) return true; | |
| return compareToolTargets(input1, input2, toolName, "similar"); | |
| } | |
| function isSameCallTarget(a, b) { | |
| if (!a.input || !b.input) return false; | |
| const toolName = a.name; | |
| const keys = {}; | |
| if (toolName === "Edit" || toolName === "Write" || toolName === "Read") { | |
| keys.path1 = a._path !== undefined ? a._path : (a._path = shortToolPath(a.input)); | |
| keys.path2 = b._path !== undefined ? b._path : (b._path = shortToolPath(b.input)); | |
| } else if (toolName === "Bash") { | |
| keys.bash1 = a._bashFirst !== undefined ? a._bashFirst : (a._bashFirst = bashSplitFirstToken(a.input)); | |
| keys.bash2 = b._bashFirst !== undefined ? b._bashFirst : (b._bashFirst = bashSplitFirstToken(b.input)); | |
| } | |
| return compareToolTargets(a.input, b.input, toolName, "same", keys); | |
| } | |
| function isSimilarCallEntry(a, b) { | |
| if (!a.input && !b.input) return true; | |
| if (!a.input || !b.input) return false; | |
| if (a.input === b.input) return true; | |
| const toolName = a.name; | |
| const keys = {}; | |
| if (toolName === "Edit" || toolName === "Write" || toolName === "Read") { | |
| keys.path1 = a._path !== undefined ? a._path : (a._path = shortToolPath(a.input)); | |
| keys.path2 = b._path !== undefined ? b._path : (b._path = shortToolPath(b.input)); | |
| } else if (toolName === "Bash") { | |
| keys.bash1 = a._bashTwo !== undefined ? a._bashTwo : (a._bashTwo = bashFirstTwoTokens(a.input)); | |
| keys.bash2 = b._bashTwo !== undefined ? b._bashTwo : (b._bashTwo = bashFirstTwoTokens(b.input)); | |
| } | |
| return compareToolTargets(a.input, b.input, toolName, "similar", keys); | |
| } | |
| const CORRECTION_HINT_RE = /\b(no|wrong|idiot|revert|undo|actually|instead)\b|not what/i; | |
| function markUserPromptCorrections(chapters) { | |
| for (let i = 0; i < chapters.length - 1; i++) { | |
| const next = chapters[i + 1].prompt; | |
| if (CORRECTION_HINT_RE.test(next)) { | |
| chapters[i].corrected = true; | |
| } | |
| } | |
| } | |
| function countSelfCorrections(seq) { | |
| let count = 0; | |
| for (let i = 0; i < seq.length; i++) { | |
| if (!seq[i].isError) continue; | |
| const errName = seq[i].name; | |
| for (let k = i + 1; k < Math.min(i + 4, seq.length); k++) { | |
| if (seq[k].name === errName) { | |
| if (isSameCallTarget(seq[i], seq[k]) && !seq[k].isError) { | |
| count++; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| return count; | |
| } | |
| function detectChapterRetries(ch) { | |
| const seq = ch._callSeq; | |
| ch.retries = 0; | |
| ch.retryGroups = []; | |
| let selfCorrections = 0; | |
| if (!seq || seq.length < 2) { | |
| ch.selfCorrections = 0; | |
| return; | |
| } | |
| let i = 0; | |
| while (i < seq.length) { | |
| if (seq[i].isError) { | |
| const errName = seq[i].name; | |
| for (let k = i + 1; k < Math.min(i + 4, seq.length); k++) { | |
| if (seq[k].name === errName) { | |
| if (isSameCallTarget(seq[i], seq[k]) && !seq[k].isError) { | |
| selfCorrections++; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| let j = i + 1; | |
| const baseName = seq[i].name; | |
| const group = [i]; | |
| while (j < seq.length && j - i < 8) { | |
| if (seq[j].name === baseName && isSimilarCallEntry(seq[i], seq[j])) { | |
| group.push(j); | |
| } | |
| j++; | |
| } | |
| if (group.length >= 2) { | |
| ch.retries += group.length - 1; | |
| ch.retryGroups.push({ tool: baseName, count: group.length, input: safeSlice(seq[i].input, 60) }); | |
| i = group[group.length - 1] + 1; | |
| } else { | |
| i++; | |
| } | |
| } | |
| ch.selfCorrections = selfCorrections; | |
| } | |
| function classifyChapterOutcome(ch) { | |
| if (ch.selfCorrections === undefined) { | |
| ch.selfCorrections = countSelfCorrections(ch._callSeq || []); | |
| } | |
| if (ch.errors === 0 && ch.retries === 0) { | |
| ch.outcome = "clean"; | |
| } else if (ch.selfCorrections > 0 && ch.selfCorrections >= ch.errors * 0.5) { | |
| ch.outcome = "corrected"; | |
| } else if (ch.corrected) { | |
| ch.outcome = "corrected"; | |
| } else if (ch.retries >= 3 || ch.errors >= 3) { | |
| ch.outcome = "struggling"; | |
| } else if (ch.selfCorrections > 0) { | |
| ch.outcome = "corrected"; | |
| } else if (ch.errors >= 2 && ch.selfCorrections === 0) { | |
| ch.outcome = "struggling"; | |
| } else if (ch.errors === 1 && ch.turns > 1) { | |
| ch.outcome = "corrected"; | |
| } else if (ch.retries > 0 && ch.retries < 3) { | |
| ch.outcome = "corrected"; | |
| } else if (ch.errors > 0) { | |
| ch.outcome = "corrected"; | |
| } else { | |
| ch.outcome = "clean"; | |
| } | |
| } | |
| function countGitOpsOfType(gitOps, type) { | |
| let n = 0; | |
| for (let i = 0; i < gitOps.length; i++) { | |
| if (gitOps[i].type === type) n++; | |
| } | |
| return n; | |
| } | |
| function countOkCommands(commands) { | |
| let n = 0; | |
| for (let i = 0; i < commands.length; i++) { | |
| if (commands[i].ok) n++; | |
| } | |
| return n; | |
| } | |
| function computeChapterEfficiency(ch, avgTokPerChapter) { | |
| const chTok = ch.tokens.input + ch.tokens.output; | |
| const fileCount = chapterFileKeys(ch).length; | |
| const commitCount = countGitOpsOfType(ch.gitOps, "commit"); | |
| const editWriteCount = (ch.toolCounts.Edit || 0) + (ch.toolCounts.Write || 0); | |
| let callCount = 0; | |
| const toolKeys = chapterToolKeys(ch); | |
| for (let i = 0; i < toolKeys.length; i++) callCount += ch.toolCounts[toolKeys[i]] || 0; | |
| const failedRetries = ch.retries; | |
| const tokPerFile = fileCount > 0 ? Math.round(chTok / fileCount) : 0; | |
| const tokPerCommit = commitCount > 0 ? Math.round(chTok / commitCount) : 0; | |
| const errorTokens = ch.errors > 0 && callCount > 0 ? Math.round(chTok * (ch.errors / callCount)) : 0; | |
| const wasteReasons = []; | |
| const highTokens = chTok > avgTokPerChapter * 1.5 && avgTokPerChapter > 0; | |
| if ( | |
| highTokens && | |
| fileCount === 0 && | |
| commitCount === 0 && | |
| editWriteCount === 0 && | |
| ch.commands.length === 0 && | |
| ch.agents.length === 0 | |
| ) { | |
| wasteReasons.push("high tokens, no file changes or actions"); | |
| } | |
| if (failedRetries >= 3 && ch.selfCorrections === 0) { | |
| wasteReasons.push(failedRetries + " failed retries without resolution"); | |
| } | |
| if (ch.thinking.length > 0 && callCount === 0) { | |
| wasteReasons.push("reasoning with no tool actions"); | |
| } | |
| if (ch.errors >= 3 && ch.selfCorrections === 0) { | |
| wasteReasons.push(ch.errors + " errors, no self-correction"); | |
| } | |
| if ((ch.toolCounts.Read || 0) >= 6 && editWriteCount === 0 && commitCount === 0 && highTokens) { | |
| wasteReasons.push("heavy reading with no edits"); | |
| } | |
| let effScore = 100; | |
| if (callCount > 0) { | |
| effScore -= Math.min(40, (ch.errors / callCount) * 200); | |
| } | |
| if (callCount > 0) { | |
| effScore -= Math.min(25, (ch.retries / callCount) * 150); | |
| } | |
| if (chTok > 0 && avgTokPerChapter > 0) { | |
| const productivity = editWriteCount + commitCount + countOkCommands(ch.commands); | |
| const tokRatio = chTok / avgTokPerChapter; | |
| if (tokRatio > 2 && productivity <= 1) { | |
| effScore -= Math.min(25, (tokRatio - 1) * 10); | |
| } | |
| } | |
| if (ch.selfCorrections > 0 && ch.errors > 0) { | |
| effScore += Math.min(10, (ch.selfCorrections / ch.errors) * 10); | |
| } | |
| effScore = Math.max(0, Math.min(100, Math.round(effScore))); | |
| const isWasteful = wasteReasons.length > 0 || effScore < 40; | |
| ch.efficiency = { | |
| score: effScore, | |
| isWasteful, | |
| wasteReasons, | |
| tokPerFile, | |
| tokPerCommit, | |
| errorTokens, | |
| wasteTokens: isWasteful ? Math.round(chTok * Math.max(0.2, 1 - effScore / 100)) : 0, | |
| }; | |
| } | |
| function enrichChaptersQualityCore(chapters) { | |
| for (const ch of chapters) { | |
| detectChapterRetries(ch); | |
| classifyChapterOutcome(ch); | |
| delete ch._callSeq; | |
| } | |
| } | |
| function buildSessionChapters(session) { | |
| const events = session.events || []; | |
| const chapters = []; | |
| let current = null; | |
| const { resultMap, toolNameMap } = buildChapterEventMaps(events); | |
| let compareUserPrompt = null; | |
| let compareAssistantPrompt = null; | |
| for (const e of events) { | |
| if (!compareUserPrompt && e.type === "user" && e.text) compareUserPrompt = e.text; | |
| else if (!compareAssistantPrompt && e.type === "assistant" && e.text && e.text.length > 20) { | |
| compareAssistantPrompt = e.text; | |
| } | |
| if (e.type === "user" && e.text) { | |
| if (current) chapters.push(current); | |
| current = newChapter(e.text, e.timestamp); | |
| continue; | |
| } | |
| if (!current) { | |
| if (e.type === "assistant") { | |
| current = newChapter("(subagent session)", e.timestamp || session.startTime); | |
| } else { | |
| continue; | |
| } | |
| } | |
| if (current.prompt === "(subagent session)" && e.type === "assistant" && e.text && e.text.length > 20) { | |
| current.prompt = e.text; | |
| } | |
| if (e.type === "assistant") { | |
| current.turns++; | |
| current.endTimestamp = e.timestamp; | |
| if (e.text) current.lastAssistantText = e.text; | |
| if (e.tokens) { | |
| current.tokens.input += e.tokens.input || 0; | |
| current.tokens.output += e.tokens.output || 0; | |
| current.tokens.cacheHit += e.tokens.cacheHit || 0; | |
| current.tokens.cacheWrite += e.tokens.cacheWrite || 0; | |
| } | |
| if (e.thinking && current.thinking.length < 20) { | |
| for (const t of e.thinking) { | |
| if (t) current.thinking.push(t); | |
| } | |
| } | |
| for (const tc of e.toolCalls || []) { | |
| accumulateAssistantToolCall(current, tc, resultMap, { | |
| trackCallSeq: true, | |
| searchOutput: true, | |
| agentResult: true, | |
| webFetchPreview: true, | |
| }); | |
| } | |
| } | |
| if (e.type === "tool_result" && e.isError) { | |
| current.errors++; | |
| if (toolNameMap[e.toolUseId]) { | |
| current.errorTools[toolNameMap[e.toolUseId]] = | |
| (current.errorTools[toolNameMap[e.toolUseId]] || 0) + 1; | |
| } | |
| } | |
| } | |
| if (current) chapters.push(current); | |
| markUserPromptCorrections(chapters); | |
| for (const ch of chapters) { | |
| ch._fileKeys = Object.keys(ch.files); | |
| ch._toolKeys = Object.keys(ch.toolCounts); | |
| } | |
| enrichChaptersQualityCore(chapters); | |
| const comparePrompt = | |
| safeSlice(compareUserPrompt, 120) || safeSlice(compareAssistantPrompt, 120) || ""; | |
| if (comparePrompt) chapters._comparePrompt = comparePrompt; | |
| return chapters; | |
| } | |
| function enrichChaptersEfficiency(chapters) { | |
| let totalTokAll = 0; | |
| for (const ch of chapters) { | |
| totalTokAll += ch.tokens.input + ch.tokens.output; | |
| } | |
| const avgTokPerChapter = chapters.length > 0 ? totalTokAll / chapters.length : 0; | |
| for (const ch of chapters) { | |
| computeChapterEfficiency(ch, avgTokPerChapter); | |
| } | |
| } | |
| function enrichChaptersForRender(chapters) { | |
| const EMPTY_DEPS = { continuesFrom: [], fixesFrom: [], sharedFiles: {} }; | |
| function addChapterTokens(charBits, bigrams, extraChars, str) { | |
| if (!str) return; | |
| const len = str.length; | |
| for (let i = 0; i < len; i++) { | |
| const c = str.charCodeAt(i); | |
| if (c < 256) charBits[c >>> 5] |= 1 << (c & 31); | |
| else extraChars.add(str[i]); | |
| if (i + 1 < len) bigrams.add((c << 16) | str.charCodeAt(i + 1)); | |
| } | |
| } | |
| function cacheLowerAndToken(charBits, bigrams, extraChars, raw) { | |
| const str = (raw || "").toLowerCase(); | |
| addChapterTokens(charBits, bigrams, extraChars, str); | |
| return str; | |
| } | |
| for (let i = 0; i < chapters.length; i++) { | |
| const ch = chapters[i]; | |
| const chFiles = chapterFileKeys(ch); | |
| if (chFiles.length === 0 && ch.errors === 0) { | |
| ch.deps = EMPTY_DEPS; | |
| continue; | |
| } | |
| const lookback = Math.max(0, i - 10); | |
| const continuesRefs = new Set(); | |
| const fixesRefs = new Set(); | |
| const sharedFiles = {}; | |
| for (let j = lookback; j < i; j++) { | |
| const prev = chapters[j]; | |
| let prevFileSet = prev._fileKeysSet; | |
| if (!prevFileSet) { | |
| prev._fileKeysSet = prevFileSet = new Set(chapterFileKeys(prev)); | |
| } | |
| const shared = []; | |
| for (const f of chFiles) { | |
| if (prevFileSet.has(f)) shared.push(f); | |
| } | |
| if (shared.length > 0) { | |
| sharedFiles[j] = shared; | |
| if ( | |
| shared.length >= Math.min(2, chFiles.length) || | |
| shared.length >= chFiles.length * 0.5 | |
| ) { | |
| continuesRefs.add(j); | |
| } | |
| } | |
| if (prev.outcome === "struggling" || prev.errors >= 2) { | |
| if (shared.length > 0) { | |
| fixesRefs.add(j); | |
| // Remove from continuesFrom to avoid double-labeling | |
| continuesRefs.delete(j); | |
| } | |
| } | |
| } | |
| if ( | |
| continuesRefs.size === 0 && | |
| fixesRefs.size === 0 && | |
| Object.keys(sharedFiles).length === 0 | |
| ) { | |
| ch.deps = EMPTY_DEPS; | |
| } else { | |
| ch.deps = { | |
| continuesFrom: Array.from(continuesRefs), | |
| fixesFrom: Array.from(fixesRefs), | |
| sharedFiles, | |
| }; | |
| } | |
| } | |
| const chainOf = new Array(chapters.length).fill(-1); | |
| let chainCount = 0; | |
| for (let i = 0; i < chapters.length; i++) { | |
| const deps = chapters[i].deps; | |
| const continuesFrom = deps.continuesFrom; | |
| const fixesFrom = deps.fixesFrom; | |
| if (continuesFrom.length === 0 && fixesFrom.length === 0) continue; | |
| let existingChain = -1; | |
| for (let r = 0; r < continuesFrom.length; r++) { | |
| const ref = continuesFrom[r]; | |
| if (chainOf[ref] >= 0) { | |
| existingChain = chainOf[ref]; | |
| break; | |
| } | |
| } | |
| if (existingChain < 0) { | |
| for (let r = 0; r < fixesFrom.length; r++) { | |
| const ref = fixesFrom[r]; | |
| if (chainOf[ref] >= 0) { | |
| existingChain = chainOf[ref]; | |
| break; | |
| } | |
| } | |
| } | |
| if (existingChain >= 0) { | |
| chainOf[i] = existingChain; | |
| } else { | |
| const seed = continuesFrom.length > 0 ? continuesFrom[0] : fixesFrom[0]; | |
| chainOf[seed] = chainCount; | |
| chainOf[i] = chainCount; | |
| chainCount++; | |
| } | |
| } | |
| const chainsSet = new Set(); | |
| let chaptersInChains = 0; | |
| for (let i = 0; i < chainOf.length; i++) { | |
| if (chainOf[i] >= 0) { | |
| chainsSet.add(chainOf[i]); | |
| chaptersInChains++; | |
| } | |
| } | |
| const workflowChainCount = chainsSet.size; | |
| if (chapters.length > 0) { | |
| chapters._depSummary = { chains: workflowChainCount, chaptersInChains }; | |
| } | |
| for (const ch of chapters) { | |
| const charBits = (ch._charBits = new Uint32Array(8)); | |
| const bigrams = (ch._tokenBigrams = new Set()); | |
| const extraChars = (ch._extraChars = new Set()); | |
| ch._promptLower = cacheLowerAndToken(charBits, bigrams, extraChars, ch.prompt); | |
| ch._lastAssistantTextLower = cacheLowerAndToken( | |
| charBits, | |
| bigrams, | |
| extraChars, | |
| ch.lastAssistantText, | |
| ); | |
| const fileKeys = chapterFileKeys(ch); | |
| const fileKeysLower = new Array(fileKeys.length); | |
| for (let fi = 0; fi < fileKeys.length; fi++) { | |
| const lower = (fileKeys[fi] || "").toLowerCase(); | |
| fileKeysLower[fi] = lower; | |
| addChapterTokens(charBits, bigrams, extraChars, lower); | |
| } | |
| ch._fileKeysLower = fileKeysLower; | |
| for (const cmd of ch.commands) { | |
| cmd._cmdLower = cacheLowerAndToken(charBits, bigrams, extraChars, cmd.cmd); | |
| cmd._outputLower = cacheLowerAndToken(charBits, bigrams, extraChars, cmd.output); | |
| } | |
| for (const s of ch.searches) { | |
| s._queryLower = cacheLowerAndToken(charBits, bigrams, extraChars, s.query); | |
| } | |
| for (const a of ch.agents) { | |
| a._descriptionLower = cacheLowerAndToken(charBits, bigrams, extraChars, a.description); | |
| a._promptLower = cacheLowerAndToken(charBits, bigrams, extraChars, a.prompt); | |
| } | |
| for (const d of ch.diffs) { | |
| d._pathLower = cacheLowerAndToken(charBits, bigrams, extraChars, d.path); | |
| if (d.diffInfo) { | |
| d.diffInfo._oldStrLower = cacheLowerAndToken(charBits, bigrams, extraChars, d.diffInfo.oldStr); | |
| d.diffInfo._newStrLower = cacheLowerAndToken(charBits, bigrams, extraChars, d.diffInfo.newStr); | |
| d.diffInfo._contentLower = cacheLowerAndToken( | |
| charBits, | |
| bigrams, | |
| extraChars, | |
| d.diffInfo.content, | |
| ); | |
| } | |
| } | |
| for (const g of ch.gitOps) { | |
| g._messageLower = cacheLowerAndToken(charBits, bigrams, extraChars, g.message); | |
| g._branchLower = cacheLowerAndToken(charBits, bigrams, extraChars, g.branch); | |
| g._hashLower = cacheLowerAndToken(charBits, bigrams, extraChars, g.hash); | |
| g._cmdLower = cacheLowerAndToken(charBits, bigrams, extraChars, g.cmd); | |
| } | |
| for (const w of ch.webOps) { | |
| w._urlLower = cacheLowerAndToken(charBits, bigrams, extraChars, w.url); | |
| w._queryLower = cacheLowerAndToken(charBits, bigrams, extraChars, w.query); | |
| w._pageTitleLower = cacheLowerAndToken(charBits, bigrams, extraChars, w.pageTitle); | |
| if (w.results) { | |
| for (const r of w.results) { | |
| r._titleLower = cacheLowerAndToken(charBits, bigrams, extraChars, r.title); | |
| r._urlLower = cacheLowerAndToken(charBits, bigrams, extraChars, r.url); | |
| } | |
| } | |
| } | |
| ch._thinkingLower = ch.thinking.map((t) => { | |
| const lower = (t || "").toLowerCase(); | |
| addChapterTokens(charBits, bigrams, extraChars, lower); | |
| return lower; | |
| }); | |
| for (const m of ch.mcpOps) { | |
| m._serverLower = cacheLowerAndToken(charBits, bigrams, extraChars, m.server); | |
| m._toolLower = cacheLowerAndToken(charBits, bigrams, extraChars, m.tool); | |
| m._rawNameLower = cacheLowerAndToken(charBits, bigrams, extraChars, m.rawName); | |
| const params = m.params; | |
| const paramVals = []; | |
| if (params) { | |
| for (const k in params) { | |
| if (Object.prototype.hasOwnProperty.call(params, k)) { | |
| const pv = String(params[k] || "").toLowerCase(); | |
| paramVals.push(pv); | |
| addChapterTokens(charBits, bigrams, extraChars, pv); | |
| } | |
| } | |
| } | |
| m._paramValsLower = paramVals; | |
| } | |
| } | |
| } | |
| function buildChapters() { | |
| const chapters = buildSessionChapters({ events, startTime: session.startTime }); | |
| enrichChaptersEfficiency(chapters); | |
| enrichChaptersForRender(chapters); | |
| return chapters; | |
| } | |
| let chaptersCache = null; | |
| function getChapters() { | |
| if (!chaptersCache) chaptersCache = buildChapters(); | |
| return chaptersCache; | |
| } | |
| let activeToolFilters = new Set(); | |
| let searchQuery = ''; | |
| let _searchQueryLowerSrc = null; | |
| let _searchQueryLowerCache = ''; | |
| let wasteFilterActive = false; | |
| function chapterMatchesFilter(ch) { | |
| if (wasteFilterActive) { | |
| if (!ch.efficiency || !ch.efficiency.isWasteful) return false; | |
| } | |
| if (activeToolFilters.size > 0) { | |
| const hasMatch = chapterToolKeys(ch).some(t => activeToolFilters.has(t)); | |
| if (!hasMatch) return false; | |
| } | |
| if (searchQuery) { | |
| if (searchQuery !== _searchQueryLowerSrc) { | |
| _searchQueryLowerSrc = searchQuery; | |
| _searchQueryLowerCache = searchQuery.toLowerCase(); | |
| } | |
| const q = _searchQueryLowerCache; | |
| // Early-exit for short queries: if query not in token index, chapter definitely doesn't match | |
| if (q.length <= 2) { | |
| if (q.length === 1) { | |
| const c = q.charCodeAt(0); | |
| if (c < 256) { | |
| if (!(ch._charBits[c >>> 5] & (1 << (c & 31)))) return false; | |
| } else if (!ch._extraChars.has(q)) { | |
| return false; | |
| } | |
| } else { | |
| const key = (q.charCodeAt(0) << 16) | q.charCodeAt(1); | |
| if (!ch._tokenBigrams.has(key)) return false; | |
| } | |
| } | |
| if (ch._promptLower.includes(q)) return true; | |
| if (ch._lastAssistantTextLower.includes(q)) return true; | |
| for (const cmd of ch.commands) { | |
| if (cmd._cmdLower.includes(q)) return true; | |
| if (cmd._outputLower && cmd._outputLower.includes(q)) return true; | |
| } | |
| for (const path of ch._fileKeysLower) { | |
| if (path.includes(q)) return true; | |
| } | |
| for (const s of ch.searches) { | |
| if (s._queryLower.includes(q)) return true; | |
| } | |
| for (const a of ch.agents) { | |
| if (a._descriptionLower.includes(q)) return true; | |
| if (a._promptLower.includes(q)) return true; | |
| } | |
| for (const d of ch.diffs) { | |
| if (d._pathLower.includes(q)) return true; | |
| if (d.diffInfo._oldStr && d.diffInfo._oldStrLower.includes(q)) return true; | |
| if (d.diffInfo._newStr && d.diffInfo._newStrLower.includes(q)) return true; | |
| if (d.diffInfo._content && d.diffInfo._contentLower.includes(q)) return true; | |
| } | |
| for (const g of ch.gitOps) { | |
| if (g._messageLower && g._messageLower.includes(q)) return true; | |
| if (g._branchLower && g._branchLower.includes(q)) return true; | |
| if (g._hashLower && g._hashLower.includes(q)) return true; | |
| if (g._cmdLower && g._cmdLower.includes(q)) return true; | |
| } | |
| for (const w of ch.webOps) { | |
| if (w._urlLower && w._urlLower.includes(q)) return true; | |
| if (w._queryLower && w._queryLower.includes(q)) return true; | |
| if (w._pageTitleLower && w._pageTitleLower.includes(q)) return true; | |
| if (w.results) { | |
| for (const r of w.results) { | |
| if (r._titleLower.includes(q)) return true; | |
| if (r._urlLower.includes(q)) return true; | |
| } | |
| } | |
| } | |
| for (const t of ch._thinkingLower) { | |
| if (t.includes(q)) return true; | |
| } | |
| for (const m of ch.mcpOps) { | |
| if (m._serverLower.includes(q)) return true; | |
| if (m._toolLower.includes(q)) return true; | |
| if (m._rawNameLower.includes(q)) return true; | |
| for (const pv of m._paramValsLower) { | |
| if (pv.includes(q)) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| return true; | |
| } | |
| function renderFilterBar() { | |
| const chapters = getChapters(); | |
| const toolSet = {}; | |
| let wastefulCount = 0; | |
| for (const ch of chapters) { | |
| if (ch.efficiency && ch.efficiency.isWasteful) wastefulCount++; | |
| for (const t of chapterToolKeys(ch)) { | |
| toolSet[t] = (toolSet[t] || 0) + ch.toolCounts[t]; | |
| } | |
| } | |
| const priority = ['Bash', 'Read', 'Edit', 'Write', 'Agent', 'Grep', 'Skill', 'WebSearch', 'WebFetch']; | |
| const sortedByCount = []; | |
| for (const name in toolSet) { | |
| if (Object.prototype.hasOwnProperty.call(toolSet, name)) sortedByCount.push(name); | |
| } | |
| sortedByCount.sort((a, b) => toolSet[b] - toolSet[a]); | |
| const chipNames = []; | |
| for (const p of priority) { | |
| if (toolSet[p]) chipNames.push(p); | |
| } | |
| for (const name of sortedByCount) { | |
| if (!chipNames.includes(name) && chipNames.length < 10) chipNames.push(name); | |
| } | |
| const bar = h('div', { className: 'filter-bar' }); | |
| const toolsWrap = h('div', { className: 'filter-tools' }); | |
| for (const name of chipNames) { | |
| const isActive = activeToolFilters.has(name); | |
| const chipLabel = fmtMcpName(name); | |
| const chip = h('span', { | |
| className: 'filter-chip' + (isActive ? ' active' : ''), | |
| onClick: (ev) => { | |
| ev.stopPropagation(); | |
| if (activeToolFilters.has(name)) activeToolFilters.delete(name); | |
| else activeToolFilters.add(name); | |
| activeToolPerfFilter = null; | |
| var perfRows = document.querySelectorAll('.tool-perf-row'); | |
| perfRows.forEach(function(r) { r.classList.remove('active'); }); | |
| applyFilters(); | |
| } | |
| }, chipLabel); | |
| chip.dataset.tool = name; | |
| toolsWrap.appendChild(chip); | |
| } | |
| if (wastefulCount > 0) { | |
| const wasteChip = h('span', { | |
| className: 'filter-chip' + (wasteFilterActive ? ' active' : ''), | |
| onClick: (ev) => { | |
| ev.stopPropagation(); | |
| wasteFilterActive = !wasteFilterActive; | |
| applyFilters(); | |
| wasteChip.classList.toggle('active', wasteFilterActive); | |
| }, | |
| style: 'border-color: rgba(232,164,76,0.3);' | |
| }, '\u26a0 wasteful ' + wastefulCount); | |
| toolsWrap.appendChild(wasteChip); | |
| } | |
| bar.appendChild(toolsWrap); | |
| const searchInput = h('input', { | |
| className: 'filter-search', | |
| type: 'text', | |
| placeholder: 'search chapters...', | |
| }); | |
| searchInput.value = searchQuery; | |
| searchInput.addEventListener('input', function() { | |
| searchQuery = this.value; | |
| applyFilters(); | |
| }); | |
| bar.appendChild(searchInput); | |
| const countEl = h('span', { className: 'filter-count', id: 'filter-count' }); | |
| bar.appendChild(countEl); | |
| return bar; | |
| } | |
| function applyFilters() { | |
| const chapters = getChapters(); | |
| let visible = 0; | |
| for (let i = 0; i < chapters.length; i++) { | |
| const el = document.getElementById('chapter-' + i); | |
| if (!el) continue; | |
| const matches = chapterMatchesFilter(chapters[i]); | |
| if (matches) { | |
| el.classList.remove('filter-hidden'); | |
| visible++; | |
| } else { | |
| el.classList.add('filter-hidden'); | |
| } | |
| } | |
| const countEl = document.getElementById('filter-count'); | |
| if (countEl) { | |
| const hasFilter = activeToolFilters.size > 0 || searchQuery || wasteFilterActive; | |
| countEl.textContent = hasFilter ? visible + '/' + chapters.length : ''; | |
| } | |
| const chips = document.querySelectorAll('.filter-chip'); | |
| chips.forEach(function(chip) { | |
| if (chip.textContent.indexOf('wasteful') !== -1) return; | |
| var toolKey = chip.dataset.tool || chip.textContent; | |
| if (activeToolFilters.has(toolKey)) chip.classList.add('active'); | |
| else chip.classList.remove('active'); | |
| }); | |
| updateUrl(); | |
| } | |
| function renderChapters() { | |
| const chapters = getChapters(); | |
| const wrap = h('div', { className: 'chapters' }); | |
| chapters.forEach((ch, idx) => { | |
| const expanded = expandedSet.has('ch' + idx); | |
| const outcome = ch.outcome || 'clean'; | |
| const outcomeCls = outcome === 'struggling' ? 'error' : outcome; | |
| const outcomeLabel = outcome === 'clean' ? 'clean' | |
| : outcome === 'corrected' ? 'corrected' | |
| : outcome === 'struggling' ? 'struggling' : 'clean'; | |
| const el = h('div', { | |
| id: 'chapter-' + idx, | |
| className: 'chapter' + (expanded ? ' expanded' : ''), | |
| onClick: () => { | |
| if (expanded) { | |
| expandedSet.delete('ch' + idx); | |
| clearChapterHash(); | |
| } else { | |
| expandedSet.add('ch' + idx); | |
| setChapterHash(idx); | |
| } | |
| render(); | |
| } | |
| }); | |
| const toolChips = h('div', { className: 'chapter-tools' }); | |
| const sortedTools = chapterToolKeys(ch).slice() | |
| .sort((a, b) => ch.toolCounts[b] - ch.toolCounts[a]); | |
| for (const name of sortedTools) { | |
| const count = ch.toolCounts[name]; | |
| const cls = ['Bash','Read','Edit','Write','Agent'].includes(name) ? name : 'other'; | |
| const chipLabel = fmtMcpName(name); | |
| toolChips.appendChild(h('span', { className: 'ch-tool ' + cls }, chipLabel + ' ' + count)); | |
| } | |
| const startTime = ch.timestamp ? formatTime(ch.timestamp) : ''; | |
| const endMs = new Date(ch.endTimestamp).getTime(); | |
| const startMs = new Date(ch.timestamp).getTime(); | |
| const dur = endMs - startMs; | |
| const patternBadges = []; | |
| if (ch.retries > 0) { | |
| patternBadges.push(h('span', { className: 'chapter-pattern-badge retry' }, | |
| '\u21bb ' + ch.retries + ' retr' + (ch.retries === 1 ? 'y' : 'ies'))); | |
| } | |
| if (ch.selfCorrections > 0) { | |
| patternBadges.push(h('span', { className: 'chapter-pattern-badge correction' }, | |
| '\u2714 ' + ch.selfCorrections + ' self-fix' + (ch.selfCorrections === 1 ? '' : 'es'))); | |
| } | |
| if (ch.efficiency && ch.efficiency.isWasteful) { | |
| patternBadges.push(h('span', { className: 'chapter-waste-badge' }, | |
| '\u26a0 waste' + (ch.efficiency.wasteTokens > 0 ? ' ~' + fmtTokens(ch.efficiency.wasteTokens) : ''))); | |
| } | |
| if (ch.deps) { | |
| if (ch.deps.fixesFrom.length > 0) { | |
| var fixRef = ch.deps.fixesFrom[0]; | |
| var badge = h('span', { | |
| className: 'chapter-dep-badge dep-fix', | |
| title: 'Fixes issues from chapter ' + (fixRef + 1), | |
| onClick: function(ev) { | |
| ev.stopPropagation(); | |
| var target = document.getElementById('chapter-' + fixRef); | |
| if (target) target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| }, '\u2192 fixes ch ' + (fixRef + 1)); | |
| patternBadges.push(badge); | |
| } else if (ch.deps.continuesFrom.length > 0) { | |
| var contRef = ch.deps.continuesFrom[ch.deps.continuesFrom.length - 1]; | |
| var badge = h('span', { | |
| className: 'chapter-dep-badge', | |
| title: 'Continues work from chapter ' + (contRef + 1), | |
| onClick: function(ev) { | |
| ev.stopPropagation(); | |
| var target = document.getElementById('chapter-' + contRef); | |
| if (target) target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| }, '\u2192 from ch ' + (contRef + 1)); | |
| patternBadges.push(badge); | |
| } | |
| } | |
| const dotCls = 'chapter-outcome-dot dot-' + outcome; | |
| const head = h('div', { className: 'chapter-head' }, | |
| h('span', { className: 'chapter-num' }, | |
| String(idx + 1), | |
| h('span', { className: dotCls }) | |
| ), | |
| h('div', { className: 'chapter-body' }, | |
| h('div', { className: 'chapter-prompt' }, | |
| h('span', { className: 'chapter-prompt-text' }, ch.prompt) | |
| ), | |
| h('div', { className: 'chapter-meta' }, | |
| toolChips, | |
| ch.gitOps.length > 0 | |
| ? (function() { | |
| var commits = countGitOpsOfType(ch.gitOps, 'commit'); | |
| var label = commits > 0 ? '● ' + commits + ' commit' + (commits > 1 ? 's' : '') : '⑃ ' + ch.gitOps.length + ' git op' + (ch.gitOps.length > 1 ? 's' : ''); | |
| return h('span', { className: 'chapter-git-badge' }, label); | |
| })() | |
| : null, | |
| ch.mcpOps.length > 0 | |
| ? (function() { | |
| var servers = {}; | |
| for (var mci = 0; mci < ch.mcpOps.length; mci++) { | |
| var sv = ch.mcpOps[mci].server; | |
| servers[sv] = (servers[sv] || 0) + 1; | |
| } | |
| var svNames = Object.keys(servers); | |
| var label = svNames.length === 1 | |
| ? svNames[0].replace(/_/g, ' ') + ' \u00d7' + ch.mcpOps.length | |
| : ch.mcpOps.length + ' MCP'; | |
| return h('span', { className: 'chapter-mcp-badge' }, '\u2699 ' + label); | |
| })() | |
| : null, | |
| patternBadges.length > 0 | |
| ? (function() { | |
| var wrap = h('span', { className: 'chapter-patterns' }); | |
| patternBadges.forEach(function(b) { wrap.appendChild(b); }); | |
| return wrap; | |
| })() | |
| : null, | |
| h('span', { className: 'chapter-outcome ' + outcomeCls }, outcomeLabel), | |
| ) | |
| ), | |
| h('div', { className: 'chapter-right' }, | |
| h('div', { className: 'chapter-time' }, startTime), | |
| h('div', { className: 'chapter-turns' }, | |
| ch.turns + ' turn' + (ch.turns !== 1 ? 's' : '') + | |
| (dur > 1000 ? ' · ' + formatDuration(dur) : '') | |
| ), | |
| (ch.tokens.input > 0 || ch.tokens.output > 0) | |
| ? h('div', { className: 'chapter-tokens' }, | |
| fmtTokens(ch.tokens.input + ch.tokens.output) + ' tok' + | |
| (ch.tokens.cacheHit > 0 && ch.tokens.input > 0 ? ' · ' + fmtPct(ch.tokens.cacheHit / ch.tokens.input * 100) + ' cache' : '') | |
| ) | |
| : null, | |
| ch.thinking.length > 0 | |
| ? h('div', { className: 'chapter-thinking-badge' }, | |
| '🧠 ' + countWordsInStrings(ch.thinking).toLocaleString() + ' words' | |
| ) | |
| : null, | |
| (function() { | |
| var link = h('span', { | |
| className: 'chapter-permalink', | |
| title: 'Copy link to this chapter', | |
| onClick: function(ev) { | |
| ev.stopPropagation(); | |
| var url = getPermalink(idx); | |
| if (navigator.clipboard && navigator.clipboard.writeText) { | |
| navigator.clipboard.writeText(url).then(function() { | |
| link.textContent = '\u2713'; | |
| link.classList.add('copied'); | |
| setTimeout(function() { link.textContent = '\u26d3'; link.classList.remove('copied'); }, 1500); | |
| }); | |
| } else { | |
| var tmp = document.createElement('input'); | |
| tmp.value = url; | |
| document.body.appendChild(tmp); | |
| tmp.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(tmp); | |
| link.textContent = '\u2713'; | |
| link.classList.add('copied'); | |
| setTimeout(function() { link.textContent = '\u26d3'; link.classList.remove('copied'); }, 1500); | |
| } | |
| } | |
| }, '\u26d3'); | |
| return link; | |
| })(), | |
| ) | |
| ); | |
| el.appendChild(head); | |
| const detail = h('div', { className: 'chapter-detail' }); | |
| if (expanded) { | |
| buildChapterDetail(ch, idx, detail); | |
| } | |
| el.appendChild(detail); | |
| wrap.appendChild(el); | |
| }); | |
| return wrap; | |
| } | |
| function buildChapterDetail(ch, _idx, detail) { | |
| var outcome = ch.outcome || 'clean'; | |
| if (ch.retries > 0 || ch.selfCorrections > 0) { | |
| var qDiv = h('div', { className: 'chapter-quality' }); | |
| var qLabel = outcome === 'struggling' ? 'struggling' | |
| : outcome === 'corrected' ? 'self-corrected' : 'minor issues'; | |
| qDiv.appendChild(h('div', { className: 'chapter-quality-label ' + outcome }, qLabel)); | |
| if (ch.retryGroups.length > 0) { | |
| var retryDiv = h('div', { className: 'chapter-quality-retries' }); | |
| for (var ri = 0; ri < ch.retryGroups.length && ri < 4; ri++) { | |
| var rg = ch.retryGroups[ri]; | |
| retryDiv.appendChild(h('span', { className: 'chapter-retry-chip' }, | |
| rg.tool + ' \u00d7' + rg.count + (rg.input ? ' (' + rg.input.slice(0, 30) + (rg.input.length > 30 ? '...' : '') + ')' : ''))); | |
| } | |
| qDiv.appendChild(retryDiv); | |
| } | |
| if (ch.selfCorrections > 0) { | |
| qDiv.appendChild(h('div', { className: 'chapter-quality-corrections' }, | |
| '\u2714 ' + ch.selfCorrections + ' error' + (ch.selfCorrections === 1 ? '' : 's') + ' detected and resolved')); | |
| } | |
| detail.appendChild(qDiv); | |
| } | |
| if (ch.deps && (ch.deps.continuesFrom.length > 0 || ch.deps.fixesFrom.length > 0)) { | |
| var depDiv = h('div', { className: 'chapter-dep-section' }); | |
| depDiv.appendChild(h('div', { className: 'chapter-dep-section-title' }, 'related chapters')); | |
| if (ch.deps.fixesFrom.length > 0) { | |
| for (var fi = 0; fi < ch.deps.fixesFrom.length; fi++) { | |
| var ref = ch.deps.fixesFrom[fi]; | |
| var row = document.createElement('div'); | |
| row.style.marginBottom = '3px'; | |
| var link = h('span', { | |
| className: 'chapter-dep-link', | |
| onClick: (function(r) { return function(ev) { | |
| ev.stopPropagation(); | |
| var target = document.getElementById('chapter-' + r); | |
| if (target) target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| }; })(ref) | |
| }, '\u2714 fixes ch ' + (ref + 1)); | |
| row.appendChild(link); | |
| var shared = ch.deps.sharedFiles[ref] || []; | |
| if (shared.length > 0) { | |
| row.appendChild(h('span', { className: 'chapter-dep-shared-files' }, | |
| '(' + shared.slice(0, 3).join(', ') + (shared.length > 3 ? ' +' + (shared.length - 3) : '') + ')')); | |
| } | |
| depDiv.appendChild(row); | |
| } | |
| } | |
| if (ch.deps.continuesFrom.length > 0) { | |
| for (var ci2 = 0; ci2 < ch.deps.continuesFrom.length; ci2++) { | |
| var ref2 = ch.deps.continuesFrom[ci2]; | |
| var row2 = document.createElement('div'); | |
| row2.style.marginBottom = '3px'; | |
| var link2 = h('span', { | |
| className: 'chapter-dep-link', | |
| onClick: (function(r) { return function(ev) { | |
| ev.stopPropagation(); | |
| var target = document.getElementById('chapter-' + r); | |
| if (target) target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| }; })(ref2) | |
| }, '\u2192 continues ch ' + (ref2 + 1)); | |
| row2.appendChild(link2); | |
| var shared2 = ch.deps.sharedFiles[ref2] || []; | |
| if (shared2.length > 0) { | |
| row2.appendChild(h('span', { className: 'chapter-dep-shared-files' }, | |
| '(' + shared2.slice(0, 3).join(', ') + (shared2.length > 3 ? ' +' + (shared2.length - 3) : '') + ')')); | |
| } | |
| depDiv.appendChild(row2); | |
| } | |
| } | |
| detail.appendChild(depDiv); | |
| } | |
| if (ch.efficiency && (ch.efficiency.isWasteful || ch.efficiency.score < 70)) { | |
| var effDiv = h('div', { className: 'chapter-efficiency' }); | |
| var effHeader = h('div', { className: 'chapter-efficiency-header' }); | |
| var scoreLabel = ch.efficiency.score >= 70 ? 'moderate efficiency' | |
| : ch.efficiency.score >= 40 ? 'low efficiency' : 'wasteful'; | |
| effHeader.appendChild(h('span', { className: 'chapter-efficiency-label' }, scoreLabel)); | |
| effHeader.appendChild(h('span', { className: 'chapter-efficiency-score' }, ch.efficiency.score + '/100')); | |
| effDiv.appendChild(effHeader); | |
| var barWrap = h('div', { className: 'chapter-efficiency-bar-wrap' }); | |
| var barCls = ch.efficiency.score >= 70 ? 'eff-good' : ch.efficiency.score >= 40 ? 'eff-ok' : 'eff-bad'; | |
| var bar = h('div', { className: 'chapter-efficiency-bar ' + barCls }); | |
| bar.style.width = Math.max(2, ch.efficiency.score) + '%'; | |
| barWrap.appendChild(bar); | |
| effDiv.appendChild(barWrap); | |
| var metricsDiv = h('div', { className: 'chapter-efficiency-metrics' }); | |
| if (ch.efficiency.tokPerFile > 0) { | |
| var valCls = ch.efficiency.tokPerFile > 500000 ? 'val-bad' : ''; | |
| metricsDiv.appendChild(h('span', { className: 'chapter-efficiency-metric' }, | |
| 'tokens/file: ', | |
| h('span', { className: 'chapter-efficiency-metric-value ' + valCls }, fmtTokens(ch.efficiency.tokPerFile)) | |
| )); | |
| } | |
| if (ch.efficiency.tokPerCommit > 0) { | |
| var valCls2 = ch.efficiency.tokPerCommit > 1000000 ? 'val-bad' : ''; | |
| metricsDiv.appendChild(h('span', { className: 'chapter-efficiency-metric' }, | |
| 'tokens/commit: ', | |
| h('span', { className: 'chapter-efficiency-metric-value ' + valCls2 }, fmtTokens(ch.efficiency.tokPerCommit)) | |
| )); | |
| } | |
| if (ch.efficiency.errorTokens > 0) { | |
| metricsDiv.appendChild(h('span', { className: 'chapter-efficiency-metric' }, | |
| 'error tokens: ', | |
| h('span', { className: 'chapter-efficiency-metric-value val-bad' }, '~' + fmtTokens(ch.efficiency.errorTokens)) | |
| )); | |
| } | |
| if (ch.efficiency.wasteTokens > 0) { | |
| metricsDiv.appendChild(h('span', { className: 'chapter-efficiency-metric' }, | |
| 'est. wasted: ', | |
| h('span', { className: 'chapter-efficiency-metric-value val-bad' }, '~' + fmtTokens(ch.efficiency.wasteTokens)) | |
| )); | |
| } | |
| if (metricsDiv.childNodes.length > 0) effDiv.appendChild(metricsDiv); | |
| if (ch.efficiency.wasteReasons.length > 0) { | |
| var reasonsDiv = h('div', { className: 'chapter-efficiency-reasons' }); | |
| for (var wr = 0; wr < ch.efficiency.wasteReasons.length; wr++) { | |
| reasonsDiv.appendChild(h('span', { className: 'chapter-efficiency-reason' }, ch.efficiency.wasteReasons[wr])); | |
| } | |
| effDiv.appendChild(reasonsDiv); | |
| } | |
| detail.appendChild(effDiv); | |
| } | |
| var fileKeys = chapterFileKeys(ch); | |
| if (fileKeys.length) { | |
| var filesDiv = h('div', { className: 'chapter-files' }); | |
| filesDiv.appendChild(h('div', { className: 'chapter-files-label' }, 'files')); | |
| for (var fei = 0; fei < fileKeys.length; fei++) { | |
| var path = fileKeys[fei]; | |
| var info = ch.files[path]; | |
| var opCount = {}; | |
| info.ops.forEach(function(o) { opCount[o] = (opCount[o] || 0) + 1; }); | |
| var opStr = ''; | |
| var opFirst = true; | |
| for (var op in opCount) { | |
| if (!Object.prototype.hasOwnProperty.call(opCount, op)) continue; | |
| if (!opFirst) opStr += ', '; | |
| opFirst = false; | |
| var opN = opCount[op]; | |
| opStr += op.toLowerCase() + (opN > 1 ? ' \u00d7' + opN : ''); | |
| } | |
| filesDiv.appendChild(h('div', { className: 'chapter-file' }, | |
| path, | |
| h('span', { className: 'chapter-file-ops' }, opStr) | |
| )); | |
| if (info.output) { | |
| filesDiv.appendChild(h('div', { className: 'chapter-output' }, info.output)); | |
| } | |
| } | |
| detail.appendChild(filesDiv); | |
| } | |
| if (ch.gitOps.length > 0) { | |
| var gitDiv = h('div', { className: 'chapter-git-ops' }); | |
| gitDiv.appendChild(h('div', { className: 'chapter-files-label' }, 'git')); | |
| for (var gi = 0; gi < ch.gitOps.length; gi++) { | |
| var op = ch.gitOps[gi]; | |
| var iconCls = 'chapter-git-op-icon '; | |
| var icon = ''; | |
| if (op.type === 'commit') { iconCls += 'commit'; icon = '\u25cf'; } | |
| else if (op.type === 'push') { iconCls += 'push'; icon = '\u2191'; } | |
| else if (op.type === 'branch-create' || op.type === 'branch-switch') { iconCls += 'branch'; icon = '\u2443'; } | |
| else if (op.type === 'merge' || op.type === 'rebase') { iconCls += 'merge'; icon = '\u2442'; } | |
| else { icon = '\u00b7'; } | |
| var desc = ''; | |
| if (op.type === 'commit') { | |
| desc = (op.hash ? op.hash.slice(0, 7) + ' ' : '') + (op.message || '(no message)'); | |
| } else if (op.type === 'push') { | |
| desc = 'push' + (op.remote ? ' ' + op.remote : '') + (op.branch ? ' ' + op.branch : '') + (op.tags ? ' --tags' : ''); | |
| } else if (op.type === 'branch-create') { | |
| desc = 'create branch ' + (op.branch || ''); | |
| } else if (op.type === 'branch-switch') { | |
| desc = 'switch to ' + (op.branch || ''); | |
| } else if (op.type === 'merge') { | |
| desc = 'merge ' + (op.branch || ''); | |
| } else if (op.type === 'rebase') { | |
| desc = 'rebase onto ' + (op.branch || ''); | |
| } else if (op.type === 'tag') { | |
| desc = 'tag ' + (op.tag || ''); | |
| } else if (op.type === 'stash') { | |
| desc = 'stash'; | |
| } else { | |
| desc = op.type; | |
| } | |
| gitDiv.appendChild(h('div', { className: 'chapter-git-op' }, | |
| h('span', { className: iconCls }, icon), | |
| desc | |
| )); | |
| } | |
| detail.appendChild(gitDiv); | |
| } | |
| if (ch.agents.length) { | |
| var agentsDiv = h('div', { className: 'chapter-agents' }); | |
| agentsDiv.appendChild(h('div', { className: 'chapter-files-label' }, 'subagents')); | |
| for (var ai = 0; ai < ch.agents.length; ai++) { | |
| var a = ch.agents[ai]; | |
| var statusCls = a.isError ? 'fail' : (a.completed ? 'ok' : 'pending'); | |
| var statusIcon = a.isError ? '\u2717' : (a.completed ? '\u2713' : '\u25e6'); | |
| var typeBadge = a.subagentType ? h('span', { className: 'chapter-agent-type' }, a.subagentType) : null; | |
| var toolBadge = a.toolName !== 'Agent' ? h('span', { className: 'chapter-agent-type' }, a.toolName) : null; | |
| agentsDiv.appendChild(h('div', { className: 'chapter-agent' }, | |
| h('span', { className: 'chapter-agent-status ' + statusCls }, statusIcon), | |
| h('span', { className: 'chapter-agent-desc' }, a.description || '(unnamed)'), | |
| typeBadge, | |
| toolBadge | |
| )); | |
| if (a.prompt) { | |
| agentsDiv.appendChild(h('div', { className: 'chapter-agent-prompt' }, a.prompt.slice(0, 200))); | |
| } | |
| if (a.result && a.completed) { | |
| agentsDiv.appendChild(h('div', { className: 'chapter-output' }, joinFirstLines(a.result, 4))); | |
| } | |
| } | |
| detail.appendChild(agentsDiv); | |
| } | |
| if (ch.diffs.length) { | |
| var diffsDiv = h('div', { className: 'chapter-diffs' }); | |
| diffsDiv.appendChild(h('div', { className: 'chapter-files-label' }, 'changes')); | |
| for (var di = 0; di < ch.diffs.length && di < 6; di++) { | |
| var d = ch.diffs[di]; | |
| var label = d.name === 'Write' ? 'write' : 'edit'; | |
| diffsDiv.appendChild(h('div', { className: 'chapter-diff-header' }, | |
| h('span', { className: 'chapter-diff-path' }, d.path), | |
| h('span', { className: 'chapter-diff-op' }, label) | |
| )); | |
| if (d.name === 'Edit' && d.diffInfo.oldStr) { | |
| var diffBlock = h('div', { className: 'chapter-diff-block' }); | |
| diffBlock.appendChild(h('div', { className: 'chapter-diff-del' }, d.diffInfo.oldStr)); | |
| if (d.diffInfo.newStr) { | |
| diffBlock.appendChild(h('div', { className: 'chapter-diff-add' }, d.diffInfo.newStr)); | |
| } | |
| diffsDiv.appendChild(diffBlock); | |
| } else if (d.name === 'Write' && d.diffInfo.content) { | |
| diffsDiv.appendChild(h('div', { className: 'chapter-diff-block' }, | |
| h('div', { className: 'chapter-diff-add' }, joinFirstLines(d.diffInfo.content, 6)) | |
| )); | |
| } | |
| } | |
| if (ch.diffs.length > 6) { | |
| diffsDiv.appendChild(h('div', { className: 'chapter-diff-header' }, | |
| h('span', { className: 'chapter-diff-op' }, '\u2026 ' + (ch.diffs.length - 6) + ' more changes') | |
| )); | |
| } | |
| detail.appendChild(diffsDiv); | |
| } | |
| if (ch.searches.length) { | |
| var searchDiv = h('div', { className: 'chapter-commands' }); | |
| searchDiv.appendChild(h('div', { className: 'chapter-files-label' }, 'searches')); | |
| for (var si = 0; si < ch.searches.length && si < 6; si++) { | |
| var s = ch.searches[si]; | |
| var sLabel = s.matches > 0 ? s.matches + ' match' + (s.matches !== 1 ? 'es' : '') : (s.ok ? 'no matches' : 'error'); | |
| searchDiv.appendChild(h('div', { className: 'chapter-cmd' }, | |
| h('span', { className: 'chapter-cmd-status ' + (s.ok ? 'ok' : 'fail') }, s.ok ? '\u2713' : '\u2717'), | |
| s.query.slice(0, 100), | |
| h('span', { className: 'chapter-search-count' }, sLabel) | |
| )); | |
| if (s.output) { | |
| searchDiv.appendChild(h('div', { className: 'chapter-output' }, joinFirstLines(s.output, 3))); | |
| } | |
| } | |
| detail.appendChild(searchDiv); | |
| } | |
| if (ch.webOps.length) { | |
| var webDiv = h('div', { className: 'chapter-web' }); | |
| webDiv.appendChild(h('div', { className: 'chapter-web-label' }, 'web')); | |
| for (var wi = 0; wi < ch.webOps.length && wi < 6; wi++) { | |
| var w = ch.webOps[wi]; | |
| var opDiv = h('div', { className: 'chapter-web-op' + (w.ok ? '' : ' error') }); | |
| var wTypeBadge = h('span', { className: 'chapter-web-type ' + w.type }, w.type); | |
| if (w.type === 'fetch') { | |
| var headerRow = h('div', {}); | |
| headerRow.appendChild(wTypeBadge); | |
| if (w.url) { | |
| var wLink = h('a', { className: 'chapter-web-url', href: w.url, target: '_blank', rel: 'noopener' }, w.url.slice(0, 80) + (w.url.length > 80 ? '...' : '')); | |
| wLink.addEventListener('click', function(ev) { ev.stopPropagation(); }); | |
| headerRow.appendChild(wLink); | |
| } | |
| opDiv.appendChild(headerRow); | |
| if (w.pageTitle) { | |
| opDiv.appendChild(h('div', { className: 'chapter-web-title' }, w.pageTitle)); | |
| } | |
| if (w.prompt) { | |
| opDiv.appendChild(h('div', { className: 'chapter-web-prompt' }, w.prompt)); | |
| } | |
| if (w.preview) { | |
| opDiv.appendChild(h('div', { className: 'chapter-web-preview' }, w.preview)); | |
| } | |
| } else if (w.type === 'search') { | |
| var headerRow2 = h('div', {}); | |
| headerRow2.appendChild(wTypeBadge); | |
| headerRow2.appendChild(h('span', { className: 'chapter-web-query' }, w.query.slice(0, 100))); | |
| if (w.resultCount) { | |
| headerRow2.appendChild(h('span', { className: 'chapter-web-count' }, w.resultCount + ' result' + (w.resultCount !== 1 ? 's' : ''))); | |
| } | |
| opDiv.appendChild(headerRow2); | |
| if (w.results && w.results.length) { | |
| var resultsDiv = h('div', { className: 'chapter-web-results' }); | |
| for (var wri = 0; wri < w.results.length && wri < 3; wri++) { | |
| var r = w.results[wri]; | |
| var resultRow = h('div', { className: 'chapter-web-result' }); | |
| resultRow.appendChild(h('span', { className: 'chapter-web-result-title' }, r.title)); | |
| if (r.url) { | |
| var rLink = h('a', { className: 'chapter-web-result-url', href: r.url, target: '_blank', rel: 'noopener' }, r.url.replace(/^https?:\/\//, '').slice(0, 40)); | |
| rLink.addEventListener('click', function(ev) { ev.stopPropagation(); }); | |
| resultRow.appendChild(rLink); | |
| } | |
| resultsDiv.appendChild(resultRow); | |
| } | |
| opDiv.appendChild(resultsDiv); | |
| } | |
| } | |
| webDiv.appendChild(opDiv); | |
| } | |
| if (ch.webOps.length > 6) { | |
| webDiv.appendChild(h('div', { className: 'chapter-web-op' }, | |
| h('span', { className: 'chapter-web-type fetch' }, '... ' + (ch.webOps.length - 6) + ' more') | |
| )); | |
| } | |
| detail.appendChild(webDiv); | |
| } | |
| if (ch.mcpOps.length) { | |
| var mcpDiv = h('div', { className: 'chapter-mcp' }); | |
| mcpDiv.appendChild(h('div', { className: 'chapter-mcp-label' }, 'integrations')); | |
| for (var mi = 0; mi < ch.mcpOps.length && mi < 10; mi++) { | |
| var mop = ch.mcpOps[mi]; | |
| var mcpCard = h('div', { className: 'chapter-mcp-op' + (mop.ok ? '' : ' mcp-error') }); | |
| var mcpHeader = h('div', { className: 'chapter-mcp-header' }); | |
| mcpHeader.appendChild(h('span', { className: 'chapter-mcp-server' }, mop.server.replace(/_/g, ' '))); | |
| mcpHeader.appendChild(h('span', { className: 'chapter-mcp-tool' }, mop.tool.replace(/_/g, ' '))); | |
| mcpHeader.appendChild(h('span', { className: 'chapter-mcp-status ' + (mop.ok ? 'ok' : 'fail') }, mop.ok ? '\u2713' : '\u2717')); | |
| mcpCard.appendChild(mcpHeader); | |
| var paramKeys = Object.keys(mop.params); | |
| if (paramKeys.length) { | |
| var paramsDiv = h('div', { className: 'chapter-mcp-params' }); | |
| for (var pk = 0; pk < paramKeys.length && pk < 4; pk++) { | |
| var pKey = paramKeys[pk]; | |
| var pVal = mop.params[pKey]; | |
| paramsDiv.appendChild(h('span', {}, h('span', { className: 'chapter-mcp-param-key' }, pKey + ': '), h('span', { className: 'chapter-mcp-param-val' }, (pVal || '').slice(0, 120)))); | |
| } | |
| mcpCard.appendChild(paramsDiv); | |
| } | |
| if (mop.output) { | |
| var mcpOut = safeSlice(joinFirstLines(mop.output, 4), 300); | |
| mcpCard.appendChild(h('div', { className: 'chapter-mcp-output' }, mcpOut)); | |
| } | |
| mcpDiv.appendChild(mcpCard); | |
| } | |
| if (ch.mcpOps.length > 10) { | |
| mcpDiv.appendChild(h('div', { className: 'chapter-mcp-op' }, | |
| h('span', { className: 'chapter-mcp-tool' }, '\u2026 ' + (ch.mcpOps.length - 10) + ' more') | |
| )); | |
| } | |
| detail.appendChild(mcpDiv); | |
| } | |
| var cmds = ch.commands.slice(0, 8); | |
| if (cmds.length) { | |
| var cmdsDiv = h('div', { className: 'chapter-commands' }); | |
| cmdsDiv.appendChild(h('div', { className: 'chapter-files-label' }, 'commands')); | |
| for (var cmi = 0; cmi < cmds.length; cmi++) { | |
| var cmd = cmds[cmi]; | |
| cmdsDiv.appendChild(h('div', { className: 'chapter-cmd' }, | |
| h('span', { className: 'chapter-cmd-status ' + (cmd.ok ? 'ok' : 'fail') }, cmd.ok ? '\u2713' : '\u2717'), | |
| cmd.cmd.slice(0, 120) | |
| )); | |
| if (cmd.output) { | |
| cmdsDiv.appendChild(h('div', { className: 'chapter-output' }, joinFirstLines(cmd.output, 4))); | |
| } | |
| } | |
| if (ch.commands.length > 8) { | |
| cmdsDiv.appendChild(h('div', { className: 'chapter-cmd' }, | |
| h('span', { className: 'chapter-cmd-status ok' }, ''), | |
| '\u2026 ' + (ch.commands.length - 8) + ' more' | |
| )); | |
| } | |
| detail.appendChild(cmdsDiv); | |
| } | |
| if (ch.tokens.input > 0 || ch.tokens.output > 0) { | |
| var tokDiv = h('div', { className: 'chapter-token-detail' }); | |
| tokDiv.appendChild(h('div', { className: 'chapter-files-label' }, 'tokens')); | |
| var parts = [ | |
| 'input: ' + fmtTokens(ch.tokens.input), | |
| 'output: ' + fmtTokens(ch.tokens.output), | |
| ]; | |
| if (ch.tokens.cacheHit > 0) parts.push('cache read: ' + fmtTokens(ch.tokens.cacheHit)); | |
| if (ch.tokens.cacheWrite > 0) parts.push('cache write: ' + fmtTokens(ch.tokens.cacheWrite)); | |
| var total = ch.tokens.input + ch.tokens.output; | |
| parts.push('total: ' + fmtTokens(total)); | |
| tokDiv.appendChild(h('div', { className: 'chapter-token-breakdown' }, parts.join(' \u00b7 '))); | |
| var cost = estimateChapterTokenCost(session.model, ch.tokens); | |
| const costLabel = fmtCost(cost, { min: 0.001 }); | |
| if (costLabel) { | |
| tokDiv.appendChild(h('div', { className: 'chapter-token-cost' }, costLabel)); | |
| } | |
| detail.appendChild(tokDiv); | |
| } | |
| if (ch.thinking.length) { | |
| var thinkDiv = h('div', { className: 'chapter-thinking' }); | |
| var totalWords = countWordsInStrings(ch.thinking); | |
| var countLabel = ch.thinking.length === 1 | |
| ? totalWords.toLocaleString() + ' words' | |
| : ch.thinking.length + ' blocks \u00b7 ' + totalWords.toLocaleString() + ' words'; | |
| thinkDiv.appendChild(h('div', { className: 'chapter-thinking-header' }, | |
| h('span', { className: 'chapter-thinking-icon' }, '\u{1F9E0}'), | |
| h('span', { className: 'chapter-files-label' }, 'thinking'), | |
| h('span', { className: 'chapter-thinking-count' }, countLabel) | |
| )); | |
| for (var ti = 0; ti < ch.thinking.length && ti < 3; ti++) { | |
| var preview = safeSlice(joinFirstLines(ch.thinking[ti], 5), 400); | |
| thinkDiv.appendChild(h('div', { className: 'chapter-thinking-block' }, preview)); | |
| } | |
| if (ch.thinking.length > 3) { | |
| thinkDiv.appendChild(h('div', { className: 'chapter-thinking-more' }, '\u2026 ' + (ch.thinking.length - 3) + ' more thinking blocks')); | |
| } | |
| detail.appendChild(thinkDiv); | |
| } | |
| if (ch.lastAssistantText) { | |
| detail.appendChild(h('div', { className: 'chapter-response' }, ch.lastAssistantText.slice(0, 500))); | |
| } | |
| } | |
| function expandAllChapterDetails() { | |
| var chapters = getChapters(); | |
| for (var i = 0; i < chapters.length; i++) { | |
| var el = document.getElementById('chapter-' + i); | |
| if (!el) continue; | |
| var detail = el.querySelector('.chapter-detail'); | |
| if (detail && detail.childNodes.length === 0) { | |
| buildChapterDetail(chapters[i], i, detail); | |
| } | |
| } | |
| attachExpandToggles(); | |
| } | |
| window.addEventListener('beforeprint', function() { | |
| expandAllChapterDetails(); | |
| }); | |
| function drawWaveform(canvas, turns, gaps, userMsgs, maxOut, maxIn, height) { | |
| const HEIGHT = height; | |
| const MID = HEIGHT * 0.5; | |
| const W = canvas.parentElement?.clientWidth || 800; | |
| const ctx = setupHiDpiCanvas(canvas, W, HEIGHT); | |
| const colW = W / turns.length; | |
| const outRange = MID - 8; | |
| const inRange = MID - 8; | |
| ctx.fillStyle = '#111113'; | |
| ctx.fillRect(0, 0, W, HEIGHT); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.06)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, MID); | |
| ctx.lineTo(W, MID); | |
| ctx.stroke(); | |
| for (let i = 0; i < turns.length; i++) { | |
| const turn = turns[i]; | |
| const x = i * colW; | |
| const color = turn.hasError ? TOOL_COLORS._error : getToolColor(turn.tool); | |
| const alpha = 0.35 + 0.65 * (1 - turn.cacheRatio); | |
| const outH = Math.max(2, (turn.output / maxOut) * outRange); | |
| const inH = Math.max(2, (turn.input / maxIn) * inRange); | |
| const outGrad = ctx.createLinearGradient(0, MID - outH, 0, MID); | |
| outGrad.addColorStop(0, color); | |
| outGrad.addColorStop(1, color + '20'); | |
| ctx.globalAlpha = alpha; | |
| ctx.fillStyle = outGrad; | |
| ctx.fillRect(x + 0.25, MID - outH, colW - 0.5, outH); | |
| ctx.fillStyle = color; | |
| ctx.globalAlpha = Math.min(1, alpha + 0.3); | |
| ctx.fillRect(x + 0.25, MID - outH, colW - 0.5, Math.min(2, outH)); | |
| const inGrad = ctx.createLinearGradient(0, MID, 0, MID + inH); | |
| inGrad.addColorStop(0, color + '40'); | |
| inGrad.addColorStop(1, color + '08'); | |
| ctx.globalAlpha = alpha * 0.6; | |
| ctx.fillStyle = inGrad; | |
| ctx.fillRect(x + 0.25, MID + 1, colW - 0.5, inH); | |
| if (turn.hasError) { | |
| ctx.globalAlpha = 0.8; | |
| ctx.fillStyle = TOOL_COLORS._error; | |
| ctx.shadowColor = TOOL_COLORS._error; | |
| ctx.shadowBlur = 10; | |
| ctx.fillRect(x, MID - outH - 4, colW, 4); | |
| ctx.shadowBlur = 0; | |
| ctx.beginPath(); | |
| ctx.arc(x + colW / 2, HEIGHT - 14, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.shadowBlur = 0; | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| ctx.globalAlpha = 1; | |
| for (const gap of gaps) { | |
| const x = gap.idx * colW; | |
| ctx.strokeStyle = 'rgba(255,255,255,0.08)'; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([2, 2]); | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 8); | |
| ctx.lineTo(x, HEIGHT - 8); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| const label = formatDuration(gap.delta); | |
| ctx.font = '9px ' + getComputedStyle(document.body).fontFamily; | |
| ctx.fillStyle = '#5c5c63'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(label, x, HEIGHT - 2); | |
| } | |
| for (const um of userMsgs) { | |
| const x = Math.min(um.idx, turns.length - 1) * colW + colW / 2; | |
| ctx.fillStyle = TOOL_COLORS._user; | |
| ctx.globalAlpha = 0.9; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 1); | |
| ctx.lineTo(x + 4, 6); | |
| ctx.lineTo(x, 11); | |
| ctx.lineTo(x - 4, 6); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.setLineDash([]); | |
| ctx.strokeStyle = 'rgba(139, 124, 246, 0.18)'; | |
| ctx.lineWidth = 1; | |
| for (let c = 1; c < userMsgs.length; c++) { | |
| const x = Math.min(userMsgs[c].idx, turns.length) * colW; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, HEIGHT); | |
| ctx.stroke(); | |
| } | |
| return W; | |
| } | |
| function drawChart(canvas, chapterCosts, maxCumulative, maxSingle, height) { | |
| const HEIGHT = height; | |
| const W = canvas.parentElement?.clientWidth || 800; | |
| const ctx = setupHiDpiCanvas(canvas, W, HEIGHT); | |
| const PAD_TOP = 10; | |
| const PAD_BOTTOM = 4; | |
| const chartH = HEIGHT - PAD_TOP - PAD_BOTTOM; | |
| const numCh = chapterCosts.length; | |
| const barW = W / numCh; | |
| ctx.fillStyle = '#111113'; | |
| ctx.fillRect(0, 0, W, HEIGHT); | |
| if (maxSingle > 0) { | |
| for (let i = 0; i < numCh; i++) { | |
| const c = chapterCosts[i]; | |
| const barH = Math.max(1, (c.cost / maxSingle) * (chartH * 0.4)); | |
| const x = i * barW; | |
| const y = HEIGHT - PAD_BOTTOM - barH; | |
| ctx.fillStyle = 'rgba(232, 164, 76, 0.15)'; | |
| ctx.fillRect(x + 1, y, barW - 2, barH); | |
| } | |
| } | |
| if (maxCumulative > 0) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, HEIGHT - PAD_BOTTOM); | |
| for (let i = 0; i < numCh; i++) { | |
| const x = i * barW + barW / 2; | |
| const y = PAD_TOP + chartH - (chapterCosts[i].cumulative / maxCumulative) * chartH; | |
| if (i === 0) ctx.lineTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.lineTo((numCh - 1) * barW + barW / 2, HEIGHT - PAD_BOTTOM); | |
| ctx.lineTo(0, HEIGHT - PAD_BOTTOM); | |
| ctx.closePath(); | |
| const grad = ctx.createLinearGradient(0, PAD_TOP, 0, HEIGHT - PAD_BOTTOM); | |
| grad.addColorStop(0, 'rgba(232, 164, 76, 0.4)'); | |
| grad.addColorStop(1, 'rgba(232, 164, 76, 0.02)'); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| for (let i = 0; i < numCh; i++) { | |
| const x = i * barW + barW / 2; | |
| const y = PAD_TOP + chartH - (chapterCosts[i].cumulative / maxCumulative) * chartH; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.strokeStyle = 'rgba(232, 164, 76, 0.9)'; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| if (numCh <= 50) { | |
| for (let i = 0; i < numCh; i++) { | |
| const x = i * barW + barW / 2; | |
| const y = PAD_TOP + chartH - (chapterCosts[i].cumulative / maxCumulative) * chartH; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 2.5, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(232, 164, 76, 0.9)'; | |
| ctx.fill(); | |
| } | |
| } | |
| } | |
| ctx.fillStyle = '#5c5c63'; | |
| ctx.font = '9px ' + getComputedStyle(document.body).fontFamily; | |
| ctx.textAlign = 'left'; | |
| const topLabel = fmtCost(maxCumulative, { prefix: '$' }); | |
| ctx.fillText(topLabel, 4, PAD_TOP + 8); | |
| ctx.fillText('$0', 4, HEIGHT - PAD_BOTTOM - 2); | |
| return W; | |
| } | |
| function sortToolPerfEntries(entries, mode) { | |
| if (mode === 'errors') { | |
| return entries.slice().sort(function(a, b) { | |
| var errDiff = b[1].errors - a[1].errors; | |
| if (errDiff !== 0) return errDiff; | |
| return b[1].total - a[1].total; | |
| }); | |
| } | |
| return entries.slice().sort(function(a, b) { return b[1].total - a[1].total; }); | |
| } | |
| function buildToolPerfRow(tool, stats) { | |
| var color = getToolColor(tool); | |
| var successes = stats.total - stats.errors; | |
| var rate = stats.total > 0 ? (successes / stats.total * 100) : 100; | |
| var rateStr = fmtPct(rate); | |
| var rateCls = 'tool-perf-rate'; | |
| if (rate === 100) rateCls += ' perfect'; | |
| else if (rate >= 90) rateCls += ' good'; | |
| else if (rate >= 70) rateCls += ' warn'; | |
| else rateCls += ' bad'; | |
| var isActive = activeToolPerfFilter === tool; | |
| var row = h('div', { className: 'tool-perf-row' + (isActive ? ' active' : '') }); | |
| row.addEventListener('click', function(ev) { | |
| ev.stopPropagation(); | |
| if (activeToolPerfFilter === tool) { | |
| activeToolPerfFilter = null; | |
| activeToolFilters.clear(); | |
| } else { | |
| activeToolPerfFilter = tool; | |
| activeToolFilters.clear(); | |
| activeToolFilters.add(tool); | |
| } | |
| applyFilters(); | |
| var rows = document.querySelectorAll('.tool-perf-row'); | |
| rows.forEach(function(r) { | |
| r.classList.toggle('active', r.dataset.tool === activeToolPerfFilter); | |
| }); | |
| }); | |
| row.dataset.tool = tool; | |
| row.appendChild(h('div', { className: 'tool-perf-dot', style: 'background:' + color })); | |
| var toolDispName = fmtMcpName(tool); | |
| row.appendChild(h('span', { className: 'tool-perf-name', title: tool }, toolDispName)); | |
| row.appendChild(h('span', { className: 'tool-perf-calls' }, String(stats.total))); | |
| var barWrap = h('div', { className: 'tool-perf-bar-wrap' }); | |
| var okPct = stats.total > 0 ? (successes / stats.total * 100) : 100; | |
| barWrap.appendChild(h('div', { className: 'tool-perf-bar-ok', style: 'width:' + okPct + '%' })); | |
| if (stats.errors > 0) { | |
| barWrap.appendChild(h('div', { className: 'tool-perf-bar-err', style: 'width:' + (100 - okPct) + '%' })); | |
| } | |
| row.appendChild(barWrap); | |
| row.appendChild(h('span', { className: rateCls }, rateStr)); | |
| var errCls = 'tool-perf-errs' + (stats.errors > 0 ? ' has-errors' : ''); | |
| row.appendChild(h('span', { className: errCls }, stats.errors > 0 ? stats.errors + ' err' : '')); | |
| if (stats.retries > 0) { | |
| var retryEl = h('div', { className: 'tool-perf-retries' }, | |
| '\u21bb ' + stats.retries + ' retr' + (stats.retries === 1 ? 'y' : 'ies') + | |
| ' across ' + stats.chapters + ' chapter' + (stats.chapters === 1 ? '' : 's')); | |
| row.appendChild(retryEl); | |
| } | |
| return row; | |
| } | |
| function rebuildToolPerfTable(sortedEntries) { | |
| var t = document.getElementById('tool-perf-table'); | |
| if (!t) return; | |
| t.innerHTML = ''; | |
| for (var idx = 0; idx < sortedEntries.length; idx++) { | |
| t.appendChild(buildToolPerfRow(sortedEntries[idx][0], sortedEntries[idx][1])); | |
| } | |
| } | |
| function renderWaveform() { | |
| const turns = []; | |
| for (const e of events) { | |
| if (e.type !== 'assistant' || !e.tokens) continue; | |
| const ts = new Date(e.timestamp).getTime(); | |
| const tools = (e.toolCalls || []).map(tc => tc.name); | |
| const primaryTool = tools[0] || '_text'; | |
| const hasError = events.some(r => r.type === 'tool_result' && r.isError && e.toolCalls?.some(tc => tc.id === r.toolUseId)); | |
| const cacheRatio = e.tokens.input > 0 ? e.tokens.cacheHit / e.tokens.input : 1; | |
| turns.push({ | |
| ms: ts, | |
| output: e.tokens.output, | |
| input: e.tokens.input, | |
| cacheRatio, | |
| tool: primaryTool, | |
| tools, | |
| hasError, | |
| timestamp: e.timestamp, | |
| text: e.text, | |
| }); | |
| } | |
| if (!turns.length) return h('div'); | |
| const userMsgs = []; | |
| for (const e of events) { | |
| if (e.type !== 'user' || !e.timestamp) continue; | |
| const ts = new Date(e.timestamp).getTime(); | |
| let idx = 0; | |
| for (let i = 0; i < turns.length; i++) { | |
| if (turns[i].ms >= ts) { idx = i; break; } | |
| idx = i + 1; | |
| } | |
| userMsgs.push({ idx, timestamp: e.timestamp }); | |
| } | |
| const turnToChapter = new Array(turns.length).fill(0); | |
| for (let c = 0; c < userMsgs.length; c++) { | |
| const start = userMsgs[c].idx; | |
| const end = c + 1 < userMsgs.length ? userMsgs[c + 1].idx : turns.length; | |
| for (let t = start; t < end; t++) turnToChapter[t] = c; | |
| } | |
| const gaps = []; | |
| for (let i = 1; i < turns.length; i++) { | |
| const delta = turns[i].ms - turns[i - 1].ms; | |
| if (delta > 30000) gaps.push({ idx: i, delta }); | |
| } | |
| let maxOut = 1; | |
| let maxIn = 1; | |
| for (let i = 0; i < turns.length; i++) { | |
| const o = turns[i].output; | |
| const inp = turns[i].input; | |
| if (o > maxOut) maxOut = o; | |
| if (inp > maxIn) maxIn = inp; | |
| } | |
| const wrap = h('div', { className: 'waveform-wrap' }); | |
| wrap.appendChild(h('div', { className: 'waveform-label' }, '▲ output · ▼ input · brightness = uncached — click to jump to chapter')); | |
| const canvas = document.createElement('canvas'); | |
| canvas.className = 'waveform-canvas'; | |
| const HEIGHT = 200; | |
| wrap.appendChild(canvas); | |
| const tooltip = h('div', { className: 'waveform-tooltip' }); | |
| wrap.appendChild(tooltip); | |
| const cursor = h('div', { className: 'waveform-cursor' }); | |
| wrap.appendChild(cursor); | |
| const regionHL = document.createElement('div'); | |
| regionHL.style.cssText = 'position:absolute;background:rgba(139,124,246,0.06);pointer-events:none;display:none;border-radius:2px;z-index:5;'; | |
| wrap.appendChild(regionHL); | |
| let canvasW = 0; | |
| let hoveredChapter = -1; | |
| setTimeout(() => { canvasW = drawWaveform(canvas, turns, gaps, userMsgs, maxOut, maxIn, HEIGHT); }, 0); | |
| window.addEventListener('resize', () => { canvasW = drawWaveform(canvas, turns, gaps, userMsgs, maxOut, maxIn, HEIGHT); }); | |
| canvas.addEventListener('mousemove', (ev) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = ev.clientX - rect.left; | |
| const idx = Math.min(turns.length - 1, Math.max(0, ((mx / rect.width) * turns.length) | 0)); | |
| const turn = turns[idx]; | |
| const oL = canvas.offsetLeft; | |
| const oT = canvas.offsetTop; | |
| const cW = rect.width / turns.length; | |
| cursor.style.display = 'block'; | |
| cursor.style.left = (oL + mx) + 'px'; | |
| cursor.style.top = oT + 'px'; | |
| cursor.style.height = canvas.style.height; | |
| const chIdx = turnToChapter[idx]; | |
| const rStart = userMsgs[chIdx] ? userMsgs[chIdx].idx : 0; | |
| const rEnd = chIdx + 1 < userMsgs.length ? userMsgs[chIdx + 1].idx : turns.length; | |
| regionHL.style.display = 'block'; | |
| regionHL.style.left = (oL + rStart * cW) + 'px'; | |
| regionHL.style.top = oT + 'px'; | |
| regionHL.style.width = ((rEnd - rStart) * cW) + 'px'; | |
| regionHL.style.height = canvas.style.height; | |
| if (chIdx !== hoveredChapter) { | |
| if (hoveredChapter >= 0) { | |
| const prev = document.getElementById('chapter-' + hoveredChapter); | |
| if (prev) prev.classList.remove('wf-hover'); | |
| } | |
| hoveredChapter = chIdx; | |
| const chEl = document.getElementById('chapter-' + chIdx); | |
| if (chEl) chEl.classList.add('wf-hover'); | |
| } | |
| if (turn) { | |
| const cacheP = ((1 - turn.cacheRatio) * 100) | 0; | |
| tooltip.style.display = 'block'; | |
| const tx = Math.min(mx + 12, (canvasW || rect.width) - 220); | |
| tooltip.style.left = (oL + tx) + 'px'; | |
| tooltip.style.top = (oT + 14) + 'px'; | |
| tooltip.innerHTML = | |
| '<span style="color:var(--fg3)">chapter ' + (chIdx + 1) + '</span> · ' + | |
| '<b>' + formatTime(turn.timestamp) + '</b> ' + | |
| (turn.tools.length ? turn.tools.join(', ') : 'text') + | |
| '<br>out: ' + fmtTokens(turn.output) + | |
| ' · in: ' + fmtTokens(turn.input) + | |
| ' · cold: ' + cacheP + '%' + | |
| (turn.hasError ? ' · <span style="color:#f07070">ERROR</span>' : ''); | |
| } else { | |
| tooltip.style.display = 'none'; | |
| } | |
| }); | |
| canvas.addEventListener('mouseleave', () => { | |
| tooltip.style.display = 'none'; | |
| cursor.style.display = 'none'; | |
| regionHL.style.display = 'none'; | |
| if (hoveredChapter >= 0) { | |
| const prev = document.getElementById('chapter-' + hoveredChapter); | |
| if (prev) prev.classList.remove('wf-hover'); | |
| hoveredChapter = -1; | |
| } | |
| }); | |
| canvas.addEventListener('click', (ev) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = ev.clientX - rect.left; | |
| const idx = Math.min(turns.length - 1, Math.max(0, ((mx / rect.width) * turns.length) | 0)); | |
| jumpToChapter(turnToChapter[idx]); | |
| }); | |
| const usedTools = [...new Set(turns.map(t => t.tool))]; | |
| const legend = h('div', { className: 'waveform-legend' }); | |
| for (const tool of usedTools) { | |
| const name = tool.startsWith('_') ? (tool === '_text' ? 'text' : tool.slice(1)) : fmtMcpName(tool); | |
| const color = getToolColor(tool); | |
| legend.appendChild(h('div', { className: 'legend-item' }, | |
| h('div', { className: 'legend-dot', style: 'background:' + color }), | |
| name | |
| )); | |
| } | |
| wrap.appendChild(legend); | |
| return wrap; | |
| } | |
| function renderCostChart() { | |
| const chapters = getChapters(); | |
| if (chapters.length < 2) return h('div'); | |
| const chapterCosts = []; | |
| let cumulative = 0; | |
| let hasCost = false; | |
| for (const ch of chapters) { | |
| const cost = estimateChapterTokenCost(session.model, ch.tokens); | |
| cumulative += cost; | |
| chapterCosts.push({ cost, cumulative }); | |
| if (cost > 0) hasCost = true; | |
| } | |
| if (!hasCost || cumulative < 0.001) return h('div'); | |
| const totalCostStr = fmtCost(cumulative, { prefix: '$' }); | |
| const maxCumulative = cumulative; | |
| let maxSingle = 0; | |
| for (let i = 0; i < chapterCosts.length; i++) { | |
| const c = chapterCosts[i].cost; | |
| if (c > maxSingle) maxSingle = c; | |
| } | |
| const wrap = h('div', { className: 'cost-chart' }); | |
| wrap.appendChild(h('div', { className: 'cost-chart-header' }, | |
| 'cost progression', | |
| h('span', { className: 'cost-chart-total' }, 'total: ~' + totalCostStr) | |
| )); | |
| const canvas = document.createElement('canvas'); | |
| canvas.className = 'cost-chart-canvas'; | |
| const HEIGHT = 100; | |
| wrap.appendChild(canvas); | |
| const tooltip = h('div', { className: 'cost-chart-tooltip' }); | |
| wrap.appendChild(tooltip); | |
| let chartW = 0; | |
| setTimeout(function() { chartW = drawChart(canvas, chapterCosts, maxCumulative, maxSingle, HEIGHT); }, 0); | |
| window.addEventListener('resize', function() { chartW = drawChart(canvas, chapterCosts, maxCumulative, maxSingle, HEIGHT); }); | |
| canvas.addEventListener('mousemove', function(ev) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = ev.clientX - rect.left; | |
| const numCh = chapterCosts.length; | |
| const idx = Math.min(numCh - 1, Math.max(0, ((mx / rect.width) * numCh) | 0)); | |
| const c = chapterCosts[idx]; | |
| tooltip.style.display = 'block'; | |
| const costStr = fmtCost(c.cost, { | |
| prefix: '$', | |
| min: 0.001, | |
| fine: true, | |
| tinyMin: 0.001, | |
| tinyLabel: '<$0.001', | |
| zeroLabel: '<$0.001', | |
| }); | |
| const cumStr = fmtCost(c.cumulative, { prefix: '$' }); | |
| tooltip.innerHTML = | |
| '<b>chapter ' + (idx + 1) + '</b><br>' + | |
| 'cost: <span style="color:var(--orange)">' + costStr + '</span> · ' + | |
| 'cumulative: <span style="color:var(--orange)">' + cumStr + '</span>'; | |
| const oL = canvas.offsetLeft; | |
| const oT = canvas.offsetTop; | |
| const tx = Math.min(mx + 12, (chartW || rect.width) - 180); | |
| tooltip.style.left = (oL + tx) + 'px'; | |
| tooltip.style.top = (oT + 8) + 'px'; | |
| }); | |
| canvas.addEventListener('mouseleave', function() { | |
| tooltip.style.display = 'none'; | |
| }); | |
| canvas.addEventListener('click', function(ev) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = ev.clientX - rect.left; | |
| const numCh = chapterCosts.length; | |
| const idx = Math.min(numCh - 1, Math.max(0, ((mx / rect.width) * numCh) | 0)); | |
| jumpToChapter(idx); | |
| }); | |
| const axis = h('div', { className: 'cost-chart-axis' }); | |
| axis.appendChild(h('span', {}, 'ch 1')); | |
| axis.appendChild(h('span', {}, 'ch ' + chapterCosts.length)); | |
| wrap.appendChild(axis); | |
| return wrap; | |
| } | |
| function renderToolFlow() { | |
| const chapters = getChapters(); | |
| const orderedSequence = []; | |
| let chIdx = -1; | |
| for (const e of events) { | |
| if (e.type === 'user' && e.text) { | |
| chIdx++; | |
| } | |
| if (e.type === 'assistant' && e.toolCalls && chIdx >= 0) { | |
| for (const tc of e.toolCalls) { | |
| orderedSequence.push({ tool: tc.name, chapterIdx: chIdx }); | |
| } | |
| } | |
| } | |
| if (orderedSequence.length < 3) return h('div'); | |
| const orderedBoundaries = new Set(); | |
| let prevCh = -1; | |
| for (let i = 0; i < orderedSequence.length; i++) { | |
| if (orderedSequence[i].chapterIdx !== prevCh) { | |
| orderedBoundaries.add(i); | |
| prevCh = orderedSequence[i].chapterIdx; | |
| } | |
| } | |
| const toolTotals = {}; | |
| for (const item of orderedSequence) { | |
| toolTotals[item.tool] = (toolTotals[item.tool] || 0) + 1; | |
| } | |
| const transitions = {}; | |
| for (let i = 0; i < orderedSequence.length - 1; i++) { | |
| const from = orderedSequence[i].tool; | |
| const to = orderedSequence[i + 1].tool; | |
| if (orderedSequence[i].chapterIdx !== orderedSequence[i + 1].chapterIdx) continue; | |
| const key = from + '>' + to; | |
| transitions[key] = (transitions[key] || 0) + 1; | |
| } | |
| const sortedToolNames = []; | |
| for (const tool in toolTotals) { | |
| if (Object.prototype.hasOwnProperty.call(toolTotals, tool)) sortedToolNames.push(tool); | |
| } | |
| sortedToolNames.sort((a, b) => toolTotals[b] - toolTotals[a]); | |
| const numChapters = chapters.length; | |
| const wrap = h('div', { className: 'tool-flow' }); | |
| wrap.appendChild(h('div', { className: 'tool-flow-header' }, 'tool usage flow')); | |
| const seqWrap = h('div', { className: 'tool-flow-sequence' }); | |
| const maxCells = 500; | |
| const step = orderedSequence.length > maxCells ? Math.ceil(orderedSequence.length / maxCells) : 1; | |
| for (let i = 0; i < orderedSequence.length; i += step) { | |
| if (orderedBoundaries.has(i) && i > 0) { | |
| seqWrap.appendChild(h('div', { className: 'tool-flow-divider' })); | |
| } | |
| const item = orderedSequence[i]; | |
| const color = getToolColor(item.tool); | |
| const displayName = fmtMcpName(item.tool); | |
| const cell = h('div', { | |
| className: 'tool-flow-cell', | |
| title: displayName + ' (ch ' + (item.chapterIdx + 1) + ')' | |
| }); | |
| cell.style.background = color; | |
| seqWrap.appendChild(cell); | |
| } | |
| wrap.appendChild(seqWrap); | |
| const summaryGrid = h('div', { className: 'tool-flow-summary' }); | |
| for (let ti = 0; ti < sortedToolNames.length && ti < 8; ti++) { | |
| const tool = sortedToolNames[ti]; | |
| const count = toolTotals[tool]; | |
| const color = getToolColor(tool); | |
| const avg = (count / numChapters).toFixed(1); | |
| const toolLabel = fmtMcpName(tool); | |
| const stat = h('div', { className: 'tool-flow-stat' }, | |
| h('div', { className: 'tool-flow-stat-dot', style: 'background:' + color }), | |
| h('span', { className: 'tool-flow-stat-name' }, toolLabel), | |
| h('span', { className: 'tool-flow-stat-count' }, String(count)), | |
| h('span', { className: 'tool-flow-stat-avg' }, avg + '/ch') | |
| ); | |
| summaryGrid.appendChild(stat); | |
| } | |
| wrap.appendChild(summaryGrid); | |
| const sortedTransitionKeys = []; | |
| for (const key in transitions) { | |
| if (Object.prototype.hasOwnProperty.call(transitions, key)) sortedTransitionKeys.push(key); | |
| } | |
| sortedTransitionKeys.sort((a, b) => transitions[b] - transitions[a]); | |
| if (sortedTransitionKeys.length > 0) { | |
| const transWrap = h('div', { className: 'tool-flow-transitions' }); | |
| transWrap.appendChild(h('div', { className: 'tool-flow-transitions-label' }, 'common transitions')); | |
| const list = h('div', { className: 'tool-flow-transition-list' }); | |
| for (let tri = 0; tri < sortedTransitionKeys.length && tri < 10; tri++) { | |
| const key = sortedTransitionKeys[tri]; | |
| const count = transitions[key]; | |
| const from = key.slice(0, key.indexOf('>')); | |
| const to = key.slice(key.indexOf('>') + 1); | |
| var fromLabel = fmtMcpName(from); | |
| var toLabel = fmtMcpName(to); | |
| list.appendChild(h('div', { className: 'tool-flow-transition' }, | |
| h('span', { style: 'color:' + getToolColor(from) }, fromLabel), | |
| h('span', { className: 'tool-flow-transition-arrow' }, '→'), | |
| h('span', { style: 'color:' + getToolColor(to) }, toLabel), | |
| h('span', { className: 'tool-flow-transition-count' }, '×' + count) | |
| )); | |
| } | |
| transWrap.appendChild(list); | |
| wrap.appendChild(transWrap); | |
| } | |
| return wrap; | |
| } | |
| let toolPerfExpanded = false; | |
| let toolPerfSort = 'errors'; // 'errors' (most error-prone first) or 'usage' (most used first) | |
| let activeToolPerfFilter = null; // tool name being filtered on via click | |
| function renderToolPerformance() { | |
| const chapters = getChapters(); | |
| const toolStats = {}; | |
| for (const ch of chapters) { | |
| const toolKeys = chapterToolKeys(ch); | |
| for (let tki = 0; tki < toolKeys.length; tki++) { | |
| const tool = toolKeys[tki]; | |
| const count = ch.toolCounts[tool]; | |
| if (!toolStats[tool]) toolStats[tool] = { total: 0, errors: 0, retries: 0, chapters: 0 }; | |
| toolStats[tool].total += count; | |
| toolStats[tool].chapters++; | |
| } | |
| for (const tool in ch.errorTools) { | |
| if (!Object.prototype.hasOwnProperty.call(ch.errorTools, tool)) continue; | |
| const count = ch.errorTools[tool]; | |
| if (!toolStats[tool]) toolStats[tool] = { total: 0, errors: 0, retries: 0, chapters: 0 }; | |
| toolStats[tool].errors += count; | |
| } | |
| for (const rg of (ch.retryGroups || [])) { | |
| if (!toolStats[rg.tool]) toolStats[rg.tool] = { total: 0, errors: 0, retries: 0, chapters: 0 }; | |
| toolStats[rg.tool].retries += rg.count - 1; | |
| } | |
| } | |
| const toolStatNames = []; | |
| for (const name in toolStats) { | |
| if (Object.prototype.hasOwnProperty.call(toolStats, name)) toolStatNames.push(name); | |
| } | |
| if (toolStatNames.length < 2) return h('div'); | |
| const entries = toolStatNames.map(function(name) { return [name, toolStats[name]]; }); | |
| var sorted = sortToolPerfEntries(entries, toolPerfSort); | |
| var wrap = h('div', { className: 'tool-perf' + (toolPerfExpanded ? ' expanded' : '') }); | |
| var totalCalls = 0; | |
| var totalErrors = 0; | |
| for (let tsi = 0; tsi < toolStatNames.length; tsi++) { | |
| const st = toolStats[toolStatNames[tsi]]; | |
| totalCalls += st.total; | |
| totalErrors += st.errors; | |
| } | |
| var overallRate = totalCalls > 0 ? ((totalCalls - totalErrors) / totalCalls * 100).toFixed(0) : '100'; | |
| var header = h('div', { className: 'tool-perf-header' }); | |
| header.appendChild(h('span', { className: 'tool-perf-title' }, | |
| 'tool performance — ' + entries.length + ' tools, ' + overallRate + '% success')); | |
| header.appendChild(h('span', { className: 'tool-perf-toggle' }, | |
| toolPerfExpanded ? '\u25be' : '\u25b8')); | |
| header.addEventListener('click', function(ev) { | |
| ev.stopPropagation(); | |
| toolPerfExpanded = !toolPerfExpanded; | |
| wrap.classList.toggle('expanded'); | |
| header.querySelector('.tool-perf-toggle').textContent = toolPerfExpanded ? '\u25be' : '\u25b8'; | |
| }); | |
| wrap.appendChild(header); | |
| var body = h('div', { className: 'tool-perf-body' }); | |
| var sortBar = h('div', { className: 'tool-perf-sort' }); | |
| ['errors', 'usage'].forEach(function(mode) { | |
| var btn = h('button', { | |
| className: 'tool-perf-sort-btn' + (toolPerfSort === mode ? ' active' : ''), | |
| onClick: function(ev) { | |
| ev.stopPropagation(); | |
| toolPerfSort = mode; | |
| var newSorted = sortToolPerfEntries(entries, mode); | |
| rebuildToolPerfTable(newSorted); | |
| sortBar.querySelectorAll('.tool-perf-sort-btn').forEach(function(b) { | |
| b.classList.toggle('active', b.textContent === mode); | |
| }); | |
| } | |
| }, mode); | |
| sortBar.appendChild(btn); | |
| }); | |
| body.appendChild(sortBar); | |
| var table = h('div', { className: 'tool-perf-table', id: 'tool-perf-table' }); | |
| for (var idx = 0; idx < sorted.length; idx++) { | |
| table.appendChild(buildToolPerfRow(sorted[idx][0], sorted[idx][1])); | |
| } | |
| body.appendChild(table); | |
| wrap.appendChild(body); | |
| return wrap; | |
| } | |
| let fileHotspotExpanded = false; | |
| let activeFileFilter = null; // path string or null | |
| function renderFileHotspot() { | |
| const chapters = getChapters(); | |
| const fileMap = {}; | |
| for (let i = 0; i < chapters.length; i++) { | |
| const ch = chapters[i]; | |
| const fileKeys = chapterFileKeys(ch); | |
| for (let fki = 0; fki < fileKeys.length; fki++) { | |
| const path = fileKeys[fki]; | |
| const info = ch.files[path]; | |
| if (!fileMap[path]) fileMap[path] = { total: 0, ops: {}, chapters: new Set() }; | |
| const fm = fileMap[path]; | |
| fm.chapters.add(i); | |
| for (const op of info.ops) { | |
| fm.ops[op] = (fm.ops[op] || 0) + 1; | |
| fm.total++; | |
| } | |
| } | |
| } | |
| const filePaths = []; | |
| for (const path in fileMap) { | |
| if (Object.prototype.hasOwnProperty.call(fileMap, path)) filePaths.push(path); | |
| } | |
| if (filePaths.length < 2) return h('div'); | |
| filePaths.sort((a, b) => fileMap[b].total - fileMap[a].total); | |
| const maxTouch = fileMap[filePaths[0]].total; | |
| const topFiles = filePaths.slice(0, 30).map(function(path) { return [path, fileMap[path]]; }); | |
| const entries = filePaths.map(function(path) { return [path, fileMap[path]]; }); | |
| const wrap = h('div', { className: 'file-hotspot' + (fileHotspotExpanded ? ' expanded' : '') }); | |
| const header = h('div', { className: 'file-hotspot-header', onClick: (ev) => { | |
| ev.stopPropagation(); | |
| fileHotspotExpanded = !fileHotspotExpanded; | |
| wrap.classList.toggle('expanded'); | |
| }}); | |
| header.appendChild(h('span', { className: 'file-hotspot-toggle' }, fileHotspotExpanded ? '▾' : '▸')); | |
| header.appendChild(h('span', { className: 'file-hotspot-title' }, 'file hotspots')); | |
| header.appendChild(h('span', { className: 'file-hotspot-count' }, entries.length + ' files · ' + entries.reduce((s, e) => s + e[1].total, 0) + ' touches')); | |
| wrap.appendChild(header); | |
| const body = h('div', { className: 'file-hotspot-body' }); | |
| const bar = h('div', { className: 'file-hotspot-bar' }); | |
| const totalTouches = entries.reduce((s, e) => s + e[1].total, 0); | |
| for (const [path, info] of topFiles.slice(0, 20)) { | |
| const pct = (info.total / totalTouches) * 100; | |
| if (pct < 0.5) continue; | |
| let maxOpName = ''; | |
| let maxOpCount = 0; | |
| for (const op in info.ops) { | |
| if (!Object.prototype.hasOwnProperty.call(info.ops, op)) continue; | |
| if (info.ops[op] > maxOpCount) { maxOpCount = info.ops[op]; maxOpName = op; } | |
| } | |
| const opColors = { Read: '#6ba4e8', Edit: '#e0c45e', Write: '#d89660' }; | |
| const color = opColors[maxOpName] || '#8b7cf6'; | |
| const seg = h('div', { className: 'file-hotspot-bar-seg', title: path + ' (' + info.total + ')' }); | |
| seg.style.width = pct + '%'; | |
| seg.style.background = color; | |
| seg.style.opacity = String(0.5 + 0.5 * (info.total / maxTouch)); | |
| bar.appendChild(seg); | |
| } | |
| body.appendChild(bar); | |
| const list = h('div', { className: 'file-hotspot-list' }); | |
| for (const [path, info] of topFiles) { | |
| const isActive = activeFileFilter === path; | |
| const item = h('div', { | |
| className: 'file-hotspot-item' + (isActive ? ' active' : ''), | |
| title: path + ' — ' + info.total + ' touches across ' + info.chapters.size + ' chapters', | |
| onClick: (ev) => { | |
| ev.stopPropagation(); | |
| if (activeFileFilter === path) { | |
| activeFileFilter = null; | |
| } else { | |
| activeFileFilter = path; | |
| } | |
| applyFileFilter(); | |
| } | |
| }); | |
| const heatRatio = info.total / maxTouch; | |
| const hue = 220 - (heatRatio * 180); | |
| const sat = 60 + heatRatio * 30; | |
| const lum = 45 + heatRatio * 15; | |
| const dotSize = 6 + Math.round(heatRatio * 8); | |
| const dot = h('div', { className: 'file-hotspot-dot' }); | |
| dot.style.background = 'hsl(' + hue + ',' + sat + '%,' + lum + '%)'; | |
| dot.style.width = dotSize + 'px'; | |
| dot.style.height = dotSize + 'px'; | |
| dot.style.boxShadow = heatRatio > 0.5 ? '0 0 ' + Math.round(heatRatio * 6) + 'px hsl(' + hue + ',' + sat + '%,' + lum + '%)' : 'none'; | |
| item.appendChild(dot); | |
| item.appendChild(h('span', { className: 'file-hotspot-path' }, path)); | |
| const opsWrap = h('div', { className: 'file-hotspot-ops' }); | |
| const opNames = []; | |
| for (const op in info.ops) { | |
| if (Object.prototype.hasOwnProperty.call(info.ops, op)) opNames.push(op); | |
| } | |
| opNames.sort((a, b) => info.ops[b] - info.ops[a]); | |
| for (let opi = 0; opi < opNames.length; opi++) { | |
| const op = opNames[opi]; | |
| const count = info.ops[op]; | |
| const cls = op.toLowerCase(); | |
| opsWrap.appendChild(h('span', { className: 'file-hotspot-op ' + cls }, op[0] + count)); | |
| } | |
| item.appendChild(opsWrap); | |
| const chList = [...info.chapters].sort((a, b) => a - b); | |
| const chStr = chList.length <= 4 | |
| ? chList.map(c => c + 1).join(',') | |
| : chList.slice(0, 3).map(c => c + 1).join(',') + '…+' + (chList.length - 3); | |
| item.appendChild(h('span', { className: 'file-hotspot-chapters' }, 'ch ' + chStr)); | |
| item.appendChild(h('span', { className: 'file-hotspot-touch' }, String(info.total))); | |
| list.appendChild(item); | |
| } | |
| body.appendChild(list); | |
| if (entries.length > 30) { | |
| body.appendChild(h('div', { style: 'font-size:11px;color:var(--fg3);margin-top:6px;padding-left:10px;' }, '… ' + (entries.length - 30) + ' more files')); | |
| } | |
| wrap.appendChild(body); | |
| return wrap; | |
| } | |
| function renderGitTimeline() { | |
| const chapters = getChapters(); | |
| const allGitOps = []; | |
| for (let i = 0; i < chapters.length; i++) { | |
| for (const op of chapters[i].gitOps) { | |
| allGitOps.push({ ...op, chapterIdx: i }); | |
| } | |
| } | |
| if (allGitOps.length === 0) return h('div'); | |
| const wrap = h('div', { className: 'git-timeline' }); | |
| const header = h('div', { className: 'git-timeline-header' }); | |
| header.appendChild(h('span', { className: 'git-timeline-title' }, 'git activity')); | |
| var commits = 0, pushes = 0, branches = 0; | |
| for (var gi = 0; gi < allGitOps.length; gi++) { | |
| var gt = allGitOps[gi].type; | |
| if (gt === 'commit') commits++; | |
| else if (gt === 'push') pushes++; | |
| else if (gt === 'branch-create' || gt === 'branch-switch') branches++; | |
| } | |
| var countParts = []; | |
| if (commits > 0) countParts.push(commits + ' commit' + (commits > 1 ? 's' : '')); | |
| if (pushes > 0) countParts.push(pushes + ' push' + (pushes > 1 ? 'es' : '')); | |
| if (branches > 0) countParts.push(branches + ' branch op' + (branches > 1 ? 's' : '')); | |
| if (countParts.length === 0) countParts.push(allGitOps.length + ' operation' + (allGitOps.length > 1 ? 's' : '')); | |
| header.appendChild(h('span', { className: 'git-timeline-count' }, countParts.join(' · '))); | |
| wrap.appendChild(header); | |
| const opsDiv = h('div', { className: 'git-timeline-ops' }); | |
| for (const op of allGitOps.slice(0, 20)) { | |
| var iconCls = 'git-op-icon '; | |
| var icon = ''; | |
| if (op.type === 'commit') { iconCls += 'commit'; icon = '●'; } | |
| else if (op.type === 'push') { iconCls += 'push'; icon = '↑'; } | |
| else if (op.type === 'branch-create') { iconCls += 'branch'; icon = '⑃'; } | |
| else if (op.type === 'branch-switch') { iconCls += 'branch'; icon = '⇢'; } | |
| else if (op.type === 'merge') { iconCls += 'merge'; icon = '⑂'; } | |
| else if (op.type === 'rebase') { iconCls += 'merge'; icon = '↻'; } | |
| else if (op.type === 'tag') { iconCls += 'push'; icon = '◆'; } | |
| else { iconCls += 'other'; icon = '·'; } | |
| var detail = h('span', { className: 'git-op-detail' }); | |
| if (op.type === 'commit') { | |
| if (op.hash) detail.appendChild(h('span', { className: 'git-op-hash' }, op.hash.slice(0, 7))); | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, op.message || '(no message)')); | |
| } else if (op.type === 'push') { | |
| var pushText = 'push'; | |
| if (op.remote) pushText += ' ' + op.remote; | |
| if (op.branch) pushText += ' ' + op.branch; | |
| if (op.tags) pushText += ' --tags'; | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, pushText)); | |
| } else if (op.type === 'branch-create') { | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, 'create ')); | |
| detail.appendChild(h('span', { className: 'git-op-branch-name' }, op.branch || '?')); | |
| } else if (op.type === 'branch-switch') { | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, 'switch to ')); | |
| detail.appendChild(h('span', { className: 'git-op-branch-name' }, op.branch || '?')); | |
| } else if (op.type === 'merge') { | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, 'merge ')); | |
| detail.appendChild(h('span', { className: 'git-op-branch-name' }, op.branch || '?')); | |
| } else if (op.type === 'rebase') { | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, 'rebase onto ')); | |
| detail.appendChild(h('span', { className: 'git-op-branch-name' }, op.branch || '?')); | |
| } else if (op.type === 'tag') { | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, 'tag ')); | |
| detail.appendChild(h('span', { className: 'git-op-branch-name' }, op.tag || '?')); | |
| } else if (op.type === 'stash') { | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, 'stash')); | |
| } else { | |
| detail.appendChild(h('span', { className: 'git-op-msg' }, op.type)); | |
| } | |
| var chapterLink = h('span', { | |
| className: 'git-op-chapter', | |
| onClick: (function(idx) { return function(ev) { | |
| ev.stopPropagation(); | |
| jumpToChapter(idx); | |
| }; })(op.chapterIdx) | |
| }, 'ch ' + (op.chapterIdx + 1)); | |
| opsDiv.appendChild(h('div', { className: 'git-op' }, | |
| h('span', { className: iconCls }, icon), | |
| detail, | |
| chapterLink | |
| )); | |
| } | |
| if (allGitOps.length > 20) { | |
| opsDiv.appendChild(h('div', { className: 'git-op' }, | |
| h('span', { className: 'git-op-icon other' }, '…'), | |
| h('span', { className: 'git-op-detail' }, | |
| h('span', { className: 'git-op-msg' }, (allGitOps.length - 20) + ' more operations') | |
| ) | |
| )); | |
| } | |
| wrap.appendChild(opsDiv); | |
| return wrap; | |
| } | |
| function applyFileFilter() { | |
| const chapters = getChapters(); | |
| const items = document.querySelectorAll('.file-hotspot-item'); | |
| items.forEach(function(item) { | |
| const path = item.querySelector('.file-hotspot-path'); | |
| if (!path) return; | |
| if (activeFileFilter && path.textContent === activeFileFilter) { | |
| item.classList.add('active'); | |
| } else { | |
| item.classList.remove('active'); | |
| } | |
| }); | |
| if (activeFileFilter) { | |
| let visible = 0; | |
| for (let i = 0; i < chapters.length; i++) { | |
| const el = document.getElementById('chapter-' + i); | |
| if (!el) continue; | |
| const hasFile = activeFileFilter in chapters[i].files; | |
| const matchesOther = chapterMatchesFilter(chapters[i]); | |
| if (hasFile && matchesOther) { | |
| el.classList.remove('filter-hidden'); | |
| visible++; | |
| } else { | |
| el.classList.add('filter-hidden'); | |
| } | |
| } | |
| const countEl = document.getElementById('filter-count'); | |
| if (countEl) countEl.textContent = visible + '/' + chapters.length; | |
| } else { | |
| applyFilters(); | |
| } | |
| } | |
| var minimapEl = null; | |
| var minimapTooltipEl = null; | |
| var minimapViewportEl = null; | |
| var minimapBlocks = []; | |
| var minimapScrollTicking = false; | |
| var MINIMAP_THRESHOLD = 10; | |
| function renderMiniMap() { | |
| if (minimapEl && minimapEl.parentNode) minimapEl.parentNode.removeChild(minimapEl); | |
| if (minimapTooltipEl && minimapTooltipEl.parentNode) minimapTooltipEl.parentNode.removeChild(minimapTooltipEl); | |
| minimapBlocks = []; | |
| var chapters = getChapters(); | |
| if (chapters.length < MINIMAP_THRESHOLD) return; | |
| if (!minimapTooltipEl) { | |
| minimapTooltipEl = document.createElement('div'); | |
| minimapTooltipEl.className = 'minimap-tooltip'; | |
| document.body.appendChild(minimapTooltipEl); | |
| } | |
| minimapEl = document.createElement('div'); | |
| minimapEl.className = 'minimap'; | |
| var maxTokens = 0; | |
| for (var i = 0; i < chapters.length; i++) { | |
| var tok = chapters[i].tokens.input + chapters[i].tokens.output; | |
| if (tok > maxTokens) maxTokens = tok; | |
| } | |
| var useTurns = maxTokens === 0; | |
| var maxTurns = 0; | |
| if (useTurns) { | |
| for (var i = 0; i < chapters.length; i++) { | |
| if (chapters[i].turns > maxTurns) maxTurns = chapters[i].turns; | |
| } | |
| } | |
| // Budget: approx 80vh minus padding/gaps. Each block: min 2px, max 16px. | |
| var viewH = window.innerHeight * 0.8; | |
| var gapSpace = (chapters.length - 1) * 1 + 8; | |
| var availH = viewH - gapSpace; | |
| var minH = 2; | |
| var maxH = Math.min(16, Math.max(minH, Math.floor(availH / chapters.length))); | |
| minimapViewportEl = document.createElement('div'); | |
| minimapViewportEl.className = 'minimap-viewport'; | |
| minimapEl.appendChild(minimapViewportEl); | |
| for (var i = 0; i < chapters.length; i++) { | |
| var ch = chapters[i]; | |
| var outcome = ch.outcome || 'clean'; | |
| var val = useTurns | |
| ? (maxTurns > 0 ? ch.turns / maxTurns : 1) | |
| : ((ch.tokens.input + ch.tokens.output) / maxTokens); | |
| var height = Math.round(minH + val * (maxH - minH)); | |
| var block = document.createElement('div'); | |
| block.className = 'minimap-block mm-' + outcome; | |
| block.style.height = height + 'px'; | |
| block.setAttribute('data-idx', String(i)); | |
| if (ch.errors > 0) { | |
| var errDot = document.createElement('span'); | |
| errDot.className = 'minimap-marker mm-error'; | |
| block.appendChild(errDot); | |
| } | |
| if (countGitOpsOfType(ch.gitOps, 'commit') > 0) { | |
| var commitDot = document.createElement('span'); | |
| commitDot.className = 'minimap-marker mm-commit'; | |
| block.appendChild(commitDot); | |
| } | |
| (function(idx) { | |
| block.addEventListener('click', function() { | |
| jumpToChapter(idx); | |
| }); | |
| })(i); | |
| (function(idx, blockEl) { | |
| blockEl.addEventListener('mouseenter', function(ev) { | |
| var c = chapters[idx]; | |
| if (!c) return; | |
| var text = 'Ch ' + (idx + 1); | |
| var promptSnip = c.prompt.slice(0, 50) + (c.prompt.length > 50 ? '...' : ''); | |
| text = text + ': ' + promptSnip; | |
| minimapTooltipEl.textContent = text; | |
| var r = blockEl.getBoundingClientRect(); | |
| var mmRight = window.innerWidth - r.left + 8; | |
| minimapTooltipEl.style.top = (r.top + r.height / 2 - 12) + 'px'; | |
| minimapTooltipEl.style.left = 'auto'; | |
| minimapTooltipEl.style.right = mmRight + 'px'; | |
| minimapTooltipEl.classList.add('visible'); | |
| }); | |
| blockEl.addEventListener('mouseleave', function() { | |
| minimapTooltipEl.classList.remove('visible'); | |
| }); | |
| })(i, block); | |
| minimapBlocks.push(block); | |
| minimapEl.appendChild(block); | |
| } | |
| document.body.appendChild(minimapEl); | |
| } | |
| function updateMiniMapViewport() { | |
| if (!minimapEl || minimapBlocks.length === 0) return; | |
| var chapters = getChapters(); | |
| if (chapters.length < MINIMAP_THRESHOLD) return; | |
| var viewTop = window.scrollY; | |
| var viewBottom = viewTop + window.innerHeight; | |
| var firstVisible = -1; | |
| var lastVisible = -1; | |
| for (var i = 0; i < chapters.length; i++) { | |
| var el = document.getElementById('chapter-' + i); | |
| if (!el || el.classList.contains('filter-hidden')) continue; | |
| var rect = el.getBoundingClientRect(); | |
| var elTop = rect.top + window.scrollY; | |
| var elBottom = elTop + rect.height; | |
| if (elBottom > viewTop && elTop < viewBottom) { | |
| if (firstVisible === -1) firstVisible = i; | |
| lastVisible = i; | |
| } | |
| } | |
| for (var i = 0; i < minimapBlocks.length; i++) { | |
| if (i >= firstVisible && i <= lastVisible && firstVisible !== -1) { | |
| minimapBlocks[i].classList.add('mm-visible'); | |
| } else { | |
| minimapBlocks[i].classList.remove('mm-visible'); | |
| } | |
| } | |
| if (minimapViewportEl && firstVisible >= 0 && lastVisible >= 0) { | |
| var firstBlock = minimapBlocks[firstVisible]; | |
| var lastBlock = minimapBlocks[lastVisible]; | |
| if (firstBlock && lastBlock) { | |
| var containerRect = minimapEl.getBoundingClientRect(); | |
| var firstRect = firstBlock.getBoundingClientRect(); | |
| var lastRect = lastBlock.getBoundingClientRect(); | |
| var vpTop = firstRect.top - containerRect.top; | |
| var vpHeight = (lastRect.top + lastRect.height) - firstRect.top; | |
| minimapViewportEl.style.top = vpTop + 'px'; | |
| minimapViewportEl.style.height = vpHeight + 'px'; | |
| minimapViewportEl.style.display = 'block'; | |
| } | |
| } else if (minimapViewportEl) { | |
| minimapViewportEl.style.display = 'none'; | |
| } | |
| } | |
| window.addEventListener('scroll', function() { | |
| if (!minimapScrollTicking) { | |
| minimapScrollTicking = true; | |
| requestAnimationFrame(function() { | |
| updateMiniMapViewport(); | |
| minimapScrollTicking = false; | |
| }); | |
| } | |
| }); | |
| window.addEventListener('resize', function() { | |
| if (!minimapScrollTicking) { | |
| minimapScrollTicking = true; | |
| requestAnimationFrame(function() { | |
| updateMiniMapViewport(); | |
| minimapScrollTicking = false; | |
| }); | |
| } | |
| }); | |
| function render() { | |
| app.innerHTML = ''; | |
| chaptersCache = null; | |
| app.appendChild(renderHeader()); | |
| app.appendChild(renderSessionSummary()); | |
| app.appendChild(renderActivityTimeline()); | |
| app.appendChild(renderStats()); | |
| app.appendChild(renderGitTimeline()); | |
| app.appendChild(renderErrorSummary()); | |
| app.appendChild(renderWaveform()); | |
| app.appendChild(renderToolFlow()); | |
| app.appendChild(renderToolPerformance()); | |
| app.appendChild(renderCostChart()); | |
| app.appendChild(renderFileHotspot()); | |
| app.appendChild(renderFilterBar()); | |
| app.appendChild(renderChapters()); | |
| requestAnimationFrame(function() { | |
| attachExpandToggles(); | |
| attachChapterTooltips(); | |
| renderMiniMap(); | |
| if (activeFileFilter) applyFileFilter(); | |
| else applyFilters(); | |
| requestAnimationFrame(function() { updateMiniMapViewport(); }); | |
| }); | |
| } | |
| function attachExpandToggles() { | |
| var standalone = app.querySelectorAll('.chapter-output, .chapter-agent-prompt, .chapter-thinking-block, .chapter-mcp-output'); | |
| standalone.forEach(function(block) { | |
| if (block.scrollHeight <= block.clientHeight + 2) return; | |
| if (block.nextElementSibling && block.nextElementSibling.classList.contains('expand-toggle')) return; | |
| var toggle = document.createElement('span'); | |
| toggle.className = 'expand-toggle'; | |
| toggle.textContent = '\u25b8 show more'; | |
| toggle.addEventListener('click', function(ev) { | |
| ev.stopPropagation(); | |
| if (block.classList.contains('expanded')) { | |
| block.classList.remove('expanded'); | |
| toggle.textContent = '\u25b8 show more'; | |
| } else { | |
| block.classList.add('expanded'); | |
| toggle.textContent = '\u25be show less'; | |
| } | |
| }); | |
| block.parentNode.insertBefore(toggle, block.nextSibling); | |
| }); | |
| var diffBlocks = app.querySelectorAll('.chapter-diff-block'); | |
| diffBlocks.forEach(function(diffBlock) { | |
| var children = diffBlock.querySelectorAll('.chapter-diff-del, .chapter-diff-add'); | |
| var anyTruncated = false; | |
| children.forEach(function(child) { | |
| if (child.scrollHeight > child.clientHeight + 2) anyTruncated = true; | |
| }); | |
| if (!anyTruncated) return; | |
| if (diffBlock.nextElementSibling && diffBlock.nextElementSibling.classList.contains('expand-toggle')) return; | |
| var toggle = document.createElement('span'); | |
| toggle.className = 'expand-toggle in-diff'; | |
| toggle.textContent = '\u25b8 show more'; | |
| toggle.addEventListener('click', function(ev) { | |
| ev.stopPropagation(); | |
| var isExpanded = children[0] && children[0].classList.contains('expanded'); | |
| children.forEach(function(child) { | |
| if (isExpanded) child.classList.remove('expanded'); | |
| else child.classList.add('expanded'); | |
| }); | |
| if (isExpanded) { | |
| toggle.textContent = '\u25b8 show more'; | |
| } else { | |
| toggle.textContent = '\u25be show less'; | |
| } | |
| }); | |
| diffBlock.parentNode.insertBefore(toggle, diffBlock.nextSibling); | |
| }); | |
| } | |
| const TOOL_DOT_COLORS = { | |
| Bash: '#e8a44c', Read: '#8b7cf6', Edit: '#4ade80', Write: '#60a5fa', | |
| Agent: '#f472b6', Grep: '#94a3b8', Skill: '#c084fc', WebSearch: '#38bdf8', | |
| WebFetch: '#2dd4bf', TaskCreate: '#fb923c' | |
| }; | |
| var tooltipEl = null; | |
| var tooltipTimer = null; | |
| var tooltipVisible = false; | |
| function getTooltipEl() { | |
| if (!tooltipEl) { | |
| tooltipEl = document.createElement('div'); | |
| tooltipEl.className = 'chapter-tooltip'; | |
| document.body.appendChild(tooltipEl); | |
| } | |
| return tooltipEl; | |
| } | |
| function showChapterTooltip(chapterIdx, anchorEl) { | |
| var chapters = getChapters(); | |
| var ch = chapters[chapterIdx]; | |
| if (!ch) return; | |
| var tip = getTooltipEl(); | |
| var html = ''; | |
| var promptRaw = ch.prompt || ''; | |
| var promptNl = promptRaw.indexOf('\n'); | |
| var promptFirst = promptNl < 0 ? promptRaw : promptRaw.slice(0, promptNl); | |
| var promptLine = promptFirst.slice(0, 80); | |
| if (promptLine.length < promptFirst.length) promptLine += '...'; | |
| html += '<div class="chapter-tooltip-prompt">' + esc(promptLine) + '</div>'; | |
| var toolKeys = chapterToolKeys(ch); | |
| var toolNames = toolKeys.slice().sort(function(a, b) { return ch.toolCounts[b] - ch.toolCounts[a]; }); | |
| if (toolNames.length > 0) { | |
| html += '<div class="chapter-tooltip-tools">'; | |
| for (var i = 0; i < Math.min(toolNames.length, 8); i++) { | |
| var tColor = TOOL_DOT_COLORS[toolNames[i]] || (typeof toolNames[i] === 'string' && toolNames[i].startsWith('mcp__') ? '#5dadec' : '#6b7280'); | |
| var tTitle = fmtMcpName(toolNames[i]); | |
| html += '<span class="chapter-tooltip-tool" style="background:' + tColor + '" title="' + esc(tTitle) + ' ' + ch.toolCounts[toolNames[i]] + '"></span>'; | |
| } | |
| if (toolNames.length > 8) html += '<span style="color:var(--fg3);font-size:10px">+' + (toolNames.length - 8) + '</span>'; | |
| html += '</div>'; | |
| } | |
| if (ch.errors > 0) { | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">errors</span><span class="chapter-tooltip-value errors">' + ch.errors + '</span></div>'; | |
| } | |
| var filePaths = chapterFileKeys(ch); | |
| if (filePaths.length > 0) { | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">files</span><span class="chapter-tooltip-value">' + filePaths.length + ' file' + (filePaths.length !== 1 ? 's' : '') + '</span></div>'; | |
| html += '<div class="chapter-tooltip-files">'; | |
| for (var fi = 0; fi < Math.min(filePaths.length, 3); fi++) { | |
| var short = shortToolPath(filePaths[fi]); | |
| html += '<div class="chapter-tooltip-file">' + esc(short) + '</div>'; | |
| } | |
| if (filePaths.length > 3) html += '<div class="chapter-tooltip-file" style="color:var(--fg3)">+' + (filePaths.length - 3) + ' more</div>'; | |
| html += '</div>'; | |
| } | |
| if (ch.mcpOps.length > 0) { | |
| var mcpServers = {}; | |
| for (var mti = 0; mti < ch.mcpOps.length; mti++) { mcpServers[ch.mcpOps[mti].server] = (mcpServers[ch.mcpOps[mti].server] || 0) + 1; } | |
| var mcpSummary = ''; | |
| var mcpFirst = true; | |
| for (var srv in mcpServers) { | |
| if (!Object.prototype.hasOwnProperty.call(mcpServers, srv)) continue; | |
| if (!mcpFirst) mcpSummary += ', '; | |
| mcpFirst = false; | |
| mcpSummary += srv.replace(/_/g, ' ') + ' \u00d7' + mcpServers[srv]; | |
| } | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">MCP</span><span class="chapter-tooltip-value" style="color:#5dadec">' + esc(mcpSummary) + '</span></div>'; | |
| } | |
| var endMs = new Date(ch.endTimestamp).getTime(); | |
| var startMs = new Date(ch.timestamp).getTime(); | |
| var dur = endMs - startMs; | |
| if (dur > 1000) { | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">duration</span><span class="chapter-tooltip-value">' + formatDuration(dur) + '</span></div>'; | |
| } | |
| var totalTok = ch.tokens.input + ch.tokens.output; | |
| if (totalTok > 0) { | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">tokens</span><span class="chapter-tooltip-value">' + fmtTokens(totalTok) + '</span></div>'; | |
| } | |
| if (ch.deps && ch.deps.fixesFrom.length > 0) { | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">fixes</span><span class="chapter-tooltip-value" style="color:var(--green)">ch ' + ch.deps.fixesFrom.map(function(r){return r+1;}).join(', ') + '</span></div>'; | |
| } else if (ch.deps && ch.deps.continuesFrom.length > 0) { | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">continues</span><span class="chapter-tooltip-value" style="color:var(--accent)">ch ' + ch.deps.continuesFrom.map(function(r){return r+1;}).join(', ') + '</span></div>'; | |
| } | |
| var outcome = ch.outcome || 'clean'; | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">quality</span><span class="chapter-tooltip-value ' + outcome + '">' + outcome + '</span></div>'; | |
| if (ch.efficiency && ch.efficiency.isWasteful) { | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">efficiency</span><span class="chapter-tooltip-value" style="color:var(--orange)">' + ch.efficiency.score + '/100 \u26a0</span></div>'; | |
| } else if (ch.efficiency && ch.efficiency.score < 70) { | |
| html += '<div class="chapter-tooltip-row"><span class="chapter-tooltip-label">efficiency</span><span class="chapter-tooltip-value" style="color:var(--fg2)">' + ch.efficiency.score + '/100</span></div>'; | |
| } | |
| tip.innerHTML = html; | |
| var rect = anchorEl.getBoundingClientRect(); | |
| var tipW = 300; | |
| var tipH = tip.offsetHeight || 150; | |
| var left = rect.left + 40; | |
| var top = rect.bottom + 6; | |
| if (left + tipW > window.innerWidth - 16) { | |
| left = window.innerWidth - tipW - 16; | |
| } | |
| if (left < 8) left = 8; | |
| if (top + tipH > window.innerHeight - 16) { | |
| top = rect.top - tipH - 6; | |
| } | |
| tip.style.left = left + 'px'; | |
| tip.style.top = top + 'px'; | |
| tip.classList.add('visible'); | |
| tooltipVisible = true; | |
| } | |
| function hideChapterTooltip() { | |
| if (tooltipTimer) { clearTimeout(tooltipTimer); tooltipTimer = null; } | |
| if (tooltipEl) { | |
| tooltipEl.classList.remove('visible'); | |
| } | |
| tooltipVisible = false; | |
| } | |
| function attachChapterTooltips() { | |
| var chapterEls = document.querySelectorAll('.chapter'); | |
| chapterEls.forEach(function(el) { | |
| var idx = parseInt(el.id.replace('chapter-', ''), 10); | |
| if (isNaN(idx)) return; | |
| el.addEventListener('mouseenter', function() { | |
| if (el.classList.contains('expanded')) return; | |
| tooltipTimer = setTimeout(function() { | |
| if (!el.classList.contains('expanded')) { | |
| showChapterTooltip(idx, el); | |
| } | |
| }, 300); | |
| }); | |
| el.addEventListener('mouseleave', function() { | |
| hideChapterTooltip(); | |
| }); | |
| }); | |
| } | |
| window.addEventListener('scroll', function() { | |
| if (tooltipVisible) hideChapterTooltip(); | |
| }, { passive: true }); | |
| let focusedChapterIdx = -1; | |
| function getVisibleChapterIndices() { | |
| var chapters = getChapters(); | |
| var visible = []; | |
| for (var i = 0; i < chapters.length; i++) { | |
| var el = document.getElementById('chapter-' + i); | |
| if (el && !el.classList.contains('filter-hidden')) visible.push(i); | |
| } | |
| return visible; | |
| } | |
| function setFocusedChapter(idx) { | |
| if (focusedChapterIdx >= 0) { | |
| var prev = document.getElementById('chapter-' + focusedChapterIdx); | |
| if (prev) prev.classList.remove('kb-focused'); | |
| } | |
| focusedChapterIdx = idx; | |
| if (idx >= 0) { | |
| var el = document.getElementById('chapter-' + idx); | |
| if (el) { | |
| el.classList.add('kb-focused'); | |
| el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| } | |
| } | |
| function clearFocus() { | |
| if (focusedChapterIdx >= 0) { | |
| var prev = document.getElementById('chapter-' + focusedChapterIdx); | |
| if (prev) prev.classList.remove('kb-focused'); | |
| } | |
| focusedChapterIdx = -1; | |
| } | |
| document.addEventListener('keydown', function(ev) { | |
| var searchInput = document.querySelector('.filter-search'); | |
| var isInSearch = document.activeElement === searchInput; | |
| if (ev.key === 'Escape') { | |
| if (isInSearch) { | |
| searchInput.blur(); | |
| if (searchQuery) { | |
| searchQuery = ''; | |
| searchInput.value = ''; | |
| applyFilters(); | |
| } | |
| } | |
| clearFocus(); | |
| return; | |
| } | |
| if (isInSearch) return; | |
| var visible = getVisibleChapterIndices(); | |
| if (!visible.length) return; | |
| if (ev.key === 'j' || ev.key === 'k') { | |
| ev.preventDefault(); | |
| if (focusedChapterIdx < 0) { | |
| setFocusedChapter(ev.key === 'j' ? visible[0] : visible[visible.length - 1]); | |
| } else { | |
| var currentPos = visible.indexOf(focusedChapterIdx); | |
| if (currentPos < 0) { | |
| setFocusedChapter(visible[0]); | |
| } else { | |
| var nextPos = ev.key === 'j' ? currentPos + 1 : currentPos - 1; | |
| if (nextPos >= 0 && nextPos < visible.length) { | |
| setFocusedChapter(visible[nextPos]); | |
| } | |
| } | |
| } | |
| return; | |
| } | |
| if (ev.key === 'Enter' || ev.key === 'o') { | |
| if (focusedChapterIdx >= 0) { | |
| ev.preventDefault(); | |
| var key = 'ch' + focusedChapterIdx; | |
| if (expandedSet.has(key)) { | |
| expandedSet.delete(key); | |
| clearChapterHash(); | |
| } else { | |
| expandedSet.add(key); | |
| setChapterHash(focusedChapterIdx); | |
| } | |
| render(); | |
| requestAnimationFrame(function() { | |
| setFocusedChapter(focusedChapterIdx); | |
| }); | |
| } | |
| return; | |
| } | |
| if (ev.key === '/') { | |
| ev.preventDefault(); | |
| if (searchInput) searchInput.focus(); | |
| return; | |
| } | |
| }); | |
| const SECRET_RULES = [ | |
| { | |
| "id": "openai-api-key", | |
| "description": "OpenAI API Key", | |
| "regex": "sk-[A-Za-z0-9]{16,}", | |
| "keywords": ["sk-"], | |
| "entropy": 3.0 | |
| }, | |
| { | |
| "id": "github-pat", | |
| "description": "GitHub Personal Access Token", | |
| "regex": "ghp_[A-Za-z0-9]{20,}", | |
| "keywords": ["ghp_"], | |
| "entropy": 3.0 | |
| }, | |
| { | |
| "id": "github-fine-grained-pat", | |
| "description": "GitHub Fine-Grained Personal Access Token", | |
| "regex": "github_pat_[A-Za-z0-9_]{20,}", | |
| "keywords": ["github_pat_"], | |
| "entropy": 3.0 | |
| }, | |
| { | |
| "id": "aws-access-key", | |
| "description": "AWS Access Key ID", | |
| "regex": "AKIA[0-9A-Z]{16}", | |
| "keywords": ["akia"], | |
| "entropy": 2.5 | |
| }, | |
| { | |
| "id": "generic-api-key", | |
| "description": "Generic API Key Assignment", | |
| "regex": "(?i)(?:api[_-]?key|apikey|secret[_-]?key|access[_-]?token)[\\s'\"`:=]{0,6}[\\s'\"`:=]{0,3}([A-Za-z0-9_\\-]{16,})", | |
| "keywords": ["api", "key", "secret", "token"], | |
| "secretGroup": 1, | |
| "entropy": 3.0 | |
| }, | |
| { | |
| "id": "bearer-token", | |
| "description": "Bearer Token", | |
| "regex": "(?i)bearer\\s+([A-Za-z0-9._\\-]{20,})", | |
| "keywords": ["bearer"], | |
| "secretGroup": 1, | |
| "entropy": 3.0 | |
| }, | |
| { | |
| "id": "private-key-block", | |
| "description": "Private Key Block", | |
| "regex": "-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", | |
| "keywords": ["private", "key"], | |
| "flags": "g" | |
| }, | |
| { | |
| "id": "huggingface-token", | |
| "description": "Hugging Face Access Token", | |
| "regex": "hf_[A-Za-z0-9]{20,}", | |
| "keywords": ["hf_"], | |
| "entropy": 3.0 | |
| }, | |
| { | |
| "id": "slack-token", | |
| "description": "Slack Token", | |
| "regex": "xox[baprs]-[0-9A-Za-z\\-]{10,}", | |
| "keywords": ["xox"], | |
| "entropy": 2.5 | |
| }, | |
| { | |
| "id": "stripe-secret-key", | |
| "description": "Stripe Secret Key", | |
| "regex": "sk_(?:live|test)_[0-9a-zA-Z]{16,}", | |
| "keywords": ["sk_live", "sk_test"], | |
| "entropy": 3.0 | |
| } | |
| ]; | |
| function shannonEntropy(str) { | |
| if (!str || str.length === 0) return 0; | |
| const freq = new Map(); | |
| for (const c of str) freq.set(c, (freq.get(c) || 0) + 1); | |
| let entropy = 0; | |
| const len = str.length; | |
| for (const count of freq.values()) { | |
| const p = count / len; | |
| entropy -= p * Math.log2(p); | |
| } | |
| return entropy; | |
| } | |
| function redactMatchDisplay(match) { | |
| if (!match) return "****"; | |
| if (match.length <= 8) return "****"; | |
| return match.slice(0, 4) + "…" + match.slice(-4); | |
| } | |
| function normalizeRuleRegex(pattern, flags = "g") { | |
| let source = String(pattern); | |
| let outFlags = flags.includes("g") ? flags : `${flags}g`; | |
| const inlineFlagRe = /^\(\?([imsu]+)\)/; | |
| let match; | |
| while ((match = inlineFlagRe.exec(source))) { | |
| for (const ch of match[1]) { | |
| const jsFlag = PCRE_INLINE_FLAG_MAP[ch]; | |
| if (jsFlag && !outFlags.includes(jsFlag)) outFlags += jsFlag; | |
| } | |
| source = source.slice(match[0].length); | |
| } | |
| return { source, flags: outFlags }; | |
| } | |
| function compileRules(rules) { | |
| return (rules || []).map((rule) => { | |
| let regex; | |
| try { | |
| const { source, flags } = normalizeRuleRegex(rule.regex, rule.flags || "g"); | |
| regex = new RegExp(source, flags); | |
| } catch { | |
| return null; | |
| } | |
| const allowlist = (rule.allowlist?.regexes || []).map((pat) => { | |
| try { | |
| return new RegExp(pat); | |
| } catch { | |
| return null; | |
| } | |
| }).filter(Boolean); | |
| const keywords = (rule.keywords || []).map((k) => String(k).toLowerCase()); | |
| return { ...rule, regex, allowlist, keywords }; | |
| }).filter(Boolean); | |
| } | |
| function textHasKeywords(text, keywords) { | |
| if (!keywords.length) return true; | |
| const lower = text.toLowerCase(); | |
| for (const kw of keywords) { | |
| if (lower.includes(kw)) return true; | |
| } | |
| return false; | |
| } | |
| function isAllowlisted(match, allowlist) { | |
| for (const re of allowlist) { | |
| if (re.test(match)) return true; | |
| } | |
| return false; | |
| } | |
| function scanText(text, compiledRules, ctx, findings, seen) { | |
| if (!text || typeof text !== "string") return; | |
| for (const rule of compiledRules) { | |
| if (!textHasKeywords(text, rule.keywords)) continue; | |
| rule.regex.lastIndex = 0; | |
| let m; | |
| while ((m = rule.regex.exec(text)) !== null) { | |
| const full = m[0]; | |
| const secret = rule.secretGroup != null ? (m[rule.secretGroup] || full) : full; | |
| if (!secret) continue; | |
| if (rule.entropy != null && shannonEntropy(secret) < rule.entropy) continue; | |
| if (isAllowlisted(secret, rule.allowlist)) continue; | |
| const key = `${rule.id}:${secret}:${ctx.eventIndex}:${ctx.field}`; | |
| if (seen.has(key)) continue; | |
| seen.add(key); | |
| findings.push({ | |
| ruleId: rule.id, | |
| description: rule.description, | |
| match: redactMatchDisplay(secret), | |
| secret, | |
| location: { | |
| chapterIndex: ctx.chapterIndex, | |
| eventIndex: ctx.eventIndex, | |
| eventType: ctx.eventType, | |
| toolName: ctx.toolName || null, | |
| field: ctx.field || null, | |
| }, | |
| }); | |
| } | |
| } | |
| } | |
| function scanObjectStrings(obj, compiledRules, ctx, findings, seen, depth = 0) { | |
| if (!obj || typeof obj !== "object" || depth > 8) return; | |
| if (Array.isArray(obj)) { | |
| for (const item of obj) scanObjectStrings(item, compiledRules, ctx, findings, seen, depth + 1); | |
| return; | |
| } | |
| for (const val of Object.values(obj)) { | |
| if (typeof val === "string") scanText(val, compiledRules, ctx, findings, seen); | |
| else if (val && typeof val === "object") scanObjectStrings(val, compiledRules, ctx, findings, seen, depth + 1); | |
| } | |
| } | |
| function scanMcpInfo(mcpInfo, compiledRules, ctx, findings, seen) { | |
| if (!mcpInfo) return; | |
| if (typeof mcpInfo.params === "string") { | |
| scanText(mcpInfo.params, compiledRules, { ...ctx, field: "mcpInfo.params" }, findings, seen); | |
| } | |
| scanObjectStrings(mcpInfo, compiledRules, { ...ctx, field: "mcpInfo" }, findings, seen); | |
| } | |
| function scanDiffInfo(diffInfo, compiledRules, ctx, findings, seen) { | |
| if (!diffInfo) return; | |
| if (diffInfo.oldStr) scanText(diffInfo.oldStr, compiledRules, { ...ctx, field: "diffInfo.oldStr" }, findings, seen); | |
| if (diffInfo.newStr) scanText(diffInfo.newStr, compiledRules, { ...ctx, field: "diffInfo.newStr" }, findings, seen); | |
| if (diffInfo.content) scanText(diffInfo.content, compiledRules, { ...ctx, field: "diffInfo.content" }, findings, seen); | |
| } | |
| function scanToolCall(tc, compiledRules, ctx, findings, seen) { | |
| if (!tc) return; | |
| const toolCtx = { ...ctx, toolName: tc.name || null, field: "toolCalls[].input" }; | |
| if (tc.input) scanText(tc.input, compiledRules, toolCtx, findings, seen); | |
| scanDiffInfo(tc.diffInfo, compiledRules, { ...ctx, toolName: tc.name || null }, findings, seen); | |
| scanMcpInfo(tc.mcpInfo, compiledRules, { ...ctx, toolName: tc.name || null }, findings, seen); | |
| if (tc.agentInfo) scanObjectStrings(tc.agentInfo, compiledRules, { ...ctx, field: "agentInfo" }, findings, seen); | |
| if (tc.webInfo) scanObjectStrings(tc.webInfo, compiledRules, { ...ctx, field: "webInfo" }, findings, seen); | |
| } | |
| function scanAgentHistory(agentHistory, compiledRules, findings, seen) { | |
| if (!agentHistory || !Array.isArray(agentHistory)) return; | |
| for (let i = 0; i < agentHistory.length; i++) { | |
| const entry = agentHistory[i]; | |
| const ctx = { | |
| chapterIndex: 0, | |
| eventIndex: i, | |
| eventType: "agentHistory", | |
| toolName: entry?.agentId || null, | |
| field: "agentHistory.record", | |
| }; | |
| if (entry?.record) scanObjectStrings(entry.record, compiledRules, ctx, findings, seen); | |
| } | |
| } | |
| function scanSessionScalars(session, compiledRules, findings, seen) { | |
| const scalarFields = ["cwd", "gitBranch", "title", "model", "project", "sessionId"]; | |
| for (const field of scalarFields) { | |
| const val = session[field]; | |
| if (typeof val !== "string" || !val) continue; | |
| const ctx = { | |
| chapterIndex: 0, | |
| eventIndex: -1, | |
| eventType: "session", | |
| toolName: null, | |
| field, | |
| }; | |
| scanText(val, compiledRules, ctx, findings, seen); | |
| } | |
| } | |
| function scanSessionForSecrets(session, rules) { | |
| const compiledRules = compileRules(rules); | |
| const findings = []; | |
| const seen = new Set(); | |
| const events = session.events || []; | |
| let chapterIndex = 0; | |
| scanSessionScalars(session, compiledRules, findings, seen); | |
| for (let eventIndex = 0; eventIndex < events.length; eventIndex++) { | |
| const e = events[eventIndex]; | |
| if (eventIndex > 0 && e.type === "user" && e.text) chapterIndex++; | |
| const ctx = { | |
| chapterIndex, | |
| eventIndex, | |
| eventType: e.type, | |
| toolName: null, | |
| field: "text", | |
| }; | |
| if (e.text) scanText(e.text, compiledRules, ctx, findings, seen); | |
| if (Array.isArray(e.thinking)) { | |
| for (const block of e.thinking) { | |
| scanText(block, compiledRules, { ...ctx, field: "thinking" }, findings, seen); | |
| } | |
| } | |
| if (Array.isArray(e.toolCalls)) { | |
| for (const tc of e.toolCalls) scanToolCall(tc, compiledRules, ctx, findings, seen); | |
| } | |
| if (e.diffInfo) scanDiffInfo(e.diffInfo, compiledRules, ctx, findings, seen); | |
| if (e.mcpInfo) scanMcpInfo(e.mcpInfo, compiledRules, ctx, findings, seen); | |
| } | |
| scanAgentHistory(session.agentHistory, compiledRules, findings, seen); | |
| findings.sort((a, b) => { | |
| const la = a.location; | |
| const lb = b.location; | |
| if (la.chapterIndex !== lb.chapterIndex) return la.chapterIndex - lb.chapterIndex; | |
| if (la.eventIndex !== lb.eventIndex) return la.eventIndex - lb.eventIndex; | |
| return (la.toolName || "").localeCompare(lb.toolName || ""); | |
| }); | |
| return findings; | |
| } | |
| function redactString(str, secrets) { | |
| if (!str || typeof str !== "string") return str; | |
| let out = str; | |
| for (const secret of secrets) { | |
| if (!secret || !out.includes(secret)) continue; | |
| out = out.split(secret).join("[REDACTED]"); | |
| } | |
| return out; | |
| } | |
| function redactValue(val, secrets) { | |
| if (typeof val === "string") return redactPemBlocks(redactString(val, secrets)); | |
| if (Array.isArray(val)) return val.map((item) => redactValue(item, secrets)); | |
| if (val && typeof val === "object") { | |
| for (const key of Object.keys(val)) { | |
| val[key] = redactValue(val[key], secrets); | |
| } | |
| return val; | |
| } | |
| return val; | |
| } | |
| function redactPemBlocks(str) { | |
| if (!str || typeof str !== "string") return str; | |
| return str.replace(PEM_BLOCK_RE, "[REDACTED]"); | |
| } | |
| function redactSession(session, findings) { | |
| if (!findings.length) return structuredClone(session); | |
| const secrets = [...new Set(findings.map((f) => f.secret).filter(Boolean))]; | |
| secrets.sort((a, b) => b.length - a.length); | |
| const hasPemFinding = findings.some((f) => f.ruleId?.includes("private-key")); | |
| const copy = redactValue(structuredClone(session), secrets); | |
| if (hasPemFinding) return redactPemInTree(copy); | |
| return copy; | |
| } | |
| const GH_LS_KEY = 'tracequest-gh-token'; | |
| const HF_LS_KEY = 'tracequest-hf-token'; | |
| const HF_REPO_LS_KEY = 'tracequest-hf-repo'; | |
| const HF_PREUPLOAD_THRESHOLD = 1048576; | |
| function sanitizePathSegment(seg) { | |
| var s = String(seg || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '-'); | |
| return s || 'unknown'; | |
| } | |
| function escHtml(s) { | |
| return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| } | |
| function isAllowedApexHost(hostname, apex) { | |
| if (hostname === apex) return true; | |
| var suffix = '.' + apex; | |
| if (hostname.slice(-suffix.length) !== suffix) return false; | |
| var sub = hostname.slice(0, -suffix.length); | |
| return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(sub); | |
| } | |
| function buildGistHostPreviewUrl(gistId, filename) { | |
| if (!gistId) return null; | |
| var base = 'https://gisthost.github.io/?' + gistId; | |
| if (filename && filename !== 'index.html') return base + '/' + filename; | |
| return base; | |
| } | |
| function isAllowedShareUrl(url) { | |
| try { | |
| var u = new URL(url); | |
| return u.protocol === 'https:' && ( | |
| u.hostname === 'gisthost.github.io' || | |
| isAllowedApexHost(u.hostname, 'gist.github.com') || | |
| isAllowedApexHost(u.hostname, 'huggingface.co') | |
| ); | |
| } catch (e) { return false; } | |
| } | |
| function showShareSuccess(statusEl, url, secondaryUrl) { | |
| if (!url || typeof url !== 'string') { | |
| statusEl.className = 'hf-modal-status hf-error'; | |
| statusEl.textContent = 'Share succeeded but no preview URL was returned'; | |
| return; | |
| } | |
| statusEl.className = 'hf-modal-status hf-success'; | |
| statusEl.textContent = ''; | |
| statusEl.appendChild(document.createTextNode('Shared! ')); | |
| if (isAllowedShareUrl(url)) { | |
| var link = document.createElement('a'); | |
| link.href = url; | |
| link.target = '_blank'; | |
| link.rel = 'noopener noreferrer'; | |
| link.textContent = url; | |
| statusEl.appendChild(link); | |
| } else { | |
| statusEl.appendChild(document.createTextNode(url)); | |
| } | |
| if (secondaryUrl && isAllowedShareUrl(secondaryUrl)) { | |
| statusEl.appendChild(document.createTextNode(' (')); | |
| var secondaryLink = document.createElement('a'); | |
| secondaryLink.href = secondaryUrl; | |
| secondaryLink.target = '_blank'; | |
| secondaryLink.rel = 'noopener noreferrer'; | |
| secondaryLink.textContent = 'raw gist'; | |
| statusEl.appendChild(secondaryLink); | |
| statusEl.appendChild(document.createTextNode(')')); | |
| } | |
| var copyBtn = document.createElement('button'); | |
| copyBtn.type = 'button'; | |
| copyBtn.className = 'hf-modal-cancel'; | |
| copyBtn.style.marginLeft = '8px'; | |
| copyBtn.textContent = 'Copy URL'; | |
| copyBtn.addEventListener('click', function() { | |
| if (navigator.clipboard && navigator.clipboard.writeText) { | |
| navigator.clipboard.writeText(url).then(function() { | |
| copyBtn.textContent = 'Copied!'; | |
| }).catch(function() {}); | |
| } | |
| }); | |
| statusEl.appendChild(copyBtn); | |
| } | |
| function shareGist(html, meta, token, isPrivate) { | |
| var sessionId = meta.sessionId || 'session'; | |
| var source = meta.source || 'claude'; | |
| var filename = 'tracequest-' + sanitizePathSegment(source) + '-' + sanitizePathSegment(sessionId.slice(0, 8)) + '.html'; | |
| var description = source + ' session — ' + (meta.firstPrompt || '(no prompt)') + ' (' + (meta.model || 'unknown') + ', ' + (meta.durationFormatted || '—') + ')'; | |
| return fetch('https://api.github.com/gists', { | |
| method: 'POST', | |
| headers: { | |
| Accept: 'application/vnd.github+json', | |
| Authorization: 'Bearer ' + token, | |
| 'Content-Type': 'application/json', | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| }, | |
| body: JSON.stringify({ | |
| description: description, | |
| public: !isPrivate, | |
| files: { [filename]: { content: html } }, | |
| }), | |
| }).then(function(res) { | |
| if (!res.ok) return res.text().then(function(t) { throw new Error('Gist upload failed (' + res.status + '): ' + t); }); | |
| return res.json(); | |
| }).then(function(data) { | |
| if (!data || !data.id) throw new Error('Gist upload succeeded but response missing id'); | |
| // gistUrl matches Node adapter url field (raw GitHub gist, not gisthost preview). | |
| return { | |
| previewUrl: buildGistHostPreviewUrl(data.id, filename), | |
| gistUrl: data.html_url, | |
| }; | |
| }); | |
| } | |
| function hfPreuploadFile(namespace, repoName, path, content, token) { | |
| var size = new Blob([content]).size; | |
| var sample = content.slice(0, 512); | |
| return fetch('https://huggingface.co/api/datasets/' + namespace + '/' + repoName + '/preupload/main', { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ files: [{ path: path, size: size, sample: sample }] }), | |
| }).then(function(res) { | |
| if (!res.ok) return res.text().then(function(t) { throw new Error('HF preupload failed: ' + t); }); | |
| return res.json(); | |
| }).then(function(data) { | |
| var info = data.files && data.files[0]; | |
| if (!info || !info.uploadUrl) throw new Error('HF preupload missing uploadUrl'); | |
| return fetch(info.uploadUrl, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/octet-stream' }, | |
| body: content, | |
| }).then(function(putRes) { | |
| if (!putRes.ok) throw new Error('HF preupload PUT failed'); | |
| return { operation: 'addOrUpdate', path: path, oid: info.oid }; | |
| }); | |
| }); | |
| } | |
| function hfFileOp(namespace, repoName, path, content, token) { | |
| var size = new Blob([content]).size; | |
| if (size >= HF_PREUPLOAD_THRESHOLD) { | |
| return hfPreuploadFile(namespace, repoName, path, content, token); | |
| } | |
| return Promise.resolve({ operation: 'addOrUpdate', path: path, content: content }); | |
| } | |
| function resolveHfRepo(token, repoInput) { | |
| if (repoInput && repoInput.trim()) return Promise.resolve(repoInput.trim()); | |
| return fetch('https://huggingface.co/api/whoami-v2', { | |
| headers: { Authorization: 'Bearer ' + token }, | |
| }).then(function(res) { | |
| if (!res.ok) throw new Error('HF whoami failed'); | |
| return res.json(); | |
| }).then(function(data) { | |
| var name = data.name || data.fullname; | |
| if (!name) throw new Error('Could not resolve HF username'); | |
| return name + '/tracequest-sessions'; | |
| }); | |
| } | |
| function shareHf(html, sidecar, token, repo, isPrivate) { | |
| return resolveHfRepo(token, repo).then(function(targetRepo) { | |
| var parts = targetRepo.split('/').filter(Boolean); | |
| if (parts.length !== 2) throw new Error('HF repo must be namespace/repo'); | |
| var namespace = parts[0]; | |
| var repoName = parts[1]; | |
| var source = sanitizePathSegment(sidecar.source || 'claude'); | |
| var sessionId = sanitizePathSegment(sidecar.sessionId || 'session'); | |
| var htmlPath = 'sessions/' + source + '/' + sessionId + '.html'; | |
| var jsonPath = 'sessions/' + source + '/' + sessionId + '.json'; | |
| var readme = '---\nlicense: mit\ntags:\n- agent-traces\n- tracequest\ntask_categories:\n- text-generation\n---\n\n# tracequest sessions\n'; | |
| function commit(ops) { | |
| return fetch('https://huggingface.co/api/datasets/' + namespace + '/' + repoName + '/commit/main', { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ operations: ops }), | |
| }); | |
| } | |
| var username = namespace; | |
| return fetch('https://huggingface.co/api/whoami-v2', { | |
| headers: { Authorization: 'Bearer ' + token }, | |
| }).then(function(w) { return w.ok ? w.json() : {}; }).then(function(who) { | |
| username = who.name || who.fullname || namespace; | |
| return fetch('https://huggingface.co/api/datasets/' + namespace + '/' + repoName, { | |
| headers: { Authorization: 'Bearer ' + token }, | |
| }); | |
| }).then(function(res) { | |
| var isNew = !res.ok; | |
| var chain = Promise.resolve(); | |
| if (isNew) { | |
| var createBody = { name: repoName, type: 'dataset', private: !!isPrivate }; | |
| if (namespace !== username) createBody.organization = namespace; | |
| chain = fetch('https://huggingface.co/api/repos/create', { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(createBody), | |
| }).then(function(r) { | |
| if (!r.ok) return r.text().then(function(t) { throw new Error('HF repo create failed: ' + t); }); | |
| }); | |
| } | |
| return chain.then(function() { | |
| return Promise.all([ | |
| hfFileOp(namespace, repoName, htmlPath, html, token), | |
| hfFileOp(namespace, repoName, jsonPath, JSON.stringify(sidecar, null, 2), token), | |
| ]).then(function(ops) { | |
| if (isNew) ops.push({ operation: 'addOrUpdate', path: 'README.md', content: readme }); | |
| return commit(ops); | |
| }); | |
| }).then(function(r) { | |
| if (!r.ok) return r.text().then(function(t) { throw new Error('HF commit failed: ' + t); }); | |
| return 'https://huggingface.co/datasets/' + namespace + '/' + repoName + '/blob/main/' + htmlPath; | |
| }); | |
| }); | |
| }); | |
| } | |
| function buildShareSidecar(sess) { | |
| var chapters = typeof buildSessionChapters === 'function' ? buildSessionChapters(sess) : []; | |
| var filePaths = []; | |
| if (typeof chapterFileKeys === 'function') { | |
| for (var i = 0; i < chapters.length; i++) { | |
| var keys = chapterFileKeys(chapters[i]); | |
| for (var j = 0; j < keys.length; j++) { | |
| if (filePaths.indexOf(keys[j]) < 0) filePaths.push(keys[j]); | |
| } | |
| } | |
| } | |
| var stats = sess.stats || {}; | |
| var firstPrompt = ''; | |
| for (var k = 0; k < (sess.events || []).length; k++) { | |
| if (sess.events[k].type === 'user' && sess.events[k].text) { | |
| firstPrompt = typeof safeSlice === 'function' ? safeSlice(sess.events[k].text, FIRST_PROMPT_MAX_LEN) : sess.events[k].text.slice(0, 200); | |
| break; | |
| } | |
| } | |
| return { | |
| sessionId: sess.sessionId, | |
| source: sess.source || 'claude', | |
| model: sess.model || '', | |
| project: sess.cwd || '', | |
| firstPrompt: firstPrompt, | |
| eventCount: sess.eventCount || (sess.events ? sess.events.length : 0), | |
| errorCount: stats.errors || 0, | |
| tools: Object.keys(stats.toolCounts || {}), | |
| filePaths: filePaths, | |
| gitBranch: sess.gitBranch || null, | |
| costEstimate: typeof estimateParsedStatsCost === 'function' ? estimateParsedStatsCost(sess.model, stats) : 0, | |
| chapterCount: chapters.length, | |
| }; | |
| } | |
| function clientRenderHTML(redactedSession) { | |
| var cssEl = document.querySelector('style'); | |
| var css = cssEl ? cssEl.textContent : ''; | |
| var scripts = document.getElementsByTagName('script'); | |
| var renderJs = ''; | |
| for (var i = 0; i < scripts.length; i++) { | |
| var txt = scripts[i].textContent || ''; | |
| var marker = 'const SESSION = '; | |
| var idx = txt.indexOf(marker); | |
| if (idx >= 0) { | |
| var after = txt.indexOf(';', idx); | |
| if (after >= 0) renderJs = txt.slice(after + 1); | |
| break; | |
| } | |
| } | |
| var sid = escHtml((redactedSession.sessionId || 'session').slice(0, 8)); | |
| var data = JSON.stringify(redactedSession).replace(/<\//g, '<\\/'); | |
| return '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>tracequest — ' + sid + '</title>\n<style>\n' + css + '\n</style>\n</head>\n<body>\n<div id="app"></div>\n<script>\nconst SESSION = ' + data + ';\n' + renderJs + '\n</scr' + 'ipt>\n</body>\n</html>'; | |
| } | |
| var shareModalEl = null; | |
| function closeShareModal() { | |
| if (shareModalEl && shareModalEl.parentNode) shareModalEl.parentNode.removeChild(shareModalEl); | |
| shareModalEl = null; | |
| } | |
| function openShareModal() { | |
| closeShareModal(); | |
| var target = 'gist'; | |
| var overlay = h('div', { className: 'hf-modal-overlay' }); | |
| shareModalEl = overlay; | |
| var modal = h('div', { className: 'hf-modal' }); | |
| modal.appendChild(h('div', { className: 'hf-modal-title' }, 'Share session')); | |
| var targetRow = h('div', { style: 'display:flex;gap:12px;margin-bottom:8px;' }); | |
| var gistRadio = h('input', { type: 'radio', name: 'share-target' }); | |
| gistRadio.value = 'gist'; | |
| gistRadio.checked = true; | |
| var hfRadio = h('input', { type: 'radio', name: 'share-target' }); | |
| hfRadio.value = 'hf'; | |
| targetRow.appendChild(h('label', null, gistRadio, ' Gist')); | |
| targetRow.appendChild(h('label', null, hfRadio, ' HF')); | |
| modal.appendChild(targetRow); | |
| modal.appendChild(h('label', { className: 'hf-modal-label' }, 'Access token')); | |
| var tokenInput = h('input', { className: 'hf-modal-input', type: 'password', placeholder: 'GitHub or Hugging Face token' }); | |
| tokenInput.value = localStorage.getItem(GH_LS_KEY) || ''; | |
| modal.appendChild(tokenInput); | |
| modal.appendChild(h('div', { className: 'hf-modal-hint' }, 'Saved locally in your browser')); | |
| modal.appendChild(h('label', { className: 'hf-modal-label' }, 'HF dataset repo (namespace/repo)')); | |
| var repoInput = h('input', { className: 'hf-modal-input', type: 'text', placeholder: 'username/tracequest-sessions (optional — defaults via whoami)' }); | |
| repoInput.value = localStorage.getItem(HF_REPO_LS_KEY) || ''; | |
| repoInput.style.display = 'none'; | |
| modal.appendChild(repoInput); | |
| var privateRow = h('label', { style: 'display:flex;align-items:center;gap:6px;margin-top:12px;font-size:12px;color:var(--fg2);' }); | |
| var privateChk = h('input', { type: 'checkbox' }); | |
| privateRow.appendChild(privateChk); | |
| privateRow.appendChild(document.createTextNode('Private (secret gist / private HF repo)')); | |
| modal.appendChild(privateRow); | |
| var privateHint = h('div', { className: 'hf-modal-hint', style: 'display:none;margin-top:4px;' }, 'Secret gists: gisthost previews require GitHub sign-in; use the raw gist link if needed.'); | |
| modal.appendChild(privateHint); | |
| var findingsPanel = h('div', { className: 'hf-modal-status', style: 'color:var(--yellow,#e0c45e);' }); | |
| findingsPanel.style.display = 'none'; | |
| modal.appendChild(findingsPanel); | |
| var confirmRow = h('label', { style: 'display:none;align-items:center;gap:6px;margin-top:8px;font-size:12px;color:var(--fg2);' }); | |
| var confirmChk = h('input', { type: 'checkbox' }); | |
| confirmRow.appendChild(confirmChk); | |
| confirmRow.appendChild(document.createTextNode('I understand — share anyway')); | |
| modal.appendChild(confirmRow); | |
| var statusEl = h('div', { className: 'hf-modal-status' }); | |
| modal.appendChild(statusEl); | |
| var spinnerEl = h('span', { className: 'hf-modal-hint', style: 'display:none;margin-left:6px;' }, '⏳'); | |
| statusEl.appendChild(spinnerEl); | |
| var actions = h('div', { className: 'hf-modal-actions' }); | |
| var cancelBtn = h('button', { className: 'hf-modal-cancel', type: 'button' }, 'Cancel'); | |
| var shareBtn = h('button', { className: 'hf-modal-share', type: 'button' }, 'Share'); | |
| actions.appendChild(cancelBtn); | |
| actions.appendChild(shareBtn); | |
| modal.appendChild(actions); | |
| overlay.appendChild(modal); | |
| function updatePrivateHint() { | |
| privateHint.style.display = privateChk.checked && gistRadio.checked ? 'block' : 'none'; | |
| } | |
| function updateTarget() { | |
| target = gistRadio.checked ? 'gist' : 'hf'; | |
| repoInput.style.display = target === 'hf' ? 'block' : 'none'; | |
| tokenInput.value = localStorage.getItem(target === 'gist' ? GH_LS_KEY : HF_LS_KEY) || ''; | |
| updatePrivateHint(); | |
| scanPreview(); | |
| } | |
| function scanPreview() { | |
| var findings = scanSessionForSecrets(session, SECRET_RULES); | |
| if (findings.length === 0) { | |
| findingsPanel.style.display = 'none'; | |
| confirmRow.style.display = 'none'; | |
| shareBtn.disabled = false; | |
| return; | |
| } | |
| findingsPanel.style.display = 'block'; | |
| confirmRow.style.display = 'flex'; | |
| findingsPanel.textContent = findings.length + ' potential secret(s) detected. Confirm to enable Share.'; | |
| shareBtn.disabled = !confirmChk.checked; | |
| } | |
| gistRadio.addEventListener('change', updateTarget); | |
| hfRadio.addEventListener('change', updateTarget); | |
| privateChk.addEventListener('change', updatePrivateHint); | |
| confirmChk.addEventListener('change', function() { shareBtn.disabled = !confirmChk.checked; }); | |
| tokenInput.addEventListener('input', function() { | |
| localStorage.setItem(target === 'gist' ? GH_LS_KEY : HF_LS_KEY, tokenInput.value); | |
| }); | |
| repoInput.addEventListener('input', function() { | |
| localStorage.setItem(HF_REPO_LS_KEY, repoInput.value); | |
| }); | |
| cancelBtn.addEventListener('click', closeShareModal); | |
| overlay.addEventListener('click', function(ev) { if (ev.target === overlay) closeShareModal(); }); | |
| shareBtn.addEventListener('click', function() { | |
| var token = tokenInput.value.trim(); | |
| if (!token) { | |
| statusEl.className = 'hf-modal-status hf-error'; | |
| statusEl.textContent = 'Token required'; | |
| return; | |
| } | |
| localStorage.setItem(target === 'gist' ? GH_LS_KEY : HF_LS_KEY, token); | |
| if (target === 'hf' && repoInput.value.trim()) { | |
| localStorage.setItem(HF_REPO_LS_KEY, repoInput.value.trim()); | |
| } | |
| var findings = scanSessionForSecrets(session, SECRET_RULES); | |
| if (findings.length && !confirmChk.checked) { | |
| statusEl.className = 'hf-modal-status hf-error'; | |
| statusEl.textContent = 'Confirm sharing with detected secrets'; | |
| return; | |
| } | |
| shareBtn.disabled = true; | |
| statusEl.className = 'hf-modal-status'; | |
| statusEl.textContent = 'Uploading…'; | |
| spinnerEl.style.display = 'inline'; | |
| var redacted = redactSession(session, findings); | |
| var html = clientRenderHTML(redacted); | |
| var sidecar = buildShareSidecar(redacted); | |
| sidecar.firstPrompt = sidecar.firstPrompt || ''; | |
| var meta = { | |
| sessionId: redacted.sessionId, | |
| source: redacted.source, | |
| model: redacted.model, | |
| firstPrompt: sidecar.firstPrompt, | |
| durationFormatted: redacted.durationMs > 0 && typeof formatDuration === 'function' ? formatDuration(redacted.durationMs) : '—', | |
| }; | |
| var upload = target === 'gist' | |
| ? shareGist(html, meta, token, privateChk.checked) | |
| : shareHf(html, sidecar, token, repoInput.value.trim(), privateChk.checked); | |
| upload.then(function(result) { | |
| spinnerEl.style.display = 'none'; | |
| var previewUrl = typeof result === 'string' ? result : (result && result.previewUrl) || null; | |
| var gistUrl = result && result.gistUrl ? result.gistUrl : null; | |
| showShareSuccess(statusEl, previewUrl, gistUrl); | |
| shareBtn.disabled = false; | |
| shareBtn.textContent = 'Share'; | |
| }).catch(function(err) { | |
| spinnerEl.style.display = 'none'; | |
| statusEl.className = 'hf-modal-status hf-error'; | |
| statusEl.textContent = err && err.message ? err.message : String(err); | |
| shareBtn.disabled = false; | |
| }); | |
| }); | |
| document.body.appendChild(overlay); | |
| updateTarget(); | |
| } | |
| // Read URL state before first render | |
| readUrlState(); | |
| render(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment