openhands / src /components /features /chat /error-message-banner.tsx
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
9b906ea verified
Raw History Blame Contribute Delete
6.85 kB
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Check, CircleAlert, CircleX, Copy, X } from "lucide-react";
import { OH_STATUS_ERROR_COLOR } from "#/constants/status-colors";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { getAcpErrorHeaderKey } from "#/utils/acp-error-codes";
import { cn } from "#/utils/utils";
import type { ErrorClassification } from "@openhands/typescript-client";
interface ErrorMessageBannerProps {
message: string;
/** Structured error code (e.g. "ACPAuthRequired") used to pick a header. */
code?: string | null;
onDismiss?: () => void;
onRetry?: () => void;
/** Recovery action (e.g. re-authenticate) shown for credential failures. */
onReauth?: () => void;
classification?: ErrorClassification | null;
}
const DEFAULT_MAX_COLLAPSED_CHARS = 220;
export function ErrorMessageBanner({
message,
code,
onDismiss,
onRetry,
onReauth,
classification,
}: ErrorMessageBannerProps) {
const { t, i18n } = useTranslation("openhands");
const headerKey = getAcpErrorHeaderKey(code);
const [isExpanded, setIsExpanded] = React.useState(false);
const [isMultiLine, setIsMultiLine] = React.useState(false);
const [isCopied, setIsCopied] = React.useState(false);
const contentRef = React.useRef<HTMLDivElement>(null);
const isI18nKey = i18n.exists(message, { ns: "openhands" });
const displayTextForLength = isI18nKey ? String(t(message)) : message;
const shouldShowToggle =
displayTextForLength.length > DEFAULT_MAX_COLLAPSED_CHARS;
const isCollapsed = shouldShowToggle && !isExpanded;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(displayTextForLength);
setIsCopied(true);
} catch {
displayErrorToast(t(I18nKey.CHAT_INTERFACE$CHAT_MESSAGE_COPY_FAILED));
}
};
React.useEffect(() => {
if (!isCopied) return undefined;
const timeout = setTimeout(() => {
setIsCopied(false);
}, 2000);
return () => clearTimeout(timeout);
}, [isCopied]);
React.useEffect(() => {
setIsCopied(false);
}, [displayTextForLength]);
React.useLayoutEffect(() => {
const content = contentRef.current;
if (!content) {
return undefined;
}
const updateIsMultiLine = () => {
const lineHeight = Number.parseFloat(
getComputedStyle(content).lineHeight,
);
if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
setIsMultiLine(false);
return;
}
setIsMultiLine(content.getBoundingClientRect().height > lineHeight * 1.5);
};
updateIsMultiLine();
const observer = new ResizeObserver(updateIsMultiLine);
observer.observe(content);
return () => observer.disconnect();
}, [displayTextForLength, isCollapsed, isExpanded, message]);
return (
<div
className={cn(
"flex w-full gap-2 rounded-lg border border-[var(--oh-border)] bg-[var(--oh-surface-raised)] p-2 text-[var(--oh-foreground)]",
isMultiLine ? "items-start" : "items-center",
)}
data-testid="error-message-banner"
>
{classification != null &&
classification.kind !== "internal" &&
classification.kind !== "unknown" ? (
<CircleAlert
aria-hidden
className="h-4 w-4 shrink-0 text-[var(--oh-warning)]"
strokeWidth={2}
data-testid="warning-message-banner-icon"
/>
) : (
<CircleX
aria-hidden
className="h-4 w-4 shrink-0"
strokeWidth={2}
style={{ color: OH_STATUS_ERROR_COLOR }}
data-testid="error-message-banner-icon"
/>
)}
<div className="min-w-0 flex-1">
{headerKey && (
<div
className="text-sm font-medium text-[var(--oh-foreground)]"
data-testid="error-message-banner-header"
>
{t(headerKey)}
</div>
)}
<div
ref={contentRef}
className={cn(
"whitespace-pre-wrap break-words text-sm text-[var(--oh-muted)]",
isCollapsed && "line-clamp-3",
)}
data-testid="error-message-banner-content"
>
{isI18nKey ? <Trans ns="openhands" i18nKey={message} /> : message}
</div>
{onReauth && (
<button
type="button"
onClick={onReauth}
className="mt-2 cursor-pointer rounded-md border border-[var(--oh-border)] px-2 py-1 text-xs font-normal text-[var(--oh-foreground)] hover:bg-[var(--oh-interactive-hover)]"
data-testid="error-message-banner-reauth"
>
{t(I18nKey.ERROR$ACP_UPDATE_CREDENTIALS)}
</button>
)}
{shouldShowToggle && (
<button
type="button"
className="mt-1 cursor-pointer text-xs font-normal text-[var(--oh-foreground)] underline"
onClick={() => setIsExpanded((prev) => !prev)}
data-testid="error-message-banner-toggle"
>
{isExpanded
? t(I18nKey.COMMON$VIEW_LESS)
: t(I18nKey.COMMON$VIEW_MORE)}
</button>
)}
</div>
<div
className={cn(
"flex shrink-0 gap-1",
isMultiLine ? "self-start" : "items-center",
)}
>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="cursor-pointer rounded-md border border-[var(--oh-border)] px-2 py-1 text-xs font-normal text-[var(--oh-foreground)] hover:bg-[var(--oh-interactive-hover)]"
data-testid="error-message-banner-retry"
>
{t(I18nKey.CHAT_INTERFACE$MESSAGE_RETRY)}
</button>
)}
<button
type="button"
onClick={handleCopy}
className="shrink-0 cursor-pointer rounded-md p-1 text-[var(--oh-muted)] hover:bg-[var(--oh-interactive-hover)] hover:text-[var(--oh-foreground)]"
aria-label={t(isCopied ? I18nKey.BUTTON$COPIED : I18nKey.BUTTON$COPY)}
data-testid="error-message-banner-copy"
>
{isCopied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="shrink-0 cursor-pointer rounded-md p-1 text-[var(--oh-muted)] hover:bg-[var(--oh-interactive-hover)] hover:text-[var(--oh-foreground)]"
aria-label={t(I18nKey.BUTTON$CLOSE)}
data-testid="error-message-banner-dismiss"
>
<X className="h-4 w-4" aria-hidden />
</button>
)}
</div>
</div>
);
}