Spaces:
Running
Running
| 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> | |
| ); | |
| }; | |