import React from "react"; import { useTranslation } from "react-i18next"; import { cn } from "#/utils/utils"; import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button"; import type { SourceType } from "#/types/agent-server/core/base/common"; import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; import { I18nKey } from "#/i18n/declaration"; import { TextShimmer } from "#/components/shared/text-shimmer"; import { formatEventTimestamp } from "#/utils/format-event-timestamp"; import { MarkdownRenderer } from "../markdown/markdown-renderer"; import { PendingStopIcon } from "./pending-stop-icon"; import { UserMessageBody, chatBubbleMarkdownComponents, USER_MESSAGE_LINE_HEIGHT_PX, } from "./user-message-body"; export type ChatMessagePendingStatus = "sending" | "error"; interface ChatMessageProps { type: SourceType; message: string; actions?: Array<{ icon: React.ReactNode; onClick: () => void; tooltip?: string; }>; isFromPlanningAgent?: boolean; pendingStatus?: ChatMessagePendingStatus; onRetry?: () => void; onDismiss?: () => void; onStop?: () => void; timestamp?: string; } export function ChatMessage({ type, message, children, actions, isFromPlanningAgent = false, pendingStatus, onRetry, onDismiss, onStop, timestamp, }: React.PropsWithChildren) { const { t, i18n } = useTranslation("openhands"); const [isHovering, setIsHovering] = React.useState(false); const [isCopy, setIsCopy] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false); const [isTruncatable, setIsTruncatable] = React.useState(false); const [isSingleLinePendingMessage, setIsSingleLinePendingMessage] = React.useState(true); const pendingMessageContentRef = React.useRef(null); const timestampLabel = formatEventTimestamp(timestamp, i18n?.language); React.useEffect(() => { setIsExpanded(false); }, [message]); const handleCopyToClipboard = async () => { await navigator.clipboard.writeText(message); setIsCopy(true); }; React.useEffect(() => { let timeout: NodeJS.Timeout; if (isCopy) { timeout = setTimeout(() => { setIsCopy(false); }, 2000); } return () => { clearTimeout(timeout); }; }, [isCopy]); const isPendingUserMessage = type === "user" && (pendingStatus === "error" || pendingStatus === "sending"); const canStopPendingMessage = pendingStatus === "sending" && onStop != null; const showStopButton = canStopPendingMessage && isHovering; const useTruncatedUserBody = type === "user" && pendingStatus == null; const isCollapsed = useTruncatedUserBody && isTruncatable && !isExpanded; const hasBubbleChildren = React.Children.count(children) > 0; React.useLayoutEffect(() => { if (!canStopPendingMessage || useTruncatedUserBody) { return undefined; } const content = pendingMessageContentRef.current; if (!content) { return undefined; } const measure = () => { const lineHeight = Number.parseFloat( getComputedStyle(content).lineHeight, ); const linePx = Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : USER_MESSAGE_LINE_HEIGHT_PX; setIsSingleLinePendingMessage(content.scrollHeight <= linePx + 1); }; measure(); const observer = new ResizeObserver(measure); observer.observe(content); return () => observer.disconnect(); }, [message, canStopPendingMessage, useTruncatedUserBody]); const messageContent = useTruncatedUserBody ? ( ) : (
{message}
); const renderedMessageContent = messageContent; const messageBubble = (
setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} className={cn( "rounded-xl relative w-fit max-w-full flex flex-col", hasBubbleChildren && "gap-2", type === "user" && "mt-6 bg-tertiary self-end px-4 py-2.5", type === "agent" && "mt-6 w-full max-w-full bg-transparent", isFromPlanningAgent && type === "agent" && "border border-[#597ff4] bg-tertiary p-4 mt-2", pendingStatus === "error" && "border border-[var(--oh-status-error)]/40", !isPendingUserMessage && "last:mb-4", )} >
event.stopPropagation()} > {actions?.map((action, index) => action.tooltip ? ( ) : ( ), )}
{renderedMessageContent} {canStopPendingMessage ? ( ) : null} {isCollapsed ? (
); const messageBubbleWithTimestamp = timestampLabel ? ( {timestampLabel}} placement="top" isOpen={isHovering} > {messageBubble} ) : ( messageBubble ); if (type === "user" && pendingStatus === "error") { return (
{messageBubbleWithTimestamp}
{t(I18nKey.CHAT_INTERFACE$MESSAGE_SEND_FAILED)} {onRetry ? ( ) : null} {onDismiss ? ( ) : null}
); } if (type === "user" && pendingStatus === "sending") { return (
{messageBubbleWithTimestamp}
{t(I18nKey.CHAT_INTERFACE$MESSAGE_SENDING)}
); } return messageBubbleWithTimestamp; }