@@ -21,6 +21,12 @@ import { useEffect, useMemo, useRef } from "react";
2121
2222import { Badge } from "@/components/ui/badge" ;
2323import { SlackIcon , NetSuiteIcon , JiraIcon } from "@/components/ui/brand-icon" ;
24+ import {
25+ Tooltip ,
26+ TooltipContent ,
27+ TooltipProvider ,
28+ TooltipTrigger ,
29+ } from "@/components/ui/tooltip" ;
2430import { useMediaQuery } from "@/hooks/use-media-query" ;
2531import {
2632 type ApprovalWorkflow ,
@@ -139,6 +145,12 @@ type NodeData = {
139145 onDecide ?: ( choice : "approve" | "reject" ) => void ;
140146 /** Record the reviewer's reject note for this gate. */
141147 onReason ?: ( reason : string ) => void ;
148+ /** The AI investigator's call for this run (verdict + reasoning), shown in the gate
149+ toolbar so it informs the decision without opening the trace. */
150+ recommendation ?: {
151+ verdict : "likely_legitimate" | "likely_overcharge" | "unclear" ;
152+ rationale : string | null ;
153+ } ;
142154} ;
143155
144156/** Ring/bg for a validation issue on a node (only used when there's no diff change). */
@@ -148,6 +160,56 @@ const issueRing = (sev: "error" | "warning" | undefined): string => {
148160 return "ring-line" ;
149161} ;
150162
163+ /** The label + tone for the AI investigator's verdict. */
164+ const VERDICT_META : Record <
165+ "likely_legitimate" | "likely_overcharge" | "unclear" ,
166+ { label : string ; text : string ; dot : string }
167+ > = {
168+ likely_legitimate : {
169+ label : "Likely legitimate" ,
170+ text : "text-ok" ,
171+ dot : "bg-ok" ,
172+ } ,
173+ likely_overcharge : {
174+ label : "Likely overcharge" ,
175+ text : "text-danger" ,
176+ dot : "bg-danger" ,
177+ } ,
178+ unclear : { label : "Unclear" , text : "text-warn" , dot : "bg-warn" } ,
179+ } ;
180+
181+ /** The AI investigator's call on the paused gate: a compact verdict chip (with a
182+ sparkle so it reads as the AI's take, not a status), and the full reasoning on
183+ hover, so the human sees what the agent concluded right where they decide. */
184+ const RecommendationChip = ( {
185+ recommendation,
186+ } : {
187+ recommendation : NonNullable < NodeData [ "recommendation" ] > ;
188+ } ) => {
189+ const meta = VERDICT_META [ recommendation . verdict ] ;
190+ const chip = (
191+ < span
192+ className = { `inline-flex w-full items-center gap-1.5 rounded-lg bg-subtle/60 px-2 py-1 text-[11px] font-medium ${ meta . text } ` }
193+ >
194+ < span aria-hidden > ✦</ span >
195+ < span className = "text-faint" > AI:</ span >
196+ < span className = { `inline-block size-1.5 rounded-full ${ meta . dot } ` } />
197+ { meta . label }
198+ </ span >
199+ ) ;
200+ if ( ! recommendation . rationale ) return chip ;
201+ return (
202+ < TooltipProvider >
203+ < Tooltip >
204+ < TooltipTrigger asChild > { chip } </ TooltipTrigger >
205+ < TooltipContent side = "bottom" className = "max-w-[280px]" >
206+ { recommendation . rationale }
207+ </ TooltipContent >
208+ </ Tooltip >
209+ </ TooltipProvider >
210+ ) ;
211+ } ;
212+
151213/** A workflow step rendered as the app's card, used as a React Flow custom node. */
152214const StepNode = ( { data } : NodeProps < Node < NodeData > > ) => {
153215 const {
@@ -164,6 +226,7 @@ const StepNode = ({ data }: NodeProps<Node<NodeData>>) => {
164226 reason,
165227 onDecide,
166228 onReason,
229+ recommendation,
167230 } = data ;
168231 const st = statusTone ( status ) ;
169232 const cb = changeBadge ( change ) ;
@@ -324,6 +387,9 @@ const StepNode = ({ data }: NodeProps<Node<NodeData>>) => {
324387 position = { Position . Bottom }
325388 className = "nodrag nopan flex w-[244px] flex-col gap-1.5 rounded-xl bg-surface p-2 shadow-lift ring-1 ring-inset ring-line"
326389 >
390+ { recommendation && (
391+ < RecommendationChip recommendation = { recommendation } />
392+ ) }
327393 < div className = "flex gap-1.5" >
328394 < button
329395 type = "button"
@@ -496,6 +562,7 @@ const Inner = ({
496562 reasons,
497563 onDecide,
498564 onReason,
565+ recommendation,
499566 focusIds,
500567} : WorkflowGraphProps ) => {
501568 // Stack the DAG vertically below the `sm` breakpoint (640px), where a wide
@@ -732,12 +799,16 @@ const Inner = ({
732799 isDecidable && onReason
733800 ? ( r : string ) => onReason ( n . id , r )
734801 : undefined ;
802+ // The AI recommendation rides only on a decidable gate (where it helps the
803+ // decision); other nodes carry none.
804+ const rec = isDecidable ? ( recommendation ?? undefined ) : undefined ;
735805 if (
736806 n . data . decidable === isDecidable &&
737807 n . data . choice === choice &&
738808 n . data . reason === reason &&
739809 n . data . onDecide === handler &&
740- n . data . onReason === reasonHandler
810+ n . data . onReason === reasonHandler &&
811+ n . data . recommendation === rec
741812 ) {
742813 return n ;
743814 }
@@ -750,6 +821,7 @@ const Inner = ({
750821 reason : reason ?? undefined ,
751822 onDecide : handler ,
752823 onReason : reasonHandler ,
824+ recommendation : rec ,
753825 } ,
754826 } ;
755827 } ) ,
@@ -763,6 +835,7 @@ const Inner = ({
763835 reasons ,
764836 onDecide ,
765837 onReason ,
838+ recommendation ,
766839 setNodes ,
767840 ] ) ;
768841
@@ -871,6 +944,13 @@ type WorkflowGraphProps = {
871944 onDecide ?: ( stepId : string , choice : "approve" | "reject" ) => void ;
872945 /** Record a per-gate reject note (the inline node reason input). */
873946 onReason ?: ( stepId : string , reason : string ) => void ;
947+ /** The AI investigator's call for THIS run, shown on the paused gate(s) so the human
948+ sees what the agent concluded (verdict + reasoning on hover) right where they
949+ decide. Null when there's no investigation (a clean run) or not paused. */
950+ recommendation ?: {
951+ verdict : "likely_legitimate" | "likely_overcharge" | "unclear" ;
952+ rationale : string | null ;
953+ } | null ;
874954 /** Smoothly pan/zoom to frame these nodes (the pending group, or one on hover). */
875955 focusIds ?: string [ ] ;
876956} ;
0 commit comments