1import type { OrchestrationV2ProviderFailureClass } from "@t3tools/contracts";
2import { memo } from "react";
3import { Alert, AlertAction, AlertDescription } from "../ui/alert";
4import { Button } from "../ui/button";
5import { CircleAlertIcon, XIcon } from "lucide-react";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7import { OpenAI } from "../Icons";
8import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
9
10export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null {
11 return error === null ? null : `${threadKey}\u0000${error}`;
12}
13
14export function shouldShowThreadErrorBanner(
15 threadKey: string,
16 error: string | null,
17 isDismissed: boolean,
18): boolean {
19 return getThreadErrorBannerKey(threadKey, error) !== null && !isDismissed;
20}
21
22// Session-scoped (module-level so it survives ChatView remounts, e.g. route
23// changes between threads). Mirrors the branch-mismatch banner: a dismissal
24// is remembered per thread key plus message, so navigating away to a thread
25// with no error cannot resurrect the banner, while a different error message
26// on the same thread still appears.
27const sessionDismissedThreadErrorBannerKeys = new Set<string>();
28
29export function dismissThreadErrorBannerForSession(bannerKey: string | null): void {
30 if (bannerKey !== null) {
31 sessionDismissedThreadErrorBannerKeys.add(bannerKey);
32 }
33}
34
35export function isThreadErrorBannerDismissedForSession(bannerKey: string | null): boolean {
36 return bannerKey !== null && sessionDismissedThreadErrorBannerKeys.has(bannerKey);
37}
38
39export const ThreadErrorBanner = memo(function ThreadErrorBanner({
40 error,
41 onDismiss,
42 errorClass,
43 chatGptUsageLimit = false,
44}: {
45 error: string | null;
46 errorClass?: OrchestrationV2ProviderFailureClass | null;
47 onDismiss?: () => void;
48 chatGptUsageLimit?: boolean;
49}) {
50 if (!error) return null;
51 const variant = errorClass === "usage_limit" ? "warning" : "error";
52 return (
53 <div className="pointer-events-auto mx-auto w-fit max-w-[min(48rem,calc(100%-2rem))] pt-3">
54 <Alert variant={variant} surface="glass" controlAlignment="first-line" data-variant={variant}>
55 {chatGptUsageLimit ? (
56 <OpenAI className="size-4 text-foreground!" aria-hidden="true" />
57 ) : (
58 <CircleAlertIcon />
59 )}
60 <AlertDescription>
61 {chatGptUsageLimit ? (
62 <div className="space-y-1">
63 <p className="font-medium">ChatGPT usage limit reached</p>
64 <p>Review your usage settings in ChatGPT to continue.</p>
65 </div>
66 ) : (
67 <Tooltip>
68 <TooltipTrigger render={<div className="line-clamp-3" />}>{error}</TooltipTrigger>
69 <TooltipPopup side="top" className="whitespace-pre-wrap">
70 {error}
71 </TooltipPopup>
72 </Tooltip>
73 )}
74 </AlertDescription>
75 {(chatGptUsageLimit || onDismiss) && (
76 <AlertAction>
77 {chatGptUsageLimit ? <ChatGptUsageButton variant="default" size="sm" /> : null}
78 {onDismiss ? (
79 <Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>
80 <XIcon />
81 </Button>
82 ) : null}
83 </AlertAction>
84 )}
85 </Alert>
86 </div>
87 );
88});