388 lines
19 KiB
HTML
388 lines
19 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Trace JSONL Compare</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Public+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
|
|
<link rel="stylesheet" href="./styles.css">
|
|
</head>
|
|
<body>
|
|
<div id="app" class="shell" v-cloak>
|
|
<header class="topbar">
|
|
<div class="topbar-inner">
|
|
<div>
|
|
<h1 class="brand-title">Webwright Trajectory Comparison</h1>
|
|
<div class="brand-copy">
|
|
Compare Codex JSONL, Webwright raw and trajectory traces, and Copilot session markdown
|
|
</div>
|
|
</div>
|
|
<nav class="tab-strip">
|
|
<button class="tab" :class="{ active: activeView === 'summary' }" @click="activeView = 'summary'">Summary View</button>
|
|
<button class="tab" :class="{ active: activeView === 'traces' }" @click="activeView = 'traces'">Detailed Trace</button>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="upload-rail">
|
|
<article
|
|
v-for="slot in slots"
|
|
:key="slot.key"
|
|
class="upload-card"
|
|
@dragenter.prevent="dragTarget = slot.key"
|
|
@dragover.prevent="dragTarget = slot.key"
|
|
@dragleave.prevent="dragTarget = null"
|
|
@drop.prevent="handleDrop(slot.key, $event)"
|
|
>
|
|
<div class="upload-head">
|
|
<div>
|
|
<h2 class="upload-title">{{ slot.title }}</h2>
|
|
<div class="upload-subtitle">{{ slot.subtitle }}</div>
|
|
</div>
|
|
<div class="badge-row" v-if="traceBySide(slot.key)">
|
|
<span class="badge good">{{ traceBySide(slot.key).formatLabel }}</span>
|
|
<span class="badge">commands {{ traceBySide(slot.key).summary.commandRuns }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="upload-body" :style="dragTarget === slot.key ? 'border-color: var(--accent); background: #f1fbf9;' : ''">
|
|
<template v-if="traceBySide(slot.key)">
|
|
<div class="upload-file">{{ traceBySide(slot.key).fileName }}</div>
|
|
<div class="upload-subtitle">{{ traceBySide(slot.key).description }}</div>
|
|
<div class="badge-row">
|
|
<span class="badge">thoughts {{ traceBySide(slot.key).summary.thoughts }}</span>
|
|
<span class="badge">commands {{ traceBySide(slot.key).summary.commands }}</span>
|
|
<span class="badge">finals {{ traceBySide(slot.key).summary.finalResponses }}</span>
|
|
<span class="badge" :class="traceBySide(slot.key).tokenModeLabel === 'exact' ? 'good' : 'warn'">{{ tokenTotalDisplay(traceBySide(slot.key)) }}</span>
|
|
</div>
|
|
<div class="button-row">
|
|
<label class="button-label primary">
|
|
Replace file
|
|
<input type="file" accept=".jsonl,.json,.md,.txt" @change="handleFileInput(slot.key, $event)">
|
|
</label>
|
|
<button class="button" @click="clearTrace(slot.key)">Clear</button>
|
|
</div>
|
|
<div v-if="traceBySide(slot.key).kind === 'raw_response_jsonl'" class="attachment-box">
|
|
<div class="upload-subtitle">
|
|
<span v-if="traceBySide(slot.key).companionFileName">Attached trajectory.json: {{ traceBySide(slot.key).companionFileName }}</span>
|
|
<span v-else>Optional: attach trajectory.json here to replace the estimate with exact cumulative tokens.</span>
|
|
</div>
|
|
<div class="button-row">
|
|
<label class="button-label">
|
|
{{ traceBySide(slot.key).companionFileName ? 'Replace trajectory.json' : 'Attach trajectory.json' }}
|
|
<input type="file" accept=".json" @change="handleCompanionInput(slot.key, $event)">
|
|
</label>
|
|
<button v-if="traceBySide(slot.key).companionFileName" class="button" @click="clearCompanion(slot.key)">Remove trajectory.json</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="upload-file">Drop a trace file here</div>
|
|
<div class="upload-subtitle">
|
|
Accepted: Codex JSONL, Webwright Responses JSONL (raw_responses.jsonl), trajectory.json, and Copilot session markdown.
|
|
</div>
|
|
<div class="button-row">
|
|
<label class="button-label primary">
|
|
Choose file
|
|
<input type="file" accept=".jsonl,.json,.md,.txt" @change="handleFileInput(slot.key, $event)">
|
|
</label>
|
|
</div>
|
|
</template>
|
|
<div class="badge-row" v-if="errorBySide(slot.key)">
|
|
<span class="badge bad">{{ errorBySide(slot.key) }}</span>
|
|
</div>
|
|
<div class="badge-row" v-if="attachmentErrorBySide(slot.key)">
|
|
<span class="badge bad">{{ attachmentErrorBySide(slot.key) }}</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<main class="content">
|
|
<section v-if="!hasAnyTrace" class="panel">
|
|
<div class="empty-state">
|
|
Load at least one trace to inspect extracted thought entries, shell commands, token provenance, and end-state outputs.
|
|
</div>
|
|
</section>
|
|
|
|
<template v-else>
|
|
<section v-if="activeView === 'summary'">
|
|
<section v-if="!bothLoaded" class="panel">
|
|
<div class="empty-state">
|
|
Load a second trace to use the side-by-side comparison matrix. Per-trace summary still works with a single upload.
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" v-if="bothLoaded">
|
|
<div class="panel-head">
|
|
<div>
|
|
<h2 class="panel-title">Comparison Matrix</h2>
|
|
<div class="muted">Every format is normalized into task hints, thoughts, commands, outputs, and token provenance rows.</div>
|
|
</div>
|
|
</div>
|
|
<table class="compare-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Metric</th>
|
|
<th>{{ leftTrace.fileName }}</th>
|
|
<th>{{ rightTrace.fileName }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in comparisonRows" :key="row.label">
|
|
<td>{{ row.label }}</td>
|
|
<td class="mono">{{ row.left }}</td>
|
|
<td class="mono">{{ row.right }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div>
|
|
<h2 class="panel-title">Per-Trace Summary</h2>
|
|
<div class="muted">Exact totals come from trace snapshots when present. Otherwise the page shows estimated output tokens from extracted trace text.</div>
|
|
</div>
|
|
<div class="toolbar-copy">{{ tokenizerStatus }}</div>
|
|
</div>
|
|
<div class="trace-pane-grid">
|
|
<article class="mini-card" v-for="trace in loadedTraces" :key="trace.side + '-summary'">
|
|
<div class="trace-card-head">
|
|
<div>
|
|
<h3>{{ trace.fileName }}</h3>
|
|
<div class="muted">{{ trace.formatLabel }}</div>
|
|
</div>
|
|
<div class="badge-row">
|
|
<span class="badge" :class="trace.tokenModeLabel === 'exact' ? 'good' : 'warn'">{{ trace.tokenModeLabel }}</span>
|
|
<span class="badge">{{ tokenTotalDisplay(trace) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="metric-grid">
|
|
<article class="metric" v-for="metric in trace.metrics" :key="trace.side + '-' + metric.label">
|
|
<div class="metric-label">{{ metric.label }}</div>
|
|
<div class="metric-value">{{ metric.value }}</div>
|
|
<div class="metric-sub">{{ metric.sub }}</div>
|
|
</article>
|
|
</div>
|
|
<div class="task-box" v-if="trace.taskPrompt">
|
|
<strong>Task Hint</strong>
|
|
{{ trace.taskPrompt }}
|
|
</div>
|
|
<div class="task-box">
|
|
<strong>Token Source</strong>
|
|
{{ tokenNote(trace) }}
|
|
</div>
|
|
<table class="compare-table token-table">
|
|
<tbody>
|
|
<tr v-for="row in trace.tokenRows" :key="trace.side + '-token-row-' + row.label">
|
|
<th>{{ row.label }}</th>
|
|
<td class="mono">{{ row.value }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="task-box" v-if="trace.countedFieldLabels.length">
|
|
<strong>Counted Fields</strong>
|
|
{{ trace.countedFieldLabels.join(', ') }}
|
|
</div>
|
|
<div class="bar-list" v-if="trace.tokenBasisRows.length">
|
|
<div class="bar-row" v-for="row in trace.tokenBasisRows" :key="trace.side + '-token-basis-' + row.label">
|
|
<div>
|
|
<div class="mono">{{ row.label }}</div>
|
|
<div class="bar-wrap"><div class="bar" :style="{ width: row.width + '%' }"></div></div>
|
|
</div>
|
|
<div class="mono">{{ row.value }}</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div>
|
|
<h2 class="panel-title">Pattern Breakdown</h2>
|
|
<div class="muted">Top command families and normalized event categories in each trace.</div>
|
|
</div>
|
|
</div>
|
|
<div class="trace-pane-grid">
|
|
<article class="mini-card" v-for="trace in loadedTraces" :key="trace.side + '-bars'">
|
|
<h3>{{ trace.fileName }}</h3>
|
|
<div class="bar-list">
|
|
<div class="bar-row" v-for="row in trace.commandFamilies" :key="trace.side + '-cmd-' + row.label">
|
|
<div>
|
|
<div class="mono">{{ row.label }}</div>
|
|
<div class="bar-wrap"><div class="bar" :style="{ width: row.width + '%' }"></div></div>
|
|
</div>
|
|
<div class="mono">{{ row.value }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="bar-list">
|
|
<div class="bar-row" v-for="row in trace.channelFamilies" :key="trace.side + '-channel-' + row.label">
|
|
<div>
|
|
<div class="mono">{{ row.label }}</div>
|
|
<div class="bar-wrap"><div class="bar" :style="{ width: row.width + '%' }"></div></div>
|
|
</div>
|
|
<div class="mono">{{ row.value }}</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section v-else>
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div>
|
|
<h2 class="panel-title">Detailed Trace</h2>
|
|
<div class="muted">Switch between extracted code, reasoning, commands, and the combined typed timeline.</div>
|
|
</div>
|
|
<div class="tab-strip">
|
|
<button
|
|
v-for="tab in detailTabs"
|
|
:key="tab.value"
|
|
class="tab"
|
|
:class="{ active: traceTab === tab.value }"
|
|
@click="traceTab = tab.value"
|
|
>
|
|
{{ tab.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="trace-grid">
|
|
<article class="trace-card" v-for="trace in loadedTraces" :key="trace.side + '-trace'">
|
|
<div class="trace-card-head">
|
|
<div>
|
|
<h3 class="trace-name">{{ trace.fileName }}</h3>
|
|
<div class="trace-file">{{ trace.filePath || 'Uploaded from local disk' }}</div>
|
|
</div>
|
|
<div class="badge-row">
|
|
<span class="badge good">{{ trace.formatLabel }}</span>
|
|
<span class="badge" :class="trace.tokenModeLabel === 'exact' ? 'good' : 'warn'">{{ tokenTotalDisplay(trace) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="metric-badges" style="margin-top: 12px;">
|
|
<span class="badge">python {{ trace.summary.pythonScripts }}</span>
|
|
<span class="badge">thoughts {{ trace.summary.thoughts }}</span>
|
|
<span class="badge">commands {{ trace.summary.commandRuns }}</span>
|
|
<span class="badge">finals {{ trace.summary.finalResponses }}</span>
|
|
</div>
|
|
|
|
<div v-if="traceTab === 'python'" class="thought-list">
|
|
<template v-if="trace.pythonScripts.length">
|
|
<article class="thought-entry" v-for="script in trace.pythonScripts" :key="trace.side + '-python-' + script.id">
|
|
<div class="thought-head">
|
|
<div class="thought-title">python {{ script.order }}</div>
|
|
<div class="event-time">{{ script.timeLabel }}</div>
|
|
</div>
|
|
<div class="event-meta">
|
|
<span class="badge" v-for="chip in script.chips" :key="script.id + '-' + chip">{{ chip }}</span>
|
|
</div>
|
|
<div class="task-box" style="margin-top: 8px;" v-if="script.title || script.sourceLabel">
|
|
<strong>{{ script.title || 'Python Script' }}</strong>
|
|
{{ script.sourceLabel }}
|
|
</div>
|
|
<div class="detail-sections">
|
|
<section class="detail-section" v-for="(section, sectionIndex) in script.sections" :key="script.id + '-section-' + sectionIndex">
|
|
<div v-if="section.label" class="detail-section-label">{{ section.label }}</div>
|
|
<div v-if="section.type === 'text'" class="detail-text">{{ section.text }}</div>
|
|
<pre v-else class="detail-code-wrap"><code class="detail-code" :class="codeClass(section.language)">{{ section.text }}</code></pre>
|
|
</section>
|
|
</div>
|
|
</article>
|
|
</template>
|
|
<div v-else class="task-box">
|
|
<strong>No Python scripts</strong>
|
|
No Python script blocks were extracted from this trace.
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="traceTab === 'thoughts'" class="thought-list">
|
|
<article class="thought-entry" v-for="thought in trace.thoughtEntries" :key="trace.side + '-thought-' + thought.id">
|
|
<div class="thought-head">
|
|
<div class="thought-title">thought {{ thought.order }}</div>
|
|
<div class="event-time">{{ thought.timeLabel }}</div>
|
|
</div>
|
|
<div class="event-meta">
|
|
<span class="badge" v-for="chip in thought.chips" :key="thought.id + '-' + chip">{{ chip }}</span>
|
|
</div>
|
|
<div class="detail-sections">
|
|
<section class="detail-section" v-for="(section, sectionIndex) in thought.sections" :key="thought.id + '-section-' + sectionIndex">
|
|
<div v-if="section.label" class="detail-section-label">{{ section.label }}</div>
|
|
<div v-if="section.type === 'text'" class="detail-text">{{ section.text }}</div>
|
|
<pre v-else class="detail-code-wrap"><code class="detail-code" :class="codeClass(section.language)">{{ section.text }}</code></pre>
|
|
</section>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<div v-else-if="traceTab === 'commands'" class="run-list">
|
|
<article class="command-run" v-for="run in trace.commandRuns" :key="trace.side + '-run-' + run.id">
|
|
<div class="run-head">
|
|
<div class="run-title">command {{ run.order }}</div>
|
|
<div class="event-time">{{ run.timeLabel }}</div>
|
|
</div>
|
|
<div class="event-meta">
|
|
<span class="badge">{{ run.primaryCommand }}</span>
|
|
<span class="badge">{{ run.channel }}</span>
|
|
<span class="badge" :class="run.statusClass">{{ run.status }}</span>
|
|
<span class="badge" v-if="run.exitCode !== null">exit {{ run.exitCode }}</span>
|
|
</div>
|
|
<div class="detail-sections">
|
|
<section class="detail-section" v-for="(section, sectionIndex) in run.sections" :key="run.id + '-section-' + sectionIndex">
|
|
<div v-if="section.label" class="detail-section-label">{{ section.label }}</div>
|
|
<div v-if="section.type === 'text'" class="detail-text">{{ section.text }}</div>
|
|
<pre v-else class="detail-code-wrap"><code class="detail-code" :class="codeClass(section.language)">{{ section.text }}</code></pre>
|
|
</section>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<div v-else class="event-list">
|
|
<article class="event detail-card" v-for="entry in trace.detailEntries" :key="trace.side + '-detail-' + entry.id">
|
|
<div class="event-head">
|
|
<div>
|
|
<div class="event-kind">{{ entry.kindLabel }}</div>
|
|
<div class="event-summary detail-summary" v-if="shouldShowEntrySummary(entry)">{{ entry.summary }}</div>
|
|
</div>
|
|
<div class="event-time">{{ entry.timeLabel }}</div>
|
|
</div>
|
|
<div class="event-meta">
|
|
<span class="badge" v-for="chip in entry.chips" :key="entry.id + '-' + chip">{{ chip }}</span>
|
|
</div>
|
|
<div class="detail-sections">
|
|
<section class="detail-section" v-for="(section, sectionIndex) in entry.sections" :key="entry.id + '-section-' + sectionIndex">
|
|
<div v-if="section.label" class="detail-section-label">{{ section.label }}</div>
|
|
<div v-if="section.type === 'text'" class="detail-text">{{ section.text }}</div>
|
|
<pre v-else class="detail-code-wrap"><code class="detail-code" :class="codeClass(section.language)">{{ section.text }}</code></pre>
|
|
</section>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
</main>
|
|
</div>
|
|
<div class="boot-overlay" aria-hidden="true">
|
|
<div class="boot-panel">
|
|
<div class="boot-spinner"></div>
|
|
<div class="boot-title">Loading comparison view</div>
|
|
<div class="boot-copy">Preparing trace parser and UI.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
|
|
<script type="module" src="./app.js"></script>
|
|
</body>
|
|
</html>
|