Skip to main content
Glama
ToolStepGallery.tsx37.4 kB
import { Badge } from '@/src/components/ui/badge'; import { Button } from '@/src/components/ui/button'; import { canExecuteStep } from '@/src/lib/client-utils'; import { type UploadedFileInfo } from '@/src/lib/file-utils'; import { buildEvolvingPayload, cn } from '@/src/lib/general-utils'; import { Integration } from "@superglue/client"; import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { FinalTransformMiniStepCard } from './cards/FinalTransformCard'; import { MiniStepCard } from './cards/MiniStepCard'; import { PayloadMiniStepCard } from './cards/PayloadCard'; import { SpotlightStepCard } from './cards/SpotlightStepCard'; import { AddStepDialog } from './dialogs/AddStepDialog'; import { InstructionDisplay } from './shared/InstructionDisplay'; export interface ToolStepGalleryProps { steps: any[]; stepResults?: Record<string, any>; finalTransform?: string; finalResult?: any; responseSchema?: string; toolId?: string; instruction?: string; onStepsChange?: (steps: any[]) => void; onStepEdit?: (stepId: string, updatedStep: any, isUserInitiated?: boolean) => void; onFinalTransformChange?: (transform: string) => void; onResponseSchemaChange?: (schema: string) => void; onPayloadChange?: (payload: string) => void; onInstructionEdit?: () => void; toolActionButtons?: React.ReactNode; onExecuteStep?: (stepIndex: number) => Promise<void>; onExecuteStepWithLimit?: (stepIndex: number, limit: number) => Promise<void>; onOpenFixStepDialog?: (stepIndex: number) => void; onExecuteAllSteps?: () => Promise<void>; onExecuteTransform?: (schema: string, transform: string) => Promise<void>; onFixTransform?: (schema: string, transform: string) => Promise<void>; completedSteps?: string[]; failedSteps?: string[]; integrations?: Integration[]; isExecuting?: boolean; isExecutingStep?: number; isRunningTransform?: boolean; isFixingTransform?: boolean; currentExecutingStepIndex?: number; readOnly?: boolean; payloadText?: string; computedPayload?: any; inputSchema?: string | null; onInputSchemaChange?: (schema: string | null) => void; headerActions?: React.ReactNode; navigateToFinalSignal?: number; showStepOutputSignal?: number; focusStepId?: string | null; uploadedFiles?: UploadedFileInfo[]; onFilesUpload?: (files: File[]) => Promise<void>; onFileRemove?: (key: string) => void; isProcessingFiles?: boolean; totalFileSize?: number; filePayloads?: Record<string, any>; isPayloadValid?: boolean; onPayloadUserEdit?: () => void; embedded?: boolean; } export function ToolStepGallery({ steps, stepResults = {}, finalTransform, finalResult, responseSchema, toolId, instruction, onStepsChange, onStepEdit: originalOnStepEdit, onFinalTransformChange, onResponseSchemaChange, onPayloadChange, onInstructionEdit, toolActionButtons, onExecuteStep, onExecuteStepWithLimit, onOpenFixStepDialog, onExecuteAllSteps, onExecuteTransform, onFixTransform, completedSteps = [], failedSteps = [], integrations, isExecuting, isExecutingStep, isRunningTransform, isFixingTransform, currentExecutingStepIndex, readOnly = false, payloadText, computedPayload, inputSchema, onInputSchemaChange, headerActions, navigateToFinalSignal, showStepOutputSignal, focusStepId, uploadedFiles, onFilesUpload, onFileRemove, isProcessingFiles, totalFileSize, filePayloads, isPayloadValid = true, onPayloadUserEdit, embedded = false }: ToolStepGalleryProps) { const [activeIndex, setActiveIndex] = useState(1); // Default to first tool step, not payload const [windowWidth, setWindowWidth] = useState(1200); const [containerWidth, setContainerWidth] = useState<number>(1200); const [isHydrated, setIsHydrated] = useState(false); const listRef = useRef<HTMLDivElement | null>(null); const [isConfiguratorEditing, setIsConfiguratorEditing] = useState<boolean>(false); const [isAddStepDialogOpen, setIsAddStepDialogOpen] = useState(false); const [defaultStepId, setDefaultStepId] = useState(''); const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null); const isConfiguratorEditingRef = useRef<boolean>(false); const [hiddenLeftCount, setHiddenLeftCount] = useState(0); const [hiddenRightCount, setHiddenRightCount] = useState(0); const scrollContainerRef = useRef<HTMLDivElement | null>(null); useEffect(() => { isConfiguratorEditingRef.current = isConfiguratorEditing; }, [isConfiguratorEditing]); const isNavigatingRef = useRef<boolean>(false); const navigationTimeoutRef = useRef<NodeJS.Timeout | null>(null); const NAV_SUPPRESS_MS = 300; const NAV_DELAY_MS = 50; const navigateToIndex = (nextIndex: number) => { isNavigatingRef.current = true; if (navigationTimeoutRef.current) clearTimeout(navigationTimeoutRef.current); navigationTimeoutRef.current = setTimeout(() => { isNavigatingRef.current = false; }, NAV_SUPPRESS_MS); setTimeout(() => { setActiveIndex(nextIndex); const container = listRef.current; const card = container?.children?.[nextIndex] as HTMLElement | undefined; if (container && card) { card.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); } // Scroll to top based on mode if (embedded) { // In embedded mode, find the nearest scrollable parent let scrollParent = scrollContainerRef.current?.parentElement; while (scrollParent && scrollParent !== document.body) { const { overflowY } = window.getComputedStyle(scrollParent); if (overflowY === 'auto' || overflowY === 'scroll') { scrollParent.scrollTo({ top: 0, behavior: 'smooth' }); break; } scrollParent = scrollParent.parentElement; } // Fallback: scroll window if (!scrollParent || scrollParent === document.body) { window.scrollTo({ top: 0, behavior: 'smooth' }); } } else if (scrollContainerRef.current) { // Non-embedded mode: use local scroll container scrollContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } }, NAV_DELAY_MS); }; // Hydration effect useEffect(() => { setIsHydrated(true); setWindowWidth(window.innerWidth); setContainerWidth(window.innerWidth); }, []); // Update window width on resize useEffect(() => { if (!isHydrated) return; const handleResize = () => { setWindowWidth(window.innerWidth); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [isHydrated]); // Observe container width (e.g., when logs panel opens/closes) to responsively adjust cards useEffect(() => { if (!isHydrated) return; const container = listRef.current?.parentElement?.parentElement as HTMLElement | null; if (!container || typeof ResizeObserver === 'undefined') return; const RESIZE_THRESHOLD = 50; // Increased from 1px to reduce sensitivity and prevent cascading re-renders const ro = new ResizeObserver((entries) => { for (const entry of entries) { const w = (entry.contentRect?.width || container.getBoundingClientRect().width); if (w && Math.abs(w - containerWidth) > RESIZE_THRESHOLD) setContainerWidth(w); } }); ro.observe(container); return () => ro.disconnect(); }, [isHydrated, listRef.current, containerWidth]); const [rawPayloadText, setRawPayloadText] = useState<string>(payloadText || ''); // Keep local payload text in sync with external prop useEffect(() => { if (typeof payloadText === 'string') { setRawPayloadText(payloadText); } }, [payloadText]); const handlePayloadJsonChange = (jsonString: string) => { setRawPayloadText(jsonString); onPayloadChange?.(jsonString); }; // Use computed payload from parent (already merged manual + files) const workingPayload = computedPayload || {}; const stepResultsMap = useMemo(() => Array.isArray(stepResults) ? stepResults.reduce((acc: Record<string, any>, result: any) => { if (result.stepId) { acc[result.stepId] = result.data; } return acc; }, {}) : stepResults, [stepResults] ); const hasTransformCompleted = completedSteps.includes('__final_transform__'); const toolItems = useMemo(() => [ { type: 'payload', data: { payloadText: rawPayloadText, inputSchema }, stepResult: undefined, transformError: undefined, evolvingPayload: workingPayload || {} }, ...steps.map((step, index) => ({ type: 'step', data: step, stepResult: stepResultsMap[step.id], transformError: undefined, evolvingPayload: buildEvolvingPayload(workingPayload || {}, steps, stepResultsMap, index - 1) })), ...(finalTransform !== undefined ? [{ type: 'transform', data: { transform: finalTransform, responseSchema }, stepResult: finalResult, transformError: failedSteps.includes('__final_transform__') ? stepResultsMap['__final_transform__'] : null, evolvingPayload: buildEvolvingPayload(workingPayload || {}, steps, stepResultsMap, steps.length - 1), hasTransformCompleted }] : []) ], [rawPayloadText, inputSchema, workingPayload, steps, stepResultsMap, finalTransform, responseSchema, finalResult, hasTransformCompleted, failedSteps]); // Memoize canExecute checks to avoid running steps.every() on every render const canExecuteTransform = useMemo(() => steps.every((s: any) => completedSteps.includes(s.id)), [steps, completedSteps] ); const currentItem = toolItems[activeIndex]; const indicatorIndices = toolItems.map((_, idx) => idx); const handleNavigation = (direction: 'prev' | 'next') => { if (isConfiguratorEditing) return; const newIndex = direction === 'prev' ? Math.max(0, activeIndex - 1) : Math.min(toolItems.length - 1, activeIndex + 1); if (newIndex === activeIndex) return; navigateToIndex(newIndex); }; const handleCardClick = (globalIndex: number) => { if (isConfiguratorEditing) return; navigateToIndex(globalIndex); }; const handleRemoveStep = (stepId: string) => { if (!onStepsChange) return; const newSteps = steps.filter(step => step.id !== stepId); onStepsChange(newSteps); // Adjust active index if needed if (activeIndex >= toolItems.length - 1) { setActiveIndex(Math.max(0, activeIndex - 1)); } }; const handleInsertStep = (afterIndex: number) => { if (!onStepsChange || readOnly) return; const defaultId = `step_${Date.now()}`; setDefaultStepId(defaultId); setPendingInsertIndex(afterIndex); setIsAddStepDialogOpen(true); }; const handleConfirmInsertStep = (stepId: string, instruction: string, integrationId?: string) => { if (pendingInsertIndex === null || !onStepsChange) return; const selectedIntegration = integrationId ? integrations?.find(i => i.id === integrationId) : undefined; const newStep = { id: stepId, name: '', integrationId: integrationId || '', apiConfig: { id: stepId, instruction: instruction, urlHost: selectedIntegration?.urlHost || '', urlPath: selectedIntegration?.urlPath || '', method: 'GET', headers: {}, queryParams: {}, body: '', authentication: 'NONE' }, executionMode: 'DIRECT' }; const newSteps = [...steps]; newSteps.splice(pendingInsertIndex, 0, newStep); onStepsChange(newSteps); const insertedIndex = pendingInsertIndex; setIsAddStepDialogOpen(false); setPendingInsertIndex(null); // Navigate to the newly inserted step (+1 for payload card, +1 because we insert after) setTimeout(() => navigateToIndex(insertedIndex + 1), 100); }; const handleConfirmInsertTool = (toolSteps: any[]) => { if (pendingInsertIndex === null || !onStepsChange) return; const newSteps = [...steps]; newSteps.splice(pendingInsertIndex, 0, ...toolSteps); onStepsChange(newSteps); const insertedIndex = pendingInsertIndex; setIsAddStepDialogOpen(false); setPendingInsertIndex(null); // Navigate to the first newly inserted step setTimeout(() => navigateToIndex(insertedIndex + 1), 100); }; const onStepEdit = (stepId: string, updatedStep: any, isUserInitiated: boolean = false) => { // Suppress user-initiated edits during navigation to prevent spurious resets if (isNavigatingRef.current && isUserInitiated) { if (originalOnStepEdit) { originalOnStepEdit(stepId, updatedStep, false); } } else if (originalOnStepEdit) { originalOnStepEdit(stepId, updatedStep, isUserInitiated); } }; useEffect(() => { setActiveIndex(steps.length > 0 && isPayloadValid ? 1 : 0); }, []); // Keyboard navigation with arrow keys useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle if not typing in an input/textarea/contenteditable if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement).isContentEditable) { return; } // Don't navigate when editing step config if (isConfiguratorEditing) { return; } if (e.key === 'ArrowLeft' && activeIndex > 0) { e.preventDefault(); handleNavigation('prev'); } else if (e.key === 'ArrowRight' && activeIndex < toolItems.length - 1) { e.preventDefault(); handleNavigation('next'); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [activeIndex, toolItems.length, isConfiguratorEditing]); useEffect(() => { if (navigateToFinalSignal) { navigateToIndex(toolItems.length - 1); } }, [navigateToFinalSignal]); useEffect(() => { if (!showStepOutputSignal || !focusStepId) return; const idx = steps.findIndex((s: any) => s.id === focusStepId); if (idx >= 0) { navigateToIndex(idx + 1); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [showStepOutputSignal, focusStepId]); return ( <div className="flex flex-col h-full"> {/* Fixed header section */} <div className="flex-shrink-0 space-y-1.5 mb-6"> <div className="flex items-center justify-center gap-3 flex-wrap"> <div className="flex items-center gap-3 min-w-0 w-full"> {typeof toolId !== 'undefined' && ( <div className="flex w-full items-center justify-between gap-3 mb-2"> <div className="flex items-center gap-2 flex-1 min-w-0"> <h1 className="text-2xl font-bold truncate"> {toolId || 'Untitled Tool'} </h1> {toolActionButtons} </div> <div className="flex items-center gap-2"> {headerActions ?? null} </div> </div> )} </div> </div> {instruction && ( <div className="w-full"> <InstructionDisplay instruction={instruction} onEdit={onInstructionEdit} showEditButton={!readOnly && !!onInstructionEdit} /> </div> )} </div> {/* Scrollable content section */} <div ref={scrollContainerRef} className="flex-1 overflow-y-auto pr-4" style={{ scrollbarGutter: 'stable' }}> <div className="space-y-6"> <div className="flex items-center gap-0"> <div className="relative"> <Button variant="outline" size="icon" onClick={() => handleNavigation('prev')} disabled={activeIndex === 0} className={cn( "shrink-0 h-9 w-9", hiddenLeftCount > 0 && !isPayloadValid && "ring-1 ring-amber-500 border-amber-500 shadow-lg shadow-amber-500/30 animate-pulse" )} title="Previous" > <ChevronLeft className="h-4 w-4" /> </Button> {hiddenLeftCount > 0 && ( <Badge variant="default" className={cn( "absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px] font-bold flex items-center justify-center", !isPayloadValid ? "bg-amber-500 text-white" : "bg-primary text-primary-foreground" )} > {hiddenLeftCount} </Badge> )} </div> <div className="flex-1 overflow-hidden px-0"> <div className="relative"> <div ref={listRef} className="flex justify-center items-center overflow-visible py-3" style={{ minHeight: '150px' }} > {!isHydrated ? ( // Show a simple loading state during hydration <div className="flex items-center justify-center"> <div className="w-48 h-24 bg-muted/20 rounded-md animate-pulse" /> </div> ) : (() => { const totalCards = toolItems.length; let startIdx = 0; let endIdx = totalCards; const CARD_WIDTH = 180; // px (matches card classes) const ARROW_WIDTH = 24; // px (ChevronRight ~20px, add buffer) const GUTTER = 16; // px (doubled spacing) const SAFE_MARGIN = 12; // px extra space to avoid clipping const available = Math.max(0, (containerWidth || windowWidth) - SAFE_MARGIN); let cardsToShow = 1; const maxCandidates = Math.min(toolItems.length, 12); for (let c = 1; c <= maxCandidates; c++) { const needed = (c * CARD_WIDTH) + ((c - 1) * (ARROW_WIDTH + GUTTER)); if (needed <= available) { cardsToShow = c; } else { break; } } cardsToShow = Math.max(1, cardsToShow); if (totalCards <= cardsToShow) { // Show all cards if we have fewer than cardsToShow startIdx = 0; endIdx = totalCards; } else { const halfWindow = Math.floor(cardsToShow / 2); startIdx = Math.max(0, Math.min(activeIndex - halfWindow, totalCards - cardsToShow)); endIdx = startIdx + cardsToShow; } const visibleItems = toolItems.slice(startIdx, endIdx); const visibleIndices = visibleItems.map((_, i) => startIdx + i); const hasHiddenLeft = startIdx > 0; const hasHiddenRight = endIdx < totalCards; const hiddenLeft = startIdx; const hiddenRight = totalCards - endIdx; // Update state for badges (use ref to avoid re-render loop) if (hiddenLeft !== hiddenLeftCount) setHiddenLeftCount(hiddenLeft); if (hiddenRight !== hiddenRightCount) setHiddenRightCount(hiddenRight); const sepWidth = ARROW_WIDTH + GUTTER; const edgeWidth = sepWidth; const count = Math.max(1, visibleItems.length); const innerAvailable = Math.max(0, (containerWidth || windowWidth) - SAFE_MARGIN - (2 * edgeWidth) - ((count - 1) * sepWidth)); const baseCardWidth = Math.floor(innerAvailable / count); const widthRemainder = innerAvailable - (baseCardWidth * count); return ( <> {hasHiddenLeft && null} {visibleItems.length > 0 && ( <div style={{ flex: `0 0 ${sepWidth}px`, width: `${sepWidth}px` }} /> )} {visibleItems.map((item, idx) => { const globalIdx = visibleIndices[idx]; const showArrow = idx < visibleItems.length - 1; return ( <React.Fragment key={globalIdx}> <div className="flex items-center justify-center" style={{ flex: `0 0 ${baseCardWidth + (idx < widthRemainder ? 1 : 0)}px`, width: `${baseCardWidth + (idx < widthRemainder ? 1 : 0)}px`, maxWidth: `${baseCardWidth + (idx < widthRemainder ? 1 : 0)}px` }} > <MiniStepCard step={item.data} index={globalIdx} isActive={globalIdx === activeIndex} onClick={() => handleCardClick(globalIdx)} stepId={item.type === 'step' ? item.data.id : undefined} isPayload={item.type === 'payload'} isTransform={item.type === 'transform'} isRunningAll={isExecuting && currentExecutingStepIndex === (globalIdx - 1)} isTesting={ item.type === 'step' ? (isExecutingStep === (globalIdx - 1)) : item.type === 'transform' ? (isRunningTransform || isFixingTransform) : false } completedSteps={completedSteps} failedSteps={failedSteps} isFirstCard={globalIdx === 0} isLastCard={globalIdx === totalCards - 1} integrations={integrations} hasTransformCompleted={hasTransformCompleted} isPayloadValid={isPayloadValid} payloadData={item.type === 'payload' ? workingPayload : undefined} /> </div> {showArrow && ( <div style={{ flex: `0 0 ${sepWidth}px`, width: `${sepWidth}px` }} className="flex items-center justify-center"> {!readOnly && onStepsChange && ( <button onClick={(e) => { e.stopPropagation(); handleInsertStep(globalIdx); }} className="group relative flex items-center justify-center h-8 w-8 rounded-full hover:bg-primary/10 transition-colors" title="Add step here" > <ChevronRight className="h-5 w-5 text-muted-foreground group-hover:opacity-0 transition-opacity" /> <Plus className="h-4 w-4 text-primary absolute opacity-0 group-hover:opacity-100 transition-opacity" /> </button> )} {(readOnly || !onStepsChange) && ( <ChevronRight className="h-5 w-5 text-muted-foreground/50" /> )} </div> )} </React.Fragment> ); })} {visibleItems.length > 0 && ( <div style={{ flex: `0 0 ${sepWidth}px`, width: `${sepWidth}px` }} /> )} {hasHiddenRight && null} </> ); })()} </div> </div> </div> <div className="relative"> <Button variant="outline" size="icon" onClick={() => handleNavigation('next')} disabled={activeIndex === toolItems.length - 1} className="shrink-0 h-9 w-9" title="Next" > <ChevronRight className="h-4 w-4" /> </Button> {hiddenRightCount > 0 && ( <Badge variant="default" className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px] font-bold flex items-center justify-center bg-primary text-primary-foreground" > {hiddenRightCount} </Badge> )} </div> </div> <div className="flex justify-center items-center gap-2"> <div className="flex gap-1"> {indicatorIndices.map((globalIdx) => ( <button key={`dot-${globalIdx}`} onClick={() => { if (isConfiguratorEditing) return; navigateToIndex(globalIdx); }} className={cn( "w-1.5 h-1.5 rounded-full transition-colors", globalIdx === activeIndex ? "bg-primary" : "bg-muted" )} aria-label={`Go to item ${globalIdx + 1}`} title={`Go to item ${globalIdx + 1}`} /> ))} </div> </div> <div className="min-h-[220px] max-w-6xl mx-auto"> {currentItem && ( currentItem.type === 'payload' ? ( <PayloadMiniStepCard payloadText={currentItem.data.payloadText} inputSchema={currentItem.data.inputSchema} onChange={handlePayloadJsonChange} onInputSchemaChange={onInputSchemaChange} readOnly={readOnly} onFilesUpload={onFilesUpload} uploadedFiles={uploadedFiles} onFileRemove={onFileRemove} isProcessingFiles={isProcessingFiles} totalFileSize={totalFileSize} onUserEdit={onPayloadUserEdit} isPayloadValid={isPayloadValid} /> ) : currentItem.type === 'transform' ? ( <FinalTransformMiniStepCard transform={currentItem.data.transform} responseSchema={currentItem.data.responseSchema} onTransformChange={onFinalTransformChange} onResponseSchemaChange={onResponseSchemaChange} readOnly={readOnly} onExecuteTransform={onExecuteTransform} onFixTransform={onFixTransform} isRunningTransform={isRunningTransform} isFixingTransform={isFixingTransform} canExecute={canExecuteTransform} transformResult={finalResult} transformError={currentItem.transformError} stepInputs={currentItem.evolvingPayload} hasTransformCompleted={hasTransformCompleted} /> ) : ( <SpotlightStepCard step={currentItem.data} stepIndex={activeIndex - 1} // Adjust for payload card evolvingPayload={currentItem.evolvingPayload || {}} stepResult={currentItem.stepResult} onEdit={!readOnly ? onStepEdit : undefined} onRemove={!readOnly && currentItem.type === 'step' ? handleRemoveStep : undefined} onExecuteStep={onExecuteStep ? () => onExecuteStep(activeIndex - 1) : undefined} onExecuteStepWithLimit={onExecuteStepWithLimit ? (limit) => onExecuteStepWithLimit(activeIndex - 1, limit) : undefined} onOpenFixStepDialog={onOpenFixStepDialog ? () => onOpenFixStepDialog(activeIndex - 1) : undefined} canExecute={canExecuteStep(activeIndex - 1, completedSteps, { steps } as any, stepResultsMap)} isExecuting={isExecutingStep === activeIndex - 1} isGlobalExecuting={!!(isExecuting || isRunningTransform || isFixingTransform)} currentExecutingStepIndex={currentExecutingStepIndex} integrations={integrations} readOnly={readOnly} failedSteps={failedSteps} showOutputSignal={showStepOutputSignal} onConfigEditingChange={setIsConfiguratorEditing} isFirstStep={activeIndex === 1} isPayloadValid={isPayloadValid} /> ) )} </div> </div> </div> <AddStepDialog open={isAddStepDialogOpen} onOpenChange={setIsAddStepDialogOpen} onConfirm={handleConfirmInsertStep} onConfirmTool={handleConfirmInsertTool} existingStepIds={steps.map((s: any) => s.id)} defaultId={defaultStepId} /> </div> ); }

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