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:
parent
a52ff32eb4
commit
fe6c257ace
@ -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 isn’t reachable, so decisions and traces can’t be shown.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (ordered.length === 0 && escalations.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The AI employee hasn’t acted on this invoice.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">The AI employee hasn’t 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user