193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { ChevronDown, FileText, RefreshCw, TriangleAlert } from 'lucide-react';
|
|
import { cn } from '../../lib/cn';
|
|
import { Avatar } from '../core/Avatar';
|
|
import { ReasoningBody } from './ReasoningBody';
|
|
import type { Decision } from '../../types';
|
|
|
|
export interface AIDecisionStreamProps {
|
|
decisions: Decision[];
|
|
loading?: boolean;
|
|
/** Manual refresh handler. Shown as a refresh button in the header. */
|
|
onRefresh?: () => void;
|
|
/** Autonomy threshold. @default 0.7 */
|
|
threshold?: number;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* AI DECISION STREAM — the stacked-decision view.
|
|
* A single-open accordion: each row is a scannable summary (employee, activity,
|
|
* confidence, one-line reasoning); expanding reveals the full reasoning in a
|
|
* height-capped, scrollable box so a wall of text never blows out the column.
|
|
*/
|
|
export function AIDecisionStream({
|
|
decisions,
|
|
loading = false,
|
|
onRefresh,
|
|
threshold = 0.7,
|
|
className,
|
|
}: AIDecisionStreamProps) {
|
|
// Single-open accordion; all collapsed by default (-1 = none open).
|
|
const [openIdx, setOpenIdx] = useState(-1);
|
|
const escalations = decisions.filter((d) => d.confidence < threshold);
|
|
|
|
return (
|
|
<div className={cn('flex flex-col gap-3.5', className)}>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy bg-navy-900 px-2 py-1 rounded-pill">
|
|
{decisions.length} action{decisions.length === 1 ? '' : 's'}
|
|
</span>
|
|
{onRefresh && (
|
|
<button
|
|
type="button"
|
|
onClick={onRefresh}
|
|
disabled={loading}
|
|
title="Refresh AI decisions"
|
|
aria-label="Refresh AI decisions"
|
|
className="w-7 h-7 rounded-md border border-border-subtle bg-card cursor-pointer text-muted hover:text-strong disabled:opacity-50 flex items-center justify-center shrink-0"
|
|
>
|
|
<RefreshCw size={14} className={cn(loading && 'animate-spin')} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{escalations.length > 0 && (
|
|
<div className="bg-escalated-soft border border-amber-300 rounded-md px-3.5 py-3 flex gap-3">
|
|
<TriangleAlert size={18} className="text-amber-600 shrink-0 mt-0.5" />
|
|
<div>
|
|
<div className="text-sm font-bold text-amber-700">
|
|
{escalations.length} escalation{escalations.length > 1 ? 's' : ''} need you
|
|
</div>
|
|
<div className="text-xs text-muted mt-0.5">
|
|
{escalations[0].employee} · confidence {Math.round(escalations[0].confidence * 100)}%.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Placeholder only on first load; a manual refresh keeps the list visible
|
|
(the header button spins) so the stream never blanks out. */}
|
|
{loading && decisions.length === 0 && (
|
|
<div className="text-sm text-faint">Loading decisions…</div>
|
|
)}
|
|
{!loading && decisions.length === 0 && (
|
|
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
|
|
)}
|
|
|
|
{decisions.length > 0 && (
|
|
<div className="bg-card rounded-lg border border-border-subtle shadow-sm overflow-hidden font-sans divide-y divide-border-subtle">
|
|
{decisions.map((d, i) => (
|
|
<DecisionRow
|
|
key={i}
|
|
decision={d}
|
|
threshold={threshold}
|
|
open={openIdx === i}
|
|
onToggle={() => setOpenIdx((cur) => (cur === i ? -1 : i))}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DecisionRow({
|
|
decision: d,
|
|
threshold,
|
|
open,
|
|
onToggle,
|
|
}: {
|
|
decision: Decision;
|
|
threshold: number;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const autonomous = d.confidence >= threshold;
|
|
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
|
|
const accentSoft = autonomous ? 'var(--status-autonomous-soft)' : 'var(--status-escalated-soft)';
|
|
const pct = Math.round(d.confidence * 100);
|
|
|
|
return (
|
|
<div className="relative">
|
|
{/* accent rail on the open row */}
|
|
{open && (
|
|
<span
|
|
className={cn('absolute top-0 left-0 bottom-0 w-1', autonomous && 'bg-sunrise')}
|
|
style={autonomous ? undefined : { background: accent }}
|
|
/>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
aria-expanded={open}
|
|
className={cn(
|
|
'w-full flex items-start gap-3 text-left px-3.5 py-3 cursor-pointer bg-transparent border-none transition-colors',
|
|
open ? 'bg-sunk/60' : 'hover:bg-sunk/40',
|
|
)}
|
|
>
|
|
<Avatar name={d.employee} ai size={30} className="mt-0.5 shrink-0" />
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-bold text-strong truncate">{d.employee}</span>
|
|
{/* confidence chip */}
|
|
<span
|
|
className="ml-auto shrink-0 inline-flex items-center gap-1 text-2xs font-bold tracking-[0.02em] px-1.5 py-0.5 rounded-pill nums"
|
|
style={{ background: accentSoft, color: accent }}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
|
|
{pct}%
|
|
</span>
|
|
<ChevronDown
|
|
size={15}
|
|
className={cn('shrink-0 text-faint transition-transform duration-200', open && 'rotate-180')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="text-2xs text-faint mt-0.5">
|
|
{d.activity}
|
|
{d.time && <span> · {d.time}</span>}
|
|
</div>
|
|
|
|
<p className={cn('m-0 mt-1.5 text-sm leading-snug text-body', !open && 'line-clamp-2')}>
|
|
{d.summary}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
|
|
{open && (d.reasoning || d.citations.length > 0) && (
|
|
<div className="px-3.5 pb-4">
|
|
{d.reasoning && (
|
|
<div className="px-4 py-3.5 bg-sunk rounded-lg">
|
|
<ReasoningBody text={d.reasoning} accent={accent} accentSoft={accentSoft} />
|
|
</div>
|
|
)}
|
|
{d.citations.length > 0 && (
|
|
<div className="mt-3">
|
|
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-[7px]">
|
|
Cited knowledge
|
|
</div>
|
|
<div className="flex flex-wrap gap-[7px]">
|
|
{d.citations.map((c, i) => (
|
|
<span
|
|
key={i}
|
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted bg-card border border-border-default rounded-sm px-2.5 py-[5px]"
|
|
>
|
|
<FileText size={12} className="text-sunrise-500" />
|
|
{c}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|