HyperCluster/Fara-BrowserUse
5
1import React, { useRef, useEffect } from 'react';
2import { AgentTrace } from '@/types/agent';
3import { Box, Typography, Stack, Paper } from '@mui/material';
4import { StepCard } from './StepCard';
5import { FinalStepCard } from './FinalStepCard';
6import { ThinkingStepCard } from './ThinkingStepCard';
7import { ConnectionStepCard } from './ConnectionStepCard';
8import ListAltIcon from '@mui/icons-material/ListAlt';
9import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
10import { useAgentStore, selectSelectedStepIndex, selectFinalStep, selectIsConnectingToE2B, selectIsAgentProcessing } from '@/stores/agentStore';
11
12interface StepsListProps {
13 trace?: AgentTrace;
14}
15
16export const StepsList: React.FC<StepsListProps> = ({ trace }) => {
17 const containerRef = useRef<HTMLDivElement>(null);
18 const selectedStepIndex = useAgentStore(selectSelectedStepIndex);
19 const setSelectedStepIndex = useAgentStore((state) => state.setSelectedStepIndex);
20 const finalStep = useAgentStore(selectFinalStep);
21 const isConnectingToE2B = useAgentStore(selectIsConnectingToE2B);
22 const isAgentProcessing = useAgentStore(selectIsAgentProcessing);
23 const isScrollingProgrammatically = useRef(false);
24 const [showThinkingCard, setShowThinkingCard] = React.useState(false);
25 const thinkingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
26 const streamStartTimeRef = useRef<number | null>(null);
27 const [showConnectionCard, setShowConnectionCard] = React.useState(false);
28 const hasConnectedRef = useRef(false);
29
30 // Check if final step is active (when selectedStepIndex is null and finalStep exists and trace is not running)
31 const isFinalStepActive = selectedStepIndex === null && finalStep && !trace?.isRunning;
32
33 // Check if thinking card is active (when in live mode and thinking card is shown)
34 const isThinkingCardActive = selectedStepIndex === null && showThinkingCard;
35
36 // Determine the active step index
37 // If a specific step is selected, use that
38 // If the final step is active, no normal step should be active
39 // If the thinking card is active, no normal step should be active
40 // Otherwise, show the last step as active
41 const activeStepIndex = selectedStepIndex !== null
42 ? selectedStepIndex
43 : isFinalStepActive
44 ? null // When final step is active, no normal step is active
45 : isThinkingCardActive
46 ? null // When thinking card is active, no normal step is active
47 : (trace?.steps && trace.steps.length > 0 && trace?.isRunning)
48 ? trace.steps.length - 1
49 : (trace?.steps && trace.steps.length > 0)
50 ? trace.steps.length - 1
51 : null;
52
53 // Manage ConnectionStepCard display:
54 // - Shows when isConnectingToE2B = true OR when we had a connection
55 // - Remains visible even when task is finished (if we have steps or finalStep)
56 useEffect(() => {
57 if (isConnectingToE2B || isAgentProcessing || (trace?.steps && trace.steps.length > 0) || finalStep) {
58 setShowConnectionCard(true);
59 hasConnectedRef.current = true;
60 }
61 }, [isConnectingToE2B, isAgentProcessing, trace?.steps, finalStep]);
62
63 // Manage ThinkingCard display:
64 // - Appears 5 seconds AFTER stream starts (isAgentProcessing = true, NOT during isConnectingToE2B)
65 // - Remains visible during the entire agent processing
66 // - Hides only when agent stops OR a finalStep exists
67 useEffect(() => {
68 // If stream really starts (isAgentProcessing = true and NOT connecting)
69 // And no startTime recorded yet
70 if (isAgentProcessing && !isConnectingToE2B && !streamStartTimeRef.current) {
71 streamStartTimeRef.current = Date.now();
72 }
73
74 // If agent stops OR we have a finalStep, reset and hide
75 if (!isAgentProcessing || finalStep) {
76 streamStartTimeRef.current = null;
77 setShowThinkingCard(false);
78 if (thinkingTimeoutRef.current) {
79 clearTimeout(thinkingTimeoutRef.current);
80 thinkingTimeoutRef.current = null;
81 }
82 return;
83 }
84
85 // If agent is running, not connecting, no finalStep: start 5 second timer
86 if (isAgentProcessing && !isConnectingToE2B && !finalStep && streamStartTimeRef.current) {
87 // Clean up any existing timeout
88 if (thinkingTimeoutRef.current) {
89 clearTimeout(thinkingTimeoutRef.current);
90 }
91
92 // Calculate elapsed time since stream started
93 const elapsedTime = Date.now() - streamStartTimeRef.current;
94 const remainingTime = Math.max(0, 5000 - elapsedTime);
95
96 thinkingTimeoutRef.current = setTimeout(() => {
97 setShowThinkingCard(true);
98 }, remainingTime);
99 }
100
101 // Cleanup on unmount or when dependencies change
102 return () => {
103 if (thinkingTimeoutRef.current) {
104 clearTimeout(thinkingTimeoutRef.current);
105 thinkingTimeoutRef.current = null;
106 }
107 };
108 }, [isAgentProcessing, isConnectingToE2B, finalStep]);
109
110 // Auto-scroll logic
111 useEffect(() => {
112 const container = containerRef.current;
113 if (!container) return;
114
115 isScrollingProgrammatically.current = true;
116
117 // Use setTimeout to ensure DOM has updated
118 setTimeout(() => {
119 if (!container) return;
120
121 // LIVE MODE: Always scroll to the bottom (last visible element)
122 if (selectedStepIndex === null) {
123 // Scroll to bottom
124 container.scrollTo({
125 top: container.scrollHeight,
126 behavior: 'smooth',
127 });
128 }
129 // NON-LIVE MODE: Scroll to selected step
130 else {
131 const selectedElement = container.querySelector(`[data-step-index="${selectedStepIndex}"]`);
132 if (selectedElement) {
133 selectedElement.scrollIntoView({
134 behavior: 'smooth',
135 block: 'center',
136 });
137 }
138 }
139
140 // Reset flag after scroll animation
141 setTimeout(() => {
142 isScrollingProgrammatically.current = false;
143 }, 500);
144 }, 100);
145 }, [selectedStepIndex, trace?.steps?.length, showThinkingCard, finalStep]);
146
147 // Detect which step is visible when scrolling (steps โ timeline)
148 useEffect(() => {
149 const container = containerRef.current;
150 if (!container || !trace?.steps || trace.steps.length === 0) return;
151
152 const handleScroll = () => {
153 // Don't update if we're scrolling programmatically
154 if (isScrollingProgrammatically.current) return;
155
156 // Don't update if agent is running (stay in live mode)
157 if (trace?.isRunning) return;
158
159 const containerRect = container.getBoundingClientRect();
160 const containerTop = containerRect.top;
161 const containerBottom = containerRect.bottom;
162 const containerCenter = containerRect.top + containerRect.height / 2;
163
164 // Check scroll position
165 const isAtTop = container.scrollTop <= 5; // 5px tolerance
166 const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 5; // 5px tolerance
167
168 let targetStepIndex: number | null = -1;
169 let targetDistance = Infinity;
170 let isFinalStepTarget = false;
171
172 if (isAtTop) {
173 // At the top: find the highest visible step
174 let highestVisibleBottom = Infinity;
175
176 trace.steps.forEach((_, index) => {
177 const stepElement = container.querySelector(`[data-step-index="${index}"]`);
178 if (stepElement) {
179 const stepRect = stepElement.getBoundingClientRect();
180 const stepTop = stepRect.top;
181 const stepBottom = stepRect.bottom;
182 const isVisible = stepTop < containerBottom && stepBottom > containerTop;
183
184 if (isVisible && stepTop < highestVisibleBottom) {
185 highestVisibleBottom = stepTop;
186 targetStepIndex = index;
187 isFinalStepTarget = false;
188 }
189 }
190 });
191 } else if (isAtBottom) {
192 // At the bottom: find the lowest visible step
193 let lowestVisibleTop = -Infinity;
194
195 trace.steps.forEach((_, index) => {
196 const stepElement = container.querySelector(`[data-step-index="${index}"]`);
197 if (stepElement) {
198 const stepRect = stepElement.getBoundingClientRect();
199 const stepTop = stepRect.top;
200 const stepBottom = stepRect.bottom;
201 const isVisible = stepTop < containerBottom && stepBottom > containerTop;
202
203 if (isVisible && stepTop > lowestVisibleTop) {
204 lowestVisibleTop = stepTop;
205 targetStepIndex = index;
206 isFinalStepTarget = false;
207 }
208 }
209 });
210
211 // Check if final step is the lowest visible
212 if (finalStep) {
213 const finalStepElement = container.querySelector(`[data-step-index="final"]`);
214 if (finalStepElement) {
215 const finalStepRect = finalStepElement.getBoundingClientRect();
216 const finalStepTop = finalStepRect.top;
217 const finalStepBottom = finalStepRect.bottom;
218 const isVisible = finalStepTop < containerBottom && finalStepBottom > containerTop;
219
220 if (isVisible && finalStepTop > lowestVisibleTop) {
221 targetStepIndex = null;
222 isFinalStepTarget = true;
223 }
224 }
225 }
226 } else {
227 // Not at bottom: find the step closest to center
228 trace.steps.forEach((_, index) => {
229 const stepElement = container.querySelector(`[data-step-index="${index}"]`);
230 if (stepElement) {
231 const stepRect = stepElement.getBoundingClientRect();
232 const stepCenter = stepRect.top + stepRect.height / 2;
233 const distance = Math.abs(containerCenter - stepCenter);
234
235 if (distance < targetDistance) {
236 targetDistance = distance;
237 targetStepIndex = index;
238 isFinalStepTarget = false;
239 }
240 }
241 });
242
243 // Check if final step is closest to center
244 if (finalStep) {
245 const finalStepElement = container.querySelector(`[data-step-index="final"]`);
246 if (finalStepElement) {
247 const finalStepRect = finalStepElement.getBoundingClientRect();
248 const finalStepCenter = finalStepRect.top + finalStepRect.height / 2;
249 const distance = Math.abs(containerCenter - finalStepCenter);
250
251 if (distance < targetDistance) {
252 targetStepIndex = null;
253 isFinalStepTarget = true;
254 }
255 }
256 }
257 }
258
259 // Update the selected step if changed
260 if (isFinalStepTarget && selectedStepIndex !== null) {
261 setSelectedStepIndex(null);
262 } else if (!isFinalStepTarget && targetStepIndex !== -1 && targetStepIndex !== selectedStepIndex) {
263 setSelectedStepIndex(targetStepIndex);
264 }
265 };
266
267 // Throttle scroll events
268 let scrollTimeout: NodeJS.Timeout;
269 const throttledScroll = () => {
270 clearTimeout(scrollTimeout);
271 scrollTimeout = setTimeout(handleScroll, 150);
272 };
273
274 container.addEventListener('scroll', throttledScroll);
275 return () => {
276 container.removeEventListener('scroll', throttledScroll);
277 clearTimeout(scrollTimeout);
278 };
279 }, [trace?.steps, selectedStepIndex, setSelectedStepIndex, finalStep]);
280
281 return (
282 <Paper
283 elevation={0}
284 sx={{
285 width: { xs: '100%', md: 320 },
286 flexShrink: 0,
287 display: 'flex',
288 flexDirection: 'column',
289 ml: { xs: 0, md: 1.5 },
290 mt: { xs: 3, md: 0 },
291 overflow: 'hidden',
292 }}
293 >
294 <Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
295 <Typography variant="h6" sx={{ fontSize: '0.9rem', fontWeight: 700, color: 'text.primary' }}>
296 Steps
297 </Typography>
298 {trace?.traceMetadata && trace.traceMetadata.numberOfSteps > 0 && (
299 <Box sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
300 <Typography
301 variant="caption"
302 sx={{
303 fontSize: '0.75rem',
304 fontWeight: 700,
305 color: 'text.primary',
306 }}
307 >
308 {trace.traceMetadata.numberOfSteps}
309 </Typography>
310 <Typography
311 variant="caption"
312 sx={{
313 fontSize: '0.75rem',
314 fontWeight: 700,
315 color: 'text.disabled',
316 }}
317 >
318 /{trace.traceMetadata.maxSteps}
319 </Typography>
320 </Box>
321 )}
322 </Box>
323 <Box
324 ref={containerRef}
325 sx={{
326 flex: 1,
327 overflowY: 'auto',
328 minHeight: 0,
329 p: 2,
330 }}
331 >
332 {(trace?.steps && trace.steps.length > 0) || finalStep || showThinkingCard || showConnectionCard ? (
333 <Stack spacing={2.5}>
334 {/* Show connection step card (first item) */}
335 {showConnectionCard && (
336 <Box data-step-index="connection">
337 <ConnectionStepCard isConnecting={isConnectingToE2B} />
338 </Box>
339 )}
340
341 {/* Show all steps */}
342 {trace?.steps && trace.steps.map((step, index) => (
343 <Box key={step.stepId} data-step-index={index}>
344 <StepCard
345 step={step}
346 index={index}
347 isLatest={index === trace.steps!.length - 1}
348 isActive={index === activeStepIndex}
349 />
350 </Box>
351 ))}
352
353 {/* Show thinking indicator after steps (appears 5 seconds after stream start) */}
354 {showThinkingCard && (
355 <Box data-step-index="thinking">
356 <ThinkingStepCard isActive={isThinkingCardActive} />
357 </Box>
358 )}
359
360 {/* Show final step card if exists */}
361 {finalStep && (
362 <Box data-step-index="final">
363 <FinalStepCard
364 finalStep={finalStep}
365 isActive={isFinalStepActive}
366 />
367 </Box>
368 )}
369 </Stack>
370 ) : (
371 <Box
372 sx={{
373 display: 'flex',
374 flexDirection: 'column',
375 alignItems: 'center',
376 justifyContent: 'center',
377 height: '100%',
378 color: 'text.secondary',
379 p: 3,
380 textAlign: 'center',
381 }}
382 >
383 <ListAltIcon sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
384 <Typography variant="body1" sx={{ fontWeight: 600, mb: 0.5 }}>
385 No steps yet
386 </Typography>
387 <Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
388 Steps will appear as the agent progresses
389 </Typography>
390 </Box>
391 )}
392 </Box>
393 </Paper>
394 );
395};
396 