Skip to content

Commit 0908e1f

Browse files
authored
feat(autofix): Add support for streamed output (#82024)
1. Adds a placeholder card that renders streamed text output 2. Updates animations to fit better 3. Reduces polling interval to 500 ms. <img width="690" alt="Screenshot 2024-12-12 at 9 31 36 AM" src="https://github.com/user-attachments/assets/286761b0-6f42-42c2-8090-ace2aa1a048e" />
1 parent 2303409 commit 0908e1f

8 files changed

+232
-36
lines changed

static/app/components/events/autofix/autofixChanges.tsx

+21-5
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,24 @@ function AutofixRepoChange({
4949
}
5050

5151
const cardAnimationProps: AnimationProps = {
52-
exit: {opacity: 0},
53-
initial: {opacity: 0, y: 20},
54-
animate: {opacity: 1, y: 0},
55-
transition: testableTransition({duration: 0.3}),
52+
exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
53+
initial: {opacity: 0, height: 0, scale: 0.8},
54+
animate: {opacity: 1, height: 'auto', scale: 1},
55+
transition: testableTransition({
56+
duration: 1.0,
57+
height: {
58+
type: 'spring',
59+
bounce: 0.2,
60+
},
61+
scale: {
62+
type: 'spring',
63+
bounce: 0.2,
64+
},
65+
y: {
66+
type: 'tween',
67+
ease: 'easeOut',
68+
},
69+
}),
5670
};
5771

5872
export function AutofixChanges({step, groupId, runId}: AutofixChangesProps) {
@@ -116,7 +130,9 @@ const PreviewContent = styled('div')`
116130
margin-top: ${space(2)};
117131
`;
118132

119-
const AnimationWrapper = styled(motion.div)``;
133+
const AnimationWrapper = styled(motion.div)`
134+
transform-origin: top center;
135+
`;
120136

121137
const PrefixText = styled('span')``;
122138

static/app/components/events/autofix/autofixInsightCards.spec.tsx

-7
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,6 @@ describe('AutofixInsightCards', () => {
122122
expect(userMessage.closest('div')).toHaveStyle('color: inherit');
123123
});
124124

125-
it('renders "No insights yet" message when there are no insights', () => {
126-
renderComponent({insights: []});
127-
expect(
128-
screen.getByText(/Autofix will share its discoveries here./)
129-
).toBeInTheDocument();
130-
});
131-
132125
it('toggles context expansion correctly', async () => {
133126
renderComponent();
134127
const contextButton = screen.getByText('Sample insight 1');

static/app/components/events/autofix/autofixInsightCards.tsx

+47-18
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,24 @@ export function ExpandableInsightContext({
123123
}
124124

125125
const animationProps: AnimationProps = {
126-
exit: {opacity: 0},
127-
initial: {opacity: 0, y: 20},
128-
animate: {opacity: 1, y: 0},
129-
transition: testableTransition({duration: 0.3}),
126+
exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
127+
initial: {opacity: 0, height: 0, scale: 0.8},
128+
animate: {opacity: 1, height: 'auto', scale: 1},
129+
transition: testableTransition({
130+
duration: 1.0,
131+
height: {
132+
type: 'spring',
133+
bounce: 0.2,
134+
},
135+
scale: {
136+
type: 'spring',
137+
bounce: 0.2,
138+
},
139+
y: {
140+
type: 'tween',
141+
ease: 'easeOut',
142+
},
143+
}),
130144
};
131145

132146
interface AutofixInsightCardProps {
@@ -348,15 +362,7 @@ function AutofixInsightCards({
348362
)
349363
)
350364
) : stepIndex === 0 && !hasStepBelow ? (
351-
<NoInsightsYet>
352-
<p>Autofix will share its discoveries here.</p>
353-
<p>
354-
Autofix is like an AI rubber ducky to help you debug your code.
355-
<br />
356-
Collaborate with it and share your own knowledge and opinions for the best
357-
results.
358-
</p>
359-
</NoInsightsYet>
365+
<NoInsightsYet />
360366
) : hasStepBelow ? (
361367
<EmptyResultsContainer>
362368
<ChainLink
@@ -590,11 +596,7 @@ const NoInsightsYet = styled('div')`
590596
display: flex;
591597
justify-content: center;
592598
flex-direction: column;
593-
padding-left: ${space(4)};
594-
padding-right: ${space(4)};
595-
text-align: center;
596599
color: ${p => p.theme.subText};
597-
padding-top: ${space(4)};
598600
`;
599601

600602
const EmptyResultsContainer = styled('div')`
@@ -611,6 +613,18 @@ const InsightContainer = styled(motion.div)`
611613
box-shadow: ${p => p.theme.dropShadowMedium};
612614
margin-left: ${space(2)};
613615
margin-right: ${space(2)};
616+
animation: fadeFromActive 1.2s ease-out;
617+
618+
@keyframes fadeFromActive {
619+
from {
620+
background-color: ${p => p.theme.active};
621+
border-color: ${p => p.theme.active};
622+
}
623+
to {
624+
background-color: ${p => p.theme.background};
625+
border-color: ${p => p.theme.innerBorder};
626+
}
627+
}
614628
`;
615629

616630
const ArrowContainer = styled('div')`
@@ -789,7 +803,22 @@ const StyledStructuredEventData = styled(StructuredEventData)`
789803
border-top-right-radius: 0;
790804
`;
791805

792-
const AnimationWrapper = styled(motion.div)``;
806+
const AnimationWrapper = styled(motion.div)`
807+
transform-origin: top center;
808+
809+
&.new-insight {
810+
animation: textFadeFromActive 1.2s ease-out;
811+
}
812+
813+
@keyframes textFadeFromActive {
814+
from {
815+
color: ${p => p.theme.white};
816+
}
817+
to {
818+
color: inherit;
819+
}
820+
}
821+
`;
793822

794823
const StyledIconChevron = styled(IconChevron)`
795824
width: 5%;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {useEffect, useRef, useState} from 'react';
2+
import {keyframes} from '@emotion/react';
3+
import styled from '@emotion/styled';
4+
import {AnimatePresence, motion} from 'framer-motion';
5+
6+
import {IconArrow} from 'sentry/icons';
7+
import {space} from 'sentry/styles/space';
8+
import testableTransition from 'sentry/utils/testableTransition';
9+
10+
interface Props {
11+
stream: string;
12+
}
13+
14+
const shimmer = keyframes`
15+
0% {
16+
background-position: -1000px 0;
17+
}
18+
100% {
19+
background-position: 1000px 0;
20+
}
21+
`;
22+
23+
export function AutofixOutputStream({stream}: Props) {
24+
const [displayedText, setDisplayedText] = useState('');
25+
const previousText = useRef('');
26+
const currentIndexRef = useRef(0);
27+
28+
useEffect(() => {
29+
const newText = stream;
30+
31+
// Reset animation if the new text is completely different
32+
if (!newText.startsWith(displayedText)) {
33+
previousText.current = newText;
34+
currentIndexRef.current = 0;
35+
setDisplayedText('');
36+
}
37+
38+
const interval = window.setInterval(() => {
39+
if (currentIndexRef.current < newText.length) {
40+
setDisplayedText(newText.slice(0, currentIndexRef.current + 1));
41+
currentIndexRef.current++;
42+
} else {
43+
window.clearInterval(interval);
44+
}
45+
}, 15);
46+
47+
return () => {
48+
window.clearInterval(interval);
49+
};
50+
}, [displayedText, stream]);
51+
52+
return (
53+
<AnimatePresence mode="wait">
54+
<Wrapper
55+
key="output-stream"
56+
initial={{opacity: 0, height: 0, scale: 0.8}}
57+
animate={{opacity: 1, height: 'auto', scale: 1}}
58+
exit={{opacity: 0, height: 0, scale: 0.8, y: -20}}
59+
transition={testableTransition({
60+
duration: 1.0,
61+
height: {
62+
type: 'spring',
63+
bounce: 0.2,
64+
},
65+
scale: {
66+
type: 'spring',
67+
bounce: 0.2,
68+
},
69+
y: {
70+
type: 'tween',
71+
ease: 'easeOut',
72+
},
73+
})}
74+
style={{
75+
transformOrigin: 'top center',
76+
}}
77+
>
78+
<StyledArrow direction="down" size="sm" />
79+
<StreamContainer layout>
80+
<StreamContent>{displayedText}</StreamContent>
81+
</StreamContainer>
82+
</Wrapper>
83+
</AnimatePresence>
84+
);
85+
}
86+
87+
const Wrapper = styled(motion.div)`
88+
display: flex;
89+
flex-direction: column;
90+
align-items: center;
91+
margin: ${space(1)} ${space(4)};
92+
gap: ${space(1)};
93+
overflow: hidden;
94+
`;
95+
96+
const StreamContainer = styled(motion.div)`
97+
position: relative;
98+
width: 100%;
99+
border-radius: ${p => p.theme.borderRadius};
100+
background: ${p => p.theme.background};
101+
border: 1px dashed ${p => p.theme.border};
102+
height: 5rem;
103+
overflow: hidden;
104+
105+
&:before {
106+
content: '';
107+
position: absolute;
108+
inset: 0;
109+
background: linear-gradient(
110+
90deg,
111+
transparent,
112+
${p => p.theme.active}20,
113+
transparent
114+
);
115+
background-size: 2000px 100%;
116+
animation: ${shimmer} 2s infinite linear;
117+
pointer-events: none;
118+
}
119+
`;
120+
121+
const StreamContent = styled('div')`
122+
margin: 0;
123+
padding: ${space(2)};
124+
white-space: pre-wrap;
125+
word-break: break-word;
126+
font-size: ${p => p.theme.fontSizeSmall};
127+
color: ${p => p.theme.subText};
128+
height: 5rem;
129+
overflow-y: auto;
130+
display: flex;
131+
flex-direction: column-reverse;
132+
`;
133+
134+
const StyledArrow = styled(IconArrow)`
135+
color: ${p => p.theme.subText};
136+
opacity: 0.5;
137+
`;

static/app/components/events/autofix/autofixRootCause.tsx

+21-5
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,24 @@ function AutofixRootCauseDisplay({
468468
}
469469

470470
const cardAnimationProps: AnimationProps = {
471-
exit: {opacity: 0},
472-
initial: {opacity: 0, y: 20},
473-
animate: {opacity: 1, y: 0},
474-
transition: testableTransition({duration: 0.3}),
471+
exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
472+
initial: {opacity: 0, height: 0, scale: 0.8},
473+
animate: {opacity: 1, height: 'auto', scale: 1},
474+
transition: testableTransition({
475+
duration: 1.0,
476+
height: {
477+
type: 'spring',
478+
bounce: 0.2,
479+
},
480+
scale: {
481+
type: 'spring',
482+
bounce: 0.2,
483+
},
484+
y: {
485+
type: 'tween',
486+
ease: 'easeOut',
487+
},
488+
}),
475489
};
476490

477491
export function AutofixRootCause(props: AutofixRootCauseProps) {
@@ -627,7 +641,9 @@ const ContentWrapper = styled(motion.div)<{selected: boolean}>`
627641
}
628642
`;
629643

630-
const AnimationWrapper = styled(motion.div)``;
644+
const AnimationWrapper = styled(motion.div)`
645+
transform-origin: top center;
646+
`;
631647

632648
const CustomRootCausePadding = styled('div')`
633649
padding: ${space(2)} ${space(2)} ${space(2)} ${space(2)};

static/app/components/events/autofix/autofixSteps.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import AutofixInsightCards, {
77
useUpdateInsightCard,
88
} from 'sentry/components/events/autofix/autofixInsightCards';
99
import AutofixMessageBox from 'sentry/components/events/autofix/autofixMessageBox';
10+
import {AutofixOutputStream} from 'sentry/components/events/autofix/autofixOutputStream';
1011
import {
1112
AutofixRootCause,
1213
useSelectCause,
@@ -280,6 +281,9 @@ export function AutofixSteps({data, groupId, runId}: AutofixStepsProps) {
280281
</div>
281282
);
282283
})}
284+
{lastStep.output_stream && (
285+
<AutofixOutputStream stream={lastStep.output_stream} />
286+
)}
283287
</StepsContainer>
284288

285289
<AutofixMessageBox

static/app/components/events/autofix/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ interface BaseStep {
8787
title: string;
8888
type: AutofixStepType;
8989
completedMessage?: string;
90+
output_stream?: string | null;
9091
}
9192

9293
export type CodeSnippetContext = {

static/app/components/events/autofix/useAutofix.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type AutofixResponse = {
1919
autofix: AutofixData | null;
2020
};
2121

22-
const POLL_INTERVAL = 1000;
22+
const POLL_INTERVAL = 500;
2323

2424
export const makeAutofixQueryKey = (groupId: string): ApiQueryKey => [
2525
`/issues/${groupId}/autofix/`,

0 commit comments

Comments
 (0)