diff --git a/web/app/components/CodeViewer.test.tsx b/web/app/components/CodeViewer.test.tsx
new file mode 100644
index 0000000..be2568e
--- /dev/null
+++ b/web/app/components/CodeViewer.test.tsx
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest';
+
+// Test the escapeHtml and string regex patterns used in CodeViewer
+// We test the logic directly since the component uses internal functions
+
+describe('CodeViewer escapeHtml', () => {
+ // Replicate the escapeHtml function from CodeViewer
+ const escapeHtml = (str: string) => str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ it('escapes double quotes for attribute safety', () => {
+ expect(escapeHtml('class="foo"')).toBe('class="foo"');
+ });
+
+ it('escapes single quotes for attribute safety', () => {
+ expect(escapeHtml("class='foo'")).toBe("class='foo'");
+ });
+
+ it('escapes HTML tags', () => {
+ expect(escapeHtml('
')).toBe('<div>');
+ });
+
+ it('escapes ampersands', () => {
+ expect(escapeHtml('a && b')).toBe('a && b');
+ });
+});
+
+describe('CodeViewer string regex patterns', () => {
+ // Test the improved string patterns
+ const doubleQuotePattern = /"(?:[^"\\]|\\.)*"/;
+ const singleQuotePattern = /'(?:[^'\\]|\\.)*'/;
+ const backtickPattern = /`(?:[^`\\]|\\.)*`/;
+
+ describe('double-quoted strings', () => {
+ it('matches simple double-quoted strings', () => {
+ expect('"hello"'.match(doubleQuotePattern)?.[0]).toBe('"hello"');
+ });
+
+ it('matches strings with escaped quotes', () => {
+ expect('"He said \\"hello\\""'.match(doubleQuotePattern)?.[0]).toBe('"He said \\"hello\\""');
+ });
+
+ it('matches strings with escaped backslashes', () => {
+ expect('"path\\\\to\\\\file"'.match(doubleQuotePattern)?.[0]).toBe('"path\\\\to\\\\file"');
+ });
+
+ it('matches empty strings', () => {
+ expect('""'.match(doubleQuotePattern)?.[0]).toBe('""');
+ });
+ });
+
+ describe('single-quoted strings', () => {
+ it('matches simple single-quoted strings', () => {
+ expect("'hello'".match(singleQuotePattern)?.[0]).toBe("'hello'");
+ });
+
+ it('matches strings with escaped quotes', () => {
+ expect("'It\\'s fine'".match(singleQuotePattern)?.[0]).toBe("'It\\'s fine'");
+ });
+
+ it('matches empty strings', () => {
+ expect("''".match(singleQuotePattern)?.[0]).toBe("''");
+ });
+ });
+
+ describe('backtick strings', () => {
+ it('matches simple backtick strings', () => {
+ expect('`hello`'.match(backtickPattern)?.[0]).toBe('`hello`');
+ });
+
+ it('matches strings with escaped backticks', () => {
+ expect('`use \\`code\\``'.match(backtickPattern)?.[0]).toBe('`use \\`code\\``');
+ });
+
+ it('matches empty strings', () => {
+ expect('``'.match(backtickPattern)?.[0]).toBe('``');
+ });
+ });
+});
diff --git a/web/app/components/CodeViewer.tsx b/web/app/components/CodeViewer.tsx
index f9f4aae..5f9cc4a 100644
--- a/web/app/components/CodeViewer.tsx
+++ b/web/app/components/CodeViewer.tsx
@@ -82,39 +82,68 @@ export function CodeViewer({ code, fileName, language }: CodeViewerProps) {
const detectedLanguage = language || getLanguageFromFileName(fileName);
- // Basic syntax highlighting for common tokens
+ // Single-pass syntax highlighting to avoid corrupting HTML class attributes
const highlightCode = (code: string): string => {
- // Escape HTML
- let highlighted = code
+ // Escape HTML helper
+ const escapeHtml = (str: string) => str
.replace(/&/g, '&')
.replace(//g, '>');
-
- // Common patterns for many languages
- const patterns = [
- // Strings
- { regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g, class: 'text-green-400' },
- // Comments
- { regex: /(\/\/.*$)/gm, class: 'text-gray-500 italic' },
- { regex: /(\/\*[\s\S]*?\*\/)/g, class: 'text-gray-500 italic' },
- { regex: /(#.*$)/gm, class: 'text-gray-500 italic' },
- // Numbers
- { regex: /\b(\d+\.?\d*)\b/g, class: 'text-purple-400' },
- // Keywords (common across many languages)
- { regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default)\b/g, class: 'text-blue-400' },
- // Boolean and null values
- { regex: /\b(true|false|null|undefined|nil|None|True|False)\b/g, class: 'text-orange-400' },
- // Function calls (basic)
- { regex: /(\w+)(?=\s*\()/g, class: 'text-yellow-400' },
- // Types/Classes (PascalCase)
- { regex: /\b([A-Z][a-zA-Z0-9]*)\b/g, class: 'text-cyan-400' },
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ // Define token patterns with priorities (first match wins)
+ // Order matters: strings and comments first to avoid highlighting inside them
+ const tokenPatterns = [
+ { regex: /"(?:[^"\\]|\\.)*"/, className: 'text-green-400' }, // double-quoted strings
+ { regex: /'(?:[^'\\]|\\.)*'/, className: 'text-green-400' }, // single-quoted strings
+ { regex: /`(?:[^`\\]|\\.)*`/, className: 'text-green-400' }, // backtick strings
+ { regex: /\/\/.*$/, className: 'text-gray-500 italic' }, // single-line comments
+ { regex: /\/\*[\s\S]*?\*\//, className: 'text-gray-500 italic' }, // multi-line comments
+ { regex: /#.*$/, className: 'text-gray-500 italic' }, // hash comments
+ { regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default|fn|pub|mod|use|mut|match|loop|impl|trait|where|type|readonly|override)\b/, className: 'text-blue-400' }, // keywords
+ { regex: /\b(true|false|null|undefined|nil|None|True|False|NULL)\b/, className: 'text-orange-400' }, // literals
+ { regex: /\b\d+\.?\d*\b/, className: 'text-purple-400' }, // numbers
+ { regex: /\b[A-Z][a-zA-Z0-9]*\b/, className: 'text-cyan-400' }, // PascalCase (types/classes)
];
- patterns.forEach(({ regex, class: className }) => {
- highlighted = highlighted.replace(regex, `
$&`);
- });
+ // Build a combined regex that matches any token
+ const combinedPattern = new RegExp(
+ tokenPatterns.map(p => `(${p.regex.source})`).join('|'),
+ 'gm'
+ );
+
+ let result = '';
+ let lastIndex = 0;
+
+ // Single pass through the string
+ for (const match of code.matchAll(combinedPattern)) {
+ // Add non-matched text before this match (escaped)
+ if (match.index! > lastIndex) {
+ result += escapeHtml(code.slice(lastIndex, match.index));
+ }
+
+ // Find which pattern matched (first non-undefined capture group)
+ const matchedText = match[0];
+ let className = '';
+ for (let i = 0; i < tokenPatterns.length; i++) {
+ if (match[i + 1] !== undefined) {
+ className = tokenPatterns[i].className;
+ break;
+ }
+ }
+
+ // Add the highlighted token (escape the matched text too)
+ result += `
${escapeHtml(matchedText)}`;
+ lastIndex = match.index! + matchedText.length;
+ }
+
+ // Add remaining text after last match
+ if (lastIndex < code.length) {
+ result += escapeHtml(code.slice(lastIndex));
+ }
- return highlighted;
+ return result;
};
const handleCopy = async () => {
diff --git a/web/app/components/RequestCompareModal.tsx b/web/app/components/RequestCompareModal.tsx
new file mode 100644
index 0000000..2d5ad0e
--- /dev/null
+++ b/web/app/components/RequestCompareModal.tsx
@@ -0,0 +1,1152 @@
+import { useState, useMemo } from 'react';
+import {
+ X,
+ GitCompare,
+ Plus,
+ Minus,
+ Equal,
+ ChevronDown,
+ ChevronRight,
+ MessageCircle,
+ User,
+ Bot,
+ Settings,
+ Clock,
+ Cpu,
+ Brain,
+ ArrowRight,
+ List,
+ FileText,
+ Download
+} from 'lucide-react';
+import { MessageContent } from './MessageContent';
+
+interface Message {
+ role: string;
+ content: any;
+}
+
+interface Request {
+ id: number;
+ timestamp: string;
+ method: string;
+ endpoint: string;
+ headers: Record
;
+ originalModel?: string;
+ routedModel?: string;
+ body?: {
+ model?: string;
+ messages?: Message[];
+ system?: Array<{
+ text: string;
+ type: string;
+ cache_control?: { type: string };
+ }>;
+ tools?: Array<{
+ name: string;
+ description: string;
+ input_schema?: any;
+ }>;
+ max_tokens?: number;
+ temperature?: number;
+ stream?: boolean;
+ };
+ response?: {
+ statusCode: number;
+ headers: Record;
+ body?: any;
+ bodyText?: string;
+ responseTime: number;
+ streamingChunks?: string[];
+ isStreaming: boolean;
+ completedAt: string;
+ };
+}
+
+interface RequestCompareModalProps {
+ request1: Request;
+ request2: Request;
+ onClose: () => void;
+}
+
+type DiffType = 'added' | 'removed' | 'unchanged' | 'modified';
+
+interface MessageDiff {
+ type: DiffType;
+ index1?: number;
+ index2?: number;
+ message1?: Message;
+ message2?: Message;
+}
+
+// Extract text content from a message for comparison
+function getMessageText(content: any): string {
+ if (typeof content === 'string') {
+ return content;
+ }
+ if (Array.isArray(content)) {
+ return content
+ .map(block => {
+ if (typeof block === 'string') return block;
+ if (block.type === 'text') return block.text || '';
+ if (block.type === 'tool_use') return `[Tool: ${block.name}]`;
+ if (block.type === 'tool_result') return `[Tool Result: ${block.tool_use_id}]`;
+ return JSON.stringify(block);
+ })
+ .join('\n');
+ }
+ return JSON.stringify(content);
+}
+
+// Compare two messages to see if they're similar
+function messagesAreSimilar(msg1: Message, msg2: Message): boolean {
+ if (msg1.role !== msg2.role) return false;
+ const text1 = getMessageText(msg1.content);
+ const text2 = getMessageText(msg2.content);
+ // Consider messages similar if they share >80% of content
+ const shorter = Math.min(text1.length, text2.length);
+ const longer = Math.max(text1.length, text2.length);
+ if (longer === 0) return true;
+ if (shorter / longer < 0.5) return false;
+ // Simple check: if one is a prefix of the other or they're equal
+ return text1 === text2 || text1.startsWith(text2.slice(0, 100)) || text2.startsWith(text1.slice(0, 100));
+}
+
+// Compute diff between two message arrays
+function computeMessageDiff(messages1: Message[], messages2: Message[]): MessageDiff[] {
+ const diffs: MessageDiff[] = [];
+ let i = 0;
+ let j = 0;
+
+ while (i < messages1.length || j < messages2.length) {
+ if (i >= messages1.length) {
+ // All remaining messages in request2 are additions
+ diffs.push({
+ type: 'added',
+ index2: j,
+ message2: messages2[j]
+ });
+ j++;
+ } else if (j >= messages2.length) {
+ // All remaining messages in request1 are removals
+ diffs.push({
+ type: 'removed',
+ index1: i,
+ message1: messages1[i]
+ });
+ i++;
+ } else if (messagesAreSimilar(messages1[i], messages2[j])) {
+ // Messages match
+ const text1 = getMessageText(messages1[i].content);
+ const text2 = getMessageText(messages2[j].content);
+ diffs.push({
+ type: text1 === text2 ? 'unchanged' : 'modified',
+ index1: i,
+ index2: j,
+ message1: messages1[i],
+ message2: messages2[j]
+ });
+ i++;
+ j++;
+ } else {
+ // Look ahead to find a match
+ let foundMatch = false;
+
+ // Check if messages1[i] matches something ahead in messages2
+ for (let k = j + 1; k < Math.min(j + 5, messages2.length); k++) {
+ if (messagesAreSimilar(messages1[i], messages2[k])) {
+ // messages2[j..k-1] are additions
+ for (let l = j; l < k; l++) {
+ diffs.push({
+ type: 'added',
+ index2: l,
+ message2: messages2[l]
+ });
+ }
+ j = k;
+ foundMatch = true;
+ break;
+ }
+ }
+
+ if (!foundMatch) {
+ // Check if messages2[j] matches something ahead in messages1
+ for (let k = i + 1; k < Math.min(i + 5, messages1.length); k++) {
+ if (messagesAreSimilar(messages1[k], messages2[j])) {
+ // messages1[i..k-1] are removals
+ for (let l = i; l < k; l++) {
+ diffs.push({
+ type: 'removed',
+ index1: l,
+ message1: messages1[l]
+ });
+ }
+ i = k;
+ foundMatch = true;
+ break;
+ }
+ }
+ }
+
+ if (!foundMatch) {
+ // No match found, treat as removal then addition
+ diffs.push({
+ type: 'removed',
+ index1: i,
+ message1: messages1[i]
+ });
+ i++;
+ }
+ }
+ }
+
+ return diffs;
+}
+
+export function RequestCompareModal({ request1, request2, onClose }: RequestCompareModalProps) {
+ const [viewMode, setViewMode] = useState<'structured' | 'diff'>('structured');
+ const [expandedSections, setExpandedSections] = useState>({
+ summary: true,
+ messages: true,
+ system: false,
+ tools: false
+ });
+
+ const toggleSection = (section: string) => {
+ setExpandedSections(prev => ({
+ ...prev,
+ [section]: !prev[section]
+ }));
+ };
+
+ const messages1 = request1.body?.messages || [];
+ const messages2 = request2.body?.messages || [];
+
+ const messageDiffs = useMemo(() => computeMessageDiff(messages1, messages2), [messages1, messages2]);
+
+ const diffStats = useMemo(() => {
+ const stats = {
+ added: 0,
+ removed: 0,
+ modified: 0,
+ unchanged: 0
+ };
+ messageDiffs.forEach(diff => {
+ stats[diff.type]++;
+ });
+ return stats;
+ }, [messageDiffs]);
+
+ const getModelDisplay = (request: Request) => {
+ const model = request.routedModel || request.body?.model || 'Unknown';
+ if (model.includes('opus')) return { name: 'Opus', color: 'text-purple-600' };
+ if (model.includes('sonnet')) return { name: 'Sonnet', color: 'text-indigo-600' };
+ if (model.includes('haiku')) return { name: 'Haiku', color: 'text-teal-600' };
+ return { name: model, color: 'text-gray-600' };
+ };
+
+ const model1 = getModelDisplay(request1);
+ const model2 = getModelDisplay(request2);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
Compare Requests
+
+
{model1.name}
+
+
{model2.name}
+
+
+
+ {/* View mode toggle */}
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {viewMode === 'diff' ? (
+
+ ) : (
+ <>
+ {/* Summary Section */}
+
+
toggleSection('summary')}
+ >
+
+
+
+ Comparison Summary
+
+ {expandedSections.summary ? (
+
+ ) : (
+
+ )}
+
+
+ {expandedSections.summary && (
+
+ {/* Stats */}
+
+
+
+
+
+ {diffStats.removed}
+
+
Removed
+
+
+
+
+ {diffStats.modified}
+
+
Modified
+
+
+
+
+ {diffStats.unchanged}
+
+
Unchanged
+
+
+
+ {/* Request comparison */}
+
+
+
+
+
+ )}
+
+
+ {/* Messages Diff Section */}
+
+
toggleSection('messages')}
+ >
+
+
+
+ Message Differences
+
+ {messages1.length} vs {messages2.length} messages
+
+
+ {expandedSections.messages ? (
+
+ ) : (
+
+ )}
+
+
+ {expandedSections.messages && (
+
+ {messageDiffs.length === 0 ? (
+
+
+
No messages to compare
+
+ ) : (
+ messageDiffs.map((diff, index) => (
+
+ ))
+ )}
+
+ )}
+
+
+ {/* System Prompts Comparison */}
+ {(request1.body?.system || request2.body?.system) && (
+
+
toggleSection('system')}
+ >
+
+
+
+ System Prompts
+
+ {request1.body?.system?.length || 0} vs {request2.body?.system?.length || 0}
+
+
+ {expandedSections.system ? (
+
+ ) : (
+
+ )}
+
+
+ {expandedSections.system && (
+
+
+
+
Request #1
+ {request1.body?.system?.map((sys, i) => (
+
+
+ {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''}
+
+
+ )) ||
No system prompt
}
+
+
+
Request #2
+ {request2.body?.system?.map((sys, i) => (
+
+
+ {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''}
+
+
+ )) ||
No system prompt
}
+
+
+
+ )}
+
+ )}
+
+ {/* Tools Comparison */}
+ {(request1.body?.tools || request2.body?.tools) && (
+
+
toggleSection('tools')}
+ >
+
+
+
+ Available Tools
+
+ {request1.body?.tools?.length || 0} vs {request2.body?.tools?.length || 0}
+
+
+ {expandedSections.tools ? (
+
+ ) : (
+
+ )}
+
+
+ {expandedSections.tools && (
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
+
+// Convert full request to plain text for diff
+function requestToText(request: Request): string[] {
+ const lines: string[] = [];
+
+ // System prompt
+ if (request.body?.system && request.body.system.length > 0) {
+ lines.push('=== SYSTEM PROMPT ===');
+ request.body.system.forEach((sys, idx) => {
+ lines.push(`--- System Block [${idx + 1}] (${(new Blob([sys.text]).size / 1024).toFixed(1)} KB) ---`);
+ sys.text.split('\n').forEach(line => lines.push(line));
+ lines.push('');
+ });
+ lines.push('');
+ }
+
+ // Tools (just names and sizes, not full definitions)
+ if (request.body?.tools && request.body.tools.length > 0) {
+ lines.push('=== TOOLS ===');
+ const toolsSize = new Blob([JSON.stringify(request.body.tools)]).size;
+ lines.push(`Total: ${request.body.tools.length} tools (${(toolsSize / 1024).toFixed(1)} KB)`);
+ request.body.tools.forEach(tool => {
+ const toolSize = new Blob([JSON.stringify(tool)]).size;
+ lines.push(` - ${tool.name} (${(toolSize / 1024).toFixed(1)} KB)`);
+ });
+ lines.push('');
+ }
+
+ // Messages
+ lines.push('=== MESSAGES ===');
+ const messages = request.body?.messages || [];
+ messages.forEach((msg, idx) => {
+ const roleLabel = msg.role.toUpperCase();
+ const msgSize = new Blob([getMessageText(msg.content)]).size;
+ lines.push(`--- ${roleLabel} [${idx + 1}] (${(msgSize / 1024).toFixed(1)} KB) ---`);
+ const text = getMessageText(msg.content);
+ text.split('\n').forEach(line => lines.push(line));
+ lines.push('');
+ });
+
+ return lines;
+}
+
+// Simple line-based diff algorithm
+function computeLineDiff(lines1: string[], lines2: string[]): Array<{ type: 'same' | 'added' | 'removed'; line: string; lineNum1?: number; lineNum2?: number }> {
+ const result: Array<{ type: 'same' | 'added' | 'removed'; line: string; lineNum1?: number; lineNum2?: number }> = [];
+
+ // Use longest common subsequence approach
+ const m = lines1.length;
+ const n = lines2.length;
+
+ // Build LCS table
+ const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (lines1[i - 1] === lines2[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ // Backtrack to find diff
+ let i = m, j = n;
+ const diffItems: Array<{ type: 'same' | 'added' | 'removed'; line: string; idx1?: number; idx2?: number }> = [];
+
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && lines1[i - 1] === lines2[j - 1]) {
+ diffItems.unshift({ type: 'same', line: lines1[i - 1], idx1: i, idx2: j });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ diffItems.unshift({ type: 'added', line: lines2[j - 1], idx2: j });
+ j--;
+ } else {
+ diffItems.unshift({ type: 'removed', line: lines1[i - 1], idx1: i });
+ i--;
+ }
+ }
+
+ // Convert to result with line numbers
+ let lineNum1 = 1, lineNum2 = 1;
+ for (const item of diffItems) {
+ if (item.type === 'same') {
+ result.push({ type: 'same', line: item.line, lineNum1: lineNum1++, lineNum2: lineNum2++ });
+ } else if (item.type === 'removed') {
+ result.push({ type: 'removed', line: item.line, lineNum1: lineNum1++ });
+ } else {
+ result.push({ type: 'added', line: item.line, lineNum2: lineNum2++ });
+ }
+ }
+
+ return result;
+}
+
+// Text diff view component
+function TextDiffView({ request1, request2 }: { request1: Request; request2: Request }) {
+ const lines1 = useMemo(() => requestToText(request1), [request1]);
+ const lines2 = useMemo(() => requestToText(request2), [request2]);
+ const diff = useMemo(() => computeLineDiff(lines1, lines2), [lines1, lines2]);
+
+ const stats = useMemo(() => {
+ let added = 0, removed = 0, same = 0;
+ diff.forEach(d => {
+ if (d.type === 'added') added++;
+ else if (d.type === 'removed') removed++;
+ else same++;
+ });
+ return { added, removed, same };
+ }, [diff]);
+
+ // Generate unified diff format
+ const generateUnifiedDiff = () => {
+ const lines: string[] = [];
+ lines.push('--- Request #1');
+ lines.push('+++ Request #2');
+ lines.push('');
+
+ diff.forEach(item => {
+ const prefix = item.type === 'added' ? '+' : item.type === 'removed' ? '-' : ' ';
+ lines.push(`${prefix}${item.line}`);
+ });
+
+ return lines.join('\n');
+ };
+
+ // Generate markdown format
+ const generateMarkdown = () => {
+ const lines: string[] = [];
+ lines.push('# Request Comparison');
+ lines.push('');
+ lines.push(`**Added:** ${stats.added} lines | **Removed:** ${stats.removed} lines | **Unchanged:** ${stats.same} lines`);
+ lines.push('');
+ lines.push('```diff');
+ diff.forEach(item => {
+ const prefix = item.type === 'added' ? '+' : item.type === 'removed' ? '-' : ' ';
+ lines.push(`${prefix}${item.line}`);
+ });
+ lines.push('```');
+ return lines.join('\n');
+ };
+
+ // Generate JSON format
+ const generateJSON = () => {
+ return JSON.stringify({
+ stats,
+ request1: {
+ lines: lines1,
+ timestamp: request1.timestamp,
+ model: request1.routedModel || request1.body?.model
+ },
+ request2: {
+ lines: lines2,
+ timestamp: request2.timestamp,
+ model: request2.routedModel || request2.body?.model
+ },
+ diff: diff.map(d => ({
+ type: d.type,
+ line: d.line,
+ lineNum1: d.lineNum1,
+ lineNum2: d.lineNum2
+ }))
+ }, null, 2);
+ };
+
+ const handleDownload = (format: 'diff' | 'md' | 'json' | 'vscode') => {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+
+ // VS Code: download both files separately
+ if (format === 'vscode') {
+ const file1Content = lines1.join('\n');
+ const file2Content = lines2.join('\n');
+
+ // Download first file
+ const blob1 = new Blob([file1Content], { type: 'text/plain' });
+ const url1 = URL.createObjectURL(blob1);
+ const a1 = document.createElement('a');
+ a1.href = url1;
+ a1.download = `request1-${timestamp}.txt`;
+ document.body.appendChild(a1);
+ a1.click();
+ document.body.removeChild(a1);
+ URL.revokeObjectURL(url1);
+
+ // Small delay then download second file
+ setTimeout(() => {
+ const blob2 = new Blob([file2Content], { type: 'text/plain' });
+ const url2 = URL.createObjectURL(blob2);
+ const a2 = document.createElement('a');
+ a2.href = url2;
+ a2.download = `request2-${timestamp}.txt`;
+ document.body.appendChild(a2);
+ a2.click();
+ document.body.removeChild(a2);
+ URL.revokeObjectURL(url2);
+
+ // Show instruction
+ alert(`Files downloaded!\n\nCompare with your preferred diff tool:\n diff ~/Downloads/request1-${timestamp}.txt ~/Downloads/request2-${timestamp}.txt\n\nOr in VS Code:\n code --diff ~/Downloads/request1-${timestamp}.txt ~/Downloads/request2-${timestamp}.txt`);
+ }, 100);
+
+ return;
+ }
+
+ let content: string;
+ let filename: string;
+ let type: string;
+
+ switch (format) {
+ case 'md':
+ content = generateMarkdown();
+ filename = `diff-${timestamp}.md`;
+ type = 'text/markdown';
+ break;
+ case 'json':
+ content = generateJSON();
+ filename = `diff-${timestamp}.json`;
+ type = 'application/json';
+ break;
+ default:
+ content = generateUnifiedDiff();
+ filename = `diff-${timestamp}.diff`;
+ type = 'text/plain';
+ }
+
+ const blob = new Blob([content], { type });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
+
+
+ Text Diff
+
+
+
+{stats.added} added
+
-{stats.removed} removed
+
{stats.same} unchanged
+
+
+
+
+
+
+
+
+
+
+
+ {diff.map((item, idx) => (
+
+ |
+ {item.lineNum1 || ''}
+ |
+
+ {item.lineNum2 || ''}
+ |
+
+ {item.type === 'added' && +}
+ {item.type === 'removed' && -}
+ |
+
+ {item.line || '\u00A0'}
+ |
+
+ ))}
+
+
+
+
+ );
+}
+
+// Calculate size of content in KB
+function getContentSize(content: any): number {
+ if (!content) return 0;
+ const text = typeof content === 'string' ? content : JSON.stringify(content);
+ return new Blob([text]).size;
+}
+
+// Download helper
+function downloadFile(content: string, filename: string, type: string = 'application/json') {
+ const blob = new Blob([content], { type });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+// Request summary card
+function RequestSummaryCard({ request, label }: { request: Request; label: string }) {
+ const model = request.routedModel || request.body?.model || 'Unknown';
+ const tokens = request.response?.body?.usage;
+ const inputTokens = (tokens?.input_tokens || 0) + (tokens?.cache_read_input_tokens || 0);
+ const outputTokens = tokens?.output_tokens || 0;
+ const cacheRead = tokens?.cache_read_input_tokens || 0;
+ const cacheCreation = tokens?.cache_creation_input_tokens || 0;
+
+ // Calculate sizes
+ const systemSize = request.body?.system?.reduce((acc, s) => acc + getContentSize(s.text), 0) || 0;
+ const toolsSize = getContentSize(request.body?.tools);
+ const messagesSize = request.body?.messages?.reduce((acc, m) => acc + getContentSize(m.content), 0) || 0;
+ const totalSize = systemSize + toolsSize + messagesSize;
+
+ const formatSize = (bytes: number) => {
+ if (bytes < 1024) return `${bytes} B`;
+ return `${(bytes / 1024).toFixed(1)} KB`;
+ };
+
+ const handleDownloadJSON = () => {
+ const timestamp = new Date(request.timestamp).toISOString().replace(/[:.]/g, '-');
+ const filename = `request-${timestamp}.json`;
+ downloadFile(JSON.stringify(request, null, 2), filename);
+ };
+
+ const handleDownloadMarkdown = () => {
+ const timestamp = new Date(request.timestamp).toISOString().replace(/[:.]/g, '-');
+ const model = request.routedModel || request.body?.model || 'Unknown';
+
+ let md = `# Request ${timestamp}\n\n`;
+ md += `**Model:** ${model}\n`;
+ md += `**Input Tokens:** ${inputTokens.toLocaleString()}\n`;
+ md += `**Output Tokens:** ${outputTokens.toLocaleString()}\n\n`;
+
+ if (request.body?.system) {
+ md += `## System Prompt\n\n`;
+ request.body.system.forEach((sys, i) => {
+ md += `### Block ${i + 1}\n\n\`\`\`\n${sys.text}\n\`\`\`\n\n`;
+ });
+ }
+
+ if (request.body?.messages) {
+ md += `## Messages\n\n`;
+ request.body.messages.forEach((msg, i) => {
+ md += `### ${msg.role.toUpperCase()} [${i + 1}]\n\n`;
+ const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
+ md += `\`\`\`\n${text}\n\`\`\`\n\n`;
+ });
+ }
+
+ downloadFile(md, `request-${timestamp}.md`, 'text/markdown');
+ };
+
+ return (
+
+
+
{label}
+
+
+
+
+
+
+
+ Model:
+ {model.split('-').slice(-1)[0] || model}
+
+
+ Input Tokens:
+ {inputTokens.toLocaleString()}
+
+
+ Output Tokens:
+ {outputTokens.toLocaleString()}
+
+ {cacheRead > 0 && (
+
+ Cache Read:
+ {cacheRead.toLocaleString()}
+
+ )}
+ {cacheCreation > 0 && (
+
+ Cache Creation:
+ {cacheCreation.toLocaleString()}
+
+ )}
+
+
Size Breakdown
+
+ System Prompt:
+ {formatSize(systemSize)}
+
+
+ Tools ({request.body?.tools?.length || 0}):
+ {formatSize(toolsSize)}
+
+
+ Messages ({request.body?.messages?.length || 0}):
+ {formatSize(messagesSize)}
+
+
+ Total:
+ {formatSize(totalSize)}
+
+
+
+ Response Time:
+ {((request.response?.responseTime || 0) / 1000).toFixed(2)}s
+
+
+ Timestamp:
+ {new Date(request.timestamp).toLocaleString()}
+
+
+
+ );
+}
+
+// Get message size in KB
+function getMessageSize(message: Message | undefined): string {
+ if (!message) return '0 KB';
+ const text = getMessageText(message.content);
+ const bytes = new Blob([text]).size;
+ if (bytes < 1024) return `${bytes} B`;
+ return `${(bytes / 1024).toFixed(1)} KB`;
+}
+
+// Message diff row component
+function MessageDiffRow({ diff }: { diff: MessageDiff }) {
+ const [expanded, setExpanded] = useState(diff.type !== 'unchanged');
+
+ const roleIcons = {
+ 'user': User,
+ 'assistant': Bot,
+ 'system': Settings
+ };
+
+ const getDiffStyles = () => {
+ switch (diff.type) {
+ case 'added':
+ return {
+ bg: 'bg-green-50',
+ border: 'border-green-200',
+ icon: ,
+ label: 'Added',
+ labelBg: 'bg-green-100 text-green-700'
+ };
+ case 'removed':
+ return {
+ bg: 'bg-red-50',
+ border: 'border-red-200',
+ icon: ,
+ label: 'Removed',
+ labelBg: 'bg-red-100 text-red-700'
+ };
+ case 'modified':
+ return {
+ bg: 'bg-yellow-50',
+ border: 'border-yellow-200',
+ icon: ,
+ label: 'Modified',
+ labelBg: 'bg-yellow-100 text-yellow-700'
+ };
+ default:
+ return {
+ bg: 'bg-gray-50',
+ border: 'border-gray-200',
+ icon: ,
+ label: 'Unchanged',
+ labelBg: 'bg-gray-100 text-gray-600'
+ };
+ }
+ };
+
+ const styles = getDiffStyles();
+ const message = diff.message1 || diff.message2;
+ const role = message?.role || 'unknown';
+ const Icon = roleIcons[role as keyof typeof roleIcons] || User;
+
+ return (
+
+
setExpanded(!expanded)}
+ >
+
+ {styles.icon}
+
+
+
+
{role}
+
+ {styles.label}
+
+ {diff.index1 !== undefined && (
+
#{diff.index1 + 1}
+ )}
+ {diff.index2 !== undefined && diff.index1 !== diff.index2 && (
+
+ {diff.index1 !== undefined ? ` → #${diff.index2 + 1}` : `#${diff.index2 + 1}`}
+
+ )}
+
+ {getMessageSize(diff.message1 || diff.message2)}
+
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ {expanded && (
+
+ {diff.type === 'modified' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
+
+// Tools comparison component
+function ToolsComparison({ tools1, tools2 }: { tools1: any[]; tools2: any[] }) {
+ const toolNames1 = new Set(tools1.map(t => t.name));
+ const toolNames2 = new Set(tools2.map(t => t.name));
+
+ const added = tools2.filter(t => !toolNames1.has(t.name));
+ const removed = tools1.filter(t => !toolNames2.has(t.name));
+ const common = tools1.filter(t => toolNames2.has(t.name));
+
+ return (
+
+ {added.length > 0 && (
+
+
+
+ Added Tools ({added.length})
+
+
+ {added.map((tool, i) => (
+
+ {tool.name}
+
+ ))}
+
+
+ )}
+ {removed.length > 0 && (
+
+
+
+ Removed Tools ({removed.length})
+
+
+ {removed.map((tool, i) => (
+
+ {tool.name}
+
+ ))}
+
+
+ )}
+ {common.length > 0 && (
+
+
+
+ Common Tools ({common.length})
+
+
+ {common.map((tool, i) => (
+
+ {tool.name}
+
+ ))}
+
+
+ )}
+ {tools1.length === 0 && tools2.length === 0 && (
+
+
+
No tools defined in either request
+
+ )}
+
+ );
+}
diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx
index 9908607..351de70 100644
--- a/web/app/routes/_index.tsx
+++ b/web/app/routes/_index.tsx
@@ -1,9 +1,9 @@
import type { MetaFunction } from "@remix-run/node";
import { useState, useEffect, useTransition } from "react";
-import {
- Activity,
- RefreshCw,
- Trash2,
+import {
+ Activity,
+ RefreshCw,
+ Trash2,
List,
FileText,
X,
@@ -29,11 +29,15 @@ import {
Check,
Lightbulb,
Loader2,
- ArrowLeftRight
+ ArrowLeftRight,
+ GitCompare,
+ Square,
+ CheckSquare
} from "lucide-react";
import RequestDetailContent from "../components/RequestDetailContent";
import { ConversationThread } from "../components/ConversationThread";
+import { RequestCompareModal } from "../components/RequestCompareModal";
import { getChatCompletionsEndpoint } from "../utils/models";
export const meta: MetaFunction = () => {
@@ -156,6 +160,11 @@ export default function Index() {
const [hasMoreConversations, setHasMoreConversations] = useState(true);
const itemsPerPage = 50;
+ // Compare mode state
+ const [compareMode, setCompareMode] = useState(false);
+ const [selectedForCompare, setSelectedForCompare] = useState([]);
+ const [isCompareModalOpen, setIsCompareModalOpen] = useState(false);
+
const loadRequests = async (filter?: string, loadMore = false) => {
setIsFetching(true);
const pageToFetch = loadMore ? requestsCurrentPage + 1 : 1;
@@ -355,6 +364,38 @@ export default function Index() {
setSelectedRequest(null);
};
+ // Compare mode functions
+ const toggleCompareMode = () => {
+ setCompareMode(!compareMode);
+ setSelectedForCompare([]);
+ };
+
+ const toggleRequestSelection = (request: Request) => {
+ setSelectedForCompare(prev => {
+ const isSelected = prev.some(r => r.id === request.id);
+ if (isSelected) {
+ return prev.filter(r => r.id !== request.id);
+ } else if (prev.length < 2) {
+ return [...prev, request];
+ }
+ return prev;
+ });
+ };
+
+ const isRequestSelected = (request: Request) => {
+ return selectedForCompare.some(r => r.id === request.id);
+ };
+
+ const openCompareModal = () => {
+ if (selectedForCompare.length === 2) {
+ setIsCompareModalOpen(true);
+ }
+ };
+
+ const closeCompareModal = () => {
+ setIsCompareModalOpen(false);
+ };
+
const getToolStats = () => {
let toolDefinitions = 0;
let toolCalls = 0;
@@ -488,21 +529,25 @@ export default function Index() {
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
- if (isModalOpen) {
+ if (isCompareModalOpen) {
+ closeCompareModal();
+ } else if (isModalOpen) {
closeModal();
} else if (isConversationModalOpen) {
setIsConversationModalOpen(false);
setSelectedConversation(null);
+ } else if (compareMode) {
+ toggleCompareMode();
}
}
};
window.addEventListener('keydown', handleEscapeKey);
-
+
return () => {
window.removeEventListener('keydown', handleEscapeKey);
};
- }, [isModalOpen, isConversationModalOpen]);
+ }, [isModalOpen, isConversationModalOpen, isCompareModalOpen, compareMode]);
const filteredRequests = filterRequests(filter);
@@ -516,6 +561,19 @@ export default function Index() {
Claude Code Monitor