solutionsHubDrawer.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. import {Fragment, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import starImage from 'sentry-images/spot/banner-star.svg';
  4. import {SeerIcon} from 'sentry/components/ai/SeerIcon';
  5. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  6. import FeatureBadge from 'sentry/components/badge/featureBadge';
  7. import {Breadcrumbs as NavigationBreadcrumbs} from 'sentry/components/breadcrumbs';
  8. import {Button} from 'sentry/components/button';
  9. import ButtonBar from 'sentry/components/buttonBar';
  10. import AutofixFeedback from 'sentry/components/events/autofix/autofixFeedback';
  11. import {AutofixSetupContent} from 'sentry/components/events/autofix/autofixSetupModal';
  12. import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps';
  13. import {useAiAutofix} from 'sentry/components/events/autofix/useAutofix';
  14. import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components';
  15. import {GroupSummary} from 'sentry/components/group/groupSummary';
  16. import HookOrDefault from 'sentry/components/hookOrDefault';
  17. import Input from 'sentry/components/input';
  18. import LoadingIndicator from 'sentry/components/loadingIndicator';
  19. import {IconArrow, IconDocs} from 'sentry/icons';
  20. import {t, tct} from 'sentry/locale';
  21. import {space} from 'sentry/styles/space';
  22. import type {Event} from 'sentry/types/event';
  23. import type {Group} from 'sentry/types/group';
  24. import type {Project} from 'sentry/types/project';
  25. import {getShortEventId} from 'sentry/utils/events';
  26. import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
  27. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  28. import {MIN_NAV_HEIGHT} from 'sentry/views/issueDetails/streamline/eventTitle';
  29. import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig';
  30. import Resources from 'sentry/views/issueDetails/streamline/sidebar/resources';
  31. interface AutofixStartBoxProps {
  32. groupId: string;
  33. onSend: (message: string) => void;
  34. }
  35. function AutofixStartBox({onSend, groupId}: AutofixStartBoxProps) {
  36. const [message, setMessage] = useState('');
  37. const handleSubmit = (e: React.FormEvent) => {
  38. e.preventDefault();
  39. onSend(message);
  40. };
  41. return (
  42. <Wrapper>
  43. <ScaleContainer>
  44. <StyledArrow direction="down" size="sm" />
  45. <Container>
  46. <AutofixStartText>
  47. <BackgroundStar
  48. src={starImage}
  49. style={{
  50. width: '20px',
  51. height: '20px',
  52. right: '5%',
  53. top: '20%',
  54. transform: 'rotate(15deg)',
  55. }}
  56. />
  57. <BackgroundStar
  58. src={starImage}
  59. style={{
  60. width: '16px',
  61. height: '16px',
  62. right: '35%',
  63. top: '40%',
  64. transform: 'rotate(45deg)',
  65. }}
  66. />
  67. <BackgroundStar
  68. src={starImage}
  69. style={{
  70. width: '14px',
  71. height: '14px',
  72. right: '25%',
  73. top: '60%',
  74. transform: 'rotate(30deg)',
  75. }}
  76. />
  77. Need help digging deeper?
  78. </AutofixStartText>
  79. <InputWrapper onSubmit={handleSubmit}>
  80. <StyledInput
  81. type="text"
  82. value={message}
  83. onChange={e => setMessage(e.target.value)}
  84. placeholder="(Optional) Share helpful context here..."
  85. />
  86. <StyledButton
  87. type="submit"
  88. priority="primary"
  89. analyticsEventKey={
  90. message
  91. ? 'autofix.give_instructions_clicked'
  92. : 'autofix.start_fix_clicked'
  93. }
  94. analyticsEventName={
  95. message
  96. ? 'Autofix: Give Instructions Clicked'
  97. : 'Autofix: Start Fix Clicked'
  98. }
  99. analyticsParams={{group_id: groupId}}
  100. >
  101. {t('Start Autofix')}
  102. </StyledButton>
  103. </InputWrapper>
  104. </Container>
  105. </ScaleContainer>
  106. </Wrapper>
  107. );
  108. }
  109. interface SolutionsHubDrawerProps {
  110. event: Event;
  111. group: Group;
  112. project: Project;
  113. }
  114. const AiSetupDataConsent = HookOrDefault({
  115. hookName: 'component:ai-setup-data-consent',
  116. defaultComponent: () => <div data-test-id="ai-setup-data-consent" />,
  117. });
  118. export function SolutionsHubDrawer({group, project, event}: SolutionsHubDrawerProps) {
  119. const {autofixData, triggerAutofix, reset} = useAiAutofix(group, event);
  120. const aiConfig = useAiConfig(group, event, project);
  121. useRouteAnalyticsParams({
  122. autofix_status: autofixData?.status ?? 'none',
  123. });
  124. const config = getConfigForIssueType(group, project);
  125. const scrollContainerRef = useRef<HTMLDivElement>(null);
  126. const userScrolledRef = useRef(false);
  127. const lastScrollTopRef = useRef(0);
  128. const handleScroll = () => {
  129. const container = scrollContainerRef.current;
  130. if (!container) {
  131. return;
  132. }
  133. // Detect scroll direction
  134. const scrollingUp = container.scrollTop < lastScrollTopRef.current;
  135. lastScrollTopRef.current = container.scrollTop;
  136. // Check if we're at the bottom
  137. const isAtBottom =
  138. container.scrollHeight - container.scrollTop - container.clientHeight < 1;
  139. // Disable auto-scroll if scrolling up
  140. if (scrollingUp) {
  141. userScrolledRef.current = true;
  142. }
  143. // Re-enable auto-scroll if we reach the bottom
  144. if (isAtBottom) {
  145. userScrolledRef.current = false;
  146. }
  147. };
  148. useEffect(() => {
  149. // Only auto-scroll if user hasn't manually scrolled
  150. if (!userScrolledRef.current && scrollContainerRef.current) {
  151. scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
  152. }
  153. }, [autofixData]);
  154. return (
  155. <SolutionsDrawerContainer className="solutions-drawer-container">
  156. <SolutionsDrawerHeader>
  157. <NavigationCrumbs
  158. crumbs={[
  159. {
  160. label: (
  161. <CrumbContainer>
  162. <ProjectAvatar project={project} />
  163. <ShortId>{group.shortId}</ShortId>
  164. </CrumbContainer>
  165. ),
  166. },
  167. {label: getShortEventId(event.id)},
  168. {label: t('Solutions Hub')},
  169. ]}
  170. />
  171. </SolutionsDrawerHeader>
  172. <SolutionsDrawerNavigator>
  173. <Header>{t('Solutions Hub')}</Header>
  174. {autofixData && (
  175. <ButtonBarWrapper>
  176. <ButtonBar gap={1}>
  177. <AutofixFeedback />
  178. <Button
  179. size="xs"
  180. onClick={reset}
  181. title={
  182. autofixData.created_at
  183. ? `Last run at ${autofixData.created_at.split('T')[0]}`
  184. : null
  185. }
  186. >
  187. {t('Start Over')}
  188. </Button>
  189. </ButtonBar>
  190. </ButtonBarWrapper>
  191. )}
  192. </SolutionsDrawerNavigator>
  193. <SolutionsDrawerBody ref={scrollContainerRef} onScroll={handleScroll}>
  194. {config.resources && (
  195. <ResourcesContainer>
  196. <ResourcesHeader>
  197. <IconDocs size="md" />
  198. {t('Resources')}
  199. </ResourcesHeader>
  200. <ResourcesBody>
  201. <Resources
  202. eventPlatform={event?.platform}
  203. group={group}
  204. configResources={config.resources}
  205. />
  206. </ResourcesBody>
  207. </ResourcesContainer>
  208. )}
  209. <HeaderText>
  210. <HeaderContainer>
  211. <SeerIcon size="lg" />
  212. {t('Sentry AI')}
  213. <StyledFeatureBadge
  214. type="beta"
  215. title={tct(
  216. 'This feature is in beta. Try it out and let us know your feedback at [email:autofix@sentry.io].',
  217. {
  218. email: <a href="mailto:autofix@sentry.io" />,
  219. }
  220. )}
  221. />
  222. </HeaderContainer>
  223. </HeaderText>
  224. {aiConfig.isAutofixSetupLoading ? (
  225. <div data-test-id="ai-setup-loading-indicator">
  226. <LoadingIndicator />
  227. </div>
  228. ) : aiConfig.needsGenAIConsent ? (
  229. <AiSetupDataConsent groupId={group.id} />
  230. ) : (
  231. <Fragment>
  232. {aiConfig.hasSummary && (
  233. <StyledCard>
  234. <GroupSummary group={group} event={event} project={project} />
  235. </StyledCard>
  236. )}
  237. {aiConfig.hasAutofix && (
  238. <Fragment>
  239. {aiConfig.needsAutofixSetup ? (
  240. <AutofixSetupContent groupId={group.id} projectId={project.id} />
  241. ) : !autofixData ? (
  242. <AutofixStartBox onSend={triggerAutofix} groupId={group.id} />
  243. ) : (
  244. <AutofixSteps
  245. data={autofixData}
  246. groupId={group.id}
  247. runId={autofixData.run_id}
  248. onRetry={reset}
  249. />
  250. )}
  251. </Fragment>
  252. )}
  253. </Fragment>
  254. )}
  255. </SolutionsDrawerBody>
  256. </SolutionsDrawerContainer>
  257. );
  258. }
  259. const ResourcesContainer = styled('div')``;
  260. const ResourcesBody = styled('div')`
  261. padding: 0 ${space(2)} ${space(2)} ${space(2)};
  262. border-bottom: 1px solid ${p => p.theme.border};
  263. margin-bottom: ${space(2)};
  264. `;
  265. const Wrapper = styled('div')`
  266. display: flex;
  267. flex-direction: column;
  268. align-items: center;
  269. margin: ${space(1)} ${space(4)};
  270. gap: ${space(1)};
  271. `;
  272. const ScaleContainer = styled('div')`
  273. width: 100%;
  274. display: flex;
  275. flex-direction: column;
  276. align-items: center;
  277. gap: ${space(1)};
  278. `;
  279. const Container = styled('div')`
  280. position: relative;
  281. width: 100%;
  282. border-radius: ${p => p.theme.borderRadius};
  283. background: ${p => p.theme.background}
  284. linear-gradient(135deg, ${p => p.theme.pink400}08, ${p => p.theme.pink400}20);
  285. overflow: hidden;
  286. padding: ${space(0.5)};
  287. `;
  288. const AutofixStartText = styled('div')`
  289. margin: 0;
  290. padding: ${space(2)};
  291. padding-bottom: ${space(1)};
  292. white-space: pre-wrap;
  293. word-break: break-word;
  294. font-size: ${p => p.theme.fontSizeLarge};
  295. position: relative;
  296. `;
  297. const BackgroundStar = styled('img')`
  298. position: absolute;
  299. filter: sepia(1) saturate(3) hue-rotate(290deg);
  300. opacity: 0.7;
  301. pointer-events: none;
  302. z-index: 0;
  303. `;
  304. const StyledArrow = styled(IconArrow)`
  305. color: ${p => p.theme.subText};
  306. opacity: 0.5;
  307. `;
  308. const InputWrapper = styled('form')`
  309. display: flex;
  310. gap: ${space(0.5)};
  311. padding: ${space(0.25)} ${space(0.25)};
  312. `;
  313. const StyledInput = styled(Input)`
  314. flex-grow: 1;
  315. background: ${p => p.theme.background};
  316. border-color: ${p => p.theme.innerBorder};
  317. &:hover {
  318. border-color: ${p => p.theme.border};
  319. }
  320. `;
  321. const StyledButton = styled(Button)`
  322. flex-shrink: 0;
  323. `;
  324. const StyledCard = styled('div')`
  325. background: ${p => p.theme.backgroundElevated};
  326. overflow: hidden;
  327. border-bottom: 1px solid ${p => p.theme.border};
  328. padding: ${space(1)} ${space(0.5)} ${space(3)} ${space(0.5)};
  329. `;
  330. const HeaderText = styled('div')`
  331. font-weight: bold;
  332. font-size: ${p => p.theme.fontSizeLarge};
  333. display: flex;
  334. align-items: center;
  335. gap: ${space(0.5)};
  336. padding-bottom: ${space(2)};
  337. justify-content: space-between;
  338. `;
  339. const StyledFeatureBadge = styled(FeatureBadge)`
  340. margin-left: ${space(0.25)};
  341. padding-bottom: 3px;
  342. `;
  343. const ResourcesHeader = styled('div')`
  344. gap: ${space(1)};
  345. font-weight: bold;
  346. font-size: ${p => p.theme.fontSizeLarge};
  347. display: flex;
  348. align-items: center;
  349. padding-bottom: ${space(2)};
  350. `;
  351. const SolutionsDrawerContainer = styled('div')`
  352. height: 100%;
  353. display: grid;
  354. grid-template-rows: auto auto 1fr;
  355. position: relative;
  356. `;
  357. const SolutionsDrawerHeader = styled(DrawerHeader)`
  358. position: unset;
  359. max-height: ${MIN_NAV_HEIGHT}px;
  360. box-shadow: none;
  361. border-bottom: 1px solid ${p => p.theme.border};
  362. `;
  363. const SolutionsDrawerNavigator = styled('div')`
  364. display: flex;
  365. align-items: center;
  366. padding: ${space(0.75)} 24px;
  367. background: ${p => p.theme.background};
  368. z-index: 1;
  369. min-height: ${MIN_NAV_HEIGHT}px;
  370. box-shadow: ${p => p.theme.translucentBorder} 0 1px;
  371. `;
  372. const SolutionsDrawerBody = styled(DrawerBody)`
  373. overflow: auto;
  374. overscroll-behavior: contain;
  375. scroll-behavior: smooth;
  376. /* Move the scrollbar to the left edge */
  377. scroll-margin: 0 ${space(2)};
  378. direction: rtl;
  379. * {
  380. direction: ltr;
  381. }
  382. `;
  383. const Header = styled('h3')`
  384. display: block;
  385. font-size: ${p => p.theme.fontSizeExtraLarge};
  386. font-weight: ${p => p.theme.fontWeightBold};
  387. margin: 0;
  388. `;
  389. const NavigationCrumbs = styled(NavigationBreadcrumbs)`
  390. margin: 0;
  391. padding: 0;
  392. `;
  393. const CrumbContainer = styled('div')`
  394. display: flex;
  395. gap: ${space(1)};
  396. align-items: center;
  397. `;
  398. const ShortId = styled('div')`
  399. font-family: ${p => p.theme.text.family};
  400. font-size: ${p => p.theme.fontSizeMedium};
  401. line-height: 1;
  402. `;
  403. const HeaderContainer = styled('div')`
  404. display: flex;
  405. align-items: center;
  406. gap: ${space(0.5)};
  407. `;
  408. const ButtonBarWrapper = styled('div')`
  409. margin-left: auto;
  410. `;