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>
  );
}