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;