Skip to main content
Glama
SpotlightStepCard.tsx20.8 kB
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/src/components/ui/alert-dialog'; import { Button } from '@/src/components/ui/button'; import { Card } from '@/src/components/ui/card'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/src/components/ui/dropdown-menu'; import { Tabs, TabsList, TabsTrigger } from '@/src/components/ui/tabs'; import { Integration, assertValidArrowFunction, executeWithVMHelpers } from '@superglue/shared'; import { Bug, ChevronDown, FileBraces, FileInput, FileOutput, Play, RotateCw, Route, Square, Trash2, Wand2 } from 'lucide-react'; import React, { useEffect, useRef, useState } from 'react'; import { type CategorizedSources } from '../templates/tiptap/TemplateContext'; import { StepConfigTab } from './tabs/StepConfigTab'; import { StepInputTab } from './tabs/StepInputTab'; import { StepResultTab } from './tabs/StepResultTab'; const dataSelectorOutputCache = new Map<string, { output: any; error: string | null }>(); let lastSeenDataSelectorVersion: number | undefined = undefined; export const SpotlightStepCard = React.memo(({ step, stepIndex, evolvingPayload, categorizedSources, stepResult, onEdit, onRemove, onExecuteStep, onExecuteStepWithLimit, onOpenFixStepDialog, onAbort, canExecute, isExecuting, isGlobalExecuting, currentExecutingStepIndex, integrations, readOnly, failedSteps = [], abortedSteps = [], showOutputSignal, onConfigEditingChange, onDataSelectorChange, isFirstStep = false, isPayloadValid = true, sourceDataVersion, }: { step: any; stepIndex: number; evolvingPayload: any; categorizedSources?: CategorizedSources; stepResult?: any; onEdit?: (stepId: string, updatedStep: any, isUserInitiated?: boolean) => void; onRemove?: (stepId: string) => void; onExecuteStep?: () => Promise<void>; onExecuteStepWithLimit?: (limit: number) => Promise<void>; onOpenFixStepDialog?: () => void; onAbort?: () => void; canExecute?: boolean; isExecuting?: boolean; isGlobalExecuting?: boolean; currentExecutingStepIndex?: number; integrations?: Integration[]; readOnly?: boolean; failedSteps?: string[]; abortedSteps?: string[]; stepResultsMap?: Record<string, any>; showOutputSignal?: number; onConfigEditingChange?: (editing: boolean) => void; onDataSelectorChange?: (itemCount: number | null, isInitial: boolean) => void; isFirstStep?: boolean; isPayloadValid?: boolean; sourceDataVersion?: number; }) => { const [activePanel, setActivePanel] = useState<'input' | 'config' | 'output'>('config'); const [showInvalidPayloadDialog, setShowInvalidPayloadDialog] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [pendingAction, setPendingAction] = useState<'execute' | null>(null); const DATA_SELECTOR_DEBOUNCE_MS = 400; if (sourceDataVersion !== lastSeenDataSelectorVersion) { dataSelectorOutputCache.clear(); lastSeenDataSelectorVersion = sourceDataVersion; } const dataSelectorCacheKey = `${step.id}:${sourceDataVersion}:${step.loopSelector}`; const cachedOutput = dataSelectorOutputCache.get(dataSelectorCacheKey); const [dataSelectorOutput, setDataSelectorOutput] = useState<any | null>(() => cachedOutput?.output ?? null); const [dataSelectorError, setDataSelectorError] = useState<string | null>(() => cachedOutput?.error ?? null); const lastEvalTimerRef = useRef<number | null>(null); const prevShowOutputSignalRef = useRef<number | undefined>(undefined); const lastNotifiedStepIdRef = useRef<string | null>(null); useEffect(() => { const currentCacheKey = `${step.id}:${sourceDataVersion}:${step.loopSelector}`; const cached = dataSelectorOutputCache.get(currentCacheKey); if (cached) { setDataSelectorOutput(cached.output); setDataSelectorError(cached.error); } else { setDataSelectorOutput(null); setDataSelectorError(null); } }, [step.id]); useEffect(() => { if (showOutputSignal && showOutputSignal !== prevShowOutputSignalRef.current && stepResult != null) { setActivePanel('output'); } prevShowOutputSignalRef.current = showOutputSignal; }, [showOutputSignal, stepResult]); useEffect(() => { if (lastEvalTimerRef.current) { window.clearTimeout(lastEvalTimerRef.current); lastEvalTimerRef.current = null; } setDataSelectorError(null); const currentCacheKey = `${step.id}:${sourceDataVersion}:${step.loopSelector}`; const t = window.setTimeout(() => { try { const sel = step?.loopSelector; assertValidArrowFunction(sel); const out = executeWithVMHelpers(sel, evolvingPayload || {}); if (typeof out === 'function') { throw new Error('Data selector returned a function. Did you forget to call it?'); } const normalizedOut = out === undefined ? null : out; dataSelectorOutputCache.set(currentCacheKey, { output: normalizedOut, error: null }); setDataSelectorOutput(normalizedOut); setDataSelectorError(null); } catch (err: any) { setDataSelectorOutput(null); let errorMessage = 'Error evaluating data selector'; if (err) { if (err instanceof Error) { errorMessage = err.message || errorMessage; } else if (typeof err === 'string') { errorMessage = err; } else if (err?.message && typeof err.message === 'string') { errorMessage = err.message; } else { errorMessage = String(err); } } dataSelectorOutputCache.set(currentCacheKey, { output: null, error: errorMessage }); setDataSelectorError(errorMessage); } }, DATA_SELECTOR_DEBOUNCE_MS); lastEvalTimerRef.current = t as unknown as number; return () => { if (lastEvalTimerRef.current) { window.clearTimeout(lastEvalTimerRef.current); lastEvalTimerRef.current = null; } }; }, [step.id, step.executionMode, step.loopSelector, evolvingPayload]); useEffect(() => { const hasValidOutput = !dataSelectorError && dataSelectorOutput != null; const isInitialForThisStep = lastNotifiedStepIdRef.current !== step.id; const itemCount = (hasValidOutput && Array.isArray(dataSelectorOutput)) ? dataSelectorOutput.length : null; onDataSelectorChange?.(itemCount, isInitialForThisStep); if (isInitialForThisStep) { lastNotifiedStepIdRef.current = step.id; } }, [dataSelectorOutput, dataSelectorError, onDataSelectorChange, step.id]); const handleRunStepClick = () => { if (isFirstStep && !isPayloadValid) { setPendingAction('execute'); setShowInvalidPayloadDialog(true); } else if (onExecuteStep) { onExecuteStep(); } }; return ( <Card className="w-full max-w-6xl mx-auto shadow-md border dark:border-border/50 overflow-hidden"> <div className="p-3"> <div className="flex items-center justify-between mb-3"> <div className="flex items-center gap-2 min-w-0 flex-1"> <Route className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <h3 className="text-lg font-semibold truncate"> {step.id || `Step ${stepIndex + 1}`} </h3> </div> <div className="flex items-center gap-1.5"> {!readOnly && onExecuteStep && ( <div className="flex items-center"> {isExecuting && onAbort ? ( <Button variant="outline" onClick={onAbort} className="h-8 px-3 gap-2" > <Square className="h-3 w-3" /> <span className="font-medium text-[13px]">Stop</span> </Button> ) : ( <span title={!canExecute ? "Execute previous steps first" : isExecuting ? "Step is executing..." : "Run this step"}> <div className={`relative flex rounded-md border border-input bg-background ${dataSelectorOutput && Array.isArray(dataSelectorOutput) && dataSelectorOutput.length > 1 && onExecuteStepWithLimit ? '' : ''}`}> <Button variant="ghost" onClick={handleRunStepClick} disabled={!canExecute || isExecuting || isGlobalExecuting} className={`h-8 pl-3 gap-2 border-0 ${dataSelectorOutput && Array.isArray(dataSelectorOutput) && dataSelectorOutput.length > 1 && onExecuteStepWithLimit ? 'pr-2 rounded-r-none' : 'pr-3'}`} > {dataSelectorOutput && Array.isArray(dataSelectorOutput) && dataSelectorOutput.length > 1 ? ( <RotateCw className="h-3.5 w-3.5" /> ) : ( <Play className="h-3 w-3" /> )} <span className="font-medium text-[13px]">Run Step</span> </Button> {dataSelectorOutput && Array.isArray(dataSelectorOutput) && dataSelectorOutput.length > 1 && onExecuteStepWithLimit && ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" disabled={!canExecute || isExecuting || isGlobalExecuting} className="h-8 px-1.5 rounded-l-none border-0" > <ChevronDown className="h-3 w-3" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => onExecuteStepWithLimit(1)}> <Bug className="h-3.5 w-3.5 mr-2" /> Run single iteration </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> )} {dataSelectorOutput && Array.isArray(dataSelectorOutput) && dataSelectorOutput.length > 1 && ( <span className="absolute -top-2 -left-2 min-w-[16px] h-[16px] px-1 text-[10px] font-bold bg-primary text-primary-foreground rounded flex items-center justify-center"> {dataSelectorOutput.length >= 1000 ? `${Math.floor(dataSelectorOutput.length / 1000)}k` : dataSelectorOutput.length} </span> )} </div> </span> )} </div> )} {!readOnly && onOpenFixStepDialog && ( <span title={!canExecute ? "Execute previous steps first" : isExecuting ? "Step is executing..." : "Fix this step with AI"}> <div className={`relative flex rounded-md border border-input bg-background ${failedSteps.includes(step.id) ? 'border-destructive/50' : ''}`}> <Button variant="ghost" onClick={onOpenFixStepDialog} disabled={!canExecute || isExecuting || isGlobalExecuting} className={`h-8 px-3 gap-2 border-0 ${failedSteps.includes(step.id) ? 'bg-destructive/10 text-destructive hover:bg-destructive/20 hover:text-destructive animate-pulse' : ''}`} > <Wand2 className="h-3 w-3" /> <span className="font-medium text-[13px]">Fix Step</span> </Button> </div> </span> )} {!readOnly && onRemove && ( <Button variant="ghost" size="icon" onClick={() => setShowDeleteConfirm(true)} className="h-8 w-8" > <Trash2 className="h-4 w-4" /> </Button> )} </div> </div> <div className={activePanel === 'config' ? 'space-y-1' : 'space-y-2'}> <div className="flex items-center justify-between"> <Tabs value={activePanel} onValueChange={(v) => setActivePanel(v as 'input' | 'config' | 'output')}> <TabsList className="h-9 p-1 rounded-md"> <TabsTrigger value="input" className="h-full px-3 text-xs flex items-center gap-1 rounded-sm data-[state=active]:rounded-sm"> <FileInput className="h-4 w-4" /> Step Input </TabsTrigger> <TabsTrigger value="config" className="h-full px-3 text-xs flex items-center gap-1 rounded-sm data-[state=active]:rounded-sm"> <FileBraces className="h-4 w-4" /> Step Config </TabsTrigger> <TabsTrigger value="output" className="h-full px-3 text-xs flex items-center gap-1 rounded-sm data-[state=active]:rounded-sm"> <FileOutput className="h-4 w-4" /> Step Result </TabsTrigger> </TabsList> </Tabs> </div> <div> {activePanel === 'input' && ( <StepInputTab step={step} stepIndex={stepIndex} evolvingPayload={evolvingPayload} canExecute={canExecute} readOnly={readOnly} onEdit={onEdit} isActive={true} sourceDataVersion={sourceDataVersion} /> )} {activePanel === 'config' && ( <StepConfigTab step={step} evolvingPayload={evolvingPayload} dataSelectorOutput={dataSelectorOutput} categorizedSources={categorizedSources} canExecute={canExecute} integrations={integrations} onEdit={onEdit} onEditingChange={onConfigEditingChange} onOpenFixStepDialog={onOpenFixStepDialog} sourceDataVersion={sourceDataVersion} /> )} {activePanel === 'output' && ( <StepResultTab step={step} stepIndex={stepIndex} stepResult={stepResult} failedSteps={failedSteps} abortedSteps={abortedSteps} isExecuting={isExecuting} isGlobalExecuting={isGlobalExecuting} currentExecutingStepIndex={currentExecutingStepIndex} isActive={true} /> )} </div> </div> </div> <AlertDialog open={showInvalidPayloadDialog} onOpenChange={(open) => { setShowInvalidPayloadDialog(open); if (!open) { setPendingAction(null); } }}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Tool Input Does Not Match Input Schema</AlertDialogTitle> <AlertDialogDescription> Your tool input does not match the input schema. This may cause execution to fail. You can edit the input and schema in the Start (Tool Input) Card. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={() => { setShowInvalidPayloadDialog(false); if (pendingAction === 'execute' && onExecuteStep) { onExecuteStep(); } setPendingAction(null); }}> Run Anyway </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Delete Step</AlertDialogTitle> <AlertDialogDescription> Are you sure you want to delete step "{step.id}"? This action cannot be undone. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={() => { setShowDeleteConfirm(false); onRemove?.(step.id); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > Delete </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </Card> ); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/superglue-ai/superglue'

If you have feedback or need assistance with the MCP directory API, please join our Discord server