AgentGraph / frontend /src /components /shared /TraceViewerSidebar.tsx
wu981526092's picture
🚀 Deploy AgentGraph: Complete agent monitoring and knowledge graph system
c2ea5ed
import React, { useEffect, useRef, useState, useMemo } from "react";
import {
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
Info,
Database,
BarChart3,
Cpu,
GitBranch,
Clock,
Layers,
} from "lucide-react";
import { Button } from "../ui/button";
import { Card, CardContent } from "../ui/card";
import Editor from "@monaco-editor/react";
import type { editor } from "monaco-editor";
import { KnowledgeGraph } from "@/types";
interface Range {
start: number; // 0-based inclusive
end: number; // 0-based inclusive
}
interface TraceViewerSidebarProps {
numberedLines: string[]; // each already prefixed with <L#>
highlightRanges: Range[];
widthClass?: string; // tailwind width override (default w-80)
knowledgeGraph?: KnowledgeGraph; // optional knowledge graph data for system tab
showTraceTab?: boolean; // whether to show the trace tab (default true)
}
interface LineMapping {
originalLineNumber: number;
startChar: number;
endChar: number;
content: string;
}
export const TraceViewerSidebar: React.FC<TraceViewerSidebarProps> = ({
numberedLines,
highlightRanges,
widthClass = "w-80",
knowledgeGraph,
showTraceTab = true,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const [isCollapsed, setIsCollapsed] = useState(false);
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(0);
const [decorations, setDecorations] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<"system" | "trace">("system");
// Parse numbered lines and reconstruct original content
const { reconstructedContent, characterRanges } = useMemo(() => {
const mapping: LineMapping[] = [];
// Parse each numbered line and extract line numbers
const parsedLines: {
lineNumber: number;
content: string;
index: number;
}[] = [];
for (let i = 0; i < numberedLines.length; i++) {
const line = numberedLines[i];
if (!line) continue;
const match = line.match(/^<L(\d+)>\s*(.*)$/);
if (!match) continue;
const [, lineNumber, content] = match;
if (!lineNumber || content === undefined) continue;
parsedLines.push({
lineNumber: parseInt(lineNumber),
content,
index: i,
});
}
// Sort by line number to ensure proper order
parsedLines.sort((a, b) => a.lineNumber - b.lineNumber);
// Simple concatenation - just join all content together
let rawContent = "";
for (let i = 0; i < parsedLines.length; i++) {
const currentLine = parsedLines[i];
if (!currentLine) continue;
// Track character mapping for highlighting
const startChar = rawContent.length;
rawContent += currentLine.content;
const endChar = rawContent.length;
mapping.push({
originalLineNumber: currentLine.lineNumber,
startChar,
endChar,
content: currentLine.content,
});
}
// Process the raw content for better formatting
let processedContent = rawContent;
// Convert literal \\n to actual line breaks
processedContent = processedContent.replace(/\\n/g, "\n");
// Convert literal \\t to actual tabs
processedContent = processedContent.replace(/\\t/g, "\t");
// Try to detect and format JSON content (handle multiple JSON objects)
const jsonRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
const jsonMatches = processedContent.match(jsonRegex);
if (jsonMatches) {
jsonMatches.forEach((jsonStr) => {
try {
const parsed = JSON.parse(jsonStr);
const formatted = JSON.stringify(parsed, null, 2);
processedContent = processedContent.replace(jsonStr, formatted);
} catch (e) {
// If JSON parsing fails, keep original content
}
});
}
// Clean up excessive whitespace but preserve intentional formatting
processedContent = processedContent
.replace(/[ \t]+/g, " ") // Multiple spaces/tabs to single space
.replace(/\n\s*\n\s*\n/g, "\n\n") // Multiple newlines to double newline max
.trim(); // Remove leading/trailing whitespace
// Update character mapping based on content transformation
const contentLengthDiff = processedContent.length - rawContent.length;
// Convert line-based ranges to character-based ranges
const charRanges = highlightRanges
.map((range) => {
const startMapping = mapping.find(
(m) => m.originalLineNumber === range.start + 1
);
const endMapping = mapping.find(
(m) => m.originalLineNumber === range.end + 1
);
if (!startMapping || !endMapping) {
return { startChar: 0, endChar: 0 };
}
// Adjust for content transformation
let adjustedStartChar = startMapping.startChar;
let adjustedEndChar = endMapping.endChar;
// Simple adjustment - this might need refinement for complex cases
if (contentLengthDiff > 0) {
const adjustmentRatio = processedContent.length / rawContent.length;
adjustedStartChar = Math.floor(
startMapping.startChar * adjustmentRatio
);
adjustedEndChar = Math.floor(endMapping.endChar * adjustmentRatio);
}
return {
startChar: adjustedStartChar,
endChar: adjustedEndChar,
};
})
.filter((range) => range.startChar !== range.endChar);
return {
reconstructedContent: processedContent,
characterRanges: charRanges,
};
}, [numberedLines, highlightRanges]);
// Sort ranges by start position for navigation
const sortedRanges = [...characterRanges].sort(
(a, b) => a.startChar - b.startChar
);
// Scroll to specific highlighted range in Monaco Editor
const scrollToHighlight = (rangeIndex: number) => {
if (!editorRef.current || sortedRanges.length === 0) return;
const range = sortedRanges[rangeIndex];
if (!range) return;
const startPosition = editorRef.current
.getModel()!
.getPositionAt(range.startChar);
// Scroll to the position
editorRef.current.revealPositionInCenter(startPosition);
// Update decorations to highlight current range
updateHighlightDecorations();
};
// Navigation handlers
const goToNextHighlight = () => {
if (sortedRanges.length === 0) return;
const nextIndex = (currentHighlightIndex + 1) % sortedRanges.length;
setCurrentHighlightIndex(nextIndex);
scrollToHighlight(nextIndex);
};
const goToPreviousHighlight = () => {
if (sortedRanges.length === 0) return;
const prevIndex =
currentHighlightIndex === 0
? sortedRanges.length - 1
: currentHighlightIndex - 1;
setCurrentHighlightIndex(prevIndex);
scrollToHighlight(prevIndex);
};
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return; // Don't interfere with input fields
}
if (e.key === ">" || (e.key === "ArrowDown" && e.ctrlKey)) {
e.preventDefault();
goToNextHighlight();
} else if (e.key === "<" || (e.key === "ArrowUp" && e.ctrlKey)) {
e.preventDefault();
goToPreviousHighlight();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentHighlightIndex, sortedRanges.length]);
// Scroll first highlighted range into view on initial load
useEffect(() => {
if (sortedRanges.length > 0) {
setCurrentHighlightIndex(0);
scrollToHighlight(0);
}
}, [highlightRanges]);
// Update decorations when current highlight changes
useEffect(() => {
updateHighlightDecorations();
}, [currentHighlightIndex, characterRanges]);
// Handle Monaco Editor mount
const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor) => {
editorRef.current = editor;
// Apply initial decorations for highlights
updateHighlightDecorations();
// Configure editor options
editor.updateOptions({
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
lineNumbers: "on",
folding: false,
renderWhitespace: "selection",
fontSize: 12,
fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
lineHeight: 18,
letterSpacing: 0.5,
fontWeight: "400",
renderLineHighlight: "none",
cursorStyle: "line",
cursorBlinking: "blink",
smoothScrolling: true,
});
};
// Update highlight decorations in Monaco Editor
const updateHighlightDecorations = () => {
if (!editorRef.current) return;
// Clear all existing decorations first to prevent old highlights from persisting
if (decorations.length > 0) {
editorRef.current.deltaDecorations(decorations, []);
setDecorations([]);
}
const newDecorations: editor.IModelDeltaDecoration[] = [];
characterRanges.forEach((range, index) => {
const startPosition = editorRef
.current!.getModel()!
.getPositionAt(range.startChar);
const endPosition = editorRef
.current!.getModel()!
.getPositionAt(range.endChar);
const isCurrentHighlight = index === currentHighlightIndex;
newDecorations.push({
range: {
startLineNumber: startPosition.lineNumber,
startColumn: startPosition.column,
endLineNumber: endPosition.lineNumber,
endColumn: endPosition.column,
},
options: {
className: isCurrentHighlight
? "current-highlight-decoration"
: "highlight-decoration",
isWholeLine: false,
},
});
});
// Apply new decorations
const decorationIds = editorRef.current.deltaDecorations(
[],
newDecorations
);
setDecorations(decorationIds);
};
// Detect language for syntax highlighting
const detectLanguage = (content: string): string => {
// Check for JSON content
if (content.trim().startsWith("{") && content.trim().endsWith("}")) {
try {
JSON.parse(content);
return "json";
} catch {
// Not valid JSON, continue checking
}
}
// Check for Python code patterns
if (/^(import|from|def|class|if __name__|print\()/m.test(content)) {
return "python";
}
// Check for JavaScript/TypeScript patterns
if (/^(import|export|function|const|let|var|console\.log)/m.test(content)) {
return "javascript";
}
// Check for shell/bash patterns
if (
/^(#!\/bin\/|cd |ls |mkdir |rm |pip install|npm install)/m.test(content)
) {
return "shell";
}
// Check for SQL patterns
if (/^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/im.test(content)) {
return "sql";
}
// Default to plain text
return "text";
};
const detectedLanguage = useMemo(
() => detectLanguage(reconstructedContent),
[reconstructedContent]
);
if (isCollapsed) {
return (
<div className="w-4 flex-shrink-0 border-r bg-muted/10 flex items-center justify-center transition-all duration-300">
<button
className="p-1 hover:bg-muted/20 rounded"
onClick={() => setIsCollapsed(false)}
title="Expand Trace"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
);
}
return (
<div
className={`${widthClass} flex-shrink-0 border-r bg-muted/10 flex flex-col transition-all duration-300`}
>
{/* Header */}
<div className="flex items-center justify-between h-10 px-2 border-b text-xs font-semibold text-muted-foreground shrink-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<button
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
activeTab === "system"
? "bg-primary/10 text-primary"
: "hover:bg-muted/20"
}`}
onClick={() => setActiveTab("system")}
>
System
</button>
{showTraceTab && (
<button
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
activeTab === "trace"
? "bg-primary/10 text-primary"
: "hover:bg-muted/20"
}`}
onClick={() => setActiveTab("trace")}
>
Trace
</button>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
className="p-1 hover:bg-muted/20 rounded"
onClick={() => setIsCollapsed(true)}
title="Collapse Trace"
>
<ChevronLeft className="h-3 w-3" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden" ref={containerRef}>
{activeTab === "system" ? (
<div className="flex flex-col h-full p-3 space-y-3 overflow-y-auto">
{knowledgeGraph ? (
<>
{/* System Hero Section */}
<div className="relative overflow-hidden rounded-lg bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-blue-950/20 dark:via-indigo-950/20 dark:to-purple-950/20 p-4 border border-blue-200/50 dark:border-blue-800/50">
<div className="relative z-10">
{knowledgeGraph.system_name && (
<div className="mb-3">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 leading-tight">
{knowledgeGraph.system_name}
</h2>
</div>
)}
{knowledgeGraph.system_summary && (
<p className="text-xs text-gray-600 dark:text-gray-300 leading-relaxed">
{knowledgeGraph.system_summary}
</p>
)}
</div>
{/* Subtle background pattern */}
<div className="absolute top-0 right-0 opacity-5">
<Cpu className="h-16 w-16" />
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-2">
{/* Entities Card */}
<Card className="border border-emerald-200/50 dark:border-emerald-800/50 bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-950/20 dark:to-green-950/20">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="p-1 rounded-md bg-emerald-100 dark:bg-emerald-900/50">
<Database className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Entities
</p>
<p className="text-lg font-bold text-emerald-700 dark:text-emerald-300 leading-none">
{knowledgeGraph.entity_count || 0}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Relations Card */}
<Card className="border border-blue-200/50 dark:border-blue-800/50 bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="p-1 rounded-md bg-blue-100 dark:bg-blue-900/50">
<GitBranch className="h-3 w-3 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-600 dark:text-blue-400 font-medium">
Relations
</p>
<p className="text-lg font-bold text-blue-700 dark:text-blue-300 leading-none">
{knowledgeGraph.relation_count || 0}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Processing Details */}
<Card className="border border-gray-200/50 dark:border-gray-800/50">
<CardContent className="p-3">
{/* Processing Information */}
{knowledgeGraph.processing_metadata && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<BarChart3 className="h-4 w-4" />
Processing Details
</div>
{knowledgeGraph.processing_metadata.method_name && (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Cpu className="h-3 w-3 text-gray-500" />
<span className="text-xs text-gray-600 dark:text-gray-400">
Method
</span>
</div>
<span className="text-xs font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-gray-700 dark:text-gray-300">
{knowledgeGraph.processing_metadata.method_name}
</span>
</div>
)}
{knowledgeGraph.processing_metadata.splitter_type && (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Layers className="h-3 w-3 text-gray-500" />
<span className="text-xs text-gray-600 dark:text-gray-400">
Splitter
</span>
</div>
<span className="text-xs font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-gray-700 dark:text-gray-300">
{knowledgeGraph.processing_metadata.splitter_type}
</span>
</div>
)}
{knowledgeGraph.window_total && (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Clock className="h-3 w-3 text-gray-500" />
<span className="text-xs text-gray-600 dark:text-gray-400">
Windows
</span>
</div>
<span className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-gray-700 dark:text-gray-300">
{knowledgeGraph.window_total}
</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
</>
) : (
<div className="flex items-center justify-center h-full text-center">
<div>
<Info className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">
System Information
</h3>
<p className="text-sm text-muted-foreground">
No system data available for this view
</p>
</div>
</div>
)}
</div>
) : activeTab === "trace" ? (
<div className="flex flex-col h-full">
{/* Navigation controls for trace */}
{sortedRanges.length > 0 && (
<div className="flex items-center justify-center gap-2 p-2 border-b bg-muted/10">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={goToPreviousHighlight}
title="Previous highlight (<)"
>
<ChevronUp className="h-3 w-3" />
</Button>
<span className="text-xs text-muted-foreground">
{currentHighlightIndex + 1} / {sortedRanges.length}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={goToNextHighlight}
title="Next highlight (>)"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
)}
<div className="flex-1">
<Editor
height="100%"
defaultLanguage={detectedLanguage}
value={reconstructedContent}
onMount={handleEditorDidMount}
theme="vs-dark"
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
lineNumbers: "on",
folding: false,
renderWhitespace: "selection",
fontSize: 12,
fontFamily:
"'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
padding: { top: 10, bottom: 10 },
}}
/>
</div>
</div>
) : null}
</div>
{/* Footer with navigation hint */}
{activeTab === "trace" && sortedRanges.length > 0 && (
<div className="px-2 py-1 border-t text-xs text-muted-foreground/80 shrink-0">
<div className="flex items-center justify-center gap-1">
<span>Press</span>
<kbd className="px-1 py-0.5 bg-muted/50 rounded text-xs">{"<"}</kbd>
<kbd className="px-1 py-0.5 bg-muted/50 rounded text-xs">{">"}</kbd>
<span>to navigate</span>
</div>
</div>
)}
</div>
);
};