File size: 4,226 Bytes
9b906ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | import React from "react";
import { useTranslation } from "react-i18next";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
import ArrowUp from "#/icons/angle-up-solid.svg?react";
import { SuccessIndicator } from "./success-indicator";
import { ObservationResultStatus } from "#/components/conversation-events/chat/event-content-helpers/get-observation-result";
import { MarkdownRenderer } from "../markdown/markdown-renderer";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import { formatEventTimestamp } from "#/utils/format-event-timestamp";
interface GenericEventMessageProps {
title: React.ReactNode;
details: string | React.ReactNode;
success?: ObservationResultStatus;
initiallyExpanded?: boolean;
/** Where to place the expand/collapse chevron relative to the title. */
chevronPosition?: "before" | "after";
/** Extra content rendered at the end of the title row (right side). */
titleTrailing?: React.ReactNode;
/** Optional icon rendered before the title text. */
titleIcon?: React.ReactNode;
timestamp?: string;
}
export function GenericEventMessage({
title,
details,
success,
initiallyExpanded = false,
chevronPosition = "after",
titleTrailing,
titleIcon,
timestamp,
}: GenericEventMessageProps) {
const { t, i18n } = useTranslation("openhands");
const [showDetails, setShowDetails] = React.useState(initiallyExpanded);
const [isHovered, setIsHovered] = React.useState(false);
const [hasFocusWithin, setHasFocusWithin] = React.useState(false);
const timestampLabel = formatEventTimestamp(timestamp, i18n?.language);
const chevron = details ? (
<button
type="button"
onClick={(event) => {
setShowDetails((prev) => !prev);
if (event.detail > 0) {
setIsHovered(false);
event.currentTarget.blur();
}
}}
className="cursor-pointer text-left"
aria-label={
showDetails ? t(I18nKey.BUTTON$COLLAPSE) : t(I18nKey.BUTTON$EXPAND)
}
>
{showDetails ? (
<ArrowUp
className={cn(
"h-4 w-4 inline fill-[var(--oh-muted)]",
chevronPosition === "after" ? "ml-2" : "mr-2",
)}
/>
) : (
<ArrowDown
className={cn(
"h-4 w-4 inline fill-[var(--oh-muted)]",
chevronPosition === "after" ? "ml-2" : "mr-2",
)}
/>
)}
</button>
) : null;
const titleContent = (
<div
data-testid="generic-event-message-title"
className="flex items-center"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocusCapture={() => setHasFocusWithin(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setHasFocusWithin(false);
}
}}
>
{chevronPosition === "before" && chevron}
{titleIcon}
{/* Wrap the title in a span so any whitespace inside Trans-rendered
fragments (e.g. "Editing <path>...</path>") is preserved by
normal inline flow instead of being collapsed between
anonymous flex items. */}
<span>{title}</span>
{chevronPosition === "after" && chevron}
</div>
);
const titleContentWithTimestamp = timestampLabel ? (
<StyledTooltip
content={<time dateTime={timestamp}>{timestampLabel}</time>}
placement="top"
isOpen={isHovered || hasFocusWithin}
>
{titleContent}
</StyledTooltip>
) : (
titleContent
);
const titleRow = (
<div className="flex items-center justify-between font-normal text-[var(--oh-muted)]">
{titleContentWithTimestamp}
<div className="flex items-center">
{titleTrailing}
{success && <SuccessIndicator status={success} />}
</div>
</div>
);
return (
<div className="flex flex-col gap-1.5 my-1 py-1 text-sm w-full">
{titleRow}
{showDetails &&
(typeof details === "string" ? (
<MarkdownRenderer>{details}</MarkdownRenderer>
) : (
details
))}
</div>
);
}
|