refactor: restructure the AI assessment and activity blocks

Assessment now reads verdict -> checks -> evidence -> reasoning. AI Reasoning
moved below the PO/GR/contract records it narrates, so it lands as the
conclusion rather than the preamble. Each part is a SubBlock: small-caps title,
optional badge, hairline above, no nested cards. Evidence rows get an aligned
label column and a red wash on the discrepancy row.

Activity gains a run summary (decisions, wall time, tokens, cost, model),
numbered decisions on a connected timeline, and honest confidence: the figure
the model acted on, colour-banded, annotated when the self-check revised it,
plus a chip when reflexion asked to escalate. Plan/reasoning/self-check sit
behind accent rules, knowledge sources render as chips, and the trace
waterfall carries per-step tokens, indents child spans and reddens failures.

Total run time sums only top-level spans; nested ones would double-count.
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-07-29 20:13:55 +05:30
parent a52ff32eb4
commit fe6c257ace
2 changed files with 299 additions and 183 deletions

View File

@ -10,9 +10,18 @@
// token/cost roll-up are the logic that came with the endpoints.
import { useMemo, useState } from "react";
import { AlertTriangle, ChevronDown, Coins, Hash, Sparkles } from "lucide-react";
import {
AlertTriangle,
ChevronDown,
Clock,
Coins,
Cpu,
Hash,
Sparkles,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { AIDecision, AIEscalation, AITrace } from "@/api/viewService";
import type { AIDecision, AIEscalation, AITrace, AITraceEvent } from "@/api/viewService";
import { activityLabel, fmtDuration } from "@/lib/workflowMeta";
import { formatDate, formatTime } from "@/lib/format";
@ -24,6 +33,10 @@ const STATUS_TONE: Record<string, string> = {
error: "bg-state-rejected/12 text-state-rejected",
};
/** Same banding as the assessment block: 90 auto-accept, 60 re-check. */
const confidenceTone = (pct: number) =>
pct >= 90 ? "text-state-approved" : pct >= 60 ? "text-state-clarification" : "text-state-rejected";
interface Props {
decisions: AIDecision[];
escalations: AIEscalation[];
@ -54,71 +67,84 @@ export default function AiActivity({ decisions, escalations, traces, unavailable
const totals = useMemo(() => {
let tokens = 0;
let cost = 0;
let ms = 0;
for (const t of traces)
for (const e of t.events) {
tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
cost += e.cost ?? 0;
// Only top-level steps, or nested spans would be counted twice.
if (e.parent_seq == null) ms += e.duration_ms ?? 0;
}
return { tokens, cost };
return { tokens, cost, ms };
}, [traces]);
if (unavailable) {
return (
<p className="text-sm text-muted-foreground">
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
The AI monitoring service isnt reachable, so decisions and traces cant be shown.
</p>
);
}
if (ordered.length === 0 && escalations.length === 0) {
return (
<p className="text-sm text-muted-foreground">
The AI employee hasnt acted on this invoice.
</p>
<p className="text-sm text-muted-foreground">The AI employee hasnt acted on this invoice.</p>
);
}
const models = [...new Set(ordered.map((d) => d.model).filter(Boolean))];
return (
<div>
{(totals.tokens > 0 || totals.cost > 0) && (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-2xs text-muted-foreground">
{/* Run summary — the cost of the automation, before the detail of it. */}
{(ordered.length > 0 || totals.tokens > 0) && (
<dl className="mb-4 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-2xs text-muted-foreground">
<Stat icon={Sparkles}>
{ordered.length} {ordered.length === 1 ? "decision" : "decisions"}
</Stat>
{totals.ms > 0 && <Stat icon={Clock}>{fmtDuration(totals.ms)}</Stat>}
{totals.tokens > 0 && (
<span className="flex items-center gap-1">
<Hash className="size-2.5" aria-hidden />
{totals.tokens.toLocaleString("en-IN")} tokens
</span>
<Stat icon={Hash}>{totals.tokens.toLocaleString("en-IN")} tokens</Stat>
)}
{totals.cost > 0 && (
<span className="flex items-center gap-1">
<Coins className="size-2.5" aria-hidden />${totals.cost.toFixed(4)}
</span>
)}
</div>
{totals.cost > 0 && <Stat icon={Coins}>${totals.cost.toFixed(4)}</Stat>}
{models.length > 0 && <Stat icon={Cpu}>{models.join(", ")}</Stat>}
</dl>
)}
<ol className="space-y-3">
{ordered.map((d) => (
<Decision key={d.id} decision={d} trace={traceByDecision.get(d.id)} />
<ol>
{ordered.map((d, i) => (
<Decision
key={d.id}
decision={d}
trace={traceByDecision.get(d.id)}
index={i + 1}
last={i === ordered.length - 1 && escalations.length === 0}
/>
))}
</ol>
{escalations.length > 0 && (
<div className="mt-4 space-y-2 border-t border-border/60 pt-3">
<div className="space-y-2.5">
{escalations.map((e) => (
<div key={e.id} className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 size-3.5 shrink-0 text-state-clarification"
aria-hidden
/>
<div className="min-w-0">
<p className="text-base font-semibold text-state-clarification">
<div
key={e.id}
className="border-l-2 border-state-clarification/50 pl-3"
>
<p className="flex flex-wrap items-center gap-1.5 text-base font-semibold text-state-clarification">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
Escalated to a human
{e.recommended_action && ` · ${e.recommended_action}`}
{e.recommended_action && (
<span className="text-2xs font-bold uppercase tracking-wider">
· {e.recommended_action}
</span>
)}
</p>
{(e.reasoning || e.reason) && (
<p className="mt-0.5 text-sm text-muted-foreground">{e.reasoning || e.reason}</p>
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{e.reasoning || e.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
@ -126,15 +152,49 @@ export default function AiActivity({ decisions, escalations, traces, unavailable
);
}
function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrace }) {
function Stat({ icon: Icon, children }: { icon: typeof Hash; children: React.ReactNode }) {
return (
<dd className="flex items-center gap-1 tabular-nums">
<Icon className="size-2.5 shrink-0 text-primary" aria-hidden />
{children}
</dd>
);
}
function Decision({
decision: d,
trace,
index,
last,
}: {
decision: AIDecision;
trace?: AITrace;
index: number;
last: boolean;
}) {
const [open, setOpen] = useState(false);
const pct = d.reflexion?.adjusted_confidence ?? d.confidence;
// The self-check can revise the model's own confidence down; show the number
// it ended up acting on, and say so when it changed.
const raw = d.confidence != null ? d.confidence * 100 : null;
const adjusted =
d.reflexion?.adjusted_confidence != null ? d.reflexion.adjusted_confidence * 100 : null;
const pct = adjusted ?? raw;
const time = formatTime(d.created_at);
const hasDetail = !!(d.plan || d.reasoning || d.reflexion?.concerns || trace);
return (
<li>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<Sparkles className="size-3 shrink-0 translate-y-px text-primary" aria-hidden />
<li className={cn("relative flex gap-3", last ? "pb-0" : "pb-4")}>
{/* Connector — makes a multi-run sequence read as one process. */}
{!last && (
<span aria-hidden className="absolute bottom-0 left-3.5 top-8 w-px bg-primary/15" />
)}
<span className="relative flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/12 text-2xs font-bold tabular-nums text-primary">
{index}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-base font-semibold text-foreground">
{activityLabel(d.activity_id)}
</span>
@ -147,8 +207,18 @@ function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrac
{d.status}
</span>
{pct != null && (
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
{Math.round(pct * 100)}% confidence
<span className={cn("text-sm font-bold tabular-nums", confidenceTone(pct))}>
{Math.round(pct)}%
{adjusted != null && raw != null && Math.round(adjusted) !== Math.round(raw) && (
<span className="ml-1 font-normal text-muted-foreground">
(self-checked from {Math.round(raw)}%)
</span>
)}
</span>
)}
{d.reflexion?.should_escalate && (
<span className="rounded-full bg-state-clarification/12 px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-clarification">
flagged for escalation
</span>
)}
</div>
@ -156,16 +226,17 @@ function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrac
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
{formatDate(d.created_at)}
{time && ` · ${time}`}
{d.model && ` · ${d.model}`}
{d.duration_ms != null && ` · ${fmtDuration(d.duration_ms)}`}
{d.llm_calls != null && d.llm_calls > 0 && ` · ${d.llm_calls} LLM calls`}
</p>
{d.error_message && (
<p className="mt-1 text-sm text-destructive">{d.error_message}</p>
<p className="mt-1.5 border-l-2 border-destructive/50 pl-2.5 text-sm text-destructive">
{d.error_message}
</p>
)}
{(d.plan || d.reasoning || d.reflexion?.concerns || trace) && (
{hasDetail && (
<>
<button
type="button"
@ -181,7 +252,7 @@ function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrac
</button>
{open && (
<div className="mt-2 space-y-2.5 border-l-2 border-primary/20 pl-3">
<div className="mt-2.5 space-y-3">
{d.plan && <Passage label="Plan" text={d.plan} />}
{d.reasoning && <Passage label="Reasoning" text={d.reasoning} />}
{d.reflexion?.concerns && (
@ -192,20 +263,46 @@ function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrac
/>
)}
{d.knowledge_sources && d.knowledge_sources.length > 0 && (
<Passage label="Knowledge used" text={d.knowledge_sources.join(", ")} />
<div>
<Label>Knowledge used</Label>
<ul className="mt-1 flex flex-wrap gap-1.5">
{d.knowledge_sources.map((s) => (
<li
key={s}
className="rounded-md bg-muted px-1.5 py-0.5 text-2xs text-muted-foreground"
>
{s}
</li>
))}
</ul>
</div>
)}
{trace && <Waterfall trace={trace} />}
</div>
)}
</>
)}
</div>
</li>
);
}
function Label({ children }: { children: React.ReactNode }) {
return (
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</p>
);
}
function Passage({ label, text, tone }: { label: string; text: string; tone?: "warn" }) {
return (
<div>
<div
className={cn(
"border-l-2 pl-2.5",
tone === "warn" ? "border-state-clarification/50" : "border-primary/20",
)}
>
<p
className={cn(
"text-2xs font-semibold uppercase tracking-wider",
@ -228,33 +325,43 @@ function Waterfall({ trace }: { trace: AITrace }) {
return (
<div>
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Run trace
</p>
<ul className="mt-1 space-y-1">
<Label>Run trace</Label>
<ul className="mt-1.5 space-y-1">
{events.map((e) => (
<li key={e.seq} className="flex items-center gap-2 text-2xs">
<span
className="min-w-0 flex-1 truncate text-muted-foreground"
style={{ paddingLeft: e.parent_seq != null ? 10 : 0 }}
>
{e.name || e.type}
</span>
<span className="h-1.5 w-24 shrink-0 overflow-hidden rounded-full bg-muted">
<span
className={cn(
"block h-full rounded-full",
e.error || e.status === "error" ? "bg-state-rejected" : "bg-primary/60",
)}
style={{ width: `${Math.max(((e.duration_ms ?? 0) / max) * 100, 2)}%` }}
/>
</span>
<span className="w-12 shrink-0 text-right tabular-nums text-muted-foreground">
{fmtDuration(e.duration_ms)}
</span>
</li>
<TraceRow key={e.seq} event={e} max={max} />
))}
</ul>
</div>
);
}
function TraceRow({ event: e, max }: { event: AITraceEvent; max: number }) {
const failed = !!e.error || e.status === "error" || e.status === "failed";
const tokens = (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
return (
<li className="flex items-center gap-2 text-2xs">
<span
className={cn("min-w-0 flex-1 truncate", failed ? "text-destructive" : "text-muted-foreground")}
style={{ paddingLeft: e.parent_seq != null ? 12 : 0 }}
title={e.error || e.name || e.type}
>
{e.name || e.type}
</span>
{tokens > 0 && (
<span className="w-20 shrink-0 text-right tabular-nums text-muted-foreground/60">
{tokens.toLocaleString("en-IN")} tok
</span>
)}
<span className="h-1.5 w-20 shrink-0 overflow-hidden rounded-full bg-muted">
<span
className={cn("block h-full rounded-full", failed ? "bg-state-rejected" : "bg-primary/60")}
style={{ width: `${Math.max(((e.duration_ms ?? 0) / max) * 100, 2)}%` }}
/>
</span>
<span className="w-11 shrink-0 text-right tabular-nums text-muted-foreground">
{fmtDuration(e.duration_ms)}
</span>
</li>
);
}

View File

@ -406,7 +406,7 @@ function AiAssessment({
return (
<section className="bg-primary/[0.02] px-6 py-5">
<div className="mb-3.5 flex flex-wrap items-center justify-between gap-2">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<SectionLabel icon={Sparkles} className="mb-0">
Automated 3-Way Match
</SectionLabel>
@ -416,16 +416,12 @@ function AiAssessment({
</span>
</div>
{/* Verdict first: what the agent concluded, at what amount, how sure. */}
{(verdict || pct !== null || recommended !== null) && (
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="mb-4 flex flex-wrap items-center gap-x-5 gap-y-2">
{verdict && (
<span
className={cn(
"inline-flex items-center gap-1.5 text-md font-semibold",
verdict.text,
)}
>
<verdict.icon className="size-4" aria-hidden />
<span className={cn("inline-flex items-center gap-2 text-lg font-bold", verdict.text)}>
<verdict.icon className="size-5" strokeWidth={2.5} aria-hidden />
{verdict.label}
</span>
)}
@ -441,15 +437,12 @@ function AiAssessment({
</div>
)}
{/* Then the checks that produced it… */}
{checks.length > 0 && (
<div className="mb-4">
<div className="mb-2.5 flex items-center gap-2">
<p className="text-xs font-medium text-muted-foreground">
{byKey.get("match_summary")?.output_label ?? "Match Summary"}
</p>
{/* Headline count first the reason to read the list at all is to
find out whether anything failed. */}
{failed > 0 ? (
<SubBlock
title={byKey.get("match_summary")?.output_label ?? "Match Summary"}
badge={
failed > 0 ? (
<span
className={cn(
"rounded-full px-2 py-0.5 text-2xs font-bold uppercase tracking-wider",
@ -464,42 +457,33 @@ function AiAssessment({
<span className="rounded-full bg-state-approved/12 px-2 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-approved">
All {checks.length} checks pass
</span>
)}
</div>
)
}
>
<ul className="grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
{ordered.map((c) => (
<MatchRow key={c.label} check={c} />
))}
</ul>
</div>
</SubBlock>
)}
{has(row, "ai_reasoning") && (
<Prose
icon={Bot}
label={byKey.get("ai_reasoning")?.output_label ?? "AI Reasoning"}
text={text(row, "ai_reasoning")}
/>
)}
{/* The gathered evidence PO, goods receipt, contract, payment history
and the specific discrepancy. Parsed into rows so the verdict above is
auditable instead of a wall of prose. */}
{/* then the underlying records those checks were run against PO,
goods receipt, contract, payment history and the discrepancy. */}
{evidence.length > 0 && (
<details className="group mt-3" open>
<summary className="flex cursor-pointer items-center gap-1 text-xs font-semibold text-primary hover:underline">
<ChevronDown
className="size-3 transition-transform group-open:rotate-180"
aria-hidden
/>
Evidence gathered
</summary>
<dl className="mt-2.5 space-y-1.5">
<SubBlock title="Evidence gathered">
<dl className="space-y-1.5">
{evidence.map((e) => (
<div key={e.label} className="flex flex-wrap gap-x-2 text-base">
<div
key={e.label}
className={cn(
"flex flex-col gap-x-3 sm:flex-row",
e.isIssue && "rounded-md bg-state-rejected/[0.06] px-2 py-1",
)}
>
<dt
className={cn(
"shrink-0 font-semibold",
"text-base font-semibold sm:w-40 sm:shrink-0",
e.isIssue ? "text-state-rejected" : "text-foreground",
)}
>
@ -507,7 +491,7 @@ function AiAssessment({
</dt>
<dd
className={cn(
"min-w-0 flex-1",
"min-w-0 flex-1 text-base",
e.isIssue ? "text-state-rejected" : "text-muted-foreground",
)}
>
@ -516,12 +500,49 @@ function AiAssessment({
</div>
))}
</dl>
</details>
</SubBlock>
)}
{/* Reasoning last: it narrates everything above, so it reads as the
conclusion rather than the preamble. */}
{has(row, "ai_reasoning") && (
<SubBlock title={byKey.get("ai_reasoning")?.output_label ?? "AI Reasoning"} icon={Bot}>
<ClampedText text={text(row, "ai_reasoning")} />
</SubBlock>
)}
</section>
);
}
/**
* A labelled part of the AI block. The hairline above each one gives the
* section an internal rhythm without nesting cards inside a card.
*/
function SubBlock({
title,
badge,
icon: Icon,
children,
}: {
title: string;
badge?: React.ReactNode;
icon?: LucideIcon;
children: React.ReactNode;
}) {
return (
<div className="mt-4 border-t border-primary/10 pt-3.5 first:mt-0 first:border-0 first:pt-0">
<div className="mb-2.5 flex flex-wrap items-center gap-2">
{Icon && <Icon className="size-3.5 shrink-0 text-primary" aria-hidden />}
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</p>
{badge}
</div>
{children}
</div>
);
}
/**
* The clarification exchange, rendered as the conversation it is: the AI asks a
* department a question, the department answers. Previously both halves sat
@ -960,15 +981,3 @@ function ClampedText({ text: value, muted }: { text: string; muted?: boolean })
</div>
);
}
function Prose({ icon: Icon, label, text: value }: { icon: LucideIcon; label: string; text: string }) {
return (
<div>
<div className="mb-1.5 flex items-center gap-1.5">
<Icon className="size-3 shrink-0 text-primary" aria-hidden />
<span className="text-xs font-medium text-muted-foreground">{formatLabel(label)}</span>
</div>
<ClampedText text={value} />
</div>
);
}