@@ -395,27 +395,51 @@ export const Dashboard = ({
395395 const [ gateChoices , setGateChoices ] = useState <
396396 Record < string , "approve" | "reject" >
397397 > ( { } ) ;
398- // Clear staged decisions whenever the run leaves the awaiting state (resolved,
399- // re-run, or a new wave streams in fresh) so they never leak across waves/runs.
398+ // Reject notes staged per gate (multi-gate), keyed by step id.
399+ const [ gateReasons , setGateReasons ] = useState < Record < string , string > > ( { } ) ;
400+ // Single-gate header: clicking Reject arms a reason input before confirming.
401+ const [ rejecting , setRejecting ] = useState ( false ) ;
402+ const [ rejectReason , setRejectReason ] = useState ( "" ) ;
403+ // Clear all staged decision/reason state whenever the run leaves the awaiting
404+ // state (resolved, re-run, or a new wave streams in fresh) so nothing leaks.
400405 const awaiting = state . status === "awaiting" ;
401406 const wasAwaitingRef = useRef ( false ) ;
402407 useEffect ( ( ) => {
403- if ( wasAwaitingRef . current && ! awaiting ) setGateChoices ( { } ) ;
408+ if ( wasAwaitingRef . current && ! awaiting ) {
409+ setGateChoices ( { } ) ;
410+ setGateReasons ( { } ) ;
411+ setRejecting ( false ) ;
412+ setRejectReason ( "" ) ;
413+ }
404414 wasAwaitingRef . current = awaiting ;
405415 } , [ awaiting ] ) ;
406416
407417 const setGate = useEventCallback ( ( id : string , choice : "approve" | "reject" ) =>
408418 setGateChoices ( ( m ) => ( { ...m , [ id ] : choice } ) ) ,
409419 ) ;
420+ // A gate flipped back to approve drops any reject note it had staged.
421+ const setGateReason = useEventCallback ( ( id : string , reason : string ) =>
422+ setGateReasons ( ( m ) => ( { ...m , [ id ] : reason } ) ) ,
423+ ) ;
410424 const setAllGates = ( choice : "approve" | "reject" ) =>
411425 setGateChoices ( Object . fromEntries ( pendingIds . map ( ( id ) => [ id , choice ] ) ) ) ;
412426 const allDecided =
413427 gates . length > 0 && pendingIds . every ( ( id ) => gateChoices [ id ] ) ;
428+ // Only notes on gates still staged as reject go out (approve drops the note).
429+ const rejectReasons = ( ) : Record < string , string > =>
430+ Object . fromEntries (
431+ Object . entries ( gateReasons ) . filter (
432+ ( [ id , r ] ) => gateChoices [ id ] === "reject" && r . trim ( ) ,
433+ ) ,
434+ ) ;
414435
415436 const select = ( id : string ) => {
416437 if ( id === selectedId || locked ) return ;
417438 setSelectedId ( id ) ;
418439 setGateChoices ( { } ) ;
440+ setGateReasons ( { } ) ;
441+ setRejecting ( false ) ;
442+ setRejectReason ( "" ) ;
419443 reset ( ) ;
420444 } ;
421445
@@ -592,33 +616,71 @@ export const Dashboard = ({
592616 size = "sm"
593617 data-testid = "submit-decisions"
594618 disabled = { ! allDecided }
595- onClick = { ( ) => decideMany ( selected . id , gateChoices ) }
619+ onClick = { ( ) =>
620+ decideMany ( selected . id , gateChoices , rejectReasons ( ) )
621+ }
596622 >
597623 Submit decisions
598624 </ Button >
599625 </ div >
600626 ) : state . status === "awaiting" && selected ? (
601- // A single gate — decide it straight from the header.
627+ // A single gate — decide it straight from the header. Reject first arms a
628+ // reason input (optional note) before confirming, so a blocked bill carries
629+ // a why into the trace + the audit history.
602630 < div
603631 className = "flex shrink-0 items-center gap-2"
604632 data-testid = "approval-gate"
605633 >
606- < Button
607- variant = "danger"
608- size = "sm"
609- data-testid = "reject-btn"
610- onClick = { ( ) => decide ( selected . id , "reject" ) }
611- >
612- Reject
613- </ Button >
614- < Button
615- variant = "ok"
616- size = "sm"
617- data-testid = "approve-btn"
618- onClick = { ( ) => decide ( selected . id , "approve" ) }
619- >
620- Approve
621- </ Button >
634+ { rejecting ? (
635+ < >
636+ < input
637+ value = { rejectReason }
638+ onChange = { ( e ) => setRejectReason ( e . target . value ) }
639+ onKeyDown = { ( e ) => {
640+ if ( e . key === "Enter" )
641+ void decide ( selected . id , "reject" , rejectReason ) ;
642+ if ( e . key === "Escape" ) setRejecting ( false ) ;
643+ } }
644+ placeholder = "Reason (optional)"
645+ data-testid = "reject-reason"
646+ className = "h-8 w-48 rounded-lg bg-surface px-2.5 text-[12px] text-ink outline-none ring-1 ring-inset ring-line-strong transition-shadow focus:ring-2 focus:ring-accent-ring"
647+ />
648+ < Button
649+ variant = "ghost"
650+ size = "sm"
651+ onClick = { ( ) => setRejecting ( false ) }
652+ >
653+ Cancel
654+ </ Button >
655+ < Button
656+ variant = "danger"
657+ size = "sm"
658+ data-testid = "reject-confirm"
659+ onClick = { ( ) => decide ( selected . id , "reject" , rejectReason ) }
660+ >
661+ Reject
662+ </ Button >
663+ </ >
664+ ) : (
665+ < >
666+ < Button
667+ variant = "danger"
668+ size = "sm"
669+ data-testid = "reject-btn"
670+ onClick = { ( ) => setRejecting ( true ) }
671+ >
672+ Reject
673+ </ Button >
674+ < Button
675+ variant = "ok"
676+ size = "sm"
677+ data-testid = "approve-btn"
678+ onClick = { ( ) => decide ( selected . id , "approve" ) }
679+ >
680+ Approve
681+ </ Button >
682+ </ >
683+ ) }
622684 </ div >
623685 ) : (
624686 // Run lives in the header at all times a row is selected: "Run
@@ -701,9 +763,16 @@ export const Dashboard = ({
701763 < WorkflowGraph
702764 workflow = { graphToShow }
703765 statuses = { graphStatuses }
766+ // When >1 gate pends in parallel, each pending node gets inline
767+ // Approve/Reject (decide one, reject another) + a reason input on a
768+ // node staged reject. A single gate uses the header buttons, so the
769+ // graph stays read-only there.
704770 decidableIds = { gates . length >= 2 ? pendingIds : undefined }
705771 decisions = { gates . length >= 2 ? gateChoices : undefined }
772+ reasons = { gates . length >= 2 ? gateReasons : undefined }
706773 onDecide = { gates . length >= 2 ? setGate : undefined }
774+ onReason = { gates . length >= 2 ? setGateReason : undefined }
775+ // Pan to frame the waiting gate(s) the moment the run pauses.
707776 focusIds = { awaiting ? pendingIds : undefined }
708777 />
709778 </ div >
0 commit comments