clinicpal / src /components /features /rag-chunks-panel.tsx
Vrda's picture
Deploy ClinIcPal frontend
9bc2f29 verified
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { RagChunk } from '@/types';
interface RagChunksPanelProps {
chunks: RagChunk[];
isLoading: boolean;
onChunkClick: (chunk: RagChunk) => void;
}
export function RagChunksPanel({ chunks, isLoading, onChunkClick }: RagChunksPanelProps) {
return (
<div className="flex flex-col gap-3 w-full">
{/* Header */}
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-[var(--foreground)]/40 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 5.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
<span className="text-xs font-medium text-[var(--foreground)]/40 uppercase tracking-wider">
{isLoading
? 'Searching sources...'
: `${chunks.length} relevant source${chunks.length !== 1 ? 's' : ''}`}
</span>
</div>
{/* Skeleton loading */}
{isLoading && (
<div className="flex flex-col gap-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className={cn(
'rounded-xl p-3',
'bg-[var(--glass-bg-muted)]',
'border border-[var(--glass-border-subtle)]',
'animate-pulse'
)}
style={{ animationDelay: `${i * 100}ms` }}
>
<div className="h-3 bg-[var(--glass-bg)] rounded w-2/3 mb-2" />
<div className="h-2.5 bg-[var(--glass-bg)] rounded w-full mb-1.5" />
<div className="h-2.5 bg-[var(--glass-bg)] rounded w-4/5" />
</div>
))}
</div>
)}
{/* Chunk cards */}
{!isLoading && (
<AnimatePresence>
<div className="flex flex-col gap-2">
{chunks.map((chunk, index) => (
<ChunkCard
key={chunk.id}
chunk={chunk}
index={index}
onClick={() => onChunkClick(chunk)}
/>
))}
</div>
</AnimatePresence>
)}
{/* Subtle CTA */}
{!isLoading && chunks.length > 0 && (
<p className="text-xs text-[var(--foreground)]/30 text-center pt-1">
Click a source to read the full excerpt
</p>
)}
</div>
);
}
function ChunkCard({
chunk,
index,
onClick,
}: {
chunk: RagChunk;
index: number;
onClick: () => void;
}) {
const snippet = chunk.text.slice(0, 140).trimEnd();
const hasMore = chunk.text.length > 140;
return (
<motion.button
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
onClick={onClick}
className={cn(
'text-left w-full rounded-xl p-3',
'bg-[var(--glass-bg-muted)]',
'border border-[var(--glass-border-subtle)]',
'hover:bg-[var(--glass-bg)] hover:border-[var(--glass-border)]',
'transition-all duration-150 group',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--suggestion-accent)]'
)}
>
{/* Title row */}
<div className="flex items-start justify-between gap-2 mb-1.5">
<span className="text-xs font-medium text-[var(--foreground)]/70 truncate flex-1 leading-tight group-hover:text-[var(--foreground)] transition-colors">
{chunk.title || chunk.source || 'Untitled source'}
</span>
<ScoreBadge score={chunk.score} />
</div>
{/* Snippet */}
<p className="text-xs text-[var(--foreground)]/50 leading-relaxed line-clamp-2">
{snippet}
{hasMore && '…'}
</p>
</motion.button>
);
}
function sigmoid(x: number) {
return 1 / (1 + Math.exp(-x));
}
function ScoreBadge({ score }: { score: number }) {
const prob = sigmoid(score);
const pct = Math.round(prob * 100);
const colorClass =
prob >= 0.7
? 'bg-[var(--success-bg)] text-[var(--success-text)]'
: prob >= 0.4
? 'bg-[var(--warning-bg)] text-[var(--warning-text)]'
: 'bg-[var(--glass-bg)] text-[var(--foreground)]/40';
return (
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded-full flex-shrink-0', colorClass)}>
{pct}%
</span>
);
}