- {/* Left Section - Logs Table */}
+ {/* Left Section - Logs */}
{/* Header */}
- {uniqueBlocks.length > 0 ? (
-
-
-
-
-
- e.stopPropagation()}
- minWidth={120}
- maxWidth={200}
- >
-
- {uniqueBlocks.map((block, index) => {
- const BlockIcon = getBlockIcon(block.blockType)
- const isSelected = filters.blockIds.has(block.blockId)
+ {/* Left side - Logs label */}
+ Logs
- return (
- toggleBlock(block.blockId)}
- className={index > 0 ? 'mt-[2px]' : ''}
- >
- {BlockIcon && }
- {block.blockName}
-
- )
- })}
-
-
-
-
- ) : (
-
- )}
- {hasStatusEntries ? (
-
-
-
-
-
- e.stopPropagation()}
- style={{ minWidth: '120px', maxWidth: '120px' }}
- >
-
- toggleStatus('error')}
- >
-
- Error
-
- toggleStatus('info')}
- className='mt-[2px]'
- >
-
- Info
-
-
-
-
-
- ) : (
-
- )}
- {uniqueRunIds.length > 0 ? (
-
-
-
-
-
- e.stopPropagation()}
- style={{ minWidth: '90px', maxWidth: '90px' }}
- >
-
- {uniqueRunIds.map((runId, index) => {
- const isSelected = filters.runIds.has(runId)
- const runIdColor = getRunIdColor(runId, executionColorMap)
-
- return (
- toggleRunId(runId)}
- className={index > 0 ? 'mt-[2px]' : ''}
- >
-
- {formatRunId(runId)}
-
-
- )
- })}
-
-
-
-
- ) : (
-
- )}
-
- {allWorkflowEntries.length > 0 ? (
-
-
-
- ) : (
-
- )}
+ {/* Right side - Filters and icons */}
{!selectedEntry && (
-
+
+ {/* Unified filter popover */}
+ {allWorkflowEntries.length > 0 && (
+
+ )}
+
+ {/* Sort toggle */}
+ {allWorkflowEntries.length > 0 && (
+
+
+
+
+
+ Sort by time
+
+
+ )}
+
{isPlaygroundEnabled && (
@@ -1138,6 +1422,7 @@ export const Terminal = memo(function Terminal() {
)}
+
{shouldShowTrainingButton && (
@@ -1162,6 +1447,7 @@ export const Terminal = memo(function Terminal() {
)}
+
{hasActiveFilters && (
@@ -1182,6 +1468,7 @@ export const Terminal = memo(function Terminal() {
)}
+
{filteredEntries.length > 0 && (
<>
@@ -1216,6 +1503,7 @@ export const Terminal = memo(function Terminal() {
>
)}
+
+
{
@@ -1261,98 +1550,25 @@ export const Terminal = memo(function Terminal() {
)}
- {/* Rows */}
-
- {filteredEntries.length === 0 ? (
+ {/* Execution list */}
+
+ {executionGroups.length === 0 ? (
No logs yet
) : (
- filteredEntries.map((entry) => {
- const statusInfo = getStatusInfo(entry.success, entry.error)
- const isSelected = selectedEntry?.id === entry.id
- const BlockIcon = getBlockIcon(entry.blockType)
- const runIdColor = getRunIdColor(entry.executionId, executionColorMap)
-
- return (
-
handleRowClick(entry)}
- onContextMenu={(e) => handleRowContextMenu(e, entry)}
- >
- {/* Block */}
-
- {BlockIcon && (
-
- )}
- {entry.blockName}
-
-
- {/* Status */}
-
- {statusInfo ? (
-
- {statusInfo.label}
-
- ) : (
- -
- )}
-
-
- {/* Run ID */}
-
- {formatRunId(entry.executionId)}
-
-
- {/* Duration */}
-
- {formatDuration(entry.durationMs)}
-
-
- {/* Timestamp */}
-
- {formatTimeWithSeconds(new Date(entry.timestamp))}
-
-
- )
- })
+ executionGroups.map((group) => (
+
handleToggleExecution(group.executionId)}
+ selectedEntryId={selectedEntry?.id || null}
+ onSelectEntry={handleSelectEntry}
+ expandedNodes={expandedNodes}
+ onToggleNode={handleToggleNode}
+ />
+ ))
)}
@@ -1361,7 +1577,6 @@ export const Terminal = memo(function Terminal() {
{selectedEntry && (
)}
@@ -1403,7 +1617,7 @@ export const Terminal = memo(function Terminal() {
position={logRowMenuPosition}
menuRef={logRowMenuRef}
onClose={closeLogRowMenu}
- entry={contextMenuEntry}
+ entry={selectedEntry}
filters={filters}
onFilterByBlock={handleFilterByBlock}
onFilterByStatus={handleFilterByStatus}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx
new file mode 100644
index 000000000..35c8607a4
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.tsx
@@ -0,0 +1,111 @@
+'use client'
+
+import { memo } from 'react'
+import { Badge } from '@/components/emcn'
+
+/**
+ * Terminal filter configuration state
+ */
+export interface TerminalFilters {
+ blockIds: Set
+ statuses: Set<'error' | 'info'>
+ runIds: Set
+}
+
+/**
+ * Context menu position for positioning floating menus
+ */
+export interface ContextMenuPosition {
+ x: number
+ y: number
+}
+
+/**
+ * Sort field options for terminal entries
+ */
+export type SortField = 'timestamp'
+
+/**
+ * Sort direction options
+ */
+export type SortDirection = 'asc' | 'desc'
+
+/**
+ * Sort configuration for terminal entries
+ */
+export interface SortConfig {
+ field: SortField
+ direction: SortDirection
+}
+
+/**
+ * Status type for console entries
+ */
+export type EntryStatus = 'error' | 'info'
+
+/**
+ * Block information for filters
+ */
+export interface BlockInfo {
+ blockId: string
+ blockName: string
+ blockType: string
+}
+
+/**
+ * Common row styling classes for terminal components
+ */
+export const ROW_STYLES = {
+ base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[6px]',
+ selected: 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]',
+ hover: 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
+ nested:
+ 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
+ iconButton: '!p-1.5 -m-1.5',
+} as const
+
+/**
+ * Common badge styling for status badges
+ */
+export const BADGE_STYLES = {
+ base: 'rounded-[4px] px-[4px] py-[0px] text-[11px]',
+ mono: 'rounded-[4px] px-[4px] py-[0px] font-mono text-[11px]',
+} as const
+
+/**
+ * Running badge component - displays a consistent "Running" indicator
+ */
+export const RunningBadge = memo(function RunningBadge() {
+ return (
+
+ Running
+
+ )
+})
+
+/**
+ * Props for StatusDisplay component
+ */
+export interface StatusDisplayProps {
+ isRunning: boolean
+ isCanceled: boolean
+ formattedDuration: string
+}
+
+/**
+ * Reusable status display for terminal rows.
+ * Shows Running badge, 'canceled' text, or formatted duration.
+ */
+export const StatusDisplay = memo(function StatusDisplay({
+ isRunning,
+ isCanceled,
+ formattedDuration,
+}: StatusDisplayProps) {
+ if (isRunning) {
+ return
+ }
+ if (isCanceled) {
+ return <>canceled>
+ }
+ return <>{formattedDuration}>
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
new file mode 100644
index 000000000..c0a9dfca2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
@@ -0,0 +1,488 @@
+'use client'
+
+import type React from 'react'
+import { RepeatIcon, SplitIcon } from 'lucide-react'
+import { getBlock } from '@/blocks'
+import type { ConsoleEntry } from '@/stores/terminal'
+
+/**
+ * Subflow colors matching the subflow tool configs
+ */
+const SUBFLOW_COLORS = {
+ loop: '#2FB3FF',
+ parallel: '#FEE12B',
+} as const
+
+/**
+ * Run ID color palette for visual distinction between executions
+ */
+export const RUN_ID_COLORS = [
+ '#4ADE80', // Green
+ '#F472B6', // Pink
+ '#60C5FF', // Blue
+ '#FF8533', // Orange
+ '#C084FC', // Purple
+ '#EAB308', // Yellow
+ '#2DD4BF', // Teal
+ '#FB7185', // Rose
+] as const
+
+/**
+ * Retrieves the icon component for a given block type
+ */
+export function getBlockIcon(
+ blockType: string
+): React.ComponentType<{ className?: string }> | null {
+ const blockConfig = getBlock(blockType)
+
+ if (blockConfig?.icon) {
+ return blockConfig.icon
+ }
+
+ if (blockType === 'loop') {
+ return RepeatIcon
+ }
+
+ if (blockType === 'parallel') {
+ return SplitIcon
+ }
+
+ return null
+}
+
+/**
+ * Gets the background color for a block type
+ */
+export function getBlockColor(blockType: string): string {
+ const blockConfig = getBlock(blockType)
+ if (blockConfig?.bgColor) {
+ return blockConfig.bgColor
+ }
+ // Use proper subflow colors matching the toolbar configs
+ if (blockType === 'loop') {
+ return SUBFLOW_COLORS.loop
+ }
+ if (blockType === 'parallel') {
+ return SUBFLOW_COLORS.parallel
+ }
+ return '#6b7280'
+}
+
+/**
+ * Formats duration from milliseconds to readable format
+ */
+export function formatDuration(ms?: number): string {
+ if (ms === undefined || ms === null) return '-'
+ if (ms < 1000) return `${ms}ms`
+ return `${(ms / 1000).toFixed(2)}s`
+}
+
+/**
+ * Truncates execution ID for display as run ID
+ */
+export function formatRunId(executionId?: string): string {
+ if (!executionId) return '-'
+ return executionId.slice(0, 8)
+}
+
+/**
+ * Gets color for a run ID from the precomputed color map
+ */
+export function getRunIdColor(
+ executionId: string | undefined,
+ colorMap: Map
+): string | null {
+ if (!executionId) return null
+ return colorMap.get(executionId) ?? null
+}
+
+/**
+ * Determines if a keyboard event originated from a text-editable element
+ */
+export function isEventFromEditableElement(e: KeyboardEvent): boolean {
+ const target = e.target as HTMLElement | null
+ if (!target) return false
+
+ const isEditable = (el: HTMLElement | null): boolean => {
+ if (!el) return false
+ if (el instanceof HTMLInputElement) return true
+ if (el instanceof HTMLTextAreaElement) return true
+ if ((el as HTMLElement).isContentEditable) return true
+ const role = el.getAttribute('role')
+ if (role === 'textbox' || role === 'combobox') return true
+ return false
+ }
+
+ let el: HTMLElement | null = target
+ while (el) {
+ if (isEditable(el)) return true
+ el = el.parentElement
+ }
+ return false
+}
+
+/**
+ * Checks if a block type is a subflow (loop or parallel)
+ */
+export function isSubflowBlockType(blockType: string): boolean {
+ const lower = blockType?.toLowerCase() || ''
+ return lower === 'loop' || lower === 'parallel'
+}
+
+/**
+ * Node type for the tree structure
+ */
+export type EntryNodeType = 'block' | 'subflow' | 'iteration'
+
+/**
+ * Entry node for tree structure - represents a block, subflow, or iteration
+ */
+export interface EntryNode {
+ /** The console entry (for blocks) or synthetic entry (for subflows/iterations) */
+ entry: ConsoleEntry
+ /** Child nodes */
+ children: EntryNode[]
+ /** Node type */
+ nodeType: EntryNodeType
+ /** Iteration info for iteration nodes */
+ iterationInfo?: {
+ current: number
+ total?: number
+ }
+}
+
+/**
+ * Execution group interface for grouping entries by execution
+ */
+export interface ExecutionGroup {
+ executionId: string
+ startTime: string
+ endTime: string
+ startTimeMs: number
+ endTimeMs: number
+ duration: number
+ status: 'success' | 'error'
+ /** Flat list of entries (legacy, kept for filters) */
+ entries: ConsoleEntry[]
+ /** Tree structure of entry nodes for nested display */
+ entryTree: EntryNode[]
+}
+
+/**
+ * Iteration group for grouping blocks within the same iteration
+ */
+interface IterationGroup {
+ iterationType: string
+ iterationCurrent: number
+ iterationTotal?: number
+ blocks: ConsoleEntry[]
+ startTimeMs: number
+}
+
+/**
+ * Builds a tree structure from flat entries.
+ * Groups iteration entries by (iterationType, iterationCurrent), showing all blocks
+ * that executed within each iteration.
+ * Sorts by start time to ensure chronological order.
+ */
+function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
+ // Separate regular blocks from iteration entries
+ const regularBlocks: ConsoleEntry[] = []
+ const iterationEntries: ConsoleEntry[] = []
+
+ for (const entry of entries) {
+ if (entry.iterationType && entry.iterationCurrent !== undefined) {
+ iterationEntries.push(entry)
+ } else {
+ regularBlocks.push(entry)
+ }
+ }
+
+ // Group iteration entries by (iterationType, iterationCurrent)
+ const iterationGroupsMap = new Map()
+ for (const entry of iterationEntries) {
+ const key = `${entry.iterationType}-${entry.iterationCurrent}`
+ let group = iterationGroupsMap.get(key)
+ const entryStartMs = new Date(entry.startedAt || entry.timestamp).getTime()
+
+ if (!group) {
+ group = {
+ iterationType: entry.iterationType!,
+ iterationCurrent: entry.iterationCurrent!,
+ iterationTotal: entry.iterationTotal,
+ blocks: [],
+ startTimeMs: entryStartMs,
+ }
+ iterationGroupsMap.set(key, group)
+ } else {
+ // Update start time to earliest
+ if (entryStartMs < group.startTimeMs) {
+ group.startTimeMs = entryStartMs
+ }
+ // Update total if available
+ if (entry.iterationTotal !== undefined) {
+ group.iterationTotal = entry.iterationTotal
+ }
+ }
+ group.blocks.push(entry)
+ }
+
+ // Sort blocks within each iteration by start time ascending (oldest first, top-down)
+ for (const group of iterationGroupsMap.values()) {
+ group.blocks.sort((a, b) => {
+ const aStart = new Date(a.startedAt || a.timestamp).getTime()
+ const bStart = new Date(b.startedAt || b.timestamp).getTime()
+ return aStart - bStart
+ })
+ }
+
+ // Group iterations by iterationType to create subflow parents
+ const subflowGroups = new Map()
+ for (const group of iterationGroupsMap.values()) {
+ const type = group.iterationType
+ let groups = subflowGroups.get(type)
+ if (!groups) {
+ groups = []
+ subflowGroups.set(type, groups)
+ }
+ groups.push(group)
+ }
+
+ // Sort iterations within each subflow by iteration number
+ for (const groups of subflowGroups.values()) {
+ groups.sort((a, b) => a.iterationCurrent - b.iterationCurrent)
+ }
+
+ // Build subflow nodes with iteration children
+ const subflowNodes: EntryNode[] = []
+ for (const [iterationType, iterationGroups] of subflowGroups.entries()) {
+ // Calculate subflow timing from all its iterations
+ const firstIteration = iterationGroups[0]
+ const allBlocks = iterationGroups.flatMap((g) => g.blocks)
+ const subflowStartMs = Math.min(
+ ...allBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime())
+ )
+ const subflowEndMs = Math.max(
+ ...allBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
+ )
+ const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
+
+ // Create synthetic subflow parent entry
+ const syntheticSubflow: ConsoleEntry = {
+ id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`,
+ timestamp: new Date(subflowStartMs).toISOString(),
+ workflowId: firstIteration.blocks[0]?.workflowId || '',
+ blockId: `${iterationType}-container`,
+ blockName: iterationType.charAt(0).toUpperCase() + iterationType.slice(1),
+ blockType: iterationType,
+ executionId: firstIteration.blocks[0]?.executionId,
+ startedAt: new Date(subflowStartMs).toISOString(),
+ endedAt: new Date(subflowEndMs).toISOString(),
+ durationMs: totalDuration,
+ success: !allBlocks.some((b) => b.error),
+ }
+
+ // Build iteration child nodes
+ const iterationNodes: EntryNode[] = iterationGroups.map((iterGroup) => {
+ // Create synthetic iteration entry
+ const iterBlocks = iterGroup.blocks
+ const iterStartMs = Math.min(
+ ...iterBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime())
+ )
+ const iterEndMs = Math.max(
+ ...iterBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
+ )
+ const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
+
+ const syntheticIteration: ConsoleEntry = {
+ id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`,
+ timestamp: new Date(iterStartMs).toISOString(),
+ workflowId: iterBlocks[0]?.workflowId || '',
+ blockId: `iteration-${iterGroup.iterationCurrent}`,
+ blockName: `Iteration ${iterGroup.iterationCurrent}${iterGroup.iterationTotal !== undefined ? ` / ${iterGroup.iterationTotal}` : ''}`,
+ blockType: iterationType,
+ executionId: iterBlocks[0]?.executionId,
+ startedAt: new Date(iterStartMs).toISOString(),
+ endedAt: new Date(iterEndMs).toISOString(),
+ durationMs: iterDuration,
+ success: !iterBlocks.some((b) => b.error),
+ iterationCurrent: iterGroup.iterationCurrent,
+ iterationTotal: iterGroup.iterationTotal,
+ iterationType: iterationType as 'loop' | 'parallel',
+ }
+
+ // Block nodes within this iteration
+ const blockNodes: EntryNode[] = iterBlocks.map((block) => ({
+ entry: block,
+ children: [],
+ nodeType: 'block' as const,
+ }))
+
+ return {
+ entry: syntheticIteration,
+ children: blockNodes,
+ nodeType: 'iteration' as const,
+ iterationInfo: {
+ current: iterGroup.iterationCurrent,
+ total: iterGroup.iterationTotal,
+ },
+ }
+ })
+
+ subflowNodes.push({
+ entry: syntheticSubflow,
+ children: iterationNodes,
+ nodeType: 'subflow' as const,
+ })
+ }
+
+ // Build nodes for regular blocks
+ const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({
+ entry,
+ children: [],
+ nodeType: 'block' as const,
+ }))
+
+ // Combine all nodes and sort by start time ascending (oldest first, top-down)
+ const allNodes = [...subflowNodes, ...regularNodes]
+ allNodes.sort((a, b) => {
+ const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime()
+ const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime()
+ return aStart - bStart
+ })
+
+ return allNodes
+}
+
+/**
+ * Groups console entries by execution ID and builds a tree structure.
+ * Pre-computes timestamps for efficient sorting.
+ */
+export function groupEntriesByExecution(entries: ConsoleEntry[]): ExecutionGroup[] {
+ const groups = new Map<
+ string,
+ { meta: Omit; entries: ConsoleEntry[] }
+ >()
+
+ for (const entry of entries) {
+ const execId = entry.executionId || entry.id
+
+ const entryStartTime = entry.startedAt || entry.timestamp
+ const entryEndTime = entry.endedAt || entry.timestamp
+ const entryStartMs = new Date(entryStartTime).getTime()
+ const entryEndMs = new Date(entryEndTime).getTime()
+
+ let group = groups.get(execId)
+
+ if (!group) {
+ group = {
+ meta: {
+ executionId: execId,
+ startTime: entryStartTime,
+ endTime: entryEndTime,
+ startTimeMs: entryStartMs,
+ endTimeMs: entryEndMs,
+ duration: 0,
+ status: 'success',
+ entries: [],
+ },
+ entries: [],
+ }
+ groups.set(execId, group)
+ } else {
+ // Update timing bounds
+ if (entryStartMs < group.meta.startTimeMs) {
+ group.meta.startTime = entryStartTime
+ group.meta.startTimeMs = entryStartMs
+ }
+ if (entryEndMs > group.meta.endTimeMs) {
+ group.meta.endTime = entryEndTime
+ group.meta.endTimeMs = entryEndMs
+ }
+ }
+
+ // Check for errors
+ if (entry.error) {
+ group.meta.status = 'error'
+ }
+
+ group.entries.push(entry)
+ }
+
+ // Build tree structure for each group
+ const result: ExecutionGroup[] = []
+ for (const group of groups.values()) {
+ group.meta.duration = group.meta.endTimeMs - group.meta.startTimeMs
+ group.meta.entries = group.entries
+ result.push({
+ ...group.meta,
+ entryTree: buildEntryTree(group.entries),
+ })
+ }
+
+ // Sort by start time descending (newest first)
+ result.sort((a, b) => b.startTimeMs - a.startTimeMs)
+
+ return result
+}
+
+/**
+ * Flattens entry tree into display order for keyboard navigation
+ */
+export function flattenEntryTree(nodes: EntryNode[]): ConsoleEntry[] {
+ const result: ConsoleEntry[] = []
+ for (const node of nodes) {
+ result.push(node.entry)
+ if (node.children.length > 0) {
+ result.push(...flattenEntryTree(node.children))
+ }
+ }
+ return result
+}
+
+/**
+ * Block entry with parent tracking for navigation
+ */
+export interface NavigableBlockEntry {
+ entry: ConsoleEntry
+ executionId: string
+ /** IDs of parent nodes (subflows, iterations) that contain this block */
+ parentNodeIds: string[]
+}
+
+/**
+ * Flattens entry tree to only include actual block entries (not subflows/iterations).
+ * Also tracks parent node IDs for auto-expanding when navigating.
+ */
+export function flattenBlockEntriesOnly(
+ nodes: EntryNode[],
+ executionId: string,
+ parentIds: string[] = []
+): NavigableBlockEntry[] {
+ const result: NavigableBlockEntry[] = []
+ for (const node of nodes) {
+ if (node.nodeType === 'block') {
+ result.push({
+ entry: node.entry,
+ executionId,
+ parentNodeIds: parentIds,
+ })
+ }
+ if (node.children.length > 0) {
+ const newParentIds = node.nodeType !== 'block' ? [...parentIds, node.entry.id] : parentIds
+ result.push(...flattenBlockEntriesOnly(node.children, executionId, newParentIds))
+ }
+ }
+ return result
+}
+
+// BlockInfo is now in types.ts for shared use across terminal components
+
+/**
+ * Terminal height configuration constants
+ */
+export const TERMINAL_CONFIG = {
+ NEAR_MIN_THRESHOLD: 40,
+ BLOCK_COLUMN_WIDTH_PX: 240,
+ HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]',
+} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
index 1c0cbcc7a..dea55acdc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
@@ -81,7 +81,8 @@ export function useWorkflowExecution() {
const queryClient = useQueryClient()
const currentWorkflow = useCurrentWorkflow()
const { activeWorkflowId, workflows } = useWorkflowRegistry()
- const { toggleConsole, addConsole } = useTerminalConsoleStore()
+ const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } =
+ useTerminalConsoleStore()
const { getAllVariables } = useEnvironmentStore()
const { getVariablesByWorkflowId, variables } = useVariablesStore()
const {
@@ -867,6 +868,8 @@ export function useWorkflowExecution() {
if (activeWorkflowId) {
logger.info('Using server-side executor')
+ const executionId = uuidv4()
+
let executionResult: ExecutionResult = {
success: false,
output: {},
@@ -910,6 +913,27 @@ export function useWorkflowExecution() {
incomingEdges.forEach((edge) => {
setEdgeRunStatus(edge.id, 'success')
})
+
+ // Add entry to terminal immediately with isRunning=true
+ const startedAt = new Date().toISOString()
+ addConsole({
+ input: {},
+ output: undefined,
+ success: undefined,
+ durationMs: undefined,
+ startedAt,
+ endedAt: undefined,
+ workflowId: activeWorkflowId,
+ blockId: data.blockId,
+ executionId,
+ blockName: data.blockName || 'Unknown Block',
+ blockType: data.blockType || 'unknown',
+ isRunning: true,
+ // Pass through iteration context for subflow grouping
+ iterationCurrent: data.iterationCurrent,
+ iterationTotal: data.iterationTotal,
+ iterationType: data.iterationType,
+ })
},
onBlockCompleted: (data) => {
@@ -940,24 +964,23 @@ export function useWorkflowExecution() {
endedAt,
})
- // Add to console
- addConsole({
- input: data.input || {},
- output: data.output,
- success: true,
- durationMs: data.durationMs,
- startedAt,
- endedAt,
- workflowId: activeWorkflowId,
- blockId: data.blockId,
- executionId: executionId || uuidv4(),
- blockName: data.blockName || 'Unknown Block',
- blockType: data.blockType || 'unknown',
- // Pass through iteration context for console pills
- iterationCurrent: data.iterationCurrent,
- iterationTotal: data.iterationTotal,
- iterationType: data.iterationType,
- })
+ // Update existing console entry (created in onBlockStarted) with completion data
+ updateConsole(
+ data.blockId,
+ {
+ input: data.input || {},
+ replaceOutput: data.output,
+ success: true,
+ durationMs: data.durationMs,
+ endedAt,
+ isRunning: false,
+ // Pass through iteration context for subflow grouping
+ iterationCurrent: data.iterationCurrent,
+ iterationTotal: data.iterationTotal,
+ iterationType: data.iterationType,
+ },
+ executionId
+ )
// Call onBlockComplete callback if provided
if (onBlockComplete) {
@@ -992,25 +1015,24 @@ export function useWorkflowExecution() {
endedAt,
})
- // Add error to console
- addConsole({
- input: data.input || {},
- output: {},
- success: false,
- error: data.error,
- durationMs: data.durationMs,
- startedAt,
- endedAt,
- workflowId: activeWorkflowId,
- blockId: data.blockId,
- executionId: executionId || uuidv4(),
- blockName: data.blockName,
- blockType: data.blockType,
- // Pass through iteration context for console pills
- iterationCurrent: data.iterationCurrent,
- iterationTotal: data.iterationTotal,
- iterationType: data.iterationType,
- })
+ // Update existing console entry (created in onBlockStarted) with error data
+ updateConsole(
+ data.blockId,
+ {
+ input: data.input || {},
+ replaceOutput: {},
+ success: false,
+ error: data.error,
+ durationMs: data.durationMs,
+ endedAt,
+ isRunning: false,
+ // Pass through iteration context for subflow grouping
+ iterationCurrent: data.iterationCurrent,
+ iterationTotal: data.iterationTotal,
+ iterationType: data.iterationType,
+ },
+ executionId
+ )
},
onStreamChunk: (data) => {
@@ -1089,7 +1111,7 @@ export function useWorkflowExecution() {
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: 'validation',
- executionId: executionId || uuidv4(),
+ executionId,
blockName: 'Workflow Validation',
blockType: 'validation',
})
@@ -1358,6 +1380,11 @@ export function useWorkflowExecution() {
// Mark current chat execution as superseded so its cleanup won't affect new executions
currentChatExecutionIdRef.current = null
+ // Mark all running entries as canceled in the terminal
+ if (activeWorkflowId) {
+ cancelRunningEntries(activeWorkflowId)
+ }
+
// Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx
setIsExecuting(false)
setIsDebugging(false)
@@ -1374,6 +1401,8 @@ export function useWorkflowExecution() {
setIsExecuting,
setIsDebugging,
setActiveBlocks,
+ activeWorkflowId,
+ cancelRunningEntries,
])
return {
diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx
index 45ac92cae..58250adc1 100644
--- a/apps/sim/components/emcn/components/code/code.tsx
+++ b/apps/sim/components/emcn/components/code/code.tsx
@@ -949,6 +949,9 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
[contentRef]
)
+ const hasCollapsibleContent = collapsibleLines.size > 0
+ const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
+
const rowProps = useMemo(
() => ({
lines: visibleLines,
@@ -957,7 +960,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
gutterStyle,
leftOffset: paddingLeft,
wrapText,
- showCollapseColumn,
+ showCollapseColumn: effectiveShowCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse: toggleCollapse,
@@ -969,7 +972,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
gutterStyle,
paddingLeft,
wrapText,
- showCollapseColumn,
+ effectiveShowCollapseColumn,
collapsibleLines,
collapsedLines,
toggleCollapse,
@@ -1103,7 +1106,10 @@ function ViewerInner({
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
- const collapseColumnWidth = showCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0
+
+ const hasCollapsibleContent = collapsibleLines.size > 0
+ const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
+ const collapseColumnWidth = effectiveShowCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0
// Grid-based rendering for gutter alignment (works with wrap)
if (showGutter) {
@@ -1116,7 +1122,7 @@ function ViewerInner({
paddingTop: '8px',
paddingBottom: '8px',
display: 'grid',
- gridTemplateColumns: showCollapseColumn
+ gridTemplateColumns: effectiveShowCollapseColumn
? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
: `${gutterWidth}px 1fr`,
}}
@@ -1134,7 +1140,7 @@ function ViewerInner({
>
{lineNumber}
- {showCollapseColumn && (
+ {effectiveShowCollapseColumn && (
{isCollapsible && (
()(
: update.input
}
+ if (update.isRunning !== undefined) {
+ updatedEntry.isRunning = update.isRunning
+ }
+
+ if (update.isCanceled !== undefined) {
+ updatedEntry.isCanceled = update.isCanceled
+ }
+
+ if (update.iterationCurrent !== undefined) {
+ updatedEntry.iterationCurrent = update.iterationCurrent
+ }
+
+ if (update.iterationTotal !== undefined) {
+ updatedEntry.iterationTotal = update.iterationTotal
+ }
+
+ if (update.iterationType !== undefined) {
+ updatedEntry.iterationType = update.iterationType
+ }
+
return updatedEntry
})
return { entries: updatedEntries }
})
},
+
+ cancelRunningEntries: (workflowId: string) => {
+ set((state) => {
+ const updatedEntries = state.entries.map((entry) => {
+ if (entry.workflowId === workflowId && entry.isRunning) {
+ return {
+ ...entry,
+ isRunning: false,
+ isCanceled: true,
+ endedAt: new Date().toISOString(),
+ }
+ }
+ return entry
+ })
+ return { entries: updatedEntries }
+ })
+ },
}),
{
name: 'terminal-console-store',
diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts
index f496c7356..ca31112eb 100644
--- a/apps/sim/stores/terminal/console/types.ts
+++ b/apps/sim/stores/terminal/console/types.ts
@@ -20,6 +20,10 @@ export interface ConsoleEntry {
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
+ /** Whether this block is currently running */
+ isRunning?: boolean
+ /** Whether this block execution was canceled */
+ isCanceled?: boolean
}
export interface ConsoleUpdate {
@@ -32,6 +36,14 @@ export interface ConsoleUpdate {
endedAt?: string
durationMs?: number
input?: any
+ /** Whether this block is currently running */
+ isRunning?: boolean
+ /** Whether this block execution was canceled */
+ isCanceled?: boolean
+ /** Iteration context for subflow blocks */
+ iterationCurrent?: number
+ iterationTotal?: number
+ iterationType?: SubflowType
}
export interface ConsoleStore {
@@ -43,6 +55,7 @@ export interface ConsoleStore {
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
toggleConsole: () => void
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
+ cancelRunningEntries: (workflowId: string) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}