frontend: dismissible chat error alert + compact import card

- the inline chat error now has an X to dismiss it, and is the single surface
  for errors (dropped the duplicate toast) so failures show once and clearly
- the import preview card caps the skipped-rows list (scrolls, one line each
  with a "+N more" tail) so a file full of validation errors no longer renders
  a giant card

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-14 19:43:55 +03:00
parent db20359b25
commit 9eb6353e00
3 changed files with 41 additions and 24 deletions
+29 -20
View File
@@ -2,7 +2,7 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { AlertTriangle, ShieldCheck } from "lucide-react";
import { AlertTriangle, ShieldCheck, X } from "lucide-react";
import { nanoid } from "nanoid";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -47,7 +47,6 @@ import type { TemetroUIMessage } from "@/lib/ai-chat";
import { getAiConfig } from "@/lib/ai-settings";
import { API_BASE_URL } from "@/lib/api-client";
import { getPatient } from "@/lib/patients";
import { notify } from "@/lib/toast";
// Trigger: `/patient 10293` or just `/10293` — a client-side fast-path that
// pulls records instantly without the LLM (also works offline).
@@ -96,12 +95,13 @@ export function ChatPanel() {
};
}, []);
// Pop a toast whenever a request errors, so failures are never silent.
// Surface errors inline (and dismissible) instead of as a toast, so a failure
// stays visible until acknowledged and isn't duplicated. Reset the dismissed
// flag whenever a fresh error arrives.
const [errorDismissed, setErrorDismissed] = useState(false);
useEffect(() => {
if (error) {
notify.error(t("chat.error.title"), error.message || t("chat.error.body"));
}
}, [error, t]);
if (error) setErrorDismissed(false);
}, [error]);
const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama";
@@ -210,20 +210,29 @@ export function ChatPanel() {
/>
) : null;
const errorAlert = error ? (
<div
className="flex w-full items-start gap-2 rounded-2xl border border-destructive/40 bg-destructive/8 px-4 py-3 text-destructive-foreground text-sm"
role="alert"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="space-y-0.5">
<p className="font-medium">{t("chat.error.title")}</p>
<p className="text-destructive-foreground/90">
{error.message || t("chat.error.body")}
</p>
const errorAlert =
error && !errorDismissed ? (
<div
className="flex w-full items-start gap-2 rounded-2xl border border-destructive/40 bg-destructive/8 px-4 py-3 text-destructive-foreground text-sm"
role="alert"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="flex-1 space-y-0.5">
<p className="font-medium">{t("chat.error.title")}</p>
<p className="text-destructive-foreground/90">
{error.message || t("chat.error.body")}
</p>
</div>
<button
aria-label={t("chat.error.dismiss")}
className="-mr-1 shrink-0 rounded-md p-1 text-destructive-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive-foreground"
onClick={() => setErrorDismissed(true)}
type="button"
>
<X className="size-4" />
</button>
</div>
</div>
) : null;
) : null;
// Render one assistant/user message: a Chain-of-Thought trace built from any
// `data-step` parts, then the rest of the parts (text + record cards) in order.
@@ -67,13 +67,19 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
</div>
{data.invalid.length > 0 ? (
<ul className="space-y-1 rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground">
<ul className="max-h-40 space-y-1 overflow-y-auto rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground">
{data.invalid.slice(0, 5).map((issue) => (
<li key={issue.index}>
<li className="truncate" key={issue.index}>
{t("chat.importCard.row", { index: issue.index + 1 })}:{" "}
{issue.errors.join("; ")}
{issue.errors[0]}
{issue.errors.length > 1 ? ` (+${issue.errors.length - 1})` : ""}
</li>
))}
{data.invalid.length > 5 ? (
<li className="text-muted-foreground/70">
{t("chat.importCard.more", { count: data.invalid.length - 5 })}
</li>
) : null}
</ul>
) : null}
@@ -745,6 +745,7 @@
"skipped": "Skipped",
"total": "Parsed",
"row": "Row {{index}}",
"more": "+{{count}} more",
"approve": "Import {{count}} record(s)",
"approve_one": "Import 1 record",
"reject": "Discard",
@@ -758,7 +759,8 @@
},
"error": {
"title": "AI request failed",
"body": "Something went wrong reaching the AI. Check your provider and API key in Settings → AI."
"body": "Something went wrong reaching the AI. Check your provider and API key in Settings → AI.",
"dismiss": "Dismiss"
}
},
"patientCard": {