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 { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { AlertTriangle, ShieldCheck } from "lucide-react"; import { AlertTriangle, ShieldCheck, X } from "lucide-react";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { getAiConfig } from "@/lib/ai-settings";
import { API_BASE_URL } from "@/lib/api-client"; import { API_BASE_URL } from "@/lib/api-client";
import { getPatient } from "@/lib/patients"; import { getPatient } from "@/lib/patients";
import { notify } from "@/lib/toast";
// Trigger: `/patient 10293` or just `/10293` — a client-side fast-path that // Trigger: `/patient 10293` or just `/10293` — a client-side fast-path that
// pulls records instantly without the LLM (also works offline). // 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(() => { useEffect(() => {
if (error) { if (error) setErrorDismissed(false);
notify.error(t("chat.error.title"), error.message || t("chat.error.body")); }, [error]);
}
}, [error, t]);
const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama"; const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama";
@@ -210,20 +210,29 @@ export function ChatPanel() {
/> />
) : null; ) : null;
const errorAlert = error ? ( const errorAlert =
<div error && !errorDismissed ? (
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" <div
role="alert" 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"> <AlertTriangle className="mt-0.5 size-4 shrink-0" />
<p className="font-medium">{t("chat.error.title")}</p> <div className="flex-1 space-y-0.5">
<p className="text-destructive-foreground/90"> <p className="font-medium">{t("chat.error.title")}</p>
{error.message || t("chat.error.body")} <p className="text-destructive-foreground/90">
</p> {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>
</div> ) : null;
) : null;
// Render one assistant/user message: a Chain-of-Thought trace built from any // 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. // `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> </div>
{data.invalid.length > 0 ? ( {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) => ( {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 })}:{" "} {t("chat.importCard.row", { index: issue.index + 1 })}:{" "}
{issue.errors.join("; ")} {issue.errors[0]}
{issue.errors.length > 1 ? ` (+${issue.errors.length - 1})` : ""}
</li> </li>
))} ))}
{data.invalid.length > 5 ? (
<li className="text-muted-foreground/70">
{t("chat.importCard.more", { count: data.invalid.length - 5 })}
</li>
) : null}
</ul> </ul>
) : null} ) : null}
@@ -745,6 +745,7 @@
"skipped": "Skipped", "skipped": "Skipped",
"total": "Parsed", "total": "Parsed",
"row": "Row {{index}}", "row": "Row {{index}}",
"more": "+{{count}} more",
"approve": "Import {{count}} record(s)", "approve": "Import {{count}} record(s)",
"approve_one": "Import 1 record", "approve_one": "Import 1 record",
"reject": "Discard", "reject": "Discard",
@@ -758,7 +759,8 @@
}, },
"error": { "error": {
"title": "AI request failed", "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": { "patientCard": {