CoolFace
Apppublic

HyperCluster/Fara-BrowserUse

sourceHugging Facemitupdated 10mo agoView on Hugging Face
5likes
StepsList.tsx396 linesDownload Raw Back to steps
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