v0.5.90: workflow duplication improvements, model allowlist, logs performance, i18n

This commit is contained in:
Waleed
2026-02-15 23:42:42 -08:00
committed by GitHub
37 changed files with 900 additions and 505 deletions

View File

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

View File

@@ -71,6 +71,7 @@ Retrieve an object from an AWS S3 bucket
| --------- | ---- | -------- | ----------- |
| `accessKeyId` | string | Yes | Your AWS Access Key ID |
| `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\) |
#### 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\) |
| `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) |
| `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 |
#### Output

View File

@@ -238,6 +238,11 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg
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') {
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 }.'

View File

@@ -1,2 +1,2 @@
export type { StatusBarSegment } from './status-bar'
export { default, StatusBar } from './status-bar'
export { StatusBar } from './status-bar'

View File

@@ -8,7 +8,7 @@ export interface StatusBarSegment {
timestamp: string
}
export function StatusBar({
function StatusBarInner({
segments,
selectedSegmentIndices,
onSegmentClick,
@@ -127,4 +127,45 @@ export function StatusBar({
)
}
export default memo(StatusBar)
/**
* Custom equality function for StatusBar memo.
* Performs structural comparison of segments array to avoid re-renders
* when poll data returns new object references with identical content.
*/
function areStatusBarPropsEqual(
prev: Parameters<typeof StatusBarInner>[0],
next: Parameters<typeof StatusBarInner>[0]
): boolean {
if (prev.workflowId !== next.workflowId) return false
if (prev.segmentDurationMs !== next.segmentDurationMs) return false
if (prev.preferBelow !== next.preferBelow) return false
if (prev.selectedSegmentIndices !== next.selectedSegmentIndices) {
if (!prev.selectedSegmentIndices || !next.selectedSegmentIndices) return false
if (prev.selectedSegmentIndices.length !== next.selectedSegmentIndices.length) return false
for (let i = 0; i < prev.selectedSegmentIndices.length; i++) {
if (prev.selectedSegmentIndices[i] !== next.selectedSegmentIndices[i]) return false
}
}
if (prev.segments !== next.segments) {
if (prev.segments.length !== next.segments.length) return false
for (let i = 0; i < prev.segments.length; i++) {
const ps = prev.segments[i]
const ns = next.segments[i]
if (
ps.successRate !== ns.successRate ||
ps.hasExecutions !== ns.hasExecutions ||
ps.totalExecutions !== ns.totalExecutions ||
ps.successfulExecutions !== ns.successfulExecutions ||
ps.timestamp !== ns.timestamp
) {
return false
}
}
}
return true
}
export const StatusBar = memo(StatusBarInner, areStatusBarPropsEqual)

View File

@@ -1,2 +1,2 @@
export type { WorkflowExecutionItem } from './workflows-list'
export { default, WorkflowsList } from './workflows-list'
export { WorkflowsList } from './workflows-list'

View File

@@ -14,7 +14,7 @@ export interface WorkflowExecutionItem {
overallSuccessRate: number
}
export function WorkflowsList({
function WorkflowsListInner({
filteredExecutions,
expandedWorkflowId,
onToggleWorkflow,
@@ -103,7 +103,7 @@ export function WorkflowsList({
<StatusBar
segments={workflow.segments}
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
onSegmentClick={onSegmentClick as any}
onSegmentClick={onSegmentClick}
workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs}
preferBelow={idx < 2}
@@ -124,4 +124,4 @@ export function WorkflowsList({
)
}
export default memo(WorkflowsList)
export const WorkflowsList = memo(WorkflowsListInner)

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -141,10 +141,10 @@ function toWorkflowExecution(wf: WorkflowStats): WorkflowExecution {
}
}
export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
function DashboardInner({ stats, isLoading, error }: DashboardProps) {
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const barsAreaRef = useRef<HTMLDivElement | null>(null)
const lastAnchorIndicesRef = useRef<Record<string, number>>({})
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
@@ -152,20 +152,79 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
const { executions, aggregateSegments, segmentMs } = useMemo(() => {
const { rawExecutions, aggregateSegments, segmentMs } = useMemo(() => {
if (!stats) {
return { executions: [], aggregateSegments: [], segmentMs: 0 }
return { rawExecutions: [], aggregateSegments: [], segmentMs: 0 }
}
const workflowExecutions = stats.workflows.map(toWorkflowExecution)
return {
executions: workflowExecutions,
rawExecutions: stats.workflows.map(toWorkflowExecution),
aggregateSegments: stats.aggregateSegments,
segmentMs: stats.segmentMs,
}
}, [stats])
/**
* Stabilize execution objects: reuse previous references for workflows
* whose segment data hasn't structurally changed between polls.
* This prevents cascading re-renders through WorkflowsList → StatusBar.
*/
const prevExecutionsRef = useRef<WorkflowExecution[]>([])
const executions = useMemo(() => {
const prevMap = new Map(prevExecutionsRef.current.map((e) => [e.workflowId, e]))
let anyChanged = false
const result = rawExecutions.map((exec) => {
const prev = prevMap.get(exec.workflowId)
if (!prev) {
anyChanged = true
return exec
}
if (
prev.overallSuccessRate !== exec.overallSuccessRate ||
prev.workflowName !== exec.workflowName ||
prev.segments.length !== exec.segments.length
) {
anyChanged = true
return exec
}
for (let i = 0; i < prev.segments.length; i++) {
const ps = prev.segments[i]
const ns = exec.segments[i]
if (
ps.totalExecutions !== ns.totalExecutions ||
ps.successfulExecutions !== ns.successfulExecutions ||
ps.timestamp !== ns.timestamp ||
ps.avgDurationMs !== ns.avgDurationMs ||
ps.p50Ms !== ns.p50Ms ||
ps.p90Ms !== ns.p90Ms ||
ps.p99Ms !== ns.p99Ms
) {
anyChanged = true
return exec
}
}
return prev
})
if (
!anyChanged &&
result.length === prevExecutionsRef.current.length &&
result.every((r, i) => r === prevExecutionsRef.current[i])
) {
return prevExecutionsRef.current
}
return result
}, [rawExecutions])
useEffect(() => {
prevExecutionsRef.current = executions
}, [executions])
const lastExecutionByWorkflow = useMemo(() => {
const map = new Map<string, number>()
for (const wf of executions) {
@@ -312,6 +371,8 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
[toggleWorkflowId]
)
lastAnchorIndicesRef.current = lastAnchorIndices
/**
* Handles segment click for selecting time segments.
* @param workflowId - The workflow containing the segment
@@ -361,7 +422,7 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
} else if (mode === 'range') {
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const anchor = lastAnchorIndicesRef.current[workflowId] ?? segmentIndex
const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
@@ -370,12 +431,12 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
})
}
},
[lastAnchorIndices]
[]
)
useEffect(() => {
setSelectedSegments({})
setLastAnchorIndices({})
setSelectedSegments((prev) => (Object.keys(prev).length > 0 ? {} : prev))
setLastAnchorIndices((prev) => (Object.keys(prev).length > 0 ? {} : prev))
}, [stats, timeRange, workflowIds, searchQuery])
if (isLoading) {
@@ -493,7 +554,7 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
</div>
</div>
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
<div className='min-h-0 flex-1 overflow-hidden'>
<WorkflowsList
filteredExecutions={filteredExecutions as WorkflowExecution[]}
expandedWorkflowId={expandedWorkflowId}
@@ -507,3 +568,5 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
</div>
)
}
export default memo(DashboardInner)

View File

@@ -43,184 +43,199 @@ import { useLogDetailsUIStore } from '@/stores/logs/store'
/**
* Workflow Output section with code viewer, copy, search, and context menu functionality
*/
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
const contentRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const WorkflowOutputSection = memo(
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
const contentRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const copyTimerRef = useRef<number | null>(null)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const {
isSearchActive,
searchQuery,
setSearchQuery,
matchCount,
currentMatchIndex,
activateSearch,
closeSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef,
} = useCodeViewerFeatures({ contentRef })
const {
isSearchActive,
searchQuery,
setSearchQuery,
matchCount,
currentMatchIndex,
activateSearch,
closeSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef,
} = useCodeViewerFeatures({ contentRef })
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true)
}, [])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true)
}, [])
const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false)
}, [])
const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false)
}, [])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
closeContextMenu()
}, [jsonString, closeContextMenu])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
setCopied(true)
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current)
copyTimerRef.current = window.setTimeout(() => setCopied(false), 1500)
closeContextMenu()
}, [jsonString, closeContextMenu])
const handleSearch = useCallback(() => {
activateSearch()
closeContextMenu()
}, [activateSearch, closeContextMenu])
useEffect(() => {
return () => {
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current)
}
}, [])
return (
<div className='relative flex min-w-0 flex-col overflow-hidden'>
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Glass action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
{copied ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
const handleSearch = useCallback(() => {
activateSearch()
closeContextMenu()
}, [activateSearch, closeContextMenu])
return (
<div className='relative flex min-w-0 flex-col overflow-hidden'>
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Glass action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
{copied ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={closeSearch}
aria-label='Close search'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button variant='ghost' className='!p-1' onClick={closeSearch} aria-label='Close search'>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div>
)
}
)
},
(prev, next) => prev.output === next.output
)
interface LogDetailsProps {
/** The log to display details for */
@@ -278,7 +293,6 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog])
// Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
const workflowOutput = useMemo(() => {
const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> }

View File

@@ -1,6 +1,7 @@
'use client'
import type { RefObject } from 'react'
import { memo } from 'react'
import {
Popover,
PopoverAnchor,
@@ -29,7 +30,7 @@ interface LogRowContextMenuProps {
* Context menu for log rows.
* Provides quick actions for copying data, navigation, and filtering.
*/
export function LogRowContextMenu({
export const LogRowContextMenu = memo(function LogRowContextMenu({
isOpen,
position,
menuRef,
@@ -121,4 +122,4 @@ export function LogRowContextMenu({
</PopoverContent>
</Popover>
)
}
})

View File

@@ -24,6 +24,7 @@ interface LogRowProps {
log: WorkflowLog
isSelected: boolean
onClick: (log: WorkflowLog) => void
onHover?: (log: WorkflowLog) => void
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
}
@@ -33,7 +34,14 @@ interface LogRowProps {
* Uses shallow comparison for the log object.
*/
const LogRow = memo(
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
function LogRow({
log,
isSelected,
onClick,
onHover,
onContextMenu,
selectedRowRef,
}: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const isDeletedWorkflow = !log.workflow?.id && !log.workflowId
const workflowName = isDeletedWorkflow
@@ -43,6 +51,8 @@ const LogRow = memo(
const handleClick = useCallback(() => onClick(log), [onClick, log])
const handleMouseEnter = useCallback(() => onHover?.(log), [onHover, log])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (onContextMenu) {
@@ -61,6 +71,7 @@ const LogRow = memo(
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onContextMenu={handleContextMenu}
>
<div className='flex flex-1 items-center'>
@@ -142,7 +153,8 @@ const LogRow = memo(
prevProps.log.id === nextProps.log.id &&
prevProps.log.duration === nextProps.log.duration &&
prevProps.log.status === nextProps.log.status &&
prevProps.isSelected === nextProps.isSelected
prevProps.isSelected === nextProps.isSelected &&
prevProps.onHover === nextProps.onHover
)
}
)
@@ -151,6 +163,7 @@ interface RowProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
onLogHover?: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
isFetchingNextPage: boolean
@@ -167,6 +180,7 @@ function Row({
logs,
selectedLogId,
onLogClick,
onLogHover,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
@@ -198,6 +212,7 @@ function Row({
log={log}
isSelected={isSelected}
onClick={onLogClick}
onHover={onLogHover}
onContextMenu={onLogContextMenu}
selectedRowRef={isSelected ? selectedRowRef : null}
/>
@@ -209,6 +224,7 @@ export interface LogsListProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
onLogHover?: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
hasNextPage: boolean
@@ -227,6 +243,7 @@ export function LogsList({
logs,
selectedLogId,
onLogClick,
onLogHover,
onLogContextMenu,
selectedRowRef,
hasNextPage,
@@ -272,6 +289,7 @@ export function LogsList({
logs,
selectedLogId,
onLogClick,
onLogHover,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
@@ -281,6 +299,7 @@ export function LogsList({
logs,
selectedLogId,
onLogClick,
onLogHover,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, X } from 'lucide-react'
import {
@@ -113,7 +113,7 @@ function formatAlertConfigLabel(config: {
}
}
export function NotificationSettings({
export const NotificationSettings = memo(function NotificationSettings({
workspaceId,
open,
onOpenChange,
@@ -144,7 +144,7 @@ export function NotificationSettings({
slackChannelId: '',
slackChannelName: '',
slackAccountId: '',
useAlertRule: false,
alertRule: 'none' as AlertRule,
consecutiveFailures: 3,
failureRatePercent: 50,
@@ -212,7 +212,7 @@ export function NotificationSettings({
slackChannelId: '',
slackChannelName: '',
slackAccountId: '',
useAlertRule: false,
alertRule: 'none',
consecutiveFailures: 3,
failureRatePercent: 50,
@@ -484,7 +484,6 @@ export function NotificationSettings({
slackChannelId: subscription.slackConfig?.channelId || '',
slackChannelName: subscription.slackConfig?.channelName || '',
slackAccountId: subscription.slackConfig?.accountId || '',
useAlertRule: !!subscription.alertConfig,
alertRule: subscription.alertConfig?.rule || 'none',
consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3,
failureRatePercent: subscription.alertConfig?.failureRatePercent || 50,
@@ -1289,4 +1288,4 @@ export function NotificationSettings({
</Modal>
</>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
@@ -149,7 +149,7 @@ function getTriggerIcon(
* @param props - The component props
* @returns The complete logs toolbar
*/
export function LogsToolbar({
export const LogsToolbar = memo(function LogsToolbar({
viewMode,
onViewModeChange,
isRefreshing,
@@ -749,4 +749,4 @@ export function LogsToolbar({
</div>
</div>
)
}
})

View File

@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
@@ -10,12 +11,17 @@ import {
hasActiveFilters,
} from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useFolders } from '@/hooks/queries/folders'
import { useDashboardStats, useLogDetail, useLogsList } from '@/hooks/queries/logs'
import {
prefetchLogDetail,
useDashboardStats,
useLogDetail,
useLogsList,
} from '@/hooks/queries/logs'
import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import {
Dashboard,
ExecutionSnapshot,
@@ -30,6 +36,38 @@ import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const
interface LogSelectionState {
selectedLogId: string | null
isSidebarOpen: boolean
}
type LogSelectionAction =
| { type: 'TOGGLE_LOG'; logId: string }
| { type: 'SELECT_LOG'; logId: string }
| { type: 'CLOSE_SIDEBAR' }
| { type: 'TOGGLE_SIDEBAR' }
function logSelectionReducer(
state: LogSelectionState,
action: LogSelectionAction
): LogSelectionState {
switch (action.type) {
case 'TOGGLE_LOG':
if (state.selectedLogId === action.logId && state.isSidebarOpen) {
return { selectedLogId: null, isSidebarOpen: false }
}
return { selectedLogId: action.logId, isSidebarOpen: true }
case 'SELECT_LOG':
return { ...state, selectedLogId: action.logId }
case 'CLOSE_SIDEBAR':
return { selectedLogId: null, isSidebarOpen: false }
case 'TOGGLE_SIDEBAR':
return state.selectedLogId ? { ...state, isSidebarOpen: !state.isSidebarOpen } : state
default:
return state
}
}
/**
* Logs page component displaying workflow execution history.
* Supports filtering, search, live updates, and detailed log inspection.
@@ -60,11 +98,13 @@ export default function Logs() {
setWorkspaceId(workspaceId)
}, [workspaceId, setWorkspaceId])
const [selectedLogId, setSelectedLogId] = useState<string | null>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, {
selectedLogId: null,
isSidebarOpen: false,
})
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isInitialized = useRef<boolean>(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -82,6 +122,13 @@ export default function Logs() {
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const isSearchOpenRef = useRef<boolean>(false)
const refreshTimersRef = useRef(new Set<number>())
const logsRef = useRef<WorkflowLog[]>([])
const selectedLogIndexRef = useRef(-1)
const selectedLogIdRef = useRef<string | null>(null)
const logsRefetchRef = useRef<() => void>(() => {})
const activeLogRefetchRef = useRef<() => void>(() => {})
const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} })
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
@@ -94,8 +141,19 @@ export default function Logs() {
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
const queryClient = useQueryClient()
const detailRefetchInterval = useCallback(
(query: { state: { data?: WorkflowLog } }) => {
if (!isLive) return false
const status = query.state.data?.status
return status === 'running' || status === 'pending' ? 3000 : false
},
[isLive]
)
const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
refetchInterval: isLive ? 3000 : false,
refetchInterval: detailRefetchInterval,
})
const logFilters = useMemo(
@@ -154,42 +212,73 @@ export default function Logs() {
return { ...selectedLogFromList, ...activeLogQuery.data }
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
const handleLogHover = useCallback(
(log: WorkflowLog) => {
prefetchLogDetail(queryClient, log.id)
},
[queryClient]
)
useFolders(workspaceId)
useEffect(() => {
logsRef.current = logs
}, [logs])
useEffect(() => {
selectedLogIndexRef.current = selectedLogIndex
}, [selectedLogIndex])
useEffect(() => {
selectedLogIdRef.current = selectedLogId
}, [selectedLogId])
useEffect(() => {
logsRefetchRef.current = logsQuery.refetch
}, [logsQuery.refetch])
useEffect(() => {
activeLogRefetchRef.current = activeLogQuery.refetch
}, [activeLogQuery.refetch])
useEffect(() => {
logsQueryRef.current = {
isFetching: logsQuery.isFetching,
hasNextPage: logsQuery.hasNextPage ?? false,
fetchNextPage: logsQuery.fetchNextPage,
}
}, [logsQuery.isFetching, logsQuery.hasNextPage, logsQuery.fetchNextPage])
useEffect(() => {
const timers = refreshTimersRef.current
return () => {
timers.forEach((id) => window.clearTimeout(id))
timers.clear()
}
}, [])
useEffect(() => {
if (isInitialized.current) {
setStoreSearchQuery(debouncedSearchQuery)
}
}, [debouncedSearchQuery, setStoreSearchQuery])
const handleLogClick = useCallback(
(log: WorkflowLog) => {
if (selectedLogId === log.id && isSidebarOpen) {
setIsSidebarOpen(false)
setSelectedLogId(null)
return
}
setSelectedLogId(log.id)
setIsSidebarOpen(true)
},
[selectedLogId, isSidebarOpen]
)
const handleLogClick = useCallback((log: WorkflowLog) => {
dispatch({ type: 'TOGGLE_LOG', logId: log.id })
}, [])
const handleNavigateNext = useCallback(() => {
if (selectedLogIndex < logs.length - 1) {
setSelectedLogId(logs[selectedLogIndex + 1].id)
const idx = selectedLogIndexRef.current
const currentLogs = logsRef.current
if (idx < currentLogs.length - 1) {
dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id })
}
}, [selectedLogIndex, logs])
}, [])
const handleNavigatePrev = useCallback(() => {
if (selectedLogIndex > 0) {
setSelectedLogId(logs[selectedLogIndex - 1].id)
const idx = selectedLogIndexRef.current
if (idx > 0) {
dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id })
}
}, [selectedLogIndex, logs])
}, [])
const handleCloseSidebar = useCallback(() => {
setIsSidebarOpen(false)
setSelectedLogId(null)
dispatch({ type: 'CLOSE_SIDEBAR' })
}, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
@@ -260,26 +349,34 @@ export default function Logs() {
const handleRefresh = useCallback(() => {
setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
logsQuery.refetch()
if (selectedLogId) {
activeLogQuery.refetch()
const timerId = window.setTimeout(() => {
setIsVisuallyRefreshing(false)
refreshTimersRef.current.delete(timerId)
}, REFRESH_SPINNER_DURATION_MS)
refreshTimersRef.current.add(timerId)
logsRefetchRef.current()
if (selectedLogIdRef.current) {
activeLogRefetchRef.current()
}
}, [logsQuery, activeLogQuery, selectedLogId])
}, [])
const handleToggleLive = useCallback(() => {
const newIsLive = !isLive
setIsLive(newIsLive)
if (newIsLive) {
setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
logsQuery.refetch()
if (selectedLogId) {
activeLogQuery.refetch()
setIsLive((prev) => {
if (!prev) {
setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => {
setIsVisuallyRefreshing(false)
refreshTimersRef.current.delete(timerId)
}, REFRESH_SPINNER_DURATION_MS)
refreshTimersRef.current.add(timerId)
logsRefetchRef.current()
if (selectedLogIdRef.current) {
activeLogRefetchRef.current()
}
}
}
}, [isLive, logsQuery, activeLogQuery, selectedLogId])
return !prev
})
}, [])
const prevIsFetchingRef = useRef(logsQuery.isFetching)
useEffect(() => {
@@ -289,11 +386,15 @@ export default function Logs() {
if (isLive && !wasFetching && isFetching) {
setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
const timerId = window.setTimeout(() => {
setIsVisuallyRefreshing(false)
refreshTimersRef.current.delete(timerId)
}, REFRESH_SPINNER_DURATION_MS)
refreshTimersRef.current.add(timerId)
}
}, [logsQuery.isFetching, isLive])
const handleExport = async () => {
const handleExport = useCallback(async () => {
setIsExporting(true)
try {
const params = new URLSearchParams()
@@ -327,7 +428,17 @@ export default function Logs() {
} finally {
setIsExporting(false)
}
}
}, [
workspaceId,
level,
triggers,
workflowIds,
folderIds,
timeRange,
startDate,
endDate,
debouncedSearchQuery,
])
useEffect(() => {
if (!isInitialized.current) {
@@ -348,41 +459,59 @@ export default function Logs() {
}, [initializeFromURL])
const loadMoreLogs = useCallback(() => {
if (!logsQuery.isFetching && logsQuery.hasNextPage) {
logsQuery.fetchNextPage()
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current
if (!isFetching && hasNextPage) {
fetchNextPage()
}
}, [logsQuery])
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isSearchOpenRef.current) return
if (logs.length === 0) return
const currentLogs = logsRef.current
const currentIndex = selectedLogIndexRef.current
if (currentLogs.length === 0) return
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault()
setSelectedLogId(logs[0].id)
dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id })
return
}
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && selectedLogIndex > 0) {
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && currentIndex > 0) {
e.preventDefault()
handleNavigatePrev()
}
if (e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey && selectedLogIndex < logs.length - 1) {
if (
e.key === 'ArrowDown' &&
!e.metaKey &&
!e.ctrlKey &&
currentIndex < currentLogs.length - 1
) {
e.preventDefault()
handleNavigateNext()
}
if (e.key === 'Enter' && selectedLogId) {
if (e.key === 'Enter' && selectedLogIdRef.current) {
e.preventDefault()
setIsSidebarOpen(!isSidebarOpen)
dispatch({ type: 'TOGGLE_SIDEBAR' })
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev])
}, [handleNavigateNext, handleNavigatePrev])
const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), [])
const handleOpenNotificationSettings = useCallback(() => setIsNotificationSettingsOpen(true), [])
const handleSearchOpenChange = useCallback((open: boolean) => {
isSearchOpenRef.current = open
}, [])
const handleClosePreview = useCallback(() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}, [])
const isDashboardView = viewMode === 'dashboard'
@@ -402,12 +531,10 @@ export default function Logs() {
onExport={handleExport}
canEdit={userPermissions.canEdit}
hasLogs={logs.length > 0}
onOpenNotificationSettings={() => setIsNotificationSettingsOpen(true)}
onOpenNotificationSettings={handleOpenNotificationSettings}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onSearchOpenChange={(open: boolean) => {
isSearchOpenRef.current = open
}}
onSearchOpenChange={handleSearchOpenChange}
/>
</div>
@@ -449,7 +576,7 @@ export default function Logs() {
</div>
{/* Table body - virtualized */}
<div className='min-h-0 flex-1 overflow-hidden' ref={scrollContainerRef}>
<div className='min-h-0 flex-1 overflow-hidden'>
{logsQuery.isLoading && !logsQuery.data ? (
<div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
@@ -476,6 +603,7 @@ export default function Logs() {
logs={logs}
selectedLogId={selectedLogId}
onLogClick={handleLogClick}
onLogHover={handleLogHover}
onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef}
hasNextPage={logsQuery.hasNextPage ?? false}
@@ -511,7 +639,7 @@ export default function Logs() {
isOpen={contextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={() => setContextMenuOpen(false)}
onClose={handleCloseContextMenu}
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow}
@@ -528,10 +656,7 @@ export default function Logs() {
traceSpans={activeLogQuery.data.executionData?.traceSpans}
isModal
isOpen={isPreviewOpen}
onClose={() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}}
onClose={handleClosePreview}
/>
)}
</div>

View File

@@ -239,7 +239,12 @@ export const ComboBox = memo(function ComboBox({
*/
const defaultOptionValue = useMemo(() => {
if (defaultValue !== undefined) {
return defaultValue
// Validate that the default value exists in the available (filtered) options
const defaultInOptions = evaluatedOptions.find((opt) => getOptionValue(opt) === defaultValue)
if (defaultInOptions) {
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

View File

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

View File

@@ -1741,36 +1741,97 @@ export const ToolInput = memo(function ToolInput({
) : null
})()}
{requiresOAuth && oauthConfig && (
<div className='relative min-w-0 space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
Account <span className='ml-0.5'>*</span>
</div>
<div className='w-full min-w-0'>
<ToolCredentialSelector
value={tool.params?.credential || ''}
onChange={(value: string) =>
handleParamChange(toolIndex, 'credential', value)
}
provider={oauthConfig.provider as OAuthProvider}
requiredScopes={
toolBlock?.subBlocks?.find((sb) => sb.id === 'credential')
?.requiredScopes ||
getCanonicalScopesForProvider(oauthConfig.provider)
}
serviceId={oauthConfig.provider}
disabled={disabled}
/>
</div>
</div>
)}
{(() => {
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)]'>
{credentialSubBlock?.title || 'Account'}{' '}
<span className='ml-0.5'>*</span>
</div>
<div className='w-full min-w-0'>
<ToolCredentialSelector
value={tool.params?.credential || ''}
onChange={(value: string) =>
handleParamChange(toolIndex, 'credential', value)
}
provider={oauthConfig.provider as OAuthProvider}
requiredScopes={
credentialSubBlock?.requiredScopes ||
getCanonicalScopesForProvider(oauthConfig.provider)
}
serviceId={oauthConfig.provider}
disabled={disabled}
/>
</div>
</div>
)
}
const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => {
const effectiveParamId = sb.id
const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
const canonicalGroup = canonicalId
? toolCanonicalIndex?.groupsById[canonicalId]
: undefined
const hasCanonicalPair = isCanonicalPair(canonicalGroup)
const canonicalMode =
canonicalGroup && hasCanonicalPair
? resolveCanonicalMode(
canonicalGroup,
{ operation: tool.operation, ...tool.params },
toolScopedOverrides
)
: undefined
const canonicalToggleProp =
hasCanonicalPair && canonicalMode && canonicalId
? {
mode: canonicalMode,
onToggle: () => {
const nextMode = canonicalMode === 'advanced' ? 'basic' : 'advanced'
collaborativeSetBlockCanonicalMode(
blockId,
`${tool.type}:${canonicalId}`,
nextMode
)
},
}
: undefined
const sbWithTitle = sb.title
? sb
: { ...sb, title: formatParameterLabel(effectiveParamId) }
return (
<ToolSubBlockRenderer
key={sb.id}
blockId={blockId}
subBlockId={subBlockId}
toolIndex={toolIndex}
subBlock={sbWithTitle}
effectiveParamId={effectiveParamId}
toolParams={tool.params}
onParamChange={handleParamChange}
disabled={disabled}
canonicalToggle={canonicalToggleProp}
/>
)
}
if (useSubBlocks && displaySubBlocks.length > 0) {
const allBlockSubBlocks = toolBlock?.subBlocks || []
const coveredParamIds = new Set(
displaySubBlocks.flatMap((sb) => {
allBlockSubBlocks.flatMap((sb) => {
const ids = [sb.id]
if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
@@ -1785,57 +1846,45 @@ export const ToolInput = memo(function ToolInput({
})
)
displaySubBlocks.forEach((sb) => {
const effectiveParamId = sb.id
const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
const canonicalGroup = canonicalId
? toolCanonicalIndex?.groupsById[canonicalId]
: undefined
const hasCanonicalPair = isCanonicalPair(canonicalGroup)
const canonicalMode =
canonicalGroup && hasCanonicalPair
? resolveCanonicalMode(
canonicalGroup,
{ operation: tool.operation, ...tool.params },
toolScopedOverrides
)
: undefined
type RenderItem =
| { kind: 'subblock'; sb: BlockSubBlockConfig }
| { kind: 'oauth' }
const canonicalToggleProp =
hasCanonicalPair && canonicalMode && canonicalId
? {
mode: canonicalMode,
onToggle: () => {
const nextMode =
canonicalMode === 'advanced' ? 'basic' : 'advanced'
collaborativeSetBlockCanonicalMode(
blockId,
`${tool.type}:${canonicalId}`,
nextMode
)
},
}
: undefined
const renderOrder: RenderItem[] = displaySubBlocks.map((sb) => ({
kind: 'subblock' as const,
sb,
}))
const sbWithTitle = sb.title
? sb
: { ...sb, title: formatParameterLabel(effectiveParamId) }
renderedElements.push(
<ToolSubBlockRenderer
key={sb.id}
blockId={blockId}
subBlockId={subBlockId}
toolIndex={toolIndex}
subBlock={sbWithTitle}
effectiveParamId={effectiveParamId}
toolParams={tool.params}
onParamChange={handleParamChange}
disabled={disabled}
canonicalToggle={canonicalToggleProp}
/>
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(
(param) =>
@@ -1873,6 +1922,11 @@ export const ToolInput = memo(function ToolInput({
)
}
{
const el = renderOAuthAccount()
if (el) renderedElements.push(el)
}
const filteredParams = displayParams.filter((param) =>
evaluateParameterCondition(param, tool)
)

View File

@@ -223,7 +223,12 @@ function resolveToolsDisplay(
* - Resolves tool names from block registry
* - Shows '-' for other selector types that need hydration
*/
function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
const SubBlockRow = memo(function SubBlockRow({
title,
value,
subBlock,
rawValue,
}: SubBlockRowProps) {
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -255,7 +260,7 @@ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
)}
</div>
)
}
})
/**
* Preview block component for workflow visualization.

View File

@@ -2,11 +2,10 @@ import { createLogger } from '@sim/logger'
import { AgentIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getApiKeyCondition } from '@/blocks/utils'
import { getApiKeyCondition, getModelOptions } from '@/blocks/utils'
import {
getBaseModelProviders,
getMaxTemperature,
getProviderIcon,
getReasoningEffortValuesForModel,
getThinkingLevelsForModel,
getVerbosityValuesForModel,
@@ -18,7 +17,6 @@ import {
providers,
supportsTemperature,
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import type { ToolResponse } from '@/tools/types'
const logger = createLogger('AgentBlock')
@@ -121,21 +119,7 @@ Return ONLY the JSON array.`,
placeholder: 'Type or select a model...',
required: true,
defaultValue: 'claude-sonnet-4-5',
options: () => {
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 }) }
})
},
options: getModelOptions,
},
{
id: 'vertexCredential',

View File

@@ -1,10 +1,13 @@
import { createLogger } from '@sim/logger'
import { ChartBarIcon } from '@/components/icons'
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 { getBaseModelProviders, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import { getBaseModelProviders } from '@/providers/utils'
import type { ToolResponse } from '@/tools/types'
const logger = createLogger('EvaluatorBlock')
@@ -175,21 +178,7 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
placeholder: 'Type or select a model...',
required: true,
defaultValue: 'claude-sonnet-4-5',
options: () => {
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 }) }
})
},
options: getModelOptions,
},
...getProviderCredentialSubBlocks(),
{

View File

@@ -1,8 +1,10 @@
import { ShieldCheckIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
import { getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import {
getModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ToolResponse } from '@/tools/types'
export interface GuardrailsResponse extends ToolResponse {
@@ -111,21 +113,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
type: 'combobox',
placeholder: 'Type or select a model...',
required: true,
options: () => {
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 }) }
})
},
options: getModelOptions,
condition: {
field: 'validationType',
value: ['hallucination'],

View File

@@ -1,9 +1,12 @@
import { ConnectIcon } from '@/components/icons'
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 { getBaseModelProviders, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import { getBaseModelProviders } from '@/providers/utils'
import type { ToolResponse } from '@/tools/types'
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`
}
/**
* 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).
* Hidden from toolbar but still supported for existing workflows.

View File

@@ -122,6 +122,25 @@ export const ScheduleBlock: BlockConfig = {
required: true,
mode: 'trigger',
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': {
baseParams.text = text
if (threadTs) {
baseParams.thread_ts = threadTs
baseParams.threadTs = threadTs
}
// files is the canonical param from attachmentFiles (basic) or files (advanced)
const normalizedFiles = normalizeFileInput(files)

View File

@@ -1,8 +1,10 @@
import { TranslateIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
import { getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import {
getModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
const getTranslationPrompt = (targetLanguage: string) =>
`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',
placeholder: 'Type or select a model...',
required: true,
options: () => {
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 }) }
})
},
options: getModelOptions,
},
...getProviderCredentialSubBlocks(),
{

View File

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

View File

@@ -1,8 +1,32 @@
import { isHosted } from '@/lib/core/config/feature-flags'
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'
/**
* 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.
* Handles both simple array format and object format with all/any fields.

View File

@@ -1008,7 +1008,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
* Non-virtualized code viewer implementation.
* Renders all lines directly without windowing.
*/
function ViewerInner({
const ViewerInner = memo(function ViewerInner({
code,
showGutter,
language,
@@ -1181,7 +1181,7 @@ function ViewerInner({
</Content>
</Container>
)
}
})
/**
* Readonly code viewer with optional gutter and syntax highlighting.

View File

@@ -1,4 +1,10 @@
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import {
keepPreviousData,
type QueryClient,
useInfiniteQuery,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import type {
@@ -146,17 +152,45 @@ export function useLogsList(
interface UseLogDetailOptions {
enabled?: boolean
refetchInterval?: number | false
refetchInterval?:
| number
| false
| ((query: { state: { data?: WorkflowLog } }) => number | false | undefined)
}
export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) {
const queryClient = useQueryClient()
return useQuery({
queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId as string),
enabled: Boolean(logId) && (options?.enabled ?? true),
refetchInterval: options?.refetchInterval ?? false,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
initialData: () => {
if (!logId) return undefined
const listQueries = queryClient.getQueriesData<{
pages: { logs: WorkflowLog[] }[]
}>({
queryKey: logKeys.lists(),
})
for (const [, data] of listQueries) {
const match = data?.pages?.flatMap((p) => p.logs).find((l) => l.id === logId)
if (match) return match
}
return undefined
},
initialDataUpdatedAt: 0,
})
}
/**
* Prefetches log detail data on hover for instant panel rendering on click.
*/
export function prefetchLogDetail(queryClient: QueryClient, logId: string) {
queryClient.prefetchQuery({
queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId),
staleTime: 30 * 1000,
})
}

View File

@@ -583,7 +583,10 @@ export function parseWorkflowJson(
loops: workflowData.loops || {},
parallels: workflowData.parallels || {},
metadata: workflowData.metadata,
variables: Array.isArray(workflowData.variables) ? workflowData.variables : undefined,
variables:
workflowData.variables && typeof workflowData.variables === 'object'
? workflowData.variables
: undefined,
}
if (regenerateIdsFlag) {

View File

@@ -33,6 +33,44 @@ interface DuplicateWorkflowResult {
subflowsCount: number
}
/**
* Remaps old variable IDs to new variable IDs inside block subBlocks.
* Specifically targets `variables-input` subblocks whose value is an array
* of variable assignments containing a `variableId` field.
*/
function remapVariableIdsInSubBlocks(
subBlocks: Record<string, any>,
varIdMap: Map<string, string>
): Record<string, any> {
const updated: Record<string, any> = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (
subBlock &&
typeof subBlock === 'object' &&
subBlock.type === 'variables-input' &&
Array.isArray(subBlock.value)
) {
updated[key] = {
...subBlock,
value: subBlock.value.map((assignment: any) => {
if (assignment && typeof assignment === 'object' && assignment.variableId) {
const newVarId = varIdMap.get(assignment.variableId)
if (newVarId) {
return { ...assignment, variableId: newVarId }
}
}
return assignment
}),
}
} else {
updated[key] = subBlock
}
}
return updated
}
/**
* Duplicate a workflow with all its blocks, edges, and subflows
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
@@ -104,6 +142,9 @@ export async function duplicateWorkflow(
.where(and(eq(workflow.workspaceId, targetWorkspaceId), folderCondition))
const sortOrder = (minResult?.minOrder ?? 1) - 1
// Mapping from old variable IDs to new variable IDs (populated during variable duplication)
const varIdMapping = new Map<string, string>()
// Create the new workflow first (required for foreign key constraints)
await tx.insert(workflow).values({
id: newWorkflowId,
@@ -123,8 +164,9 @@ export async function duplicateWorkflow(
variables: (() => {
const sourceVars = (source.variables as Record<string, Variable>) || {}
const remapped: Record<string, Variable> = {}
for (const [, variable] of Object.entries(sourceVars) as [string, Variable][]) {
for (const [oldVarId, variable] of Object.entries(sourceVars) as [string, Variable][]) {
const newVarId = crypto.randomUUID()
varIdMapping.set(oldVarId, newVarId)
remapped[newVarId] = {
...variable,
id: newVarId,
@@ -181,6 +223,20 @@ export async function duplicateWorkflow(
}
}
// Update variable references in subBlocks (e.g. variables-input assignments)
let updatedSubBlocks = block.subBlocks
if (
varIdMapping.size > 0 &&
block.subBlocks &&
typeof block.subBlocks === 'object' &&
!Array.isArray(block.subBlocks)
) {
updatedSubBlocks = remapVariableIdsInSubBlocks(
block.subBlocks as Record<string, any>,
varIdMapping
)
}
return {
...block,
id: newBlockId,
@@ -188,6 +244,7 @@ export async function duplicateWorkflow(
parentId: newParentId,
extent: newExtent,
data: updatedData,
subBlocks: updatedSubBlocks,
locked: false, // Duplicated blocks should always be unlocked
createdAt: now,
updatedAt: now,

View File

@@ -57,12 +57,15 @@ export interface ExportWorkflowState {
sortOrder?: number
exportedAt?: string
}
variables?: Array<{
id: string
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
value: unknown
}>
variables?: Record<
string,
{
id: string
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
value: unknown
}
>
}
}

View File

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

View File

@@ -827,11 +827,10 @@ export function formatParameterLabel(paramId: string): string {
}
/**
* SubBlock IDs that are "structural" — they control tool routing or auth,
* not user-facing parameters. These are excluded from tool-input rendering
* unless they have an explicit paramVisibility set.
* SubBlock IDs that control tool routing, not user-facing parameters.
* Excluded from tool-input rendering 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
@@ -955,12 +954,8 @@ export function getSubBlocksForToolInput(
} else if (sb.id in toolParamVisibility) {
visibility = toolParamVisibility[sb.id]
} 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'
} else {
// SubBlock has no corresponding tool param — skip it
continue
}
}

View File

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

View File

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