clinicpal / src /components /features /analysis-results.tsx
Vrda's picture
Deploy ClinIcPal frontend
9bc2f29 verified
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { cn, formatDuration, SEVERITY_INFO } from '@/lib/utils';
import {
GlassPanel,
SeverityBadge,
CategoryBadge,
ConfidenceBar,
AnalysisLoadingState,
} from '@/components/ui';
import {
useIsAnalyzing,
useAnalysisError,
useAnalysisResult,
useFilteredErrors,
useFilteredSummary,
useQuickResult,
useAnalysisStage,
useAppStore,
useInputText,
} from '@/store/app-store';
import { useExport } from '@/hooks/use-export';
import { RagChunksPanel } from './rag-chunks-panel';
import { ChunkModal } from './chunk-modal';
import type { AnalysisResult, DetectedError, QuickSeverity, RagChunk } from '@/types';
import type { ExportFormat } from '@/types/export';
interface AnalysisResultsProps {
isCollapsed?: boolean;
onToggleCollapse?: () => void;
}
export function AnalysisResults({
isCollapsed = false,
onToggleCollapse,
}: AnalysisResultsProps) {
const isAnalyzing = useIsAnalyzing();
const analysisError = useAnalysisError();
const analysisResult = useAnalysisResult();
const filteredErrors = useFilteredErrors();
const filteredSummary = useFilteredSummary();
const quickResult = useQuickResult();
const analysisStage = useAnalysisStage();
const inputText = useInputText();
const settings = useAppStore((state) => state.settings);
const streamingErrors = useAppStore((state) => state.streamingErrors);
const ragChunks = useAppStore((state) => state.ragChunks);
const isFetchingChunks = useAppStore((state) => state.isFetchingChunks);
const { exportAnalysis } = useExport();
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [activeChunk, setActiveChunk] = useState<RagChunk | null>(null);
const [showSources, setShowSources] = useState(false);
const [showRawOutput, setShowRawOutput] = useState(false);
const handleExport = (format: ExportFormat) => {
if (!analysisResult || !inputText) return;
exportAnalysis(format, inputText, analysisResult, {
selectedModel: settings.selectedModel,
temperature: settings.temperature,
confidenceThreshold: settings.confidenceThreshold,
enabledCategories: settings.enabledCategories,
enabledSeverities: settings.enabledSeverities,
});
setExportMenuOpen(false);
};
// Collapsed view - show only metrics stacked (or expand button if no results)
if (isCollapsed) {
return (
<GlassPanel className="p-3 h-full flex flex-col">
<button
onClick={onToggleCollapse}
className="p-2 mb-3 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors"
title="Expand"
>
<svg
className="w-4 h-4 text-[var(--foreground)]/60 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
/>
</svg>
</button>
{analysisResult ? (
<motion.div
className="flex flex-col gap-2 flex-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<CollapsedMetricCard
count={filteredSummary.total}
label="Total"
/>
<CollapsedMetricCard
count={filteredSummary.critical}
label="Critical"
severity="critical"
/>
<CollapsedMetricCard
count={filteredSummary.warning}
label="Warn"
severity="warning"
/>
<CollapsedMetricCard
count={filteredSummary.suggestion}
label="Info"
severity="suggestion"
/>
</motion.div>
) : (
<div className="flex-1 flex items-center justify-center">
<span className="text-2xl">🔍</span>
</div>
)}
</GlassPanel>
);
}
// Streaming state: error cards arrive progressively while still analyzing
if (isAnalyzing && streamingErrors.length > 0) {
const streamingSummary = {
total: streamingErrors.length,
critical: streamingErrors.filter((e) => e.severity === 'critical').length,
warning: streamingErrors.filter((e) => e.severity === 'warning').length,
suggestion: streamingErrors.filter((e) => e.severity === 'suggestion').length,
};
return (
<GlassPanel className="h-full overflow-y-auto relative">
<div className="sticky top-0 z-10 backdrop-blur-xl bg-[var(--glass-bg)]/80 px-4 sm:px-6 pt-4 sm:pt-6 pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-[var(--foreground)]">Analyzing…</h2>
{/* Pulsing dot */}
<motion.span
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ repeat: Infinity, duration: 1.4 }}
className="inline-block w-2 h-2 rounded-full bg-[var(--suggestion-accent)]"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<MetricCard label="Critical" count={streamingSummary.critical} severity="critical" />
<MetricCard label="Warnings" count={streamingSummary.warning} severity="warning" />
<MetricCard label="Suggestions" count={streamingSummary.suggestion} severity="suggestion" />
</div>
</div>
<div className="px-4 sm:px-6 pt-0 pb-4 space-y-4">
<AnimatePresence>
{streamingErrors.map((error, index) => (
<ErrorCard key={error.id} error={error} index={index} />
))}
</AnimatePresence>
{/* Still-generating indicator */}
<motion.div
animate={{ opacity: [0.4, 0.9, 0.4] }}
transition={{ repeat: Infinity, duration: 1.6 }}
className="flex items-center justify-center gap-2 py-3 text-xs text-[var(--foreground)]/40"
>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-current" />
<span className="inline-block w-1.5 h-1.5 rounded-full bg-current" style={{ animationDelay: '0.2s' }} />
<span className="inline-block w-1.5 h-1.5 rounded-full bg-current" style={{ animationDelay: '0.4s' }} />
</motion.div>
</div>
<div
className="sticky bottom-0 z-[9] h-6 pointer-events-none -mt-6"
style={{
background: 'linear-gradient(to top, var(--glass-bg) 20%, transparent)',
}}
/>
</GlassPanel>
);
}
// Loading state — spinner until first card arrives
if (isAnalyzing) {
return (
<GlassPanel className="p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-[var(--foreground)]">
Analysis Results
</h2>
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors"
title="Collapse"
>
<svg className="w-4 h-4 text-[var(--foreground)]/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
)}
</div>
{quickResult && analysisStage === 'full' && (
<QuickPreviewBanner quickResult={quickResult} />
)}
<AnalysisLoadingState stage={analysisStage} hasQuickResult={!!quickResult} />
</GlassPanel>
);
}
// Idle state — show RAG chunks if available, otherwise minimal prompt
if (!analysisResult && !analysisError) {
const showChunks = isFetchingChunks || ragChunks.length > 0;
return (
<GlassPanel className="p-6 h-full flex flex-col">
{onToggleCollapse && (
<div className="flex justify-end mb-4">
<button
onClick={onToggleCollapse}
className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors"
title="Collapse"
>
<svg className="w-4 h-4 text-[var(--foreground)]/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
)}
{showChunks ? (
<>
<RagChunksPanel
chunks={ragChunks}
isLoading={isFetchingChunks}
onChunkClick={(chunk) => setActiveChunk(chunk)}
/>
<ChunkModal
chunk={activeChunk}
open={activeChunk !== null}
onOpenChange={(open) => { if (!open) setActiveChunk(null); }}
/>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<div className="text-5xl mb-4">🔍</div>
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-2">
Ready to Analyze
</h3>
<p className="text-sm text-[var(--foreground)]/60 max-w-xs">
Paste a clinical note and click Analyze to detect potential errors and
inconsistencies.
</p>
</div>
)}
</GlassPanel>
);
}
// Error state
if (analysisError) {
return (
<GlassPanel
severity="critical"
className="p-6 h-full flex flex-col"
>
{onToggleCollapse && (
<div className="flex justify-end mb-4">
<button
onClick={onToggleCollapse}
className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors"
title="Collapse"
>
<svg
className="w-4 h-4 text-[var(--foreground)]/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 5l7 7-7 7M5 5l7 7-7 7"
/>
</svg>
</button>
</div>
)}
<div className="flex-1 flex flex-col items-center justify-center text-center">
<div className="text-5xl mb-4">⚠️</div>
<h3 className="text-lg font-semibold text-[var(--critical-text)] mb-2">
Analysis Failed
</h3>
<p className="text-sm text-[var(--foreground)]/70 max-w-md mb-4">
{analysisError}
</p>
<div className="text-xs text-[var(--foreground)]/50">
Make sure Ollama is running and try again.
</div>
</div>
</GlassPanel>
);
}
// Success with no errors
if (analysisResult && filteredErrors.length === 0) {
return (
<GlassPanel className="p-6 h-full">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
Analysis Results
</h2>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className={cn(
'flex flex-col items-center justify-center text-center py-12',
'bg-[var(--success-bg-glass)] rounded-2xl border border-[var(--success-border)]'
)}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="text-6xl mb-4"
>
</motion.div>
<h3 className="text-xl font-semibold text-[var(--success-text)] mb-2">
No Issues Found
</h3>
<p className="text-sm text-[var(--foreground)]/60 max-w-xs">
{analysisResult.errors.length === 0
? 'The clinical note appears to be free of detectable errors.'
: 'All detected issues are filtered out by your current settings.'}
</p>
{analysisResult.elapsed_ms && (
<div className="mt-4 text-xs text-[var(--foreground)]/40">
Analysis completed in {formatDuration(analysisResult.elapsed_ms)}
</div>
)}
<button
onClick={() => setShowRawOutput(true)}
className="mt-4 text-xs text-[var(--foreground)]/40 hover:text-[var(--foreground)]/70 underline underline-offset-2 transition-colors"
>
View raw model output
</button>
</motion.div>
{/* Raw Output Modal */}
<RawOutputModal
open={showRawOutput}
onOpenChange={setShowRawOutput}
analysisResult={analysisResult}
/>
</GlassPanel>
);
}
// Results with errors
return (
<GlassPanel className="h-full overflow-y-auto relative">
{/* Sticky Header with liquid glass effect */}
<div className="sticky top-0 z-10 backdrop-blur-xl bg-[var(--glass-bg)]/80 px-4 sm:px-6 pt-4 sm:pt-6 pb-4 relative">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-[var(--foreground)]">
Analysis Results
</h2>
<div className="flex items-center gap-2">
{analysisResult?.elapsed_ms && (
<span className="text-xs text-[var(--foreground)]/40">
{formatDuration(analysisResult.elapsed_ms)}
</span>
)}
{/* Export Dropdown */}
<DropdownMenu.Root open={exportMenuOpen} onOpenChange={setExportMenuOpen}>
<DropdownMenu.Trigger asChild>
<button
className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors"
title="Export"
>
<svg
className="w-4 h-4 text-[var(--foreground)]/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
</DropdownMenu.Trigger>
<AnimatePresence>
{exportMenuOpen && (
<DropdownMenu.Portal forceMount>
<DropdownMenu.Content
asChild
sideOffset={8}
align="end"
className="z-50"
>
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.15 }}
className={cn(
'min-w-[160px] rounded-xl p-1',
'glass-elevated',
'border border-[var(--glass-border)]',
'shadow-xl'
)}
>
<DropdownMenu.Item
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'text-sm cursor-pointer outline-none',
'hover:bg-[var(--glass-bg-muted)]',
'transition-colors duration-150'
)}
onSelect={() => handleExport('pdf')}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Export as PDF
</DropdownMenu.Item>
<DropdownMenu.Item
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'text-sm cursor-pointer outline-none',
'hover:bg-[var(--glass-bg-muted)]',
'transition-colors duration-150'
)}
onSelect={() => handleExport('csv')}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Export as CSV
</DropdownMenu.Item>
<DropdownMenu.Item
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'text-sm cursor-pointer outline-none',
'hover:bg-[var(--glass-bg-muted)]',
'transition-colors duration-150'
)}
onSelect={() => handleExport('txt')}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Export as Text
</DropdownMenu.Item>
<DropdownMenu.Item
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'text-sm cursor-pointer outline-none',
'hover:bg-[var(--glass-bg-muted)]',
'transition-colors duration-150'
)}
onSelect={() => handleExport('json')}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
Export as JSON
</DropdownMenu.Item>
</motion.div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</AnimatePresence>
</DropdownMenu.Root>
{analysisResult?.raw_response && (
<button
onClick={() => setShowRawOutput(true)}
className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors"
title="View raw model output"
>
<svg className="w-4 h-4 text-[var(--foreground)]/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
</button>
)}
{ragChunks.length > 0 && (
<button
onClick={() => setShowSources((v) => !v)}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors',
showSources
? 'bg-[var(--suggestion-accent)]/15 text-[var(--suggestion-accent)]'
: 'hover:bg-[var(--glass-bg-elevated)] text-[var(--foreground)]/60'
)}
title={showSources ? 'Hide sources' : 'Show sources'}
>
<svg className="w-3.5 h-3.5" 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>
{ragChunks.length}
</button>
)}
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors"
title="Collapse"
>
<svg
className="w-4 h-4 text-[var(--foreground)]/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 5l7 7-7 7M5 5l7 7-7 7"
/>
</svg>
</button>
)}
</div>
</div>
{/* Summary Metrics */}
<motion.div
className="grid grid-cols-3 gap-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<MetricCard
label="Critical"
count={filteredSummary.critical}
severity="critical"
/>
<MetricCard
label="Warnings"
count={filteredSummary.warning}
severity="warning"
/>
<MetricCard
label="Suggestions"
count={filteredSummary.suggestion}
severity="suggestion"
/>
</motion.div>
</div>
{/* Error Cards */}
<div className="px-4 sm:px-6 pt-0 pb-4 space-y-4">
<AnimatePresence>
{filteredErrors.map((error, index) => (
<ErrorCard key={error.id} error={error} index={index} />
))}
</AnimatePresence>
</div>
{/* RAG Sources (collapsible) */}
<AnimatePresence>
{showSources && ragChunks.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 sm:px-6 pb-6 pt-2 border-t border-[var(--glass-border-subtle)]">
<RagChunksPanel
chunks={ragChunks}
isLoading={false}
onChunkClick={(chunk) => setActiveChunk(chunk)}
/>
<ChunkModal
chunk={activeChunk}
open={activeChunk !== null}
onOpenChange={(open) => { if (!open) setActiveChunk(null); }}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Bottom fade - sticks at bottom */}
<div
className="sticky bottom-0 z-[9] h-6 backdrop-blur-sm bg-gradient-to-t from-[var(--glass-bg)]/90 to-transparent pointer-events-none -mt-6"
style={{
maskImage: 'linear-gradient(to top, black 20%, transparent)',
WebkitMaskImage: 'linear-gradient(to top, black 20%, transparent)',
} as React.CSSProperties}
/>
{/* Raw Output Modal */}
<RawOutputModal
open={showRawOutput}
onOpenChange={setShowRawOutput}
analysisResult={analysisResult}
/>
</GlassPanel>
);
}
// Metric Card Component
function MetricCard({
label,
count,
severity,
className,
}: {
label: string;
count: number;
severity?: 'critical' | 'warning' | 'suggestion';
className?: string;
}) {
const info = severity ? SEVERITY_INFO[severity] : null;
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
'p-4 rounded-xl glass',
severity && info?.className,
severity === 'critical' && count > 0 && 'pulse-glow-critical',
className
)}
>
<motion.div
key={count}
initial={{ scale: 1.2 }}
animate={{ scale: 1 }}
className={cn(
'text-2xl font-bold tabular-nums',
info?.textColor || 'text-[var(--foreground)]'
)}
>
{count}
</motion.div>
<div className="text-xs text-[var(--foreground)]/60 mt-1">{label}</div>
</motion.div>
);
}
// Error Card Component
function ErrorCard({ error, index }: { error: DetectedError; index: number }) {
const info = SEVERITY_INFO[error.severity];
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{
duration: 0.3,
delay: index * 0.05,
ease: [0.25, 0.46, 0.45, 0.94],
}}
className={cn(
'p-4 rounded-xl glass',
info.className,
'hover:shadow-lg transition-shadow duration-200'
)}
>
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<SeverityBadge severity={error.severity} size="sm" />
<CategoryBadge category={error.category} size="sm" />
</div>
{/* Explanation */}
<p className="text-sm text-[var(--foreground)]/80 mb-3">
{error.explanation}
</p>
{/* Reasoning (chain-of-thought) */}
{error.reasoning && (
<div
className={cn(
'p-3 rounded-lg mb-3',
'bg-[var(--glass-bg-muted)]',
'border-l-2',
info.borderColor,
'text-sm text-[var(--foreground)]/70 italic'
)}
>
{error.reasoning}
</div>
)}
{/* Suggestion */}
{error.suggestion && (
<div
className={cn(
'flex items-start gap-2 p-3 rounded-lg mb-3',
'bg-[var(--success-bg-glass)]',
'border border-[var(--success-border)]'
)}
>
<span className="text-base">💡</span>
<div>
<span className="text-xs font-medium text-[var(--success-text)]">
Suggestion:
</span>
<p className="text-sm text-[var(--foreground)]/80 mt-0.5">
{error.suggestion}
</p>
</div>
</div>
)}
{/* Confidence */}
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--foreground)]/50">Confidence:</span>
<ConfidenceBar confidence={error.confidence} size="sm" className="flex-1" />
</div>
</motion.div>
);
}
// Collapsed Metric Card Component
function CollapsedMetricCard({
label,
count,
severity,
}: {
label: string;
count: number;
severity?: 'critical' | 'warning' | 'suggestion';
}) {
const info = severity ? SEVERITY_INFO[severity] : null;
return (
<div
className={cn(
'p-2 rounded-lg glass text-center',
severity && info?.className,
severity === 'critical' && count > 0 && 'pulse-glow-critical'
)}
>
<div
className={cn(
'text-lg font-bold tabular-nums',
info?.textColor || 'text-[var(--foreground)]'
)}
>
{count}
</div>
<div className="text-[10px] text-[var(--foreground)]/60">{label}</div>
</div>
);
}
// Raw Output Modal Component
function RawOutputModal({
open,
onOpenChange,
analysisResult,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
analysisResult: AnalysisResult | null;
}) {
if (!open || !analysisResult) return null;
const rawResponse = analysisResult.raw_response || '';
const medgemmaResult = analysisResult.medgemma_result;
let formattedJson = '';
try {
const parsed = JSON.parse(rawResponse);
formattedJson = JSON.stringify(parsed, null, 2);
} catch {
formattedJson = '';
}
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2 }}
className={cn(
'w-full max-w-3xl max-h-[80vh] flex flex-col rounded-2xl',
'glass-elevated border border-[var(--glass-border)]',
'shadow-2xl'
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-border-subtle)]">
<div>
<h3 className="text-base font-semibold text-[var(--foreground)]">
Raw Model Output
</h3>
<p className="text-xs text-[var(--foreground)]/50 mt-0.5">
{analysisResult.model_used || 'MedGemma'} &middot; {analysisResult.provider || 'vastai'}
{analysisResult.elapsed_ms ? ` \u00b7 ${formatDuration(analysisResult.elapsed_ms)}` : ''}
</p>
</div>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors"
>
<svg className="w-5 h-5 text-[var(--foreground)]/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Parsed Result Summary */}
{medgemmaResult && (
<div>
<h4 className="text-xs font-semibold text-[var(--foreground)]/60 uppercase tracking-wider mb-2">
Parsed Result ({medgemmaResult.errors.length} error{medgemmaResult.errors.length !== 1 ? 's' : ''} extracted)
</h4>
{medgemmaResult.errors.length > 0 ? (
<div className="space-y-2">
{medgemmaResult.errors.map((err, i) => (
<div
key={i}
className={cn(
'p-3 rounded-lg text-xs',
'bg-[var(--glass-bg-muted)] border border-[var(--glass-border-subtle)]'
)}
>
<div className="flex items-center gap-2 mb-1">
<span className={cn(
'px-1.5 py-0.5 rounded text-[10px] font-bold uppercase',
err.severity === 'critical'
? 'bg-[var(--critical-bg)] text-[var(--critical-text)]'
: 'bg-[var(--warning-bg)] text-[var(--warning-text)]'
)}>
{err.severity}
</span>
<span className="font-mono text-[var(--foreground)]/70">{err.type}</span>
<span className="ml-auto text-[var(--foreground)]/40">
{(err.confidence * 100).toFixed(0)}%
</span>
</div>
<p className="text-[var(--foreground)]/80">{err.problem}</p>
{err.reasoning && (
<p className="mt-1 text-[var(--foreground)]/50 italic">{err.reasoning}</p>
)}
</div>
))}
</div>
) : (
<p className="text-xs text-[var(--foreground)]/50 italic">
No errors were parsed from the model output.
{medgemmaResult.summary && ` Summary: "${medgemmaResult.summary}"`}
</p>
)}
</div>
)}
{/* Raw Text */}
<div>
<h4 className="text-xs font-semibold text-[var(--foreground)]/60 uppercase tracking-wider mb-2">
Raw Response Text
</h4>
<pre
className={cn(
'p-4 rounded-xl text-xs leading-relaxed overflow-x-auto',
'bg-black/30 border border-[var(--glass-border-subtle)]',
'text-[var(--foreground)]/80 font-mono whitespace-pre-wrap break-words'
)}
>
{formattedJson || rawResponse || '(empty response)'}
</pre>
</div>
</div>
{/* Footer */}
<div className="flex justify-end px-6 py-3 border-t border-[var(--glass-border-subtle)]">
<button
onClick={() => {
navigator.clipboard.writeText(rawResponse);
}}
className="text-xs px-3 py-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] text-[var(--foreground)]/60 hover:text-[var(--foreground)] transition-colors"
>
Copy to clipboard
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
// Quick Preview Banner Component
function QuickPreviewBanner({
quickResult,
}: {
quickResult: { errors_found: boolean; count: number; severity: QuickSeverity };
}) {
const getSeverityStyles = (severity: QuickSeverity) => {
switch (severity) {
case 'critical':
return {
bg: 'bg-[var(--critical-bg-glass)]',
border: 'border-[var(--critical-border)]',
text: 'text-[var(--critical-text)]',
};
case 'warning':
return {
bg: 'bg-[var(--warning-bg-glass)]',
border: 'border-[var(--warning-border)]',
text: 'text-[var(--warning-text)]',
};
default:
return {
bg: 'bg-[var(--success-bg-glass)]',
border: 'border-[var(--success-border)]',
text: 'text-[var(--success-text)]',
};
}
};
const styles = getSeverityStyles(quickResult.severity);
if (!quickResult.errors_found) {
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
'p-3 rounded-xl mb-4 border',
styles.bg,
styles.border
)}
>
<div className="flex items-center gap-2">
<span className="text-lg"></span>
<span className={cn('text-sm font-medium', styles.text)}>
No issues detected
</span>
<span className="text-xs text-[var(--foreground)]/50 ml-auto">
Confirming...
</span>
</div>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
'p-3 rounded-xl mb-4 border',
styles.bg,
styles.border
)}
>
<div className="flex items-center gap-2">
<span className="text-lg">
{quickResult.severity === 'critical' ? '⚠️' : '⚡'}
</span>
<span className={cn('text-sm font-medium', styles.text)}>
{quickResult.count} {quickResult.count === 1 ? 'issue' : 'issues'} found
</span>
<span className="text-xs text-[var(--foreground)]/50 ml-auto">
Getting details...
</span>
</div>
</motion.div>
);
}