Spaces:
Running
Running
| import React, { useRef, useEffect } from 'react'; | |
| import { AgentTrace } from '@/types/agent'; | |
| import { Box, Typography, Stack, Paper } from '@mui/material'; | |
| import { StepCard } from './StepCard'; | |
| import { FinalStepCard } from './FinalStepCard'; | |
| import { ThinkingStepCard } from './ThinkingStepCard'; | |
| import { ConnectionStepCard } from './ConnectionStepCard'; | |
| import ListAltIcon from '@mui/icons-material/ListAlt'; | |
| import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; | |
| import { useAgentStore, selectSelectedStepIndex, selectFinalStep, selectIsConnectingToE2B, selectIsAgentProcessing } from '@/stores/agentStore'; | |
| interface StepsListProps { | |
| trace?: AgentTrace; | |
| } | |
| export const StepsList: React.FC<StepsListProps> = ({ trace }) => { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const selectedStepIndex = useAgentStore(selectSelectedStepIndex); | |
| const setSelectedStepIndex = useAgentStore((state) => state.setSelectedStepIndex); | |
| const finalStep = useAgentStore(selectFinalStep); | |
| const isConnectingToE2B = useAgentStore(selectIsConnectingToE2B); | |
| const isAgentProcessing = useAgentStore(selectIsAgentProcessing); | |
| const isScrollingProgrammatically = useRef(false); | |
| const [showThinkingCard, setShowThinkingCard] = React.useState(false); | |
| const thinkingTimeoutRef = useRef<NodeJS.Timeout | null>(null); | |
| const streamStartTimeRef = useRef<number | null>(null); | |
| const [showConnectionCard, setShowConnectionCard] = React.useState(false); | |
| const hasConnectedRef = useRef(false); | |
| // Check if final step is active (when selectedStepIndex is null and finalStep exists and trace is not running) | |
| const isFinalStepActive = selectedStepIndex === null && finalStep && !trace?.isRunning; | |
| // Determine the active step index | |
| // If a specific step is selected, use that | |
| // If the final step is active, no normal step should be active | |
| // Otherwise, show the last step as active | |
| const activeStepIndex = selectedStepIndex !== null | |
| ? selectedStepIndex | |
| : isFinalStepActive | |
| ? null // When final step is active, no normal step is active | |
| : (trace?.steps && trace.steps.length > 0 && trace?.isRunning) | |
| ? trace.steps.length - 1 | |
| : (trace?.steps && trace.steps.length > 0) | |
| ? trace.steps.length - 1 | |
| : null; | |
| // Manage ConnectionStepCard display: | |
| // - Shows when isConnectingToE2B = true OR when we had a connection | |
| // - Remains visible even when task is finished (if we have steps or finalStep) | |
| useEffect(() => { | |
| if (isConnectingToE2B || isAgentProcessing || (trace?.steps && trace.steps.length > 0) || finalStep) { | |
| setShowConnectionCard(true); | |
| hasConnectedRef.current = true; | |
| } | |
| }, [isConnectingToE2B, isAgentProcessing, trace?.steps, finalStep]); | |
| // Manage ThinkingCard display: | |
| // - Appears 5 seconds AFTER stream starts (isAgentProcessing = true, NOT during isConnectingToE2B) | |
| // - Remains visible during the entire agent processing | |
| // - Hides only when agent stops OR a finalStep exists | |
| useEffect(() => { | |
| // If stream really starts (isAgentProcessing = true and NOT connecting) | |
| // And no startTime recorded yet | |
| if (isAgentProcessing && !isConnectingToE2B && !streamStartTimeRef.current) { | |
| streamStartTimeRef.current = Date.now(); | |
| } | |
| // If agent stops OR we have a finalStep, reset and hide | |
| if (!isAgentProcessing || finalStep) { | |
| streamStartTimeRef.current = null; | |
| setShowThinkingCard(false); | |
| if (thinkingTimeoutRef.current) { | |
| clearTimeout(thinkingTimeoutRef.current); | |
| thinkingTimeoutRef.current = null; | |
| } | |
| return; | |
| } | |
| // If agent is running, not connecting, no finalStep: start 5 second timer | |
| if (isAgentProcessing && !isConnectingToE2B && !finalStep && streamStartTimeRef.current) { | |
| // Clean up any existing timeout | |
| if (thinkingTimeoutRef.current) { | |
| clearTimeout(thinkingTimeoutRef.current); | |
| } | |
| // Calculate elapsed time since stream started | |
| const elapsedTime = Date.now() - streamStartTimeRef.current; | |
| const remainingTime = Math.max(0, 5000 - elapsedTime); | |
| thinkingTimeoutRef.current = setTimeout(() => { | |
| setShowThinkingCard(true); | |
| }, remainingTime); | |
| } | |
| // Cleanup on unmount or when dependencies change | |
| return () => { | |
| if (thinkingTimeoutRef.current) { | |
| clearTimeout(thinkingTimeoutRef.current); | |
| thinkingTimeoutRef.current = null; | |
| } | |
| }; | |
| }, [isAgentProcessing, isConnectingToE2B, finalStep]); | |
| // Auto-scroll to active step when it changes (timeline → steps) | |
| useEffect(() => { | |
| if (containerRef.current) { | |
| isScrollingProgrammatically.current = true; | |
| // Use setTimeout to ensure DOM has updated | |
| setTimeout(() => { | |
| if (containerRef.current) { | |
| // Scroll to final step if it's active | |
| if (isFinalStepActive) { | |
| const finalStepElement = containerRef.current.querySelector(`[data-step-index="final"]`); | |
| if (finalStepElement) { | |
| finalStepElement.scrollIntoView({ | |
| behavior: 'smooth', | |
| block: 'center' | |
| }); | |
| setTimeout(() => { | |
| isScrollingProgrammatically.current = false; | |
| }, 500); | |
| } | |
| } | |
| // Otherwise scroll to active step | |
| else if (activeStepIndex !== null && trace?.steps) { | |
| const activeStepElement = containerRef.current.querySelector(`[data-step-index="${activeStepIndex}"]`); | |
| if (activeStepElement) { | |
| activeStepElement.scrollIntoView({ | |
| behavior: 'smooth', | |
| block: 'center' | |
| }); | |
| // Reset flag after scroll animation | |
| setTimeout(() => { | |
| isScrollingProgrammatically.current = false; | |
| }, 500); | |
| } | |
| } | |
| } | |
| }, 100); | |
| } | |
| }, [activeStepIndex, trace?.steps?.length, isFinalStepActive]); | |
| // Detect which step is visible when scrolling (steps → timeline) | |
| useEffect(() => { | |
| const container = containerRef.current; | |
| if (!container || !trace?.steps || trace.steps.length === 0) return; | |
| const handleScroll = () => { | |
| // Don't update if we're scrolling programmatically | |
| if (isScrollingProgrammatically.current) return; | |
| const containerRect = container.getBoundingClientRect(); | |
| const containerTop = containerRect.top; | |
| const containerBottom = containerRect.bottom; | |
| const containerCenter = containerRect.top + containerRect.height / 2; | |
| // Check scroll position | |
| const isAtTop = container.scrollTop <= 5; // 5px tolerance | |
| const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 5; // 5px tolerance | |
| let targetStepIndex: number | null = -1; | |
| let targetDistance = Infinity; | |
| let isFinalStepTarget = false; | |
| if (isAtTop) { | |
| // At the top: find the highest visible step | |
| let highestVisibleBottom = Infinity; | |
| trace.steps.forEach((_, index) => { | |
| const stepElement = container.querySelector(`[data-step-index="${index}"]`); | |
| if (stepElement) { | |
| const stepRect = stepElement.getBoundingClientRect(); | |
| const stepTop = stepRect.top; | |
| const stepBottom = stepRect.bottom; | |
| const isVisible = stepTop < containerBottom && stepBottom > containerTop; | |
| if (isVisible && stepTop < highestVisibleBottom) { | |
| highestVisibleBottom = stepTop; | |
| targetStepIndex = index; | |
| isFinalStepTarget = false; | |
| } | |
| } | |
| }); | |
| } else if (isAtBottom) { | |
| // At the bottom: find the lowest visible step | |
| let lowestVisibleTop = -Infinity; | |
| trace.steps.forEach((_, index) => { | |
| const stepElement = container.querySelector(`[data-step-index="${index}"]`); | |
| if (stepElement) { | |
| const stepRect = stepElement.getBoundingClientRect(); | |
| const stepTop = stepRect.top; | |
| const stepBottom = stepRect.bottom; | |
| const isVisible = stepTop < containerBottom && stepBottom > containerTop; | |
| if (isVisible && stepTop > lowestVisibleTop) { | |
| lowestVisibleTop = stepTop; | |
| targetStepIndex = index; | |
| isFinalStepTarget = false; | |
| } | |
| } | |
| }); | |
| // Check if final step is the lowest visible | |
| if (finalStep) { | |
| const finalStepElement = container.querySelector(`[data-step-index="final"]`); | |
| if (finalStepElement) { | |
| const finalStepRect = finalStepElement.getBoundingClientRect(); | |
| const finalStepTop = finalStepRect.top; | |
| const finalStepBottom = finalStepRect.bottom; | |
| const isVisible = finalStepTop < containerBottom && finalStepBottom > containerTop; | |
| if (isVisible && finalStepTop > lowestVisibleTop) { | |
| targetStepIndex = null; | |
| isFinalStepTarget = true; | |
| } | |
| } | |
| } | |
| } else { | |
| // Not at bottom: find the step closest to center | |
| trace.steps.forEach((_, index) => { | |
| const stepElement = container.querySelector(`[data-step-index="${index}"]`); | |
| if (stepElement) { | |
| const stepRect = stepElement.getBoundingClientRect(); | |
| const stepCenter = stepRect.top + stepRect.height / 2; | |
| const distance = Math.abs(containerCenter - stepCenter); | |
| if (distance < targetDistance) { | |
| targetDistance = distance; | |
| targetStepIndex = index; | |
| isFinalStepTarget = false; | |
| } | |
| } | |
| }); | |
| // Check if final step is closest to center | |
| if (finalStep) { | |
| const finalStepElement = container.querySelector(`[data-step-index="final"]`); | |
| if (finalStepElement) { | |
| const finalStepRect = finalStepElement.getBoundingClientRect(); | |
| const finalStepCenter = finalStepRect.top + finalStepRect.height / 2; | |
| const distance = Math.abs(containerCenter - finalStepCenter); | |
| if (distance < targetDistance) { | |
| targetStepIndex = null; | |
| isFinalStepTarget = true; | |
| } | |
| } | |
| } | |
| } | |
| // Update the selected step if changed | |
| if (isFinalStepTarget && selectedStepIndex !== null) { | |
| setSelectedStepIndex(null); | |
| } else if (!isFinalStepTarget && targetStepIndex !== -1 && targetStepIndex !== selectedStepIndex) { | |
| setSelectedStepIndex(targetStepIndex); | |
| } | |
| }; | |
| // Throttle scroll events | |
| let scrollTimeout: NodeJS.Timeout; | |
| const throttledScroll = () => { | |
| clearTimeout(scrollTimeout); | |
| scrollTimeout = setTimeout(handleScroll, 150); | |
| }; | |
| container.addEventListener('scroll', throttledScroll); | |
| return () => { | |
| container.removeEventListener('scroll', throttledScroll); | |
| clearTimeout(scrollTimeout); | |
| }; | |
| }, [trace?.steps, selectedStepIndex, setSelectedStepIndex, finalStep]); | |
| return ( | |
| <Paper | |
| elevation={0} | |
| sx={{ | |
| width: { xs: '100%', md: 320 }, | |
| flexShrink: 0, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| ml: { xs: 0, md: 1.5 }, | |
| mt: { xs: 3, md: 0 }, | |
| overflow: 'hidden', | |
| }} | |
| > | |
| <Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> | |
| <Typography variant="h6" sx={{ fontSize: '0.9rem', fontWeight: 700, color: 'text.primary' }}> | |
| Steps | |
| </Typography> | |
| {trace?.traceMetadata && trace.traceMetadata.numberOfSteps > 0 && ( | |
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 0 }}> | |
| <Typography | |
| variant="caption" | |
| sx={{ | |
| fontSize: '0.75rem', | |
| fontWeight: 700, | |
| color: 'text.primary', | |
| }} | |
| > | |
| {trace.traceMetadata.numberOfSteps} | |
| </Typography> | |
| <Typography | |
| variant="caption" | |
| sx={{ | |
| fontSize: '0.75rem', | |
| fontWeight: 700, | |
| color: 'text.disabled', | |
| }} | |
| > | |
| /{trace.traceMetadata.maxSteps} | |
| </Typography> | |
| </Box> | |
| )} | |
| </Box> | |
| <Box | |
| ref={containerRef} | |
| sx={{ | |
| flex: 1, | |
| overflowY: 'auto', | |
| minHeight: 0, | |
| p: 2, | |
| }} | |
| > | |
| {(trace?.steps && trace.steps.length > 0) || finalStep || showThinkingCard || showConnectionCard ? ( | |
| <Stack spacing={2.5}> | |
| {/* Show connection step card (first item) */} | |
| {showConnectionCard && ( | |
| <Box data-step-index="connection"> | |
| <ConnectionStepCard isConnecting={isConnectingToE2B} /> | |
| </Box> | |
| )} | |
| {/* Show all steps */} | |
| {trace?.steps && trace.steps.map((step, index) => ( | |
| <Box key={step.stepId} data-step-index={index}> | |
| <StepCard | |
| step={step} | |
| index={index} | |
| isLatest={index === trace.steps!.length - 1} | |
| isActive={index === activeStepIndex} | |
| /> | |
| </Box> | |
| ))} | |
| {/* Show thinking indicator after steps (appears 5 seconds after stream start) */} | |
| {showThinkingCard && ( | |
| <Box data-step-index="thinking"> | |
| <ThinkingStepCard /> | |
| </Box> | |
| )} | |
| {/* Show final step card if exists */} | |
| {finalStep && ( | |
| <Box data-step-index="final"> | |
| <FinalStepCard | |
| finalStep={finalStep} | |
| isActive={isFinalStepActive} | |
| /> | |
| </Box> | |
| )} | |
| </Stack> | |
| ) : ( | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| height: '100%', | |
| color: 'text.secondary', | |
| p: 3, | |
| textAlign: 'center', | |
| }} | |
| > | |
| <ListAltIcon sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} /> | |
| <Typography variant="body1" sx={{ fontWeight: 600, mb: 0.5 }}> | |
| No steps yet | |
| </Typography> | |
| <Typography variant="caption" sx={{ fontSize: '0.75rem' }}> | |
| Steps will appear as the agent progresses | |
| </Typography> | |
| </Box> | |
| )} | |
| </Box> | |
| </Paper> | |
| ); | |
| }; | |