Skip to content

Commit c34011b

Browse files
DylanMerigaudclaude
andcommitted
#5 Show the AI investigator's recommendation on the paused gate (verdict + why on hover)
The investigator's call ("Likely legitimate" / "Likely overcharge") only lived in the trace drawer, so the human deciding Approve/Reject on the gate couldn't see it without opening the trace. Read the recommendation from the trace (readRecommendation) and pass it to the paused gate's toolbar: a compact "✦ AI: <verdict>" chip above Reject/Approve, with the full reasoning on hover (tooltip). The verdict is immediate where the decision is made; the detail is one hover away, no drawer needed. The chip rides only on decidable gates (where it informs the call), and lives in the sibling toolbar so it doesn't touch the card's measured height. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e2385e6 commit c34011b

2 files changed

Lines changed: 107 additions & 1 deletion

File tree

components/dashboard.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,29 @@ const isBlockedRun = (
155155
return isRecord(matching?.data) && matching.data["verdict"] === "duplicate";
156156
};
157157

158+
/** The exception investigator's recommendation, pulled from the trace, so the paused
159+
gate can show what the AI concluded right where the human decides (verdict + the
160+
reasoning on hover), instead of only in the trace drawer. Null until the agent has
161+
produced a recommendation. */
162+
type Recommendation = {
163+
verdict: "likely_legitimate" | "likely_overcharge" | "unclear";
164+
rationale: string | null;
165+
};
166+
const readRecommendation = (trace: TraceEvent[]): Recommendation | null => {
167+
const inv = trace.find((e) => e.stage === "investigation" && e.data != null);
168+
if (!inv || !isRecord(inv.data)) return null;
169+
const rec = inv.data["recommendation"];
170+
const verdict =
171+
rec === "likely_legitimate"
172+
? "likely_legitimate"
173+
: rec === "likely_overcharge"
174+
? "likely_overcharge"
175+
: "unclear";
176+
const rationale =
177+
typeof inv.data["rationale"] === "string" ? inv.data["rationale"] : null;
178+
return { verdict, rationale };
179+
};
180+
158181
/** Build the approval engine's InvoiceContext from the matching trace event, so the
159182
run graph's path can be resolved client-side. Returns undefined (draw the full
160183
graph) if the matching event isn't present/valid yet.
@@ -833,6 +856,9 @@ export const Dashboard = ({
833856
reasons={awaiting ? gateReasons : undefined}
834857
onDecide={awaiting ? setGate : undefined}
835858
onReason={awaiting ? setGateReason : undefined}
859+
// The AI investigator's call, shown on the paused gate where the human
860+
// decides (verdict + reasoning on hover), not only in the trace drawer.
861+
recommendation={awaiting ? readRecommendation(state.trace) : null}
836862
// Pan to frame the waiting gate(s) the moment the run pauses.
837863
focusIds={awaiting ? pendingIds : undefined}
838864
/>

components/workflow-graph.tsx

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import { useEffect, useMemo, useRef } from "react";
2121

2222
import { Badge } from "@/components/ui/badge";
2323
import { SlackIcon, NetSuiteIcon, JiraIcon } from "@/components/ui/brand-icon";
24+
import {
25+
Tooltip,
26+
TooltipContent,
27+
TooltipProvider,
28+
TooltipTrigger,
29+
} from "@/components/ui/tooltip";
2430
import { useMediaQuery } from "@/hooks/use-media-query";
2531
import {
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. */
152214
const 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

Comments
 (0)