123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832 |
- import {useCallback, useEffect, useState} from 'react';
- import {createPortal} from 'react-dom';
- import {usePopper} from 'react-popper';
- import styled from '@emotion/styled';
- import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
- import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
- import {Button} from 'sentry/components/button';
- import {
- replaceHeadersWithBold,
- SuggestedFixSnippet,
- } from 'sentry/components/events/autofix/autofixRootCause';
- import type {
- AutofixInsight,
- AutofixRepository,
- BreadcrumbContext,
- } from 'sentry/components/events/autofix/types';
- import {makeAutofixQueryKey} from 'sentry/components/events/autofix/useAutofix';
- import BreadcrumbItemContent from 'sentry/components/events/breadcrumbs/breadcrumbItemContent';
- import {
- BreadcrumbIcon,
- BreadcrumbLevel,
- getBreadcrumbColorConfig,
- getBreadcrumbTitle,
- } from 'sentry/components/events/breadcrumbs/utils';
- import Input from 'sentry/components/input';
- import StructuredEventData from 'sentry/components/structuredEventData';
- import Timeline from 'sentry/components/timeline';
- import {
- IconArrow,
- IconChevron,
- IconCode,
- IconFire,
- IconRefresh,
- IconSpan,
- IconUser,
- } from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
- import {singleLineRenderer} from 'sentry/utils/marked';
- import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
- import testableTransition from 'sentry/utils/testableTransition';
- import useApi from 'sentry/utils/useApi';
- interface AutofixBreadcrumbSnippetProps {
- breadcrumb: BreadcrumbContext;
- }
- function AutofixBreadcrumbSnippet({breadcrumb}: AutofixBreadcrumbSnippetProps) {
- const type = BreadcrumbType[breadcrumb.category.toUpperCase()];
- const level = BreadcrumbLevelType[breadcrumb.level.toUpperCase()];
- const rawCrumb = {
- message: breadcrumb.body,
- category: breadcrumb.category,
- type,
- level,
- };
- return (
- <BackgroundPanel>
- <BreadcrumbItem
- title={
- <Header>
- <div>
- <TextBreak>{getBreadcrumbTitle(rawCrumb)}</TextBreak>
- </div>
- <BreadcrumbLevel level={level}>{level}</BreadcrumbLevel>
- </Header>
- }
- colorConfig={getBreadcrumbColorConfig(type)}
- icon={<BreadcrumbIcon type={type} />}
- isActive
- showLastLine
- >
- <ContentWrapper>
- <BreadcrumbItemContent breadcrumb={rawCrumb} meta={{}} fullyExpanded />
- </ContentWrapper>
- </BreadcrumbItem>
- </BackgroundPanel>
- );
- }
- export function ExpandableInsightContext({
- children,
- title,
- icon,
- rounded,
- expandByDefault = false,
- }: {
- children: React.ReactNode;
- title: string;
- expandByDefault?: boolean;
- icon?: React.ReactNode;
- rounded?: boolean;
- }) {
- const [expanded, setExpanded] = useState(expandByDefault);
- const toggleExpand = () => {
- setExpanded(oldState => !oldState);
- };
- return (
- <ExpandableContext isRounded={rounded}>
- <ContextHeader
- onClick={toggleExpand}
- name={title}
- isRounded={rounded}
- isExpanded={expanded}
- size="sm"
- >
- <ContextHeaderWrapper>
- <ContextHeaderLeftAlign>
- {icon}
- <ContextHeaderText>{title}</ContextHeaderText>
- </ContextHeaderLeftAlign>
- <IconChevron size="xs" direction={expanded ? 'down' : 'right'} />
- </ContextHeaderWrapper>
- </ContextHeader>
- {expanded && <ContextBody>{children}</ContextBody>}
- </ExpandableContext>
- );
- }
- const animationProps: AnimationProps = {
- exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
- initial: {opacity: 0, height: 0, scale: 0.8},
- animate: {opacity: 1, height: 'auto', scale: 1},
- transition: testableTransition({
- duration: 1.0,
- height: {
- type: 'spring',
- bounce: 0.2,
- },
- scale: {
- type: 'spring',
- bounce: 0.2,
- },
- y: {
- type: 'tween',
- ease: 'easeOut',
- },
- }),
- };
- interface AutofixInsightCardProps {
- groupId: string;
- hasCardAbove: boolean;
- hasCardBelow: boolean;
- index: number;
- insight: AutofixInsight;
- repos: AutofixRepository[];
- runId: string;
- stepIndex: number;
- isLastInsightInStep?: boolean;
- shouldHighlightRethink?: boolean;
- }
- function AutofixInsightCard({
- insight,
- hasCardBelow,
- hasCardAbove,
- repos,
- index,
- stepIndex,
- groupId,
- runId,
- shouldHighlightRethink,
- isLastInsightInStep,
- }: AutofixInsightCardProps) {
- const isUserMessage = insight.justification === 'USER';
- const [expanded, setExpanded] = useState(false);
- const toggleExpand = () => {
- setExpanded(oldState => !oldState);
- };
- return (
- <ContentWrapper>
- <AnimatePresence initial>
- <AnimationWrapper key="content" {...animationProps}>
- {hasCardAbove && (
- <ChainLink
- insightCardAboveIndex={index - 1}
- stepIndex={stepIndex}
- groupId={groupId}
- runId={runId}
- isHighlighted={shouldHighlightRethink}
- />
- )}
- {!isUserMessage && (
- <InsightContainer>
- <InsightCardRow onClick={toggleExpand}>
- <MiniHeader
- dangerouslySetInnerHTML={{
- __html: singleLineRenderer(insight.insight),
- }}
- />
- <StyledIconChevron direction={expanded ? 'down' : 'right'} size="xs" />
- </InsightCardRow>
- {expanded && (
- <ContextBody>
- <p
- dangerouslySetInnerHTML={{
- __html: singleLineRenderer(
- replaceHeadersWithBold(insight.justification)
- ),
- }}
- />
- {insight.stacktrace_context &&
- insight.stacktrace_context.length > 0 && (
- <div>
- <ContextSectionTitle>
- <IconFire color="red400" />
- {t(
- 'Stacktrace%s and Variables:',
- insight.stacktrace_context.length > 1 ? 's' : ''
- )}
- </ContextSectionTitle>
- {insight.stacktrace_context
- .map((stacktrace, i) => {
- let vars: any = {};
- try {
- vars = JSON.parse(stacktrace.vars_as_json);
- } catch {
- vars = {vars: stacktrace.vars_as_json};
- }
- return (
- <div key={i}>
- <SuggestedFixSnippet
- snippet={{
- snippet: stacktrace.code_snippet,
- repo_name: stacktrace.repo_name,
- file_path: stacktrace.file_name,
- }}
- linesToHighlight={[]}
- repos={repos}
- />
- <StyledStructuredEventData
- data={vars}
- maxDefaultDepth={1}
- />
- </div>
- );
- })
- .reverse()}
- </div>
- )}
- {insight.breadcrumb_context &&
- insight.breadcrumb_context.length > 0 && (
- <div>
- <ContextSectionTitle>
- <IconSpan color="green400" />
- {t(
- 'Breadcrumb%s:',
- insight.breadcrumb_context.length > 1 ? 's' : ''
- )}
- </ContextSectionTitle>
- {insight.breadcrumb_context
- .map((breadcrumb, i) => {
- return (
- <AutofixBreadcrumbSnippet key={i} breadcrumb={breadcrumb} />
- );
- })
- .reverse()}
- </div>
- )}
- {insight.codebase_context && insight.codebase_context.length > 0 && (
- <div>
- <ContextSectionTitle>
- <IconCode color="purple400" />
- {t(
- 'Code Snippet%s:',
- insight.codebase_context.length > 1 ? 's' : ''
- )}
- </ContextSectionTitle>
- {insight.codebase_context
- .map((code, i) => {
- return (
- <SuggestedFixSnippet
- key={i}
- snippet={code}
- linesToHighlight={[]}
- repos={repos}
- />
- );
- })
- .reverse()}
- </div>
- )}
- </ContextBody>
- )}
- </InsightContainer>
- )}
- {isUserMessage && (
- <UserMessageContainer>
- <IconUser />
- <UserMessage
- dangerouslySetInnerHTML={{
- __html: singleLineRenderer(insight.insight),
- }}
- />
- </UserMessageContainer>
- )}
- {hasCardBelow && (
- <ChainLink
- insightCardAboveIndex={index}
- stepIndex={stepIndex}
- groupId={groupId}
- runId={runId}
- isHighlighted={shouldHighlightRethink}
- isLastCard={isLastInsightInStep}
- />
- )}
- </AnimationWrapper>
- </AnimatePresence>
- </ContentWrapper>
- );
- }
- interface AutofixInsightCardsProps {
- groupId: string;
- hasStepAbove: boolean;
- hasStepBelow: boolean;
- insights: AutofixInsight[];
- repos: AutofixRepository[];
- runId: string;
- stepIndex: number;
- shouldHighlightRethink?: boolean;
- }
- function AutofixInsightCards({
- insights,
- repos,
- hasStepBelow,
- hasStepAbove,
- stepIndex,
- groupId,
- runId,
- shouldHighlightRethink,
- }: AutofixInsightCardsProps) {
- return (
- <InsightsContainer>
- {insights.length > 0 ? (
- insights.map((insight, index) =>
- !insight ? null : (
- <AutofixInsightCard
- key={index}
- insight={insight}
- hasCardBelow={index < insights.length - 1 || hasStepBelow}
- hasCardAbove={hasStepAbove && index === 0}
- repos={repos}
- index={index}
- stepIndex={stepIndex}
- groupId={groupId}
- runId={runId}
- isLastInsightInStep={index === insights.length - 1}
- shouldHighlightRethink={shouldHighlightRethink}
- />
- )
- )
- ) : stepIndex === 0 && !hasStepBelow ? (
- <NoInsightsYet />
- ) : hasStepBelow ? (
- <EmptyResultsContainer>
- <ChainLink
- insightCardAboveIndex={null}
- stepIndex={stepIndex}
- groupId={groupId}
- runId={runId}
- isHighlighted={shouldHighlightRethink}
- isLastCard
- />
- </EmptyResultsContainer>
- ) : null}
- </InsightsContainer>
- );
- }
- export function useUpdateInsightCard({groupId, runId}: {groupId: string; runId: string}) {
- const api = useApi({persistInFlight: true});
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (params: {
- message: string;
- retain_insight_card_index: number | null;
- step_index: number;
- }) => {
- return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'restart_from_point_with_feedback',
- message: params.message,
- step_index: params.step_index,
- retain_insight_card_index: params.retain_insight_card_index,
- },
- },
- });
- },
- onSuccess: _ => {
- queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)});
- addSuccessMessage(t('Thanks, rethinking this...'));
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when sending Autofix your message.'));
- },
- });
- }
- function ChainLink({
- groupId,
- runId,
- stepIndex,
- insightCardAboveIndex,
- isHighlighted,
- isLastCard,
- }: {
- groupId: string;
- insightCardAboveIndex: number | null;
- runId: string;
- stepIndex: number;
- isHighlighted?: boolean;
- isLastCard?: boolean;
- }) {
- const [showOverlay, setShowOverlay] = useState(false);
- const [referenceElement, setReferenceElement] = useState<
- HTMLAnchorElement | HTMLButtonElement | null
- >(null);
- const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
- const [comment, setComment] = useState('');
- const {mutate: send} = useUpdateInsightCard({groupId, runId});
- const {styles, attributes} = usePopper(referenceElement, popperElement, {
- placement: 'left-start',
- modifiers: [
- {
- name: 'offset',
- options: {
- offset: [-16, 8],
- },
- },
- {
- name: 'flip',
- options: {
- fallbackPlacements: ['right-start', 'bottom-start'],
- },
- },
- ],
- });
- const handleClickOutside = useCallback(
- (event: MouseEvent) => {
- if (
- referenceElement?.contains(event.target as Node) ||
- popperElement?.contains(event.target as Node)
- ) {
- return;
- }
- setShowOverlay(false);
- },
- [popperElement, referenceElement]
- );
- useEffect(() => {
- if (showOverlay) {
- document.addEventListener('mousedown', handleClickOutside);
- } else {
- document.removeEventListener('mousedown', handleClickOutside);
- }
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [showOverlay, handleClickOutside]);
- return (
- <ArrowContainer>
- <IconArrow direction={'down'} className="arrow-icon" />
- <RethinkButtonContainer className="rethink-button-container">
- <AnimatePresence>
- {isLastCard && isHighlighted && (
- <RethinkMessage
- initial={{opacity: 0, x: 20}}
- animate={{opacity: 1, x: 0}}
- exit={{opacity: 0, x: 20}}
- transition={{duration: 0.4}}
- >
- Not satisfied?
- </RethinkMessage>
- )}
- </AnimatePresence>
- <RethinkButton
- ref={setReferenceElement}
- icon={<IconRefresh size="xs" />}
- size="zero"
- className="rethink-button"
- title={t('Rethink from here')}
- aria-label={t('Rethink from here')}
- onClick={() => setShowOverlay(true)}
- isHighlighted={isHighlighted}
- />
- </RethinkButtonContainer>
- {showOverlay &&
- createPortal(
- <RethinkInput
- ref={setPopperElement}
- style={styles.popper}
- {...attributes.popper}
- id="autofix-rethink-input"
- >
- <form
- onSubmit={e => {
- e.preventDefault();
- e.stopPropagation();
- setShowOverlay(false);
- setComment('');
- send({
- message: comment,
- step_index: stepIndex,
- retain_insight_card_index: insightCardAboveIndex,
- });
- }}
- className="row-form"
- onClick={e => e.stopPropagation()}
- id="autofix-rethink-input"
- >
- <Input
- type="text"
- placeholder="You should know X... Dive deeper into Y... Look at Z..."
- value={comment}
- onChange={e => setComment(e.target.value)}
- size="md"
- autoFocus
- id="autofix-rethink-input"
- />
- <Button
- type="submit"
- icon={<IconRefresh />}
- title="Restart analysis from this point in the chain"
- aria-label="Restart analysis from this point in the chain"
- priority="primary"
- size="md"
- id="autofix-rethink-input"
- />
- </form>
- </RethinkInput>,
- document.querySelector('.solutions-drawer-container') ?? document.body
- )}
- </ArrowContainer>
- );
- }
- const ContextSectionTitle = styled('p')`
- font-weight: bold;
- margin-bottom: 0;
- display: flex;
- align-items: center;
- gap: ${space(1)};
- `;
- const InsightCardRow = styled('div')`
- display: flex;
- justify-content: space-between;
- align-items: center;
- cursor: pointer;
- &:hover {
- background-color: ${p => p.theme.backgroundSecondary};
- }
- `;
- const UserMessageContainer = styled('div')`
- color: ${p => p.theme.subText};
- display: flex;
- padding: ${space(2)};
- align-items: center;
- border: 1px solid ${p => p.theme.innerBorder};
- border-radius: ${p => p.theme.borderRadius};
- overflow: hidden;
- box-shadow: ${p => p.theme.dropShadowMedium};
- margin-left: ${space(4)};
- margin-right: ${space(4)};
- `;
- const UserMessage = styled('div')`
- margin-left: ${space(2)};
- flex-shrink: 100;
- word-break: break-word;
- `;
- const NoInsightsYet = styled('div')`
- display: flex;
- justify-content: center;
- flex-direction: column;
- color: ${p => p.theme.subText};
- `;
- const EmptyResultsContainer = styled('div')`
- position: relative;
- bottom: -${space(1)};
- `;
- const InsightsContainer = styled('div')``;
- const InsightContainer = styled(motion.div)`
- border: 1px solid ${p => p.theme.innerBorder};
- border-radius: ${p => p.theme.borderRadius};
- overflow: hidden;
- box-shadow: ${p => p.theme.dropShadowMedium};
- margin-left: ${space(2)};
- margin-right: ${space(2)};
- animation: fadeFromActive 1.2s ease-out;
- @keyframes fadeFromActive {
- from {
- background-color: ${p => p.theme.active};
- border-color: ${p => p.theme.active};
- }
- to {
- background-color: ${p => p.theme.background};
- border-color: ${p => p.theme.innerBorder};
- }
- }
- `;
- const ArrowContainer = styled('div')`
- display: grid;
- grid-template-columns: 1fr auto 1fr;
- color: ${p => p.theme.subText};
- align-items: center;
- position: relative;
- z-index: 0;
- padding-top: ${space(1)};
- padding-bottom: ${space(1)};
- .arrow-icon {
- grid-column: 2 / 3;
- justify-self: center;
- align-self: center;
- }
- .rethink-button-container {
- grid-column: 3 / 4;
- justify-self: end;
- align-self: center;
- position: relative;
- }
- `;
- const RethinkButtonContainer = styled('div')`
- position: relative;
- `;
- const RethinkMessage = styled(motion.div)`
- color: ${p => p.theme.active};
- font-size: ${p => p.theme.fontSizeSmall};
- position: absolute;
- right: calc(100% + ${space(1)});
- margin-top: 1px;
- white-space: nowrap;
- `;
- const RethinkButton = styled(Button)<{isHighlighted?: boolean}>`
- font-weight: normal;
- font-size: small;
- border: none;
- color: ${p => p.theme.subText};
- transition: all 0.4s ease-in-out;
- position: relative;
- ${p =>
- p.isHighlighted &&
- `
- color: ${p.theme.button.primary.backgroundActive};
- background: ${p.theme.purple100};
- border-radius: ${p.theme.borderRadius};
- &:hover {
- color: ${p.theme.activeHover};
- background: ${p.theme.purple200};
- }
- `}
- &:hover {
- transform: scale(1.05);
- }
- &:active {
- transform: scale(0.95);
- }
- `;
- const RethinkInput = styled('div')`
- position: fixed;
- box-shadow: ${p => p.theme.dropShadowHeavy};
- border: 1px solid ${p => p.theme.border};
- width: 90%;
- background: ${p => p.theme.backgroundElevated};
- padding: ${space(0.5)};
- border-radius: ${p => p.theme.borderRadius};
- z-index: ${p => p.theme.zIndex.tooltip};
- .row-form {
- display: flex;
- gap: ${space(1)};
- }
- `;
- const BreadcrumbItem = styled(Timeline.Item)`
- border-bottom: 1px solid transparent;
- &:not(:last-child) {
- border-image: linear-gradient(
- to right,
- transparent 20px,
- ${p => p.theme.translucentInnerBorder} 20px
- )
- 100% 1;
- }
- `;
- const ContentWrapper = styled('div')``;
- const Header = styled('div')`
- display: grid;
- grid-template-columns: 1fr auto;
- `;
- const TextBreak = styled('span')`
- word-wrap: break-word;
- word-break: break-all;
- `;
- const BackgroundPanel = styled('div')`
- padding: ${space(1)};
- margin-top: ${space(2)};
- margin-bottom: ${space(2)};
- background: ${p => p.theme.backgroundSecondary};
- border-radius: ${p => p.theme.borderRadius};
- `;
- const MiniHeader = styled('p')`
- padding-top: ${space(2)};
- padding-right: ${space(2)};
- padding-left: ${space(2)};
- width: 95%;
- word-break: break-word;
- `;
- const ExpandableContext = styled('div')<{isRounded?: boolean}>`
- width: 100%;
- border-radius: ${p => (p.isRounded ? p.theme.borderRadius : 0)};
- `;
- const ContextHeader = styled(Button)<{isExpanded?: boolean; isRounded?: boolean}>`
- width: 100%;
- box-shadow: none;
- margin: 0;
- border: none;
- font-weight: normal;
- background: ${p => p.theme.backgroundSecondary};
- border-radius: ${p => {
- if (!p.isRounded) {
- return 0;
- }
- if (p.isExpanded) {
- return `${p.theme.borderRadius} ${p.theme.borderRadius} 0 0`;
- }
- return p.theme.borderRadius;
- }};
- `;
- const ContextHeaderLeftAlign = styled('div')`
- display: flex;
- gap: ${space(1)};
- align-items: center;
- `;
- const ContextHeaderWrapper = styled('div')`
- display: flex;
- justify-content: space-between;
- align-items: center;
- width: 100%;
- `;
- const ContextHeaderText = styled('p')`
- height: 0;
- `;
- const ContextBody = styled('div')`
- padding: ${space(2)};
- background: ${p => p.theme.alert.info.backgroundLight};
- border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius};
- overflow: hidden;
- `;
- const StyledStructuredEventData = styled(StructuredEventData)`
- border-top: solid 1px ${p => p.theme.border};
- border-top-left-radius: 0;
- border-top-right-radius: 0;
- `;
- const AnimationWrapper = styled(motion.div)`
- transform-origin: top center;
- &.new-insight {
- animation: textFadeFromActive 1.2s ease-out;
- }
- @keyframes textFadeFromActive {
- from {
- color: ${p => p.theme.white};
- }
- to {
- color: inherit;
- }
- }
- `;
- const StyledIconChevron = styled(IconChevron)`
- width: 5%;
- flex-shrink: 0;
- display: flex;
- justify-content: center;
- align-items: center;
- color: ${p => p.theme.subText};
- `;
- export default AutofixInsightCards;
|