Files
Khalid Abdi e841cb56e1 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>
2026-06-20 01:57:31 +03:00

135 lines
4.4 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Components } from "streamdown";
import { MessageResponse } from "@/components/ai-elements/message";
import { Badge } from "@/components/ui/badge";
import {
PreviewCard,
PreviewCardPopup,
PreviewCardTrigger,
} from "@/components/ui/preview-card";
import type { SourceData } from "@/lib/ai-chat";
// The agent cites a retrieved record inline with a [[src:<id>]] marker. We turn
// each marker into a hash link ([n](#cite-<id>)) — hash links survive
// react-markdown's URL sanitization — then override the link renderer to show a
// numbered inline citation chip whose hover card names the source.
const CITATION_RE = /\[\[\s*src:\s*([a-zA-Z0-9_-]+)\s*\]\]/g;
const CITE_HREF_PREFIX = "#cite-";
export function hasCitationMarkers(text: string): boolean {
CITATION_RE.lastIndex = 0;
return CITATION_RE.test(text);
}
// 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);
if (!num || seen.has(id)) return "";
seen.add(id);
return `[${num}](${CITE_HREF_PREFIX}${id})`;
});
}
function CitationChip({ num, source }: { num: number; source?: SourceData }) {
const { t } = useTranslation();
if (!source) return null;
const kindKey = `chat.sources.kind.${source.kind}`;
const kindLabel = t(kindKey);
return (
<PreviewCard>
<PreviewCardTrigger
render={
<Badge
className="mx-0.5 cursor-default rounded-full px-1.5 align-super text-[10px] leading-none no-underline"
variant="secondary"
/>
}
>
{num}
</PreviewCardTrigger>
<PreviewCardPopup className="w-64 p-3">
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{kindLabel === kindKey ? t("chat.sources.title") : kindLabel}
</p>
<p className="mt-0.5 text-foreground text-sm">{source.title}</p>
</PreviewCardPopup>
</PreviewCard>
);
}
// Renders assistant markdown with inline citation chips resolved from the
// message's streamed sources.
export function CitedResponse({
text,
sources,
}: {
text: string;
sources: SourceData[];
}) {
const { numberById, sourceById, processed } = useMemo(() => {
const numberById = new Map<string, number>();
const sourceById = new Map<string, SourceData>();
sources.forEach((s, i) => {
numberById.set(s.id, i + 1);
sourceById.set(s.id, s);
});
return {
numberById,
sourceById,
processed: withCitationLinks(text, numberById),
};
}, [text, sources]);
const components = useMemo<Components>(
() => ({
a({ href, children, ...props }) {
if (typeof href === "string" && href.startsWith(CITE_HREF_PREFIX)) {
const id = href.slice(CITE_HREF_PREFIX.length);
const num = numberById.get(id) ?? 0;
return <CitationChip num={num} source={sourceById.get(id)} />;
}
return (
<a href={href} rel="noreferrer" target="_blank" {...props}>
{children}
</a>
);
},
}),
[numberById, sourceById],
);
return <MessageResponse components={components}>{processed}</MessageResponse>;
}
// Provenance footer shown beneath a message that has sources — primarily a
// fallback when the model didn't place inline markers, so retrieved records are
// always attributed.
export function SourcesFooter({ sources }: { sources: SourceData[] }) {
const { t } = useTranslation();
if (sources.length === 0) return null;
return (
<div className="mt-1 flex flex-wrap items-center gap-1.5 border-border/60 border-t pt-2">
<span className="text-muted-foreground text-xs">
{t("chat.sources.title")}
</span>
{sources.map((s, i) => (
<Badge className="gap-1 rounded-full font-normal" key={s.id} variant="outline">
<span className="text-muted-foreground">{i + 1}</span>
<span className="max-w-48 truncate">{s.title}</span>
</Badge>
))}
</div>
);
}