diff --git a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx index 137900cb64a7f8..d21ae207363512 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx @@ -103,6 +103,7 @@ describe('SeerDrawer', () => { beforeEach(() => { MockApiClient.clearMockResponses(); + localStorage.clear(); MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, @@ -136,12 +137,26 @@ describe('SeerDrawer', () => { url: `/organizations/${mockProject.organization.slug}/group-search-views/starred/`, body: [], }); + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/group-search-views/`, + body: [], + }); MockApiClient.addMockResponse({ url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/`, body: { autofixAutomationTuning: 'off', }, }); + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/integrations/coding-agents/`, + body: { + integrations: [], + }, + }); + MockApiClient.addMockResponse({ + url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/autofix-repos/`, + body: [], + }); }); it('renders consent state if not consented', async () => { @@ -633,4 +648,161 @@ describe('SeerDrawer', () => { screen.getByText(/It currently only supports GitHub repositories/) ).toBeInTheDocument(); }); + + it('shows cursor integration onboarding step if integration is installed but handoff not configured', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + MockApiClient.addMockResponse({ + url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/seer/preferences/`, + body: { + code_mapping_repos: [], + preference: { + repositories: [{external_id: 'repo-123', name: 'org/repo', provider: 'github'}], + automated_run_stopping_point: 'root_cause', + // No automation_handoff + }, + }, + }); + MockApiClient.addMockResponse({ + url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/`, + body: { + autofixAutomationTuning: 'medium', + seerScannerAutomation: true, + }, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: {autofix: null}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/autofix-repos/`, + body: [ + { + name: 'org/repo', + provider: 'github', + owner: 'org', + external_id: 'repo-123', + is_readable: true, + is_writeable: true, + }, + ], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/group-search-views/starred/`, + body: [ + { + id: '1', + name: 'Fixability View', + query: 'is:unresolved issue.seer_actionability:high', + starred: true, + }, + ], + }); + + render(, { + organization: OrganizationFixture({ + features: ['gen-ai-features', 'integrations-cursor', 'issue-views'], + }), + }); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('ai-setup-loading-indicator') + ); + + expect( + await screen.findByText('Hand Off to Cursor Background Agents') + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).toBeInTheDocument(); + }); + + it('does not show cursor integration step if localStorage skip key is set', async () => { + // Set skip key BEFORE rendering + localStorage.setItem(`seer-onboarding-cursor-skipped:${mockProject.id}`, 'true'); + + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + MockApiClient.addMockResponse({ + url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/seer/preferences/`, + body: { + code_mapping_repos: [], + preference: { + repositories: [{external_id: 'repo-123', name: 'org/repo', provider: 'github'}], + automated_run_stopping_point: 'root_cause', + // No automation_handoff + }, + }, + }); + MockApiClient.addMockResponse({ + url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/`, + body: { + autofixAutomationTuning: 'medium', + seerScannerAutomation: true, + }, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: {autofix: null}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/autofix-repos/`, + body: [ + { + name: 'org/repo', + provider: 'github', + owner: 'org', + external_id: 'repo-123', + is_readable: true, + is_writeable: true, + }, + ], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/group-search-views/starred/`, + body: [ + { + id: '1', + name: 'Fixability View', + query: 'is:unresolved issue.seer_actionability:high', + starred: true, + }, + ], + }); + + render(, { + organization: OrganizationFixture({ + features: ['gen-ai-features', 'integrations-cursor', 'issue-views'], + }), + }); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('ai-setup-loading-indicator') + ); + + // Should not show the step since it was skipped + expect( + screen.queryByText('Hand Off to Cursor Background Agents') + ).not.toBeInTheDocument(); + }); }); diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx index 11c6855d744f9d..def819cc24f961 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx @@ -49,6 +49,12 @@ describe('SeerNotices', () => { url: `/projects/${organization.slug}/${ProjectFixture().slug}/autofix-repos/`, body: [createRepository()], }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [], + }, + }); }); it('shows automation step if automation is allowed and tuning is off', async () => { @@ -98,6 +104,167 @@ describe('SeerNotices', () => { }); }); + it('shows cursor integration step if integration is installed but handoff not configured', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${ProjectFixture().slug}/seer/preferences/`, + body: { + code_mapping_repos: [], + preference: { + repositories: [], + automated_run_stopping_point: 'root_cause', + // No automation_handoff - handoff is not configured + }, + }, + }); + MockApiClient.addMockResponse({ + method: 'GET', + url: `/projects/${organization.slug}/${ProjectFixture().slug}/`, + body: { + autofixAutomationTuning: 'medium', + }, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/group-search-views/starred/`, + body: [ + GroupSearchViewFixture({ + query: 'is:unresolved issue.seer_actionability:high', + starred: true, + }), + ], + }); + const project = getProjectWithAutomation('medium'); + render(, { + organization: { + ...organization, + features: ['integrations-cursor'], + }, + }); + await waitFor(() => { + expect( + screen.getByText('Hand Off to Cursor Background Agents') + ).toBeInTheDocument(); + }); + }); + + it('does not show cursor integration step if localStorage skip key is set', () => { + // Set localStorage skip key + localStorage.setItem(`seer-onboarding-cursor-skipped:${ProjectFixture().id}`, 'true'); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + MockApiClient.addMockResponse({ + method: 'GET', + url: `/projects/${organization.slug}/${ProjectFixture().slug}/`, + body: { + autofixAutomationTuning: 'medium', + }, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/group-search-views/starred/`, + body: [ + GroupSearchViewFixture({ + query: 'is:unresolved issue.seer_actionability:high', + starred: true, + }), + ], + }); + const project = getProjectWithAutomation('medium'); + render(, { + organization: { + ...organization, + features: ['integrations-cursor'], + }, + }); + + // Should not show the cursor step since it was skipped + expect( + screen.queryByText('Hand Off to Cursor Background Agents') + ).not.toBeInTheDocument(); + + // Clean up localStorage + localStorage.removeItem(`seer-onboarding-cursor-skipped:${ProjectFixture().id}`); + }); + + it('does not show cursor integration step if handoff is already configured', () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${ProjectFixture().slug}/seer/preferences/`, + body: { + code_mapping_repos: [], + preference: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }, + }); + MockApiClient.addMockResponse({ + method: 'GET', + url: `/projects/${organization.slug}/${ProjectFixture().slug}/`, + body: { + autofixAutomationTuning: 'medium', + }, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/group-search-views/starred/`, + body: [ + GroupSearchViewFixture({ + query: 'is:unresolved issue.seer_actionability:high', + starred: true, + }), + ], + }); + const project = getProjectWithAutomation('medium'); + render(, { + organization: { + ...organization, + features: ['integrations-cursor'], + }, + }); + + // Should not show the cursor step since handoff is already configured + expect( + screen.queryByText('Hand Off to Cursor Background Agents') + ).not.toBeInTheDocument(); + }); + it('does not render guided steps if all onboarding steps are complete', () => { MockApiClient.addMockResponse({ method: 'GET', @@ -126,5 +293,8 @@ describe('SeerNotices', () => { expect(screen.queryByText('Pick Repositories to Work In')).not.toBeInTheDocument(); expect(screen.queryByText('Unleash Automation')).not.toBeInTheDocument(); expect(screen.queryByText('Get Some Quick Wins')).not.toBeInTheDocument(); + expect( + screen.queryByText('Hand Off to Cursor Background Agents') + ).not.toBeInTheDocument(); }); }); diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx index a86458cf68c1da..9c77db5f787c73 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx @@ -1,25 +1,38 @@ -import {Fragment} from 'react'; +import {Fragment, useCallback} from 'react'; import styled from '@emotion/styled'; +import {useQueryClient} from '@tanstack/react-query'; import {AnimatePresence, motion} from 'framer-motion'; import addIntegrationProvider from 'sentry-images/spot/add-integration-provider.svg'; +import alertsEmptyStateImg from 'sentry-images/spot/alerts-empty-state.svg'; import feedbackOnboardingImg from 'sentry-images/spot/feedback-onboarding.svg'; import onboardingCompass from 'sentry-images/spot/onboarding-compass.svg'; import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg'; +import {Flex} from '@sentry/scraps/layout'; + import {Alert} from 'sentry/components/core/alert'; import {Button} from 'sentry/components/core/button'; import {LinkButton} from 'sentry/components/core/button/linkButton'; -import {ExternalLink} from 'sentry/components/core/link'; -import {useProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import {ExternalLink, Link} from 'sentry/components/core/link'; +import { + makeProjectSeerPreferencesQueryKey, + useProjectSeerPreferences, +} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import {useUpdateProjectAutomation} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectAutomation'; +import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; import StarFixabilityViewButton from 'sentry/components/events/autofix/seerCreateViewButton'; -import {useAutofixRepos} from 'sentry/components/events/autofix/useAutofix'; +import { + useAutofixRepos, + useCodingAgentIntegrations, +} from 'sentry/components/events/autofix/useAutofix'; import { GuidedSteps, useGuidedStepsContext, } from 'sentry/components/guidedSteps/guidedSteps'; import {IconChevron, IconSeer} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; +import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; import {space} from 'sentry/styles/space'; import type {Project} from 'sentry/types/project'; import {FieldKey} from 'sentry/utils/fields'; @@ -82,12 +95,16 @@ function CustomStepButtons({ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNoticesProps) { const organization = useOrganization(); + const queryClient = useQueryClient(); const {repos} = useAutofixRepos(groupId); const { preference, isLoading: isLoadingPreferences, codeMappingRepos, } = useProjectSeerPreferences(project); + const {mutate: updateProjectSeerPreferences} = useUpdateProjectSeerPreferences(project); + const {mutateAsync: updateProjectAutomation} = useUpdateProjectAutomation(project); + const {data: codingAgentIntegrations} = useCodingAgentIntegrations(); const {starredViews: views} = useStarredIssueViews(); const detailedProject = useDetailedProject({ @@ -98,6 +115,14 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice const hasIssueViews = useHasIssueViews(); const isStarredViewAllowed = hasIssueViews; + const cursorIntegration = codingAgentIntegrations?.integrations.find( + integration => integration.provider === 'cursor' + ); + const hasCursorFeatureFlagEnabled = Boolean( + organization.features.includes('integrations-cursor') + ); + const isCursorHandoffConfigured = Boolean(preference?.automation_handoff); + const unreadableRepos = repos.filter(repo => repo.is_readable === false); const githubRepos = unreadableRepos.filter(repo => repo.provider.includes('github')); const nonGithubRepos = unreadableRepos.filter( @@ -122,11 +147,20 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice const hasMultipleUnreadableRepos = unreadableRepos.length > 1; const hasSingleUnreadableRepo = unreadableRepos.length === 1; - // Use localStorage for collapsed state + // Use localStorage for collapsed state and cursor step skip const [stepsCollapsed, setStepsCollapsed] = useLocalStorageState( `seer-onboarding-collapsed:${project.id}`, false ); + const [cursorStepSkipped, setCursorStepSkipped] = useLocalStorageState( + `seer-onboarding-cursor-skipped:${project.id}`, + false + ); + + const needsCursorIntegration = + hasCursorFeatureFlagEnabled && + (!isCursorHandoffConfigured || !cursorIntegration) && + !cursorStepSkipped; // Calculate incomplete steps const stepConditions = [ @@ -134,7 +168,61 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice needsRepoSelection, needsAutomation, needsFixabilityView, + needsCursorIntegration, ]; + + const handleSetupCursorHandoff = useCallback(async () => { + if (!cursorIntegration) { + return; + } + + const isAutomationDisabled = + project.seerScannerAutomation === false || + project.autofixAutomationTuning === 'off'; + + if (isAutomationDisabled) { + await updateProjectAutomation({ + autofixAutomationTuning: 'low', + seerScannerAutomation: true, + }); + } + + updateProjectSeerPreferences( + { + repositories: preference?.repositories || [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: parseInt(cursorIntegration.id, 10), + }, + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ + makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), + ], + }); + }, + } + ); + }, [ + cursorIntegration, + project.seerScannerAutomation, + project.autofixAutomationTuning, + updateProjectAutomation, + updateProjectSeerPreferences, + preference?.repositories, + queryClient, + organization.slug, + project.slug, + ]); + + const handleSkipCursorStep = useCallback(() => { + setCursorStepSkipped(true); + setStepsCollapsed(true); + }, [setCursorStepSkipped, setStepsCollapsed]); const incompleteStepIndices = stepConditions .map((needed, idx) => (needed ? idx : null)) .filter(idx => idx !== null); @@ -349,7 +437,7 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice setStepsCollapsed(true)} > )} + + {/* Step 5: Cursor Integration */} + {hasCursorFeatureFlagEnabled && ( + + + + + {t('Hand Off to Cursor Background Agents')} + + } + isCompleted={!needsCursorIntegration} + > + + + + {cursorIntegration ? ( + + + {t( + 'Enable Seer automation and set up handoff to Cursor Background Agents when Seer identifies a root cause.' + )} + + + {tct( + 'During automation, Seer will trigger Cursor Background Agents to generate and submit pull requests directly to your repos. Configure in [seerProjectSettings:Seer project settings] or [docsLink:read the docs] to learn more.', + { + seerProjectSettings: ( + + ), + docsLink: ( + + ), + } + )} + + + ) : ( + + + {t( + 'Connect Cursor to automatically hand off Seer root cause analysis to Cursor Background Agents for seamless code fixes.' + )} + + + {tct( + 'Set up the [integrationLink:Cursor Integration] to enable automatic handoff. [docsLink:Read the docs] to learn more.', + { + integrationLink: ( + + ), + docsLink: ( + + ), + } + )} + + + )} + + + + + + + + {cursorIntegration ? ( + + ) : ( + + {t('Install Cursor Integration')} + + )} + + + )} @@ -444,6 +631,14 @@ const CardIllustration = styled('img')` margin-right: 10px; `; +const CursorCardIllustration = styled(CardIllustration)` + max-width: 160px; +`; + +const CursorPluginIcon = styled('div')` + transform: translateY(3px); +`; + const StepContentRow = styled('div')` display: flex; flex-direction: row;