diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index 0d9475126a4..35d9bbcc6f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -8,6 +8,7 @@ import clsx from 'clsx' import { Button, ChevronDown } from '@/components/emcn' import type { TraceSpan } from '@/stores/logs/filters/types' import '@/components/emcn/components/code/code.css' +import { WorkflowIcon } from '@/components/icons' import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { getBlock, getBlockByToolName } from '@/blocks' @@ -120,6 +121,14 @@ function getBlockColor(type: string): string { return '#2FA1FF' case 'api': return '#2F55FF' + case 'loop': + case 'loop-iteration': + return '#2FB3FF' + case 'parallel': + case 'parallel-iteration': + return '#FEE12B' + case 'workflow': + return '#705335' default: return '#6b7280' } @@ -134,12 +143,15 @@ function getBlockIconAndColor(type: string): { } { const lowerType = type.toLowerCase() - if (lowerType === 'loop') { + if (lowerType === 'loop' || lowerType === 'loop-iteration') { return { icon: LoopTool.icon, bgColor: LoopTool.bgColor } } - if (lowerType === 'parallel') { + if (lowerType === 'parallel' || lowerType === 'parallel-iteration') { return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor } } + if (lowerType === 'workflow') { + return { icon: WorkflowIcon, bgColor: '#705335' } + } const blockType = lowerType === 'model' ? 'agent' : lowerType const blockConfig = getBlock(blockType) @@ -289,15 +301,11 @@ function InputOutputSection({ {isExpanded && (
{isError && typeof data === 'object' && data !== null && 'error' in data ? ( -
-
Error
-
{(data as { error: string }).error}
+
+
Error
+
+ {(data as { error: string }).error} +
) : (
@@ -313,6 +321,116 @@ function InputOutputSection({ ) } +interface NestedBlockItemProps { + span: TraceSpan + parentId: string + index: number + expandedSections: Set + onToggle: (section: string) => void + workflowStartTime: number + totalDuration: number +} + +/** + * Recursive component for rendering nested blocks at any depth + */ +function NestedBlockItem({ + span, + parentId, + index, + expandedSections, + onToggle, + workflowStartTime, + totalDuration, +}: NestedBlockItemProps): React.ReactNode { + const spanId = span.id || `${parentId}-nested-${index}` + const isError = span.status === 'error' + const toolBlock = + span.type?.toLowerCase() === 'tool' && span.name ? getBlockByToolName(span.name) : null + const { icon: SpanIcon, bgColor } = toolBlock + ? { icon: toolBlock.icon, bgColor: toolBlock.bgColor } + : getBlockIconAndColor(span.type) + + return ( +
+
+
+
+ {SpanIcon && } +
+ + {span.name} + +
+ + {formatDuration(span.duration || 0)} + +
+ + + + {span.input && ( + + )} + + {span.input && span.output && ( +
+ )} + + {span.output && ( + + )} + + {/* Recursively render children */} + {span.children && span.children.length > 0 && ( +
+ {span.children.map((child, childIndex) => ( + + ))} +
+ )} +
+ ) +} + interface TraceSpanItemProps { span: TraceSpan totalDuration: number @@ -346,11 +464,22 @@ function TraceSpanItem({ const hasOutput = Boolean(span.output) const isError = span.status === 'error' - const inlineChildTypes = new Set(['tool', 'model']) - const inlineChildren = - span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || [] - const otherChildren = - span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || [] + const inlineChildTypes = new Set([ + 'tool', + 'model', + 'loop-iteration', + 'parallel-iteration', + 'workflow', + ]) + + // For workflow-in-workflow blocks, all children should be rendered inline/nested + const isWorkflowBlock = span.type?.toLowerCase() === 'workflow' + const inlineChildren = isWorkflowBlock + ? span.children || [] + : span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || [] + const otherChildren = isWorkflowBlock + ? [] + : span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || [] const toolCallSpans = useMemo(() => { if (!hasToolCalls) return [] @@ -502,7 +631,14 @@ function TraceSpanItem({ 0) + ? childSpan.children + : undefined + } workflowStartTime={workflowStartTime} totalDuration={totalDuration} /> @@ -534,6 +670,29 @@ function TraceSpanItem({ onToggle={handleSectionToggle} /> )} + + {/* Render nested blocks for loop/parallel iterations, nested workflows, and workflow block children */} + {(childSpan.type?.toLowerCase() === 'loop-iteration' || + childSpan.type?.toLowerCase() === 'parallel-iteration' || + childSpan.type?.toLowerCase() === 'workflow' || + isWorkflowBlock) && + childSpan.children && + childSpan.children.length > 0 && ( +
+ {childSpan.children.map((nestedChild, nestedIndex) => ( + + ))} +
+ )}
) 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 f698ed050c0..a89fa9091e8 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 @@ -6,10 +6,12 @@ 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 { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' import type { LogStatus } from '@/app/workspace/[workspaceId]/logs/utils' import { formatDate, StatusBadge, TriggerBadge } from '@/app/workspace/[workspaceId]/logs/utils' import { formatCost } from '@/providers/utils' import type { WorkflowLog } from '@/stores/logs/filters/types' +import { useLogDetailsUIStore } from '@/stores/logs/store' interface LogDetailsProps { /** The log to display details for */ @@ -45,6 +47,8 @@ export function LogDetails({ }: LogDetailsProps) { const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false) const scrollAreaRef = useRef(null) + const panelWidth = useLogDetailsUIStore((state) => state.panelWidth) + const { handleMouseDown } = useLogDetailsResize() useEffect(() => { if (scrollAreaRef.current) { @@ -103,234 +107,255 @@ export function LogDetails({ }, [log]) return ( -
- {log && ( -
- {/* Header */} -
-

Log Details

-
- - - -
-
+ <> + {/* Resize Handle - positioned outside the panel */} + {isOpen && ( +
+ )} - {/* Content - Scrollable */} - -
- {/* Timestamp & Workflow Row */} -
- {/* Timestamp Card */} -
-
- Timestamp -
-
- - {formattedTimestamp?.compactDate || 'N/A'} - - - {formattedTimestamp?.compactTime || 'N/A'} - -
-
+
+ {log && ( +
+ {/* Header */} +
+

Log Details

+
+ + + +
+
- {/* Workflow Card */} - {log.workflow && ( -
+ {/* Content - Scrollable */} + +
+ {/* Timestamp & Workflow Row */} +
+ {/* Timestamp Card */} +
- Workflow + Timestamp
-
-
+
- {log.workflow.name} + {formattedTimestamp?.compactDate || 'N/A'} + + + {formattedTimestamp?.compactTime || 'N/A'}
- )} -
- {/* Execution ID */} - {log.executionId && ( -
- - Execution ID - - - {log.executionId} - + {/* Workflow Card */} + {log.workflow && ( +
+
+ Workflow +
+
+
+ + {log.workflow.name} + +
+
+ )}
- )} - {/* Details Section */} -
- {/* Level */} -
- Level - -
+ {/* Execution ID */} + {log.executionId && ( +
+ + Execution ID + + + {log.executionId} + +
+ )} - {/* Trigger */} -
- - Trigger - - {log.trigger ? ( - - ) : ( - - )} -
+ {/* Details Section */} +
+ {/* Level */} +
+ + Level + + +
- {/* Duration */} -
- - Duration - - - {log.duration || '—'} - -
-
+ {/* Trigger */} +
+ + Trigger + + {log.trigger ? ( + + ) : ( + + — + + )} +
- {/* Workflow State */} - {isWorkflowExecutionLog && log.executionId && ( -
- - Workflow State - - + + {log.duration || '—'} + +
- )} - {/* Workflow Execution - Trace Spans */} - {isWorkflowExecutionLog && log.executionData?.traceSpans && ( - - )} + {/* Workflow State */} + {isWorkflowExecutionLog && log.executionId && ( +
+ + Workflow State + + +
+ )} - {/* Files */} - {log.files && log.files.length > 0 && } + {/* Workflow Execution - Trace Spans */} + {isWorkflowExecutionLog && log.executionData?.traceSpans && ( + + )} - {/* Cost Breakdown */} - {hasCostInfo && ( -
- - Cost Breakdown - + {/* Files */} + {log.files && log.files.length > 0 && ( + + )} -
-
-
- - Base Execution: - - - {formatCost(BASE_EXECUTION_CHARGE)} - -
-
- - Model Input: - - - {formatCost(log.cost?.input || 0)} - -
-
- - Model Output: - - - {formatCost(log.cost?.output || 0)} - + {/* Cost Breakdown */} + {hasCostInfo && ( +
+ + Cost Breakdown + + +
+
+
+ + Base Execution: + + + {formatCost(BASE_EXECUTION_CHARGE)} + +
+
+ + Model Input: + + + {formatCost(log.cost?.input || 0)} + +
+
+ + Model Output: + + + {formatCost(log.cost?.output || 0)} + +
-
-
+
-
-
- - Total: - - - {formatCost(log.cost?.total || 0)} - -
-
- - Tokens: - - - {log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '} - out - +
+
+ + Total: + + + {formatCost(log.cost?.total || 0)} + +
+
+ + Tokens: + + + {log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '} + out + +
-
-
-

- Total cost includes a base execution charge of{' '} - {formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs. -

+
+

+ Total cost includes a base execution charge of{' '} + {formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs. +

+
-
- )} -
- -
- )} + )} +
+ +
+ )} - {/* Frozen Canvas Modal */} - {log?.executionId && ( - setIsFrozenCanvasOpen(false)} - /> - )} -
+ {/* Frozen Canvas Modal */} + {log?.executionId && ( + setIsFrozenCanvasOpen(false)} + /> + )} +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/index.ts new file mode 100644 index 00000000000..934fac5db7c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/index.ts @@ -0,0 +1,2 @@ +export { useLogDetailsResize } from './use-log-details-resize' +export { useSearchState } from './use-search-state' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-details-resize.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-details-resize.ts new file mode 100644 index 00000000000..a05cd83c444 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-details-resize.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from 'react' +import { + MAX_LOG_DETAILS_WIDTH, + MIN_LOG_DETAILS_WIDTH, + useLogDetailsUIStore, +} from '@/stores/logs/store' + +/** + * Hook for handling log details panel resize via mouse drag. + * @returns Resize state and mouse event handler. + */ +export function useLogDetailsResize() { + const setPanelWidth = useLogDetailsUIStore((state) => state.setPanelWidth) + const setIsResizing = useLogDetailsUIStore((state) => state.setIsResizing) + const [isResizing, setLocalIsResizing] = useState(false) + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + setLocalIsResizing(true) + setIsResizing(true) + }, + [setIsResizing] + ) + + useEffect(() => { + if (!isResizing) return + + const handleMouseMove = (e: MouseEvent) => { + // Calculate new width from right edge of window + const newWidth = window.innerWidth - e.clientX + const clampedWidth = Math.max( + MIN_LOG_DETAILS_WIDTH, + Math.min(newWidth, MAX_LOG_DETAILS_WIDTH) + ) + + setPanelWidth(clampedWidth) + } + + const handleMouseUp = () => { + setLocalIsResizing(false) + setIsResizing(false) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + }, [isResizing, setPanelWidth, setIsResizing]) + + return { + isResizing, + handleMouseDown, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 86ba275832f..6f8ffdea212 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -107,6 +107,33 @@ export default function Logs() { } }, [debouncedSearchQuery, setStoreSearchQuery]) + // Sync selected log with refreshed data from logs list + useEffect(() => { + if (!selectedLog?.id || logs.length === 0) return + + const updatedLog = logs.find((l) => l.id === selectedLog.id) + if (updatedLog) { + // Update selectedLog with fresh data from the list + setSelectedLog(updatedLog) + // Update index in case position changed + const newIndex = logs.findIndex((l) => l.id === selectedLog.id) + if (newIndex !== selectedLogIndex) { + setSelectedLogIndex(newIndex) + } + } + }, [logs, selectedLog?.id, selectedLogIndex]) + + // Refetch log details during live mode + useEffect(() => { + if (!isLive || !selectedLog?.id) return + + const interval = setInterval(() => { + logDetailQuery.refetch() + }, 5000) + + return () => clearInterval(interval) + }, [isLive, selectedLog?.id, logDetailQuery]) + const handleLogClick = (log: WorkflowLog) => { // If clicking on the same log that's already selected and sidebar is open, close it if (selectedLog?.id === log.id && isSidebarOpen) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx index 382974d1988..5c5a660341d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx @@ -836,8 +836,7 @@ try { <> { if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) { e.preventDefault() diff --git a/apps/sim/stores/logs/store.ts b/apps/sim/stores/logs/store.ts new file mode 100644 index 00000000000..3fa3514396b --- /dev/null +++ b/apps/sim/stores/logs/store.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +/** + * Width constraints for the log details panel. + */ +export const MIN_LOG_DETAILS_WIDTH = 340 +export const MAX_LOG_DETAILS_WIDTH = 700 +export const DEFAULT_LOG_DETAILS_WIDTH = 340 + +/** + * Log details UI state persisted across sessions. + */ +interface LogDetailsUIState { + panelWidth: number + setPanelWidth: (width: number) => void + isResizing: boolean + setIsResizing: (isResizing: boolean) => void +} + +export const useLogDetailsUIStore = create()( + persist( + (set) => ({ + panelWidth: DEFAULT_LOG_DETAILS_WIDTH, + /** + * Updates the log details panel width, enforcing min/max constraints. + * @param width - Desired width in pixels for the panel. + */ + setPanelWidth: (width) => { + const clampedWidth = Math.max(MIN_LOG_DETAILS_WIDTH, Math.min(width, MAX_LOG_DETAILS_WIDTH)) + set({ panelWidth: clampedWidth }) + }, + isResizing: false, + /** + * Updates the resize state flag. + * @param isResizing - True while the panel is being resized via mouse drag. + */ + setIsResizing: (isResizing) => { + set({ isResizing }) + }, + }), + { + name: 'log-details-ui-state', + } + ) +)