autofixHighlightPopup.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import {
  2. startTransition,
  3. useEffect,
  4. useLayoutEffect,
  5. useMemo,
  6. useRef,
  7. useState,
  8. } from 'react';
  9. import {createPortal} from 'react-dom';
  10. import styled from '@emotion/styled';
  11. import {useMutation, useQueryClient} from '@tanstack/react-query';
  12. import {motion} from 'framer-motion';
  13. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  14. import {SeerIcon} from 'sentry/components/ai/SeerIcon';
  15. import UserAvatar from 'sentry/components/avatar/userAvatar';
  16. import {Button} from 'sentry/components/button';
  17. import {
  18. makeAutofixQueryKey,
  19. useAutofixData,
  20. } from 'sentry/components/events/autofix/useAutofix';
  21. import Input from 'sentry/components/input';
  22. import LoadingIndicator from 'sentry/components/loadingIndicator';
  23. import {IconChevron} from 'sentry/icons';
  24. import {t} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import testableTransition from 'sentry/utils/testableTransition';
  27. import useApi from 'sentry/utils/useApi';
  28. import {useUser} from 'sentry/utils/useUser';
  29. import type {CommentThreadMessage} from './types';
  30. interface Props {
  31. groupId: string;
  32. referenceElement: HTMLElement | null;
  33. retainInsightCardIndex: number | null;
  34. runId: string;
  35. selectedText: string;
  36. stepIndex: number;
  37. }
  38. interface OptimisticMessage extends CommentThreadMessage {
  39. isLoading?: boolean;
  40. }
  41. function useCommentThread({groupId, runId}: {groupId: string; runId: string}) {
  42. const api = useApi({persistInFlight: true});
  43. const queryClient = useQueryClient();
  44. return useMutation({
  45. mutationFn: (params: {
  46. message: string;
  47. retain_insight_card_index: number | null;
  48. selected_text: string;
  49. step_index: number;
  50. thread_id: string;
  51. }) => {
  52. return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
  53. method: 'POST',
  54. data: {
  55. run_id: runId,
  56. payload: {
  57. type: 'comment_thread',
  58. message: params.message,
  59. thread_id: params.thread_id,
  60. selected_text: params.selected_text,
  61. step_index: params.step_index,
  62. retain_insight_card_index: params.retain_insight_card_index,
  63. },
  64. },
  65. });
  66. },
  67. onSuccess: _ => {
  68. queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)});
  69. },
  70. onError: () => {
  71. addErrorMessage(t('Something went wrong when sending your comment.'));
  72. },
  73. });
  74. }
  75. function AutofixHighlightPopupContent({
  76. selectedText,
  77. groupId,
  78. runId,
  79. stepIndex,
  80. retainInsightCardIndex,
  81. }: Omit<Props, 'referenceElement'>) {
  82. const {mutate: submitComment} = useCommentThread({groupId, runId});
  83. const [comment, setComment] = useState('');
  84. const [threadId] = useState(() => {
  85. const timestamp = Date.now();
  86. const random = Math.floor(Math.random() * 10000);
  87. return `thread-${timestamp}-${random}`;
  88. });
  89. const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
  90. const messagesEndRef = useRef<HTMLDivElement>(null);
  91. // Fetch current autofix data to get comment thread
  92. const autofixData = useAutofixData({groupId});
  93. const currentStep = autofixData?.steps?.[stepIndex];
  94. const commentThread =
  95. currentStep?.active_comment_thread?.id === threadId
  96. ? currentStep.active_comment_thread
  97. : null;
  98. const messages = useMemo(
  99. () => commentThread?.messages ?? [],
  100. [commentThread?.messages]
  101. );
  102. // Combine server messages with optimistic ones
  103. const allMessages = useMemo(
  104. () => [...messages, ...optimisticMessages],
  105. [messages, optimisticMessages]
  106. );
  107. const truncatedText =
  108. selectedText.length > 70
  109. ? selectedText.slice(0, 35).split(' ').slice(0, -1).join(' ') +
  110. '... ...' +
  111. selectedText.slice(-35)
  112. : selectedText;
  113. const currentUser = useUser();
  114. const handleSubmit = (e: React.FormEvent) => {
  115. e.preventDefault();
  116. if (!comment.trim()) {
  117. return;
  118. }
  119. // Add user message and loading assistant message immediately
  120. setOptimisticMessages([
  121. {role: 'user', content: comment},
  122. {role: 'assistant', content: '', isLoading: true},
  123. ]);
  124. submitComment({
  125. message: comment,
  126. thread_id: threadId,
  127. selected_text: selectedText,
  128. step_index: stepIndex,
  129. retain_insight_card_index: retainInsightCardIndex,
  130. });
  131. setComment('');
  132. };
  133. // Clear optimistic messages when we get new server messages
  134. useEffect(() => {
  135. if (messages.length > 0) {
  136. setOptimisticMessages([]);
  137. }
  138. }, [messages]);
  139. const handleContainerClick = (e: React.MouseEvent) => {
  140. e.stopPropagation();
  141. };
  142. const scrollToBottom = () => {
  143. messagesEndRef.current?.scrollIntoView({behavior: 'smooth'});
  144. };
  145. // Add effect to scroll to bottom when messages change
  146. useEffect(() => {
  147. scrollToBottom();
  148. }, [allMessages]);
  149. return (
  150. <Container onClick={handleContainerClick}>
  151. <Header>
  152. <SelectedText>
  153. <span>"{truncatedText}"</span>
  154. </SelectedText>
  155. </Header>
  156. {allMessages.length > 0 && (
  157. <MessagesContainer>
  158. {allMessages.map((message, i) => (
  159. <Message key={i} role={message.role}>
  160. {message.role === 'assistant' ? (
  161. <CircularSeerIcon>
  162. <SeerIcon />
  163. </CircularSeerIcon>
  164. ) : (
  165. <UserAvatar user={currentUser} size={24} />
  166. )}
  167. <MessageContent>
  168. {message.isLoading ? (
  169. <LoadingWrapper>
  170. <LoadingIndicator mini size={12} />
  171. </LoadingWrapper>
  172. ) : (
  173. message.content
  174. )}
  175. </MessageContent>
  176. </Message>
  177. ))}
  178. <div ref={messagesEndRef} />
  179. </MessagesContainer>
  180. )}
  181. {commentThread?.is_completed !== true && (
  182. <InputWrapper onSubmit={handleSubmit}>
  183. <StyledInput
  184. placeholder={t('Questions or comments?')}
  185. value={comment}
  186. onChange={e => setComment(e.target.value)}
  187. size="sm"
  188. autoFocus
  189. />
  190. <StyledButton
  191. size="zero"
  192. type="submit"
  193. borderless
  194. aria-label={t('Submit Comment')}
  195. >
  196. <IconChevron direction="right" />
  197. </StyledButton>
  198. </InputWrapper>
  199. )}
  200. </Container>
  201. );
  202. }
  203. function AutofixHighlightPopup(props: Props) {
  204. const {referenceElement} = props;
  205. const popupRef = useRef<HTMLDivElement>(null);
  206. const [position, setPosition] = useState({left: 0, top: 0});
  207. useLayoutEffect(() => {
  208. if (!referenceElement || !popupRef.current) {
  209. return undefined;
  210. }
  211. const updatePosition = () => {
  212. const rect = referenceElement.getBoundingClientRect();
  213. startTransition(() => {
  214. setPosition({
  215. left: rect.left - 320,
  216. top: rect.top,
  217. });
  218. });
  219. };
  220. // Initial position
  221. updatePosition();
  222. // Create observer to track reference element changes
  223. const resizeObserver = new ResizeObserver(updatePosition);
  224. resizeObserver.observe(referenceElement);
  225. // Track scroll events
  226. const scrollElements = [window, ...getScrollParents(referenceElement)];
  227. scrollElements.forEach(element => {
  228. element.addEventListener('scroll', updatePosition, {passive: true});
  229. });
  230. return () => {
  231. resizeObserver.disconnect();
  232. scrollElements.forEach(element => {
  233. element.removeEventListener('scroll', updatePosition);
  234. });
  235. };
  236. }, [referenceElement]);
  237. return createPortal(
  238. <Wrapper
  239. ref={popupRef}
  240. id="autofix-rethink-input"
  241. data-popup="autofix-highlight"
  242. initial={{opacity: 0, x: -10}}
  243. animate={{opacity: 1, x: 0}}
  244. exit={{opacity: 0, x: -10}}
  245. transition={testableTransition({
  246. duration: 0.2,
  247. })}
  248. style={{
  249. left: `${position.left}px`,
  250. top: `${position.top}px`,
  251. transform: 'none',
  252. }}
  253. >
  254. <Arrow />
  255. <ScaleContainer>
  256. <AutofixHighlightPopupContent {...props} />
  257. </ScaleContainer>
  258. </Wrapper>,
  259. document.body
  260. );
  261. }
  262. const Wrapper = styled(motion.div)`
  263. z-index: ${p => p.theme.zIndex.tooltip};
  264. display: flex;
  265. flex-direction: column;
  266. align-items: flex-start;
  267. margin-right: ${space(1)};
  268. gap: ${space(1)};
  269. width: 300px;
  270. position: fixed;
  271. will-change: transform;
  272. `;
  273. const ScaleContainer = styled(motion.div)`
  274. width: 100%;
  275. display: flex;
  276. flex-direction: column;
  277. align-items: flex-start;
  278. transform-origin: top left;
  279. padding-left: ${space(2)};
  280. `;
  281. const Container = styled(motion.div)`
  282. position: relative;
  283. width: 100%;
  284. border-radius: ${p => p.theme.borderRadius};
  285. background: ${p => p.theme.background};
  286. border: 1px dashed ${p => p.theme.border};
  287. overflow: hidden;
  288. box-shadow: ${p => p.theme.dropShadowHeavy};
  289. &:before {
  290. content: '';
  291. position: absolute;
  292. inset: 0;
  293. background: linear-gradient(
  294. 90deg,
  295. transparent,
  296. ${p => p.theme.active}20,
  297. transparent
  298. );
  299. background-size: 2000px 100%;
  300. pointer-events: none;
  301. }
  302. `;
  303. const InputWrapper = styled('form')`
  304. display: flex;
  305. padding: ${space(0.5)};
  306. background: ${p => p.theme.backgroundSecondary};
  307. position: relative;
  308. `;
  309. const StyledInput = styled(Input)`
  310. flex-grow: 1;
  311. background: ${p => p.theme.background}
  312. linear-gradient(to left, ${p => p.theme.background}, ${p => p.theme.pink400}20);
  313. border-color: ${p => p.theme.innerBorder};
  314. padding-right: ${space(4)};
  315. &:hover {
  316. border-color: ${p => p.theme.border};
  317. }
  318. `;
  319. const StyledButton = styled(Button)`
  320. position: absolute;
  321. right: ${space(1)};
  322. top: 50%;
  323. transform: translateY(-50%);
  324. height: 24px;
  325. width: 24px;
  326. margin-right: 0;
  327. z-index: 2;
  328. `;
  329. const Header = styled('div')`
  330. display: flex;
  331. align-items: center;
  332. gap: ${space(1)};
  333. padding: ${space(1)};
  334. background: ${p => p.theme.backgroundSecondary};
  335. word-break: break-word;
  336. overflow-wrap: break-word;
  337. `;
  338. const SelectedText = styled('div')`
  339. font-size: ${p => p.theme.fontSizeSmall};
  340. color: ${p => p.theme.subText};
  341. display: flex;
  342. align-items: center;
  343. span {
  344. overflow: wrap;
  345. white-space: wrap;
  346. }
  347. `;
  348. const Arrow = styled('div')`
  349. position: absolute;
  350. width: 12px;
  351. height: 12px;
  352. background: ${p => p.theme.active}01;
  353. border: 1px dashed ${p => p.theme.border};
  354. border-right: none;
  355. border-bottom: none;
  356. top: 20px;
  357. right: -6px;
  358. transform: rotate(135deg);
  359. `;
  360. const MessagesContainer = styled('div')`
  361. padding: ${space(1)};
  362. display: flex;
  363. flex-direction: column;
  364. gap: ${space(0.5)};
  365. max-height: 200px;
  366. overflow-y: auto;
  367. scroll-behavior: smooth;
  368. `;
  369. const Message = styled('div')<{role: CommentThreadMessage['role']}>`
  370. display: flex;
  371. gap: ${space(1)};
  372. align-items: flex-start;
  373. `;
  374. const MessageContent = styled('div')`
  375. flex-grow: 1;
  376. border-radius: ${p => p.theme.borderRadius};
  377. padding-top: ${space(0.5)};
  378. font-size: ${p => p.theme.fontSizeSmall};
  379. color: ${p => p.theme.textColor};
  380. `;
  381. const CircularSeerIcon = styled('div')`
  382. display: flex;
  383. align-items: center;
  384. justify-content: center;
  385. width: 24px;
  386. height: 24px;
  387. border-radius: 50%;
  388. background: ${p => p.theme.purple300};
  389. flex-shrink: 0;
  390. > svg {
  391. width: 14px;
  392. height: 14px;
  393. color: ${p => p.theme.white};
  394. }
  395. `;
  396. const LoadingWrapper = styled('div')`
  397. display: flex;
  398. align-items: center;
  399. height: 24px;
  400. margin-top: ${space(0.25)};
  401. `;
  402. function getScrollParents(element: HTMLElement): Element[] {
  403. const scrollParents: Element[] = [];
  404. let currentElement = element.parentElement;
  405. while (currentElement) {
  406. const overflow = window.getComputedStyle(currentElement).overflow;
  407. if (overflow.includes('scroll') || overflow.includes('auto')) {
  408. scrollParents.push(currentElement);
  409. }
  410. currentElement = currentElement.parentElement;
  411. }
  412. return scrollParents;
  413. }
  414. export default AutofixHighlightPopup;