mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-29 08:48:02 -05:00
improvement(preview): include current workflow badge in breadcrumb in workflow snapshot (#3062)
* feat(preview): add workflow context badge for nested navigation Adds a badge next to the Back button when viewing nested workflows to help users identify which workflow they are currently viewing. This is especially helpful when navigating deeply into nested workflow blocks. Changes: - Added workflowName field to WorkflowStackEntry interface - Capture workflow name from metadata when drilling down - Display workflow name badge next to Back button Co-authored-by: emir <emir@simstudio.ai> * added workflow name and desc to metadata for workflow preview * added copy and search icon in code in preview editor --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
@@ -133,9 +133,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const finalWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
// Default values for expected properties
|
||||
deploymentStatuses: {},
|
||||
// Data from normalized tables
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
@@ -143,8 +141,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
metadata: {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
},
|
||||
},
|
||||
// Include workflow variables
|
||||
variables: workflowData.variables || {},
|
||||
}
|
||||
|
||||
@@ -166,6 +167,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
metadata: {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
},
|
||||
},
|
||||
variables: workflowData.variables || {},
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
ChevronUp,
|
||||
Clipboard,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
RepeatIcon,
|
||||
Search,
|
||||
SplitIcon,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -813,6 +816,13 @@ function PreviewEditorContent({
|
||||
} = useContextMenu()
|
||||
|
||||
const [contextMenuData, setContextMenuData] = useState({ content: '', copyOnly: false })
|
||||
const [copiedSection, setCopiedSection] = useState<'input' | 'output' | null>(null)
|
||||
|
||||
const handleCopySection = useCallback((content: string, section: 'input' | 'output') => {
|
||||
navigator.clipboard.writeText(content)
|
||||
setCopiedSection(section)
|
||||
setTimeout(() => setCopiedSection(null), 1500)
|
||||
}, [])
|
||||
|
||||
const openContextMenu = useCallback(
|
||||
(e: React.MouseEvent, content: string, copyOnly: boolean) => {
|
||||
@@ -862,9 +872,6 @@ function PreviewEditorContent({
|
||||
}
|
||||
}, [contextMenuData.content])
|
||||
|
||||
/**
|
||||
* Handles mouse down event on the resize handle to initiate resizing
|
||||
*/
|
||||
const handleConnectionsResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setIsResizing(true)
|
||||
@@ -874,18 +881,12 @@ function PreviewEditorContent({
|
||||
[connectionsHeight]
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle connections collapsed state
|
||||
*/
|
||||
const toggleConnectionsCollapsed = useCallback(() => {
|
||||
setConnectionsHeight((prev) =>
|
||||
prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT
|
||||
)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Sets up resize event listeners during resize operations
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
@@ -1205,7 +1206,11 @@ function PreviewEditorContent({
|
||||
}
|
||||
emptyMessage='No input data'
|
||||
>
|
||||
<div onContextMenu={handleExecutionContextMenu} ref={contentRef}>
|
||||
<div
|
||||
onContextMenu={handleExecutionContextMenu}
|
||||
ref={contentRef}
|
||||
className='relative'
|
||||
>
|
||||
<Code.Viewer
|
||||
code={formatValueAsJson(executionData.input)}
|
||||
language='json'
|
||||
@@ -1215,6 +1220,49 @@ function PreviewEditorContent({
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
{/* 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='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopySection(formatValueAsJson(executionData.input), 'input')
|
||||
}}
|
||||
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
|
||||
>
|
||||
{copiedSection === 'input' ? (
|
||||
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
|
||||
) : (
|
||||
<Clipboard className='h-[10px] w-[10px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{copiedSection === 'input' ? 'Copied' : 'Copy'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
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-4)]'
|
||||
>
|
||||
<Search className='h-[10px] w-[10px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Search</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
@@ -1231,7 +1279,7 @@ function PreviewEditorContent({
|
||||
emptyMessage='No output data'
|
||||
isError={executionData.status === 'error'}
|
||||
>
|
||||
<div onContextMenu={handleExecutionContextMenu}>
|
||||
<div onContextMenu={handleExecutionContextMenu} className='relative'>
|
||||
<Code.Viewer
|
||||
code={formatValueAsJson(executionData.output)}
|
||||
language='json'
|
||||
@@ -1244,6 +1292,49 @@ function PreviewEditorContent({
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
{/* 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='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopySection(formatValueAsJson(executionData.output), 'output')
|
||||
}}
|
||||
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
|
||||
>
|
||||
{copiedSection === 'output' ? (
|
||||
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
|
||||
) : (
|
||||
<Clipboard className='h-[10px] w-[10px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{copiedSection === 'output' ? 'Copied' : 'Copy'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
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-4)]'
|
||||
>
|
||||
<Search className='h-[10px] w-[10px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Search</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ interface WorkflowStackEntry {
|
||||
workflowState: WorkflowState
|
||||
traceSpans: TraceSpan[]
|
||||
blockExecutions: Record<string, BlockExecutionData>
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +145,6 @@ export function Preview({
|
||||
initialSelectedBlockId,
|
||||
autoSelectLeftmost = true,
|
||||
}: PreviewProps) {
|
||||
/** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
|
||||
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
|
||||
if (initialSelectedBlockId) return initialSelectedBlockId
|
||||
if (autoSelectLeftmost) {
|
||||
@@ -153,17 +153,14 @@ export function Preview({
|
||||
return null
|
||||
})
|
||||
|
||||
/** Stack for nested workflow navigation. Empty means we're at the root level. */
|
||||
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
|
||||
|
||||
/** Block executions for the root level */
|
||||
const rootBlockExecutions = useMemo(() => {
|
||||
if (providedBlockExecutions) return providedBlockExecutions
|
||||
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
|
||||
return buildBlockExecutions(rootTraceSpans)
|
||||
}, [providedBlockExecutions, rootTraceSpans])
|
||||
|
||||
/** Current block executions - either from stack or root */
|
||||
const blockExecutions = useMemo(() => {
|
||||
if (workflowStack.length > 0) {
|
||||
return workflowStack[workflowStack.length - 1].blockExecutions
|
||||
@@ -171,7 +168,6 @@ export function Preview({
|
||||
return rootBlockExecutions
|
||||
}, [workflowStack, rootBlockExecutions])
|
||||
|
||||
/** Current workflow state - either from stack or root */
|
||||
const workflowState = useMemo(() => {
|
||||
if (workflowStack.length > 0) {
|
||||
return workflowStack[workflowStack.length - 1].workflowState
|
||||
@@ -179,41 +175,39 @@ export function Preview({
|
||||
return rootWorkflowState
|
||||
}, [workflowStack, rootWorkflowState])
|
||||
|
||||
/** Whether we're in execution mode (have trace spans/block executions) */
|
||||
const isExecutionMode = useMemo(() => {
|
||||
return Object.keys(blockExecutions).length > 0
|
||||
}, [blockExecutions])
|
||||
|
||||
/** Handler to drill down into a nested workflow block */
|
||||
const handleDrillDown = useCallback(
|
||||
(blockId: string, childWorkflowState: WorkflowState) => {
|
||||
const blockExecution = blockExecutions[blockId]
|
||||
const childTraceSpans = extractChildTraceSpans(blockExecution)
|
||||
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
|
||||
|
||||
const workflowName = childWorkflowState.metadata?.name || 'Nested Workflow'
|
||||
|
||||
setWorkflowStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
workflowState: childWorkflowState,
|
||||
traceSpans: childTraceSpans,
|
||||
blockExecutions: childBlockExecutions,
|
||||
workflowName,
|
||||
},
|
||||
])
|
||||
|
||||
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
|
||||
const leftmostId = getLeftmostBlockId(childWorkflowState)
|
||||
setPinnedBlockId(leftmostId)
|
||||
},
|
||||
[blockExecutions]
|
||||
)
|
||||
|
||||
/** Handler to go back up the stack */
|
||||
const handleGoBack = useCallback(() => {
|
||||
setWorkflowStack((prev) => prev.slice(0, -1))
|
||||
setPinnedBlockId(null)
|
||||
}, [])
|
||||
|
||||
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
|
||||
const handleNodeClick = useCallback((blockId: string) => {
|
||||
setPinnedBlockId(blockId)
|
||||
}, [])
|
||||
@@ -232,6 +226,8 @@ export function Preview({
|
||||
|
||||
const isNested = workflowStack.length > 0
|
||||
|
||||
const currentWorkflowName = isNested ? workflowStack[workflowStack.length - 1].workflowName : null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height, width }}
|
||||
@@ -242,20 +238,27 @@ export function Preview({
|
||||
)}
|
||||
>
|
||||
{isNested && (
|
||||
<div className='absolute top-[12px] left-[12px] z-20'>
|
||||
<div className='absolute top-[12px] left-[12px] z-20 flex items-center gap-[6px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleGoBack}
|
||||
className='flex h-[30px] items-center gap-[5px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] hover:bg-[var(--surface-4)]'
|
||||
className='flex h-[28px] items-center gap-[5px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] text-[var(--text-secondary)] shadow-sm hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<ArrowLeft className='h-[13px] w-[13px]' />
|
||||
<span className='font-medium text-[13px]'>Back</span>
|
||||
<ArrowLeft className='h-[12px] w-[12px]' />
|
||||
<span className='font-medium text-[12px]'>Back</span>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{currentWorkflowName && (
|
||||
<div className='flex h-[28px] max-w-[200px] items-center rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] shadow-sm'>
|
||||
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{currentWorkflowName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user