mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 15:31:38 +08:00
Compare commits
4 Commits
fix/plugin
...
bb/tui-per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b7bad6be | ||
|
|
6022d95732 | ||
|
|
2614d46f06 | ||
|
|
2c5fb45d08 |
70
perf-analysis/AppLayoutOptimized.tsx
Normal file
70
perf-analysis/AppLayoutOptimized.tsx
Normal file
@@ -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 (
|
||||||
|
<Box flexDirection="column" height={stdout.rows} width={stdout.columns}>
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
height={viewportHeight}
|
||||||
|
width={viewportWidth}
|
||||||
|
overflow="hidden"
|
||||||
|
// Use stable scrollbar gutter to prevent layout shifts
|
||||||
|
style={{ scrollbarGutter: 'stable' }}
|
||||||
|
>
|
||||||
|
<VirtualizedMessageContainer
|
||||||
|
messages={messages}
|
||||||
|
height={viewportHeight}
|
||||||
|
width={viewportWidth}
|
||||||
|
expandCode={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Performance metrics display */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor="yellow"
|
||||||
|
paddingX={1}
|
||||||
|
width={viewportWidth}
|
||||||
|
>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Box width={25}>Avg render time:</Box>
|
||||||
|
<Box>{metrics.averageRenderTime.toFixed(2)}ms</Box>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Box width={25}>Total renders:</Box>
|
||||||
|
<Box>{metrics.totalRenders}</Box>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Box width={25}>Slow renders:</Box>
|
||||||
|
<Box>{metrics.slowRenders}</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
147
perf-analysis/VirtualizedMessageContainer.tsx
Normal file
147
perf-analysis/VirtualizedMessageContainer.tsx
Normal file
@@ -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<MessageLineProps> = 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 (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={0}
|
||||||
|
paddingY={0}
|
||||||
|
borderStyle={isHighlighted ? 'bold' : undefined}
|
||||||
|
borderColor={isHighlighted ? theme.focused : undefined}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<RoleLabel variant={role as any}>{role}:</RoleLabel>
|
||||||
|
</Box>
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Markdown
|
||||||
|
variant={role as keyof typeof roleStyles}
|
||||||
|
content={content || ''}
|
||||||
|
expandCode={expandCode}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}, (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<MessageContainerProps> = ({
|
||||||
|
messages,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
expandCode = false,
|
||||||
|
highlightedMessageId,
|
||||||
|
}) => {
|
||||||
|
const listRef = useRef<List>(null);
|
||||||
|
const [measuredHeights, setMeasuredHeights] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<List
|
||||||
|
ref={listRef}
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
itemCount={messages.length}
|
||||||
|
itemSize={ESTIMATED_ROW_HEIGHT}
|
||||||
|
overscanCount={OVERSCAN_COUNT}
|
||||||
|
style={{ scrollbarGutter: 'stable' }}
|
||||||
|
>
|
||||||
|
{({ index, style }) => {
|
||||||
|
const message = messages[index];
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<MessageLine
|
||||||
|
message={message}
|
||||||
|
expandCode={expandCode}
|
||||||
|
isHighlighted={message.id === highlightedMessageId}
|
||||||
|
onRender={() => handleMessageRender(message.id, index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
188
perf-analysis/messageLine-optimized.tsx
Normal file
188
perf-analysis/messageLine-optimized.tsx
Normal file
@@ -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 (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={0}
|
||||||
|
paddingY={0}
|
||||||
|
borderStyle={isHighlighted ? 'bold' : undefined}
|
||||||
|
borderColor={isHighlighted ? theme.focused : undefined}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<RoleLabel variant={role as any}>{role}:</RoleLabel>
|
||||||
|
</Box>
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Markdown
|
||||||
|
variant={role as keyof typeof roleStyles}
|
||||||
|
content={content || ''}
|
||||||
|
expandCode={expandCode}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}, (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<HTMLDivElement>(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 (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
overflow="auto"
|
||||||
|
ref={containerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
style={{ scrollbarGutter: 'stable both-edges' }}
|
||||||
|
>
|
||||||
|
{/* Spacer for scroll position */}
|
||||||
|
{visibleRange.start > 0 && (
|
||||||
|
<Box
|
||||||
|
height={visibleRange.start * 3}
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visible messages */}
|
||||||
|
{visibleMessages.map((message) => (
|
||||||
|
<MessageLine
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
expandCode={expandCode}
|
||||||
|
isHighlighted={message.id === highlightedMessageId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Spacer for remaining messages */}
|
||||||
|
{visibleRange.end < messages.length && (
|
||||||
|
<Box
|
||||||
|
height={(messages.length - visibleRange.end) * 3}
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
207
perf-analysis/performanceHooks.ts
Normal file
207
perf-analysis/performanceHooks.ts
Normal file
@@ -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<number[]>([]);
|
||||||
|
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<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to throttle frequent updates
|
||||||
|
*/
|
||||||
|
export function useThrottle<T>(value: T, limit: number): T {
|
||||||
|
const [throttledValue, setThrottledValue] = useState<T>(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<number[]>([]);
|
||||||
|
const isScrollingRef = useRef(false);
|
||||||
|
const scrollStartTimeRef = useRef(0);
|
||||||
|
const scrollThrottleTimerRef = useRef<NodeJS.Timeout | null>(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 };
|
||||||
|
}
|
||||||
118
perf-analysis/tui-perf-analysis.md
Normal file
118
perf-analysis/tui-perf-analysis.md
Normal file
@@ -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
|
||||||
|
<List
|
||||||
|
height={viewportHeight}
|
||||||
|
itemCount={messages.length}
|
||||||
|
itemSize={estimatedRowHeight}
|
||||||
|
width="100%"
|
||||||
|
overscanCount={5}
|
||||||
|
>
|
||||||
|
{({ index, style }) => (
|
||||||
|
<div style={style}>
|
||||||
|
<MessageLine message={messages[index]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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)
|
||||||
197
ui-tui/src/components/FixedWindowScroller.tsx
Normal file
197
ui-tui/src/components/FixedWindowScroller.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, useApp } from 'ink';
|
||||||
|
import { usePerformanceMonitor } from '../hooks/usePerformance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fixed window scroller component for efficient rendering of large lists
|
||||||
|
* This is a lightweight virtualization component that only renders visible items
|
||||||
|
* plus a configurable overscan buffer for smooth scrolling
|
||||||
|
*/
|
||||||
|
export const FixedWindowScroller = React.forwardRef(({
|
||||||
|
items,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
itemHeight = 3, // Average height of each item in terminal rows
|
||||||
|
renderItem,
|
||||||
|
overscrollItems = 20, // Number of items to render outside visible area
|
||||||
|
onScroll,
|
||||||
|
initialScrollToEnd = true,
|
||||||
|
}, ref) => {
|
||||||
|
const { stdout } = useApp();
|
||||||
|
const { logEvent } = usePerformanceMonitor('FixedWindowScroller', {
|
||||||
|
logToConsole: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Container ref for scroll measurements
|
||||||
|
const containerRef = React.useRef(null);
|
||||||
|
|
||||||
|
// Track scroll state
|
||||||
|
const lastScrollTopRef = React.useRef(0);
|
||||||
|
const lastItemsLengthRef = React.useRef(items.length);
|
||||||
|
|
||||||
|
// Calculate visible window based on container dimensions
|
||||||
|
const [visibleWindow, setVisibleWindow] = React.useState({
|
||||||
|
startIndex: Math.max(0, items.length - Math.floor(height / itemHeight) - overscrollItems),
|
||||||
|
endIndex: items.length,
|
||||||
|
scrollTop: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose scroll methods via ref
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
scrollToItem: (index, align = 'auto') => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const itemOffset = index * itemHeight;
|
||||||
|
|
||||||
|
if (align === 'start') {
|
||||||
|
container.scrollTop = itemOffset;
|
||||||
|
} else if (align === 'end') {
|
||||||
|
container.scrollTop = itemOffset - height + itemHeight;
|
||||||
|
} else if (align === 'center') {
|
||||||
|
container.scrollTop = itemOffset - height / 2 + itemHeight / 2;
|
||||||
|
} else {
|
||||||
|
// Auto alignment - only scroll if item is outside visible area
|
||||||
|
const { scrollTop } = container;
|
||||||
|
const visibleBottom = scrollTop + height;
|
||||||
|
|
||||||
|
if (itemOffset < scrollTop) {
|
||||||
|
container.scrollTop = itemOffset;
|
||||||
|
} else if (itemOffset + itemHeight > visibleBottom) {
|
||||||
|
container.scrollTop = itemOffset - height + itemHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToTop: () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom: () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Compatibility with ScrollBoxHandle
|
||||||
|
getScrollTop: () => containerRef.current?.scrollTop || 0,
|
||||||
|
getViewportHeight: () => height,
|
||||||
|
getPendingDelta: () => 0,
|
||||||
|
isSticky: () => visibleWindow.startIndex === items.length - visibleItemCount,
|
||||||
|
}), [height, itemHeight, items.length, visibleWindow.startIndex]);
|
||||||
|
|
||||||
|
// Calculate how many items fit in the viewport
|
||||||
|
const visibleItemCount = Math.ceil(height / itemHeight);
|
||||||
|
|
||||||
|
// Handle scroll events
|
||||||
|
const handleScroll = React.useCallback((event) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||||
|
const scrollTopDiff = Math.abs(scrollTop - lastScrollTopRef.current);
|
||||||
|
|
||||||
|
// Only update if we've scrolled a significant amount
|
||||||
|
if (scrollTopDiff > (itemHeight / 2)) {
|
||||||
|
const totalItems = items.length;
|
||||||
|
const visibleItems = Math.floor(clientHeight / itemHeight);
|
||||||
|
|
||||||
|
// Calculate the first visible item index
|
||||||
|
const firstVisibleItemIndex = Math.floor(scrollTop / itemHeight);
|
||||||
|
|
||||||
|
// Calculate start and end indices with overscroll
|
||||||
|
const startIndex = Math.max(0, firstVisibleItemIndex - overscrollItems);
|
||||||
|
const endIndex = Math.min(
|
||||||
|
totalItems,
|
||||||
|
firstVisibleItemIndex + visibleItems + overscrollItems
|
||||||
|
);
|
||||||
|
|
||||||
|
logEvent(`window-update-${startIndex}-${endIndex}`);
|
||||||
|
|
||||||
|
setVisibleWindow({ startIndex, endIndex, scrollTop });
|
||||||
|
lastScrollTopRef.current = scrollTop;
|
||||||
|
|
||||||
|
// Call external scroll handler if provided
|
||||||
|
if (onScroll) {
|
||||||
|
onScroll({
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
firstVisibleItemIndex,
|
||||||
|
lastVisibleItemIndex: firstVisibleItemIndex + visibleItems,
|
||||||
|
isAtTop: scrollTop < itemHeight,
|
||||||
|
isAtBottom: scrollTop + clientHeight >= scrollHeight - itemHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [items.length, itemHeight, overscrollItems, onScroll, logEvent]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new items are added
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const isNewMessagesAdded = items.length > lastItemsLengthRef.current;
|
||||||
|
const isNearBottom = containerRef.current.scrollHeight - containerRef.current.clientHeight - containerRef.current.scrollTop < itemHeight * 3;
|
||||||
|
|
||||||
|
if ((isNewMessagesAdded && isNearBottom) || initialScrollToEnd) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
|
||||||
|
// Update the visible window to show the end
|
||||||
|
setVisibleWindow({
|
||||||
|
startIndex: Math.max(0, items.length - Math.floor(height / itemHeight) - overscrollItems),
|
||||||
|
endIndex: items.length,
|
||||||
|
scrollTop: containerRef.current.scrollHeight
|
||||||
|
});
|
||||||
|
|
||||||
|
logEvent('auto-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
lastItemsLengthRef.current = items.length;
|
||||||
|
}, [items.length, height, itemHeight, overscrollItems, initialScrollToEnd, logEvent]);
|
||||||
|
|
||||||
|
// Get the visible subset of items
|
||||||
|
const visibleItems = items.slice(visibleWindow.startIndex, visibleWindow.endIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={containerRef}
|
||||||
|
overflow="auto"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
style={{ scrollbarGutter: 'stable' }}
|
||||||
|
>
|
||||||
|
{/* Top spacer */}
|
||||||
|
{visibleWindow.startIndex > 0 && (
|
||||||
|
<Box
|
||||||
|
width="100%"
|
||||||
|
height={visibleWindow.startIndex * itemHeight}
|
||||||
|
padding={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visible items */}
|
||||||
|
{visibleItems.map((item, index) =>
|
||||||
|
renderItem({
|
||||||
|
item,
|
||||||
|
index: visibleWindow.startIndex + index,
|
||||||
|
isVisible: true
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom spacer */}
|
||||||
|
{visibleWindow.endIndex < items.length && (
|
||||||
|
<Box
|
||||||
|
width="100%"
|
||||||
|
height={(items.length - visibleWindow.endIndex) * itemHeight}
|
||||||
|
padding={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FixedWindowScroller.displayName = 'FixedWindowScroller';
|
||||||
|
|
||||||
|
export default FixedWindowScroller;
|
||||||
76
ui-tui/src/components/OptimizedTranscriptPane.tsx
Normal file
76
ui-tui/src/components/OptimizedTranscriptPane.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from 'ink';
|
||||||
|
import { FixedWindowScroller } from './FixedWindowScroller';
|
||||||
|
import { usePerformanceMonitor } from '../hooks/usePerformance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OptimizedTranscriptPane is a drop-in replacement for the transcript area
|
||||||
|
* that uses virtualization to dramatically improve performance with large
|
||||||
|
* message histories.
|
||||||
|
*/
|
||||||
|
export const OptimizedTranscriptPane = React.memo(({
|
||||||
|
messages,
|
||||||
|
renderMessage,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
onScroll,
|
||||||
|
}) => {
|
||||||
|
const { logEvent } = usePerformanceMonitor('OptimizedTranscriptPane', {
|
||||||
|
logToConsole: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reference to the scroller component
|
||||||
|
const scrollerRef = React.useRef(null);
|
||||||
|
|
||||||
|
// Keep track of visible window for debugging
|
||||||
|
const [visibleRange, setVisibleRange] = React.useState({ start: 0, end: 0 });
|
||||||
|
|
||||||
|
// Handle scroll events
|
||||||
|
const handleScroll = React.useCallback((scrollInfo) => {
|
||||||
|
setVisibleRange({
|
||||||
|
start: scrollInfo.firstVisibleItemIndex,
|
||||||
|
end: scrollInfo.lastVisibleItemIndex
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onScroll) {
|
||||||
|
onScroll(scrollInfo);
|
||||||
|
}
|
||||||
|
}, [onScroll]);
|
||||||
|
|
||||||
|
// Memoize the render function for better performance
|
||||||
|
const renderItem = React.useCallback(({ item, index, isVisible }) => {
|
||||||
|
if (!isVisible) {
|
||||||
|
return <Box height={3} />; // Placeholder with approximate height
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderMessage(item, index);
|
||||||
|
}, [renderMessage]);
|
||||||
|
|
||||||
|
// Log performance data
|
||||||
|
React.useEffect(() => {
|
||||||
|
logEvent(`render-range-${visibleRange.start}-${visibleRange.end}`);
|
||||||
|
}, [visibleRange, logEvent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
style={{ scrollbarGutter: 'stable' }}
|
||||||
|
>
|
||||||
|
<FixedWindowScroller
|
||||||
|
ref={scrollerRef}
|
||||||
|
items={messages}
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
itemHeight={3} // Average message height (will be refined)
|
||||||
|
renderItem={renderItem}
|
||||||
|
overscrollItems={25} // Number of off-screen items to keep mounted
|
||||||
|
onScroll={handleScroll}
|
||||||
|
initialScrollToEnd={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default OptimizedTranscriptPane;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $uiState } from '../app/uiStore.js'
|
||||||
|
import { OptimizedTranscriptPane } from './OptimizedTranscriptPane.js'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
|
||||||
import { useGateway } from '../app/gatewayContext.js'
|
import { useGateway } from '../app/gatewayContext.js'
|
||||||
@@ -98,21 +100,23 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const TranscriptPane = memo(function TranscriptPane({
|
const TranscriptPane = memo(function TranscriptPane({
|
||||||
actions,
|
const TranscriptPane = memo(function TranscriptPane({
|
||||||
composer,
|
actions,
|
||||||
progress,
|
composer,
|
||||||
transcript
|
progress,
|
||||||
|
transcript
|
||||||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
|
const usePerfMode = true // Always use performance mode for better scrolling
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
{usePerfMode ? (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<OptimizedTranscriptPane
|
||||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
messages={transcript.virtualRows}
|
||||||
|
height={ui.rows - 6} // Reserve space for input/status
|
||||||
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
|
width={composer.cols}
|
||||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
renderMessage={(row) => (
|
||||||
|
<Box flexDirection="column" key={row.key} paddingX={1}>
|
||||||
{row.msg.kind === 'intro' ? (
|
{row.msg.kind === 'intro' ? (
|
||||||
<Box flexDirection="column" paddingTop={1}>
|
<Box flexDirection="column" paddingTop={1}>
|
||||||
<Banner t={ui.theme} />
|
<Banner t={ui.theme} />
|
||||||
@@ -132,18 +136,35 @@ const TranscriptPane = memo(function TranscriptPane({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||||
|
|
||||||
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
|
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
|
||||||
|
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||||
|
{row.msg.kind === 'intro' ? (
|
||||||
|
<Box flexDirection="column" paddingTop={1}>
|
||||||
|
<Banner t={ui.theme} />
|
||||||
|
|
||||||
<StreamingAssistant
|
{row.msg.info?.version && <SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />}
|
||||||
busy={ui.busy}
|
</Box>
|
||||||
cols={composer.cols}
|
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
||||||
compact={ui.compact}
|
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
|
||||||
detailsMode={ui.detailsMode}
|
) : (
|
||||||
progress={progress}
|
<MessageLine
|
||||||
sections={ui.sections}
|
cols={composer.cols}
|
||||||
t={ui.theme}
|
compact={ui.compact}
|
||||||
|
detailsMode={ui.detailsMode}
|
||||||
|
msg={row.msg}
|
||||||
|
sections={ui.sections}
|
||||||
|
t={ui.theme}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</ScrollBox>
|
</ScrollBox>
|
||||||
|
|||||||
421
ui-tui/src/hooks/usePerformance.ts
Normal file
421
ui-tui/src/hooks/usePerformance.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { useRef, useCallback, useState, useEffect, useLayoutEffect } 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<number[]>([]);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced version of useVirtualHistory with better performance characteristics
|
||||||
|
* Uses the same API as the original but with optimizations for large message lists
|
||||||
|
*/
|
||||||
|
export function useEnhancedVirtualHistory(
|
||||||
|
scrollRef: any,
|
||||||
|
items: readonly { key: string }[],
|
||||||
|
columns: number,
|
||||||
|
options = {}
|
||||||
|
) {
|
||||||
|
// Core state
|
||||||
|
const nodesRef = useRef(new Map<string, unknown>());
|
||||||
|
const heightsRef = useRef(new Map<string, number>());
|
||||||
|
const refsMap = useRef(new Map<string, (el: unknown) => void>());
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
// Performance tracking
|
||||||
|
const measureTime = useRef({
|
||||||
|
offsetCalculation: 0,
|
||||||
|
heightUpdate: 0,
|
||||||
|
rangeCalculation: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default options
|
||||||
|
const {
|
||||||
|
estimate = 4,
|
||||||
|
overscan = 40,
|
||||||
|
maxMounted = 260,
|
||||||
|
coldStartCount = 40,
|
||||||
|
logPerformance = false
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Width change handling with scaling
|
||||||
|
const prevColumns = useRef(columns);
|
||||||
|
const skipMeasurement = useRef(false);
|
||||||
|
const prevRange = useRef<null | readonly [number, number]>(null);
|
||||||
|
const freezeRenders = useRef(0);
|
||||||
|
|
||||||
|
// Handle column width changes - scale heights to avoid full remeasurement
|
||||||
|
if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) {
|
||||||
|
const ratio = prevColumns.current / columns;
|
||||||
|
prevColumns.current = columns;
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
for (const [k, h] of heightsRef.current) {
|
||||||
|
heightsRef.current.set(k, Math.max(1, Math.round(h * ratio)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logPerformance) {
|
||||||
|
console.log(`[PERF] Height scaling: ${(performance.now() - start).toFixed(2)}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
skipMeasurement.current = true;
|
||||||
|
freezeRenders.current = 2; // Freeze for 2 renders to allow memos to stabilize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track scroll position and viewport
|
||||||
|
const metricsRef = useRef({
|
||||||
|
sticky: true,
|
||||||
|
top: 0,
|
||||||
|
viewportHeight: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
pendingDelta: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update scroll metrics whenever the scroll position changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const updateMetrics = () => {
|
||||||
|
const s = scrollRef.current;
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
metricsRef.current = {
|
||||||
|
sticky: s.isSticky?.() ?? true,
|
||||||
|
top: Math.max(0, s.getScrollTop?.() ?? 0),
|
||||||
|
viewportHeight: Math.max(0, s.getViewportHeight?.() ?? 0),
|
||||||
|
scrollTop: Math.max(0, s.getScrollTop?.() ?? 0),
|
||||||
|
pendingDelta: s.getPendingDelta?.() ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Force update if we need to recalculate visible range
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateMetrics();
|
||||||
|
|
||||||
|
// Subscribe to scroll events if supported
|
||||||
|
const unsubscribe = scrollRef.current.subscribe?.(updateMetrics) ?? (() => {});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [scrollRef.current]);
|
||||||
|
|
||||||
|
// Clean up stale items
|
||||||
|
useEffect(() => {
|
||||||
|
const keep = new Set(items.map(i => i.key));
|
||||||
|
let dirty = false;
|
||||||
|
|
||||||
|
for (const k of heightsRef.current.keys()) {
|
||||||
|
if (!keep.has(k)) {
|
||||||
|
heightsRef.current.delete(k);
|
||||||
|
nodesRef.current.delete(k);
|
||||||
|
refsMap.current.delete(k);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
// Calculate offsets based on cached heights - memoized to avoid recalculation
|
||||||
|
const offsets = React.useMemo(() => {
|
||||||
|
void version; // Depends on version to trigger recalculation
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const out = new Array<number>(items.length + 1).fill(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
out[i + 1] = out[i]! + Math.max(1, Math.floor(heightsRef.current.get(items[i]!.key) ?? estimate));
|
||||||
|
}
|
||||||
|
|
||||||
|
measureTime.current.offsetCalculation = performance.now() - start;
|
||||||
|
if (logPerformance && measureTime.current.offsetCalculation > 5) {
|
||||||
|
console.log(`[PERF] Offset calculation: ${measureTime.current.offsetCalculation.toFixed(2)}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}, [estimate, items, version]);
|
||||||
|
|
||||||
|
// Calculate visible range
|
||||||
|
const rangeStart = React.useMemo(() => {
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const n = items.length;
|
||||||
|
const total = offsets[n] ?? 0;
|
||||||
|
const metrics = metricsRef.current;
|
||||||
|
const { top, viewportHeight, sticky } = metrics;
|
||||||
|
|
||||||
|
// Handle frozen range for width changes
|
||||||
|
const frozenRange =
|
||||||
|
freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n ? prevRange.current : null;
|
||||||
|
|
||||||
|
let startIdx = 0;
|
||||||
|
let endIdx = n;
|
||||||
|
|
||||||
|
if (frozenRange) {
|
||||||
|
startIdx = frozenRange[0];
|
||||||
|
endIdx = Math.min(frozenRange[1], n);
|
||||||
|
} else if (n > 0) {
|
||||||
|
if (viewportHeight <= 0) {
|
||||||
|
startIdx = Math.max(0, n - coldStartCount);
|
||||||
|
} else {
|
||||||
|
// Binary search for start and end indices
|
||||||
|
let lo = 0;
|
||||||
|
let hi = n;
|
||||||
|
|
||||||
|
// Find start index (first item below top - overscan)
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
offsets[mid]! <= Math.max(0, top - overscan) ? (lo = mid + 1) : (hi = mid);
|
||||||
|
}
|
||||||
|
startIdx = Math.max(0, lo - 1);
|
||||||
|
|
||||||
|
// Find end index (first item below top + viewportHeight + overscan)
|
||||||
|
lo = startIdx;
|
||||||
|
hi = n;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
offsets[mid]! <= top + viewportHeight + overscan ? (lo = mid + 1) : (hi = mid);
|
||||||
|
}
|
||||||
|
endIdx = lo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit number of mounted items
|
||||||
|
if (endIdx - startIdx > maxMounted) {
|
||||||
|
sticky ? (startIdx = Math.max(0, endIdx - maxMounted)) : (endIdx = Math.min(n, startIdx + maxMounted));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update freeze counter
|
||||||
|
if (freezeRenders.current > 0) {
|
||||||
|
freezeRenders.current--;
|
||||||
|
} else {
|
||||||
|
prevRange.current = [startIdx, endIdx];
|
||||||
|
}
|
||||||
|
|
||||||
|
measureTime.current.rangeCalculation = performance.now() - start;
|
||||||
|
if (logPerformance && measureTime.current.rangeCalculation > 5) {
|
||||||
|
console.log(`[PERF] Range calculation: ${measureTime.current.rangeCalculation.toFixed(2)}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: startIdx, end: endIdx };
|
||||||
|
}, [items.length, offsets, version, overscan, maxMounted, coldStartCount]);
|
||||||
|
|
||||||
|
// Create measurement ref callback
|
||||||
|
const measureRef = useCallback((key: string) => {
|
||||||
|
let fn = refsMap.current.get(key);
|
||||||
|
|
||||||
|
if (!fn) {
|
||||||
|
fn = (el: unknown) => (el ? nodesRef.current.set(key, el) : nodesRef.current.delete(key));
|
||||||
|
refsMap.current.set(key, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update height measurements after render
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const start = performance.now();
|
||||||
|
let dirty = false;
|
||||||
|
|
||||||
|
if (skipMeasurement.current) {
|
||||||
|
skipMeasurement.current = false;
|
||||||
|
} else {
|
||||||
|
for (let i = rangeStart.start; i < rangeStart.end; i++) {
|
||||||
|
const k = items[i]?.key;
|
||||||
|
|
||||||
|
if (!k) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodesRef.current.get(k) as any;
|
||||||
|
const h = Math.ceil(node?.yogaNode?.getComputedHeight?.() ?? 0);
|
||||||
|
|
||||||
|
if (h > 0 && heightsRef.current.get(k) !== h) {
|
||||||
|
heightsRef.current.set(k, h);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
measureTime.current.heightUpdate = performance.now() - start;
|
||||||
|
if (logPerformance && measureTime.current.heightUpdate > 5) {
|
||||||
|
console.log(`[PERF] Height update: ${measureTime.current.heightUpdate.toFixed(2)}ms`);
|
||||||
|
}
|
||||||
|
}, [rangeStart.end, rangeStart.start, items]);
|
||||||
|
|
||||||
|
// Return the same API as the original hook for compatibility
|
||||||
|
return {
|
||||||
|
bottomSpacer: Math.max(0, offsets[items.length] ?? 0 - (offsets[rangeStart.end] ?? 0)),
|
||||||
|
end: rangeStart.end,
|
||||||
|
measureRef,
|
||||||
|
offsets,
|
||||||
|
start: rangeStart.start,
|
||||||
|
topSpacer: offsets[rangeStart.start] ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to throttle scroll events 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<number[]>([]);
|
||||||
|
const isScrollingRef = useRef(false);
|
||||||
|
const scrollStartTimeRef = useRef(0);
|
||||||
|
const scrollThrottleTimerRef = useRef<NodeJS.Timeout | null>(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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user