From 76fb9d74f2e6c976d1883dc8aacd4c1799dc897d Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Tue, 11 Nov 2025 22:14:57 +0700 Subject: [PATCH 1/6] feat(seer-cursor): add cursor integration CTA component and settings page integration - Add reusable CursorIntegrationCta component for prompting users to configure Cursor integration - Integrate CTA component into Seer project settings page - Export makeProjectSeerPreferencesQueryKey for cache management - Add query invalidation on preferences update for better cache consistency --- .../autofix/cursorIntegrationCta.spec.tsx | 345 ++++++++++++++++++ .../events/autofix/cursorIntegrationCta.tsx | 281 ++++++++++++++ .../hooks/useProjectSeerPreferences.ts | 2 +- .../hooks/useUpdateProjectSeerPreferences.ts | 9 +- .../app/views/settings/projectSeer/index.tsx | 2 + 5 files changed, 637 insertions(+), 2 deletions(-) create mode 100644 static/app/components/events/autofix/cursorIntegrationCta.spec.tsx create mode 100644 static/app/components/events/autofix/cursorIntegrationCta.tsx diff --git a/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx new file mode 100644 index 00000000000000..d69eb745119bcd --- /dev/null +++ b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx @@ -0,0 +1,345 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {CursorIntegrationCta} from 'sentry/components/events/autofix/cursorIntegrationCta'; + +describe('CursorIntegrationCta', () => { + const project = ProjectFixture(); + const organization = OrganizationFixture({ + features: ['integrations-cursor'], + }); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + localStorage.clear(); + + // Default mock for seer preferences + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + body: { + code_mapping_repos: [], + preference: null, + }, + }); + + // Default mock for coding agent integrations + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [], + }, + }); + }); + + describe('Feature Flag', () => { + it('does not render without integrations-cursor feature flag', () => { + const orgWithoutFlag = OrganizationFixture({ + features: [], + }); + + const {container} = render(, { + organization: orgWithoutFlag, + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders with integrations-cursor feature flag', async () => { + render(, { + organization, + }); + + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + }); + }); + + describe('Loading State', () => { + it('shows loading placeholder while fetching preferences', () => { + render(, { + organization, + }); + + expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument(); + }); + + it('shows loading placeholder while fetching integrations', () => { + render(, { + organization, + }); + + expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument(); + }); + }); + + describe('Stage 1: Integration Not Installed', () => { + it('shows install stage when cursor integration is not installed', async () => { + render(, { + organization, + }); + + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect( + screen.getByText(/Connect Cursor to automatically hand off/) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Install Cursor Integration'}) + ).toBeInTheDocument(); + }); + + it('links to cursor integration settings', async () => { + render(, { + organization, + }); + + const installLink = await screen.findByRole('button', { + name: 'Install Cursor Integration', + }); + expect(installLink).toHaveAttribute('href', '/settings/integrations/cursor/'); + }); + + it('includes documentation link', async () => { + render(, { + organization, + }); + + await screen.findByText('Cursor Agent Integration'); + const docsLink = screen.getByRole('link', {name: 'Read the docs'}); + expect(docsLink).toHaveAttribute( + 'href', + 'https://docs.sentry.io/integrations/cursor/' + ); + }); + }); + + describe('Stage 2: Integration Installed but Not Configured', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + }); + + it('shows configure stage when integration installed but not configured', async () => { + render(, { + organization, + }); + + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect( + screen.getByText(/You have the Cursor integration installed/) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).toBeInTheDocument(); + }); + + it('configures handoff when setup button is clicked', async () => { + const updateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + body: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }); + + render(, { + organization, + }); + + const setupButton = await screen.findByRole('button', { + name: 'Set Seer to hand off to Cursor', + }); + await userEvent.click(setupButton); + + await waitFor(() => { + expect(updateMock).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }) + ); + }); + }); + + it('includes link to project seer settings', async () => { + render(, { + organization, + }); + + await screen.findByText('Cursor Agent Integration'); + const settingsLink = screen.getByRole('link', { + name: 'Configure in Seer project settings', + }); + expect(settingsLink).toHaveAttribute( + 'href', + `/settings/projects/${project.slug}/seer/` + ); + }); + }); + + describe('Stage 3: Integration Configured', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.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, + }, + }, + }, + }); + }); + + it('shows configured stage when handoff is set up', async () => { + render(, { + organization, + }); + + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect(screen.getByText(/Cursor handoff is active/)).toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).not.toBeInTheDocument(); + }); + + it('does not show setup button in configured stage', async () => { + render(, { + organization, + }); + + await screen.findByText('Cursor Agent Integration'); + expect( + screen.queryByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).not.toBeInTheDocument(); + }); + }); + + describe('Dismissible Functionality', () => { + const dismissKey = 'test-dismiss-key'; + + it('shows dismiss button when dismissible is true', async () => { + render( + , + { + organization, + } + ); + + expect(await screen.findByRole('button', {name: 'Dismiss'})).toBeInTheDocument(); + }); + + it('does not show dismiss button when dismissible is false', async () => { + render(, { + organization, + }); + + await screen.findByText('Cursor Agent Integration'); + expect(screen.queryByRole('button', {name: 'Dismiss'})).not.toBeInTheDocument(); + }); + + it('hides component when dismissed', async () => { + render( + , + { + organization, + } + ); + + const dismissButton = await screen.findByRole('button', {name: 'Dismiss'}); + await userEvent.click(dismissButton); + + expect(screen.queryByText('Cursor Agent Integration')).not.toBeInTheDocument(); + + // Verify localStorage was set + expect(localStorage.getItem(dismissKey)).toBe('true'); + expect(localStorage.getItem(`${dismissKey}-stage`)).toBe('install'); + }); + + it('respects existing dismissal from localStorage', () => { + localStorage.setItem(dismissKey, 'true'); + + const {container} = render( + , + { + organization, + } + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('resets dismissal when stage changes', async () => { + localStorage.setItem(dismissKey, 'true'); + localStorage.setItem(`${dismissKey}-stage`, 'install'); + + // Now mock the integration being installed (stage changes to 'configure') + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + + render( + , + { + organization, + } + ); + + // Component should be visible since stage changed + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect(localStorage.getItem(dismissKey)).toBeNull(); + }); + }); +}); diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx new file mode 100644 index 00000000000000..a319d11c671f8e --- /dev/null +++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx @@ -0,0 +1,281 @@ +import {useCallback, useEffect, useState} from 'react'; +import styled from '@emotion/styled'; +import {useQueryClient} from '@tanstack/react-query'; + +import {Flex} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {Button} from 'sentry/components/core/button/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; +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 {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix'; +import Placeholder from 'sentry/components/placeholder'; +import {IconClose} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; +import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import useOrganization from 'sentry/utils/useOrganization'; + +interface CursorIntegrationCtaProps { + project: Project; + dismissKey?: string; + dismissible?: boolean; + organization?: Organization; +} + +export function CursorIntegrationCta({ + project, + dismissible = false, + dismissKey, +}: CursorIntegrationCtaProps) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + + const {preference, isFetching: isLoadingPreferences} = + useProjectSeerPreferences(project); + const {mutate: updateProjectSeerPreferences, isPending: isUpdatingPreferences} = + useUpdateProjectSeerPreferences(project); + const {data: codingAgentIntegrations, isLoading: isLoadingIntegrations} = + useCodingAgentIntegrations(); + + const cursorIntegration = codingAgentIntegrations?.integrations.find( + integration => integration.provider === 'cursor' + ); + + const [isDismissed, setIsDismissed] = useState(() => { + if (!dismissible || !dismissKey) { + return false; + } + return localStorage.getItem(dismissKey) === 'true'; + }); + + const hasCursorIntegrationFeatureFlag = + organization?.features.includes('integrations-cursor'); + const hasCursorIntegration = Boolean(cursorIntegration); + const isConfigured = Boolean(preference?.automation_handoff); + + // Determine the current stage + const stage = hasCursorIntegration + ? isConfigured + ? 'configured' + : 'configure' + : 'install'; + + // Reset dismissal if stage changes + useEffect(() => { + if (!dismissible || !dismissKey) { + return; + } + const dismissedStage = localStorage.getItem(`${dismissKey}-stage`); + if (dismissedStage && dismissedStage !== stage) { + setIsDismissed(false); + localStorage.removeItem(dismissKey); + } + }, [stage, dismissKey, dismissible]); + + const handleDismiss = useCallback(() => { + if (!dismissKey) { + return; + } + localStorage.setItem(dismissKey, 'true'); + localStorage.setItem(`${dismissKey}-stage`, stage); + setIsDismissed(true); + }, [dismissKey, stage]); + + const handleSetupClick = useCallback(() => { + if (!cursorIntegration) { + throw new Error('Cursor integration not found'); + } + 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: () => { + // Invalidate queries to update the dropdown in the settings page + queryClient.invalidateQueries({ + queryKey: [ + makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), + ], + }); + }, + } + ); + }, [ + project.slug, + organization.slug, + updateProjectSeerPreferences, + preference?.repositories, + cursorIntegration, + queryClient, + ]); + + if (!organization) { + return null; + } + + if (isDismissed) { + return null; + } + + if (!hasCursorIntegrationFeatureFlag) { + return null; + } + + // Show loading state while fetching data + if (isLoadingPreferences || isLoadingIntegrations || isUpdatingPreferences) { + return ( + + + + ); + } + + // Stage 1: Integration not installed + if (!hasCursorIntegration) { + return ( + + {dismissible && ( + } + aria-label={t('Dismiss')} + onClick={handleDismiss} + /> + )} + + + + + Cursor Agent Integration + + + + {tct( + 'Connect Cursor to automatically hand off Seer root cause analysis to Cursor Background Agents for seamless code fixes. [docsLink:Read the docs] to learn more.', + { + docsLink: ( + + ), + } + )} + +
+ + {t('Install Cursor Integration')} + +
+
+
+ ); + } + + // Stage 2: Integration installed but handoff not configured + if (!isConfigured) { + return ( + + {dismissible && ( + } + aria-label={t('Dismiss')} + onClick={handleDismiss} + /> + )} + + + + + Cursor Agent Integration + + + + {tct( + 'You have the Cursor integration installed. Set up Seer to hand off and trigger Cursor Background Agents during automation. [seerProjectSettings:Configure in Seer project settings] or [docsLink:read the docs] to learn more.', + { + seerProjectSettings: ( + + ), + docsLink: ( + + ), + } + )} + +
+ +
+
+
+ ); + } + + // Stage 3: Configured or just configured + return ( + + {dismissible && ( + } + aria-label={t('Dismiss')} + onClick={handleDismiss} + /> + )} + + + + + Cursor Agent Integration + + + + {tct( + 'Cursor handoff is active. During automation runs, Seer will automatically trigger Cursor Background Agents. [docsLink:Read the docs] to learn more.', + { + docsLink: ( + + ), + } + )} + + + + ); +} + +const Card = styled('div')` + position: relative; + padding: ${p => p.theme.space.xl}; + border: 1px solid ${p => p.theme.border}; + border-radius: ${p => p.theme.borderRadius}; + margin-top: ${p => p.theme.space['2xl']}; + margin-bottom: ${p => p.theme.space['2xl']}; +`; + +const DismissButton = styled(Button)` + position: absolute; + top: ${p => p.theme.space.md}; + right: ${p => p.theme.space.md}; + color: ${p => p.theme.subText}; + + &:hover { + color: ${p => p.theme.textColor}; + } +`; diff --git a/static/app/components/events/autofix/preferences/hooks/useProjectSeerPreferences.ts b/static/app/components/events/autofix/preferences/hooks/useProjectSeerPreferences.ts index 5226e2e269b805..453d6c377df8db 100644 --- a/static/app/components/events/autofix/preferences/hooks/useProjectSeerPreferences.ts +++ b/static/app/components/events/autofix/preferences/hooks/useProjectSeerPreferences.ts @@ -11,7 +11,7 @@ export interface SeerPreferencesResponse { preference?: ProjectSeerPreferences | null; } -function makeProjectSeerPreferencesQueryKey(orgSlug: string, projectSlug: string) { +export function makeProjectSeerPreferencesQueryKey(orgSlug: string, projectSlug: string) { return `/projects/${orgSlug}/${projectSlug}/seer/preferences/`; } diff --git a/static/app/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences.ts b/static/app/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences.ts index 98acecac62ed72..13df39b7f62688 100644 --- a/static/app/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences.ts +++ b/static/app/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences.ts @@ -1,12 +1,14 @@ +import {makeProjectSeerPreferencesQueryKey} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; import type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types'; import type {Project} from 'sentry/types/project'; -import {useMutation} from 'sentry/utils/queryClient'; +import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; export function useUpdateProjectSeerPreferences(project: Project) { const organization = useOrganization(); const api = useApi({persistInFlight: true}); + const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: ProjectSeerPreferences) => { @@ -23,5 +25,10 @@ export function useUpdateProjectSeerPreferences(project: Project) { } ); }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [makeProjectSeerPreferencesQueryKey(organization.slug, project.slug)], + }); + }, }); } diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx index d53d90d12c2fa2..5ab3dfd8a46707 100644 --- a/static/app/views/settings/projectSeer/index.tsx +++ b/static/app/views/settings/projectSeer/index.tsx @@ -6,6 +6,7 @@ import {hasEveryAccess} from 'sentry/components/acl/access'; import FeatureDisabled from 'sentry/components/acl/featureDisabled'; import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Link} from 'sentry/components/core/link'; +import {CursorIntegrationCta} from 'sentry/components/events/autofix/cursorIntegrationCta'; import {useProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; import {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix'; @@ -324,6 +325,7 @@ function ProjectSeer({ })} /> +
Date: Wed, 12 Nov 2025 01:15:00 +0700 Subject: [PATCH 2/6] turn on automation if it's off --- .../autofix/cursorIntegrationCta.spec.tsx | 206 +++++++++++++++++- .../events/autofix/cursorIntegrationCta.tsx | 55 ++++- .../app/views/settings/projectSeer/index.tsx | 4 +- 3 files changed, 255 insertions(+), 10 deletions(-) diff --git a/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx index d69eb745119bcd..906d07ffc9adfe 100644 --- a/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx +++ b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx @@ -4,6 +4,7 @@ import {ProjectFixture} from 'sentry-fixture/project'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import {CursorIntegrationCta} from 'sentry/components/events/autofix/cursorIntegrationCta'; +import ProjectsStore from 'sentry/stores/projectsStore'; describe('CursorIntegrationCta', () => { const project = ProjectFixture(); @@ -200,6 +201,195 @@ describe('CursorIntegrationCta', () => { `/settings/projects/${project.slug}/seer/` ); }); + + it('enables automation when setup button is clicked and automation is disabled', async () => { + const projectWithoutAutomation = ProjectFixture({ + seerScannerAutomation: false, + autofixAutomationTuning: 'off', + }); + + const updatedProject = { + ...projectWithoutAutomation, + seerScannerAutomation: true, + autofixAutomationTuning: 'low', + }; + + const projectUpdateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${projectWithoutAutomation.slug}/`, + method: 'PUT', + body: updatedProject, + }); + + const preferencesUpdateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${projectWithoutAutomation.slug}/seer/preferences/`, + method: 'POST', + body: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }); + + const onUpdateSuccessSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + + render(, { + organization, + }); + + const setupButton = await screen.findByRole('button', { + name: 'Set Seer to hand off to Cursor', + }); + await userEvent.click(setupButton); + + // Should first enable automation + await waitFor(() => { + expect(projectUpdateMock).toHaveBeenCalledWith( + `/projects/${organization.slug}/${projectWithoutAutomation.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: { + autofixAutomationTuning: 'low', + seerScannerAutomation: true, + }, + }) + ); + }); + + // Should update the project store + await waitFor(() => { + expect(onUpdateSuccessSpy).toHaveBeenCalledWith(updatedProject); + }); + + // Then configure handoff + await waitFor(() => { + expect(preferencesUpdateMock).toHaveBeenCalledWith( + `/projects/${organization.slug}/${projectWithoutAutomation.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }) + ); + }); + + onUpdateSuccessSpy.mockRestore(); + }); + + it('does not enable automation when already enabled', async () => { + const projectWithAutomation = ProjectFixture({ + seerScannerAutomation: true, + autofixAutomationTuning: 'medium', + }); + + const projectUpdateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${projectWithAutomation.slug}/`, + method: 'PUT', + body: {}, + }); + + const preferencesUpdateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${projectWithAutomation.slug}/seer/preferences/`, + method: 'POST', + body: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }); + + render(, { + organization, + }); + + const setupButton = await screen.findByRole('button', { + name: 'Set Seer to hand off to Cursor', + }); + await userEvent.click(setupButton); + + // Should NOT call project update since automation is already enabled + expect(projectUpdateMock).not.toHaveBeenCalled(); + + // Should only configure handoff + await waitFor(() => { + expect(preferencesUpdateMock).toHaveBeenCalledWith( + `/projects/${organization.slug}/${projectWithAutomation.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + }) + ); + }); + }); + }); + + describe('Stage 2: Automation Disabled with Handoff Configured', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.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, + }, + }, + }, + }); + }); + + it('shows configure stage when handoff is configured but automation is disabled', async () => { + const projectWithoutAutomation = ProjectFixture({ + seerScannerAutomation: false, + autofixAutomationTuning: 'off', + }); + + render(, { + organization, + }); + + // Should show configure stage, not configured stage + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect( + screen.getByText(/You have the Cursor integration installed/) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).toBeInTheDocument(); + + // Should NOT show the configured message + expect(screen.queryByText(/Cursor handoff is active/)).not.toBeInTheDocument(); + }); }); describe('Stage 3: Integration Configured', () => { @@ -234,8 +424,13 @@ describe('CursorIntegrationCta', () => { }); }); - it('shows configured stage when handoff is set up', async () => { - render(, { + it('shows configured stage when handoff is set up and automation is enabled', async () => { + const projectWithAutomation = ProjectFixture({ + seerScannerAutomation: true, + autofixAutomationTuning: 'medium', + }); + + render(, { organization, }); @@ -247,7 +442,12 @@ describe('CursorIntegrationCta', () => { }); it('does not show setup button in configured stage', async () => { - render(, { + const projectWithAutomation = ProjectFixture({ + seerScannerAutomation: true, + autofixAutomationTuning: 'medium', + }); + + render(, { organization, }); diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx index a319d11c671f8e..e99ceff791e912 100644 --- a/static/app/components/events/autofix/cursorIntegrationCta.tsx +++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx @@ -18,8 +18,11 @@ import Placeholder from 'sentry/components/placeholder'; import {IconClose} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; +import ProjectsStore from 'sentry/stores/projectsStore'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import {fetchMutation, useMutation} from 'sentry/utils/queryClient'; +import {makeDetailedProjectQueryKey} from 'sentry/utils/useDetailedProject'; import useOrganization from 'sentry/utils/useOrganization'; interface CursorIntegrationCtaProps { @@ -44,6 +47,33 @@ export function CursorIntegrationCta({ const {data: codingAgentIntegrations, isLoading: isLoadingIntegrations} = useCodingAgentIntegrations(); + const {mutateAsync: updateProjectAutomation} = useMutation< + Project, + Error, + {autofixAutomationTuning: string; seerScannerAutomation: boolean} + >({ + mutationFn: (data: { + autofixAutomationTuning: string; + seerScannerAutomation: boolean; + }) => { + return fetchMutation({ + method: 'PUT', + url: `/projects/${organization.slug}/${project.slug}/`, + data, + }); + }, + onSuccess: (updatedProject: Project) => { + ProjectsStore.onUpdateSuccess(updatedProject); + + queryClient.invalidateQueries({ + queryKey: makeDetailedProjectQueryKey({ + orgSlug: organization.slug, + projectSlug: project.slug, + }), + }); + }, + }); + const cursorIntegration = codingAgentIntegrations?.integrations.find( integration => integration.provider === 'cursor' ); @@ -58,9 +88,10 @@ export function CursorIntegrationCta({ const hasCursorIntegrationFeatureFlag = organization?.features.includes('integrations-cursor'); const hasCursorIntegration = Boolean(cursorIntegration); - const isConfigured = Boolean(preference?.automation_handoff); + const isAutomationEnabled = + project.seerScannerAutomation !== false && project.autofixAutomationTuning !== 'off'; + const isConfigured = Boolean(preference?.automation_handoff) && isAutomationEnabled; - // Determine the current stage const stage = hasCursorIntegration ? isConfigured ? 'configured' @@ -88,10 +119,22 @@ export function CursorIntegrationCta({ setIsDismissed(true); }, [dismissKey, stage]); - const handleSetupClick = useCallback(() => { + const handleSetupClick = useCallback(async () => { if (!cursorIntegration) { throw new Error('Cursor integration not found'); } + + const isAutomationDisabled = + project.seerScannerAutomation === false || + project.autofixAutomationTuning === 'off'; + + if (isAutomationDisabled) { + await updateProjectAutomation({ + autofixAutomationTuning: 'low', + seerScannerAutomation: true, + }); + } + updateProjectSeerPreferences( { repositories: preference?.repositories || [], @@ -115,8 +158,11 @@ export function CursorIntegrationCta({ ); }, [ project.slug, + project.seerScannerAutomation, + project.autofixAutomationTuning, organization.slug, updateProjectSeerPreferences, + updateProjectAutomation, preference?.repositories, cursorIntegration, queryClient, @@ -134,7 +180,6 @@ export function CursorIntegrationCta({ return null; } - // Show loading state while fetching data if (isLoadingPreferences || isLoadingIntegrations || isUpdatingPreferences) { return ( @@ -205,7 +250,7 @@ export function CursorIntegrationCta({ {tct( - 'You have the Cursor integration installed. Set up Seer to hand off and trigger Cursor Background Agents during automation. [seerProjectSettings:Configure in Seer project settings] or [docsLink:read the docs] to learn more.', + 'You have the Cursor integration installed. Turn on Seer automation and set up hand off to trigger Cursor Background Agents during automation. [seerProjectSettings:Configure in Seer project settings] or [docsLink:read the docs] to learn more.', { seerProjectSettings: ( diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx index 5ab3dfd8a46707..8366a2564f28ca 100644 --- a/static/app/views/settings/projectSeer/index.tsx +++ b/static/app/views/settings/projectSeer/index.tsx @@ -229,11 +229,11 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { return (
Date: Wed, 12 Nov 2025 01:18:57 +0700 Subject: [PATCH 3/6] good bot --- static/app/components/events/autofix/cursorIntegrationCta.tsx | 2 -- static/app/views/settings/projectSeer/index.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx index e99ceff791e912..d6b365583eb96f 100644 --- a/static/app/components/events/autofix/cursorIntegrationCta.tsx +++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx @@ -19,7 +19,6 @@ import {IconClose} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; import ProjectsStore from 'sentry/stores/projectsStore'; -import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {fetchMutation, useMutation} from 'sentry/utils/queryClient'; import {makeDetailedProjectQueryKey} from 'sentry/utils/useDetailedProject'; @@ -29,7 +28,6 @@ interface CursorIntegrationCtaProps { project: Project; dismissKey?: string; dismissible?: boolean; - organization?: Organization; } export function CursorIntegrationCta({ diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx index 8366a2564f28ca..f0236d9956bd88 100644 --- a/static/app/views/settings/projectSeer/index.tsx +++ b/static/app/views/settings/projectSeer/index.tsx @@ -325,7 +325,7 @@ function ProjectSeer({ })} /> - +
Date: Wed, 12 Nov 2025 01:27:23 +0700 Subject: [PATCH 4/6] centralized --- .../events/autofix/cursorIntegrationCta.tsx | 32 +---------- .../hooks/useUpdateProjectAutomation.tsx | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+), 30 deletions(-) create mode 100644 static/app/components/events/autofix/preferences/hooks/useUpdateProjectAutomation.tsx diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx index d6b365583eb96f..bfa1c3bc87f82a 100644 --- a/static/app/components/events/autofix/cursorIntegrationCta.tsx +++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx @@ -12,16 +12,14 @@ 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 {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix'; import Placeholder from 'sentry/components/placeholder'; import {IconClose} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; -import ProjectsStore from 'sentry/stores/projectsStore'; import type {Project} from 'sentry/types/project'; -import {fetchMutation, useMutation} from 'sentry/utils/queryClient'; -import {makeDetailedProjectQueryKey} from 'sentry/utils/useDetailedProject'; import useOrganization from 'sentry/utils/useOrganization'; interface CursorIntegrationCtaProps { @@ -44,33 +42,7 @@ export function CursorIntegrationCta({ useUpdateProjectSeerPreferences(project); const {data: codingAgentIntegrations, isLoading: isLoadingIntegrations} = useCodingAgentIntegrations(); - - const {mutateAsync: updateProjectAutomation} = useMutation< - Project, - Error, - {autofixAutomationTuning: string; seerScannerAutomation: boolean} - >({ - mutationFn: (data: { - autofixAutomationTuning: string; - seerScannerAutomation: boolean; - }) => { - return fetchMutation({ - method: 'PUT', - url: `/projects/${organization.slug}/${project.slug}/`, - data, - }); - }, - onSuccess: (updatedProject: Project) => { - ProjectsStore.onUpdateSuccess(updatedProject); - - queryClient.invalidateQueries({ - queryKey: makeDetailedProjectQueryKey({ - orgSlug: organization.slug, - projectSlug: project.slug, - }), - }); - }, - }); + const {mutateAsync: updateProjectAutomation} = useUpdateProjectAutomation(project); const cursorIntegration = codingAgentIntegrations?.integrations.find( integration => integration.provider === 'cursor' diff --git a/static/app/components/events/autofix/preferences/hooks/useUpdateProjectAutomation.tsx b/static/app/components/events/autofix/preferences/hooks/useUpdateProjectAutomation.tsx new file mode 100644 index 00000000000000..31754fb3cfd39d --- /dev/null +++ b/static/app/components/events/autofix/preferences/hooks/useUpdateProjectAutomation.tsx @@ -0,0 +1,55 @@ +import ProjectsStore from 'sentry/stores/projectsStore'; +import type {Project} from 'sentry/types/project'; +import { + fetchMutation, + setApiQueryData, + useMutation, + useQueryClient, +} from 'sentry/utils/queryClient'; +import {makeDetailedProjectQueryKey} from 'sentry/utils/useDetailedProject'; +import useOrganization from 'sentry/utils/useOrganization'; + +interface UpdateProjectAutomationData { + autofixAutomationTuning: 'off' | 'super_low' | 'low' | 'medium' | 'high' | 'always'; + seerScannerAutomation: boolean; +} + +export function useUpdateProjectAutomation(project: Project) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: UpdateProjectAutomationData) => { + return fetchMutation({ + method: 'PUT', + url: `/projects/${organization.slug}/${project.slug}/`, + data: { + autofixAutomationTuning: data.autofixAutomationTuning, + seerScannerAutomation: data.seerScannerAutomation, + }, + }); + }, + onSuccess: (updatedProject: Project) => { + // Update the project store so the UI reflects the changes immediately + ProjectsStore.onUpdateSuccess(updatedProject); + + // Update the query cache optimistically + setApiQueryData( + queryClient, + makeDetailedProjectQueryKey({ + orgSlug: organization.slug, + projectSlug: project.slug, + }), + existingData => (updatedProject ? updatedProject : existingData) + ); + + // Invalidate to refetch and ensure consistency + queryClient.invalidateQueries({ + queryKey: makeDetailedProjectQueryKey({ + orgSlug: organization.slug, + projectSlug: project.slug, + }), + }); + }, + }); +} From 08bf9675fe612add72d2f27143ccda648736f4e0 Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:10:22 +0700 Subject: [PATCH 5/6] remove dismissability --- .../events/autofix/cursorIntegrationCta.tsx | 96 +------------------ 1 file changed, 3 insertions(+), 93 deletions(-) diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx index bfa1c3bc87f82a..c373ce09369dc0 100644 --- a/static/app/components/events/autofix/cursorIntegrationCta.tsx +++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useState} from 'react'; +import {useCallback} from 'react'; import styled from '@emotion/styled'; import {useQueryClient} from '@tanstack/react-query'; @@ -16,7 +16,6 @@ import {useUpdateProjectAutomation} from 'sentry/components/events/autofix/prefe import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; import {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix'; import Placeholder from 'sentry/components/placeholder'; -import {IconClose} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; import type {Project} from 'sentry/types/project'; @@ -24,15 +23,9 @@ import useOrganization from 'sentry/utils/useOrganization'; interface CursorIntegrationCtaProps { project: Project; - dismissKey?: string; - dismissible?: boolean; } -export function CursorIntegrationCta({ - project, - dismissible = false, - dismissKey, -}: CursorIntegrationCtaProps) { +export function CursorIntegrationCta({project}: CursorIntegrationCtaProps) { const organization = useOrganization(); const queryClient = useQueryClient(); @@ -48,47 +41,13 @@ export function CursorIntegrationCta({ integration => integration.provider === 'cursor' ); - const [isDismissed, setIsDismissed] = useState(() => { - if (!dismissible || !dismissKey) { - return false; - } - return localStorage.getItem(dismissKey) === 'true'; - }); - const hasCursorIntegrationFeatureFlag = - organization?.features.includes('integrations-cursor'); + organization.features.includes('integrations-cursor'); const hasCursorIntegration = Boolean(cursorIntegration); const isAutomationEnabled = project.seerScannerAutomation !== false && project.autofixAutomationTuning !== 'off'; const isConfigured = Boolean(preference?.automation_handoff) && isAutomationEnabled; - const stage = hasCursorIntegration - ? isConfigured - ? 'configured' - : 'configure' - : 'install'; - - // Reset dismissal if stage changes - useEffect(() => { - if (!dismissible || !dismissKey) { - return; - } - const dismissedStage = localStorage.getItem(`${dismissKey}-stage`); - if (dismissedStage && dismissedStage !== stage) { - setIsDismissed(false); - localStorage.removeItem(dismissKey); - } - }, [stage, dismissKey, dismissible]); - - const handleDismiss = useCallback(() => { - if (!dismissKey) { - return; - } - localStorage.setItem(dismissKey, 'true'); - localStorage.setItem(`${dismissKey}-stage`, stage); - setIsDismissed(true); - }, [dismissKey, stage]); - const handleSetupClick = useCallback(async () => { if (!cursorIntegration) { throw new Error('Cursor integration not found'); @@ -138,14 +97,6 @@ export function CursorIntegrationCta({ queryClient, ]); - if (!organization) { - return null; - } - - if (isDismissed) { - return null; - } - if (!hasCursorIntegrationFeatureFlag) { return null; } @@ -162,16 +113,6 @@ export function CursorIntegrationCta({ if (!hasCursorIntegration) { return ( - {dismissible && ( - } - aria-label={t('Dismiss')} - onClick={handleDismiss} - /> - )} - @@ -202,16 +143,6 @@ export function CursorIntegrationCta({ if (!isConfigured) { return ( - {dismissible && ( - } - aria-label={t('Dismiss')} - onClick={handleDismiss} - /> - )} - @@ -244,16 +175,6 @@ export function CursorIntegrationCta({ // Stage 3: Configured or just configured return ( - {dismissible && ( - } - aria-label={t('Dismiss')} - onClick={handleDismiss} - /> - )} - @@ -283,14 +204,3 @@ const Card = styled('div')` margin-top: ${p => p.theme.space['2xl']}; margin-bottom: ${p => p.theme.space['2xl']}; `; - -const DismissButton = styled(Button)` - position: absolute; - top: ${p => p.theme.space.md}; - right: ${p => p.theme.space.md}; - color: ${p => p.theme.subText}; - - &:hover { - color: ${p => p.theme.textColor}; - } -`; From 1266cd4bd635cfbe8b0f445e5fea1ef37bdf67f2 Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Wed, 12 Nov 2025 22:40:52 +0700 Subject: [PATCH 6/6] remove unused dismiss test --- .../autofix/cursorIntegrationCta.spec.tsx | 85 ------------------- 1 file changed, 85 deletions(-) diff --git a/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx index 906d07ffc9adfe..3114f46ccadd07 100644 --- a/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx +++ b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx @@ -457,89 +457,4 @@ describe('CursorIntegrationCta', () => { ).not.toBeInTheDocument(); }); }); - - describe('Dismissible Functionality', () => { - const dismissKey = 'test-dismiss-key'; - - it('shows dismiss button when dismissible is true', async () => { - render( - , - { - organization, - } - ); - - expect(await screen.findByRole('button', {name: 'Dismiss'})).toBeInTheDocument(); - }); - - it('does not show dismiss button when dismissible is false', async () => { - render(, { - organization, - }); - - await screen.findByText('Cursor Agent Integration'); - expect(screen.queryByRole('button', {name: 'Dismiss'})).not.toBeInTheDocument(); - }); - - it('hides component when dismissed', async () => { - render( - , - { - organization, - } - ); - - const dismissButton = await screen.findByRole('button', {name: 'Dismiss'}); - await userEvent.click(dismissButton); - - expect(screen.queryByText('Cursor Agent Integration')).not.toBeInTheDocument(); - - // Verify localStorage was set - expect(localStorage.getItem(dismissKey)).toBe('true'); - expect(localStorage.getItem(`${dismissKey}-stage`)).toBe('install'); - }); - - it('respects existing dismissal from localStorage', () => { - localStorage.setItem(dismissKey, 'true'); - - const {container} = render( - , - { - organization, - } - ); - - expect(container).toBeEmptyDOMElement(); - }); - - it('resets dismissal when stage changes', async () => { - localStorage.setItem(dismissKey, 'true'); - localStorage.setItem(`${dismissKey}-stage`, 'install'); - - // Now mock the integration being installed (stage changes to 'configure') - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/coding-agents/`, - body: { - integrations: [ - { - id: '123', - provider: 'cursor', - name: 'Cursor', - }, - ], - }, - }); - - render( - , - { - organization, - } - ); - - // Component should be visible since stage changed - expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); - expect(localStorage.getItem(dismissKey)).toBeNull(); - }); - }); });