diff --git a/perf-analysis/AppLayoutOptimized.tsx b/perf-analysis/AppLayoutOptimized.tsx new file mode 100644 index 00000000000..8d12527a7e6 --- /dev/null +++ b/perf-analysis/AppLayoutOptimized.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Box, useApp } from 'ink'; +import { VirtualizedMessageContainer } from './VirtualizedMessageContainer'; +import { usePerformanceMonitor } from './performanceHooks'; + +// This is a proof-of-concept component to demonstrate the performance fixes +export const AppLayoutOptimized: React.FC = () => { + const { stdout } = useApp(); + const { metrics, measureOperation } = usePerformanceMonitor('AppLayout', { + logToConsole: true + }); + + // Calculate viewport dimensions based on terminal size + const viewportHeight = stdout.rows - 4; // Reserve space for input, etc. + const viewportWidth = stdout.columns; + + // In a real implementation, messages would come from app state + const messages = React.useMemo(() => { + return Array(1000).fill(null).map((_, index) => ({ + id: `msg-${index}`, + role: index % 2 === 0 ? 'user' : 'assistant', + content: `This is message ${index}. It contains some content that might wrap to multiple lines depending on the terminal width. This demonstrates how virtualization can significantly improve performance.`, + })); + }, []); + + return ( + + + + + + {/* Performance metrics display */} + + + + + Avg render time: + {metrics.averageRenderTime.toFixed(2)}ms + + + Total renders: + {metrics.totalRenders} + + + Slow renders: + {metrics.slowRenders} + + + + + + ); +}; \ No newline at end of file diff --git a/perf-analysis/VirtualizedMessageContainer.tsx b/perf-analysis/VirtualizedMessageContainer.tsx new file mode 100644 index 00000000000..ca679754d12 --- /dev/null +++ b/perf-analysis/VirtualizedMessageContainer.tsx @@ -0,0 +1,147 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { FixedSizeList as List } from 'react-window'; +import { Box, Text } from 'ink'; +import { useTheme } from '../hooks/useTheme'; +import { MessageData } from '../gatewayTypes'; +import { Markdown } from './markdown'; +import { themed } from './themed'; + +// Estimated average height for message rows (will be refined later) +const ESTIMATED_ROW_HEIGHT = 50; + +// Overscan count - render this many items above/below the visible area +const OVERSCAN_COUNT = 10; + +interface MessageLineProps { + message: MessageData; + onRender?: () => void; + isHighlighted?: boolean; + expandCode?: boolean; +} + +export const MessageLine: React.FC = React.memo(({ + message, + onRender, + isHighlighted = false, + expandCode = false +}) => { + const theme = useTheme(); + const { role, content } = message; + + useEffect(() => { + onRender?.(); + }, [onRender]); + + // Skip rendering for empty messages + if (!content) return null; + + const RoleLabel = themed(Text, { + user: theme.message.user.label, + assistant: theme.message.assistant.label, + system: theme.message.system.label, + tool: theme.message.tool.label, + function: theme.message.function.label, + }); + + const roleStyles = { + user: theme.message.user.content, + assistant: theme.message.assistant.content, + system: theme.message.system.content, + tool: theme.message.tool.content, + function: theme.message.function.content, + }; + + return ( + + + {role}: + + + + + + ); +}, (prevProps, nextProps) => { + // Custom comparison logic for memoization + return ( + prevProps.message.id === nextProps.message.id && + prevProps.message.content === nextProps.message.content && + prevProps.message.role === nextProps.message.role && + prevProps.isHighlighted === nextProps.isHighlighted && + prevProps.expandCode === nextProps.expandCode + ); +}); + +interface MessageContainerProps { + messages: MessageData[]; + height: number; + width: number; + expandCode?: boolean; + highlightedMessageId?: string; +} + +export const VirtualizedMessageContainer: React.FC = ({ + messages, + height, + width, + expandCode = false, + highlightedMessageId, +}) => { + const listRef = useRef(null); + const [measuredHeights, setMeasuredHeights] = useState>({}); + + // Scroll to bottom on new messages + useEffect(() => { + if (listRef.current && messages.length > 0) { + listRef.current.scrollToItem(messages.length - 1); + } + }, [messages.length]); + + // Record the actual rendered heights for more accurate virtualization + const handleMessageRender = (id: string, index: number) => { + // In a real implementation, we would measure DOM nodes here + // This is a placeholder for the concept + if (!measuredHeights[id]) { + setMeasuredHeights(prev => ({ + ...prev, + [id]: ESTIMATED_ROW_HEIGHT // In reality, we'd measure the actual height + })); + } + }; + + return ( + + {({ index, style }) => { + const message = messages[index]; + return ( +
+ handleMessageRender(message.id, index)} + /> +
+ ); + }} +
+ ); +}; \ No newline at end of file diff --git a/perf-analysis/messageLine-optimized.tsx b/perf-analysis/messageLine-optimized.tsx new file mode 100644 index 00000000000..a9e3d914094 --- /dev/null +++ b/perf-analysis/messageLine-optimized.tsx @@ -0,0 +1,188 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { useTheme } from '../hooks/useTheme'; +import { MessageData } from '../gatewayTypes'; +import { Markdown } from './markdown'; +import { themed } from './themed'; +import { usePerformanceMonitor, useScrollPerformance } from '../hooks/performanceHooks'; + +// Optimize the MessageLine component with proper memoization +export const MessageLine: React.FC<{ + message: MessageData; + isHighlighted?: boolean; + expandCode?: boolean; +}> = React.memo(({ message, isHighlighted = false, expandCode = false }) => { + const theme = useTheme(); + const { role, content } = message; + const { logEvent } = usePerformanceMonitor(`MessageLine-${role.substring(0,1)}${message.id?.substring(0,4)}`); + + // Skip rendering for empty messages + if (!content) return null; + + const RoleLabel = themed(Text, { + user: theme.message.user.label, + assistant: theme.message.assistant.label, + system: theme.message.system.label, + tool: theme.message.tool.label, + function: theme.message.function.label, + }); + + const roleStyles = { + user: theme.message.user.content, + assistant: theme.message.assistant.content, + system: theme.message.system.content, + tool: theme.message.tool.content, + function: theme.message.function.content, + }; + + // Log initial render for performance monitoring + useEffect(() => { + logEvent('initial-render'); + }, []); + + return ( + + + {role}: + + + + + + ); +}, (prevProps, nextProps) => { + // Custom comparison to prevent unnecessary re-renders + return ( + prevProps.message.id === nextProps.message.id && + prevProps.message.content === nextProps.message.content && + prevProps.message.role === nextProps.message.role && + prevProps.isHighlighted === nextProps.isHighlighted && + prevProps.expandCode === nextProps.expandCode + ); +}); + +// Fixed window approach for rendering only visible + buffer messages +export const MessageContainer: React.FC<{ + messages: MessageData[]; + scrollBuffer?: number; + expandCode?: boolean; + highlightedMessageId?: string; +}> = ({ messages, scrollBuffer = 50, expandCode = false, highlightedMessageId }) => { + const containerRef = useRef(null); + const { onScroll } = useScrollPerformance('MessageContainer'); + const { logEvent } = usePerformanceMonitor('MessageContainer'); + + // Track visible range + const [visibleRange, setVisibleRange] = useState({ + start: Math.max(0, messages.length - 30), + end: messages.length + }); + + // Handle scroll events to update visible range + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const scrollRatio = scrollTop / (scrollHeight - clientHeight); + + // Calculate visible range based on scroll position + const totalMessages = messages.length; + const visibleCount = 30; // Approximate number of visible messages + const bufferSize = scrollBuffer; + + // Calculate start/end indices + const middleIndex = Math.floor(scrollRatio * totalMessages); + const halfVisible = Math.floor(visibleCount / 2); + + let start = Math.max(0, middleIndex - halfVisible - bufferSize); + let end = Math.min(totalMessages, middleIndex + halfVisible + bufferSize); + + // Special case for start/end of list + if (scrollRatio < 0.1) { + start = 0; + end = Math.min(totalMessages, visibleCount + bufferSize); + } else if (scrollRatio > 0.9) { + end = totalMessages; + start = Math.max(0, totalMessages - visibleCount - bufferSize); + } + + setVisibleRange({ start, end }); + + // Performance monitoring + onScroll(); + }, [messages.length, scrollBuffer, onScroll]); + + // Auto-scroll to bottom on new messages + useEffect(() => { + if (containerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; + + if (isNearBottom) { + // Only auto-scroll if we're already near the bottom + logEvent('auto-scroll'); + containerRef.current.scrollTop = scrollHeight; + + // Update visible range to show bottom messages + setVisibleRange({ + start: Math.max(0, messages.length - 30 - scrollBuffer), + end: messages.length + }); + } + } + }, [messages.length, scrollBuffer]); + + // Log rendering details + useEffect(() => { + logEvent(`render-range-${visibleRange.start}-${visibleRange.end}`); + }, [visibleRange]); + + // Get visible messages subset + const visibleMessages = messages.slice(visibleRange.start, visibleRange.end); + + return ( + + {/* Spacer for scroll position */} + {visibleRange.start > 0 && ( + + )} + + {/* Visible messages */} + {visibleMessages.map((message) => ( + + ))} + + {/* Spacer for remaining messages */} + {visibleRange.end < messages.length && ( + + )} + + ); +}; \ No newline at end of file diff --git a/perf-analysis/performanceHooks.ts b/perf-analysis/performanceHooks.ts new file mode 100644 index 00000000000..b82e0e20114 --- /dev/null +++ b/perf-analysis/performanceHooks.ts @@ -0,0 +1,207 @@ +import { useRef, useCallback, useState, useEffect } from 'react'; + +/** + * Custom hook for performance monitoring + * Helps track and log performance metrics for components + */ +export function usePerformanceMonitor(componentName: string, options = { + logToConsole: false, + thresholdMs: 16 // 60fps threshold +}) { + const renderCountRef = useRef(0); + const renderTimesRef = useRef([]); + const lastRenderTimeRef = useRef(performance.now()); + const [metrics, setMetrics] = useState({ + averageRenderTime: 0, + totalRenders: 0, + slowRenders: 0 + }); + + // Measure start of render cycle + useEffect(() => { + const startTime = performance.now(); + + return () => { + const endTime = performance.now(); + const renderTime = endTime - startTime; + + renderCountRef.current += 1; + renderTimesRef.current.push(renderTime); + + // Keep only the last 100 measurements + if (renderTimesRef.current.length > 100) { + renderTimesRef.current.shift(); + } + + // Calculate average render time + const average = renderTimesRef.current.reduce((sum, time) => sum + time, 0) / + renderTimesRef.current.length; + + // Count slow renders + const slowRenders = renderTimesRef.current.filter(time => time > options.thresholdMs).length; + + // Update metrics + setMetrics({ + averageRenderTime: average, + totalRenders: renderCountRef.current, + slowRenders + }); + + if (options.logToConsole && renderTime > options.thresholdMs) { + console.log( + `[PERF] ${componentName} render: ${renderTime.toFixed(2)}ms ` + + `(avg: ${average.toFixed(2)}ms, slow: ${slowRenders}/${renderCountRef.current})` + ); + } + + lastRenderTimeRef.current = endTime; + }; + }); + + // Function to measure specific operations + const measureOperation = useCallback((operationName: string, fn: () => void) => { + const start = performance.now(); + fn(); + const duration = performance.now() - start; + + if (options.logToConsole && duration > options.thresholdMs) { + console.log(`[PERF] ${componentName}.${operationName}: ${duration.toFixed(2)}ms`); + } + + return duration; + }, [componentName, options.logToConsole, options.thresholdMs]); + + return { + metrics, + measureOperation, + logEvent: (event: string, durationMs?: number) => { + if (options.logToConsole) { + const message = durationMs + ? `[PERF] ${componentName}.${event}: ${durationMs.toFixed(2)}ms` + : `[PERF] ${componentName}.${event}`; + console.log(message); + } + } + }; +} + +/** + * Hook to debounce frequent updates + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +/** + * Hook to throttle frequent updates + */ +export function useThrottle(value: T, limit: number): T { + const [throttledValue, setThrottledValue] = useState(value); + const lastRan = useRef(Date.now()); + + useEffect(() => { + const handler = setTimeout(() => { + if (Date.now() - lastRan.current >= limit) { + setThrottledValue(value); + lastRan.current = Date.now(); + } + }, limit - (Date.now() - lastRan.current)); + + return () => { + clearTimeout(handler); + }; + }, [value, limit]); + + return throttledValue; +} + +/** + * Hook to measure and track scroll performance + */ +export function useScrollPerformance(componentName: string, options = { + logToConsole: false, + sampleRate: 0.1, // Only log 10% of scroll events to reduce noise + thresholdMs: 16 +}) { + const scrollCountRef = useRef(0); + const scrollTimesRef = useRef([]); + const isScrollingRef = useRef(false); + const scrollStartTimeRef = useRef(0); + const scrollThrottleTimerRef = useRef(null); + + const onScrollStart = useCallback(() => { + if (!isScrollingRef.current) { + isScrollingRef.current = true; + scrollStartTimeRef.current = performance.now(); + + if (options.logToConsole) { + console.log(`[SCROLL] ${componentName} scroll started`); + } + } + }, [componentName, options.logToConsole]); + + const onScrollEnd = useCallback(() => { + if (isScrollingRef.current) { + const duration = performance.now() - scrollStartTimeRef.current; + scrollTimesRef.current.push(duration); + + // Keep array at reasonable size + if (scrollTimesRef.current.length > 50) { + scrollTimesRef.current.shift(); + } + + isScrollingRef.current = false; + + if (options.logToConsole && Math.random() < options.sampleRate) { + const avg = scrollTimesRef.current.reduce((sum, time) => sum + time, 0) / + scrollTimesRef.current.length; + + console.log( + `[SCROLL] ${componentName} scroll ended: ${duration.toFixed(2)}ms ` + + `(avg: ${avg.toFixed(2)}ms)` + ); + } + } + }, [componentName, options.logToConsole, options.sampleRate]); + + const onScroll = useCallback(() => { + scrollCountRef.current += 1; + + // Start scrolling tracking if not already + onScrollStart(); + + // Reset the scroll end timer + if (scrollThrottleTimerRef.current) { + clearTimeout(scrollThrottleTimerRef.current); + } + + // Set timer to detect when scrolling stops + scrollThrottleTimerRef.current = setTimeout(() => { + onScrollEnd(); + }, 150); // Consider scrolling stopped after 150ms of inactivity + + }, [onScrollStart, onScrollEnd]); + + // Clean up + useEffect(() => { + return () => { + if (scrollThrottleTimerRef.current) { + clearTimeout(scrollThrottleTimerRef.current); + } + }; + }, []); + + return { onScroll }; +} \ No newline at end of file diff --git a/perf-analysis/tui-perf-analysis.md b/perf-analysis/tui-perf-analysis.md new file mode 100644 index 00000000000..0957c22380c --- /dev/null +++ b/perf-analysis/tui-perf-analysis.md @@ -0,0 +1,118 @@ +# TUI Performance Analysis + +## Issues Identified + +1. **Scrolling lag with large message history** + - No virtualization or windowing in message rendering + - Each message re-renders on scroll + - Complete DOM reconstruction on each render + +2. **Input jitter with scrollbar** + - Composer width changes when scrollbar appears/disappears + - Layout shifts when scrolling near bottom + +3. **Layout thrashing** + - Multiple successive layout recalculations + - Excessive style computations in the render loop + +## Investigation Areas + +### 1. Message Rendering Performance + +Current implementation in `messageLine.tsx` renders all messages in the transcript without virtualization. For long sessions, this means: + +- Every message is always in the DOM +- Complete re-rendering happens on each state change +- No windowing or culling of off-screen content +- Layout recalculations for entire transcript on each scroll + +### 2. Re-rendering Optimization + +- No memoization of message components +- No element recycling +- Each message potentially triggers layout shifts + +### 3. Scrollbar Behavior + +- Composer width calculation doesn't account for scrollbar presence +- No stable layout constraints + +## Proposed Solutions + +### 1. Implement Virtualized List for Messages + +Add `react-window` or similar virtualization library to render only visible messages: + +```tsx +import { FixedSizeList as List } from 'react-window'; + +// In the component render + + {({ index, style }) => ( +
+ +
+ )} +
+``` + +### 2. Memoize Message Components + +Use `React.memo` to prevent unnecessary re-renders: + +```tsx +const MessageLine = React.memo(({ message, ...props }) => { + // Component logic +}, (prevProps, nextProps) => { + // Custom comparison logic + return prevProps.message.id === nextProps.message.id && + prevProps.message.content === nextProps.message.content; +}); +``` + +### 3. Fix Scrollbar Layout Issues + +- Add scrollbar-gutter CSS to reserve space for scrollbar +- Stabilize layout with fixed container dimensions + +```css +.message-container { + scrollbar-gutter: stable; + overflow-y: auto; +} +``` + +### 4. Add Performance Measurements + +Add performance monitoring to identify bottlenecks: + +```tsx +useEffect(() => { + const start = performance.now(); + // Measure key operations + return () => { + console.log(`Operation took ${performance.now() - start}ms`); + }; +}, [dependencyArray]); +``` + +## Implementation Plan + +1. Add virtualization for message rendering +2. Implement memo optimization for components +3. Fix scrollbar layout issues +4. Add performance monitoring +5. Optimize re-render triggers +6. Improve scroll restoration + +## Resources + +- [React Window](https://github.com/bvaughn/react-window) +- [React Virtualized](https://github.com/bvaughn/react-virtualized) +- [CSS Scrollbar Gutter](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter) \ No newline at end of file