diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index 0a39af6657..0a43ea1849 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -175,7 +175,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { const distanceFromBottom = scrollHeight - scrollTop - clientHeight setShowScrollButton(distanceFromBottom > 100) - // Track if user is manually scrolling during streaming if (isStreamingResponse && !isUserScrollingRef.current) { setUserHasScrolled(true) } @@ -191,13 +190,10 @@ export default function ChatClient({ identifier }: { identifier: string }) { return () => container.removeEventListener('scroll', handleScroll) }, [handleScroll]) - // Reset user scroll tracking when streaming starts useEffect(() => { if (isStreamingResponse) { - // Reset userHasScrolled when streaming starts setUserHasScrolled(false) - // Give a small delay to distinguish between programmatic scroll and user scroll isUserScrollingRef.current = true setTimeout(() => { isUserScrollingRef.current = false @@ -215,7 +211,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { }) if (!response.ok) { - // Check if auth is required if (response.status === 401) { const errorData = await response.json() @@ -236,7 +231,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { throw new Error(`Failed to load chat configuration: ${response.status}`) } - // Reset auth required state when authentication is successful setAuthRequired(null) const data = await response.json() @@ -260,7 +254,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { } } - // Fetch chat config on mount and generate new conversation ID useEffect(() => { fetchChatConfig() setConversationId(uuidv4()) @@ -285,7 +278,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { }, 800) } - // Handle sending a message const handleSendMessage = async ( messageParam?: string, isVoiceInput = false, @@ -308,7 +300,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { filesCount: files?.length, }) - // Reset userHasScrolled when sending a new message setUserHasScrolled(false) const userMessage: ChatMessage = { @@ -325,24 +316,20 @@ export default function ChatClient({ identifier }: { identifier: string }) { })), } - // Add the user's message to the chat setMessages((prev) => [...prev, userMessage]) setInputValue('') setIsLoading(true) - // Scroll to show only the user's message and loading indicator setTimeout(() => { scrollToMessage(userMessage.id, true) }, 100) - // Create abort controller for request cancellation const abortController = new AbortController() const timeoutId = setTimeout(() => { abortController.abort() }, CHAT_REQUEST_TIMEOUT_MS) try { - // Send structured payload to maintain chat context const payload: any = { input: typeof userMessage.content === 'string' @@ -351,7 +338,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { conversationId, } - // Add files if present (convert to base64 for JSON transmission) if (files && files.length > 0) { payload.files = await Promise.all( files.map(async (file) => ({ @@ -379,7 +365,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { signal: abortController.signal, }) - // Clear timeout since request succeeded clearTimeout(timeoutId) if (!response.ok) { @@ -392,7 +377,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { throw new Error('Response body is missing') } - // Use the streaming hook with audio support const shouldPlayAudio = isVoiceInput || isVoiceFirstMode const audioHandler = shouldPlayAudio ? createAudioStreamHandler( @@ -421,7 +405,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { } ) } catch (error: any) { - // Clear timeout in case of error clearTimeout(timeoutId) if (error.name === 'AbortError') { @@ -442,7 +425,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { } } - // Stop audio when component unmounts or when streaming is stopped useEffect(() => { return () => { stopAudio() @@ -452,28 +434,23 @@ export default function ChatClient({ identifier }: { identifier: string }) { } }, [stopAudio]) - // Voice interruption - stop audio when user starts speaking const handleVoiceInterruption = useCallback(() => { stopAudio() - // Stop any ongoing streaming response if (isStreamingResponse) { stopStreaming(setMessages) } }, [isStreamingResponse, stopStreaming, setMessages, stopAudio]) - // Handle voice mode activation const handleVoiceStart = useCallback(() => { setIsVoiceFirstMode(true) }, []) - // Handle exiting voice mode const handleExitVoiceMode = useCallback(() => { setIsVoiceFirstMode(false) - stopAudio() // Stop any playing audio when exiting + stopAudio() }, [stopAudio]) - // Handle voice transcript from voice-first interface const handleVoiceTranscript = useCallback( (transcript: string) => { logger.info('Received voice transcript:', transcript) @@ -482,14 +459,11 @@ export default function ChatClient({ identifier }: { identifier: string }) { [handleSendMessage] ) - // If error, show error message using the extracted component if (error) { return } - // If authentication is required, use the extracted components if (authRequired) { - // Get title and description from the URL params or use defaults const title = new URLSearchParams(window.location.search).get('title') || 'chat' const primaryColor = new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)' @@ -526,12 +500,10 @@ export default function ChatClient({ identifier }: { identifier: string }) { } } - // Loading state while fetching config using the extracted component if (!chatConfig) { return } - // Voice-first mode interface if (isVoiceFirstMode) { return ( {/* Header component */} diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx index 55752a5531..fe26fd1558 100644 --- a/apps/sim/app/templates/[id]/template.tsx +++ b/apps/sim/app/templates/[id]/template.tsx @@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor' -import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' +import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' import { getBlock } from '@/blocks/registry' import { useStarTemplate, useTemplate } from '@/hooks/queries/templates' diff --git a/apps/sim/app/templates/components/template-card.tsx b/apps/sim/app/templates/components/template-card.tsx index 071d01b6e5..32daadfcf3 100644 --- a/apps/sim/app/templates/components/template-card.tsx +++ b/apps/sim/app/templates/components/template-card.tsx @@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { VerifiedBadge } from '@/components/ui/verified-badge' import { cn } from '@/lib/core/utils/cn' -import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' +import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' import { getBlock } from '@/blocks/registry' import { useStarTemplate } from '@/hooks/queries/templates' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts index cc6f091ed9..1a907cfd89 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts @@ -1,7 +1,7 @@ export { Dashboard } from './dashboard' export { LogDetails } from './log-details' +export { ExecutionSnapshot } from './log-details/components/execution-snapshot' export { FileCards } from './log-details/components/file-download' -export { FrozenCanvas } from './log-details/components/frozen-canvas' export { TraceSpans } from './log-details/components/trace-spans' export { LogRowContextMenu } from './log-row-context-menu' export { LogsList } from './logs-list' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx new file mode 100644 index 0000000000..7aed4ba6a6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx @@ -0,0 +1,252 @@ +'use client' + +import { useEffect, useState } from 'react' +import { createLogger } from '@sim/logger' +import { AlertCircle, Loader2 } from 'lucide-react' +import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn' +import { redactApiKeys } from '@/lib/core/security/redaction' +import { cn } from '@/lib/core/utils/cn' +import { + BlockDetailsSidebar, + WorkflowPreview, +} from '@/app/workspace/[workspaceId]/w/components/preview' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('ExecutionSnapshot') + +interface TraceSpan { + blockId?: string + input?: unknown + output?: unknown + status?: string + duration?: number + children?: TraceSpan[] +} + +interface BlockExecutionData { + input: unknown + output: unknown + status: string + durationMs: number +} + +interface ExecutionSnapshotData { + executionId: string + workflowId: string + workflowState: WorkflowState + executionMetadata: { + trigger: string + startedAt: string + endedAt?: string + totalDurationMs?: number + + cost: { + total: number | null + input: number | null + output: number | null + } + totalTokens: number | null + } +} + +interface ExecutionSnapshotProps { + executionId: string + traceSpans?: TraceSpan[] + className?: string + height?: string | number + width?: string | number + isModal?: boolean + isOpen?: boolean + onClose?: () => void +} + +export function ExecutionSnapshot({ + executionId, + traceSpans, + className, + height = '100%', + width = '100%', + isModal = false, + isOpen = false, + onClose = () => {}, +}: ExecutionSnapshotProps) { + const [data, setData] = useState(null) + const [blockExecutions, setBlockExecutions] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [pinnedBlockId, setPinnedBlockId] = useState(null) + + useEffect(() => { + if (traceSpans && Array.isArray(traceSpans)) { + const blockExecutionMap: Record = {} + + const collectBlockSpans = (spans: TraceSpan[]): TraceSpan[] => { + const blockSpans: TraceSpan[] = [] + + for (const span of spans) { + if (span.blockId) { + blockSpans.push(span) + } + if (span.children && Array.isArray(span.children)) { + blockSpans.push(...collectBlockSpans(span.children)) + } + } + + return blockSpans + } + + const allBlockSpans = collectBlockSpans(traceSpans) + + for (const span of allBlockSpans) { + if (span.blockId && !blockExecutionMap[span.blockId]) { + blockExecutionMap[span.blockId] = { + input: redactApiKeys(span.input || {}), + output: redactApiKeys(span.output || {}), + status: span.status || 'unknown', + durationMs: span.duration || 0, + } + } + } + + setBlockExecutions(blockExecutionMap) + } + }, [traceSpans]) + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true) + setError(null) + + const response = await fetch(`/api/logs/execution/${executionId}`) + if (!response.ok) { + throw new Error(`Failed to fetch execution snapshot data: ${response.statusText}`) + } + + const result = await response.json() + setData(result) + logger.debug(`Loaded execution snapshot data for execution: ${executionId}`) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error' + logger.error('Failed to fetch execution snapshot data:', err) + setError(errorMessage) + } finally { + setLoading(false) + } + } + + fetchData() + }, [executionId]) + + const renderContent = () => { + if (loading) { + return ( +
+
+ + Loading execution snapshot... +
+
+ ) + } + + if (error) { + return ( +
+
+ + Failed to load execution snapshot: {error} +
+
+ ) + } + + if (!data) { + return ( +
+
No data available
+
+ ) + } + + const isMigratedLog = (data.workflowState as any)?._migrated === true + if (isMigratedLog) { + return ( +
+
+ + Logged State Not Found +
+
+ This log was migrated from the old logging system. The workflow state at execution time + is not available. +
+
+ Note: {(data.workflowState as any)?._note} +
+
+ ) + } + + return ( +
+
+ { + // Toggle: clicking same block closes sidebar, clicking different block switches + setPinnedBlockId((prev) => (prev === blockId ? null : blockId)) + }} + cursorStyle='pointer' + executedBlocks={blockExecutions} + /> +
+ {pinnedBlockId && data.workflowState.blocks[pinnedBlockId] && ( + + )} +
+ ) + } + + if (isModal) { + return ( + + + Workflow State + + {renderContent()} + + + ) + } + + return renderContent() +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts new file mode 100644 index 0000000000..a80bf4e337 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts @@ -0,0 +1 @@ +export { ExecutionSnapshot } from './execution-snapshot' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx deleted file mode 100644 index b48bf8b63c..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx +++ /dev/null @@ -1,657 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { - AlertCircle, - ChevronDown, - ChevronLeft, - ChevronRight, - ChevronUp, - Clock, - DollarSign, - Hash, - Loader2, - Maximize2, - X, - Zap, -} from 'lucide-react' -import { Badge, Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { redactApiKeys } from '@/lib/core/security/redaction' -import { cn } from '@/lib/core/utils/cn' -import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -const logger = createLogger('FrozenCanvas') - -function ExpandableDataSection({ title, data }: { title: string; data: any }) { - const [isExpanded, setIsExpanded] = useState(false) - const [isModalOpen, setIsModalOpen] = useState(false) - - const jsonString = JSON.stringify(data, null, 2) - const isLargeData = jsonString.length > 500 || jsonString.split('\n').length > 10 - - return ( - <> -
-
-

{title}

-
- {isLargeData && ( - - )} - -
-
-
-
-            {jsonString}
-          
-
-
- - {isModalOpen && ( -
-
-
-

{title}

- -
-
-
-                {jsonString}
-              
-
-
-
- )} - - ) -} - -function formatExecutionData(executionData: any) { - const { - inputData, - outputData, - cost, - tokens, - durationMs, - status, - blockName, - blockType, - errorMessage, - errorStackTrace, - } = executionData - - return { - blockName: blockName || 'Unknown Block', - blockType: blockType || 'unknown', - status, - duration: durationMs ? `${durationMs}ms` : 'N/A', - input: redactApiKeys(inputData || {}), - output: redactApiKeys(outputData || {}), - errorMessage, - errorStackTrace, - cost: cost - ? { - input: cost.input || 0, - output: cost.output || 0, - total: cost.total || 0, - } - : null, - tokens: tokens - ? { - input: tokens.input || tokens.prompt || 0, - output: tokens.output || tokens.completion || 0, - total: tokens.total || 0, - } - : null, - } -} - -function getCurrentIterationData(blockExecutionData: any) { - if (blockExecutionData.iterations && Array.isArray(blockExecutionData.iterations)) { - const currentIndex = blockExecutionData.currentIteration ?? 0 - return { - executionData: blockExecutionData.iterations[currentIndex], - currentIteration: currentIndex, - totalIterations: blockExecutionData.totalIterations ?? blockExecutionData.iterations.length, - hasMultipleIterations: blockExecutionData.iterations.length > 1, - } - } - - return { - executionData: blockExecutionData, - currentIteration: 0, - totalIterations: 1, - hasMultipleIterations: false, - } -} - -function PinnedLogs({ - executionData, - blockId, - workflowState, - onClose, -}: { - executionData: any | null - blockId: string - workflowState: any - onClose: () => void -}) { - const [currentIterationIndex, setCurrentIterationIndex] = useState(0) - - useEffect(() => { - setCurrentIterationIndex(0) - }, [executionData]) - - if (!executionData) { - const blockInfo = workflowState?.blocks?.[blockId] - const formatted = { - blockName: blockInfo?.name || 'Unknown Block', - blockType: blockInfo?.type || 'unknown', - status: 'not_executed', - } - - return ( - - -
- - - {formatted.blockName} - - -
-
- {formatted.blockType} - not executed -
-
- - -
-
- This block was not executed because the workflow failed before reaching it. -
-
-
-
- ) - } - - const iterationInfo = getCurrentIterationData({ - ...executionData, - currentIteration: currentIterationIndex, - }) - - const formatted = formatExecutionData(iterationInfo.executionData) - const totalIterations = executionData.iterations?.length || 1 - - const goToPreviousIteration = () => { - if (currentIterationIndex > 0) { - setCurrentIterationIndex(currentIterationIndex - 1) - } - } - - const goToNextIteration = () => { - if (currentIterationIndex < totalIterations - 1) { - setCurrentIterationIndex(currentIterationIndex + 1) - } - } - - return ( - - -
- - - {formatted.blockName} - - -
-
-
- - {formatted.blockType} - - {formatted.status} -
- - {iterationInfo.hasMultipleIterations && ( -
- - - {iterationInfo.totalIterations !== undefined - ? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}` - : `${currentIterationIndex + 1}`} - - -
- )} -
-
- - -
-
- - {formatted.duration} -
- - {formatted.cost && formatted.cost.total > 0 && ( -
- - - ${formatted.cost.total.toFixed(5)} - -
- )} - - {formatted.tokens && formatted.tokens.total > 0 && ( -
- - - {formatted.tokens.total} tokens - -
- )} -
- - - - - - {formatted.cost && formatted.cost.total > 0 && ( -
-

- Cost Breakdown -

-
-
- Input: - ${formatted.cost.input.toFixed(5)} -
-
- Output: - ${formatted.cost.output.toFixed(5)} -
-
- Total: - ${formatted.cost.total.toFixed(5)} -
-
-
- )} - - {formatted.tokens && formatted.tokens.total > 0 && ( -
-

- Token Usage -

-
-
- Input: - {formatted.tokens.input} -
-
- Output: - {formatted.tokens.output} -
-
- Total: - {formatted.tokens.total} -
-
-
- )} -
-
- ) -} - -interface FrozenCanvasData { - executionId: string - workflowId: string - workflowState: WorkflowState - executionMetadata: { - trigger: string - startedAt: string - endedAt?: string - totalDurationMs?: number - - cost: { - total: number | null - input: number | null - output: number | null - } - totalTokens: number | null - } -} - -interface FrozenCanvasProps { - executionId: string - traceSpans?: any[] - className?: string - height?: string | number - width?: string | number - isModal?: boolean - isOpen?: boolean - onClose?: () => void -} - -export function FrozenCanvas({ - executionId, - traceSpans, - className, - height = '100%', - width = '100%', - isModal = false, - isOpen = false, - onClose, -}: FrozenCanvasProps) { - const [data, setData] = useState(null) - const [blockExecutions, setBlockExecutions] = useState>({}) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const [pinnedBlockId, setPinnedBlockId] = useState(null) - - // Process traceSpans to create blockExecutions map - useEffect(() => { - if (traceSpans && Array.isArray(traceSpans)) { - const blockExecutionMap: Record = {} - - logger.debug('Processing trace spans for frozen canvas:', { traceSpans }) - - // Recursively collect all spans with blockId from the trace spans tree - const collectBlockSpans = (spans: any[]): any[] => { - const blockSpans: any[] = [] - - for (const span of spans) { - // If this span has a blockId, it's a block execution - if (span.blockId) { - blockSpans.push(span) - } - - // Recursively check children - if (span.children && Array.isArray(span.children)) { - blockSpans.push(...collectBlockSpans(span.children)) - } - } - - return blockSpans - } - - const allBlockSpans = collectBlockSpans(traceSpans) - logger.debug('Collected all block spans:', allBlockSpans) - - // Group spans by blockId - const traceSpansByBlockId = allBlockSpans.reduce((acc: any, span: any) => { - if (span.blockId) { - if (!acc[span.blockId]) { - acc[span.blockId] = [] - } - acc[span.blockId].push(span) - } - return acc - }, {}) - - logger.debug('Grouped trace spans by blockId:', traceSpansByBlockId) - - for (const [blockId, spans] of Object.entries(traceSpansByBlockId)) { - const spanArray = spans as any[] - - const iterations = spanArray.map((span: any) => { - // Extract error information from span output if status is error - let errorMessage = null - let errorStackTrace = null - - if (span.status === 'error' && span.output) { - // Error information can be in different formats in the output - if (typeof span.output === 'string') { - errorMessage = span.output - } else if (span.output.error) { - errorMessage = span.output.error - errorStackTrace = span.output.stackTrace || span.output.stack - } else if (span.output.message) { - errorMessage = span.output.message - errorStackTrace = span.output.stackTrace || span.output.stack - } else { - // Fallback: stringify the entire output for error cases - errorMessage = JSON.stringify(span.output) - } - } - - return { - id: span.id, - blockId: span.blockId, - blockName: span.name, - blockType: span.type, - status: span.status, - startedAt: span.startTime, - endedAt: span.endTime, - durationMs: span.duration, - inputData: span.input, - outputData: span.output, - errorMessage, - errorStackTrace, - cost: span.cost || { - input: null, - output: null, - total: null, - }, - tokens: span.tokens || { - input: null, - output: null, - total: null, - }, - modelUsed: span.model || null, - metadata: {}, - } - }) - - blockExecutionMap[blockId] = { - iterations, - currentIteration: 0, - totalIterations: iterations.length, - } - } - - setBlockExecutions(blockExecutionMap) - } - }, [traceSpans]) - - useEffect(() => { - const fetchData = async () => { - try { - setLoading(true) - setError(null) - - const response = await fetch(`/api/logs/execution/${executionId}`) - if (!response.ok) { - throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`) - } - - const result = await response.json() - setData(result) - logger.debug(`Loaded frozen canvas data for execution: ${executionId}`) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error' - logger.error('Failed to fetch frozen canvas data:', err) - setError(errorMessage) - } finally { - setLoading(false) - } - } - - fetchData() - }, [executionId]) - - const renderContent = () => { - if (loading) { - return ( -
-
- - Loading frozen canvas... -
-
- ) - } - - if (error) { - return ( -
-
- - Failed to load frozen canvas: {error} -
-
- ) - } - - if (!data) { - return ( -
-
No data available
-
- ) - } - - const isMigratedLog = (data.workflowState as any)?._migrated === true - if (isMigratedLog) { - return ( -
-
- - Logged State Not Found -
-
- This log was migrated from the old logging system. The workflow state at execution time - is not available. -
-
- Note: {(data.workflowState as any)?._note} -
-
- ) - } - - return ( - <> -
- { - setPinnedBlockId(blockId) - }} - /> -
- - {pinnedBlockId && ( - setPinnedBlockId(null)} - /> - )} - - ) - } - - if (isModal) { - return ( - - - Workflow State - - -
-
- {renderContent()} -
-
-
-
-
- ) - } - - return renderContent() -} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/index.ts deleted file mode 100644 index 7e9eba695b..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FrozenCanvas } from './frozen-canvas' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 611f1b8976..000e1be1ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -5,7 +5,11 @@ import { ChevronUp, X } from 'lucide-react' import { Button, Eye } from '@/components/emcn' import { ScrollArea } from '@/components/ui/scroll-area' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' -import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components' +import { + ExecutionSnapshot, + FileCards, + TraceSpans, +} from '@/app/workspace/[workspaceId]/logs/components' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' import { formatDate, @@ -49,7 +53,7 @@ export const LogDetails = memo(function LogDetails({ hasNext = false, hasPrev = false, }: LogDetailsProps) { - const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false) + const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) const scrollAreaRef = useRef(null) const panelWidth = useLogDetailsUIStore((state) => state.panelWidth) const { handleMouseDown } = useLogDetailsResize() @@ -266,7 +270,7 @@ export const LogDetails = memo(function LogDetails({ Workflow State + ) : (
Deploy your workflow to see a preview @@ -304,6 +320,51 @@ export function GeneralDeploy({ + + {workflowToShow && ( + { + if (!open) { + setExpandedSelectedBlockId(null) + } + setShowExpandedPreview(open) + }} + > + + + {previewMode === 'selected' && selectedVersionInfo + ? selectedVersionInfo.name || `v${selectedVersion}` + : 'Live Workflow'} + + +
+
+ { + setExpandedSelectedBlockId( + expandedSelectedBlockId === blockId ? null : blockId + ) + }} + cursorStyle='pointer' + /> +
+ {expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && ( + setExpandedSelectedBlockId(null)} + /> + )} +
+
+
+
+ )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx index f5b15f522c..3bd4301250 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx @@ -18,7 +18,7 @@ import { Skeleton, TagInput } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og' -import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' +import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' import { useCreateTemplate, useDeleteTemplate, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx index 541fb538aa..2cd1b039b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx @@ -332,7 +332,10 @@ export function LongInput({ />
{formattedText}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slider-input/slider-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slider-input/slider-input.tsx index 8b947a6af3..673669356e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slider-input/slider-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slider-input/slider-input.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { Slider } from '@/components/emcn/components/slider/slider' +import { cn } from '@/lib/core/utils/cn' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' interface SliderInputProps { @@ -58,15 +59,17 @@ export function SliderInput({ const percentage = ((normalizedValue - min) / (max - min)) * 100 + const isDisabled = isPreview || disabled + return ( -
+
)} - + {!data.isPreview && ( + + )} {shouldShowDefaultHandles && } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 14e64108c8..783b0b8f7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -55,7 +55,11 @@ const WorkflowEdgeComponent = ({ const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error' - const edgeRunStatus = lastRunEdges.get(id) + // Check for execution status from both live execution store and preview data + const previewExecutionStatus = ( + data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined + )?.executionStatus + const edgeRunStatus = previewExecutionStatus || lastRunEdges.get(id) // Memoize diff status calculation to avoid recomputing on every render const edgeDiffStatus = useMemo((): EdgeDiffStatus => { @@ -87,18 +91,37 @@ const WorkflowEdgeComponent = ({ // Memoize edge style to prevent object recreation const edgeStyle = useMemo(() => { let color = 'var(--workflow-edge)' - if (edgeDiffStatus === 'deleted') color = 'var(--text-error)' - else if (isErrorEdge) color = 'var(--text-error)' - else if (edgeDiffStatus === 'new') color = 'var(--brand-tertiary)' - else if (edgeRunStatus === 'success') color = 'var(--border-success)' - else if (edgeRunStatus === 'error') color = 'var(--text-error)' + let opacity = 1 + + if (edgeDiffStatus === 'deleted') { + color = 'var(--text-error)' + opacity = 0.7 + } else if (isErrorEdge) { + color = 'var(--text-error)' + } else if (edgeDiffStatus === 'new') { + color = 'var(--brand-tertiary)' + } else if (edgeRunStatus === 'success') { + color = 'var(--border-success)' + } else if (edgeRunStatus === 'error') { + color = 'var(--text-error)' + } + + if (isSelected) { + opacity = 0.5 + } return { ...(style ?? {}), - strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2, + strokeWidth: edgeDiffStatus + ? 3 + : edgeRunStatus === 'success' || edgeRunStatus === 'error' + ? 2.5 + : isSelected + ? 2.5 + : 2, stroke: color, strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined, - opacity: edgeDiffStatus === 'deleted' ? 0.7 : isSelected ? 0.5 : 1, + opacity, } }, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx new file mode 100644 index 0000000000..756e68fed2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx @@ -0,0 +1,678 @@ +'use client' + +import { useMemo, useState } from 'react' +import { ChevronDown as ChevronDownIcon, X } from 'lucide-react' +import { ReactFlowProvider } from 'reactflow' +import { Badge, Button, ChevronDown, Code } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references' +import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' +import { getBlock } from '@/blocks' +import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types' +import { normalizeName } from '@/executor/constants' +import { navigatePath } from '@/executor/variables/resolvers/reference' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * Evaluate whether a subblock's condition is met based on current values. + */ +function evaluateCondition( + condition: SubBlockConfig['condition'], + subBlockValues: Record +): boolean { + if (!condition) return true + + const actualCondition = typeof condition === 'function' ? condition() : condition + + const fieldValueObj = subBlockValues[actualCondition.field] + const fieldValue = + fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj + ? (fieldValueObj as { value: unknown }).value + : fieldValueObj + + const conditionValues = Array.isArray(actualCondition.value) + ? actualCondition.value + : [actualCondition.value] + + let isMatch = conditionValues.some((v) => v === fieldValue) + + if (actualCondition.not) { + isMatch = !isMatch + } + + if (actualCondition.and && isMatch) { + const andFieldValueObj = subBlockValues[actualCondition.and.field] + const andFieldValue = + andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj + ? (andFieldValueObj as { value: unknown }).value + : andFieldValueObj + + const andConditionValues = Array.isArray(actualCondition.and.value) + ? actualCondition.and.value + : [actualCondition.and.value] + + let andMatch = andConditionValues.some((v) => v === andFieldValue) + + if (actualCondition.and.not) { + andMatch = !andMatch + } + + isMatch = isMatch && andMatch + } + + return isMatch +} + +/** + * Format a value for display as JSON string + */ +function formatValueAsJson(value: unknown): string { + if (value === null || value === undefined || value === '') { + return '—' + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } + } + return String(value) +} + +interface ResolvedConnection { + blockId: string + blockName: string + blockType: string + fields: Array<{ path: string; value: string; tag: string }> +} + +/** + * Extract all variable references from nested subblock values + */ +function extractAllReferencesFromSubBlocks(subBlockValues: Record): string[] { + const refs = new Set() + + const processValue = (value: unknown) => { + if (typeof value === 'string') { + const extracted = extractReferencePrefixes(value) + extracted.forEach((ref) => refs.add(ref.raw)) + } else if (Array.isArray(value)) { + value.forEach(processValue) + } else if (value && typeof value === 'object') { + if ('value' in value) { + processValue((value as { value: unknown }).value) + } else { + Object.values(value).forEach(processValue) + } + } + } + + Object.values(subBlockValues).forEach(processValue) + return Array.from(refs) +} + +/** + * Format a value for inline display (single line, truncated) + */ +function formatInlineValue(value: unknown): string { + if (value === null || value === undefined) return 'null' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch { + return String(value) + } + } + return String(value) +} + +interface ExecutionDataSectionProps { + title: string + data: unknown + isError?: boolean +} + +/** + * Collapsible section for execution data (input/output) + * Uses Code.Viewer for proper syntax highlighting matching the logs UI + */ +function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSectionProps) { + const [isExpanded, setIsExpanded] = useState(false) + + const jsonString = useMemo(() => { + if (!data) return '' + return formatValueAsJson(data) + }, [data]) + + const isEmpty = jsonString === '—' || jsonString === '' + + return ( +
+
setIsExpanded(!isExpanded)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setIsExpanded(!isExpanded) + } + }} + role='button' + tabIndex={0} + aria-expanded={isExpanded} + aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${title.toLowerCase()}`} + > + + {title} + + +
+ + {isExpanded && ( + <> + {isEmpty ? ( +
+ No data +
+ ) : ( + + )} + + )} +
+ ) +} + +/** + * Section showing resolved variable references - styled like the connections section in editor + */ +function ResolvedConnectionsSection({ connections }: { connections: ResolvedConnection[] }) { + const [isCollapsed, setIsCollapsed] = useState(false) + const [expandedBlocks, setExpandedBlocks] = useState>(() => { + // Start with all blocks expanded + return new Set(connections.map((c) => c.blockId)) + }) + + if (connections.length === 0) return null + + const toggleBlock = (blockId: string) => { + setExpandedBlocks((prev) => { + const next = new Set(prev) + if (next.has(blockId)) { + next.delete(blockId) + } else { + next.add(blockId) + } + return next + }) + } + + return ( +
+ {/* Header with Chevron */} +
setIsCollapsed(!isCollapsed)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setIsCollapsed(!isCollapsed) + } + }} + role='button' + tabIndex={0} + aria-label={isCollapsed ? 'Expand connections' : 'Collapse connections'} + > + +
Connections
+
+ + {/* Content - styled like ConnectionBlocks */} + {!isCollapsed && ( +
+ {connections.map((connection) => { + const blockConfig = getBlock(connection.blockType) + const Icon = blockConfig?.icon + const bgColor = blockConfig?.bgColor || '#6B7280' + const isExpanded = expandedBlocks.has(connection.blockId) + const hasFields = connection.fields.length > 0 + + return ( +
+ {/* Block header - styled like ConnectionItem */} +
hasFields && toggleBlock(connection.blockId)} + > +
+ {Icon && ( + + )} +
+ + {connection.blockName} + + {hasFields && ( + + )} +
+ + {/* Fields - styled like FieldItem but showing resolved values */} + {isExpanded && hasFields && ( +
+
+ {connection.fields.map((field) => ( +
+ + {field.path} + + + {field.value} + +
+ ))} +
+ )} +
+ ) + })} +
+ )} +
+ ) +} + +/** + * Icon component for rendering block icons + */ +function IconComponent({ + icon: Icon, + className, +}: { + icon: BlockIcon | undefined + className?: string +}) { + if (!Icon) return null + return +} + +interface ExecutionData { + input?: unknown + output?: unknown + status?: string + durationMs?: number +} + +interface BlockDetailsSidebarProps { + block: BlockState + executionData?: ExecutionData + /** All block execution data for resolving variable references */ + allBlockExecutions?: Record + /** All workflow blocks for mapping block names to IDs */ + workflowBlocks?: Record + /** When true, shows "Not Executed" badge if no executionData is provided */ + isExecutionMode?: boolean + /** Optional close handler - if not provided, no close button is shown */ + onClose?: () => void +} + +/** + * Format duration for display + */ +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +/** + * Readonly sidebar panel showing block configuration using SubBlock components. + */ +function BlockDetailsSidebarContent({ + block, + executionData, + allBlockExecutions, + workflowBlocks, + isExecutionMode = false, + onClose, +}: BlockDetailsSidebarProps) { + const blockConfig = getBlock(block.type) as BlockConfig | undefined + const subBlockValues = block.subBlocks || {} + + const blockNameToId = useMemo(() => { + const map = new Map() + if (workflowBlocks) { + for (const [blockId, blockData] of Object.entries(workflowBlocks)) { + if (blockData.name) { + map.set(normalizeName(blockData.name), blockId) + } + } + } + return map + }, [workflowBlocks]) + + const resolveReference = useMemo(() => { + return (reference: string): unknown => { + if (!allBlockExecutions || !workflowBlocks) return undefined + + const inner = reference.slice(1, -1) // Remove < and > + const parts = inner.split('.') + if (parts.length < 1) return undefined + + const [blockName, ...pathParts] = parts + const normalizedBlockName = normalizeName(blockName) + + const blockId = blockNameToId.get(normalizedBlockName) + if (!blockId) return undefined + + const blockExecution = allBlockExecutions[blockId] + if (!blockExecution?.output) return undefined + + if (pathParts.length === 0) { + return blockExecution.output + } + + return navigatePath(blockExecution.output, pathParts) + } + }, [allBlockExecutions, workflowBlocks, blockNameToId]) + + // Group resolved variables by source block for display + const resolvedConnections = useMemo((): ResolvedConnection[] => { + if (!allBlockExecutions || !workflowBlocks) return [] + + const allRefs = extractAllReferencesFromSubBlocks(subBlockValues) + const seen = new Set() + const blockMap = new Map() + + for (const ref of allRefs) { + if (seen.has(ref)) continue + + // Parse reference: + const inner = ref.slice(1, -1) + const parts = inner.split('.') + if (parts.length < 1) continue + + const [blockName, ...pathParts] = parts + const normalizedBlockName = normalizeName(blockName) + const blockId = blockNameToId.get(normalizedBlockName) + if (!blockId) continue + + const sourceBlock = workflowBlocks[blockId] + if (!sourceBlock) continue + + const resolvedValue = resolveReference(ref) + if (resolvedValue === undefined) continue + + seen.add(ref) + + // Get or create block entry + if (!blockMap.has(blockId)) { + blockMap.set(blockId, { + blockId, + blockName: sourceBlock.name || blockName, + blockType: sourceBlock.type, + fields: [], + }) + } + + const connection = blockMap.get(blockId)! + connection.fields.push({ + path: pathParts.join('.') || 'output', + value: formatInlineValue(resolvedValue), + tag: ref, + }) + } + + return Array.from(blockMap.values()) + }, [subBlockValues, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference]) + + if (!blockConfig) { + return ( +
+
+
+ + {block.name || 'Unknown Block'} + +
+
+

Block configuration not found.

+
+
+ ) + } + + const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => { + if (subBlock.hidden || subBlock.hideFromPreview) return false + if (subBlock.mode === 'trigger') return false + if (subBlock.condition) { + return evaluateCondition(subBlock.condition, subBlockValues) + } + return true + }) + + const statusVariant = + executionData?.status === 'error' + ? 'red' + : executionData?.status === 'success' + ? 'green' + : 'gray' + + return ( +
+ {/* Header - styled like editor */} +
+
+ +
+ + {block.name || blockConfig.name} + + {block.enabled === false && ( + + Disabled + + )} + {onClose && ( + + )} +
+ + {/* Scrollable content */} +
+ {/* Not Executed Banner - shown when in execution mode but block wasn't executed */} + {isExecutionMode && !executionData && ( +
+
+ + Not Executed + +
+
+ )} + + {/* Execution Input/Output (if provided) */} + {executionData && + (executionData.input !== undefined || executionData.output !== undefined) ? ( +
+ {/* Execution Status & Duration Header */} + {(executionData.status || executionData.durationMs !== undefined) && ( +
+ {executionData.status && ( + + {executionData.status} + + )} + {executionData.durationMs !== undefined && ( + + {formatDuration(executionData.durationMs)} + + )} +
+ )} + + {/* Divider between Status/Duration and Input/Output */} + {(executionData.status || executionData.durationMs !== undefined) && + (executionData.input !== undefined || executionData.output !== undefined) && ( +
+ )} + + {/* Input Section */} + {executionData.input !== undefined && ( + + )} + + {/* Divider between Input and Output */} + {executionData.input !== undefined && executionData.output !== undefined && ( +
+ )} + + {/* Output Section */} + {executionData.output !== undefined && ( + + )} +
+ ) : null} + + {/* Subblock Values - Using SubBlock components in preview mode */} +
+ {/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */} + + {visibleSubBlocks.length > 0 ? ( +
+ {visibleSubBlocks.map((subBlockConfig, index) => ( +
+ + {index < visibleSubBlocks.length - 1 && ( +
+
+
+ )} +
+ ))} +
+ ) : ( +
+

+ No configurable fields for this block. +

+
+ )} +
+
+ + {/* Resolved Variables Section - Pinned at bottom, outside scrollable area */} + {resolvedConnections.length > 0 && ( + + )} +
+ ) +} + +/** + * Block details sidebar wrapped in ReactFlowProvider for hook compatibility. + */ +export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) { + return ( + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx similarity index 96% rename from apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx index d8986c7c47..874f1975a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx @@ -29,10 +29,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps } const IconComponent = blockConfig.icon - // Hide input handle for triggers, starters, or blocks in trigger mode const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger - // Get visible subblocks from config (no fetching, just config structure) const visibleSubBlocks = useMemo(() => { if (!blockConfig.subBlocks) return [] @@ -48,7 +46,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const hasSubBlocks = visibleSubBlocks.length > 0 const showErrorRow = !isStarterOrTrigger - // Handle styles based on orientation const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]' const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx similarity index 96% rename from apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx index a292d661ea..67befddbda 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx @@ -26,11 +26,9 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps } /** @@ -105,10 +110,9 @@ export function WorkflowPreview({ onNodeClick, lightweight = false, cursorStyle = 'grab', + executedBlocks, }: WorkflowPreviewProps) { - // Use lightweight node types for better performance in template cards const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes - // Check if the workflow state is valid const isValidWorkflowState = workflowState?.blocks && workflowState.edges const blocksStructure = useMemo(() => { @@ -178,9 +182,7 @@ export function WorkflowPreview({ const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks) - // Lightweight mode: create minimal node data for performance if (lightweight) { - // Handle loops and parallels as subflow nodes if (block.type === 'loop' || block.type === 'parallel') { nodeArray.push({ id: blockId, @@ -197,7 +199,6 @@ export function WorkflowPreview({ return } - // Regular blocks nodeArray.push({ id: blockId, type: 'workflowBlock', @@ -214,10 +215,9 @@ export function WorkflowPreview({ return } - // Full mode: create detailed node data for interactive previews if (block.type === 'loop') { nodeArray.push({ - id: block.id, + id: blockId, type: 'subflowNode', position: absolutePosition, parentId: block.data?.parentId, @@ -238,7 +238,7 @@ export function WorkflowPreview({ if (block.type === 'parallel') { nodeArray.push({ - id: block.id, + id: blockId, type: 'subflowNode', position: absolutePosition, parentId: block.data?.parentId, @@ -265,11 +265,22 @@ export function WorkflowPreview({ const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock' + let executionStatus: ExecutionStatus | undefined + if (executedBlocks) { + const blockExecution = executedBlocks[blockId] + if (blockExecution) { + executionStatus = blockExecution.status === 'error' ? 'error' : 'success' + } else { + executionStatus = 'not-executed' + } + } + nodeArray.push({ id: blockId, type: nodeType, position: absolutePosition, draggable: false, + className: executionStatus ? `execution-${executionStatus}` : undefined, data: { type: block.type, config: blockConfig, @@ -278,43 +289,9 @@ export function WorkflowPreview({ canEdit: false, isPreview: true, subBlockValues: block.subBlocks ?? {}, + executionStatus, }, }) - - if (block.type === 'loop') { - const childBlocks = Object.entries(workflowState.blocks || {}).filter( - ([_, childBlock]) => childBlock.data?.parentId === blockId - ) - - childBlocks.forEach(([childId, childBlock]) => { - const childConfig = getBlock(childBlock.type) - - if (childConfig) { - const childNodeType = childBlock.type === 'note' ? 'noteBlock' : 'workflowBlock' - - nodeArray.push({ - id: childId, - type: childNodeType, - position: { - x: block.position.x + 50, - y: block.position.y + (childBlock.position?.y || 100), - }, - data: { - type: childBlock.type, - config: childConfig, - name: childBlock.name, - blockState: childBlock, - showSubBlocks, - isChild: true, - parentId: blockId, - canEdit: false, - isPreview: true, - }, - draggable: false, - }) - } - }) - } }) return nodeArray @@ -326,21 +303,41 @@ export function WorkflowPreview({ workflowState.blocks, isValidWorkflowState, lightweight, + executedBlocks, ]) const edges: Edge[] = useMemo(() => { if (!isValidWorkflowState) return [] - return (workflowState.edges || []).map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - })) - }, [edgesStructure, workflowState.edges, isValidWorkflowState]) + return (workflowState.edges || []).map((edge) => { + let executionStatus: ExecutionStatus | undefined + if (executedBlocks) { + const sourceExecuted = executedBlocks[edge.source] + const targetExecuted = executedBlocks[edge.target] + + if (sourceExecuted && targetExecuted) { + if (targetExecuted.status === 'error') { + executionStatus = 'error' + } else { + executionStatus = 'success' + } + } else { + executionStatus = 'not-executed' + } + } + + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + data: executionStatus ? { executionStatus } : undefined, + className: executionStatus === 'not-executed' ? 'execution-not-executed' : undefined, + } + }) + }, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks]) - // Handle migrated logs that don't have complete workflow state if (!isValidWorkflowState) { return (
- {cursorStyle && ( - - )} + acc + char.charCodeAt(0), 0)) -} - -/** - * Gets a consistent color for a user based on their ID. - * The same user will always get the same color across cursors, avatars, and terminal. - * - * @param userId - The unique user identifier - * @returns A hex color string - */ -export function getUserColor(userId: string): string { - const hash = hashUserId(userId) - return USER_COLORS[hash % USER_COLORS.length] -} - -/** - * Creates a stable mapping of user IDs to color indices for a list of users. - * Useful when you need to maintain consistent color assignments across renders. - * - * @param userIds - Array of user IDs to map - * @returns Map of user ID to color index - */ -export function createUserColorMap(userIds: string[]): Map { - const colorMap = new Map() - let colorIndex = 0 - - for (const userId of userIds) { - if (!colorMap.has(userId)) { - colorMap.set(userId, colorIndex++) - } - } - - return colorMap -} diff --git a/apps/sim/components/emcn/components/checkbox/checkbox.tsx b/apps/sim/components/emcn/components/checkbox/checkbox.tsx index 6e5b6f64c7..c32ba636c3 100644 --- a/apps/sim/components/emcn/components/checkbox/checkbox.tsx +++ b/apps/sim/components/emcn/components/checkbox/checkbox.tsx @@ -23,7 +23,7 @@ import { cn } from '@/lib/core/utils/cn' * ``` */ const checkboxVariants = cva( - 'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]', + 'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]', { variants: { size: { diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index b72db43c00..19dc56a343 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -467,7 +467,12 @@ const Combobox = forwardRef( {...inputProps} /> {(overlayContent || SelectedIcon) && ( -
+
{overlayContent ? ( overlayContent ) : ( @@ -505,6 +510,7 @@ const Combobox = forwardRef( className={cn( comboboxVariants({ variant, size }), 'relative cursor-pointer items-center justify-between', + disabled && 'cursor-not-allowed opacity-50', className )} onClick={handleToggle} diff --git a/apps/sim/components/emcn/components/date-picker/date-picker.tsx b/apps/sim/components/emcn/components/date-picker/date-picker.tsx index 3196852bde..1fb2616dad 100644 --- a/apps/sim/components/emcn/components/date-picker/date-picker.tsx +++ b/apps/sim/components/emcn/components/date-picker/date-picker.tsx @@ -844,6 +844,7 @@ const DatePicker = React.forwardRef((props, ref className={cn( datePickerVariants({ variant, size }), 'relative cursor-pointer items-center justify-between', + disabled && 'cursor-not-allowed opacity-50', className )} onClick={handleTriggerClick} diff --git a/apps/sim/components/emcn/components/slider/slider.tsx b/apps/sim/components/emcn/components/slider/slider.tsx index d6ccc54b72..71a84a9120 100644 --- a/apps/sim/components/emcn/components/slider/slider.tsx +++ b/apps/sim/components/emcn/components/slider/slider.tsx @@ -16,12 +16,13 @@ export interface SliderProps extends React.ComponentPropsWithoutRef, SliderProps>( - ({ className, ...props }, ref) => ( + ({ className, disabled, ...props }, ref) => ( , React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +>(({ className, disabled, ...props }, ref) => ( { + const colorMap = new Map() + let colorIndex = 0 + + for (const userId of userIds) { + if (!colorMap.has(userId)) { + colorMap.set(userId, colorIndex++) + } + } + + return colorMap +}