Skip to content

Commit 0dc61a7

Browse files
committed
feat(ui): add parent/child workflow navigation
- Add parentRunId and parentNodeRef fields to run API response - Add floating breadcrumb on canvas when viewing child runs - Add 'View Child Run' button on workflow.call nodes - Add /runs/:runId redirect route for navigation - Add RunBreadcrumbs component with floating/inline variants - Add getChildRuns API method for listing child runs - Update runStore to include parent run fields Navigation flow: - Parent run: 'View Child Run' button appears on call-child node - Child run: Floating breadcrumb links back to parent workflow Signed-off-by: betterclever <[email protected]>
1 parent 213205a commit 0dc61a7

File tree

16 files changed

+376
-40
lines changed

16 files changed

+376
-40
lines changed

backend/src/workflows/workflows.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export interface WorkflowRunSummary {
7373
triggerSource?: string | null;
7474
triggerLabel?: string | null;
7575
inputPreview: ExecutionInputPreview;
76+
parentRunId?: string | null;
77+
parentNodeRef?: string | null;
7678
}
7779

7880
const SHIPSEC_WORKFLOW_TYPE = 'shipsecWorkflowRun';
@@ -494,6 +496,8 @@ export class WorkflowsService {
494496
triggerSource,
495497
triggerLabel,
496498
inputPreview,
499+
parentRunId: run.parentRunId ?? null,
500+
parentNodeRef: run.parentNodeRef ?? null,
497501
};
498502
}
499503

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { IntegrationCallback } from '@/pages/IntegrationCallback'
99
import { NotFound } from '@/pages/NotFound'
1010
import { SchedulesPage } from '@/pages/SchedulesPage'
1111
import { ActionCenterPage } from '@/pages/ActionCenterPage'
12+
import { RunRedirect } from '@/pages/RunRedirect'
1213
import { ToastProvider } from '@/components/ui/toast-provider'
1314
import { AppLayout } from '@/components/layout/AppLayout'
1415
import { AuthProvider } from '@/auth/auth-context'
@@ -77,6 +78,7 @@ function App() {
7778
<Route path="/schedules" element={<SchedulesPage />} />
7879
<Route path="/action-center" element={<ActionCenterPage />} />
7980
<Route path="/artifacts" element={<ArtifactLibrary />} />
81+
<Route path="/runs/:runId" element={<RunRedirect />} />
8082
<Route
8183
path="/integrations/callback/:provider"
8284
element={<IntegrationCallback />}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { useEffect, useState } from 'react'
2+
import { useNavigate } from 'react-router-dom'
3+
import { GitBranch, ArrowLeft } from 'lucide-react'
4+
import { api } from '@/services/api'
5+
import { cn } from '@/lib/utils'
6+
7+
interface RunInfo {
8+
id: string
9+
workflowId: string
10+
workflowName: string
11+
parentRunId?: string | null
12+
parentNodeRef?: string | null
13+
}
14+
15+
interface RunBreadcrumbsProps {
16+
currentRun: RunInfo | null
17+
className?: string
18+
/** 'floating' for canvas overlay, 'inline' for panel integration */
19+
variant?: 'floating' | 'inline'
20+
}
21+
22+
/**
23+
* Displays breadcrumb navigation for parent/child workflow runs.
24+
* Shows a link to navigate back to the parent run.
25+
*/
26+
export function RunBreadcrumbs({ currentRun, className, variant = 'inline' }: RunBreadcrumbsProps) {
27+
const navigate = useNavigate()
28+
const [parentRun, setParentRun] = useState<RunInfo | null>(null)
29+
const [loading, setLoading] = useState(false)
30+
31+
useEffect(() => {
32+
if (!currentRun?.parentRunId) {
33+
setParentRun(null)
34+
return
35+
}
36+
37+
const fetchParentRun = async () => {
38+
setLoading(true)
39+
try {
40+
const run = await api.executions.getRun(currentRun.parentRunId!)
41+
if (run) {
42+
setParentRun({
43+
id: run.id as string,
44+
workflowId: run.workflowId as string,
45+
workflowName: (run as any).workflowName || 'Parent Workflow',
46+
parentRunId: (run as any).parentRunId,
47+
parentNodeRef: (run as any).parentNodeRef,
48+
})
49+
}
50+
} catch (err) {
51+
console.error('Failed to fetch parent run:', err)
52+
} finally {
53+
setLoading(false)
54+
}
55+
}
56+
57+
fetchParentRun()
58+
}, [currentRun?.parentRunId])
59+
60+
// Only show breadcrumbs if this is a child run
61+
if (!currentRun?.parentRunId) {
62+
return null
63+
}
64+
65+
const handleNavigateToParent = () => {
66+
if (parentRun) {
67+
navigate(`/workflows/${parentRun.workflowId}/runs/${parentRun.id}`)
68+
}
69+
}
70+
71+
if (variant === 'floating') {
72+
return (
73+
<div
74+
className={cn(
75+
'flex items-center gap-2 px-3 py-2 rounded-md border bg-background shadow-sm',
76+
'text-xs font-medium transition-all duration-200',
77+
className
78+
)}
79+
>
80+
<GitBranch className="h-4 w-4 text-muted-foreground flex-shrink-0" />
81+
<span className="text-muted-foreground">Child of</span>
82+
83+
{loading ? (
84+
<span className="text-muted-foreground animate-pulse">loading...</span>
85+
) : parentRun ? (
86+
<button
87+
onClick={handleNavigateToParent}
88+
className="inline-flex items-center gap-1.5 font-medium text-primary hover:text-primary/80 hover:underline"
89+
>
90+
<ArrowLeft className="h-3.5 w-3.5" />
91+
<span className="truncate max-w-[180px]" title={parentRun.workflowName}>
92+
{parentRun.workflowName}
93+
</span>
94+
</button>
95+
) : (
96+
<span className="font-mono text-muted-foreground">
97+
{currentRun.parentRunId.split('-').slice(0, 3).join('-')}
98+
</span>
99+
)}
100+
101+
{currentRun.parentNodeRef && (
102+
<code className="font-mono text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
103+
{currentRun.parentNodeRef}
104+
</code>
105+
)}
106+
</div>
107+
)
108+
}
109+
110+
// Inline variant (for panel integration)
111+
return (
112+
<div className={cn('flex items-center gap-1.5 text-xs', className)}>
113+
<GitBranch className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
114+
<span className="text-muted-foreground">Sub-workflow of</span>
115+
116+
{loading ? (
117+
<span className="text-muted-foreground animate-pulse">loading...</span>
118+
) : parentRun ? (
119+
<button
120+
onClick={handleNavigateToParent}
121+
className="inline-flex items-center gap-1 font-medium text-primary hover:text-primary/80 hover:underline"
122+
>
123+
<ArrowLeft className="h-3 w-3" />
124+
<span className="truncate max-w-[200px]" title={parentRun.workflowName}>
125+
{parentRun.workflowName}
126+
</span>
127+
<span className="font-mono text-[10px] text-muted-foreground">
128+
({parentRun.id.split('-').slice(0, 3).join('-')})
129+
</span>
130+
</button>
131+
) : (
132+
<span className="font-mono text-muted-foreground">
133+
{currentRun.parentRunId.split('-').slice(0, 3).join('-')}
134+
</span>
135+
)}
136+
137+
{currentRun.parentNodeRef && (
138+
<>
139+
<span className="text-muted-foreground mx-1"></span>
140+
<span className="text-muted-foreground">
141+
node <code className="font-mono text-[10px] bg-muted px-1 py-0.5 rounded">{currentRun.parentNodeRef}</code>
142+
</span>
143+
</>
144+
)}
145+
</div>
146+
)
147+
}

frontend/src/components/workflow/ParameterField.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,7 @@ export function ParameterField({
114114
const graph = workflow.graph
115115
const entrypoint = graph.nodes.find((node) => node.type === 'core.workflow.entrypoint')
116116

117-
const runtimeInputsCandidate =
118-
entrypoint && typeof entrypoint === 'object'
119-
? ((entrypoint as any).data?.config as any)?.runtimeInputs
120-
: undefined
117+
const runtimeInputsCandidate = entrypoint?.data?.config?.runtimeInputs
121118

122119
const runtimeInputs = Array.isArray(runtimeInputsCandidate)
123120
? runtimeInputsCandidate

frontend/src/components/workflow/WorkflowBuilderShell.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ interface WorkflowBuilderShellProps {
2828
onRedo?: () => void
2929
canUndo?: boolean
3030
canRedo?: boolean
31+
/** Floating overlay content for execution mode (e.g., parent run breadcrumbs) */
32+
executionOverlay?: ReactNode
3133
}
3234

3335
const LIBRARY_PANEL_WIDTH = 320
@@ -58,6 +60,7 @@ export function WorkflowBuilderShell({
5860
onRedo,
5961
canUndo,
6062
canRedo,
63+
executionOverlay,
6164
}: WorkflowBuilderShellProps) {
6265
const isMobile = useIsMobile()
6366
const layoutRef = useRef<HTMLDivElement | null>(null)
@@ -291,6 +294,14 @@ export function WorkflowBuilderShell({
291294
)}
292295
</div>
293296
)}
297+
298+
{/* Execution mode floating overlay (e.g., parent run breadcrumbs) */}
299+
{mode === 'execution' && executionOverlay && (
300+
<div className="absolute z-[35] top-[10px] left-[10px]">
301+
{executionOverlay}
302+
</div>
303+
)}
304+
294305
{showLoadingOverlay && (
295306
<div className="absolute inset-0 z-[70] flex flex-col items-center justify-center bg-background/60 backdrop-blur-sm">
296307
<svg

frontend/src/components/workflow/WorkflowNode.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useRef, useState } from 'react'
22
import { Handle, NodeResizer, Position, type NodeProps, type Node, useReactFlow, useUpdateNodeInternals } from 'reactflow'
33
import { ExecutionErrorView } from './ExecutionErrorView'
4-
import { Loader2, CheckCircle, XCircle, Clock, Activity, AlertCircle, Pause, Terminal as TerminalIcon, Trash2, ChevronDown } from 'lucide-react'
4+
import { Loader2, CheckCircle, XCircle, Clock, Activity, AlertCircle, Pause, Terminal as TerminalIcon, Trash2, ChevronDown, ExternalLink } from 'lucide-react'
55
import * as LucideIcons from 'lucide-react'
66
import { cn } from '@/lib/utils'
77
import { MarkdownView } from '@/components/ui/markdown'
@@ -1143,6 +1143,22 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps<NodeData>) => {
11431143
</Badge>
11441144
)}
11451145

1146+
{/* View Child Run button - shown when this node spawned a child workflow */}
1147+
{visualState.lastMetadata?.childRunId && (
1148+
<Button
1149+
variant="outline"
1150+
size="sm"
1151+
className="w-full h-7 text-xs font-medium gap-1.5 bg-violet-50 hover:bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-900/20 dark:hover:bg-violet-900/30 dark:text-violet-300 dark:border-violet-700"
1152+
onClick={(e) => {
1153+
e.stopPropagation()
1154+
navigate(`/runs/${visualState.lastMetadata!.childRunId}`)
1155+
}}
1156+
>
1157+
<ExternalLink className="h-3 w-3" />
1158+
View Child Run
1159+
</Button>
1160+
)}
1161+
11461162
{/* Detailed error representation - shown only when expanded */}
11471163
{visualState.status === 'error' && showErrorDetails && visualState.lastEvent?.error && (
11481164
<ExecutionErrorView

frontend/src/features/workflow-builder/WorkflowBuilder.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { TopBar } from '@/components/layout/TopBar'
1111
import { Sidebar } from '@/components/layout/Sidebar'
1212
import { ExecutionInspector } from '@/components/timeline/ExecutionInspector'
13+
import { RunBreadcrumbs } from '@/components/timeline/RunBreadcrumbs'
1314
import { RunWorkflowDialog } from '@/components/workflow/RunWorkflowDialog'
1415
import { WorkflowBuilderShell } from '@/components/workflow/WorkflowBuilderShell'
1516
import {
@@ -34,6 +35,7 @@ import { useWorkflowUiStore } from '@/store/workflowUiStore'
3435
import { useExecutionTimelineStore } from '@/store/executionTimelineStore'
3536
import { useWorkflowExecutionLifecycle } from '@/features/workflow-builder/hooks/useWorkflowExecutionLifecycle'
3637
import { api, API_BASE_URL } from '@/services/api'
38+
import { useRunStore } from '@/store/runStore'
3739
import {
3840
deserializeNodes,
3941
deserializeEdges,
@@ -154,6 +156,8 @@ function WorkflowBuilderContent() {
154156
} = useWorkflowUiStore()
155157

156158
const selectedRunId = useExecutionTimelineStore((state) => state.selectedRunId)
159+
const getRunById = useRunStore((state) => state.getRunById)
160+
const selectedRun = selectedRunId ? getRunById(selectedRunId) : null
157161
const isMobile = useIsMobile()
158162

159163
// Undo/redo history management
@@ -954,6 +958,20 @@ function WorkflowBuilderContent() {
954958
onRedo={redo}
955959
canUndo={canUndo}
956960
canRedo={canRedo}
961+
executionOverlay={
962+
selectedRun?.parentRunId ? (
963+
<RunBreadcrumbs
964+
currentRun={{
965+
id: selectedRun.id,
966+
workflowId: selectedRun.workflowId,
967+
workflowName: selectedRun.workflowName,
968+
parentRunId: selectedRun.parentRunId,
969+
parentNodeRef: selectedRun.parentNodeRef,
970+
}}
971+
variant="floating"
972+
/>
973+
) : null
974+
}
957975
/>
958976
)
959977
}

frontend/src/pages/RunRedirect.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useEffect, useState } from 'react'
2+
import { useParams, useNavigate } from 'react-router-dom'
3+
import { api } from '@/services/api'
4+
import { Loader2 } from 'lucide-react'
5+
6+
/**
7+
* Redirect page for /runs/:runId
8+
*
9+
* Fetches the run to get its workflowId, then redirects to the correct URL:
10+
* /workflows/:workflowId/runs/:runId
11+
*/
12+
export function RunRedirect() {
13+
const { runId } = useParams<{ runId: string }>()
14+
const navigate = useNavigate()
15+
const [error, setError] = useState<string | null>(null)
16+
17+
useEffect(() => {
18+
if (!runId) {
19+
setError('No run ID provided')
20+
return
21+
}
22+
23+
const fetchAndRedirect = async () => {
24+
try {
25+
const run = await api.executions.getRun(runId)
26+
if (run?.workflowId) {
27+
navigate(`/workflows/${run.workflowId}/runs/${runId}`, { replace: true })
28+
} else {
29+
setError('Run not found or missing workflow ID')
30+
}
31+
} catch (err) {
32+
console.error('Failed to fetch run:', err)
33+
setError('Failed to load run')
34+
}
35+
}
36+
37+
fetchAndRedirect()
38+
}, [runId, navigate])
39+
40+
if (error) {
41+
return (
42+
<div className="flex h-full items-center justify-center">
43+
<div className="text-center">
44+
<p className="text-destructive">{error}</p>
45+
<button
46+
onClick={() => navigate('/')}
47+
className="mt-4 text-sm text-primary hover:underline"
48+
>
49+
Go to Dashboard
50+
</button>
51+
</div>
52+
</div>
53+
)
54+
}
55+
56+
return (
57+
<div className="flex h-full items-center justify-center">
58+
<div className="flex items-center gap-2 text-muted-foreground">
59+
<Loader2 className="h-4 w-4 animate-spin" />
60+
<span>Loading run...</span>
61+
</div>
62+
</div>
63+
)
64+
}

frontend/src/services/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,12 @@ export const api = {
661661
return response.data
662662
},
663663

664+
getChildRuns: async (runId: string) => {
665+
const response = await apiClient.listWorkflowRunChildren(runId)
666+
if (response.error) throw new Error('Failed to fetch child runs')
667+
return response.data || { runs: [] }
668+
},
669+
664670
getLogs: async (runId: string, options?: { nodeRef?: string; stream?: 'stdout' | 'stderr' | 'console'; level?: 'debug' | 'info' | 'warn' | 'error'; limit?: number; cursor?: string; startTime?: string; endTime?: string }) => {
665671
const client = createShipSecClient({
666672
baseUrl: API_BASE_URL,

frontend/src/store/runStore.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface ExecutionRun {
2222
triggerSource: string | null
2323
triggerLabel: string | null
2424
inputPreview: ExecutionInputPreview
25+
parentRunId?: string | null
26+
parentNodeRef?: string | null
2527
}
2628

2729
interface RunCacheEntry {
@@ -115,6 +117,8 @@ const normalizeRun = (run: any): ExecutionRun => {
115117
triggerLabel: triggerLabelRaw.length > 0 ? triggerLabelRaw : TRIGGER_LABELS[triggerType],
116118
inputPreview:
117119
(run.inputPreview as ExecutionInputPreview) ?? { runtimeInputs: {}, nodeOverrides: {} },
120+
parentRunId: typeof run.parentRunId === 'string' ? run.parentRunId : null,
121+
parentNodeRef: typeof run.parentNodeRef === 'string' ? run.parentNodeRef : null,
118122
}
119123
}
120124

0 commit comments

Comments
 (0)