Browse Source

feat(autofix): Initial interactive UI with insight card flow (#77710)

Redesign Autofix to support a new "insight card" flow, plus an
"interjection" interaction. (Other interactions will be added in later
PRs)

<img width="1120" alt="Screenshot 2024-09-16 at 1 14 11 PM"
src="https://github.com/user-attachments/assets/28f074db-de11-40b3-90e9-269dfaf55430">
Rohan Agarwal 5 months ago
parent
commit
b2e0b6e5ba

+ 69 - 56
static/app/components/events/autofix/autofixBanner.spec.tsx

@@ -1,83 +1,96 @@
-import {
-  render,
-  renderGlobalModal,
-  screen,
-  userEvent,
-} from 'sentry-test/reactTestingLibrary';
+import {EventFixture} from 'sentry-fixture/event';
+import {GroupFixture} from 'sentry-fixture/group';
+import {ProjectFixture} from 'sentry-fixture/project';
 
-import {AutofixBanner} from './autofixBanner';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
-function mockIsSentryEmployee(isEmployee: boolean) {
-  jest
-    .spyOn(require('sentry/utils/useIsSentryEmployee'), 'useIsSentryEmployee')
-    .mockImplementation(() => isEmployee);
-}
+import {openModal} from 'sentry/actionCreators/modal';
+import {AutofixBanner} from 'sentry/components/events/autofix/autofixBanner';
+import {useIsSentryEmployee} from 'sentry/utils/useIsSentryEmployee';
+
+jest.mock('sentry/utils/useIsSentryEmployee');
+jest.mock('sentry/actionCreators/modal');
+
+const mockGroup = GroupFixture();
+const mockProject = ProjectFixture();
+const mockEvent = EventFixture();
 
 describe('AutofixBanner', () => {
-  afterEach(() => {
-    jest.resetAllMocks();
+  beforeEach(() => {
+    jest.clearAllMocks();
+    (useIsSentryEmployee as jest.Mock).mockReturnValue(false);
   });
 
-  const defaultProps = {
-    groupId: '1',
-    hasSuccessfulSetup: true,
-    triggerAutofix: jest.fn(),
-  };
-
-  it('shows PII check for sentry employee users', () => {
-    mockIsSentryEmployee(true);
-
-    render(<AutofixBanner {...defaultProps} projectId="1" />);
-    expect(
-      screen.getByText(
-        'By clicking the button above, you confirm that there is no PII in this event.'
-      )
-    ).toBeInTheDocument();
+  it('renders the banner with "Set up Autofix" button when setup is not done', () => {
+    render(
+      <AutofixBanner
+        group={mockGroup}
+        project={mockProject}
+        event={mockEvent}
+        hasSuccessfulSetup={false}
+      />
+    );
+
+    expect(screen.getByText('Try Autofix')).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: /Set up Autofix/i})).toBeInTheDocument();
   });
 
-  it('does not show PII check for non sentry employee users', () => {
-    mockIsSentryEmployee(false);
+  it('renders the banner with "Run Autofix" button when setup is done', () => {
+    render(
+      <AutofixBanner
+        group={mockGroup}
+        project={mockProject}
+        event={mockEvent}
+        hasSuccessfulSetup
+      />
+    );
 
-    render(<AutofixBanner {...defaultProps} projectId="1" />);
-    expect(
-      screen.queryByText(
-        'By clicking the button above, you confirm that there is no PII in this event.'
-      )
-    ).not.toBeInTheDocument();
+    expect(screen.getByText(/Try Autofix/i)).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: /Run Autofix/i})).toBeInTheDocument();
   });
 
-  it('can run without instructions', async () => {
-    const mockTriggerAutofix = jest.fn();
+  it('opens the AutofixSetupModal when "Set up Autofix" is clicked', async () => {
+    const mockOpenModal = jest.fn();
+    (openModal as jest.Mock).mockImplementation(mockOpenModal);
 
     render(
       <AutofixBanner
-        {...defaultProps}
-        triggerAutofix={mockTriggerAutofix}
-        projectId="1"
+        group={mockGroup}
+        project={mockProject}
+        event={mockEvent}
+        hasSuccessfulSetup={false}
       />
     );
-    renderGlobalModal();
 
-    await userEvent.click(screen.getByRole('button', {name: 'Get root causes'}));
-    expect(mockTriggerAutofix).toHaveBeenCalledWith('');
+    await userEvent.click(screen.getByRole('button', {name: /Set up Autofix/i}));
+    expect(openModal).toHaveBeenCalled();
   });
 
-  it('can provide instructions', async () => {
-    const mockTriggerAutofix = jest.fn();
-
+  it('does not render PII message for non-Sentry employees', () => {
     render(
       <AutofixBanner
-        {...defaultProps}
-        triggerAutofix={mockTriggerAutofix}
-        projectId="1"
+        group={mockGroup}
+        project={mockProject}
+        event={mockEvent}
+        hasSuccessfulSetup
       />
     );
-    renderGlobalModal();
 
-    await userEvent.click(screen.getByRole('button', {name: 'Provide context first'}));
-    await userEvent.type(screen.getByRole('textbox'), 'instruction!');
-    await userEvent.click(screen.getByRole('button', {name: "Let's go!"}));
+    expect(screen.queryByText(/By clicking the button above/i)).not.toBeInTheDocument();
+  });
+
+  it('renders PII message for Sentry employees when setup is successful', () => {
+    (useIsSentryEmployee as jest.Mock).mockReturnValue(true);
+
+    render(
+      <AutofixBanner
+        group={mockGroup}
+        project={mockProject}
+        event={mockEvent}
+        hasSuccessfulSetup
+      />
+    );
 
-    expect(mockTriggerAutofix).toHaveBeenCalledWith('instruction!');
+    expect(screen.getByText(/By clicking the button above/i)).toBeInTheDocument();
   });
 });

+ 45 - 54
static/app/components/events/autofix/autofixBanner.tsx

@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {useRef} from 'react';
 import styled from '@emotion/styled';
 
 import bannerImage from 'sentry-images/spot/ai-suggestion-banner.svg';
@@ -6,77 +6,73 @@ import bannerImage from 'sentry-images/spot/ai-suggestion-banner.svg';
 import {openModal} from 'sentry/actionCreators/modal';
 import FeatureBadge from 'sentry/components/badge/featureBadge';
 import {Button} from 'sentry/components/button';
-import {AutofixInstructionsModal} from 'sentry/components/events/autofix/autofixInstructionsModal';
+import {AutofixDrawer} from 'sentry/components/events/autofix/autofixDrawer';
 import {AutofixSetupModal} from 'sentry/components/events/autofix/autofixSetupModal';
+import useDrawer from 'sentry/components/globalDrawer';
 import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import type {Event} from 'sentry/types/event';
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
 import {useIsSentryEmployee} from 'sentry/utils/useIsSentryEmployee';
 
 type Props = {
-  groupId: string;
+  event: Event;
+  group: Group;
   hasSuccessfulSetup: boolean;
-  projectId: string;
-  triggerAutofix: (value: string) => void;
+  project: Project;
 };
 
 function SuccessfulSetup({
-  groupId,
-  triggerAutofix,
-}: Pick<Props, 'groupId' | 'triggerAutofix'>) {
-  const onClickGiveInstructions = () => {
-    openModal(deps => (
-      <AutofixInstructionsModal
-        {...deps}
-        triggerAutofix={triggerAutofix}
-        groupId={groupId}
-      />
-    ));
+  group,
+  project,
+  event,
+}: Pick<Props, 'group' | 'project' | 'event'>) {
+  const {openDrawer, isDrawerOpen, closeDrawer} = useDrawer();
+  const openButtonRef = useRef<HTMLButtonElement>(null);
+
+  const openAutofix = () => {
+    if (!isDrawerOpen) {
+      openDrawer(() => <AutofixDrawer group={group} project={project} event={event} />, {
+        ariaLabel: t('Autofix drawer'),
+        // We prevent a click on the Open/Close Autofix button from closing the drawer so that
+        // we don't reopen it immediately, and instead let the button handle this itself.
+        shouldCloseOnInteractOutside: element => {
+          const viewAllButton = openButtonRef.current;
+          if (viewAllButton?.contains(element)) {
+            return false;
+          }
+          return true;
+        },
+        transitionProps: {stiffness: 1000},
+      });
+    } else {
+      closeDrawer();
+    }
   };
 
   return (
-    <Fragment>
-      <Button
-        onClick={() => triggerAutofix('')}
-        size="sm"
-        analyticsEventKey="autofix.start_fix_clicked"
-        analyticsEventName="Autofix: Start Fix Clicked"
-        analyticsParams={{group_id: groupId}}
-      >
-        {t('Get root causes')}
-      </Button>
-      <Button
-        onClick={onClickGiveInstructions}
-        size="sm"
-        analyticsEventKey="autofix.give_instructions_clicked"
-        analyticsEventName="Autofix: Give Instructions Clicked"
-        analyticsParams={{group_id: groupId}}
-      >
-        {t('Provide context first')}
-      </Button>
-    </Fragment>
+    <Button onClick={() => openAutofix()} size="sm" ref={openButtonRef}>
+      {t('Run Autofix')}
+    </Button>
   );
 }
 
-function AutofixBannerContent({
-  groupId,
-  triggerAutofix,
-  hasSuccessfulSetup,
-  projectId,
-}: Props) {
+function AutofixBannerContent({group, hasSuccessfulSetup, project, event}: Props) {
   if (hasSuccessfulSetup) {
-    return <SuccessfulSetup groupId={groupId} triggerAutofix={triggerAutofix} />;
+    return <SuccessfulSetup group={group} project={project} event={event} />;
   }
 
   return (
     <Button
       analyticsEventKey="autofix.setup_clicked"
       analyticsEventName="Autofix: Setup Clicked"
-      analyticsParams={{group_id: groupId}}
+      analyticsParams={{group_id: group.id}}
       onClick={() => {
         openModal(deps => (
-          <AutofixSetupModal {...deps} groupId={groupId} projectId={projectId} />
+          <AutofixSetupModal {...deps} groupId={group.id} projectId={project.id} />
         ));
       }}
       size="sm"
@@ -86,12 +82,7 @@ function AutofixBannerContent({
   );
 }
 
-export function AutofixBanner({
-  groupId,
-  projectId,
-  triggerAutofix,
-  hasSuccessfulSetup,
-}: Props) {
+export function AutofixBanner({group, project, event, hasSuccessfulSetup}: Props) {
   const isSentryEmployee = useIsSentryEmployee();
 
   return (
@@ -117,9 +108,9 @@ export function AutofixBanner({
         </div>
         <ButtonGroup>
           <AutofixBannerContent
-            groupId={groupId}
-            projectId={projectId}
-            triggerAutofix={triggerAutofix}
+            group={group}
+            project={project}
+            event={event}
             hasSuccessfulSetup={hasSuccessfulSetup}
           />
         </ButtonGroup>

+ 0 - 77
static/app/components/events/autofix/autofixCard.tsx

@@ -1,77 +0,0 @@
-import {useRef} from 'react';
-import styled from '@emotion/styled';
-
-import {Button} from 'sentry/components/button';
-import ButtonBar from 'sentry/components/buttonBar';
-import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps';
-import type {AutofixData} from 'sentry/components/events/autofix/types';
-import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
-import Panel from 'sentry/components/panels/panel';
-import {IconMegaphone} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-
-type AutofixCardProps = {
-  data: AutofixData;
-  groupId: string;
-  onRetry: () => void;
-};
-
-function AutofixFeedback() {
-  const buttonRef = useRef<HTMLButtonElement>(null);
-  const feedback = useFeedbackWidget({
-    buttonRef,
-    messagePlaceholder: t('How can we make Autofix better for you?'),
-    optionOverrides: {
-      tags: {
-        ['feedback.source']: 'issue_details_ai_autofix',
-        ['feedback.owner']: 'ml-ai',
-      },
-    },
-  });
-
-  if (!feedback) {
-    return null;
-  }
-
-  return (
-    <Button ref={buttonRef} size="xs" icon={<IconMegaphone />}>
-      {t('Give Feedback')}
-    </Button>
-  );
-}
-
-export function AutofixCard({data, onRetry, groupId}: AutofixCardProps) {
-  return (
-    <AutofixPanel>
-      <AutofixHeader>
-        <Title>{t('Autofix')}</Title>
-        <ButtonBar gap={1}>
-          <AutofixFeedback />
-          <Button size="xs" onClick={onRetry}>
-            {t('Start Over')}
-          </Button>
-        </ButtonBar>
-      </AutofixHeader>
-      <AutofixSteps data={data} runId={data.run_id} groupId={groupId} onRetry={onRetry} />
-    </AutofixPanel>
-  );
-}
-
-const Title = styled('div')`
-  font-size: ${p => p.theme.fontSizeExtraLarge};
-  font-weight: ${p => p.theme.fontWeightBold};
-`;
-
-const AutofixPanel = styled(Panel)`
-  margin-bottom: 0;
-  overflow: hidden;
-  background: ${p => p.theme.backgroundSecondary};
-  padding: ${space(2)} ${space(3)} ${space(3)} ${space(3)};
-`;
-
-const AutofixHeader = styled('div')`
-  display: grid;
-  grid-template-columns: 1fr auto;
-  margin-bottom: ${space(2)};
-`;

+ 1 - 37
static/app/components/events/autofix/autofixChanges.spec.tsx

@@ -50,7 +50,7 @@ describe('AutofixChanges', function () {
     );
   });
 
-  it('displays create PR button when it is last step', function () {
+  it('displays create PR button', function () {
     MockApiClient.addMockResponse({
       url: '/issues/1/autofix/setup/',
       body: {
@@ -76,7 +76,6 @@ describe('AutofixChanges', function () {
             ],
           }) as AutofixChangesStep
         }
-        isLastStep
       />
     );
 
@@ -85,40 +84,6 @@ describe('AutofixChanges', function () {
     ).toBeInTheDocument();
   });
 
-  it('does not display create PR button when it is not the last step', function () {
-    MockApiClient.addMockResponse({
-      url: '/issues/1/autofix/setup/',
-      body: {
-        genAIConsent: {ok: true},
-        codebaseIndexing: {ok: true},
-        integration: {ok: true},
-        githubWriteIntegration: {
-          repos: [{ok: true, owner: 'owner', name: 'hello-world', id: 100}],
-        },
-      },
-    });
-
-    render(
-      <AutofixChanges
-        {...defaultProps}
-        step={
-          AutofixStepFixture({
-            type: AutofixStepType.CHANGES,
-            changes: [
-              AutofixCodebaseChangeData({
-                pull_request: undefined,
-              }),
-            ],
-          }) as AutofixChangesStep
-        }
-      />
-    );
-
-    expect(
-      screen.queryByRole('button', {name: 'Create a Pull Request'})
-    ).not.toBeInTheDocument();
-  });
-
   it('displays setup button when permissions do not exist for repo', async function () {
     MockApiClient.addMockResponse({
       url: '/issues/1/autofix/setup/',
@@ -147,7 +112,6 @@ describe('AutofixChanges', function () {
             ],
           }) as AutofixChangesStep
         }
-        isLastStep
       />
     );
     renderGlobalModal();

+ 27 - 28
static/app/components/events/autofix/autofixChanges.tsx

@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
 import {openModal} from 'sentry/actionCreators/modal';
 import {Button, LinkButton} from 'sentry/components/button';
+import ClippedBox from 'sentry/components/clippedBox';
 import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
 import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
 import type {
@@ -27,7 +28,6 @@ type AutofixChangesProps = {
   groupId: string;
   onRetry: () => void;
   step: AutofixChangesStep;
-  isLastStep?: boolean;
 };
 
 function CreatePullRequestButton({
@@ -109,11 +109,9 @@ function CreatePullRequestButton({
 function PullRequestLinkOrCreateButton({
   change,
   groupId,
-  isLastStep,
 }: {
   change: AutofixCodebaseChange;
   groupId: string;
-  isLastStep?: boolean;
 }) {
   const {data} = useAutofixSetup({groupId});
 
@@ -133,10 +131,6 @@ function PullRequestLinkOrCreateButton({
     );
   }
 
-  if (!isLastStep) {
-    return null;
-  }
-
   if (
     !data?.githubWriteIntegration?.repos?.find(
       repo => `${repo.owner}/${repo.name}` === change.repo_name
@@ -172,11 +166,9 @@ function PullRequestLinkOrCreateButton({
 function AutofixRepoChange({
   change,
   groupId,
-  isLastStep,
 }: {
   change: AutofixCodebaseChange;
   groupId: string;
-  isLastStep?: boolean;
 }) {
   return (
     <Content>
@@ -185,23 +177,14 @@ function AutofixRepoChange({
           <Title>{change.repo_name}</Title>
           <PullRequestTitle>{change.title}</PullRequestTitle>
         </div>
-        <PullRequestLinkOrCreateButton
-          change={change}
-          groupId={groupId}
-          isLastStep={isLastStep}
-        />
+        <PullRequestLinkOrCreateButton change={change} groupId={groupId} />
       </RepoChangesHeader>
       <AutofixDiff diff={change.diff} />
     </Content>
   );
 }
 
-export function AutofixChanges({
-  step,
-  onRetry,
-  groupId,
-  isLastStep,
-}: AutofixChangesProps) {
+export function AutofixChanges({step, onRetry, groupId}: AutofixChangesProps) {
   const data = useAutofixData({groupId});
 
   if (step.status === 'ERROR' || data?.status === 'ERROR') {
@@ -242,14 +225,17 @@ export function AutofixChanges({
   }
 
   return (
-    <Content>
-      {step.changes.map((change, i) => (
-        <Fragment key={change.repo_external_id}>
-          {i > 0 && <Separator />}
-          <AutofixRepoChange change={change} groupId={groupId} isLastStep={isLastStep} />
-        </Fragment>
-      ))}
-    </Content>
+    <ChangesContainer>
+      <ClippedBox clipHeight={408}>
+        <HeaderText>{t('Fixes')}</HeaderText>
+        {step.changes.map((change, i) => (
+          <Fragment key={change.repo_external_id}>
+            {i > 0 && <Separator />}
+            <AutofixRepoChange change={change} groupId={groupId} />
+          </Fragment>
+        ))}
+      </ClippedBox>
+    </ChangesContainer>
   );
 }
 
@@ -262,6 +248,14 @@ const PreviewContent = styled('div')`
 
 const PrefixText = styled('span')``;
 
+const ChangesContainer = styled('div')`
+  border: 1px solid ${p => p.theme.innerBorder};
+  border-radius: ${p => p.theme.borderRadius};
+  overflow: hidden;
+  box-shadow: ${p => p.theme.dropShadowHeavy};
+  padding: ${space(2)};
+`;
+
 const Content = styled('div')`
   padding: 0 ${space(1)} ${space(1)} ${space(1)};
 `;
@@ -295,6 +289,11 @@ const Separator = styled('hr')`
   margin: ${space(2)} -${space(2)} 0 -${space(2)};
 `;
 
+const HeaderText = styled('div')`
+  font-weight: bold;
+  font-size: 1.2em;
+`;
+
 const ProcessingStatusIndicator = styled(LoadingIndicator)`
   && {
     margin: 0;

+ 95 - 0
static/app/components/events/autofix/autofixDrawer.spec.tsx

@@ -0,0 +1,95 @@
+import {AutofixDataFixture} from 'sentry-fixture/autofixData';
+import {AutofixStepFixture} from 'sentry-fixture/autofixStep';
+import {EventFixture} from 'sentry-fixture/event';
+import {GroupFixture} from 'sentry-fixture/group';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {AutofixDrawer} from 'sentry/components/events/autofix/autofixDrawer';
+import {t} from 'sentry/locale';
+
+describe('AutofixDrawer', () => {
+  const mockEvent = EventFixture();
+  const mockGroup = GroupFixture();
+  const mockProject = ProjectFixture();
+
+  const mockAutofixData = AutofixDataFixture({steps: [AutofixStepFixture()]});
+
+  beforeEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('renders properly', () => {
+    MockApiClient.addMockResponse({
+      url: `/issues/${mockGroup.id}/autofix/`,
+      body: {autofix: mockAutofixData},
+    });
+
+    render(<AutofixDrawer event={mockEvent} group={mockGroup} project={mockProject} />);
+
+    expect(screen.getByText(mockGroup.shortId)).toBeInTheDocument();
+
+    expect(screen.getByText(mockEvent.id)).toBeInTheDocument();
+
+    expect(screen.getByRole('heading', {name: 'Autofix'})).toBeInTheDocument();
+
+    expect(screen.getByText('Ready to begin analyzing the issue?')).toBeInTheDocument();
+
+    const startButton = screen.getByRole('button', {name: 'Start'});
+    expect(startButton).toBeInTheDocument();
+  });
+
+  it('triggers autofix on clicking the Start button', async () => {
+    MockApiClient.addMockResponse({
+      url: `/issues/${mockGroup.id}/autofix/`,
+      method: 'POST',
+      body: {autofix: null},
+    });
+    MockApiClient.addMockResponse({
+      url: `/issues/${mockGroup.id}/autofix/`,
+      method: 'GET',
+      body: {autofix: null},
+    });
+
+    render(<AutofixDrawer event={mockEvent} group={mockGroup} project={mockProject} />);
+
+    const startButton = screen.getByRole('button', {name: 'Start'});
+    await userEvent.click(startButton);
+
+    expect(
+      await screen.findByRole('button', {name: t('Start Over')})
+    ).toBeInTheDocument();
+  });
+
+  it('displays autofix steps and Start Over button when autofixData is available', async () => {
+    MockApiClient.addMockResponse({
+      url: `/issues/${mockGroup.id}/autofix/`,
+      body: {autofix: mockAutofixData},
+    });
+
+    render(<AutofixDrawer event={mockEvent} group={mockGroup} project={mockProject} />);
+
+    expect(
+      await screen.findByRole('button', {name: t('Start Over')})
+    ).toBeInTheDocument();
+  });
+
+  it('resets autofix on clicking the start over button', async () => {
+    MockApiClient.addMockResponse({
+      url: `/issues/${mockGroup.id}/autofix/`,
+      body: {autofix: mockAutofixData},
+    });
+
+    render(<AutofixDrawer event={mockEvent} group={mockGroup} project={mockProject} />);
+
+    const startOverButton = await screen.findByRole('button', {name: t('Start Over')});
+    expect(startOverButton).toBeInTheDocument();
+    await userEvent.click(startOverButton);
+
+    await waitFor(() => {
+      expect(screen.getByText('Ready to begin analyzing the issue?')).toBeInTheDocument();
+      expect(screen.getByRole('button', {name: 'Start'})).toBeInTheDocument();
+    });
+  });
+});

+ 158 - 0
static/app/components/events/autofix/autofixDrawer.tsx

@@ -0,0 +1,158 @@
+import {useState} from 'react';
+import styled from '@emotion/styled';
+
+import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
+import {Breadcrumbs as NavigationBreadcrumbs} from 'sentry/components/breadcrumbs';
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import AutofixFeedback from 'sentry/components/events/autofix/autofixFeedback';
+import AutofixMessageBox from 'sentry/components/events/autofix/autofixMessageBox';
+import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps';
+import {useAiAutofix} from 'sentry/components/events/autofix/useAutofix';
+import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Event} from 'sentry/types/event';
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
+import {getShortEventId} from 'sentry/utils/events';
+import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
+import {MIN_NAV_HEIGHT} from 'sentry/views/issueDetails/streamline/eventNavigation';
+
+interface AutofixDrawerProps {
+  event: Event;
+  group: Group;
+  project: Project;
+}
+
+export function AutofixDrawer({group, project, event}: AutofixDrawerProps) {
+  const {autofixData, triggerAutofix, reset} = useAiAutofix(group, event);
+  useRouteAnalyticsParams({
+    autofix_status: autofixData?.status ?? 'none',
+  });
+
+  const [_, setContainer] = useState<HTMLElement | null>(null);
+
+  return (
+    <AutofixDrawerContainer>
+      <AutofixDrawerHeader>
+        <NavigationCrumbs
+          crumbs={[
+            {
+              label: (
+                <CrumbContainer>
+                  <ProjectAvatar project={project} />
+                  <ShortId>{group.shortId}</ShortId>
+                </CrumbContainer>
+              ),
+            },
+            {label: getShortEventId(event.id)},
+            {label: t('Autofix')},
+          ]}
+        />
+      </AutofixDrawerHeader>
+      <AutofixNavigator>
+        <Header>{t('Autofix')}</Header>
+        {autofixData && (
+          <ButtonBar gap={1}>
+            <AutofixFeedback />
+            <Button
+              size="xs"
+              onClick={reset}
+              title={
+                autofixData.created_at
+                  ? `Last run at ${autofixData.created_at.split('T')[0]}`
+                  : null
+              }
+            >
+              {t('Start Over')}
+            </Button>
+          </ButtonBar>
+        )}
+      </AutofixNavigator>
+      <AutofixDrawerBody ref={setContainer}>
+        {!autofixData ? (
+          <AutofixMessageBox
+            displayText={'Ready to begin analyzing the issue?'}
+            step={null}
+            inputPlaceholder={'Optionally provide any extra context before we start...'}
+            responseRequired={false}
+            onSend={triggerAutofix}
+            actionText={'Start'}
+            allowEmptyMessage
+            isDisabled={false}
+            runId={''}
+            groupId={group.id}
+          />
+        ) : (
+          <AutofixSteps
+            data={autofixData}
+            groupId={group.id}
+            runId={autofixData.run_id}
+            onRetry={reset}
+          />
+        )}
+      </AutofixDrawerBody>
+    </AutofixDrawerContainer>
+  );
+}
+
+const AutofixDrawerContainer = styled('div')`
+  height: 100%;
+  display: grid;
+  grid-template-rows: auto auto 1fr;
+`;
+
+const AutofixDrawerHeader = styled(DrawerHeader)`
+  position: unset;
+  max-height: ${MIN_NAV_HEIGHT}px;
+  box-shadow: none;
+  border-bottom: 1px solid ${p => p.theme.border};
+`;
+
+const AutofixNavigator = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr auto;
+  align-items: center;
+  column-gap: ${space(1)};
+  padding: ${space(0.75)} 24px;
+  background: ${p => p.theme.background};
+  z-index: 1;
+  min-height: ${MIN_NAV_HEIGHT}px;
+  box-shadow: ${p => p.theme.translucentBorder} 0 1px;
+`;
+
+const AutofixDrawerBody = styled(DrawerBody)`
+  overflow: auto;
+  overscroll-behavior: contain;
+  /* Move the scrollbar to the left edge */
+  scroll-margin: 0 ${space(2)};
+  direction: rtl;
+  * {
+    direction: ltr;
+  }
+`;
+
+const Header = styled('h3')`
+  display: block;
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  font-weight: ${p => p.theme.fontWeightBold};
+  margin: 0;
+`;
+
+const NavigationCrumbs = styled(NavigationBreadcrumbs)`
+  margin: 0;
+  padding: 0;
+`;
+
+const CrumbContainer = styled('div')`
+  display: flex;
+  gap: ${space(1)};
+  align-items: center;
+`;
+
+const ShortId = styled('div')`
+  font-family: ${p => p.theme.text.family};
+  font-size: ${p => p.theme.fontSizeMedium};
+  line-height: 1;
+`;

+ 32 - 0
static/app/components/events/autofix/autofixFeedback.tsx

@@ -0,0 +1,32 @@
+import {useRef} from 'react';
+
+import {Button} from 'sentry/components/button';
+import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
+import {IconMegaphone} from 'sentry/icons/iconMegaphone';
+import {t} from 'sentry/locale';
+
+function AutofixFeedback() {
+  const buttonRef = useRef<HTMLButtonElement>(null);
+  const feedback = useFeedbackWidget({
+    buttonRef,
+    messagePlaceholder: t('How can we make Autofix better for you?'),
+    optionOverrides: {
+      tags: {
+        ['feedback.source']: 'issue_details_ai_autofix',
+        ['feedback.owner']: 'ml-ai',
+      },
+    },
+  });
+
+  if (!feedback) {
+    return null;
+  }
+
+  return (
+    <Button ref={buttonRef} size="xs" icon={<IconMegaphone />}>
+      {t('Give Feedback')}
+    </Button>
+  );
+}
+
+export default AutofixFeedback;

+ 0 - 64
static/app/components/events/autofix/autofixInputField.spec.tsx

@@ -1,64 +0,0 @@
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
-
-import {AutofixInputField} from 'sentry/components/events/autofix/autofixInputField';
-
-describe('AutofixInputField', function () {
-  const defaultProps = {
-    groupId: '123',
-    runId: '456',
-  };
-
-  it('renders the input field correctly', function () {
-    render(<AutofixInputField {...defaultProps} />);
-
-    // Check if the input field is present
-    expect(
-      screen.getByPlaceholderText('Rename the function foo_bar to fooBar')
-    ).toBeInTheDocument();
-
-    // Check if the send button is present
-    expect(screen.getByText('Send')).toBeInTheDocument();
-  });
-
-  it('handles input change correctly', async function () {
-    render(<AutofixInputField {...defaultProps} />);
-
-    const inputField = screen.getByPlaceholderText(
-      'Rename the function foo_bar to fooBar'
-    );
-
-    // Simulate user typing in the input field
-    await userEvent.click(inputField);
-    await userEvent.type(inputField, 'Change the variable name');
-
-    // Check if the input field value has changed
-    expect(inputField).toHaveValue('Change the variable name');
-  });
-
-  it('handles send button click correctly', async function () {
-    const mockSendUpdate = MockApiClient.addMockResponse({
-      url: '/issues/123/autofix/update/',
-      method: 'POST',
-    });
-
-    render(<AutofixInputField {...defaultProps} />);
-
-    const inputField = screen.getByPlaceholderText(
-      'Rename the function foo_bar to fooBar'
-    );
-    const sendButton = screen.getByText('Send');
-
-    // Simulate user typing in the input field
-    await userEvent.click(inputField);
-    await userEvent.type(inputField, 'Change the variable name');
-
-    // Simulate user clicking the send button
-    await userEvent.click(sendButton);
-
-    // Check if the input field is disabled after clicking the send button
-    expect(inputField).toBeDisabled();
-
-    // Check if the API request was sent
-    expect(mockSendUpdate).toHaveBeenCalledTimes(1);
-  });
-});

+ 0 - 143
static/app/components/events/autofix/autofixInputField.tsx

@@ -1,143 +0,0 @@
-import {useCallback, useState} from 'react';
-import styled from '@emotion/styled';
-
-import {addErrorMessage} from 'sentry/actionCreators/indicator';
-import {Button} from 'sentry/components/button';
-import {
-  type AutofixResponse,
-  makeAutofixQueryKey,
-} from 'sentry/components/events/autofix/useAutofix';
-import TextArea from 'sentry/components/forms/controls/textarea';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import Panel from 'sentry/components/panels/panel';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {isCtrlKeyPressed} from 'sentry/utils/isCtrlKeyPressed';
-import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient';
-import useApi from 'sentry/utils/useApi';
-
-const useAutofixUserInstruction = (groupId: string, runId: string) => {
-  const api = useApi();
-  const queryClient = useQueryClient();
-
-  const [instruction, setInstruction] = useState<string>('');
-  const [isSubmitting, setIsSubmitting] = useState(false);
-
-  const {mutate} = useMutation({
-    mutationFn: (params: {instruction: string}) => {
-      return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
-        method: 'POST',
-        data: {
-          run_id: runId,
-          payload: {
-            type: 'instruction',
-            content: {
-              type: 'text',
-              text: params.instruction,
-            },
-          },
-        },
-      });
-    },
-    onSuccess: _ => {
-      setApiQueryData<AutofixResponse>(
-        queryClient,
-        makeAutofixQueryKey(groupId),
-        data => {
-          if (!data || !data.autofix) {
-            return data;
-          }
-
-          return {
-            ...data,
-            autofix: {
-              ...data.autofix,
-              status: 'PROCESSING',
-            },
-          };
-        }
-      );
-    },
-    onError: () => {
-      addErrorMessage(t('Something went wrong when responding to autofix.'));
-      setIsSubmitting(false);
-    },
-  });
-
-  const sendInstruction = useCallback(() => {
-    mutate({instruction});
-    setIsSubmitting(true);
-  }, [instruction, mutate, setIsSubmitting]);
-
-  return {sendInstruction, instruction, setInstruction, isSubmitting};
-};
-
-export function AutofixInputField({groupId, runId}: {groupId: string; runId: string}) {
-  const {sendInstruction, instruction, setInstruction, isSubmitting} =
-    useAutofixUserInstruction(groupId, runId);
-
-  return (
-    <Card>
-      <Title>{t("Doesn't look right? Tell Autofix what needs to be changed")}</Title>
-      <form
-        onSubmit={e => {
-          e.preventDefault();
-          sendInstruction();
-        }}
-      >
-        <FormRow>
-          <TextArea
-            aria-label={t('Provide context')}
-            placeholder={t('Rename the function foo_bar to fooBar')}
-            value={instruction}
-            onChange={e => setInstruction(e.target.value)}
-            disabled={isSubmitting}
-            onKeyDown={e => {
-              if (isCtrlKeyPressed(e) && e.key === 'Enter') {
-                sendInstruction();
-              }
-            }}
-          />
-          <Button
-            type="submit"
-            icon={
-              isSubmitting && <ProcessingStatusIndicator size={18} mini hideMessage />
-            }
-            disabled={isSubmitting || !instruction}
-          >
-            {t('Send')}
-          </Button>
-        </FormRow>
-      </form>
-    </Card>
-  );
-}
-
-const Card = styled(Panel)`
-  padding: ${space(2)};
-  margin-bottom: 0;
-  display: flex;
-  flex-direction: column;
-  gap: ${space(1)};
-`;
-
-const Title = styled('div')`
-  font-weight: bold;
-  white-space: nowrap;
-`;
-
-const FormRow = styled('div')`
-  display: flex;
-  flex-direction: row;
-  align-items: flex-start;
-  gap: ${space(1)};
-  width: 100%;
-`;
-
-const ProcessingStatusIndicator = styled(LoadingIndicator)`
-  && {
-    margin: 0;
-    height: 18px;
-    width: 18px;
-  }
-`;

Some files were not shown because too many files changed in this diff