diff --git a/frontend/app/globals.css b/frontend/app/globals.css index f15e306..56a432a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -121,6 +121,22 @@ @layer base { * { @apply border-border outline-ring/50; + scrollbar-width: thin; + scrollbar-color: oklch(1 0 0 / 18%) transparent; + } + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + *::-webkit-scrollbar-track { + background: transparent; + } + *::-webkit-scrollbar-thumb { + background-color: oklch(1 0 0 / 18%); + border-radius: 9999px; + } + *::-webkit-scrollbar-thumb:hover { + background-color: oklch(1 0 0 / 28%); } body { @apply bg-background text-foreground; diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index 58d71b6..46a39b5 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -11,9 +11,24 @@ import { Plus, Square, Stethoscope, + X, } from "lucide-react"; -import { type KeyboardEvent, useCallback, useState } from "react"; +import { + type ChangeEvent, + type KeyboardEvent, + type ReactNode, + useCallback, + useRef, + useState, +} from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; type ChatInputProps = { @@ -22,6 +37,37 @@ type ChatInputProps = { onStop?: () => void; }; +type Option = { value: string; label: string }; + +const ACCESS_OPTIONS: Option[] = [ + { value: "standard", label: "Standard access" }, + { value: "break-glass", label: "Break-glass (emergency)" }, + { value: "read-only", label: "Read-only" }, +]; +const RESPONSE_OPTIONS: Option[] = [ + { value: "concise", label: "Concise" }, + { value: "detailed", label: "Detailed" }, + { value: "comprehensive", label: "Comprehensive" }, +]; +const SPECIALTY_OPTIONS: Option[] = [ + { value: "internal-medicine", label: "Internal Medicine" }, + { value: "cardiology", label: "Cardiology" }, + { value: "pediatrics", label: "Pediatrics" }, + { value: "emergency", label: "Emergency" }, + { value: "all", label: "All specialties" }, +]; +const FACILITY_OPTIONS: Option[] = [ + { value: "main-hospital", label: "Main Hospital" }, + { value: "north-clinic", label: "North Clinic" }, + { value: "telehealth", label: "Telehealth" }, +]; +const TIME_OPTIONS: Option[] = [ + { value: "30d", label: "Last 30 days" }, + { value: "12m", label: "Last 12 months" }, + { value: "5y", label: "Last 5 years" }, + { value: "all", label: "All time" }, +]; + const iconButton = "flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"; const pillButton = @@ -29,19 +75,86 @@ const pillButton = const contextPill = "flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground"; +function SelectPill({ + ariaLabel, + triggerClassName, + chevronClassName, + icon, + prefix, + value, + onValueChange, + options, + align = "start", +}: { + ariaLabel: string; + triggerClassName: string; + chevronClassName: string; + icon: ReactNode; + prefix?: string; + value: string; + onValueChange: (value: string) => void; + options: Option[]; + align?: "start" | "center" | "end"; +}) { + const selected = options.find((option) => option.value === value); + + return ( + + + } + > + {icon} + {prefix ? ( + {prefix} + ) : null} + {selected?.label} + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + + ); +} + export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { const [value, setValue] = useState(""); + const [files, setFiles] = useState([]); + const [access, setAccess] = useState("standard"); + const [responseMode, setResponseMode] = useState("detailed"); + const [specialty, setSpecialty] = useState("internal-medicine"); + const [facility, setFacility] = useState("main-hospital"); + const [timeRange, setTimeRange] = useState("12m"); + const fileInputRef = useRef(null); + const isGenerating = status === "submitted" || status === "streaming"; - const canSend = value.trim().length > 0 && !isGenerating; + const canSend = + (value.trim().length > 0 || files.length > 0) && !isGenerating; const submit = useCallback(() => { const trimmed = value.trim(); - if (!trimmed || isGenerating) { + if ((!trimmed && files.length === 0) || isGenerating) { return; } - onSubmit(trimmed); + const parts: string[] = []; + if (trimmed) { + parts.push(trimmed); + } + if (files.length > 0) { + parts.push(`[Attached: ${files.map((file) => file.name).join(", ")}]`); + } + onSubmit(parts.join("\n\n")); setValue(""); - }, [value, isGenerating, onSubmit]); + setFiles([]); + }, [value, files, isGenerating, onSubmit]); const handleKeyDown = useCallback( (event: KeyboardEvent) => { @@ -57,6 +170,22 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { [submit] ); + const handleFilesSelected = useCallback( + (event: ChangeEvent) => { + const selected = event.target.files; + if (selected && selected.length > 0) { + setFiles((prev) => [...prev, ...Array.from(selected)]); + } + // Reset so picking the same file again still fires onChange. + event.target.value = ""; + }, + [] + ); + + const removeFile = useCallback((index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)); + }, []); + return (
{ @@ -77,24 +206,69 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { value={value} /> + {files.length > 0 && ( +
+ {files.map((file, index) => ( + + {file.name} + + + ))} +
+ )} + + +
- - + } + onValueChange={setAccess} + options={ACCESS_OPTIONS} + triggerClassName={pillButton} + value={access} + />
- + @@ -122,21 +296,33 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { {/* Bottom (darker) card peeking out below, more rounded: context selectors */}
- - - + } + onValueChange={setSpecialty} + options={SPECIALTY_OPTIONS} + triggerClassName={contextPill} + value={specialty} + /> + } + onValueChange={setFacility} + options={FACILITY_OPTIONS} + triggerClassName={contextPill} + value={facility} + /> + } + onValueChange={setTimeRange} + options={TIME_OPTIONS} + triggerClassName={contextPill} + value={timeRange} + />
); diff --git a/frontend/components/chat/patient-cards.tsx b/frontend/components/chat/patient-cards.tsx index ae6ac98..70c837b 100644 --- a/frontend/components/chat/patient-cards.tsx +++ b/frontend/components/chat/patient-cards.tsx @@ -42,6 +42,9 @@ const statusVariant: Record = { const sexLabel: Record = { F: "Female", M: "Male" }; +// Fixed width so the cards sit in a horizontal scroll row instead of squashing. +const rowCard = "w-80 shrink-0 snap-start"; + function SectionLabel({ children }: { children: string }) { return (

@@ -52,7 +55,7 @@ function SectionLabel({ children }: { children: string }) { function SummaryCard({ patient }: { patient: Patient }) { return ( - +

@@ -79,7 +82,7 @@ function SummaryCard({ patient }: { patient: Patient }) { function AllergiesCard({ patient }: { patient: Patient }) { return ( - + Allergies & alerts @@ -127,7 +130,7 @@ function AllergiesCard({ patient }: { patient: Patient }) { function MedicationsCard({ patient }: { patient: Patient }) { return ( - + Medications & problems @@ -171,7 +174,7 @@ function VitalsCard({ patient }: { patient: Patient }) { ]; return ( - + Vitals, labs & visits Vitals taken {vitals.takenAt} @@ -222,7 +225,7 @@ function VitalsCard({ patient }: { patient: Patient }) { function LoadingCards() { return ( <> - +
@@ -234,7 +237,7 @@ function LoadingCards() { {[0, 1, 2].map((card) => ( - + @@ -263,7 +266,7 @@ export function PatientResult({ status, fileNumber, patient }: PatientResultProp } return ( -
+
{status === "loading" || !patient ? ( ) : (