diff --git a/static/app/components/events/autofix/autofixRootCause.spec.tsx b/static/app/components/events/autofix/autofixRootCause.spec.tsx index 802bb1e5530e60..889e5c5d9c8225 100644 --- a/static/app/components/events/autofix/autofixRootCause.spec.tsx +++ b/static/app/components/events/autofix/autofixRootCause.spec.tsx @@ -147,10 +147,10 @@ describe('AutofixRootCause', () => { await userEvent.click(dropdownTrigger); // Click the Cursor option in the dropdown - await userEvent.click(await screen.findByText('Send to Cursor Cloud Agent')); + await userEvent.click(await screen.findByText('Send to Cursor')); expect(JSON.parse(localStorage.getItem('autofix:rootCauseActionPreference')!)).toBe( - 'cursor_background_agent' + 'cursor:cursor-integration-id' ); }); @@ -204,13 +204,13 @@ describe('AutofixRootCause', () => { localStorage.setItem( 'autofix:rootCauseActionPreference', - JSON.stringify('cursor_background_agent') + JSON.stringify('cursor:cursor-integration-id') ); render(); expect( - await screen.findByRole('button', {name: 'Send to Cursor Cloud Agent'}) + await screen.findByRole('button', {name: 'Send to Cursor'}) ).toBeInTheDocument(); // Verify Seer option is in the dropdown @@ -249,6 +249,6 @@ describe('AutofixRootCause', () => { }); await userEvent.click(dropdownTrigger); - expect(await screen.findByText('Send to Cursor Cloud Agent')).toBeInTheDocument(); + expect(await screen.findByText('Send to Cursor')).toBeInTheDocument(); }); }); diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx index bfde47b781d4db..b67250b29d11bc 100644 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ b/static/app/components/events/autofix/autofixRootCause.tsx @@ -247,6 +247,149 @@ function CopyRootCauseButton({ ); } +type CodingAgentIntegration = { + id: string; + name: string; + provider: string; +}; + +function SolutionActionButton({ + cursorIntegrations, + preferredAction, + primaryButtonPriority, + isSelectingRootCause, + isLaunchingAgent, + isLoadingAgents, + submitFindSolution, + handleLaunchCodingAgent, + findSolutionTitle, +}: { + cursorIntegrations: CodingAgentIntegration[]; + findSolutionTitle: string; + handleLaunchCodingAgent: (integrationId: string, integrationName: string) => void; + isLaunchingAgent: boolean; + isLoadingAgents: boolean; + isSelectingRootCause: boolean; + preferredAction: string; + primaryButtonPriority: React.ComponentProps['priority']; + submitFindSolution: () => void; +}) { + const preferredIntegration = preferredAction.startsWith('cursor:') + ? cursorIntegrations.find(i => i.id === preferredAction.replace('cursor:', '')) + : null; + + const effectivePreference = + preferredAction === 'seer_solution' || !preferredIntegration + ? 'seer_solution' + : preferredAction; + + const isSeerPreferred = effectivePreference === 'seer_solution'; + + // Check if there are duplicate names among integrations (need to show ID to distinguish) + const hasDuplicateNames = + cursorIntegrations.length > 1 && + new Set(cursorIntegrations.map(i => i.name)).size < cursorIntegrations.length; + + // If no integrations, show simple Seer button + if (cursorIntegrations.length === 0) { + return ( + + ); + } + + const dropdownItems = [ + ...(isSeerPreferred + ? [] + : [ + { + key: 'seer_solution', + label: t('Find Solution with Seer'), + onAction: submitFindSolution, + disabled: isSelectingRootCause, + }, + ]), + // Show all integrations except the currently preferred one + ...cursorIntegrations + .filter(integration => `cursor:${integration.id}` !== effectivePreference) + .map(integration => ({ + key: `cursor:${integration.id}`, + label: ( + + +
{t('Send to %s', integration.name)}
+ {hasDuplicateNames && ( + ({integration.id}) + )} +
+ ), + onAction: () => handleLaunchCodingAgent(integration.id, integration.name), + disabled: isLoadingAgents || isLaunchingAgent, + })), + ]; + + const primaryButtonLabel = isSeerPreferred + ? t('Find Solution with Seer') + : hasDuplicateNames + ? t('Send to %s (%s)', preferredIntegration!.name, preferredIntegration!.id) + : t('Send to %s', preferredIntegration!.name); + + const primaryButtonProps = isSeerPreferred + ? { + onClick: submitFindSolution, + busy: isSelectingRootCause, + icon: undefined, + children: primaryButtonLabel, + } + : { + onClick: () => + handleLaunchCodingAgent(preferredIntegration!.id, preferredIntegration!.name), + busy: isLaunchingAgent, + icon: , + children: primaryButtonLabel, + }; + + return ( + + + ( + + ) : ( + + ) + } + /> + )} + /> + + ); +} + function AutofixRootCauseDisplay({ causes, groupId, @@ -276,9 +419,11 @@ function AutofixRootCauseDisplay({ runId ); - const [preferredAction, setPreferredAction] = useLocalStorageState< - 'seer_solution' | 'cursor_background_agent' - >('autofix:rootCauseActionPreference', 'seer_solution'); + // Stores 'seer_solution' or an integration ID (e.g., 'cursor:123') + const [preferredAction, setPreferredAction] = useLocalStorageState( + 'autofix:rootCauseActionPreference', + 'seer_solution' + ); const handleSelectDescription = () => { if (descriptionRef.current) { @@ -323,27 +468,29 @@ function AutofixRootCauseDisplay({ }); }; - // Find Cursor integration specifically - const cursorIntegration = codingAgentIntegrations.find( + const cursorIntegrations = codingAgentIntegrations.filter( integration => integration.provider === 'cursor' ); - const handleLaunchCodingAgent = () => { - if (!cursorIntegration) { + const handleLaunchCodingAgent = (integrationId: string, integrationName: string) => { + const targetIntegration = cursorIntegrations.find(i => i.id === integrationId); + + if (!targetIntegration) { return; } - // Save user preference - setPreferredAction('cursor_background_agent'); + // Save user preference with specific integration ID + setPreferredAction(`cursor:${integrationId}`); - // Show immediate loading toast - addLoadingMessage(t('Launching %s...', cursorIntegration.name), {duration: 60000}); + addLoadingMessage(t('Launching %s...', integrationName), { + duration: 60000, + }); const instruction = solutionText.trim(); launchCodingAgent({ - integrationId: cursorIntegration.id, - agentName: cursorIntegration.name, + integrationId: targetIntegration.id, + agentName: targetIntegration.name, triggerSource: 'root_cause', instruction: instruction || undefined, }); @@ -478,107 +625,17 @@ function AutofixRootCauseDisplay({ /> - {cursorIntegration ? ( - - {preferredAction === 'cursor_background_agent' ? ( - - - ( - - ) : ( - - ) - } - /> - )} - /> - - ) : ( - - - - -
{t('Send to Cursor Cloud Agent')}
- - ), - onAction: handleLaunchCodingAgent, - disabled: isLoadingAgents || isLaunchingAgent, - }, - ]} - trigger={(triggerProps, isOpen) => ( - - ) : ( - - ) - } - /> - )} - /> -
- )} -
- ) : ( - - )} +
{status === AutofixStatus.COMPLETED && ( @@ -697,3 +754,8 @@ const DropdownTrigger = styled(Button)` border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0; border-left: none; `; + +const SmallIntegrationIdText = styled('div')` + font-size: ${p => p.theme.fontSize.sm}; + color: ${p => p.theme.subText}; +`; diff --git a/static/app/views/settings/projectSeer/index.spec.tsx b/static/app/views/settings/projectSeer/index.spec.tsx index cd2efb3a047b73..2378edf81e39c7 100644 --- a/static/app/views/settings/projectSeer/index.spec.tsx +++ b/static/app/views/settings/projectSeer/index.spec.tsx @@ -904,5 +904,297 @@ describe('ProjectSeer', () => { ); }); }); + + it('shows integration selector when multiple cursor integrations exist', async () => { + MockApiClient.clearMockResponses(); + + const orgWithCursorFeature = OrganizationFixture({ + features: ['autofix-seer-preferences', 'integrations-cursor'], + }); + + const initialProject: Project = { + ...project, + autofixAutomationTuning: 'medium', + seerScannerAutomation: true, + }; + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, + method: 'GET', + body: { + setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true}, + billing: {hasAutofixQuota: true, hasScannerQuota: true}, + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/repos/`, + query: {status: 'active'}, + method: 'GET', + body: [], + }); + + // Mock multiple cursor integrations + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/integrations/coding-agents/`, + method: 'GET', + body: { + integrations: [ + { + id: '123', + name: 'Cursor - user1@example.com/api-key-1', + provider: 'cursor', + }, + { + id: '456', + name: 'Cursor - user2@example.com/api-key-2', + provider: 'cursor', + }, + ], + }, + }); + + // Mock preferences with automation_handoff using first integration + MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: false, + }, + }, + code_mapping_repos: [], + }, + }); + + MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/`, + method: 'PUT', + body: {}, + }); + + render(, { + organization: orgWithCursorFeature, + outletContext: {project: initialProject}, + }); + + // The integration selector should be visible with multiple integrations + const integrationSelect = await screen.findByRole('textbox', { + name: /Select Configuration/i, + }); + expect(integrationSelect).toBeInTheDocument(); + + // The auto-create PR toggle should also be visible + expect( + screen.getByRole('checkbox', {name: /Auto-Create Pull Requests/i}) + ).toBeInTheDocument(); + }); + + it('calls update mutation when switching integration', async () => { + MockApiClient.clearMockResponses(); + + const orgWithCursorFeature = OrganizationFixture({ + features: ['autofix-seer-preferences', 'integrations-cursor'], + }); + + const initialProject: Project = { + ...project, + autofixAutomationTuning: 'medium', + seerScannerAutomation: true, + }; + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, + method: 'GET', + body: { + setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true}, + billing: {hasAutofixQuota: true, hasScannerQuota: true}, + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/repos/`, + query: {status: 'active'}, + method: 'GET', + body: [], + }); + + // Mock multiple cursor integrations + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/integrations/coding-agents/`, + method: 'GET', + body: { + integrations: [ + { + id: '123', + name: 'Cursor - user1@example.com/api-key-1', + provider: 'cursor', + }, + { + id: '456', + name: 'Cursor - user2@example.com/api-key-2', + provider: 'cursor', + }, + ], + }, + }); + + // Mock preferences with automation_handoff using first integration + MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: false, + }, + }, + code_mapping_repos: [], + }, + }); + + MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/`, + method: 'PUT', + body: {}, + }); + + // Mock for the Form's empty apiEndpoint POST + MockApiClient.addMockResponse({ + url: '', + method: 'POST', + body: {}, + }); + + const seerPreferencesPostRequest = MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + }); + + render(, { + organization: orgWithCursorFeature, + outletContext: {project: initialProject}, + }); + + // Find and click the integration selector + const integrationSelect = await screen.findByRole('textbox', { + name: /Select Configuration/i, + }); + + act(() => { + integrationSelect.focus(); + }); + + await userEvent.click(integrationSelect); + + // Select the second integration + const secondIntegration = await screen.findByText( + 'Cursor - user2@example.com/api-key-2 (456)' + ); + await userEvent.click(secondIntegration); + + // Wait for the POST request to be called with the new integration ID + await waitFor(() => { + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + integration_id: 456, + }), + repositories: expect.any(Array), + }), + }) + ); + }); + }); + + it('does not show integration selector with single cursor integration', async () => { + MockApiClient.clearMockResponses(); + + const orgWithCursorFeature = OrganizationFixture({ + features: ['autofix-seer-preferences', 'integrations-cursor'], + }); + + const initialProject: Project = { + ...project, + autofixAutomationTuning: 'medium', + seerScannerAutomation: true, + }; + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, + method: 'GET', + body: { + setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true}, + billing: {hasAutofixQuota: true, hasScannerQuota: true}, + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/repos/`, + query: {status: 'active'}, + method: 'GET', + body: [], + }); + + // Mock single cursor integration + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/integrations/coding-agents/`, + method: 'GET', + body: { + integrations: [ + { + id: '123', + name: 'Cursor', + provider: 'cursor', + }, + ], + }, + }); + + // Mock preferences with automation_handoff + MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: false, + }, + }, + code_mapping_repos: [], + }, + }); + + render(, { + organization: orgWithCursorFeature, + outletContext: {project: initialProject}, + }); + + // Wait for the page to load + await screen.findByRole('checkbox', {name: /Auto-Create Pull Requests/i}); + + // The integration selector should NOT be visible with only one integration + expect( + screen.queryByRole('textbox', {name: /Select Configuration/i}) + ).not.toBeInTheDocument(); + }); }); }); diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx index 40221ea3488fbb..1d6711c11ba160 100644 --- a/static/app/views/settings/projectSeer/index.tsx +++ b/static/app/views/settings/projectSeer/index.tsx @@ -101,14 +101,24 @@ const autofixAutomationToggleField = { }), } satisfies FieldObject; +interface CursorIntegration { + id: string; + name: string; + provider: string; +} + function CodingAgentSettings({ preference, handleAutoCreatePrChange, + handleIntegrationChange, canWriteProject, isAutomationOn, + cursorIntegrations, }: { canWriteProject: boolean; + cursorIntegrations: CursorIntegration[]; handleAutoCreatePrChange: (value: boolean) => void; + handleIntegrationChange: (integrationId: number) => void; preference: ProjectSeerPreferences | null | undefined; isAutomationOn?: boolean; }) { @@ -116,36 +126,63 @@ function CodingAgentSettings({ return null; } - const initialValue = preference?.automation_handoff?.auto_create_pr ?? false; + const autoCreatePrValue = preference?.automation_handoff?.auto_create_pr ?? false; + const selectedIntegrationId = preference?.automation_handoff?.integration_id; + + const integrationOptions = cursorIntegrations.map(integration => ({ + value: integration.id, + label: `${integration.name} (${integration.id})`, + })); + + const fields: FieldObject[] = []; + + // Only show integration selector if there are multiple integrations + if (cursorIntegrations.length > 1) { + fields.push({ + name: 'integration_id', + label: t('Select Configuration'), + help: t( + 'You have multiple configurations installed. Select which one to use for hand off.' + ), + type: 'choice', + options: integrationOptions, + saveOnBlur: true, + getData: () => ({}), + getValue: () => String(selectedIntegrationId), + disabled: !canWriteProject, + onChange: (value: string) => handleIntegrationChange(parseInt(value, 10)), + } satisfies FieldObject); + } + + fields.push({ + name: 'auto_create_pr', + label: t('Auto-Create Pull Requests'), + help: t( + 'When enabled, Cursor Cloud Agents will automatically create pull requests after hand off.' + ), + saveOnBlur: true, + type: 'boolean', + getData: () => ({}), + getValue: () => autoCreatePrValue, + disabled: !canWriteProject, + onChange: handleAutoCreatePrChange, + } satisfies FieldObject); return (
({}), - getValue: () => initialValue, - disabled: !canWriteProject, - onChange: handleAutoCreatePrChange, - } satisfies FieldObject, - ], + fields, }, ]} /> @@ -163,9 +200,13 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { const isTriageSignalsFeatureOn = project.features.includes('triage-signals-v0'); const canWriteProject = hasEveryAccess(['project:read'], {organization, project}); - const cursorIntegration = codingAgentIntegrations?.integrations.find( - integration => integration.provider === 'cursor' - ); + const cursorIntegrations = + codingAgentIntegrations?.integrations.filter( + integration => integration.provider === 'cursor' + ) ?? []; + + // For backwards compatibility, use the first cursor integration as default + const cursorIntegration = cursorIntegrations[0]; const handleSubmitSuccess = useCallback( (resp: Project) => { @@ -230,6 +271,23 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { [preference, updateProjectSeerPreferences] ); + const handleIntegrationChange = useCallback( + (integrationId: number) => { + if (!preference?.automation_handoff) { + return; + } + updateProjectSeerPreferences({ + repositories: preference?.repositories || [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: { + ...preference.automation_handoff, + integration_id: integrationId, + }, + }); + }, + [preference, updateProjectSeerPreferences] + ); + const automatedRunStoppingPointField = { name: 'automated_run_stopping_point', label: t('Where should Seer stop?'), @@ -368,7 +426,9 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { preference={preference} handleAutoCreatePrChange={handleAutoCreatePrChange} isAutomationOn={automationTuning && automationTuning !== 'off'} + handleIntegrationChange={handleIntegrationChange} canWriteProject={canWriteProject} + cursorIntegrations={cursorIntegrations} /> );