| 1 | import type { OrchestrationV2ProviderFailureClass } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; |
| 3 | import { Alert, AlertAction, AlertDescription } from "../ui/alert"; |
| 4 | import { Button } from "../ui/button"; |
| 5 | import { CircleAlertIcon, XIcon } from "lucide-react"; |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | import { OpenAI } from "../Icons"; |
| 8 | import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; |
| 9 | |
| 10 | export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null { |
| 11 | return error === null ? null : `${threadKey}\u0000${error}`; |
| 12 | } |
| 13 | |
| 14 | export 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. |
| 27 | const sessionDismissedThreadErrorBannerKeys = new Set<string>(); |
| 28 | |
| 29 | export function dismissThreadErrorBannerForSession(bannerKey: string | null): void { |
| 30 | if (bannerKey !== null) { |
| 31 | sessionDismissedThreadErrorBannerKeys.add(bannerKey); |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | export function isThreadErrorBannerDismissedForSession(bannerKey: string | null): boolean { |
| 36 | return bannerKey !== null && sessionDismissedThreadErrorBannerKeys.has(bannerKey); |
| 37 | } |
| 38 | |
| 39 | export 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 | }); |