Add /patient lookup with patient-info cards, clinical chat input

Type `/patient <fileNumber>` to pull up a patient: a skeleton card set
appears, then fills in with summary, allergies & alerts, medications &
problems, and vitals/labs/visits — composed from existing Card/Badge/
Avatar/Separator/Skeleton primitives (no new design).

- lib/patients.ts: typed mock fixture + async getPatient() (swap point
  for a real API later).
- components/chat/patient-cards.tsx: PatientResult (loading/ready/
  not-found).
- chat-panel.tsx: discriminated-union message model, strict /patient
  parse, async lookup with update-by-id, clinical heading.
- chat-input.tsx: relabel leftover coding-assistant controls to clinical
  copy + clinical icons; layout/styling unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-01 18:56:56 +03:00
parent 2042a60b91
commit 88a45cf19d
4 changed files with 608 additions and 36 deletions
+15 -15
View File
@@ -3,14 +3,14 @@
import type { ChatStatus } from "ai";
import {
ArrowUp,
Building2,
CalendarRange,
ChevronDown,
FolderGit2,
GitBranch,
Hand,
Laptop,
Mic,
Plus,
Square,
Stethoscope,
} from "lucide-react";
import { type KeyboardEvent, useCallback, useState } from "react";
@@ -72,30 +72,30 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Do anything"
placeholder="Look up a patient — try /patient 10293"
rows={1}
value={value}
/>
<div className="flex items-center justify-between gap-2 px-3 pb-3">
<div className="flex min-w-0 items-center gap-1">
<button aria-label="Add context" className={iconButton} type="button">
<button aria-label="Attach file" className={iconButton} type="button">
<Plus className="size-[18px]" />
</button>
<button className={pillButton} type="button">
<Hand className="size-4" />
<span className="truncate">Default permissions</span>
<span className="truncate">Standard access</span>
<ChevronDown className="size-4 opacity-70" />
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
<button className={cn(pillButton, "mr-1")} type="button">
<span className="font-medium text-foreground">Custom</span>
<span>High</span>
<span className="font-medium text-foreground">Clinical</span>
<span>Detailed</span>
<ChevronDown className="size-4 opacity-70" />
</button>
<button aria-label="Voice input" className={iconButton} type="button">
<button aria-label="Dictate" className={iconButton} type="button">
<Mic className="size-[18px]" />
</button>
<button
@@ -123,18 +123,18 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
<button className={contextPill} type="button">
<FolderGit2 className="size-4" />
<span>design-ai</span>
<Stethoscope className="size-4" />
<span>Internal Medicine</span>
<ChevronDown className="size-3.5 opacity-70" />
</button>
<button className={contextPill} type="button">
<Laptop className="size-4" />
<span>Work locally</span>
<Building2 className="size-4" />
<span>Main Hospital</span>
<ChevronDown className="size-3.5 opacity-70" />
</button>
<button className={contextPill} type="button">
<GitBranch className="size-4" />
<span>main</span>
<CalendarRange className="size-4" />
<span>Last 12 months</span>
<ChevronDown className="size-3.5 opacity-70" />
</button>
</div>
+86 -21
View File
@@ -15,21 +15,31 @@ import {
MessageResponse,
} from "@/components/ai-elements/message";
import { ChatInput } from "@/components/chat/chat-input";
import { PatientResult } from "@/components/chat/patient-cards";
import { getPatient, type Patient } from "@/lib/patients";
type ChatMessage = {
id: string;
role: "user" | "assistant";
text: string;
};
type ChatMessage =
| { id: string; role: "user"; text: string }
| { id: string; role: "assistant"; kind: "text"; text: string }
| {
id: string;
role: "assistant";
kind: "patient";
fileNumber: string;
status: "loading" | "ready" | "not-found";
patient?: Patient;
};
const HEADING = "What should we build in design-ai?";
const HEADING = "Which patient would you like to look up?";
// Strict trigger: only `/patient <fileNumber>` pulls up records.
const PATIENT_COMMAND = /^\/patient\s+(\S+)$/i;
export function ChatPanel() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>("ready");
// UI-only: append the user's message and a mock assistant reply.
const send = useCallback((text: string) => {
const send = useCallback(async (text: string) => {
const trimmed = text.trim();
if (!trimmed) {
return;
@@ -39,8 +49,46 @@ export function ChatPanel() {
...prev,
{ id: nanoid(), role: "user", text: trimmed },
]);
setStatus("submitted");
const match = trimmed.match(PATIENT_COMMAND);
// Patient lookup: append a loading card set, then fill it in once the
// (mock) record comes back.
if (match) {
const fileNumber = match[1];
const resultId = nanoid();
setStatus("submitted");
setMessages((prev) => [
...prev,
{
id: resultId,
role: "assistant",
kind: "patient",
fileNumber,
status: "loading",
},
]);
const patient = await getPatient(fileNumber);
setMessages((prev) =>
prev.map((message) =>
message.id === resultId &&
message.role === "assistant" &&
message.kind === "patient"
? {
...message,
status: patient ? "ready" : "not-found",
patient: patient ?? undefined,
}
: message
)
);
setStatus("ready");
return;
}
// UI-only: mock assistant reply for anything that isn't a command.
setStatus("submitted");
window.setTimeout(() => {
setStatus("streaming");
setMessages((prev) => [
@@ -48,7 +96,8 @@ export function ChatPanel() {
{
id: nanoid(),
role: "assistant",
text: `This is a **UI-only preview** of temetro. Connecting to your records and a model comes next.\n\nYou asked:\n\n> ${trimmed}`,
kind: "text",
text: `This is a **UI-only preview** of temetro. Try \`/patient 10293\` to pull up a patient's records.\n\nYou asked:\n\n> ${trimmed}`,
},
]);
window.setTimeout(() => setStatus("ready"), 250);
@@ -78,17 +127,33 @@ export function ChatPanel() {
<div className="flex flex-1 flex-col overflow-hidden">
<Conversation>
<ConversationContent className="mx-auto w-full max-w-3xl">
{messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.role === "assistant" ? (
<MessageResponse>{message.text}</MessageResponse>
) : (
<span className="whitespace-pre-wrap">{message.text}</span>
)}
</MessageContent>
</Message>
))}
{messages.map((message) => {
if (message.role === "assistant" && message.kind === "patient") {
return (
<Message from="assistant" key={message.id}>
<MessageContent className="w-full">
<PatientResult
fileNumber={message.fileNumber}
patient={message.patient}
status={message.status}
/>
</MessageContent>
</Message>
);
}
return (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.role === "user" ? (
<span className="whitespace-pre-wrap">{message.text}</span>
) : (
<MessageResponse>{message.text}</MessageResponse>
)}
</MessageContent>
</Message>
);
})}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
+279
View File
@@ -0,0 +1,279 @@
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { AllergySeverity, LabFlag, Patient } from "@/lib/patients";
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
type PatientResultProps = {
status: "loading" | "ready" | "not-found";
fileNumber: string;
patient?: Patient;
};
const severityVariant: Record<AllergySeverity, BadgeVariant> = {
mild: "outline",
moderate: "secondary",
severe: "destructive",
};
const labFlagVariant: Record<LabFlag, BadgeVariant> = {
normal: "outline",
low: "secondary",
high: "secondary",
critical: "destructive",
};
const statusVariant: Record<Patient["status"], BadgeVariant> = {
active: "secondary",
inpatient: "destructive",
discharged: "outline",
};
const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
function SectionLabel({ children }: { children: string }) {
return (
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{children}
</p>
);
}
function SummaryCard({ patient }: { patient: Patient }) {
return (
<Card size="sm">
<CardHeader>
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback>{patient.initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 gap-0.5">
<CardTitle>{patient.name}</CardTitle>
<CardDescription>
{patient.age} · {sexLabel[patient.sex]} · MRN {patient.fileNumber}
</CardDescription>
</div>
<Badge className="capitalize" variant={statusVariant[patient.status]}>
{patient.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<span className="text-muted-foreground">Primary care: </span>
<span className="text-foreground">{patient.pcp}</span>
</CardContent>
</Card>
);
}
function AllergiesCard({ patient }: { patient: Patient }) {
return (
<Card size="sm">
<CardHeader>
<CardTitle>Allergies & alerts</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{patient.alerts.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{patient.alerts.map((alert) => (
<Badge key={alert} variant="outline">
{alert}
</Badge>
))}
</div>
)}
<div className="flex flex-col gap-2">
<SectionLabel>Allergies</SectionLabel>
{patient.allergies.length === 0 ? (
<p className="text-muted-foreground">No known allergies.</p>
) : (
patient.allergies.map((allergy) => (
<div
className="flex items-center justify-between gap-3"
key={allergy.substance}
>
<span className="text-foreground">
{allergy.substance}
<span className="text-muted-foreground">
{" "}
{allergy.reaction}
</span>
</span>
<Badge
className="capitalize"
variant={severityVariant[allergy.severity]}
>
{allergy.severity}
</Badge>
</div>
))
)}
</div>
</CardContent>
</Card>
);
}
function MedicationsCard({ patient }: { patient: Patient }) {
return (
<Card size="sm">
<CardHeader>
<CardTitle>Medications & problems</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<SectionLabel>Active medications</SectionLabel>
{patient.medications.map((med) => (
<div className="flex items-baseline justify-between gap-3" key={med.name}>
<span className="text-foreground">{med.name}</span>
<span className="text-muted-foreground">
{med.dose} · {med.frequency}
</span>
</div>
))}
</div>
<Separator />
<div className="flex flex-col gap-2">
<SectionLabel>Problem list</SectionLabel>
{patient.problems.map((problem) => (
<div
className="flex items-baseline justify-between gap-3"
key={problem.label}
>
<span className="text-foreground">{problem.label}</span>
<span className="text-muted-foreground">since {problem.since}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function VitalsCard({ patient }: { patient: Patient }) {
const { vitals } = patient;
const vitalItems = [
{ label: "BP", value: vitals.bp },
{ label: "HR", value: vitals.hr },
{ label: "Temp", value: vitals.temp },
{ label: "SpO₂", value: vitals.spo2 },
];
return (
<Card size="sm">
<CardHeader>
<CardTitle>Vitals, labs & visits</CardTitle>
<CardDescription>Vitals taken {vitals.takenAt}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{vitalItems.map((item) => (
<div className="flex flex-col gap-0.5" key={item.label}>
<span className="text-xs text-muted-foreground">{item.label}</span>
<span className="text-foreground">{item.value}</span>
</div>
))}
</div>
<Separator />
<div className="flex flex-col gap-2">
<SectionLabel>Recent labs</SectionLabel>
{patient.labs.map((lab) => (
<div className="flex items-center justify-between gap-3" key={lab.name}>
<span className="text-foreground">{lab.name}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{lab.value}</span>
<Badge className="capitalize" variant={labFlagVariant[lab.flag]}>
{lab.flag}
</Badge>
</div>
</div>
))}
</div>
<Separator />
<div className="flex flex-col gap-3">
<SectionLabel>Recent visits</SectionLabel>
{patient.encounters.map((encounter) => (
<div className="flex flex-col gap-0.5" key={encounter.date + encounter.type}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-foreground">{encounter.type}</span>
<span className="text-xs text-muted-foreground">{encounter.date}</span>
</div>
<span className="text-muted-foreground">{encounter.summary}</span>
<span className="text-xs text-muted-foreground">{encounter.provider}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function LoadingCards() {
return (
<>
<Card size="sm">
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="grid flex-1 gap-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
</div>
</CardHeader>
</Card>
{[0, 1, 2].map((card) => (
<Card key={card} size="sm">
<CardHeader>
<Skeleton className="h-4 w-44" />
</CardHeader>
<CardContent className="flex flex-col gap-2.5">
{[0, 1, 2].map((row) => (
<Skeleton className="h-3.5 w-full" key={row} />
))}
</CardContent>
</Card>
))}
</>
);
}
export function PatientResult({ status, fileNumber, patient }: PatientResultProps) {
if (status === "not-found") {
return (
<Card size="sm">
<CardContent>
<p className="text-muted-foreground">
No patient found for file #{fileNumber}.
</p>
</CardContent>
</Card>
);
}
return (
<div className="flex w-full flex-col gap-4">
{status === "loading" || !patient ? (
<LoadingCards />
) : (
<>
<SummaryCard patient={patient} />
<AllergiesCard patient={patient} />
<MedicationsCard patient={patient} />
<VitalsCard patient={patient} />
</>
)}
</div>
);
}