@@ -291,7 +291,62 @@ function InspectorPanelIcon({ className }: { className?: string }) {
291291 ) ;
292292}
293293
294- function ErrorsPanel ( { errors } : { errors : string [ ] } ) {
294+ /** Maps a structured ValidationError to a user-friendly message string. */
295+ function friendlyValidationMessage ( e : { code : string ; bindingPath : string ; message : string } ) : string {
296+ // Extract the leaf field name from paths like "input_bindings.ticket_details"
297+ const field = e . bindingPath . includes ( "." ) ? e . bindingPath . split ( "." ) . slice ( 1 ) . join ( "." ) : "" ;
298+ const q = ( s : string ) => s ? `"${ s } "` : "" ;
299+ switch ( e . code ) {
300+ case "missing_required_input" :
301+ return `${ q ( field ) } must be connected` ;
302+ case "binding_type_mismatch" :
303+ return `${ q ( field ) } has an incompatible type` ;
304+ case "unknown_action_input" :
305+ return `${ q ( field ) } is not a recognized input` ;
306+ case "unknown_workflow_input" :
307+ return `${ q ( field ) } references a workflow input that doesn't exist` ;
308+ case "unknown_step_output" :
309+ return field === "when"
310+ ? "Condition references a step output that doesn't exist"
311+ : `${ q ( field ) } references a step output that doesn't exist` ;
312+ case "unknown_step" :
313+ return `${ q ( field ) } references a step that doesn't exist` ;
314+ case "unknown_action_ref" :
315+ return "Unknown action type" ;
316+ case "cycle_detected" :
317+ return "Circular dependency detected" ;
318+ case "duplicate_step_id" :
319+ return "Duplicate step ID" ;
320+ case "when_not_bool" :
321+ return "Condition must evaluate to true/false" ;
322+ case "invalid_when" :
323+ return "Invalid condition" ;
324+ case "invalid_enum_value" :
325+ return "Invalid enum value in condition" ;
326+ case "unknown_workflow_output" :
327+ return "References a workflow output that doesn't exist" ;
328+ case "workflow_output_type_mismatch" :
329+ return "Output type doesn't match the workflow output" ;
330+ case "required_workflow_output_unsatisfied" :
331+ return `Required output ${ q ( field ) } has no producers` ;
332+ case "conditional_output_incomplete_branch" :
333+ return `Required output ${ q ( field ) } is not produced for all cases` ;
334+ case "workflow_output_multiple_producers" :
335+ return "Multiple steps unconditionally produce the same output" ;
336+ default :
337+ return e . message ;
338+ }
339+ }
340+
341+ function ValidationErrorsPanel ( {
342+ validationErrors,
343+ definition,
344+ onJump,
345+ } : {
346+ validationErrors : import ( "@/gen/engine/v1/engine_pb" ) . ValidationError [ ] ;
347+ definition : import ( "@/gen/engine/v1/engine_pb" ) . WorkflowDefinition | null ;
348+ onJump : ( stepId : string ) => void ;
349+ } ) {
295350 const [ expanded , setExpanded ] = useState ( false ) ;
296351 const ref = useRef < HTMLDivElement > ( null ) ;
297352
@@ -304,28 +359,64 @@ function ErrorsPanel({ errors }: { errors: string[] }) {
304359 return ( ) => document . removeEventListener ( "click" , onClick ) ;
305360 } , [ expanded ] ) ;
306361
307- if ( errors . length === 0 ) return null ;
362+ if ( validationErrors . length === 0 ) return null ;
363+
364+ // Build stepId → label map for display
365+ const stepLabelById = new Map < string , string > ( ) ;
366+ for ( const st of definition ?. steps ?? [ ] ) {
367+ stepLabelById . set ( st . stepId , st . name || st . stepId ) ;
368+ }
369+
308370 return (
309- < div className = "relative ml-1 shrink-0" ref = { ref } >
371+ < div className = "relative shrink-0" ref = { ref } >
310372 < button
311373 type = "button"
312374 onClick = { ( ) => setExpanded ( ( v ) => ! v ) }
313- className = "flex items-center gap-1.5 rounded-lg border border-red-300 bg-red-50 px-2 py-1.5 text-red-800 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/60 dark:text-red-200 dark:hover:bg-red-900/50 "
375+ className = "flex items-center gap-1 rounded border border-red-200 bg-red-50 px-2 py-1.5 text-red-700 transition-colors hover:bg-red-100 dark:border-red-800/50 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-900/40 "
314376 aria-expanded = { expanded }
315- aria-label = { `${ errors . length } error(s)` }
377+ aria-label = { `${ validationErrors . length } validation error(s)` }
316378 >
317- < AlertIcon className = "h-4 w-4 " />
318- < span className = "text-xs font-semibold" > { errors . length } </ span >
379+ < AlertIcon className = "h-3.5 w-3.5 " />
380+ < span className = "text-xs font-semibold" > { validationErrors . length } </ span >
319381 </ button >
320382 { expanded ? (
321- < div className = "absolute right-0 top-full z-50 mt-1.5 max-h-72 w-[min(26rem,calc(100vw-2rem))] overflow-auto rounded-lg border border-red-200 bg-white py-2 shadow-lg dark:border-red-900/50 dark:bg-zinc-950" >
322- < div className = "px-3 pb-2 text-xs font-semibold text-red-700 dark:text-red-300" > Errors</ div >
323- < ul className = "space-y-1 px-3" >
324- { errors . map ( ( e , i ) => (
325- < li key = { i } className = "break-words rounded bg-red-50/80 py-2 px-2.5 text-sm text-red-900 dark:bg-red-950/40 dark:text-red-100" >
326- { e }
327- </ li >
328- ) ) }
383+ < div className = "absolute right-0 top-full z-50 mt-1.5 w-[min(28rem,calc(100vw-2rem))] overflow-hidden rounded-lg border border-red-200 bg-white shadow-lg dark:border-red-900/50 dark:bg-zinc-950" >
384+ < div className = "border-b border-red-100 px-3 py-2 text-[11px] font-semibold text-red-700 dark:border-red-900/50 dark:text-red-300" >
385+ { validationErrors . length } validation error{ validationErrors . length !== 1 ? "s" : "" }
386+ </ div >
387+ < ul className = "max-h-80 overflow-auto py-1.5" >
388+ { validationErrors . map ( ( e , i ) => {
389+ const stepLabel = e . stepId ? ( stepLabelById . get ( e . stepId ) ?? e . stepId ) : null ;
390+ const msg = friendlyValidationMessage ( e ) ;
391+ const clickable = ! ! e . stepId ;
392+ return (
393+ < li key = { i } >
394+ < button
395+ type = "button"
396+ disabled = { ! clickable }
397+ onClick = { ( ) => {
398+ if ( e . stepId ) {
399+ onJump ( e . stepId ) ;
400+ setExpanded ( false ) ;
401+ }
402+ } }
403+ className = { [
404+ "w-full px-3 py-2 text-left transition-colors" ,
405+ clickable
406+ ? "cursor-pointer hover:bg-red-50/60 dark:hover:bg-red-950/30"
407+ : "cursor-default" ,
408+ ] . join ( " " ) }
409+ >
410+ { stepLabel ? (
411+ < div className = "mb-0.5 text-[10px] font-medium text-zinc-500 dark:text-zinc-400" >
412+ { stepLabel }
413+ </ div >
414+ ) : null }
415+ < div className = "text-xs text-red-800 dark:text-red-200" > { msg } </ div >
416+ </ button >
417+ </ li >
418+ ) ;
419+ } ) }
329420 </ ul >
330421 </ div >
331422 ) : null }
@@ -349,7 +440,7 @@ export default function WorkflowBuilderPage({
349440 const [ edges , setEdges ] = useEdgesState < Edge > ( [ ] ) ;
350441 const [ errors , setErrors ] = useState < string [ ] > ( [ ] ) ;
351442 const [ validationErrors , setValidationErrors ] = useState <
352- { stepId ?: string ; message : string } [ ]
443+ import ( "@/gen/engine/v1/engine_pb" ) . ValidationError [ ]
353444 > ( [ ] ) ;
354445 const [ status , setStatus ] = useState <
355446 "idle" | "loading" | "saving" | "validating"
@@ -1213,13 +1304,10 @@ export default function WorkflowBuilderPage({
12131304 create ( ValidateRequestSchema , { definition } )
12141305 ) ;
12151306 if ( ! resp . runnable ) {
1216- const errs = resp . errors . map ( ( e ) => ( e . stepId ? `Step ${ e . stepId } : ` : "" ) + e . message ) ;
1217- setErrors ( errs ) ;
1218- setValidationErrors (
1219- resp . errors . map ( ( e ) => ( { stepId : e . stepId || undefined , message : e . message } ) )
1220- ) ;
1307+ setValidationErrors ( resp . errors ) ;
1308+ setErrors ( resp . errors . map ( ( e ) => friendlyValidationMessage ( e ) ) ) ;
12211309 setLastValidateOk ( false ) ;
1222- const first = resp . errors [ 0 ] ;
1310+ const first = resp . errors . find ( ( e ) => e . stepId ) ;
12231311 setFirstValidationErrorStepId ( first ?. stepId ?? null ) ;
12241312 return ;
12251313 }
@@ -1573,14 +1661,12 @@ export default function WorkflowBuilderPage({
15731661 </ button >
15741662 < button
15751663 className = { [
1576- "flex min-w-[80px] items-center justify-center gap-1.5 rounded border px-3 py-1.5 text-xs font-medium transition-colors" ,
1664+ "flex min-w-[80px] items-center justify-center gap-1.5 rounded px-3 py-1.5 text-xs font-semibold transition-colors" ,
15771665 status === "validating"
1578- ? "cursor-wait border-zinc-200 bg-zinc-50 text-zinc-500 dark:border -zinc-700 dark:bg -zinc-900"
1666+ ? "cursor-wait bg-zinc-800 text-white dark:bg -zinc-200 dark:text -zinc-900"
15791667 : validateSuccessAt
1580- ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400"
1581- : errors . length > 0 && lastValidateOk === false
1582- ? "border-red-200 bg-red-50 text-red-700 dark:border-red-800/50 dark:bg-red-950/30 dark:text-red-400"
1583- : "border-zinc-200 text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800" ,
1668+ ? "bg-emerald-600 text-white dark:bg-emerald-500"
1669+ : "bg-zinc-200 text-zinc-500 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-600" ,
15841670 ] . join ( " " ) }
15851671 onClick = { ( ) => void validate ( ) }
15861672 disabled = { status !== "idle" }
@@ -1589,13 +1675,21 @@ export default function WorkflowBuilderPage({
15891675 < SpinnerIcon className = "h-3.5 w-3.5" />
15901676 ) : validateSuccessAt ? (
15911677 < CheckIcon className = "h-3.5 w-3.5" />
1592- ) : errors . length > 0 && lastValidateOk === false ? (
1593- < svg className = "h-3.5 w-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" strokeWidth = { 2 } >
1594- < path strokeLinecap = "round" strokeLinejoin = "round" d = "M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
1595- </ svg >
15961678 ) : null }
1597- { status === "validating" ? "Checking…" : validateSuccessAt ? "Valid" : errors . length > 0 && lastValidateOk === false ? "Errors" : "Validate" }
1679+ { status === "validating" ? "Checking…" : validateSuccessAt ? "Valid" : "Validate" }
15981680 </ button >
1681+ < ValidationErrorsPanel
1682+ validationErrors = { validationErrors }
1683+ definition = { definition }
1684+ onJump = { ( stepId ) => {
1685+ setShowRightPanel ( true ) ;
1686+ setSelectedStepId ( stepId ) ;
1687+ // Scroll canvas to the erroring node
1688+ setTimeout ( ( ) => {
1689+ rf ?. fitView ( { nodes : [ { id : stepId } ] , padding : 0.35 , duration : 350 } ) ;
1690+ } , 50 ) ;
1691+ } }
1692+ />
15991693 < button
16001694 className = { [
16011695 "flex min-w-[72px] items-center justify-center gap-1.5 rounded px-3 py-1.5 text-xs font-semibold transition-colors" ,
@@ -1639,15 +1733,7 @@ export default function WorkflowBuilderPage({
16391733 ? "bg-emerald-50/70 dark:bg-emerald-950/30"
16401734 : "bg-zinc-50/60 dark:bg-zinc-900/30" ,
16411735 operationStatusClass ,
1642- firstValidationErrorStepId ? "cursor-pointer hover:bg-zinc-100/80 dark:hover:bg-zinc-900/60" : "" ,
16431736 ] . join ( " " ) }
1644- onClick = { ( ) => {
1645- if ( firstValidationErrorStepId ) {
1646- setShowRightPanel ( true ) ;
1647- setSelectedStepId ( firstValidationErrorStepId ) ;
1648- }
1649- } }
1650- role = { firstValidationErrorStepId ? "button" : undefined }
16511737 >
16521738 < span
16531739 className = { [
@@ -1662,11 +1748,6 @@ export default function WorkflowBuilderPage({
16621748 ] . join ( " " ) }
16631749 />
16641750 < span className = "truncate" > { operationStatusText } </ span >
1665- { errors . length > 0 && lastValidateOk === false ? (
1666- < span className = "ml-1 shrink-0 text-[10px] text-zinc-500 dark:text-zinc-400" >
1667- (click to jump to first error)
1668- </ span >
1669- ) : null }
16701751 </ div >
16711752 ) : null }
16721753
@@ -2528,7 +2609,7 @@ function StepInspector({
25282609 onSetOptionalConstBinding : ( inputName : string , jsValue : unknown ) => void ;
25292610 onClearOptionalBinding : ( inputName : string ) => void ;
25302611 onSetOptionalSourceBinding : ( inputName : string , sourceKey : string ) => void ;
2531- validationErrors : { stepId ?: string ; message : string } [ ] ;
2612+ validationErrors : import ( "@/gen/engine/v1/engine_pb" ) . ValidationError [ ] ;
25322613} ) {
25332614 const [ tab , setTab ] = useState < "inputs" | "outputs" | "when" | "advanced" > ( "inputs" ) ;
25342615 const [ showActions , setShowActions ] = useState ( false ) ;
@@ -2709,24 +2790,6 @@ function StepInspector({
27092790 Input Bindings
27102791 </ div >
27112792 </ div >
2712- { /* Per-step validation messages */ }
2713- { validationErrors && validationErrors . length > 0 ? (
2714- < div className = "mt-2 space-y-1.5" >
2715- { validationErrors
2716- . filter ( ( ve ) => ! ve . stepId || ve . stepId === selectedStep . stepId )
2717- . map ( ( ve , idx : number ) => (
2718- < div
2719- key = { `${ ve . stepId ?? "workflow" } :${ idx } ` }
2720- className = "flex items-start gap-1 text-[11px] text-red-600 dark:text-red-300"
2721- >
2722- < span className = "mt-[3px] inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
2723- < span className = "leading-snug" >
2724- { ve . message }
2725- </ span >
2726- </ div >
2727- ) ) }
2728- </ div >
2729- ) : null }
27302793 < div className = "mt-2 space-y-1.5" >
27312794 { Object . entries ( selectedStep . inputBindings ?? { } )
27322795 . filter ( ( [ name ] ) => name !== "..." ) // strip sentinel
0 commit comments