Compare commits

...

4 Commits

Author SHA1 Message Date
waleed
42d09ef8cb feat(terminal): show child workflow blocks as nested expandable tree
When a workflow block executes a child workflow, the terminal console
now shows each child block as a nested expandable entry under the
parent — matching the existing loop/parallel subflow pattern.

- Extract childTraceSpans from workflow block output into console entries
- Extend buildEntryTree with recursive workflow node nesting
- Add 'workflow' node type to EntryNodeRow with recursive rendering
- Shared typed utility (extractChildWorkflowEntries) for both execution paths
- Filter UI excludes synthetic child IDs; children follow parent visibility
- CSV export and error notifications skip child workflow entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:37:07 -08:00
waleed
cbb98a0868 fix(executor): resolve block ID for parallel subflow active state 2026-02-14 13:12:10 -08:00
Waleed
5b0532d473 refactor(tool-input): replace bidirectional effects with zustand subscription (#3215)
* refactor(tool-input): replace bidirectional effects with zustand subscription

* added wand for custom cron, fixed slack inconsistency

* fix slack
2026-02-14 11:19:51 -08:00
Waleed
3ef6b05035 fix(model): validate default model against available options 2026-02-13 15:16:20 -08:00
31 changed files with 613 additions and 264 deletions

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard <BlockInfoCard
type="google_books" type="google_books"
color="#FFFFFF" color="#E0E0E0"
/> />
## Usage Instructions ## Usage Instructions

View File

@@ -71,6 +71,7 @@ Retrieve an object from an AWS S3 bucket
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `accessKeyId` | string | Yes | Your AWS Access Key ID | | `accessKeyId` | string | Yes | Your AWS Access Key ID |
| `secretAccessKey` | string | Yes | Your AWS Secret Access Key | | `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
| `region` | string | No | Optional region override when URL does not include region \(e.g., us-east-1, eu-west-1\) |
| `s3Uri` | string | Yes | S3 Object URL \(e.g., https://bucket.s3.region.amazonaws.com/path/to/file\) | | `s3Uri` | string | Yes | S3 Object URL \(e.g., https://bucket.s3.region.amazonaws.com/path/to/file\) |
#### Output #### Output

View File

@@ -79,7 +79,7 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
| `channel` | string | No | Slack channel ID \(e.g., C1234567890\) | | `channel` | string | No | Slack channel ID \(e.g., C1234567890\) |
| `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) | | `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) | | `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
| `thread_ts` | string | No | Thread timestamp to reply to \(creates thread reply\) | | `threadTs` | string | No | Thread timestamp to reply to \(creates thread reply\) |
| `files` | file[] | No | Files to attach to the message | | `files` | file[] | No | Files to attach to the message |
#### Output #### Output

View File

@@ -238,6 +238,11 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg
finalSystemPrompt += currentTimeContext finalSystemPrompt += currentTimeContext
} }
if (generationType === 'cron-expression') {
finalSystemPrompt +=
'\n\nIMPORTANT: Return ONLY the raw cron expression (e.g., "0 9 * * 1-5"). Do NOT wrap it in markdown code blocks, backticks, or quotes. Do NOT include any explanation or text before or after the expression.'
}
if (generationType === 'json-object') { if (generationType === 'json-object') {
finalSystemPrompt += finalSystemPrompt +=
'\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.' '\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.'

View File

@@ -239,8 +239,13 @@ export const ComboBox = memo(function ComboBox({
*/ */
const defaultOptionValue = useMemo(() => { const defaultOptionValue = useMemo(() => {
if (defaultValue !== undefined) { if (defaultValue !== undefined) {
// Validate that the default value exists in the available (filtered) options
const defaultInOptions = evaluatedOptions.find((opt) => getOptionValue(opt) === defaultValue)
if (defaultInOptions) {
return defaultValue return defaultValue
} }
// Default not available (e.g. provider disabled) — fall through to other fallbacks
}
// For model field, default to claude-sonnet-4-5 if available // For model field, default to claude-sonnet-4-5 if available
if (subBlockId === 'model') { if (subBlockId === 'model') {

View File

@@ -1,9 +1,10 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface ToolSubBlockRendererProps { interface ToolSubBlockRendererProps {
blockId: string blockId: string
@@ -44,53 +45,43 @@ export function ToolSubBlockRenderer({
canonicalToggle, canonicalToggle,
}: ToolSubBlockRendererProps) { }: ToolSubBlockRendererProps) {
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}` const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
const toolParamValue = toolParams?.[effectiveParamId] ?? '' const toolParamValue = toolParams?.[effectiveParamId] ?? ''
const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type) const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type)
const lastPushedToStoreRef = useRef<string | null>(null) const syncedRef = useRef<string | null>(null)
const lastPushedToParamsRef = useRef<string | null>(null) const onParamChangeRef = useRef(onParamChange)
onParamChangeRef.current = onParamChange
useEffect(() => { useEffect(() => {
if (!toolParamValue && lastPushedToStoreRef.current === null) { const unsub = useSubBlockStore.subscribe((state, prevState) => {
lastPushedToStoreRef.current = toolParamValue const wfId = useWorkflowRegistry.getState().activeWorkflowId
lastPushedToParamsRef.current = toolParamValue if (!wfId) return
return const newVal = state.workflowValues[wfId]?.[blockId]?.[syntheticId]
} const oldVal = prevState.workflowValues[wfId]?.[blockId]?.[syntheticId]
if (toolParamValue !== lastPushedToStoreRef.current) { if (newVal === oldVal) return
lastPushedToStoreRef.current = toolParamValue const stringified =
lastPushedToParamsRef.current = toolParamValue newVal == null ? '' : typeof newVal === 'string' ? newVal : JSON.stringify(newVal)
if (stringified === syncedRef.current) return
syncedRef.current = stringified
onParamChangeRef.current(toolIndex, effectiveParamId, stringified)
})
return unsub
}, [blockId, syntheticId, toolIndex, effectiveParamId])
if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) { useEffect(() => {
if (toolParamValue === syncedRef.current) return
syncedRef.current = toolParamValue
if (isObjectType && toolParamValue) {
try { try {
const parsed = JSON.parse(toolParamValue) const parsed = JSON.parse(toolParamValue)
if (typeof parsed === 'object' && parsed !== null) { if (typeof parsed === 'object' && parsed !== null) {
setStoreValue(parsed) useSubBlockStore.getState().setValue(blockId, syntheticId, parsed)
return return
} }
} catch { } catch {}
// Not valid JSON — fall through to set as string
} }
} useSubBlockStore.getState().setValue(blockId, syntheticId, toolParamValue)
setStoreValue(toolParamValue) }, [toolParamValue, blockId, syntheticId, isObjectType])
}
}, [toolParamValue, setStoreValue, isObjectType])
useEffect(() => {
if (storeValue == null && lastPushedToParamsRef.current === null) return
const stringValue =
storeValue == null
? ''
: typeof storeValue === 'string'
? storeValue
: JSON.stringify(storeValue)
if (stringValue !== lastPushedToParamsRef.current) {
lastPushedToParamsRef.current = stringValue
lastPushedToStoreRef.current = stringValue
onParamChange(toolIndex, effectiveParamId, stringValue)
}
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
const visibility = subBlock.paramVisibility ?? 'user-or-llm' const visibility = subBlock.paramVisibility ?? 'user-or-llm'
const isOptionalForUser = visibility !== 'user-only' const isOptionalForUser = visibility !== 'user-only'

View File

@@ -1741,10 +1741,22 @@ export const ToolInput = memo(function ToolInput({
) : null ) : null
})()} })()}
{requiresOAuth && oauthConfig && ( {(() => {
<div className='relative min-w-0 space-y-[6px]'> const renderedElements: React.ReactNode[] = []
const showOAuth =
requiresOAuth && oauthConfig && tool.params?.authMethod !== 'bot_token'
const renderOAuthAccount = (): React.ReactNode => {
if (!showOAuth || !oauthConfig) return null
const credentialSubBlock = toolBlock?.subBlocks?.find(
(s) => s.type === 'oauth-input'
)
return (
<div key='oauth-account' className='relative min-w-0 space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'> <div className='font-medium text-[13px] text-[var(--text-primary)]'>
Account <span className='ml-0.5'>*</span> {credentialSubBlock?.title || 'Account'}{' '}
<span className='ml-0.5'>*</span>
</div> </div>
<div className='w-full min-w-0'> <div className='w-full min-w-0'>
<ToolCredentialSelector <ToolCredentialSelector
@@ -1754,8 +1766,7 @@ export const ToolInput = memo(function ToolInput({
} }
provider={oauthConfig.provider as OAuthProvider} provider={oauthConfig.provider as OAuthProvider}
requiredScopes={ requiredScopes={
toolBlock?.subBlocks?.find((sb) => sb.id === 'credential') credentialSubBlock?.requiredScopes ||
?.requiredScopes ||
getCanonicalScopesForProvider(oauthConfig.provider) getCanonicalScopesForProvider(oauthConfig.provider)
} }
serviceId={oauthConfig.provider} serviceId={oauthConfig.provider}
@@ -1763,29 +1774,10 @@ export const ToolInput = memo(function ToolInput({
/> />
</div> </div>
</div> </div>
)}
{(() => {
const renderedElements: React.ReactNode[] = []
if (useSubBlocks && displaySubBlocks.length > 0) {
const coveredParamIds = new Set(
displaySubBlocks.flatMap((sb) => {
const ids = [sb.id]
if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
if (cId) {
const group = toolCanonicalIndex?.groupsById[cId]
if (group) {
if (group.basicId) ids.push(group.basicId)
ids.push(...group.advancedIds)
}
}
return ids
})
) )
}
displaySubBlocks.forEach((sb) => { const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => {
const effectiveParamId = sb.id const effectiveParamId = sb.id
const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
const canonicalGroup = canonicalId const canonicalGroup = canonicalId
@@ -1806,8 +1798,7 @@ export const ToolInput = memo(function ToolInput({
? { ? {
mode: canonicalMode, mode: canonicalMode,
onToggle: () => { onToggle: () => {
const nextMode = const nextMode = canonicalMode === 'advanced' ? 'basic' : 'advanced'
canonicalMode === 'advanced' ? 'basic' : 'advanced'
collaborativeSetBlockCanonicalMode( collaborativeSetBlockCanonicalMode(
blockId, blockId,
`${tool.type}:${canonicalId}`, `${tool.type}:${canonicalId}`,
@@ -1821,7 +1812,7 @@ export const ToolInput = memo(function ToolInput({
? sb ? sb
: { ...sb, title: formatParameterLabel(effectiveParamId) } : { ...sb, title: formatParameterLabel(effectiveParamId) }
renderedElements.push( return (
<ToolSubBlockRenderer <ToolSubBlockRenderer
key={sb.id} key={sb.id}
blockId={blockId} blockId={blockId}
@@ -1835,7 +1826,65 @@ export const ToolInput = memo(function ToolInput({
canonicalToggle={canonicalToggleProp} canonicalToggle={canonicalToggleProp}
/> />
) )
}
if (useSubBlocks && displaySubBlocks.length > 0) {
const allBlockSubBlocks = toolBlock?.subBlocks || []
const coveredParamIds = new Set(
allBlockSubBlocks.flatMap((sb) => {
const ids = [sb.id]
if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
if (cId) {
const group = toolCanonicalIndex?.groupsById[cId]
if (group) {
if (group.basicId) ids.push(group.basicId)
ids.push(...group.advancedIds)
}
}
return ids
}) })
)
type RenderItem =
| { kind: 'subblock'; sb: BlockSubBlockConfig }
| { kind: 'oauth' }
const renderOrder: RenderItem[] = displaySubBlocks.map((sb) => ({
kind: 'subblock' as const,
sb,
}))
if (showOAuth) {
const credentialIdx = allBlockSubBlocks.findIndex(
(sb) => sb.type === 'oauth-input'
)
if (credentialIdx >= 0) {
const sbPositions = new Map(allBlockSubBlocks.map((sb, i) => [sb.id, i]))
const insertAt = renderOrder.findIndex(
(item) =>
item.kind === 'subblock' &&
(sbPositions.get(item.sb.id) ?? Number.POSITIVE_INFINITY) >
credentialIdx
)
if (insertAt === -1) {
renderOrder.push({ kind: 'oauth' })
} else {
renderOrder.splice(insertAt, 0, { kind: 'oauth' })
}
} else {
renderOrder.unshift({ kind: 'oauth' })
}
}
for (const item of renderOrder) {
if (item.kind === 'oauth') {
const el = renderOAuthAccount()
if (el) renderedElements.push(el)
} else {
renderedElements.push(renderSubBlock(item.sb))
}
}
const uncoveredParams = displayParams.filter( const uncoveredParams = displayParams.filter(
(param) => (param) =>
@@ -1873,6 +1922,11 @@ export const ToolInput = memo(function ToolInput({
) )
} }
{
const el = renderOAuthAccount()
if (el) renderedElements.push(el)
}
const filteredParams = displayParams.filter((param) => const filteredParams = displayParams.filter((param) =>
evaluateParameterCondition(param, tool) evaluateParameterCondition(param, tool)
) )

View File

@@ -88,21 +88,38 @@ export function useTerminalFilters() {
let result = entries let result = entries
if (hasActiveFilters) { if (hasActiveFilters) {
result = entries.filter((entry) => { // Determine which top-level entries pass the filters
// Block ID filter const visibleBlockIds = new Set<string>()
if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) { for (const entry of entries) {
return false if (entry.parentWorkflowBlockId) continue
}
// Status filter let passes = true
if (filters.statuses.size > 0) { if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) {
passes = false
}
if (passes && filters.statuses.size > 0) {
const isError = !!entry.error const isError = !!entry.error
const hasStatus = isError ? filters.statuses.has('error') : filters.statuses.has('info') const hasStatus = isError ? filters.statuses.has('error') : filters.statuses.has('info')
if (!hasStatus) return false if (!hasStatus) passes = false
}
if (passes) {
visibleBlockIds.add(entry.blockId)
}
} }
return true // Propagate visibility to child workflow entries (handles arbitrary nesting).
}) // Keep iterating until no new children are discovered.
let prevSize = 0
while (visibleBlockIds.size !== prevSize) {
prevSize = visibleBlockIds.size
for (const entry of entries) {
if (entry.parentWorkflowBlockId && visibleBlockIds.has(entry.parentWorkflowBlockId)) {
visibleBlockIds.add(entry.blockId)
}
}
}
result = entries.filter((entry) => visibleBlockIds.has(entry.blockId))
} }
// Sort by executionOrder (monotonically increasing integer from server) // Sort by executionOrder (monotonically increasing integer from server)

View File

@@ -339,7 +339,8 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
}) })
/** /**
* Entry node component - dispatches to appropriate component based on node type * Entry node component - dispatches to appropriate component based on node type.
* Handles recursive rendering for workflow nodes with arbitrarily nested children.
*/ */
const EntryNodeRow = memo(function EntryNodeRow({ const EntryNodeRow = memo(function EntryNodeRow({
node, node,
@@ -380,6 +381,98 @@ const EntryNodeRow = memo(function EntryNodeRow({
) )
} }
if (nodeType === 'workflow') {
const { entry, children } = node
const BlockIcon = getBlockIcon(entry.blockType)
const hasError = Boolean(entry.error) || children.some((c) => c.entry.error)
const bgColor = getBlockColor(entry.blockType)
const nodeId = entry.id
const isExpanded = expandedNodes.has(nodeId)
const hasChildren = children.length > 0
const isSelected = selectedEntryId === entry.id
const isRunning = Boolean(entry.isRunning)
const isCanceled = Boolean(entry.isCanceled)
return (
<div className='flex min-w-0 flex-col'>
{/* Workflow Block Header */}
<div
data-entry-id={entry.id}
className={clsx(
ROW_STYLES.base,
'h-[26px]',
isSelected ? ROW_STYLES.selected : ROW_STYLES.hover
)}
onClick={(e) => {
e.stopPropagation()
if (hasChildren) {
onToggleNode(nodeId)
}
onSelectEntry(entry)
}}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
</div>
<span
className={clsx(
'min-w-0 truncate font-medium text-[13px]',
hasError
? 'text-[var(--text-error)]'
: isSelected || isExpanded
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{entry.blockName}
</span>
{hasChildren && (
<ChevronDown
className={clsx(
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
!isExpanded && '-rotate-90'
)}
/>
)}
</div>
<span
className={clsx(
'flex-shrink-0 font-medium text-[13px]',
!isRunning &&
(isCanceled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]')
)}
>
<StatusDisplay
isRunning={isRunning}
isCanceled={isCanceled}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>
{/* Nested Child Workflow Blocks (recursive) */}
{isExpanded && hasChildren && (
<div className={ROW_STYLES.nested}>
{children.map((child) => (
<EntryNodeRow
key={child.entry.id}
node={child}
selectedEntryId={selectedEntryId}
onSelectEntry={onSelectEntry}
expandedNodes={expandedNodes}
onToggleNode={onToggleNode}
/>
))}
</div>
)}
</div>
)
}
// Regular block // Regular block
return ( return (
<BlockRow <BlockRow
@@ -555,6 +648,8 @@ export const Terminal = memo(function Terminal() {
const uniqueBlocks = useMemo(() => { const uniqueBlocks = useMemo(() => {
const blocksMap = new Map<string, { blockId: string; blockName: string; blockType: string }>() const blocksMap = new Map<string, { blockId: string; blockName: string; blockType: string }>()
allWorkflowEntries.forEach((entry) => { allWorkflowEntries.forEach((entry) => {
// Skip child workflow entries — they use synthetic IDs and shouldn't appear in filters
if (entry.parentWorkflowBlockId) return
if (!blocksMap.has(entry.blockId)) { if (!blocksMap.has(entry.blockId)) {
blocksMap.set(entry.blockId, { blocksMap.set(entry.blockId, {
blockId: entry.blockId, blockId: entry.blockId,
@@ -667,19 +762,22 @@ export const Terminal = memo(function Terminal() {
const newestExec = executionGroups[0] const newestExec = executionGroups[0]
// Collect all node IDs that should be expanded (subflows and their iterations) // Collect all expandable node IDs recursively (subflows, iterations, and workflow nodes)
const nodeIdsToExpand: string[] = [] const nodeIdsToExpand: string[] = []
for (const node of newestExec.entryTree) { const collectExpandableNodes = (nodes: EntryNode[]) => {
if (node.nodeType === 'subflow' && node.children.length > 0) { for (const node of nodes) {
if (node.children.length === 0) continue
if (
node.nodeType === 'subflow' ||
node.nodeType === 'iteration' ||
node.nodeType === 'workflow'
) {
nodeIdsToExpand.push(node.entry.id) nodeIdsToExpand.push(node.entry.id)
// Also expand all iteration children collectExpandableNodes(node.children)
for (const iterNode of node.children) {
if (iterNode.nodeType === 'iteration') {
nodeIdsToExpand.push(iterNode.entry.id)
}
} }
} }
} }
collectExpandableNodes(newestExec.entryTree)
if (nodeIdsToExpand.length > 0) { if (nodeIdsToExpand.length > 0) {
setExpandedNodes((prev) => { setExpandedNodes((prev) => {

View File

@@ -120,10 +120,10 @@ export function isSubflowBlockType(blockType: string): boolean {
/** /**
* Node type for the tree structure * Node type for the tree structure
*/ */
export type EntryNodeType = 'block' | 'subflow' | 'iteration' export type EntryNodeType = 'block' | 'subflow' | 'iteration' | 'workflow'
/** /**
* Entry node for tree structure - represents a block, subflow, or iteration * Entry node for tree structure - represents a block, subflow, iteration, or workflow
*/ */
export interface EntryNode { export interface EntryNode {
/** The console entry (for blocks) or synthetic entry (for subflows/iterations) */ /** The console entry (for blocks) or synthetic entry (for subflows/iterations) */
@@ -175,12 +175,17 @@ interface IterationGroup {
* Sorts by start time to ensure chronological order. * Sorts by start time to ensure chronological order.
*/ */
function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
// Separate regular blocks from iteration entries // Separate regular blocks from iteration entries and child workflow entries
const regularBlocks: ConsoleEntry[] = [] const regularBlocks: ConsoleEntry[] = []
const iterationEntries: ConsoleEntry[] = [] const iterationEntries: ConsoleEntry[] = []
const childWorkflowEntries = new Map<string, ConsoleEntry[]>()
for (const entry of entries) { for (const entry of entries) {
if (entry.iterationType && entry.iterationCurrent !== undefined) { if (entry.parentWorkflowBlockId) {
const existing = childWorkflowEntries.get(entry.parentWorkflowBlockId) || []
existing.push(entry)
childWorkflowEntries.set(entry.parentWorkflowBlockId, existing)
} else if (entry.iterationType && entry.iterationCurrent !== undefined) {
iterationEntries.push(entry) iterationEntries.push(entry)
} else { } else {
regularBlocks.push(entry) regularBlocks.push(entry)
@@ -338,12 +343,53 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
}) })
} }
// Build nodes for regular blocks /**
const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({ * Recursively builds child nodes for workflow blocks.
* Handles multi-level nesting where a child workflow block itself has children.
*/
const buildWorkflowChildNodes = (parentBlockId: string): EntryNode[] => {
const childEntries = childWorkflowEntries.get(parentBlockId)
if (!childEntries || childEntries.length === 0) return []
childEntries.sort((a, b) => {
const aTime = new Date(a.startedAt || a.timestamp).getTime()
const bTime = new Date(b.startedAt || b.timestamp).getTime()
return aTime - bTime
})
return childEntries.map((child) => {
const nestedChildren = buildWorkflowChildNodes(child.blockId)
if (nestedChildren.length > 0) {
return {
entry: child,
children: nestedChildren,
nodeType: 'workflow' as const,
}
}
return {
entry: child,
children: [],
nodeType: 'block' as const,
}
})
}
// Build nodes for regular blocks, promoting workflow blocks with children to 'workflow' nodes
const regularNodes: EntryNode[] = regularBlocks.map((entry) => {
const childNodes = buildWorkflowChildNodes(entry.blockId)
if (childNodes.length > 0) {
return {
entry,
children: childNodes,
nodeType: 'workflow' as const,
}
}
return {
entry, entry,
children: [], children: [],
nodeType: 'block' as const, nodeType: 'block' as const,
})) }
})
// Combine all nodes and sort by executionOrder ascending (oldest first, top-down) // Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
const allNodes = [...subflowNodes, ...regularNodes] const allNodes = [...subflowNodes, ...regularNodes]

View File

@@ -38,7 +38,11 @@ import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/executi
import { useNotificationStore } from '@/stores/notifications' import { useNotificationStore } from '@/stores/notifications'
import { useVariablesStore } from '@/stores/panel' import { useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment' import { useEnvironmentStore } from '@/stores/settings/environment'
import { useTerminalConsoleStore } from '@/stores/terminal' import {
extractChildWorkflowEntries,
hasChildTraceSpans,
useTerminalConsoleStore,
} from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils' import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -63,6 +67,7 @@ interface BlockEventHandlerConfig {
executionIdRef: { current: string } executionIdRef: { current: string }
workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }> workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }>
activeBlocksSet: Set<string> activeBlocksSet: Set<string>
activeBlockRefCounts: Map<string, number>
accumulatedBlockLogs: BlockLog[] accumulatedBlockLogs: BlockLog[]
accumulatedBlockStates: Map<string, BlockState> accumulatedBlockStates: Map<string, BlockState>
executedBlockIds: Set<string> executedBlockIds: Set<string>
@@ -309,6 +314,7 @@ export function useWorkflowExecution() {
executionIdRef, executionIdRef,
workflowEdges, workflowEdges,
activeBlocksSet, activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs, accumulatedBlockLogs,
accumulatedBlockStates, accumulatedBlockStates,
executedBlockIds, executedBlockIds,
@@ -328,9 +334,18 @@ export function useWorkflowExecution() {
const updateActiveBlocks = (blockId: string, isActive: boolean) => { const updateActiveBlocks = (blockId: string, isActive: boolean) => {
if (!workflowId) return if (!workflowId) return
if (isActive) { if (isActive) {
const count = activeBlockRefCounts.get(blockId) ?? 0
activeBlockRefCounts.set(blockId, count + 1)
activeBlocksSet.add(blockId) activeBlocksSet.add(blockId)
} else { } else {
const count = activeBlockRefCounts.get(blockId) ?? 1
const next = count - 1
if (next <= 0) {
activeBlockRefCounts.delete(blockId)
activeBlocksSet.delete(blockId) activeBlocksSet.delete(blockId)
} else {
activeBlockRefCounts.set(blockId, next)
}
} }
setActiveBlocks(workflowId, new Set(activeBlocksSet)) setActiveBlocks(workflowId, new Set(activeBlocksSet))
} }
@@ -506,6 +521,20 @@ export function useWorkflowExecution() {
addConsoleEntry(data, data.output as NormalizedBlockOutput) addConsoleEntry(data, data.output as NormalizedBlockOutput)
} }
// Extract child workflow trace spans into separate console entries
if (data.blockType === 'workflow' && hasChildTraceSpans(data.output)) {
const childEntries = extractChildWorkflowEntries({
parentBlockId: data.blockId,
executionId: executionIdRef.current,
executionOrder: data.executionOrder,
workflowId: workflowId!,
childTraceSpans: data.output.childTraceSpans,
})
for (const entry of childEntries) {
addConsole(entry)
}
}
if (onBlockCompleteCallback) { if (onBlockCompleteCallback) {
onBlockCompleteCallback(data.blockId, data.output).catch((error) => { onBlockCompleteCallback(data.blockId, data.output).catch((error) => {
logger.error('Error in onBlockComplete callback:', error) logger.error('Error in onBlockComplete callback:', error)
@@ -1280,6 +1309,7 @@ export function useWorkflowExecution() {
} }
const activeBlocksSet = new Set<string>() const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
const streamedContent = new Map<string, string>() const streamedContent = new Map<string, string>()
const accumulatedBlockLogs: BlockLog[] = [] const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>() const accumulatedBlockStates = new Map<string, BlockState>()
@@ -1292,6 +1322,7 @@ export function useWorkflowExecution() {
executionIdRef, executionIdRef,
workflowEdges, workflowEdges,
activeBlocksSet, activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs, accumulatedBlockLogs,
accumulatedBlockStates, accumulatedBlockStates,
executedBlockIds, executedBlockIds,
@@ -1902,6 +1933,7 @@ export function useWorkflowExecution() {
const accumulatedBlockStates = new Map<string, BlockState>() const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>() const executedBlockIds = new Set<string>()
const activeBlocksSet = new Set<string>() const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
try { try {
const blockHandlers = buildBlockEventHandlers({ const blockHandlers = buildBlockEventHandlers({
@@ -1909,6 +1941,7 @@ export function useWorkflowExecution() {
executionIdRef, executionIdRef,
workflowEdges, workflowEdges,
activeBlocksSet, activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs, accumulatedBlockLogs,
accumulatedBlockStates, accumulatedBlockStates,
executedBlockIds, executedBlockIds,
@@ -2104,6 +2137,7 @@ export function useWorkflowExecution() {
const workflowEdges = useWorkflowStore.getState().edges const workflowEdges = useWorkflowStore.getState().edges
const activeBlocksSet = new Set<string>() const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
const accumulatedBlockLogs: BlockLog[] = [] const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>() const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>() const executedBlockIds = new Set<string>()
@@ -2115,6 +2149,7 @@ export function useWorkflowExecution() {
executionIdRef, executionIdRef,
workflowEdges, workflowEdges,
activeBlocksSet, activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs, accumulatedBlockLogs,
accumulatedBlockStates, accumulatedBlockStates,
executedBlockIds, executedBlockIds,

View File

@@ -1,7 +1,11 @@
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import type { ExecutionResult, StreamingExecution } from '@/executor/types' import type { ExecutionResult, StreamingExecution } from '@/executor/types'
import { useExecutionStore } from '@/stores/execution' import { useExecutionStore } from '@/stores/execution'
import { useTerminalConsoleStore } from '@/stores/terminal' import {
extractChildWorkflowEntries,
hasChildTraceSpans,
useTerminalConsoleStore,
} from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -39,6 +43,7 @@ export async function executeWorkflowWithFullLogging(
const workflowEdges = useWorkflowStore.getState().edges const workflowEdges = useWorkflowStore.getState().edges
const activeBlocksSet = new Set<string>() const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
const payload: any = { const payload: any = {
input: options.workflowInput, input: options.workflowInput,
@@ -103,6 +108,8 @@ export async function executeWorkflowWithFullLogging(
switch (event.type) { switch (event.type) {
case 'block:started': { case 'block:started': {
const startCount = activeBlockRefCounts.get(event.data.blockId) ?? 0
activeBlockRefCounts.set(event.data.blockId, startCount + 1)
activeBlocksSet.add(event.data.blockId) activeBlocksSet.add(event.data.blockId)
setActiveBlocks(wfId, new Set(activeBlocksSet)) setActiveBlocks(wfId, new Set(activeBlocksSet))
@@ -115,8 +122,14 @@ export async function executeWorkflowWithFullLogging(
break break
} }
case 'block:completed': case 'block:completed': {
const completeCount = activeBlockRefCounts.get(event.data.blockId) ?? 1
if (completeCount <= 1) {
activeBlockRefCounts.delete(event.data.blockId)
activeBlocksSet.delete(event.data.blockId) activeBlocksSet.delete(event.data.blockId)
} else {
activeBlockRefCounts.set(event.data.blockId, completeCount - 1)
}
setActiveBlocks(wfId, new Set(activeBlocksSet)) setActiveBlocks(wfId, new Set(activeBlocksSet))
setBlockRunStatus(wfId, event.data.blockId, 'success') setBlockRunStatus(wfId, event.data.blockId, 'success')
@@ -140,13 +153,34 @@ export async function executeWorkflowWithFullLogging(
iterationContainerId: event.data.iterationContainerId, iterationContainerId: event.data.iterationContainerId,
}) })
// Extract child workflow trace spans into separate console entries
if (event.data.blockType === 'workflow' && hasChildTraceSpans(event.data.output)) {
const childEntries = extractChildWorkflowEntries({
parentBlockId: event.data.blockId,
executionId,
executionOrder: event.data.executionOrder,
workflowId: activeWorkflowId,
childTraceSpans: event.data.output.childTraceSpans,
})
for (const entry of childEntries) {
addConsole(entry)
}
}
if (options.onBlockComplete) { if (options.onBlockComplete) {
options.onBlockComplete(event.data.blockId, event.data.output).catch(() => {}) options.onBlockComplete(event.data.blockId, event.data.output).catch(() => {})
} }
break break
}
case 'block:error': case 'block:error': {
const errorCount = activeBlockRefCounts.get(event.data.blockId) ?? 1
if (errorCount <= 1) {
activeBlockRefCounts.delete(event.data.blockId)
activeBlocksSet.delete(event.data.blockId) activeBlocksSet.delete(event.data.blockId)
} else {
activeBlockRefCounts.set(event.data.blockId, errorCount - 1)
}
setActiveBlocks(wfId, new Set(activeBlocksSet)) setActiveBlocks(wfId, new Set(activeBlocksSet))
setBlockRunStatus(wfId, event.data.blockId, 'error') setBlockRunStatus(wfId, event.data.blockId, 'error')
@@ -171,6 +205,7 @@ export async function executeWorkflowWithFullLogging(
iterationContainerId: event.data.iterationContainerId, iterationContainerId: event.data.iterationContainerId,
}) })
break break
}
case 'execution:completed': case 'execution:completed':
executionResult = { executionResult = {

View File

@@ -2,11 +2,10 @@ import { createLogger } from '@sim/logger'
import { AgentIcon } from '@/components/icons' import { AgentIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types' import { AuthMode } from '@/blocks/types'
import { getApiKeyCondition } from '@/blocks/utils' import { getApiKeyCondition, getModelOptions } from '@/blocks/utils'
import { import {
getBaseModelProviders, getBaseModelProviders,
getMaxTemperature, getMaxTemperature,
getProviderIcon,
getReasoningEffortValuesForModel, getReasoningEffortValuesForModel,
getThinkingLevelsForModel, getThinkingLevelsForModel,
getVerbosityValuesForModel, getVerbosityValuesForModel,
@@ -18,7 +17,6 @@ import {
providers, providers,
supportsTemperature, supportsTemperature,
} from '@/providers/utils' } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
const logger = createLogger('AgentBlock') const logger = createLogger('AgentBlock')
@@ -121,21 +119,7 @@ Return ONLY the JSON array.`,
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
defaultValue: 'claude-sonnet-4-5', defaultValue: 'claude-sonnet-4-5',
options: () => { options: getModelOptions,
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
{ {
id: 'vertexCredential', id: 'vertexCredential',

View File

@@ -1,10 +1,13 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { ChartBarIcon } from '@/components/icons' import { ChartBarIcon } from '@/components/icons'
import type { BlockConfig, ParamType } from '@/blocks/types' import type { BlockConfig, ParamType } from '@/blocks/types'
import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils' import {
getModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ProviderId } from '@/providers/types' import type { ProviderId } from '@/providers/types'
import { getBaseModelProviders, getProviderIcon } from '@/providers/utils' import { getBaseModelProviders } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
const logger = createLogger('EvaluatorBlock') const logger = createLogger('EvaluatorBlock')
@@ -175,21 +178,7 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
defaultValue: 'claude-sonnet-4-5', defaultValue: 'claude-sonnet-4-5',
options: () => { options: getModelOptions,
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
...getProviderCredentialSubBlocks(), ...getProviderCredentialSubBlocks(),
{ {

View File

@@ -1,8 +1,10 @@
import { ShieldCheckIcon } from '@/components/icons' import { ShieldCheckIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils' import {
import { getProviderIcon } from '@/providers/utils' getModelOptions,
import { useProvidersStore } from '@/stores/providers/store' getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
export interface GuardrailsResponse extends ToolResponse { export interface GuardrailsResponse extends ToolResponse {
@@ -111,21 +113,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
type: 'combobox', type: 'combobox',
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
options: () => { options: getModelOptions,
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
condition: { condition: {
field: 'validationType', field: 'validationType',
value: ['hallucination'], value: ['hallucination'],

View File

@@ -1,9 +1,12 @@
import { ConnectIcon } from '@/components/icons' import { ConnectIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types' import { AuthMode, type BlockConfig } from '@/blocks/types'
import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils' import {
getModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ProviderId } from '@/providers/types' import type { ProviderId } from '@/providers/types'
import { getBaseModelProviders, getProviderIcon } from '@/providers/utils' import { getBaseModelProviders } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
interface RouterResponse extends ToolResponse { interface RouterResponse extends ToolResponse {
@@ -134,25 +137,6 @@ Respond with a JSON object containing:
- reasoning: A brief explanation (1-2 sentences) of why you chose this route` - reasoning: A brief explanation (1-2 sentences) of why you chose this route`
} }
/**
* Helper to get model options for both router versions.
*/
const getModelOptions = () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
}
/** /**
* Legacy Router Block (block-based routing). * Legacy Router Block (block-based routing).
* Hidden from toolbar but still supported for existing workflows. * Hidden from toolbar but still supported for existing workflows.

View File

@@ -122,6 +122,25 @@ export const ScheduleBlock: BlockConfig = {
required: true, required: true,
mode: 'trigger', mode: 'trigger',
condition: { field: 'scheduleType', value: 'custom' }, condition: { field: 'scheduleType', value: 'custom' },
wandConfig: {
enabled: true,
prompt: `You are an expert at writing cron expressions. Generate a valid cron expression based on the user's description.
Cron format: minute hour day-of-month month day-of-week
- minute: 0-59
- hour: 0-23
- day-of-month: 1-31
- month: 1-12
- day-of-week: 0-7 (0 and 7 are Sunday)
Special characters: * (any), , (list), - (range), / (step)
{context}
Return ONLY the cron expression, nothing else. No explanation, no backticks, no quotes.`,
placeholder: 'Describe your schedule (e.g., "every weekday at 9am")',
generationType: 'cron-expression',
},
}, },
{ {

View File

@@ -604,7 +604,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
case 'send': { case 'send': {
baseParams.text = text baseParams.text = text
if (threadTs) { if (threadTs) {
baseParams.thread_ts = threadTs baseParams.threadTs = threadTs
} }
// files is the canonical param from attachmentFiles (basic) or files (advanced) // files is the canonical param from attachmentFiles (basic) or files (advanced)
const normalizedFiles = normalizeFileInput(files) const normalizedFiles = normalizeFileInput(files)

View File

@@ -1,8 +1,10 @@
import { TranslateIcon } from '@/components/icons' import { TranslateIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types' import { AuthMode, type BlockConfig } from '@/blocks/types'
import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils' import {
import { getProviderIcon } from '@/providers/utils' getModelOptions,
import { useProvidersStore } from '@/stores/providers/store' getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
const getTranslationPrompt = (targetLanguage: string) => const getTranslationPrompt = (targetLanguage: string) =>
`Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.` `Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.`
@@ -38,18 +40,7 @@ export const TranslateBlock: BlockConfig = {
type: 'combobox', type: 'combobox',
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
options: () => { options: getModelOptions,
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels]))
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
...getProviderCredentialSubBlocks(), ...getProviderCredentialSubBlocks(),
{ {

View File

@@ -40,6 +40,7 @@ export type GenerationType =
| 'neo4j-parameters' | 'neo4j-parameters'
| 'timestamp' | 'timestamp'
| 'timezone' | 'timezone'
| 'cron-expression'
export type SubBlockType = export type SubBlockType =
| 'short-input' // Single line input | 'short-input' // Single line input

View File

@@ -1,8 +1,32 @@
import { isHosted } from '@/lib/core/config/feature-flags' import { isHosted } from '@/lib/core/config/feature-flags'
import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types' import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types'
import { getHostedModels, getProviderFromModel, providers } from '@/providers/utils' import {
getHostedModels,
getProviderFromModel,
getProviderIcon,
providers,
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store' import { useProvidersStore } from '@/stores/providers/store'
/**
* Returns model options for combobox subblocks, combining all provider sources.
*/
export function getModelOptions() {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
}
/** /**
* Checks if a field is included in the dependsOn config. * Checks if a field is included in the dependsOn config.
* Handles both simple array format and object format with all/any fields. * Handles both simple array format and object format with all/any fields.

View File

@@ -428,7 +428,7 @@ export class BlockExecutor {
block: SerializedBlock, block: SerializedBlock,
executionOrder: number executionOrder: number
): void { ): void {
const blockId = node.id const blockId = node.metadata?.originalBlockId ?? node.id
const blockName = block.metadata?.name ?? blockId const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
@@ -456,7 +456,7 @@ export class BlockExecutor {
executionOrder: number, executionOrder: number,
endedAt: string endedAt: string
): void { ): void {
const blockId = node.id const blockId = node.metadata?.originalBlockId ?? node.id
const blockName = block.metadata?.name ?? blockId const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE

View File

@@ -30,8 +30,8 @@ export const vertexProvider: ProviderConfig = {
executeRequest: async ( executeRequest: async (
request: ProviderRequest request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => { ): Promise<ProviderResponse | StreamingExecution> => {
const vertexProject = env.VERTEX_PROJECT || request.vertexProject const vertexProject = request.vertexProject || env.VERTEX_PROJECT
const vertexLocation = env.VERTEX_LOCATION || request.vertexLocation || 'us-central1' const vertexLocation = request.vertexLocation || env.VERTEX_LOCATION || 'us-central1'
if (!vertexProject) { if (!vertexProject) {
throw new Error( throw new Error(

View File

@@ -1,3 +1,4 @@
export { indexedDBStorage } from './storage' export { indexedDBStorage } from './storage'
export { useTerminalConsoleStore } from './store' export { useTerminalConsoleStore } from './store'
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types' export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types'
export { extractChildWorkflowEntries, hasChildTraceSpans } from './utils'

View File

@@ -224,7 +224,11 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
const newEntry = get().entries[0] const newEntry = get().entries[0]
if (newEntry?.error && newEntry.blockType !== 'cancelled') { if (
newEntry?.error &&
newEntry.blockType !== 'cancelled' &&
!newEntry.parentWorkflowBlockId
) {
notifyBlockError({ notifyBlockError({
error: newEntry.error, error: newEntry.error,
blockName: newEntry.blockName || 'Unknown Block', blockName: newEntry.blockName || 'Unknown Block',
@@ -249,7 +253,9 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
})), })),
exportConsoleCSV: (workflowId: string) => { exportConsoleCSV: (workflowId: string) => {
const entries = get().entries.filter((entry) => entry.workflowId === workflowId) const entries = get().entries.filter(
(entry) => entry.workflowId === workflowId && !entry.parentWorkflowBlockId
)
if (entries.length === 0) { if (entries.length === 0) {
return return

View File

@@ -22,6 +22,7 @@ export interface ConsoleEntry {
iterationTotal?: number iterationTotal?: number
iterationType?: SubflowType iterationType?: SubflowType
iterationContainerId?: string iterationContainerId?: string
parentWorkflowBlockId?: string
isRunning?: boolean isRunning?: boolean
isCanceled?: boolean isCanceled?: boolean
} }
@@ -44,6 +45,7 @@ export interface ConsoleUpdate {
iterationTotal?: number iterationTotal?: number
iterationType?: SubflowType iterationType?: SubflowType
iterationContainerId?: string iterationContainerId?: string
parentWorkflowBlockId?: string
} }
export interface ConsoleStore { export interface ConsoleStore {

View File

@@ -0,0 +1,78 @@
import type { TraceSpan } from '@/lib/logs/types'
import type { ConsoleEntry } from '@/stores/terminal/console/types'
/**
* Parameters for extracting child workflow entries from trace spans
*/
interface ExtractChildWorkflowEntriesParams {
parentBlockId: string
executionId: string
executionOrder: number
workflowId: string
childTraceSpans: TraceSpan[]
}
/**
* Extracts child workflow trace spans into console entry payloads.
* Handles recursive nesting for multi-level child workflows by flattening
* nested children with a parent block ID chain.
*/
export function extractChildWorkflowEntries(
params: ExtractChildWorkflowEntriesParams
): Omit<ConsoleEntry, 'id' | 'timestamp'>[] {
const { parentBlockId, executionId, executionOrder, workflowId, childTraceSpans } = params
const entries: Omit<ConsoleEntry, 'id' | 'timestamp'>[] = []
for (const span of childTraceSpans) {
if (!span.blockId) continue
const childBlockId = `child-${parentBlockId}-${span.blockId}`
entries.push({
blockId: childBlockId,
blockName: span.name || 'Unknown Block',
blockType: span.type || 'unknown',
parentWorkflowBlockId: parentBlockId,
input: span.input || {},
output: (span.output || {}) as ConsoleEntry['output'],
durationMs: span.duration,
startedAt: span.startTime,
endedAt: span.endTime,
success: span.status !== 'error',
error:
span.status === 'error'
? (span.output?.error as string) || `${span.name || 'Block'} failed`
: undefined,
executionId,
executionOrder,
workflowId,
})
// Recursively extract nested child workflow spans
if (span.children && span.children.length > 0 && span.type === 'workflow') {
const nestedEntries = extractChildWorkflowEntries({
parentBlockId: childBlockId,
executionId,
executionOrder,
workflowId,
childTraceSpans: span.children,
})
entries.push(...nestedEntries)
}
}
return entries
}
/**
* Checks if a block completed event output contains child trace spans
*/
export function hasChildTraceSpans(output: unknown): output is Record<string, unknown> & {
childTraceSpans: TraceSpan[]
} {
return (
output !== null &&
typeof output === 'object' &&
Array.isArray((output as Record<string, unknown>).childTraceSpans)
)
}

View File

@@ -1,4 +1,4 @@
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './console' export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './console'
export { useTerminalConsoleStore } from './console' export { extractChildWorkflowEntries, hasChildTraceSpans, useTerminalConsoleStore } from './console'
export { useTerminalStore } from './store' export { useTerminalStore } from './store'
export type { TerminalState } from './types' export type { TerminalState } from './types'

View File

@@ -827,11 +827,10 @@ export function formatParameterLabel(paramId: string): string {
} }
/** /**
* SubBlock IDs that are "structural" — they control tool routing or auth, * SubBlock IDs that control tool routing, not user-facing parameters.
* not user-facing parameters. These are excluded from tool-input rendering * Excluded from tool-input rendering unless they have an explicit paramVisibility set.
* unless they have an explicit paramVisibility set.
*/ */
const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation', 'authMethod', 'destinationType']) const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation'])
/** /**
* SubBlock types that represent auth/credential inputs handled separately * SubBlock types that represent auth/credential inputs handled separately
@@ -955,12 +954,8 @@ export function getSubBlocksForToolInput(
} else if (sb.id in toolParamVisibility) { } else if (sb.id in toolParamVisibility) {
visibility = toolParamVisibility[sb.id] visibility = toolParamVisibility[sb.id]
} else if (sb.canonicalParamId) { } else if (sb.canonicalParamId) {
// SubBlock has a canonicalParamId that doesn't directly match a tool param.
// This means the block's params() function transforms it before sending to the tool
// (e.g. listFolderId → folderId). These are user-facing inputs, default to user-or-llm.
visibility = 'user-or-llm' visibility = 'user-or-llm'
} else { } else {
// SubBlock has no corresponding tool param — skip it
continue continue
} }
} }

View File

@@ -57,7 +57,7 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
visibility: 'user-or-llm', visibility: 'user-or-llm',
description: 'Message text to send (supports Slack mrkdwn formatting)', description: 'Message text to send (supports Slack mrkdwn formatting)',
}, },
thread_ts: { threadTs: {
type: 'string', type: 'string',
required: false, required: false,
visibility: 'user-or-llm', visibility: 'user-or-llm',
@@ -84,7 +84,7 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
channel: isDM ? undefined : params.channel, channel: isDM ? undefined : params.channel,
userId: isDM ? params.dmUserId : params.userId, userId: isDM ? params.dmUserId : params.userId,
text: params.text, text: params.text,
thread_ts: params.thread_ts || undefined, thread_ts: params.threadTs || undefined,
files: params.files || null, files: params.files || null,
} }
}, },

View File

@@ -516,7 +516,7 @@ export interface SlackMessageParams extends SlackBaseParams {
dmUserId?: string dmUserId?: string
userId?: string userId?: string
text: string text: string
thread_ts?: string threadTs?: string
files?: UserFile[] files?: UserFile[]
} }