clinicpal / src /components /features /clinical-note-editor.tsx
Vrda's picture
Deploy ClinIcPal frontend
9bc2f29 verified
'use client';
import { useState, useRef, useEffect, useCallback, type DragEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { cn } from '@/lib/utils';
import {
GlassPanel,
GlassButton,
GlassTextarea,
GlassTooltip,
GlassDialog,
} from '@/components/ui';
import { useToast } from '@/components/ui/glass-toast';
import type { DetectedError, QuickAnalyzeRequest } from '@/types';
import {
useAppStore,
useInputText,
useIsAnalyzing,
useConnectionStatus,
useAnalysisResult,
useAnalysisStage,
} from '@/store/app-store';
import type { AnalysisResult } from '@/types';
import { sampleNotes } from '@/data/sample-notes';
import { useDocumentImport } from '@/hooks/use-document-import';
import { useNoteExport, type NoteExportFormat } from '@/hooks/use-note-export';
import { useAutoSave } from '@/hooks/use-auto-save';
import { useRecentFiles } from '@/hooks/use-recent-files';
// Upload icon component
function UploadIcon({ className }: { className?: string }) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
);
}
// Document icon for drop zone
function DocumentIcon({ className }: { className?: string }) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
);
}
export function ClinicalNoteEditor() {
const inputText = useInputText();
const setInputText = useAppStore((state) => state.setInputText);
const isAnalyzing = useIsAnalyzing();
const setIsAnalyzing = useAppStore((state) => state.setIsAnalyzing);
const connectionStatus = useConnectionStatus();
const settings = useAppStore((state) => state.settings);
const setAnalysisResult = useAppStore((state) => state.setAnalysisResult);
const setAnalysisError = useAppStore((state) => state.setAnalysisError);
const clearInput = useAppStore((state) => state.clearInput);
const analysisResult = useAnalysisResult();
const showAnnotatedView = useAppStore((state) => state.showAnnotatedView);
const setShowAnnotatedView = useAppStore((state) => state.setShowAnnotatedView);
const analysisStage = useAnalysisStage();
const setAnalysisStage = useAppStore((state) => state.setAnalysisStage);
const setQuickResult = useAppStore((state) => state.setQuickResult);
const ragChunks = useAppStore((state) => state.ragChunks);
const [showSamples, setShowSamples] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [showReplaceConfirm, setShowReplaceConfirm] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [pendingImportText, setPendingImportText] = useState<string | null>(null);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [isTranslating, setIsTranslating] = useState(false);
const [translateProgress, setTranslateProgress] = useState<string | null>(null);
const [multiagentEnabled, setMultiagentEnabled] = useState(false);
const [showMultiagentInfo, setShowMultiagentInfo] = useState(false);
const { showToast } = useToast();
const { importFile, isImporting, importProgress, fileInputRef, acceptedTypes } = useDocumentImport();
const { exportNote } = useNoteExport();
const { clearSaved } = useAutoSave(inputText);
const { recentFiles, addRecentFile } = useRecentFiles();
const [importMenuOpen, setImportMenuOpen] = useState(false);
const dropZoneRef = useRef<HTMLDivElement>(null);
// AbortController refs for request cancellation
const quickAbortControllerRef = useRef<AbortController | null>(null);
const fullAbortControllerRef = useRef<AbortController | null>(null);
// Cleanup on unmount
useEffect(() => {
return () => {
quickAbortControllerRef.current?.abort();
fullAbortControllerRef.current?.abort();
};
}, []);
// Handle successful import
const handleImportSuccess = useCallback((text: string, fileName?: string) => {
setInputText(text);
setAnalysisResult(null);
setShowAnnotatedView(false);
showToast('Document imported successfully', 'success');
// Add to recent files if we have a file name
if (fileName) {
addRecentFile(fileName, text);
}
}, [setInputText, setAnalysisResult, setShowAnnotatedView, showToast, addRecentFile]);
// Process file import
const processFileImport = useCallback(async (file: File) => {
const result = await importFile(file);
if (result.success && result.text) {
// Check if there's existing content
if (inputText.trim().length > 0) {
setPendingImportText(result.text);
setShowReplaceConfirm(true);
} else {
handleImportSuccess(result.text, file.name);
}
} else if (result.errorMessage) {
showToast(result.errorMessage, 'error');
}
}, [importFile, inputText, handleImportSuccess, showToast]);
// Handle file input change
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
processFileImport(file);
}
// Reset input so the same file can be selected again
e.target.value = '';
}, [processFileImport]);
// Handle import button click
const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
}, [fileInputRef]);
// Handle confirm replace
const handleConfirmReplace = useCallback(() => {
if (pendingImportText) {
setInputText(pendingImportText);
setAnalysisResult(null);
setShowAnnotatedView(false);
setPendingImportText(null);
setShowReplaceConfirm(false);
}
}, [pendingImportText, setInputText, setAnalysisResult, setShowAnnotatedView]);
// Handle cancel replace
const handleCancelReplace = useCallback(() => {
setPendingImportText(null);
}, []);
// Drag and drop handlers
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only show drag over if files are being dragged
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only hide if leaving the drop zone (not entering a child element)
const relatedTarget = e.relatedTarget as Node | null;
if (!dropZoneRef.current?.contains(relatedTarget)) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) {
processFileImport(file);
}
}, [processFileImport]);
// Stage 2: Full analysis — Ollama (non-streaming)
const runFullAnalysisOllama = async () => {
setAnalysisStage('full');
fullAbortControllerRef.current = new AbortController();
try {
const response = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: inputText,
model: settings.selectedModel,
temperature: settings.temperature,
categories: settings.enabledCategories,
}),
signal: fullAbortControllerRef.current.signal,
});
const data = await response.json();
if (data.success) {
setAnalysisResult(data.data);
} else {
setAnalysisError(data.error || 'Analysis failed');
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return;
setAnalysisError(error instanceof Error ? error.message : 'An unexpected error occurred');
} finally {
setIsAnalyzing(false);
setAnalysisStage('idle');
}
};
// Stage 2: Full analysis — MedGemma / local (non-streaming, with RAG context)
const runFullAnalysisMedgemma = async () => {
setAnalysisStage('full');
fullAbortControllerRef.current = new AbortController();
try {
const response = await fetch('/api/analyze-medgemma', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: inputText,
temperature: settings.temperature,
provider: settings.provider === 'local' ? 'local' : 'vastai',
rag_context: ragChunks.map((c) => ({
text: c.text,
title: c.title,
source: c.source,
})),
}),
signal: fullAbortControllerRef.current.signal,
});
const data = await response.json();
if (data.success) {
setAnalysisResult(data.data);
} else {
setAnalysisError(data.error || 'Analysis failed');
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return;
setAnalysisError(error instanceof Error ? error.message : 'An unexpected error occurred');
} finally {
setIsAnalyzing(false);
setAnalysisStage('idle');
}
};
// Stage 2: Full analysis — MedGemma / local (multiagent pipeline)
const runFullAnalysisMultiagent = async () => {
setAnalysisStage('full');
fullAbortControllerRef.current = new AbortController();
try {
const response = await fetch('/api/analyze-multiagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: inputText,
temperature: settings.temperature,
provider: settings.provider === 'local' ? 'local' : 'vastai',
rag_context: ragChunks.map((c) => ({
text: c.text,
title: c.title,
source: c.source,
})),
}),
signal: fullAbortControllerRef.current.signal,
});
const data = await response.json();
if (data.success) {
setAnalysisResult(data.data);
} else {
setAnalysisError(data.error || 'Multiagent analysis failed');
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return;
setAnalysisError(error instanceof Error ? error.message : 'An unexpected error occurred');
} finally {
setIsAnalyzing(false);
setAnalysisStage('idle');
}
};
// Two-stage analysis flow
const handleAnalyze = async () => {
const useBackend = settings.provider === 'medgemma' || settings.provider === 'local';
// For Ollama: require a selected model. For MedGemma/Local: model is fixed.
if (!inputText.trim() || isAnalyzing) return;
if (!useBackend && !settings.selectedModel) return;
// Cancel any in-flight requests
quickAbortControllerRef.current?.abort();
fullAbortControllerRef.current?.abort();
setIsAnalyzing(true);
setAnalysisError(null);
setAnalysisResult(null);
setQuickResult(null);
// MedGemma/Local: skip quick detection, go straight to full analysis
if (useBackend) {
if (multiagentEnabled) {
runFullAnalysisMultiagent();
} else {
runFullAnalysisMedgemma();
}
return;
}
setAnalysisStage('quick');
// Create new AbortController for quick detection
quickAbortControllerRef.current = new AbortController();
try {
// Stage 1: Quick detection (Ollama only)
const quickRequest: QuickAnalyzeRequest = {
text: inputText,
model: settings.selectedModel,
};
const quickResponse = await fetch('/api/analyze-quick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(quickRequest),
signal: quickAbortControllerRef.current.signal,
});
const quickData = await quickResponse.json();
if (quickData.success) {
setQuickResult(quickData.data);
// Automatically trigger Stage 2 in background
runFullAnalysisOllama();
} else {
// If quick detection fails, try full analysis directly
console.warn('Quick detection failed, falling back to full analysis:', quickData.error);
runFullAnalysisOllama();
}
} catch (error) {
// Don't proceed if request was cancelled
if (error instanceof Error && error.name === 'AbortError') {
console.log('Quick detection cancelled');
return;
}
// If quick detection fails, try full analysis directly
console.warn('Quick detection failed, falling back to full analysis:', error);
runFullAnalysisOllama();
}
};
const handleLoadSample = (content: string) => {
setInputText(content);
setShowSamples(false);
setAnalysisResult(null);
setShowAnnotatedView(false);
};
const handleExportNote = (format: NoteExportFormat) => {
if (!inputText.trim()) return;
exportNote(format, inputText);
setExportMenuOpen(false);
};
const handleClearClick = () => {
// Show confirmation if text is longer than 100 characters
if (inputText.trim().length > 100) {
setShowClearConfirm(true);
} else {
clearInput();
}
};
const handleConfirmClear = () => {
clearInput();
clearSaved(); // Clear auto-saved draft
setShowClearConfirm(false);
};
// Translate the current note from Croatian → English
const handleTranslate = useCallback(async () => {
if (!inputText.trim() || isTranslating || isAnalyzing) return;
// Derive translate provider from the MedGemma provider setting:
// medgemma → vastai, local / ollama → local
const translateProvider = settings.provider === 'medgemma' ? 'vastai' : 'local';
setIsTranslating(true);
setTranslateProgress(
translateProvider === 'vastai'
? 'Translating via Vast.ai...'
: 'Translating locally...'
);
try {
const response = await fetch('/api/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: inputText,
source_lang: 'hr',
target_lang: 'en',
provider: translateProvider,
}),
});
const data = await response.json();
if (data.success && data.translated_text) {
setInputText(data.translated_text);
setAnalysisResult(null);
setShowAnnotatedView(false);
const secs = Math.round((data.elapsed_ms || 0) / 1000);
const via = data.provider === 'vastai' ? 'Vast.ai' : 'local GPU';
showToast(
`Translated via ${via} (${data.chunks_count} chunk${data.chunks_count !== 1 ? 's' : ''}, ${secs}s)`,
'success'
);
} else {
showToast(data.error || 'Translation failed', 'error');
}
} catch (error) {
showToast(
error instanceof Error ? error.message : 'Translation failed',
'error'
);
} finally {
setIsTranslating(false);
setTranslateProgress(null);
}
}, [inputText, isTranslating, isAnalyzing, settings.provider, setInputText, setAnalysisResult, setShowAnnotatedView, showToast]);
const handleLoadRecentFile = (content: string) => {
if (inputText.trim().length > 0) {
setPendingImportText(content);
setShowReplaceConfirm(true);
} else {
setInputText(content);
setAnalysisResult(null);
setShowAnnotatedView(false);
}
setImportMenuOpen(false);
};
const isBackendProvider = settings.provider === 'medgemma' || settings.provider === 'local';
const canAnalyze = isBackendProvider
? inputText.trim().length > 0 && !isAnalyzing && !isTranslating
: connectionStatus === 'connected' &&
inputText.trim().length > 0 &&
settings.selectedModel &&
!isAnalyzing &&
!isTranslating;
return (
<>
<GlassPanel className="p-4 sm:p-6 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-[var(--foreground)]">
Clinical Note
</h2>
<div className="flex items-center gap-2">
{/* Import Dropdown */}
<DropdownMenu.Root open={importMenuOpen} onOpenChange={setImportMenuOpen}>
<DropdownMenu.Trigger asChild>
<button
disabled={isAnalyzing || isImporting}
className={cn(
'text-sm font-medium px-3 py-1.5 rounded-lg',
'transition-colors duration-200',
'flex items-center gap-1.5',
'text-[var(--foreground)]/60 hover:text-[var(--foreground)]',
'hover:bg-[var(--glass-bg-muted)]',
'disabled:opacity-50 disabled:pointer-events-none'
)}
>
<UploadIcon className="w-4 h-4" />
{isImporting ? importProgress : 'Import'}
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</DropdownMenu.Trigger>
<AnimatePresence>
{importMenuOpen && (
<DropdownMenu.Portal forceMount>
<DropdownMenu.Content
asChild
sideOffset={8}
align="start"
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-[280px] max-w-[320px] 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',
'font-medium'
)}
onSelect={handleImportClick}
>
<UploadIcon className="w-4 h-4" />
Import from File...
</DropdownMenu.Item>
{recentFiles.length > 0 && (
<>
<DropdownMenu.Separator className="h-px bg-[var(--glass-border-subtle)] my-1" />
<div className="px-2 py-1.5 text-xs text-[var(--foreground)]/40 font-medium">
Recent Files
</div>
{recentFiles.map((file) => (
<DropdownMenu.Item
key={file.id}
className={cn(
'flex flex-col gap-1 px-3 py-2 rounded-lg',
'text-sm cursor-pointer outline-none',
'hover:bg-[var(--glass-bg-muted)]',
'transition-colors duration-150'
)}
onSelect={() => handleLoadRecentFile(file.content)}
>
<div className="flex items-center gap-2 w-full">
<svg className="w-3.5 h-3.5 flex-shrink-0 text-[var(--foreground)]/60" 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>
<span className="font-medium truncate">{file.name}</span>
</div>
<div className="text-xs text-[var(--foreground)]/50 pl-5 truncate">
{file.preview}...
</div>
</DropdownMenu.Item>
))}
</>
)}
</motion.div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</AnimatePresence>
</DropdownMenu.Root>
{/* Edit Mode / View Annotations Button */}
{analysisResult && analysisResult.errors.length > 0 && (
<button
onClick={() => setShowAnnotatedView(!showAnnotatedView)}
className={cn(
'text-sm font-medium px-3 py-1.5 rounded-lg',
'transition-colors duration-200',
showAnnotatedView
? 'bg-[var(--suggestion-bg)] text-[var(--suggestion-text)]'
: 'text-[var(--foreground)]/60 hover:text-[var(--foreground)]'
)}
>
{showAnnotatedView ? 'Edit Mode' : 'View Annotations'}
</button>
)}
</div>
</div>
{/* Text Area or Annotated View with Drag-Drop Zone */}
<div
ref={dropZoneRef}
className="flex-1 min-h-0 overflow-hidden relative"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{showAnnotatedView && analysisResult ? (
<AnnotatedView text={inputText} errors={analysisResult.errors} />
) : (
<GlassTextarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Paste or type a clinical note here to analyze for errors..."
showCharCount
className="h-full"
disabled={isAnalyzing || isImporting || isTranslating}
/>
)}
{/* Drag-Drop Overlay */}
<AnimatePresence>
{isDragOver && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
'absolute inset-0 z-10',
'bg-[var(--suggestion-accent)]/10',
'backdrop-blur-[2px]',
'border-2 border-dashed border-[var(--suggestion-accent)]',
'rounded-xl',
'flex flex-col items-center justify-center gap-3',
'pointer-events-none'
)}
>
<motion.div
initial={{ scale: 0.8, y: 10 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.8, y: 10 }}
transition={{ duration: 0.15 }}
className="flex flex-col items-center gap-3"
>
<div className="p-4 rounded-full bg-[var(--suggestion-accent)]/20">
<DocumentIcon className="w-10 h-10 text-[var(--suggestion-accent)]" />
</div>
<p className="text-lg font-medium text-[var(--suggestion-accent)]">
Drop file to import
</p>
<p className="text-sm text-[var(--foreground)]/60">
Supports PDF, DOCX, and TXT files
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Importing Overlay */}
<AnimatePresence>
{isImporting && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
'absolute inset-0 z-10',
'bg-[var(--background)]/60',
'backdrop-blur-[2px]',
'rounded-xl',
'flex flex-col items-center justify-center gap-3'
)}
>
<div className="flex items-center gap-3">
<svg
className="animate-spin h-6 w-6 text-[var(--suggestion-accent)]"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="text-[var(--foreground)] font-medium">
{importProgress || 'Importing...'}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Translating Overlay */}
<AnimatePresence>
{isTranslating && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
'absolute inset-0 z-10',
'bg-[var(--background)]/60',
'backdrop-blur-[2px]',
'rounded-xl',
'flex flex-col items-center justify-center gap-3'
)}
>
<div className="flex flex-col items-center gap-3">
<svg
className="animate-spin h-8 w-8 text-[var(--suggestion-accent)]"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="text-[var(--foreground)] font-medium text-lg">
Translating HR → EN...
</span>
<span className="text-[var(--foreground)]/60 text-sm">
Using TranslateGemma 4B — this may take a few minutes
</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
accept={acceptedTypes}
onChange={handleFileInputChange}
className="hidden"
/>
{/* Actions */}
<div className="flex flex-col gap-3 mt-4">
{/* Row 1: Analyze + secondary actions */}
<div className="flex flex-wrap gap-2">
<GlassButton
variant="primary"
size="lg"
onClick={handleAnalyze}
disabled={!canAnalyze}
isLoading={isAnalyzing}
>
{isAnalyzing
? multiagentEnabled ? 'Multiagent...' : 'Analyzing...'
: settings.provider === 'local'
? 'Analyze (Local)'
: 'Analyze Note'}
</GlassButton>
<GlassButton
variant="secondary"
size="lg"
onClick={handleTranslate}
disabled={isTranslating || isAnalyzing || !inputText.trim()}
isLoading={isTranslating}
>
{isTranslating ? (translateProgress || 'Translating...') : 'Translate HR → EN'}
</GlassButton>
<GlassButton
variant="secondary"
size="lg"
onClick={handleClearClick}
disabled={isAnalyzing || isTranslating || !inputText}
>
Clear
</GlassButton>
{/* Export Dropdown */}
<DropdownMenu.Root open={exportMenuOpen} onOpenChange={setExportMenuOpen}>
<DropdownMenu.Trigger asChild>
<button
disabled={isAnalyzing || !inputText.trim()}
className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors disabled:opacity-50 disabled:pointer-events-none"
title="Export"
>
<svg className="w-4 h-4 text-[var(--foreground)]/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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={() => handleExportNote('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={() => handleExportNote('docx')}
>
<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 DOCX
</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={() => handleExportNote('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 TXT
</DropdownMenu.Item>
</motion.div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</AnimatePresence>
</DropdownMenu.Root>
</div>
{/* Multiagent toggle (only for MedGemma / local providers) */}
{isBackendProvider && (
<div className="flex items-center gap-2">
<button
onClick={() => setMultiagentEnabled((v) => !v)}
disabled={isAnalyzing}
className={cn(
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 ease-in-out focus:outline-none',
'disabled:opacity-50 disabled:cursor-not-allowed',
multiagentEnabled
? 'bg-[var(--suggestion-accent)]'
: 'bg-[var(--glass-bg-elevated)]'
)}
role="switch"
aria-checked={multiagentEnabled}
aria-label="Enable multiagent pipeline"
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0',
'transition duration-200 ease-in-out',
multiagentEnabled ? 'translate-x-4' : 'translate-x-0'
)}
/>
</button>
<span className="text-xs text-[var(--foreground)]/60">
Multiagent pipeline
</span>
<div className="relative">
<button
onClick={() => setShowMultiagentInfo((v) => !v)}
onMouseEnter={() => setShowMultiagentInfo(true)}
onMouseLeave={() => setShowMultiagentInfo(false)}
className="w-4 h-4 rounded-full bg-[var(--glass-bg-elevated)] text-[var(--foreground)]/40 hover:text-[var(--foreground)]/70 flex items-center justify-center text-[10px] font-bold transition-colors"
aria-label="What is multiagent pipeline?"
>
?
</button>
<AnimatePresence>
{showMultiagentInfo && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
className={cn(
'absolute left-0 bottom-full mb-2 z-50',
'w-64 p-3 rounded-xl text-xs leading-relaxed',
'glass-elevated border border-[var(--glass-border)]',
'shadow-xl text-[var(--foreground)]/80'
)}
>
Runs 6 sequential LLM passes: 2 first-pass reviewers
(conservative + exploratory), 3 specialist critics
(diagnostics, treatment, follow-up), and a final adjudicator
that selects the top 3 highest-risk errors. Takes longer but
catches more subtle issues.
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)}
<GlassButton
variant="ghost"
onClick={() => setShowSamples(!showSamples)}
className="text-sm"
>
{showSamples ? 'Hide Samples' : 'Load Sample Note'}
</GlassButton>
</div>
{/* Sample Notes */}
<AnimatePresence>
{showSamples && (
<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="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4 pt-4 border-t border-[var(--glass-border-subtle)]">
{sampleNotes.map((sample) => (
<button
key={sample.id}
onClick={() => handleLoadSample(sample.content)}
className={cn(
'text-left p-3 rounded-xl',
'bg-[var(--glass-bg-muted)]',
'border border-[var(--glass-border-subtle)]',
'hover:bg-[var(--glass-bg)] hover:border-[var(--glass-border)]',
'transition-all duration-200',
'group'
)}
>
<div className="flex items-start justify-between">
<div>
<h4 className="font-medium text-[var(--foreground)] group-hover:text-[var(--suggestion-accent)] transition-colors">
{sample.title}
</h4>
<p className="text-xs text-[var(--foreground)]/60 mt-1">
{sample.description}
</p>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--warning-bg)] text-[var(--warning-text)]">
~{sample.expectedErrors} errors
</span>
</div>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</GlassPanel>
{/* Replace Confirmation Dialog */}
<GlassDialog
open={showReplaceConfirm}
onOpenChange={setShowReplaceConfirm}
title="Replace existing note?"
description="The current note will be replaced with the imported content. This action cannot be undone."
confirmLabel="Replace"
cancelLabel="Cancel"
onConfirm={handleConfirmReplace}
onCancel={handleCancelReplace}
/>
{/* Clear Confirmation Dialog */}
<GlassDialog
open={showClearConfirm}
onOpenChange={setShowClearConfirm}
title="Clear this note?"
description="This will permanently delete the current note. This action cannot be undone."
confirmLabel="Clear"
cancelLabel="Cancel"
onConfirm={handleConfirmClear}
onCancel={() => setShowClearConfirm(false)}
/>
</>
);
}
// Annotated View Component (read-only display of the clinical note)
function AnnotatedView({
text,
}: {
text: string;
errors: DetectedError[];
}) {
return (
<div
className={cn(
'h-full overflow-y-auto p-4 rounded-xl',
'bg-[var(--glass-bg-muted)]',
'border border-[var(--glass-border-subtle)]',
'font-mono text-sm leading-relaxed whitespace-pre-wrap'
)}
>
{text}
</div>
);
}