From 25a41b60747277cdaa9c766f2ff56fd3c7107a9f Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Tue, 11 Nov 2025 22:31:17 +0700 Subject: [PATCH 1/3] feat(seer-cursor): add cursor integration to onboarding flow - Add Cursor integration as Step 5 in Seer onboarding in issue sidebar - Add setup and skip functionality for Cursor handoff configuration - Display integration card with setup instructions for users - Update tests for new onboarding step --- .../streamline/sidebar/seerDrawer.spec.tsx | 172 ++++++++++++++++ .../streamline/sidebar/seerNotices.spec.tsx | 170 ++++++++++++++++ .../streamline/sidebar/seerNotices.tsx | 188 +++++++++++++++++- 3 files changed, 524 insertions(+), 6 deletions(-) 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..79851521995da5 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx @@ -1,25 +1,37 @@ -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 {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 +94,15 @@ 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 {data: codingAgentIntegrations} = useCodingAgentIntegrations(); const {starredViews: views} = useStarredIssueViews(); const detailedProject = useDetailedProject({ @@ -98,6 +113,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 +145,18 @@ 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 = + (!isCursorHandoffConfigured || !cursorIntegration) && !cursorStepSkipped; // Calculate incomplete steps const stepConditions = [ @@ -134,7 +164,46 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice needsRepoSelection, needsAutomation, needsFixabilityView, + needsCursorIntegration, ]; + + const handleSetupCursorHandoff = useCallback(() => { + if (!cursorIntegration) { + return; + } + 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, + 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 +418,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 automatic 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 +612,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; From 064616c3c39990728fbf228c17dc7ff18a1bfb3c Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Wed, 12 Nov 2025 01:28:09 +0700 Subject: [PATCH 2/3] turn on automation too --- .../streamline/sidebar/seerNotices.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx index 79851521995da5..1b84151440478b 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx @@ -19,6 +19,7 @@ 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 { @@ -102,6 +103,7 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice codeMappingRepos, } = useProjectSeerPreferences(project); const {mutate: updateProjectSeerPreferences} = useUpdateProjectSeerPreferences(project); + const {mutateAsync: updateProjectAutomation} = useUpdateProjectAutomation(project); const {data: codingAgentIntegrations} = useCodingAgentIntegrations(); const {starredViews: views} = useStarredIssueViews(); @@ -167,10 +169,22 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice needsCursorIntegration, ]; - const handleSetupCursorHandoff = useCallback(() => { + 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 || [], @@ -193,6 +207,9 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice ); }, [ cursorIntegration, + project.seerScannerAutomation, + project.autofixAutomationTuning, + updateProjectAutomation, updateProjectSeerPreferences, preference?.repositories, queryClient, @@ -451,7 +468,7 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice {t( - 'Enable automatic handoff to Cursor Background Agents when Seer identifies a root cause.' + 'Enable Seer automation and set up handoff to Cursor Background Agents when Seer identifies a root cause.' )} From 60b51bb5f6cb1d7021dc7c1dd7491f9a0381803f Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Wed, 12 Nov 2025 02:26:29 +0700 Subject: [PATCH 3/3] check for flag in needsCursorIntegration --- .../app/views/issueDetails/streamline/sidebar/seerNotices.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx index 1b84151440478b..9c77db5f787c73 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx @@ -158,7 +158,9 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice ); const needsCursorIntegration = - (!isCursorHandoffConfigured || !cursorIntegration) && !cursorStepSkipped; + hasCursorFeatureFlagEnabled && + (!isCursorHandoffConfigured || !cursorIntegration) && + !cursorStepSkipped; // Calculate incomplete steps const stepConditions = [