mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 06:45:26 +00:00
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:
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user