-
Notifications
You must be signed in to change notification settings - Fork 54
Add request comparison feature #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds a request comparison feature to the Claude Code Monitor, enabling users to select and compare two API requests side-by-side. The feature helps developers investigate differences between requests, such as changes in context after restart.
Key Changes:
- Added compare mode toggle with visual selection UI in the request list view
- Created a comprehensive comparison modal showing message diffs, system prompts, and tool differences
- Implemented a diff algorithm to intelligently compare message arrays between requests
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| web/app/routes/_index.tsx | Adds compare mode state management, UI controls (toggle button, sticky banner), checkbox selection in request list, and keyboard shortcut handling for the compare feature |
| web/app/components/RequestCompareModal.tsx | New modal component providing side-by-side comparison of two requests with expandable sections for messages, system prompts, and tools, including a custom diff algorithm for message comparison |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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)); |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The similarity check logic has a potential issue. Line 107 checks if shorter / longer < 0.5 and returns false, but then line 109 only checks prefix matching with .slice(0, 100). This means two messages could be considered similar even if they differ significantly after the first 100 characters. Consider using a more robust similarity comparison, such as checking if one string contains a larger portion of the other, or using edit distance for better accuracy.
| {request1.body?.system?.map((sys, i) => ( | ||
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | ||
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | ||
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | ||
| </pre> | ||
| </div> | ||
| )) || <div className="text-gray-400 text-xs">No system prompt</div>} | ||
| </div> | ||
| <div> | ||
| <h5 className="text-xs font-medium text-gray-500 mb-2">Request #2</h5> | ||
| {request2.body?.system?.map((sys, i) => ( | ||
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | ||
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | ||
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | ||
| </pre> | ||
| </div> | ||
| )) || <div className="text-gray-400 text-xs">No system prompt</div>} |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fallback message "No system prompt" won't be displayed when the system array is empty. The expression [].map(...) || <div>No system prompt</div> evaluates to an empty array (which is truthy), so the fallback is never rendered. Consider using a conditional check: {request1.body?.system?.length > 0 ? request1.body.system.map(...) : <div>No system prompt</div>}
| {request1.body?.system?.map((sys, i) => ( | |
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | |
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | |
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | |
| </pre> | |
| </div> | |
| )) || <div className="text-gray-400 text-xs">No system prompt</div>} | |
| </div> | |
| <div> | |
| <h5 className="text-xs font-medium text-gray-500 mb-2">Request #2</h5> | |
| {request2.body?.system?.map((sys, i) => ( | |
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | |
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | |
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | |
| </pre> | |
| </div> | |
| )) || <div className="text-gray-400 text-xs">No system prompt</div>} | |
| {request1.body?.system?.length > 0 | |
| ? request1.body.system.map((sys, i) => ( | |
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | |
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | |
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | |
| </pre> | |
| </div> | |
| )) | |
| : <div className="text-gray-400 text-xs">No system prompt</div>} | |
| </div> | |
| <div> | |
| <h5 className="text-xs font-medium text-gray-500 mb-2">Request #2</h5> | |
| {request2.body?.system?.length > 0 | |
| ? request2.body.system.map((sys, i) => ( | |
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | |
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | |
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | |
| </pre> | |
| </div> | |
| )) | |
| : <div className="text-gray-400 text-xs">No system prompt</div>} |
| {request2.body?.system?.map((sys, i) => ( | ||
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | ||
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | ||
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | ||
| </pre> | ||
| </div> | ||
| )) || <div className="text-gray-400 text-xs">No system prompt</div>} |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue as Request #1: The fallback message "No system prompt" won't be displayed when the system array is empty. Consider using: {request2.body?.system?.length > 0 ? request2.body.system.map(...) : <div>No system prompt</div>}
| {request2.body?.system?.map((sys, i) => ( | |
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | |
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | |
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | |
| </pre> | |
| </div> | |
| )) || <div className="text-gray-400 text-xs">No system prompt</div>} | |
| {request2.body?.system && request2.body.system.length > 0 | |
| ? request2.body.system.map((sys, i) => ( | |
| <div key={i} className="bg-yellow-50 border border-yellow-200 rounded p-2 mb-2 text-xs"> | |
| <pre className="whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto"> | |
| {sys.text.slice(0, 500)}{sys.text.length > 500 ? '...' : ''} | |
| </pre> | |
| </div> | |
| )) | |
| : <div className="text-gray-400 text-xs">No system prompt</div> | |
| } |
| User, | ||
| Bot, | ||
| Settings, | ||
| Clock, |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Clock icon is imported but never used in this component. Consider removing it from the imports.
| Clock, |
| import { | ||
| X, | ||
| GitCompare, | ||
| Plus, | ||
| Minus, | ||
| Equal, | ||
| ChevronDown, | ||
| ChevronRight, | ||
| MessageCircle, | ||
| User, | ||
| Bot, | ||
| Settings, | ||
| Clock, | ||
| Cpu, | ||
| Brain, | ||
| ArrowRight | ||
| } from 'lucide-react'; |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused import Clock.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
- Replace sequential regex replacements with single-pass tokenizer in CodeViewer.tsx highlightCode() function - The old approach applied patterns sequentially, causing later patterns to match numbers inside class attributes (e.g., "400" in "text-purple-400") - New approach: build combined regex, iterate matches once, escape HTML on matched tokens only - Also fix escapeHtml in formatters.ts to not use document.createElement (fails during SSR) and simplify formatLargeText to avoid over-formatting
- Add quote escaping to escapeHtml in CodeViewer.tsx for XSS protection - Fix string regex patterns to properly handle escaped quotes - Use template literal for paragraph wrapping in formatLargeText - Add vitest and tests for escapeHtml, formatLargeText, and string patterns
- Change combined "X tokens" to separate "X in" / "Y out" display - Makes it clearer how many tokens are uploaded vs generated - Helps users understand conversation growth per turn
- Show total input tokens (cached + non-cached) instead of just non-cached - Change cache display from absolute number to percentage - "68,446 in 100% cached" instead of "1 in 153,525 cached"
Add guard to ensure denominator is non-zero before calculating the cache percentage. This prevents NaN when both input_tokens and cache_read_input_tokens are 0.
- Add Compare button in header to enter compare mode - Allow selecting 2 requests via checkboxes for side-by-side comparison - Create RequestCompareModal component with: - Summary stats (added/removed/modified/unchanged messages) - Side-by-side request metadata comparison - Message diff view with color-coded changes - System prompt comparison - Tools comparison (added/removed/common) - Sticky compare mode banner that persists while scrolling - Button label changes based on state (Compare / Exit Compare)
RequestCompareModal: - Add text diff view with side-by-side line comparison (LCS algorithm) - Show system prompt and tools in diff, not just messages - Add size breakdown: system prompt, tools, messages in KB - Show cache read/creation tokens separately - Add message size (KB) to each message row in structured view - Add download options: .diff, .json, .md formats - Add "Side-by-Side" export for external diff tools - Toggle between Structured and Text Diff views _index.tsx: - Fix cache display to only show when > 0
e3b7e9f to
789581f
Compare
Adds the ability to compare two API requests.
Motiviation I was trying to figure out why claude's context reduces after restart. To investigate this bug: anthropics/claude-code#10161
Net result of investigation: https://ejc3.github.io/claude-restart-trajectory-analysis/
What it does
Screenshots
Compare mode with selection checkboxes
Comparison modal showing message differences
Changes
web/app/routes/_index.tsx: Added compare mode UI (toggle, checkboxes, sticky banner)web/app/components/RequestCompareModal.tsx: New component for side-by-side comparison