fix(chat,analysis,sidebar): compact cards, sparse citations, range + active state

- Chat patient cards are now compact previews (header + key stat + "Click for
  more"); cards size to content (items-start) instead of stretching to the tallest;
  Edit record moved into the summary detail dialog
- AI citations render once per source per message (collapses per-word spam) and the
  prompt now asks the model to cite sparingly (once per paragraph, not per list item)
- Sidebar sub-items use exact-match active state so Meetings no longer lights Inbox
- Analysis defaults to All Time, drops the 12m option, and clamps the chart window
  to >=2 points so 30d/Today render instead of collapsing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 01:57:31 +03:00
parent c88b674196
commit e841cb56e1
6 changed files with 77 additions and 101 deletions
+6 -5
View File
@@ -165,11 +165,12 @@ function systemPrompt(
"tool, so keep your prose a brief summary rather than re-listing every field.",
"",
"Citations: every retrieval tool result includes a `sourceId` (e.g. \"s1\").",
"When you state a fact drawn from a tool result, append an inline citation",
"marker immediately after that statement, in the exact form [[src:ID]] using",
"the matching sourceId e.g. \"BP is well controlled [[src:s1]].\" Cite only",
"facts grounded in tool results, place the marker right after the relevant",
"sentence, and never invent or guess a sourceId.",
"Cite **sparingly** — add at most ONE marker per paragraph, on the single most",
"important record-derived claim, in the exact form [[src:ID]] using the matching",
"sourceId (e.g. \"BP is well controlled this quarter [[src:s1]].\"). Do NOT cite",
"every sentence, do NOT cite individual list items (e.g. each allergy or",
"medication), and never repeat the same source more than once. Cite only facts",
"grounded in tool results, and never invent or guess a sourceId.",
veilActive
? `Privacy: this conversation runs on an external provider (${providerLabel}). Patient identifiers are de-identified as tokens like [PATIENT_1] / [MRN_1]; refer to patients generically ("this patient") rather than repeating tokens.`
: "",
@@ -28,18 +28,21 @@ import { cn } from "@/lib/utils";
// Time-range filter for the Overview header. Month-based series are sliced to
// the trailing window; sub-month ranges (30d/today) fall back to the latest
// point since the backend has no finer-grained series yet.
const RANGES = ["all", "12m", "3m", "30d", "today"] as const;
const RANGES = ["all", "3m", "30d", "today"] as const;
type Range = (typeof RANGES)[number];
const RANGE_MONTHS: Record<Range, number | null> = {
all: null,
"12m": 12,
"3m": 3,
"30d": 1,
today: 1,
};
// Charts use monthly aggregates, so a sub-month window would collapse to a
// single point (which an area chart can't draw). Keep at least two trailing
// points so every range renders a valid mini-trend.
function sliceMonths<T>(arr: T[], range: Range): T[] {
const months = RANGE_MONTHS[range];
return months == null ? arr : arr.slice(-months);
if (months == null) return arr;
return arr.slice(-Math.max(months, 2));
}
// The Overview sections the Customize popover can show/hide.
@@ -128,7 +131,7 @@ export function AnalysisView() {
const { t } = useTranslation();
const [data, setData] = useState<Analytics | null>(null);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [range, setRange] = useState<Range>("30d");
const [range, setRange] = useState<Range>("all");
const [visible, setVisible] = useState<Record<SectionKey, boolean>>({
visits: true,
patients: true,
@@ -26,13 +26,18 @@ export function hasCitationMarkers(text: string): boolean {
}
// Rewrites [[src:id]] → [n](#cite-id) for known sources; strips unknown markers.
// Each source is cited at most once (its first occurrence) so an over-eager model
// that tags every word with the same source collapses to a single chip.
function withCitationLinks(
text: string,
numberById: Map<string, number>,
): string {
const seen = new Set<string>();
return text.replace(CITATION_RE, (_match, id: string) => {
const num = numberById.get(id);
return num ? `[${num}](${CITE_HREF_PREFIX}${id})` : "";
if (!num || seen.has(id)) return "";
seen.add(id);
return `[${num}](${CITE_HREF_PREFIX}${id})`;
});
}
+48 -87
View File
@@ -1,6 +1,6 @@
"use client";
import { Pencil } from "lucide-react";
import { ArrowRight, Pencil } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -61,9 +61,10 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
};
// Fixed width so the cards sit in a horizontal scroll row instead of squashing,
// plus a subtle clickable affordance (they open a detail dialog).
// plus a subtle clickable affordance (they open a detail dialog). Compact cards
// size to their own (short) content — see `items-start` in PatientResult.
const rowCard =
"w-80 shrink-0 cursor-pointer text-left outline-none transition hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
@@ -100,30 +101,6 @@ function Empty({ children }: { children: ReactNode }) {
return <p className="text-muted-foreground">{children}</p>;
}
function TrendBlock({ trend }: { trend: Trend }) {
const { t } = useTranslation();
if (trend.points.length === 0) {
return <Empty>{t("patientCard.trend.empty")}</Empty>;
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<SectionLabel>
{t("patientCard.trend.last", {
label: trend.label,
count: trend.points.length,
})}
</SectionLabel>
<span className="text-foreground">
{trend.points.at(-1)}
<span className="text-muted-foreground"> {trend.unit}</span>
</span>
</div>
<Sparkline points={trend.points} unit={trend.unit} />
</div>
);
}
function TrendDetail({ trend }: { trend: Trend }) {
const { t } = useTranslation();
if (trend.points.length === 0) {
@@ -177,7 +154,8 @@ function AlertBadges({ alerts }: { alerts: string[] }) {
);
}
// A card that previews `children` and opens a roomier dialog of `detail` on click.
// A compact card that previews `children` and opens a roomier dialog of `detail`
// on click. A muted "Click for more" footer signals the card is expandable.
function ExpandableCard({
title,
description,
@@ -189,6 +167,7 @@ function ExpandableCard({
detail: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<Dialog>
<DialogTrigger
@@ -196,6 +175,10 @@ function ExpandableCard({
render={<Card className={cn(rowCard, compactCard)} />}
>
{children}
<div className="flex items-center gap-1 px-4 pt-2 pb-3 text-muted-foreground text-xs">
{t("patientCard.clickForMore")}
<ArrowRight className="size-3" />
</div>
</DialogTrigger>
<DialogPopup className="max-h-[80dvh] sm:max-w-lg">
<DialogHeader>
@@ -243,6 +226,16 @@ function SummaryCard({
/>
</div>
<AlertBadges alerts={patient.alerts} />
{onEdit ? (
<button
className="flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 font-medium text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={onEdit}
type="button"
>
<Pencil className="size-4" />
{t("patientCard.summary.editRecord")}
</button>
) : null}
</div>
}
title={patient.name}
@@ -259,34 +252,15 @@ function SummaryCard({
<Badge variant={statusVariant[patient.status]}>{statusLabel}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<Stat label={t("patientCard.summary.primaryCare")} value={patient.pcp} />
<Stat
label={t("patientCard.summary.lastSeen")}
value={patient.encounters[0]?.date ?? "—"}
/>
<Stat
label={t("patientCard.summary.activeMeds")}
value={patient.medications.length}
/>
<Stat
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
</div>
<AlertBadges alerts={patient.alerts} />
<button
className="mt-auto flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
onEdit?.();
}}
type="button"
>
<Pencil className="size-4" />
{t("patientCard.summary.editRecord")}
</button>
<CardContent className="grid grid-cols-2 gap-x-3 gap-y-2 pt-0">
<Stat
label={t("patientCard.summary.activeMeds")}
value={patient.medications.length}
/>
<Stat
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
</CardContent>
</ExpandableCard>
);
@@ -327,10 +301,9 @@ function VitalsCard({ patient }: { patient: Patient }) {
{t("patientCard.vitals.taken", { at: vitals.takenAt })}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
<Separator />
<TrendBlock trend={patient.vitalsTrend} />
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-2 pt-0">
<Stat label={t("patientCard.vitals.bp")} value={vitals.bp} />
<Stat label={t("patientCard.vitals.hr")} value={vitals.hr} />
</CardContent>
</ExpandableCard>
);
@@ -387,25 +360,6 @@ function LabsCard({ patient }: { patient: Patient }) {
{t("patientCard.labs.asOf", { at: patient.labs[0]?.takenAt ?? "—" })}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{patient.labs.length === 0 ? (
<Empty>{t("patientCard.labs.empty")}</Empty>
) : (
<>
<div className="flex flex-col gap-2">
{patient.labs.map((lab) => (
<Row
key={lab.name}
label={lab.name}
value={<LabValue flag={lab.flag} value={lab.value} />}
/>
))}
</div>
<Separator />
<TrendBlock trend={patient.labTrend} />
</>
)}
</CardContent>
</ExpandableCard>
);
}
@@ -442,7 +396,6 @@ function MedicationsCard({ patient }: { patient: Patient }) {
})}
</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
);
}
@@ -477,7 +430,6 @@ function ProblemsCard({ patient }: { patient: Patient }) {
{t("patientCard.problems.active", { count: patient.problems.length })}
</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
);
}
@@ -528,10 +480,19 @@ function AllergiesCard({ patient }: { patient: Patient }) {
>
<CardHeader>
<CardTitle>{t("patientCard.allergies.title")}</CardTitle>
<CardDescription>
{patient.allergies.length === 0
? t("patientCard.allergies.none")
: t("patientCard.allergies.count", {
count: patient.allergies.length,
})}
</CardDescription>
</CardHeader>
<CardContent>
<AllergiesList patient={patient} />
</CardContent>
{patient.alerts.length > 0 ? (
<CardContent className="pt-0">
<AlertBadges alerts={patient.alerts} />
</CardContent>
) : null}
</ExpandableCard>
);
}
@@ -576,10 +537,10 @@ function VisitsCard({ patient }: { patient: Patient }) {
>
<CardHeader>
<CardTitle>{t("patientCard.visits.title")}</CardTitle>
<CardDescription>
{t("patientCard.visits.recent", { count: patient.encounters.length })}
</CardDescription>
</CardHeader>
<CardContent>
<VisitsList patient={patient} />
</CardContent>
</ExpandableCard>
);
}
+7 -3
View File
@@ -31,10 +31,14 @@ export type Route = {
// True when `link` matches the current path. "/" only matches exactly; any other
// link matches itself and its nested routes (so a parent stays lit on subpages).
// Pass `exact` for sub-items so a parent link (e.g. /messages) doesn't also light
// the Inbox sub when a sibling sub (/messages/meetings) is the active page.
function useIsActive() {
const pathname = usePathname();
return (link: string) =>
link === "/" ? pathname === "/" : pathname === link || pathname.startsWith(`${link}/`);
return (link: string, exact = false) => {
if (link === "/" || exact) return pathname === link;
return pathname === link || pathname.startsWith(`${link}/`);
};
}
export default function DashboardNavigation({ routes }: { routes: Route[] }) {
@@ -90,7 +94,7 @@ export default function DashboardNavigation({ routes }: { routes: Route[] }) {
<SidebarMenuSubItem key={`${route.id}-${subRoute.link}`}>
<SidebarMenuSubButton
className="text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent data-[active=true]:bg-transparent data-[active=true]:font-medium data-[active=true]:text-foreground"
isActive={isActive(subRoute.link)}
isActive={isActive(subRoute.link, true)}
render={<Link href={subRoute.link} prefetch={true} />}
>
<span className="truncate">{subRoute.title}</span>
@@ -1206,6 +1206,7 @@
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",
"edit": "Edit",
"clickForMore": "Click for more",
"sex": {
"F": "Female",
"M": "Male"
@@ -1251,7 +1252,8 @@
"allergies": {
"title": "Allergies & alerts",
"sectionLabel": "Allergies",
"none": "No known allergies."
"none": "No known allergies.",
"count": "{{count}} recorded"
},
"visits": {
"title": "Recent visits",