diff --git a/src/sentry/dashboards/endpoints/organization_dashboard_generate.py b/src/sentry/dashboards/endpoints/organization_dashboard_generate.py
index ad7ad69302a788..3d8a68d5cc4de4 100644
--- a/src/sentry/dashboards/endpoints/organization_dashboard_generate.py
+++ b/src/sentry/dashboards/endpoints/organization_dashboard_generate.py
@@ -27,13 +27,17 @@
logger = logging.getLogger(__name__)
TRACE_METRICS_GUIDANCE = """When generating widgets with `widget_type: "tracemetrics"`:
-- Aggregates use a multi-argument form: `func(attribute, metric_name, metric_type)`.
+- Aggregates use a required 4-argument form: `func(attribute, metric_name, metric_type, metric_unit)`.
- `attribute` must be `value` (the numeric value of the metric); no other attributes are supported at this time.
- `metric_name` is the metric's name as ingested (e.g. `my.app.latency`).
- `metric_type` is exactly one of `counter`, `gauge`, or `distribution`.
-- Examples: `count(value, my.app.requests, counter)`, `avg(value, my.app.cpu, gauge)`, `p95(value, my.app.latency, distribution)`.
-- Single-argument forms like `p50(my.metric)` are INVALID for tracemetrics.
-- Before emitting a tracemetrics widget you MUST look up the metric's `metric_type` using available tools (e.g. by querying the tracemetrics dataset for distinct `metric.name`/`metric.type` values, or fetching trace-item attributes). Do NOT guess the type — if you cannot confirm it, pick a different dataset or omit the widget."""
+ - `metric_unit` is the metric's unit as ingested (e.g. `milliseconds`, `bytes`). Use `none` only when the metric has no unit.
+- Each `metric_type` only accepts a specific set of aggregate functions. Using a function not listed for the metric's type will fail:
+ - `counter`: `sum`, `per_second`, `per_minute`.
+ - `gauge`: `avg`, `min`, `max`, `per_second`, `per_minute`.
+ - `distribution`: `p50`, `p75`, `p90`, `p95`, `p99`, `avg`, `min`, `max`, `sum`, `count`, `per_second`, `per_minute`.
+- Examples: `sum(value, my.app.requests, counter, none)`, `avg(value, my.app.cpu, gauge, percent)`, `p95(value, my.app.latency, distribution, milliseconds)`.
+- Before emitting a tracemetrics widget you MUST look up the metric's `metric_type` AND `metric_unit` using available tools (e.g. by querying the tracemetrics dataset for distinct `metric.name`/`metric.type`/`metric.unit` values, or fetching trace-item attributes). Do NOT guess the type or unit — if you cannot confirm both, pick a different dataset or omit the widget."""
CREATE_ON_PAGE_CONTEXT = (
"The user is on the dashboard generation page. This session must ONLY generate a dashboard "
diff --git a/src/sentry/dashboards/models/generate_dashboard_artifact.py b/src/sentry/dashboards/models/generate_dashboard_artifact.py
index 26836ec51f89e7..af46ff349d33ad 100644
--- a/src/sentry/dashboards/models/generate_dashboard_artifact.py
+++ b/src/sentry/dashboards/models/generate_dashboard_artifact.py
@@ -54,20 +54,25 @@ class GeneratedWidgetQuery(BaseModel):
"values; for table widgets they become data columns alongside columns[]. Valid "
"aggregate function values vary by dataset type. Do not make up functions or use "
"unsupported functions.\n\n"
- "For the 'tracemetrics' widget_type, aggregates have a SPECIAL multi-argument form: "
- "`func(attribute, metric_name, metric_type)` where attribute must be `value` "
- "(the numeric value of the metric; no other attributes are supported at this time), "
- "metric_name is the metric's name as ingested, and "
- "metric_type is one of 'counter', 'gauge', or 'distribution'. Examples: "
- "`count(value, my.app.requests, counter)`, "
- "`avg(value, my.app.cpu, gauge)`, "
- "`p95(value, my.app.latency, distribution)`. "
- "Allowed functions for tracemetrics: count, count_unique, sum, avg, max, min, "
- "p50, p75, p90, p95, p99. The single-argument form like `p50(my.metric)` is INVALID "
- "for tracemetrics — the metric_name and metric_type MUST be passed as separate "
- "positional arguments. You MUST NOT guess the metric_name or metric_type; look them "
- "up first using the available tools (e.g. by querying the tracemetrics dataset for "
- "distinct `metric.name` and `metric.type` values, or fetching trace-item attributes)."
+ "For the 'tracemetrics' widget_type, aggregates use a required 4-argument form: "
+ "`func(attribute, metric_name, metric_type, metric_unit)` where attribute must be "
+ "`value` (the numeric value of the metric; no other attributes are supported at this "
+ "time), metric_name is the metric's name as ingested, metric_type is one of "
+ "'counter', 'gauge', or 'distribution', and metric_unit is the metric's unit as "
+ "ingested (e.g. 'milliseconds', 'bytes'); use 'none' only when the metric has no "
+ "unit. Examples: `sum(value, my.app.requests, counter, none)`, "
+ "`avg(value, my.app.cpu, gauge, percent)`, "
+ "`p95(value, my.app.latency, distribution, milliseconds)`. "
+ "Each metric_type only accepts a specific set of aggregate functions, and using a "
+ "function outside that set will fail:\n"
+ "- counter: sum, per_second, per_minute.\n"
+ "- gauge: avg, min, max, per_second, per_minute.\n"
+ "- distribution: p50, p75, p90, p95, p99, avg, min, max, sum, count, per_second, "
+ "per_minute.\n"
+ "You MUST NOT guess metric_name, metric_type, or metric_unit; look them up first "
+ "using the available tools (e.g. by querying the tracemetrics dataset for distinct "
+ "`metric.name`, `metric.type`, and `metric.unit` values, or fetching trace-item "
+ "attributes)."
),
)
columns: list[str] = Field(
diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py
index b07b19671fe7db..127c03cce2ca24 100644
--- a/src/sentry/features/temporary.py
+++ b/src/sentry/features/temporary.py
@@ -272,6 +272,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:seer-explorer-context-engine-fe-override-ui-flag", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable code editing tools in Seer Agent chat
manager.add("organizations:seer-explorer-chat-coding", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
+ # Enable sentry source code search tool
+ manager.add("organizations:seer-agent-source-code-search", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable code mode tools (sentry_api_search/execute) in Seer Agent
manager.add("organizations:seer-explorer-code-mode-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable code mode tools for Slack-initiated Explorer sessions
diff --git a/src/sentry/seer/agent/client.py b/src/sentry/seer/agent/client.py
index 0cb121ec587206..4236f93c720d52 100644
--- a/src/sentry/seer/agent/client.py
+++ b/src/sentry/seer/agent/client.py
@@ -364,6 +364,13 @@ def start_run(
):
chat_body["is_context_engine_enabled"] = override_ce_enable
+ if features.has(
+ "organizations:seer-agent-source-code-search",
+ self.organization,
+ actor=self.user,
+ ):
+ chat_body["enable_frontend_code_search"] = True
+
if features.has("organizations:seer-run-mirror-explorer", self.organization):
user_id = (
self.user.id
@@ -542,6 +549,13 @@ def continue_run(
if _has_context_engine(self.organization, self.user):
chat_body["is_context_engine_enabled"] = True
+ if features.has(
+ "organizations:seer-agent-source-code-search",
+ self.organization,
+ actor=self.user,
+ ):
+ chat_body["enable_frontend_code_search"] = True
+
response = make_agent_chat_request(chat_body, viewer_context=self.viewer_context)
if response.status >= 400:
diff --git a/src/sentry/seer/agent/client_utils.py b/src/sentry/seer/agent/client_utils.py
index 2faa0c919307d9..07b9e3d2eb584d 100644
--- a/src/sentry/seer/agent/client_utils.py
+++ b/src/sentry/seer/agent/client_utils.py
@@ -73,6 +73,7 @@ class AgentChatRequest(TypedDict):
category_value: NotRequired[str]
metadata: NotRequired[dict[str, Any]]
is_context_engine_enabled: NotRequired[bool]
+ enable_frontend_code_search: NotRequired[bool]
max_iterations: NotRequired[int]
proxy_headers: NotRequired[dict[str, str] | None]
ui_tools: NotRequired[str | None]
diff --git a/src/sentry/seer/endpoints/organization_seer_rpc.py b/src/sentry/seer/endpoints/organization_seer_rpc.py
index c67aad92c4e82d..004ec836a38c21 100644
--- a/src/sentry/seer/endpoints/organization_seer_rpc.py
+++ b/src/sentry/seer/endpoints/organization_seer_rpc.py
@@ -64,6 +64,7 @@
get_attributes_and_values,
get_attributes_for_span,
get_github_enterprise_integration_config,
+ get_organization_features,
get_organization_project_ids,
get_organization_slug,
has_repo_code_mappings,
@@ -87,6 +88,7 @@
# Common to Seer features
"get_organization_project_ids": map_org_id_param(get_organization_project_ids),
"get_organization_slug": map_org_id_param(get_organization_slug),
+ "get_organization_features": map_org_id_param(get_organization_features),
"validate_repo": validate_repo,
"get_github_enterprise_integration_config": get_github_enterprise_integration_config,
#
diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py
index 505cf436cc24cc..54133bedb7843b 100644
--- a/src/sentry/seer/endpoints/seer_rpc.py
+++ b/src/sentry/seer/endpoints/seer_rpc.py
@@ -36,6 +36,7 @@
from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue, StrArray
from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter
+from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.authentication import AuthenticationSiloLimit, StandardAuthentication
@@ -44,6 +45,7 @@
from sentry.api.utils import get_date_range_from_params
from sentry.constants import ObjectStatus
from sentry.exceptions import InvalidSearchQuery
+from sentry.features.base import OrganizationFeature
from sentry.hybridcloud.rpc.service import RpcAuthenticationSetupException, RpcResolutionException
from sentry.hybridcloud.rpc.sig import SerializableFunctionValueException
from sentry.integrations.github_enterprise.integration import GitHubEnterpriseIntegration
@@ -124,6 +126,7 @@
from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization
from sentry.silo.base import SiloMode
from sentry.snuba.referrer import Referrer
+from sentry.users.services.user.service import user_service
from sentry.utils import snuba_rpc
from sentry.utils.env import in_test_environment
from sentry.utils.snuba_rpc import SnubaRPCRateLimitExceeded
@@ -317,6 +320,47 @@ def get_organization_project_ids(*, org_id: int) -> dict:
return {"projects": projects}
+_ORGANIZATION_SCOPE_PREFIX = "organizations:"
+
+
+def get_organization_features(*, org_id: int, user_id: int | None = None) -> dict[str, list[str]]:
+ try:
+ organization = Organization.objects.get(id=org_id)
+ except Organization.DoesNotExist:
+ return {"features": []}
+
+ actor = user_service.get_user(user_id=user_id) if user_id is not None else None
+
+ features_to_check = {
+ feature
+ for feature in features.all(feature_type=OrganizationFeature, api_expose_only=True).keys()
+ if feature.startswith(_ORGANIZATION_SCOPE_PREFIX)
+ }
+
+ feature_set: set[str] = set()
+
+ with sentry_sdk.start_span(op="features.check", name="check batch features"):
+ batch = features.batch_has(
+ list(features_to_check),
+ actor=actor,
+ organization=organization,
+ skip_experiment_exposure=True,
+ )
+
+ if batch:
+ for name, active in batch.get(f"organization:{organization.id}", {}).items():
+ if active:
+ feature_set.add(name[len(_ORGANIZATION_SCOPE_PREFIX) :])
+ features_to_check.discard(name)
+
+ with sentry_sdk.start_span(op="features.check", name="check individual features"):
+ for name in features_to_check:
+ if features.has(name, organization, actor=actor, skip_entity=True):
+ feature_set.add(name[len(_ORGANIZATION_SCOPE_PREFIX) :])
+
+ return {"features": list(sorted(feature_set))}
+
+
class SentryOrganizaionIdsAndSlugs(TypedDict):
org_ids: list[int]
org_slugs: list[str]
@@ -917,6 +961,7 @@ def bulk_get_project_preferences(
# Common to Seer features
"get_github_enterprise_integration_config": get_github_enterprise_integration_config,
"get_organization_project_ids": get_organization_project_ids,
+ "get_organization_features": get_organization_features,
"check_repository_integrations_status": check_repository_integrations_status,
"validate_repo": validate_repo,
"get_repo_installation_id": get_repo_installation_id,
diff --git a/static/app/components/charts/baseChart.tsx b/static/app/components/charts/baseChart.tsx
index 84b61984d24f19..9d41ca139851f8 100644
--- a/static/app/components/charts/baseChart.tsx
+++ b/static/app/components/charts/baseChart.tsx
@@ -764,6 +764,11 @@ const getTooltipStyles = (p: {theme: Theme}) => css`
justify-content: flex-start;
align-items: baseline;
}
+ .tooltip-label-centered {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
.tooltip-code-no-margin {
padding-left: 0;
margin-left: 0;
diff --git a/static/app/components/charts/components/tooltip.tsx b/static/app/components/charts/components/tooltip.tsx
index b90d8c92a2d2b6..030b14e35468a4 100644
--- a/static/app/components/charts/components/tooltip.tsx
+++ b/static/app/components/charts/components/tooltip.tsx
@@ -243,21 +243,6 @@ export function getFormatter({
serie
);
- if (serie.seriesType === 'heatmap') {
- const zAxisCountValue = (getSeriesValue(serie, 2) ?? 0).toString();
- const yAxisValue = valueFormatter(
- getSeriesValue(serie, 1),
- serie.seriesName,
- serie
- );
-
- acc.series.push(
- `
${yAxisValue} ${zAxisCountValue}
`
- );
-
- return acc;
- }
-
const value = valueFormatter(getSeriesValue(serie, 1), serie.seriesName, serie);
const marker = markerFormatter(serie.marker ?? '', serie.seriesName);
diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx
index f14541a03d140b..8c597a30dd056c 100644
--- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx
+++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx
@@ -333,14 +333,12 @@ export function GlobalCommandPaletteActions() {
to={`${prefix}/issues/views/${starredView.id}/`}
/>
))}
- {organization.features.includes('autofix-on-explorer') && (
-
-
-
- )}
+
+
+
}} limit={4}>
diff --git a/static/app/components/events/autofix/FlyingLinesEffect.tsx b/static/app/components/events/autofix/FlyingLinesEffect.tsx
deleted file mode 100644
index 4f9dcb60179983..00000000000000
--- a/static/app/components/events/autofix/FlyingLinesEffect.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-import {useLayoutEffect, useRef, useState} from 'react';
-import {createPortal} from 'react-dom';
-import {keyframes} from '@emotion/react';
-import styled from '@emotion/styled';
-
-export function FlyingLinesEffect({targetElement}: {targetElement: HTMLElement | null}) {
- const [position, setPosition] = useState({left: 0, top: 0});
- const portalContainerRef = useRef(null);
- const rafRef = useRef(null);
- const lastUpdateRef = useRef(0);
- const THROTTLE_MS = 16;
-
- useLayoutEffect(() => {
- if (!targetElement) {
- return;
- }
-
- function getScrollParents(element: HTMLElement): Element[] {
- const scrollParents: Element[] = [];
- let currentElement = element.parentElement;
-
- while (currentElement) {
- const overflow = window.getComputedStyle(currentElement).overflow;
- if (overflow.includes('scroll') || overflow.includes('auto')) {
- scrollParents.push(currentElement);
- }
- currentElement = currentElement.parentElement;
- }
-
- return scrollParents;
- }
-
- const updatePosition = () => {
- const now = Date.now();
- if (now - lastUpdateRef.current < THROTTLE_MS) {
- rafRef.current = requestAnimationFrame(updatePosition);
- return;
- }
-
- const rect = targetElement.getBoundingClientRect();
- const left = rect.left + rect.width / 2;
- const top = rect.top + rect.height / 2;
- setPosition({left, top});
- lastUpdateRef.current = now;
- rafRef.current = requestAnimationFrame(updatePosition);
- };
-
- // Create portal container if it doesn't exist
- if (!portalContainerRef.current) {
- portalContainerRef.current = document.createElement('div');
- document.body.appendChild(portalContainerRef.current);
- }
-
- rafRef.current = requestAnimationFrame(updatePosition);
-
- const scrollElements = [window, ...getScrollParents(targetElement)];
- scrollElements.forEach(element => {
- element.addEventListener('scroll', updatePosition, {passive: true});
- });
-
- window.addEventListener('resize', updatePosition, {passive: true});
-
- const resizeObserver = new ResizeObserver(updatePosition);
- resizeObserver.observe(targetElement);
-
- return () => {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- }
- scrollElements.forEach(element => {
- element.removeEventListener('scroll', updatePosition);
- });
- window.removeEventListener('resize', updatePosition);
- resizeObserver.disconnect();
-
- // Clean up portal container
- if (portalContainerRef.current) {
- document.body.removeChild(portalContainerRef.current);
- portalContainerRef.current = null;
- }
- };
- }, [targetElement]);
-
- if (!targetElement || !portalContainerRef.current) {
- return null;
- }
-
- return createPortal(
-
-
-
-
- ,
- portalContainerRef.current
- );
-}
-
-const flyingLines = keyframes`
- 0% {
- transform: scale(1.5);
- opacity: 0;
- }
- 50% {
- opacity: 0.7;
- }
- 100% {
- transform: scale(0);
- opacity: 0;
- }
-`;
-
-const AdditionalLine = styled('div')<{
- delay: number;
- rotation?: number;
- variant?: 'leftColored' | 'rightColored';
-}>`
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 50%;
- border: 2px solid transparent;
- border-top-color: ${p => p.theme.tokens.border.secondary};
- border-bottom-color: ${p => p.theme.tokens.border.secondary};
- border-left-color: ${p =>
- p.variant === 'leftColored' ? p.theme.tokens.border.secondary : 'transparent'};
- border-right-color: ${p =>
- p.variant === 'rightColored' ? p.theme.tokens.border.secondary : 'transparent'};
- animation: ${flyingLines} 1s linear infinite;
- animation-delay: ${p => p.delay}s;
- transform: ${p => (p.rotation ? `rotate(${p.rotation}deg)` : 'none')};
-`;
-
-const FlyingLinesContainer = styled('div')`
- position: fixed;
- width: 50px;
- height: 50px;
- transform: translate(-50%, -50%);
- z-index: ${p => p.theme.zIndex.tooltip};
- opacity: 0.5;
- pointer-events: none;
-
- &:before,
- &:after {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 50%;
- border: 2px solid transparent;
- border-top-color: ${p => p.theme.tokens.border.secondary};
- border-bottom-color: ${p => p.theme.tokens.border.secondary};
- animation: ${flyingLines} 1s linear infinite;
- }
-
- &:before {
- border-left-color: ${p => p.theme.tokens.border.secondary};
- border-right-color: transparent;
- animation-delay: -0.4s;
- }
-
- &:after {
- border-left-color: transparent;
- border-right-color: ${p => p.theme.tokens.border.secondary};
- animation-delay: -0.2s;
- }
-`;
diff --git a/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx b/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx
deleted file mode 100644
index 9c2ac387b69d60..00000000000000
--- a/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx
+++ /dev/null
@@ -1,278 +0,0 @@
-import {AutofixCodebaseChangeData} from 'sentry-fixture/autofixCodebaseChangeData';
-import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture';
-import {AutofixStepFixture} from 'sentry-fixture/autofixStep';
-
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
-
-import {Button} from '@sentry/scraps/button';
-
-import {AutofixChanges} from 'sentry/components/events/autofix/autofixChanges';
-import {
- AutofixStatus,
- AutofixStepType,
- type AutofixChangesStep,
-} from 'sentry/components/events/autofix/types';
-import {
- useAutofixData,
- useAutofixRepos,
-} from 'sentry/components/events/autofix/useAutofix';
-
-jest.mock('@sentry/scraps/button', () => ({
- Button: jest.fn(props => {
- // Forward the click handler while allowing us to inspect props
- return {props.children} ;
- }),
- LinkButton: jest.fn(props => {
- return {props.children} ;
- }),
- ButtonBar: jest.fn(props => {
- return {props.children}
;
- }),
-}));
-
-jest.mock('sentry/components/events/autofix/useAutofix');
-
-const mockButton = jest.mocked(Button);
-
-describe('AutofixChanges', () => {
- const defaultProps = {
- groupId: '123',
- runId: '456',
- step: AutofixStepFixture({
- type: AutofixStepType.CHANGES,
- changes: [AutofixCodebaseChangeData()],
- }) as AutofixChangesStep,
- } satisfies React.ComponentProps;
-
- beforeEach(() => {
- MockApiClient.clearMockResponses();
- mockButton.mockClear();
- jest.mocked(useAutofixRepos).mockReset();
- jest.mocked(useAutofixData).mockReset();
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [],
- codebases: {},
- });
- jest.mocked(useAutofixData).mockReturnValue({
- data: {
- request: {
- repos: [],
- },
- codebases: {},
- last_triggered_at: '2024-01-01T00:00:00Z',
- run_id: '456',
- status: AutofixStatus.COMPLETED,
- },
- isPending: false,
- });
- });
-
- it('passes correct analytics props for Create PR button when write access is enabled', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true',
- method: 'GET',
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
-
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- body: {ok: true},
- });
-
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'org/repo',
- owner: 'org',
- provider: 'github',
- provider_raw: 'github',
- external_id: '100',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {
- '100': {
- repo_external_id: '100',
- is_readable: true,
- is_writeable: true,
- },
- },
- });
-
- render( );
- await userEvent.click(screen.getByRole('button', {name: 'Draft PR'}));
-
- const createPRButtonCall = mockButton.mock.calls.find(
- call => call[0]?.analyticsEventKey === 'autofix.create_pr_clicked'
- );
- expect(createPRButtonCall?.[0]).toEqual(
- expect.objectContaining({
- analyticsEventKey: 'autofix.create_pr_clicked',
- analyticsEventName: 'Autofix: Create PR Clicked',
- analyticsParams: {group_id: '123'},
- })
- );
- });
-
- it('passes correct analytics props for Create PR Setup button when write access is not enabled', () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true',
- method: 'GET',
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {
- ok: true,
- repos: [{ok: false, owner: 'owner', name: 'hello-world', provider: 'github'}],
- },
- }),
- });
-
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'org/repo',
- owner: 'org',
- provider: 'github',
- provider_raw: 'github',
- external_id: 'repo-123',
- is_readable: true,
- is_writeable: false,
- },
- ],
- codebases: {
- 'repo-123': {
- repo_external_id: 'repo-123',
- is_readable: true,
- is_writeable: false,
- },
- },
- });
-
- render( );
-
- // Find the last call to Button that matches our Setup button
- const setupButtonCall = mockButton.mock.calls.find(
- call => call[0].children === 'Draft PR'
- );
- expect(setupButtonCall?.[0]).toEqual(
- expect.objectContaining({
- analyticsEventKey: 'autofix.create_pr_setup_clicked',
- analyticsEventName: 'Autofix: Create PR Setup Clicked',
- analyticsParams: {
- group_id: '123',
- },
- })
- );
- });
-
- it('passes correct analytics props for Create Branch button when write access is enabled', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true',
- method: 'GET',
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {
- ok: true,
- repos: [{ok: true, owner: 'owner', name: 'hello-world', provider: 'github'}],
- },
- }),
- });
-
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- body: {ok: true},
- });
-
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'org/repo',
- owner: 'org',
- provider: 'github',
- provider_raw: 'github',
- external_id: '100',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {
- '100': {
- repo_external_id: '100',
- is_readable: true,
- is_writeable: true,
- },
- },
- });
-
- render( );
-
- await userEvent.click(screen.getByRole('button', {name: 'Check Out Locally'}));
-
- const createBranchButtonCall = mockButton.mock.calls.find(
- call => call[0]?.analyticsEventKey === 'autofix.push_to_branch_clicked'
- );
- expect(createBranchButtonCall?.[0]).toEqual(
- expect.objectContaining({
- analyticsEventKey: 'autofix.push_to_branch_clicked',
- analyticsEventName: 'Autofix: Push to Branch Clicked',
- analyticsParams: {group_id: '123'},
- })
- );
- });
-
- it('passes correct analytics props for Create Branch Setup button when write access is not enabled', () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true',
- method: 'GET',
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {
- ok: true,
- repos: [{ok: false, owner: 'owner', name: 'hello-world', provider: 'github'}],
- },
- }),
- });
-
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'org/repo',
- owner: 'org',
- provider: 'github',
- provider_raw: 'github',
- external_id: 'repo-123',
- is_readable: true,
- is_writeable: false,
- },
- ],
- codebases: {
- 'repo-123': {
- repo_external_id: 'repo-123',
- is_readable: true,
- is_writeable: false,
- },
- },
- });
-
- render( );
-
- const setupButtonCall = mockButton.mock.calls.find(
- call => call[0].children === 'Check Out Locally'
- );
- expect(setupButtonCall?.[0]).toEqual(
- expect.objectContaining({
- analyticsEventKey: 'autofix.create_branch_setup_clicked',
- analyticsEventName: 'Autofix: Create Branch Setup Clicked',
- analyticsParams: {
- group_id: '123',
- },
- })
- );
- });
-});
diff --git a/static/app/components/events/autofix/autofixChanges.spec.tsx b/static/app/components/events/autofix/autofixChanges.spec.tsx
deleted file mode 100644
index f9591f372c9090..00000000000000
--- a/static/app/components/events/autofix/autofixChanges.spec.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import {AutofixCodebaseChangeData} from 'sentry-fixture/autofixCodebaseChangeData';
-
-import {render, screen} from 'sentry-test/reactTestingLibrary';
-
-import {AutofixChanges} from './autofixChanges';
-import {AutofixStatus, AutofixStepType} from './types';
-
-const mockUseAutofix = jest.fn();
-jest.mock('sentry/components/events/autofix/useAutofix', () => ({
- ...jest.requireActual('sentry/components/events/autofix/useAutofix'),
- useAutofixData: () => mockUseAutofix(),
-}));
-
-const mockUseAutofixSetup = jest.fn();
-jest.mock('sentry/components/events/autofix/useAutofixSetup', () => ({
- useAutofixSetup: () => mockUseAutofixSetup(),
-}));
-
-const mockUpdateInsightCard = jest.fn();
-jest.mock('sentry/components/events/autofix/hooks/useUpdateInsightCard', () => ({
- useUpdateInsightCard: () => ({
- mutate: mockUpdateInsightCard,
- }),
-}));
-
-describe('AutofixChanges', () => {
- const defaultProps = {
- groupId: '123',
- runId: 'run-123',
- step: {
- id: 'step-123',
- progress: [],
- title: 'Changes',
- type: AutofixStepType.CHANGES as const,
- index: 0,
- status: AutofixStatus.COMPLETED,
- changes: [AutofixCodebaseChangeData({pull_request: undefined})],
- },
- } satisfies React.ComponentProps;
-
- beforeEach(() => {
- mockUseAutofix.mockReturnValue({
- status: 'COMPLETED',
- steps: [
- {
- type: AutofixStepType.DEFAULT,
- index: 0,
- insights: [],
- },
- ],
- });
-
- mockUseAutofixSetup.mockReturnValue({
- data: {
- githubWriteIntegration: {
- repos: [
- {
- owner: 'getsentry',
- name: 'sentry',
- ok: true,
- },
- ],
- },
- },
- });
- });
-
- it('renders error state when step has error', () => {
- render(
-
- );
-
- expect(screen.getByText('Something went wrong.')).toBeInTheDocument();
- });
-
- it('renders empty state when no changes', () => {
- render(
-
- );
-
- expect(
- screen.getByText('Seer had trouble applying its code changes.')
- ).toBeInTheDocument();
- });
-
- it('renders changes with action buttons', () => {
- render( );
-
- expect(screen.getByText('Code Changes')).toBeInTheDocument();
- expect(screen.getByText('Add error handling')).toBeInTheDocument();
- expect(screen.getByText('owner/hello-world')).toBeInTheDocument();
-
- expect(screen.getByRole('button', {name: 'Check Out Locally'})).toBeInTheDocument();
- expect(screen.getByRole('button', {name: 'Draft PR'})).toBeInTheDocument();
- });
-
- it('shows PR links when PRs are created', () => {
- const changeWithPR = AutofixCodebaseChangeData({
- pull_request: {
- pr_number: 123,
- pr_url: 'https://github.com/owner/hello-world/pull/123',
- },
- });
-
- render(
-
- );
-
- expect(
- screen.getByRole('button', {name: 'View PR in owner/hello-world'})
- ).toBeInTheDocument();
- });
-
- it('shows branch checkout buttons when branches are created', () => {
- const changeWithBranch = AutofixCodebaseChangeData({
- branch_name: 'fix/issue-123',
- pull_request: undefined,
- });
-
- render(
-
- );
-
- expect(
- screen.getByRole('button', {name: 'Copy branch in owner/hello-world'})
- ).toBeInTheDocument();
- });
-});
diff --git a/static/app/components/events/autofix/autofixChanges.tsx b/static/app/components/events/autofix/autofixChanges.tsx
deleted file mode 100644
index cc449251ba8ed2..00000000000000
--- a/static/app/components/events/autofix/autofixChanges.tsx
+++ /dev/null
@@ -1,766 +0,0 @@
-import {Fragment, useEffect, useMemo, useRef, useState} from 'react';
-import styled from '@emotion/styled';
-import {useMutation, useQueryClient} from '@tanstack/react-query';
-import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion';
-
-import {Alert} from '@sentry/scraps/alert';
-import {Button, LinkButton} from '@sentry/scraps/button';
-import {Flex, Grid} from '@sentry/scraps/layout';
-import {useModal} from '@sentry/scraps/modal';
-
-import {addErrorMessage} from 'sentry/actionCreators/indicator';
-import {ClippedBox} from 'sentry/components/clippedBox';
-import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
-import {AutofixHighlightPopup} from 'sentry/components/events/autofix/autofixHighlightPopup';
-import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper';
-import {replaceHeadersWithBold} from 'sentry/components/events/autofix/autofixRootCause';
-import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
-import {AutofixStepFeedback} from 'sentry/components/events/autofix/autofixStepFeedback';
-import {
- AutofixStatus,
- type AutofixChangesStep,
- type AutofixCodebaseChange,
- type CommentThread,
-} from 'sentry/components/events/autofix/types';
-import {
- autofixApiOptions,
- useAutofixData,
- useAutofixRepos,
-} from 'sentry/components/events/autofix/useAutofix';
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
-import {ScrollCarousel} from 'sentry/components/scrollCarousel';
-import {IconChat, IconCode, IconCopy, IconOpen} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {singleLineRenderer} from 'sentry/utils/marked/marked';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-import {useApi} from 'sentry/utils/useApi';
-import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-type AutofixChangesProps = {
- groupId: string;
- runId: string;
- step: AutofixChangesStep;
- agentCommentThread?: CommentThread;
- isChangesFirstAppearance?: boolean;
- previousDefaultStepIndex?: number;
- previousInsightCount?: number;
-};
-
-function AutofixRepoChange({
- change,
- groupId,
- runId,
- previousDefaultStepIndex,
- previousInsightCount,
- ref,
-}: {
- change: AutofixCodebaseChange;
- groupId: string;
- runId: string;
- previousDefaultStepIndex?: number;
- previousInsightCount?: number;
- ref?: React.RefObject;
-}) {
- const changeDescriptionHtml = useMemo(() => {
- return {
- __html: singleLineRenderer(change.description),
- };
- }, [change.description]);
-
- return (
-
-
-
-
= 0
- ? previousInsightCount
- : null
- }
- >
-
-
{change.repo_name}
-
{change.title}
-
-
-
-
-
-
-
- );
-}
-
-const cardAnimationProps: MotionNodeAnimationOptions = {
- exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
- initial: {opacity: 0, height: 0, scale: 0.8},
- animate: {opacity: 1, height: 'auto', scale: 1},
- transition: {
- duration: 1,
- height: {
- type: 'spring',
- bounce: 0.2,
- },
- scale: {
- type: 'spring',
- bounce: 0.2,
- },
- y: {
- type: 'tween',
- ease: 'easeOut',
- },
- },
-};
-
-function BranchButton({change}: {change: AutofixCodebaseChange}) {
- const {copy} = useCopyToClipboard();
-
- return (
-
-
- copy(change.branch_name ?? '', {
- successMessage: t('Branch name copied to clipboard.'),
- })
- }
- icon={ }
- aria-label={t('Copy branch in %s', change.repo_name)}
- tooltipProps={{title: t('Copy branch in %s', change.repo_name)}}
- />
- {change.branch_name}
-
- );
-}
-
-const CopyContainer = styled('div')`
- display: inline-flex;
- align-items: stretch;
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- background: ${p => p.theme.tokens.background.secondary};
- max-width: 25rem;
- min-width: 0;
- flex: 1;
- flex-shrink: 1;
-`;
-
-const CopyButton = styled(Button)`
- border: none;
- border-radius: ${p => p.theme.radius.md} 0 0 ${p => p.theme.radius.md};
- border-right: 1px solid ${p => p.theme.tokens.border.primary};
- height: auto;
- flex-shrink: 0;
-`;
-
-const CodeText = styled('code')`
- font-family: ${p => p.theme.font.family.mono};
- padding: ${p => p.theme.space.xs} ${p => p.theme.space.md};
- font-size: ${p => p.theme.font.size.sm};
- display: block;
- min-width: 0;
- width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-`;
-
-export function AutofixChanges({
- step,
- groupId,
- runId,
- previousDefaultStepIndex,
- previousInsightCount,
- agentCommentThread,
- isChangesFirstAppearance,
-}: AutofixChangesProps) {
- const {data} = useAutofixData({groupId});
- const isBusy = step.status === AutofixStatus.PROCESSING;
- const iconCodeRef = useRef(null);
- const firstChangeRef = useRef(null);
- const [isPrProcessing, setIsPrProcessing] = useState(false);
- const [isBranchProcessing, setIsBranchProcessing] = useState(false);
-
- const handleSelectFirstChange = () => {
- if (firstChangeRef.current) {
- // Simulate a click on the first change to trigger the text selection
- const clickEvent = new MouseEvent('click', {
- bubbles: true,
- cancelable: true,
- view: window,
- });
- firstChangeRef.current.dispatchEvent(clickEvent);
- }
- };
-
- useEffect(() => {
- if (step.status === AutofixStatus.COMPLETED) {
- const prsNowExist =
- step.changes.length > 0 && step.changes.every(c => c.pull_request);
- const branchesNowExist =
- step.changes.length > 0 && step.changes.every(c => c.branch_name);
-
- if (prsNowExist) {
- setIsPrProcessing(false);
- }
- if (branchesNowExist) {
- setIsBranchProcessing(false);
- }
- }
- }, [step.status, step.changes]);
-
- if (step.status === 'ERROR' || data?.status === 'ERROR') {
- return (
-
-
- {data?.error_message ? (
-
- {t('Something went wrong')}
- {data.error_message}
-
- ) : (
- {t('Something went wrong.')}
- )}
-
-
- );
- }
-
- if (!step.changes.length) {
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-
- const prsMade =
- step.status === AutofixStatus.COMPLETED &&
- step.changes.length >= 1 &&
- step.changes.every(change => change.pull_request);
-
- const branchesMade =
- step.status === AutofixStatus.COMPLETED &&
- step.changes.length >= 1 &&
- step.changes.every(change => change.branch_name);
-
- return (
-
-
-
-
-
-
-
-
- {t('Code Changes')}
-
-
-
-
-
-
- {agentCommentThread && iconCodeRef.current && (
- = 0
- ? previousInsightCount
- : null
- }
- isAgentComment
- blockName={t('Seer is uncertain of the code changes...')}
- />
- )}
-
-
- {step.changes.map((change, i) => (
-
- {i > 0 && }
-
-
- ))}
-
-
-
- {step.termination_reason && (
- {step.termination_reason}
- )}
-
- {!prsMade && (
-
- {branchesMade ? (
- step.changes.length === 1 && step.changes[0] ? (
-
- ) : (
-
- {step.changes.map(
- (change, idx) =>
- change.branch_name && (
-
- )
- )}
-
- )
- ) : (
-
- )}
-
-
- )}
- {step.status === AutofixStatus.COMPLETED && (
-
- )}
- {prsMade &&
- (step.changes.length === 1 && step.changes[0]?.pull_request?.pr_url ? (
- }
- href={step.changes[0].pull_request.pr_url}
- external
- >
- View PR in {step.changes[0].repo_name}
-
- ) : (
-
- {step.changes.map(
- (change, idx) =>
- change.pull_request?.pr_url && (
- }
- href={change.pull_request.pr_url}
- external
- >
- View PR in {change.repo_name}
-
- )
- )}
-
- ))}
-
-
-
-
-
- );
-}
-
-const PreviewContent = styled('div')`
- display: flex;
- flex-direction: column;
- color: ${p => p.theme.tokens.content.primary};
- margin-top: ${p => p.theme.space.xl};
-`;
-
-const AnimationWrapper = styled(motion.div)`
- transform-origin: top center;
-`;
-
-const PrefixText = styled('span')``;
-
-const ChangesContainer = styled('div')`
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- box-shadow: ${p => p.theme.shadow.medium};
- padding: ${p => p.theme.space.xl};
- background: ${p => p.theme.tokens.background.primary};
-`;
-
-const Content = styled('div')`
- padding: 0 0 ${p => p.theme.space.md};
-`;
-
-const Title = styled('div')`
- font-weight: ${p => p.theme.font.weight.sans.medium};
- margin-top: ${p => p.theme.space.md};
- margin-bottom: ${p => p.theme.space.md};
- text-decoration: underline dashed;
- text-decoration-color: ${p => p.theme.tokens.content.accent};
- text-decoration-thickness: 1px;
- text-underline-offset: 4px;
-`;
-
-const PullRequestTitle = styled('div')`
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const RepoChangesHeader = styled('div')`
- display: grid;
- align-items: center;
- grid-template-columns: 1fr auto;
-`;
-
-const MarkdownAlert = styled(MarkedText)`
- border: 1px solid ${p => p.theme.colors.yellow200};
- background-color: ${p => p.theme.colors.yellow100};
- padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0 ${p => p.theme.space.xl};
- border-radius: ${p => p.theme.radius.md};
- color: ${p => p.theme.colors.yellow500};
-`;
-
-const NoChangesPadding = styled('div')`
- padding: 0 ${p => p.theme.space.xl};
-`;
-
-const Separator = styled('hr')`
- border: none;
- border-top: 1px solid ${p => p.theme.tokens.border.secondary};
- margin: ${p => p.theme.space.xl} -${p => p.theme.space.xl} 0 -${p => p.theme.space.xl};
-`;
-
-const HeaderText = styled('div')`
- font-weight: ${p => p.theme.font.weight.sans.medium};
- font-size: ${p => p.theme.font.size.lg};
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.md};
- margin-right: ${p => p.theme.space.xl};
-`;
-
-const BottomDivider = styled('div')`
- border-top: 1px solid ${p => p.theme.tokens.border.secondary};
- margin-top: ${p => p.theme.space.xl};
- margin-bottom: ${p => p.theme.space.xl};
-`;
-
-const BottomButtonContainer = styled('div')<{hasTerminationReason?: boolean}>`
- display: flex;
- justify-content: ${p => (p.hasTerminationReason ? 'space-between' : 'flex-end')};
- align-items: center;
- gap: ${p => p.theme.space.xl};
-`;
-
-const TerminationReasonText = styled('div')`
- color: ${p => p.theme.tokens.content.danger};
- font-size: ${p => p.theme.font.size.sm};
- flex: 1;
- min-width: 0;
-`;
-
-function CreatePRsButton({
- changes,
- groupId,
- runId,
- isBusy,
- onProcessingChange,
-}: {
- changes: AutofixCodebaseChange[];
- groupId: string;
- isBusy: boolean;
- onProcessingChange: (processing: boolean) => void;
- runId: string;
-}) {
- const api = useApi();
- const queryClient = useQueryClient();
- const [hasClicked, setHasClicked] = useState(false);
- const orgSlug = useOrganization().slug;
-
- // Reset hasClicked state and notify parent when isBusy goes from true to false
- useEffect(() => {
- if (!isBusy) {
- setHasClicked(false);
- onProcessingChange(false);
- }
- }, [isBusy, onProcessingChange]);
-
- const {mutate: createPr} = useMutation({
- mutationFn: ({change}: {change: AutofixCodebaseChange}) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'create_pr',
- repo_external_id: change.repo_external_id,
- },
- },
- }
- );
- },
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- setHasClicked(true);
- },
- onError: () => {
- addErrorMessage(t('Failed to create a pull request'));
- setHasClicked(false);
- onProcessingChange(false);
- },
- });
-
- const createPRs = () => {
- setHasClicked(true);
- onProcessingChange(true);
- for (const change of changes) {
- createPr({change});
- }
- };
-
- return (
- }
- size="sm"
- busy={isBusy || hasClicked}
- disabled={isBusy || hasClicked}
- analyticsEventName="Autofix: Create PR Clicked"
- analyticsEventKey="autofix.create_pr_clicked"
- analyticsParams={{group_id: groupId}}
- >
- Draft PR{changes.length > 1 ? 's' : ''}
-
- );
-}
-
-function CreateBranchButton({
- changes,
- groupId,
- runId,
- isBusy,
- onProcessingChange,
-}: {
- changes: AutofixCodebaseChange[];
- groupId: string;
- isBusy: boolean;
- onProcessingChange: (processing: boolean) => void;
- runId: string;
-}) {
- const api = useApi();
- const queryClient = useQueryClient();
- const [hasClicked, setHasClicked] = useState(false);
- const orgSlug = useOrganization().slug;
-
- // Reset hasClicked state and notify parent when isBusy goes from true to false
- useEffect(() => {
- if (!isBusy) {
- setHasClicked(false);
- onProcessingChange(false);
- }
- }, [isBusy, onProcessingChange]);
-
- const {mutate: createBranch} = useMutation({
- mutationFn: ({change}: {change: AutofixCodebaseChange}) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'create_branch',
- repo_external_id: change.repo_external_id,
- },
- },
- }
- );
- },
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- },
- onError: () => {
- addErrorMessage(t('Failed to push to branches.'));
- setHasClicked(false);
- onProcessingChange(false);
- },
- });
-
- const pushToBranch = () => {
- setHasClicked(true);
- onProcessingChange(true);
- for (const change of changes) {
- createBranch({change});
- }
- };
-
- return (
- }
- size="sm"
- busy={isBusy || hasClicked}
- disabled={isBusy || hasClicked}
- analyticsEventName="Autofix: Push to Branch Clicked"
- analyticsEventKey="autofix.push_to_branch_clicked"
- analyticsParams={{group_id: groupId}}
- >
- {t('Check Out Locally')}
-
- );
-}
-
-function SetupAndCreateBranchButton({
- changes,
- groupId,
- runId,
- isBusy,
- onProcessingChange,
-}: {
- changes: AutofixCodebaseChange[];
- groupId: string;
- isBusy: boolean;
- onProcessingChange: (processing: boolean) => void;
- runId: string;
-}) {
- const {openModal} = useModal();
-
- const {codebases} = useAutofixRepos(groupId);
-
- if (
- !changes.every(
- change =>
- change.repo_external_id && codebases[change.repo_external_id]?.is_writeable
- )
- ) {
- return (
- {
- openModal(deps => );
- }}
- size="sm"
- analyticsEventName="Autofix: Create Branch Setup Clicked"
- analyticsEventKey="autofix.create_branch_setup_clicked"
- analyticsParams={{group_id: groupId}}
- tooltipProps={{title: t('Enable write access to create branches')}}
- >
- {t('Check Out Locally')}
-
- );
- }
-
- return (
-
- );
-}
-
-function SetupAndCreatePRsButton({
- changes,
- groupId,
- runId,
- isBusy,
- onProcessingChange,
-}: {
- changes: AutofixCodebaseChange[];
- groupId: string;
- isBusy: boolean;
- onProcessingChange: (processing: boolean) => void;
- runId: string;
-}) {
- const {openModal} = useModal();
-
- const {codebases} = useAutofixRepos(groupId);
- if (
- !changes.every(
- change =>
- change.repo_external_id && codebases[change.repo_external_id]?.is_writeable
- )
- ) {
- return (
- {
- openModal(deps => );
- }}
- size="sm"
- analyticsEventName="Autofix: Create PR Setup Clicked"
- analyticsEventKey="autofix.create_pr_setup_clicked"
- analyticsParams={{group_id: groupId}}
- tooltipProps={{title: t('Enable write access to create pull requests')}}
- >
- {t('Draft PR')}
-
- );
- }
-
- return (
-
- );
-}
diff --git a/static/app/components/events/autofix/autofixDiff.spec.tsx b/static/app/components/events/autofix/autofixDiff.spec.tsx
deleted file mode 100644
index 5a1c9f876fcebd..00000000000000
--- a/static/app/components/events/autofix/autofixDiff.spec.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import {AutofixDiffFilePatch} from 'sentry-fixture/autofixDiffFilePatch';
-
-import {
- render,
- screen,
- userEvent,
- waitFor,
- within,
-} from 'sentry-test/reactTestingLibrary';
-import {textWithMarkupMatcher} from 'sentry-test/utils';
-
-import {addErrorMessage} from 'sentry/actionCreators/indicator';
-import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
-
-jest.mock('sentry/actionCreators/indicator');
-
-describe('AutofixDiff', () => {
- const defaultProps = {
- diff: [AutofixDiffFilePatch()],
- groupId: '1',
- runId: '1',
- editable: true,
- } satisfies React.ComponentProps;
-
- beforeEach(() => {
- MockApiClient.clearMockResponses();
- jest.mocked(addErrorMessage).mockClear();
- });
-
- it('displays a modified file diff correctly', () => {
- render( );
-
- // File path
- expect(
- screen.getByText('src/sentry/processing/backpressure/memory.py')
- ).toBeInTheDocument();
-
- // Lines changed
- expect(screen.getByText('+1')).toBeInTheDocument();
- expect(screen.getByText('-1')).toBeInTheDocument();
-
- // Hunk section header
- expect(
- screen.getByText(
- textWithMarkupMatcher(
- '@@ -47,7 +47,7 @@ def get_memory_usage(node_id: str, info: Mapping[str, Any]) -> ServiceMemory:'
- )
- )
- ).toBeInTheDocument();
-
- // One removed line
- const removedLine = screen.getByTestId('line-removed');
- expect(
- within(removedLine).getByText(
- textWithMarkupMatcher(
- 'memory_available = info.get("maxmemory", 0) or info["total_system_memory"]'
- )
- )
- ).toBeInTheDocument();
-
- // One added line
- const addedLine = screen.getByTestId('line-added');
- expect(
- within(addedLine).getByText(
- textWithMarkupMatcher(
- 'memory_available = info.get("maxmemory", 0) or info.get("total_system_memory", 0)'
- )
- )
- ).toBeInTheDocument();
-
- // 6 context lines
- expect(screen.getAllByTestId('line-context')).toHaveLength(6);
- });
-
- it('can collapse a file diff', async () => {
- render( );
-
- expect(screen.getAllByTestId('line-context')).toHaveLength(6);
-
- // Clicking toggle hides file context
- await userEvent.click(screen.getByRole('button', {name: 'Toggle file diff'}));
- expect(screen.queryByTestId('line-context')).not.toBeInTheDocument();
-
- // Clicking again shows file context
- await userEvent.click(screen.getByRole('button', {name: 'Toggle file diff'}));
- expect(screen.getAllByTestId('line-context')).toHaveLength(6);
- });
-
- it('can edit changes', async () => {
- render( );
-
- await userEvent.click(screen.getByRole('button', {name: 'Edit changes'}));
-
- expect(
- screen.getByRole('heading', {
- name: 'Editing src/sentry/processing/backpressure/memory.py',
- })
- ).toBeInTheDocument();
- expect(
- screen.getAllByText('src/sentry/processing/backpressure/memory.py')
- ).toHaveLength(2); // one in the header of the diff and one in the popup
-
- const textarea = screen.getAllByRole('textbox')[0];
- await userEvent.clear(textarea!);
- await userEvent.type(textarea!, 'New content');
-
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/update/',
- method: 'POST',
- });
-
- await userEvent.click(screen.getByRole('button', {name: 'Save'}));
-
- await waitFor(() => {
- expect(screen.queryByText('Editing')).not.toBeInTheDocument();
- });
- expect(
- screen.getAllByText('src/sentry/processing/backpressure/memory.py')
- ).toHaveLength(1); // one in the header of the diff and none in the popup
- });
-
- it('can reject changes', async () => {
- render( );
-
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/update/',
- method: 'POST',
- });
-
- await userEvent.click(screen.getByRole('button', {name: 'Reject changes'}));
-
- await waitFor(() => {
- expect(screen.queryByTestId('line-added')).not.toBeInTheDocument();
- });
- expect(screen.queryByTestId('line-removed')).not.toBeInTheDocument();
- });
-
- it('shows error message on failed edit', async () => {
- render( );
-
- await userEvent.click(screen.getByRole('button', {name: 'Edit changes'}));
-
- const textarea = screen.getAllByRole('textbox')[0];
- await userEvent.clear(textarea!);
- await userEvent.type(textarea!, 'New content');
-
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/update/',
- method: 'POST',
- statusCode: 500,
- });
-
- await userEvent.click(screen.getByRole('button', {name: 'Save'}));
-
- await waitFor(() => {
- expect(addErrorMessage).toHaveBeenCalledWith(
- 'Something went wrong when updating changes.'
- );
- });
- });
-
- it('does not show edit buttons when editable is false', () => {
- render( );
-
- expect(screen.queryByRole('button', {name: 'Edit changes'})).not.toBeInTheDocument();
- expect(
- screen.queryByRole('button', {name: 'Reject changes'})
- ).not.toBeInTheDocument();
-
- // Ensure the diff content is still visible
- expect(
- screen.getByText('src/sentry/processing/backpressure/memory.py')
- ).toBeInTheDocument();
- expect(screen.getByTestId('line-added')).toBeInTheDocument();
- expect(screen.getByTestId('line-removed')).toBeInTheDocument();
- });
-});
diff --git a/static/app/components/events/autofix/autofixDiff.tsx b/static/app/components/events/autofix/autofixDiff.tsx
deleted file mode 100644
index 4967d24bbe5096..00000000000000
--- a/static/app/components/events/autofix/autofixDiff.tsx
+++ /dev/null
@@ -1,923 +0,0 @@
-import {Fragment, useEffect, useMemo, useRef, useState} from 'react';
-import {createPortal} from 'react-dom';
-import styled from '@emotion/styled';
-import {useMutation, useQueryClient} from '@tanstack/react-query';
-import {diffWords, type Change} from 'diff';
-
-import {Button} from '@sentry/scraps/button';
-import InteractionStateLayer from '@sentry/scraps/interactionStateLayer';
-import {Flex, Stack} from '@sentry/scraps/layout';
-import {TextArea} from '@sentry/scraps/textarea';
-
-import {addErrorMessage} from 'sentry/actionCreators/indicator';
-import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper';
-import {
- DiffLineType,
- type DiffLine,
- type FilePatch,
-} from 'sentry/components/events/autofix/types';
-import {autofixApiOptions} from 'sentry/components/events/autofix/useAutofix';
-import {DIFF_COLORS} from 'sentry/components/splitDiff';
-import {IconChevron, IconClose, IconDelete, IconEdit} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {getPrismLanguage} from 'sentry/utils/prism';
-import {useApi} from 'sentry/utils/useApi';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {usePrismTokens} from 'sentry/utils/usePrismTokens';
-
-type AutofixDiffProps = {
- diff: FilePatch[];
- editable: boolean;
- groupId: string;
- runId: string;
- integratedStyle?: boolean;
- previousDefaultStepIndex?: number;
- previousInsightCount?: number;
- repoId?: string;
-};
-
-interface DiffLineWithChanges extends DiffLine {
- changes?: Change[];
-}
-
-function makeTestIdFromLineType(lineType: DiffLineType) {
- switch (lineType) {
- case DiffLineType.ADDED:
- return 'line-added';
- case DiffLineType.REMOVED:
- return 'line-removed';
- default:
- return 'line-context';
- }
-}
-
-function addChangesToDiffLines(lines: DiffLineWithChanges[]): DiffLineWithChanges[] {
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i]!;
- if (line.line_type === DiffLineType.CONTEXT) {
- continue;
- }
-
- if (line.line_type === DiffLineType.REMOVED) {
- const prevLine = lines[i - 1];
- const nextLine = lines[i + 1];
- const nextNextLine = lines[i + 2];
-
- if (
- nextLine?.line_type === DiffLineType.ADDED &&
- prevLine?.line_type !== DiffLineType.REMOVED &&
- nextNextLine?.line_type !== DiffLineType.ADDED
- ) {
- const changes = diffWords(line.value, nextLine.value);
- lines[i] = {...line, changes: changes.filter(change => !change.added)};
- lines[i + 1] = {...nextLine, changes: changes.filter(change => !change.removed)};
- }
- }
- }
-
- return lines;
-}
-
-function detectLanguageFromPath(filePath: string): string {
- if (!filePath) {
- return 'plaintext';
- }
- const extension = filePath.split('.').pop()?.toLowerCase();
- if (!extension) {
- return 'plaintext';
- }
-
- const language = getPrismLanguage(extension);
- return language || 'plaintext';
-}
-
-const SyntaxHighlightedCode = styled('div')`
- font-family: ${p => p.theme.font.family.mono};
- white-space: pre;
-
- && pre,
- && code {
- margin: 0;
- padding: 0;
- background: transparent;
- }
-`;
-
-function DiffLineCode({line, fileName}: {line: DiffLineWithChanges; fileName?: string}) {
- const language = useMemo(
- () => (fileName ? detectLanguageFromPath(fileName) : 'plaintext'),
- [fileName]
- );
-
- const tokens = usePrismTokens({code: line.value, language});
-
- // If we have changes (diff), use the CodeDiff component
- if (line.changes) {
- return (
-
- {line.changes.map((change, i) => (
-
- {change.value}
-
- ))}
-
- );
- }
-
- // For non-changed lines, apply syntax highlighting
- return (
-
-
-
- {tokens.map((lineTokens, i) => (
-
- {lineTokens.map((token, j) => (
-
- {token.children}
-
- ))}
-
- ))}
-
-
-
- );
-}
-
-function HunkHeader({lines, sectionHeader}: {lines: DiffLine[]; sectionHeader: string}) {
- const {sourceStart, sourceLength, targetStart, targetLength} = useMemo(
- () => ({
- sourceStart: lines.at(0)?.source_line_no ?? 0,
- sourceLength: lines.filter(line => line.line_type !== DiffLineType.ADDED).length,
- targetStart: lines.at(0)?.target_line_no ?? 0,
- targetLength: lines.filter(line => line.line_type !== DiffLineType.REMOVED).length,
- }),
- [lines]
- );
-
- return (
- {`@@ -${sourceStart},${sourceLength} +${targetStart},${targetLength} @@ ${sectionHeader ? ' ' + sectionHeader : ''}`}
- );
-}
-
-function useUpdateHunk({groupId, runId}: {groupId: string; runId: string}) {
- const api = useApi({persistInFlight: true});
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
-
- return useMutation({
- mutationFn: (params: {
- fileName: string;
- hunkIndex: number;
- lines: DiffLine[];
- repoId?: string;
- }) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'update_code_change',
- repo_id: params.repoId ?? null,
- hunk_index: params.hunkIndex,
- lines: params.lines,
- file_path: params.fileName,
- },
- },
- }
- );
- },
- onSuccess: _ => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when updating changes.'));
- },
- });
-}
-
-function DiffHunkContent({
- groupId,
- runId,
- repoId,
- hunkIndex,
- lines,
- header,
- fileName,
- editable,
-}: {
- editable: boolean;
- fileName: string;
- groupId: string;
- header: string;
- hunkIndex: number;
- lines: DiffLine[];
- runId: string;
- repoId?: string;
-}) {
- const [linesWithChanges, setLinesWithChanges] = useState([]);
-
- useEffect(() => {
- setLinesWithChanges(addChangesToDiffLines(lines));
- }, [lines]);
-
- const [editingGroup, setEditingGroup] = useState(null);
- const [editedContent, setEditedContent] = useState('');
- const [editedLines, setEditedLines] = useState([]);
- const overlayRef = useRef(null);
- const [hoveredGroup, setHoveredGroup] = useState(null);
-
- useEffect(() => {
- function handleClickOutside(event: MouseEvent) {
- if (overlayRef.current && !overlayRef.current.contains(event.target as Node)) {
- setEditingGroup(null);
- setEditedContent('');
- }
- }
-
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [overlayRef]);
-
- const lineGroups = useMemo(() => {
- const groups: Array<{end: number; start: number; type: 'change' | DiffLineType}> = [];
- let currentGroup: (typeof groups)[number] | null = null;
-
- linesWithChanges.forEach((line, index) => {
- if (line.line_type !== DiffLineType.CONTEXT) {
- if (!currentGroup) {
- currentGroup = {start: index, end: index, type: 'change'};
- } else if (currentGroup.type === 'change') {
- currentGroup.end = index;
- } else {
- groups.push(currentGroup);
- currentGroup = {start: index, end: index, type: 'change'};
- }
- } else if (currentGroup) {
- groups.push(currentGroup);
- currentGroup = null;
- }
- });
-
- if (currentGroup) {
- groups.push(currentGroup);
- }
-
- return groups;
- }, [linesWithChanges]);
-
- const handleEditClick = (index: number) => {
- const group = lineGroups.find(g => g.start === index);
- if (group) {
- const content = linesWithChanges
- .slice(group.start, group.end + 1)
- .filter(line => line.line_type === DiffLineType.ADDED)
- .map(line => line.value)
- .join('');
- const splitLines = content.split('\n');
- if (splitLines[splitLines.length - 1] === '') {
- splitLines.pop();
- }
- setEditedLines(splitLines);
- if (content === '\n') {
- setEditedContent('');
- } else {
- setEditedContent(content.endsWith('\n') ? content.slice(0, -1) : content);
- }
- setEditingGroup(index);
- }
- };
-
- const handleTextAreaChange = (e: React.ChangeEvent) => {
- const newContent = e.target.value;
- setEditedContent(newContent);
- setEditedLines(newContent.split('\n'));
- };
-
- const updateHunk = useUpdateHunk({groupId, runId});
- const handleSaveEdit = () => {
- if (editingGroup === null) {
- return;
- }
- const group = lineGroups.find(g => g.start === editingGroup);
- if (!group) {
- return;
- }
-
- let lastSourceLineNo = 0;
- let lastTargetLineNo = 0;
- let lastDiffLineNo = 0;
-
- const updatedLines = linesWithChanges
- .map((line, index) => {
- if (index < group.start) {
- lastSourceLineNo = line.source_line_no ?? lastSourceLineNo;
- lastTargetLineNo = line.target_line_no ?? lastTargetLineNo;
- lastDiffLineNo = line.diff_line_no ?? lastDiffLineNo;
- }
- if (index >= group.start && index <= group.end) {
- if (line.line_type === DiffLineType.ADDED) {
- return null; // Remove existing added lines
- }
- if (line.line_type === DiffLineType.REMOVED) {
- lastSourceLineNo = line.source_line_no ?? lastSourceLineNo;
- }
- return line; // Keep other lines (removed and context) as is
- }
- return line;
- })
- .filter((line): line is DiffLine => line !== null);
-
- // Insert new added lines
- const newAddedLines: DiffLine[] = editedContent.split('\n').map((content, i) => {
- lastDiffLineNo++;
- lastTargetLineNo++;
- return {
- diff_line_no: lastDiffLineNo,
- source_line_no: null,
- target_line_no: lastTargetLineNo,
- line_type: DiffLineType.ADDED,
- value: content + (i === editedContent.split('\n').length - 1 ? '' : '\n'),
- };
- });
-
- // Find the insertion point (after the last removed line or at the start of the group)
- const insertionIndex = updatedLines.findIndex(
- (line, index) => index >= group.start && line.line_type !== DiffLineType.REMOVED
- );
-
- updatedLines.splice(
- insertionIndex === -1 ? group.start : insertionIndex,
- 0,
- ...newAddedLines
- );
-
- // Update diff_line_no for all lines after the insertion
- for (let i = insertionIndex + newAddedLines.length; i < updatedLines.length; i++) {
- updatedLines[i]!.diff_line_no = ++lastDiffLineNo;
- }
-
- updateHunk.mutate({hunkIndex, lines: updatedLines, repoId, fileName});
- setLinesWithChanges(addChangesToDiffLines(updatedLines));
- setEditingGroup(null);
- setEditedContent('');
- };
-
- const handleCancelEdit = () => {
- setEditingGroup(null);
- setEditedContent('');
- };
-
- const rejectChanges = (index: number) => {
- const group = lineGroups.find(g => g.start === index);
- if (!group) {
- return;
- }
-
- const updatedLines = linesWithChanges
- .map((line, i) => {
- if (i >= group.start && i <= group.end) {
- if (line.line_type === DiffLineType.ADDED) {
- return null; // Remove added lines
- }
- if (line.line_type === DiffLineType.REMOVED) {
- return {...line, line_type: DiffLineType.CONTEXT}; // Convert removed lines to context
- }
- }
- return line;
- })
- .filter((line): line is DiffLine => line !== null);
-
- updateHunk.mutate({hunkIndex, lines: updatedLines, repoId, fileName});
- setLinesWithChanges(addChangesToDiffLines(updatedLines));
- };
-
- const getStartLineNumber = (index: number, lineType: DiffLineType) => {
- const line = linesWithChanges[index]!;
- if (lineType === DiffLineType.REMOVED) {
- return line.source_line_no;
- }
- if (lineType === DiffLineType.ADDED) {
- // Find the first non-null target_line_no
- for (let i = index; i < linesWithChanges.length; i++) {
- if (linesWithChanges[i]!.target_line_no !== null) {
- return linesWithChanges[i]!.target_line_no;
- }
- }
- }
- return null;
- };
-
- const handleClearChanges = () => {
- setEditedContent('');
- setEditedLines([]);
- };
-
- const getDeletedLineTitle = (index: number) => {
- return t(
- '%s deleted line%s%s',
- linesWithChanges
- .slice(index, lineGroups.find(g => g.start === index)?.end! + 1)
- .filter(l => l.line_type === DiffLineType.REMOVED).length,
- linesWithChanges
- .slice(index, lineGroups.find(g => g.start === index)?.end)
- .filter(l => l.line_type === DiffLineType.REMOVED).length === 1
- ? ''
- : 's',
- linesWithChanges
- .slice(index, lineGroups.find(g => g.start === index)?.end)
- .some(l => l.line_type === DiffLineType.REMOVED)
- ? t(' from line %s', getStartLineNumber(index, DiffLineType.REMOVED))
- : ''
- );
- };
-
- const getNewLineTitle = (index: number) => {
- return t(
- '%s new line%s%s',
- editedLines.length,
- editedLines.length === 1 ? '' : 's',
- editedLines.length > 0
- ? t(' from line %s', getStartLineNumber(index, DiffLineType.ADDED))
- : ''
- );
- };
-
- return (
-
-
-
- {linesWithChanges.map((line, index) => (
-
- {line.source_line_no}
- {line.target_line_no}
- {
- const group = lineGroups.find(g => index >= g.start && index <= g.end);
- if (group) {
- setHoveredGroup(group.start);
- }
- }}
- onMouseLeave={() => setHoveredGroup(null)}
- >
-
- {editable && lineGroups.some(group => index === group.start) && (
-
- }
- aria-label={t('Edit changes')}
- tooltipProps={{title: t('Edit')}}
- onClick={() => handleEditClick(index)}
- isHovered={hoveredGroup === index}
- />
- }
- aria-label={t('Reject changes')}
- tooltipProps={{title: t('Reject')}}
- onClick={() => rejectChanges(index)}
- isHovered={hoveredGroup === index}
- />
-
- )}
-
-
- ))}
- {editingGroup !== null &&
- document.body &&
- createPortal(
-
-
-
- {t('Editing')} {fileName}
-
-
-
- {getDeletedLineTitle(editingGroup)}
- {linesWithChanges
- .slice(
- editingGroup,
- lineGroups.find(g => g.start === editingGroup)?.end! + 1
- )
- .some(l => l.line_type === DiffLineType.REMOVED) ? (
-
- {linesWithChanges
- .slice(
- editingGroup,
- lineGroups.find(g => g.start === editingGroup)?.end! + 1
- )
- .filter(l => l.line_type === DiffLineType.REMOVED)
- .map((l, i) => (
- {l.value}
- ))}
-
- ) : (
- {t('No lines are being deleted.')}
- )}
- {getNewLineTitle(editingGroup)}
-
-
- }
- tooltipProps={{title: t('Clear all new lines')}}
- />
-
-
-
-
- {t('Cancel')}
-
- {t('Save')}
-
-
-
- ,
- document.body
- )}
-
- );
-}
-
-function FileDiff({
- file,
- groupId,
- runId,
- repoId,
- editable,
- integratedStyle,
-}: {
- editable: boolean;
- file: FilePatch;
- groupId: string;
- integratedStyle: boolean;
- runId: string;
- repoId?: string;
-}) {
- const [isExpanded, setIsExpanded] = useState(true);
-
- const containerRef = useRef(null);
-
- return (
-
- {!integratedStyle && (
- setIsExpanded(value => !value)}>
-
-
- +{file.added}
- -{file.removed}
-
- {file.path}
- }
- aria-label={t('Toggle file diff')}
- aria-expanded={isExpanded}
- size="zero"
- variant="transparent"
- />
-
- )}
- {integratedStyle && (
-
-
- +{file.added}
- -{file.removed}
-
- {file.path}
-
- )}
- {isExpanded && (
-
- {file.hunks.map(({section_header, source_start, lines}, index) => {
- return (
-
- );
- })}
-
- )}
-
- );
-}
-
-export function AutofixDiff({
- diff,
- groupId,
- runId,
- repoId,
- editable,
- previousDefaultStepIndex,
- previousInsightCount,
- integratedStyle = false,
-}: AutofixDiffProps) {
- if (!diff?.length) {
- return null;
- }
-
- return (
-
- {diff.map(file => (
- = 0
- ? previousInsightCount - 1
- : -1
- }
- displayName={`Diff for ${file.path}`}
- >
-
-
- ))}
-
- );
-}
-
-const FileDiffWrapper = styled('div')<{integratedStyle?: boolean}>`
- font-family: ${p => p.theme.font.family.mono};
- font-size: ${p => p.theme.font.size.sm};
- & code {
- font-size: ${p => p.theme.font.size.sm};
- }
- line-height: 20px;
- vertical-align: middle;
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-color: ${p =>
- p.integratedStyle ? 'transparent' : p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- overflow: hidden;
- background-color: ${p => p.theme.tokens.background.primary};
-`;
-
-const FileHeader = styled('div')`
- position: relative;
- display: grid;
- align-items: center;
- grid-template-columns: minmax(60px, auto) 1fr auto;
- gap: ${p => p.theme.space.xl};
- background-color: ${p => p.theme.tokens.background.secondary};
- padding: ${p => p.theme.space.md} ${p => p.theme.space.xl};
- cursor: pointer;
-`;
-
-const FileAdded = styled('div')`
- color: ${p => p.theme.tokens.content.success};
-`;
-
-const FileRemoved = styled('div')`
- color: ${p => p.theme.tokens.content.danger};
-`;
-
-const FileName = styled('div')`
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- direction: rtl;
- text-align: left;
-`;
-
-const DiffContainer = styled('div')<{integratedStyle?: boolean}>`
- border-top: ${p =>
- p.integratedStyle ? 'none' : '1px solid ' + p.theme.tokens.border.secondary};
- display: grid;
- grid-template-columns: auto auto 1fr;
- overflow-x: auto;
-`;
-
-const HunkHeaderEmptySpace = styled('div')`
- grid-column: 1 / 3;
- background-color: ${p => p.theme.tokens.background.secondary};
-`;
-
-const HunkHeaderContent = styled('div')`
- grid-column: 3 / -1;
- background-color: ${p => p.theme.tokens.background.secondary};
- color: ${p => p.theme.tokens.content.secondary};
- padding: ${p => p.theme.space.sm} ${p => p.theme.space.md} ${p => p.theme.space.sm}
- ${p => p.theme.space['3xl']};
- white-space: pre-wrap;
-`;
-
-const LineNumber = styled('div')<{lineType: DiffLineType}>`
- display: flex;
- padding: ${p => p.theme.space['2xs']} ${p => p.theme.space.md};
- user-select: none;
-
- background-color: ${p => p.theme.tokens.background.secondary};
- color: ${p => p.theme.tokens.content.secondary};
-
- ${p =>
- p.lineType === DiffLineType.ADDED &&
- `background-color: ${DIFF_COLORS.added}; color: ${p.theme.tokens.content.primary}`};
- ${p =>
- p.lineType === DiffLineType.REMOVED &&
- `background-color: ${DIFF_COLORS.removed}; color: ${p.theme.tokens.content.primary}`};
-
- & + & {
- padding-left: 0;
- }
-`;
-
-const DiffContent = styled('div')<{lineType: DiffLineType}>`
- position: relative;
- padding-left: ${p => p.theme.space['3xl']};
- padding-right: ${p => p.theme.space['3xl']};
- white-space: pre-wrap;
- word-break: break-all;
- word-wrap: break-word;
- overflow: visible;
-
- ${p =>
- p.lineType === DiffLineType.ADDED &&
- `background-color: ${DIFF_COLORS.addedRow}; color: ${p.theme.tokens.content.primary}`};
- ${p =>
- p.lineType === DiffLineType.REMOVED &&
- `background-color: ${DIFF_COLORS.removedRow}; color: ${p.theme.tokens.content.primary}`};
-
- &::before {
- content: ${p =>
- p.lineType === DiffLineType.ADDED
- ? "'+'"
- : p.lineType === DiffLineType.REMOVED
- ? "'-'"
- : "''"};
- position: absolute;
- top: 1px;
- left: ${p => p.theme.space.md};
- }
-`;
-
-const CodeDiff = styled('span')<{added?: boolean; removed?: boolean}>`
- vertical-align: middle;
- ${p => p.added && `background-color: ${DIFF_COLORS.added};`};
- ${p => p.removed && `background-color: ${DIFF_COLORS.removed};`};
-`;
-
-const ButtonGroup = styled('div')`
- position: absolute;
- top: 0;
- right: ${p => p.theme.space.xs};
- display: flex;
-`;
-
-const ActionButton = styled(Button)<{isHovered: boolean}>`
- margin-left: ${p => p.theme.space.xs};
- font-family: ${p => p.theme.font.family.sans};
- background-color: ${p => p.theme.tokens.background.primary};
- color: ${p => (p.isHovered ? p.theme.colors.pink500 : p.theme.tokens.content.primary)};
- transition:
- background-color 0.2s ease-in-out,
- color 0.2s ease-in-out;
-
- &:hover {
- background-color: ${p => p.theme.colors.pink500}10;
- color: ${p => p.theme.colors.pink500};
- }
-`;
-
-const EditOverlay = styled('div')`
- position: fixed;
- bottom: ${p => p.theme.space.xl};
- left: 50%;
- right: ${p => p.theme.space.xl};
- background: ${p => p.theme.tokens.background.primary};
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- box-shadow: ${p => p.theme.shadow.high};
- z-index: ${p => p.theme.zIndex.tooltip};
- display: flex;
- flex-direction: column;
- max-height: calc(100vh - 18rem);
-
- @media (max-width: ${p => p.theme.breakpoints.sm}) {
- left: ${p => p.theme.space.xl};
- }
-`;
-
-const OverlayHeader = styled('div')`
- padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0;
- border-bottom: 1px solid ${p => p.theme.tokens.border.primary};
-`;
-
-const OverlayContent = styled('div')`
- padding: 0 ${p => p.theme.space.xl} ${p => p.theme.space.xl} ${p => p.theme.space.xl};
- overflow-y: auto;
-`;
-
-const OverlayFooter = styled('div')`
- padding: ${p => p.theme.space.md};
- border-top: 1px solid ${p => p.theme.tokens.border.primary};
-`;
-
-const OverlayButtonGroup = styled('div')`
- display: flex;
- justify-content: flex-end;
- gap: ${p => p.theme.space.md};
- font-family: ${p => p.theme.font.family.sans};
-`;
-
-const RemovedLines = styled('div')`
- margin-bottom: ${p => p.theme.space.md};
- font-family: ${p => p.theme.font.family.mono};
- border-radius: ${p => p.theme.radius.md};
- overflow: hidden;
-`;
-
-const RemovedLine = styled('div')`
- background-color: ${DIFF_COLORS.removedRow};
- color: ${p => p.theme.tokens.content.primary};
- padding: ${p => p.theme.space['2xs']} ${p => p.theme.space.xs};
- white-space: pre-wrap;
-`;
-
-const StyledTextArea = styled(TextArea)`
- font-family: ${p => p.theme.font.family.mono};
- background-color: ${DIFF_COLORS.addedRow};
- border-color: ${p => p.theme.tokens.border.primary};
- position: relative;
- min-height: 250px;
-
- &:focus {
- border-color: ${p => p.theme.tokens.focus.default};
- box-shadow: inset 0 0 0 1px ${p => p.theme.tokens.focus.default};
- }
-`;
-
-const ClearButton = styled(Button)`
- position: absolute;
- top: -${p => p.theme.space.md};
- right: -${p => p.theme.space.md};
- z-index: 1;
-`;
-
-const TextAreaWrapper = styled('div')`
- position: relative;
-`;
-
-const SectionTitle = styled('p')`
- margin: ${p => p.theme.space.md} 0;
- font-size: ${p => p.theme.font.size.md};
- font-weight: bold;
- color: ${p => p.theme.tokens.content.primary};
- font-family: ${p => p.theme.font.family.sans};
-`;
-
-const NoChangesMessage = styled('p')`
- margin: ${p => p.theme.space.md} 0;
- color: ${p => p.theme.tokens.content.secondary};
- font-family: ${p => p.theme.font.family.sans};
-`;
-
-const OverlayTitle = styled('h3')`
- margin: 0 0 ${p => p.theme.space.xl} 0;
- font-size: ${p => p.theme.font.size.md};
- font-weight: bold;
- color: ${p => p.theme.tokens.content.primary};
- font-family: ${p => p.theme.font.family.sans};
-`;
diff --git a/static/app/components/events/autofix/autofixFeedback.tsx b/static/app/components/events/autofix/autofixFeedback.tsx
deleted file mode 100644
index 96df51fd5e54ca..00000000000000
--- a/static/app/components/events/autofix/autofixFeedback.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import {type ComponentProps} from 'react';
-
-import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton';
-import {t} from 'sentry/locale';
-
-interface AutofixFeedbackProps extends ComponentProps {
- iconOnly?: boolean;
-}
-
-export function AutofixFeedback({
- children,
- iconOnly = false,
- ...buttonProps
-}: AutofixFeedbackProps) {
- return (
-
- {iconOnly ? null : children}
-
- );
-}
diff --git a/static/app/components/events/autofix/autofixHighlightPopup.tsx b/static/app/components/events/autofix/autofixHighlightPopup.tsx
deleted file mode 100644
index d4f384ad3085fe..00000000000000
--- a/static/app/components/events/autofix/autofixHighlightPopup.tsx
+++ /dev/null
@@ -1,886 +0,0 @@
-import React, {
- Fragment,
- startTransition,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import {createPortal} from 'react-dom';
-import {useTheme} from '@emotion/react';
-import styled from '@emotion/styled';
-import {useMutation, useQueryClient} from '@tanstack/react-query';
-import {AnimatePresence, motion} from 'framer-motion';
-
-import {UserAvatar} from '@sentry/scraps/avatar';
-import {Button} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-import {TextArea} from '@sentry/scraps/textarea';
-
-import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator';
-import {FlippedReturnIcon} from 'sentry/components/events/autofix/insights/autofixInsightCard';
-import {
- autofixApiOptions,
- useAutofixData,
-} from 'sentry/components/events/autofix/useAutofix';
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
-import {IconClose, IconSeer} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-import {useApi} from 'sentry/utils/useApi';
-import {useMedia} from 'sentry/utils/useMedia';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useUser} from 'sentry/utils/useUser';
-import {Divider} from 'sentry/views/issueDetails/divider';
-
-import type {CommentThreadMessage} from './types';
-
-interface Props {
- groupId: string;
- referenceElement: HTMLElement | null;
- retainInsightCardIndex: number | null;
- runId: string;
- selectedText: string;
- stepIndex: number;
- blockName?: string;
- hasUserSelection?: boolean;
- isAgentComment?: boolean;
- onShouldPersistChange?: (shouldPersist: boolean) => void;
-}
-
-interface OptimisticMessage extends CommentThreadMessage {
- isLoading?: boolean;
-}
-
-const MIN_LEFT_MARGIN = 8;
-
-function useCommentThread({groupId, runId}: {groupId: string; runId: string}) {
- const api = useApi({persistInFlight: true});
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
-
- return useMutation({
- mutationFn: (params: {
- is_agent_comment: boolean;
- message: string;
- retain_insight_card_index: number | null;
- selected_text: string;
- step_index: number;
- thread_id: string;
- }) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'comment_thread',
- message: params.message,
- thread_id: params.thread_id,
- selected_text: params.selected_text,
- step_index: params.step_index,
- retain_insight_card_index: params.retain_insight_card_index,
- is_agent_comment: params.is_agent_comment,
- },
- },
- }
- );
- },
- onSuccess: _ => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when sending your comment.'));
- },
- });
-}
-
-function useCloseCommentThread({groupId, runId}: {groupId: string; runId: string}) {
- const api = useApi({persistInFlight: true});
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
-
- return useMutation({
- mutationFn: (params: {
- is_agent_comment: boolean;
- step_index: number;
- thread_id: string;
- }) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'resolve_comment_thread',
- thread_id: params.thread_id,
- step_index: params.step_index,
- is_agent_comment: params.is_agent_comment,
- },
- },
- }
- );
- },
- onSuccess: _ => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when resolving the thread.'));
- },
- });
-}
-
-function useRethinkWithCommentThread({groupId, runId}: {groupId: string; runId: string}) {
- const api = useApi({persistInFlight: true});
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
-
- return useMutation({
- mutationFn: (params: {
- is_agent_comment: boolean;
- retain_insight_card_index: number | null;
- selected_text: string;
- step_index: number;
- thread_id: string;
- }) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'rethink_with_comment_thread',
- thread_id: params.thread_id,
- step_index: params.step_index,
- is_agent_comment: params.is_agent_comment,
- selected_text: params.selected_text,
- retain_insight_card_index: params.retain_insight_card_index,
- },
- },
- }
- );
- },
- onSuccess: _ => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- addLoadingMessage(t('Rethinking based on this thread...'));
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when rethinking with the thread.'));
- },
- });
-}
-
-function AutofixHighlightPopupContent({
- selectedText,
- groupId,
- runId,
- stepIndex,
- retainInsightCardIndex,
- isAgentComment,
- blockName,
- isFocused,
- hasUserSelection,
- onShouldPersistChange,
-}: Props & {isFocused?: boolean}) {
- const organization = useOrganization();
-
- const {mutate: submitComment} = useCommentThread({groupId, runId});
- const {mutate: closeCommentThread} = useCloseCommentThread({groupId, runId});
- const {mutate: rethinkWithCommentThread} = useRethinkWithCommentThread({
- groupId,
- runId,
- });
-
- const [hidden, setHidden] = useState(false);
- const [comment, setComment] = useState('');
- const [threadId] = useState(() => {
- const timestamp = Date.now();
- const random = Math.floor(Math.random() * 10000);
- return `thread-${timestamp}-${random}`;
- });
- const [pendingUserMessage, setPendingUserMessage] = useState(
- null
- );
- const [showLoadingAssistant, setShowLoadingAssistant] = useState(false);
- const messagesEndRef = useRef(null);
-
- // Fetch current autofix data to get comment thread
- const {data: autofixData} = useAutofixData({groupId, isUserWatching: true});
- const currentStep = isAgentComment
- ? autofixData?.steps?.[stepIndex + 1]
- : autofixData?.steps?.[stepIndex];
-
- const commentThread = isAgentComment
- ? currentStep?.agent_comment_thread
- : currentStep?.active_comment_thread?.id === threadId
- ? currentStep.active_comment_thread
- : null;
-
- const serverMessages = useMemo(
- () => commentThread?.messages ?? [],
- [commentThread?.messages]
- );
-
- // Effect to clear pending messages when server data updates
- useEffect(() => {
- if (serverMessages.length > 0) {
- const lastServerMessage = serverMessages[serverMessages.length - 1];
-
- // If the last server message is from the assistant, clear all pending messages
- if (lastServerMessage?.role === 'assistant') {
- setPendingUserMessage(null);
- setShowLoadingAssistant(false);
- }
-
- // If the last server message is from the user, keep loading assistant state
- // but clear the pending user message
- if (lastServerMessage?.role === 'user') {
- setPendingUserMessage(null);
- setShowLoadingAssistant(true);
- }
- }
- }, [serverMessages]);
-
- // Combine server messages with optimistic ones
- const allMessages = useMemo(() => {
- const result = [...serverMessages];
-
- // Add pending user message if it exists
- if (pendingUserMessage) {
- result.push(pendingUserMessage);
- }
-
- // Add loading assistant message if needed
- if (showLoadingAssistant) {
- result.push({
- role: 'assistant' as const,
- content: '',
- isLoading: true,
- });
- }
-
- return result;
- }, [serverMessages, pendingUserMessage, showLoadingAssistant]);
-
- const truncatedText =
- selectedText.length > 35
- ? selectedText.slice(0, 35).split(' ').slice(0, -1).join(' ') + '...'
- : selectedText;
-
- const currentUser = useUser();
-
- const hasLoadingMessage = useMemo(
- () => allMessages.some(msg => msg.role === 'assistant' && msg.isLoading),
- [allMessages]
- );
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (!comment.trim() || hasLoadingMessage) {
- return;
- }
-
- // Add optimistic user message and show loading assistant
- setPendingUserMessage({role: 'user', content: comment});
- setShowLoadingAssistant(true);
-
- submitComment({
- message: comment,
- thread_id: threadId,
- selected_text: selectedText,
- step_index: stepIndex,
- retain_insight_card_index: retainInsightCardIndex,
- is_agent_comment: isAgentComment ?? false,
- });
- setComment('');
-
- trackAnalytics('autofix.comment_thread.submit', {
- organization,
- group_id: groupId,
- run_id: runId,
- step_index: stepIndex,
- is_agent_comment: isAgentComment ?? false,
- });
- };
-
- const handleContainerClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- };
-
- const scrollToBottom = () => {
- messagesEndRef.current?.scrollIntoView({behavior: 'smooth'});
- };
-
- // Add effect to scroll to bottom when messages change
- useEffect(() => {
- scrollToBottom();
- }, [allMessages]);
-
- const handleResolve = (e: React.MouseEvent) => {
- e.stopPropagation();
- resolveThread();
- };
-
- const resolveThread = () => {
- setHidden(true);
- closeCommentThread({
- thread_id: threadId,
- step_index: stepIndex,
- is_agent_comment: isAgentComment ?? false,
- });
- };
-
- const handleRework = (e: React.MouseEvent) => {
- e.stopPropagation();
- rethinkWithCommentThread({
- thread_id: threadId,
- step_index: stepIndex,
- is_agent_comment: isAgentComment ?? false,
- selected_text: selectedText,
- retain_insight_card_index: retainInsightCardIndex,
- });
-
- trackAnalytics('autofix.comment_thread.rework', {
- organization,
- group_id: groupId,
- run_id: runId,
- step_index: stepIndex,
- is_agent_comment: isAgentComment ?? false,
- });
- };
-
- useEffect(() => {
- if (onShouldPersistChange) {
- onShouldPersistChange(!!commentThread && !commentThread.is_completed);
- }
- }, [commentThread, onShouldPersistChange]);
-
- if (hidden) {
- return null;
- }
-
- return (
-
-
-
-
-
- {allMessages.filter(msg => !msg.isLoading).length >= 2 ? (
-
-
- {t('Rethink based on this convo?')}
-
-
-
-
-
-
-
- }
- />
-
-
- ) : (
-
-
- {blockName ? (
- {blockName}
- ) : (
- truncatedText && "{truncatedText}"
- )}
-
- {allMessages.length > 0 && (
- }
- />
- )}
-
- )}
-
-
-
- {allMessages.length > 0 && (
-
- {allMessages.map((message, i) => (
-
- {message.role === 'assistant' ? (
-
-
-
- ) : (
-
- )}
-
- {message.isLoading ? (
-
-
-
- ) : (
-
- )}
-
-
- ))}
-
-
- )}
-
- {commentThread?.is_completed !== true && (
-
-
- setComment(e.target.value)}
- maxLength={4096}
- size="sm"
- autoFocus={!isAgentComment && !hasUserSelection}
- maxRows={5}
- autosize
- onKeyDown={e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSubmit(e);
- } else if (e.key === 'Escape') {
- e.preventDefault();
- resolveThread();
- }
- }}
- />
-
- {'\u23CE'}
-
-
-
- )}
-
-
- );
-}
-
-function getOptimalPosition(
- referenceRect: DOMRect,
- popupRect: DOMRect,
- drawerWidth?: number
-) {
- const viewportHeight = window.innerHeight;
- const viewportWidth = window.innerWidth;
-
- const effectiveDrawerWidth = drawerWidth ?? viewportWidth * 0.5;
-
- // Calculate initial position to the left of the drawer
- let left = viewportWidth - effectiveDrawerWidth - popupRect.width - 8;
-
- // Ensure the popup is not cut off on the left side
- if (left < MIN_LEFT_MARGIN) {
- left = MIN_LEFT_MARGIN;
- }
-
- let top = referenceRect.top;
-
- // Ensure the popup stays within the viewport vertically
- if (top + popupRect.height > viewportHeight) {
- top = viewportHeight - popupRect.height;
- }
- if (top < 42) {
- top = 42;
- }
-
- return {left, top};
-}
-
-export function AutofixHighlightPopup(props: Props) {
- const {referenceElement} = props;
- const popupRef = useRef(null);
- const [position, setPosition] = useState({
- left: 0,
- top: 0,
- });
- const [width, setWidth] = useState(undefined);
- const [isFocused, setIsFocused] = useState(false);
-
- const theme = useTheme();
- const isSmallScreen = useMedia(`(max-width: ${theme.breakpoints.sm})`);
-
- useLayoutEffect(() => {
- if (!referenceElement || !popupRef.current) {
- return;
- }
-
- const updatePosition = () => {
- if (!referenceElement || !popupRef.current) {
- return;
- }
-
- const referenceRect = referenceElement.getBoundingClientRect();
- const popupRect = popupRef.current.getBoundingClientRect();
- const viewportWidth = window.innerWidth;
-
- const drawerElement = document.querySelector('.drawer-panel');
- const drawerWidth = drawerElement
- ? drawerElement.getBoundingClientRect().width
- : undefined;
-
- // Calculate available width for the popup
- const availableWidth = viewportWidth - (drawerWidth ?? viewportWidth * 0.5) - 16;
- const defaultWidth = 300;
- const newWidth = Math.min(defaultWidth, Math.max(200, availableWidth));
-
- startTransition(() => {
- setPosition(getOptimalPosition(referenceRect, popupRect, drawerWidth));
- setWidth(newWidth);
- });
- };
-
- // Initial position
- updatePosition();
-
- // Create observers to track both elements
- const referenceObserver = new ResizeObserver(updatePosition);
- const popupObserver = new ResizeObserver(updatePosition);
-
- referenceObserver.observe(referenceElement);
- popupObserver.observe(popupRef.current);
-
- // Track scroll events
- const scrollElements = [window, ...getScrollParents(referenceElement)];
- scrollElements.forEach(element => {
- element.addEventListener('scroll', updatePosition, {passive: true});
- });
-
- // Track window resize
- window.addEventListener('resize', updatePosition, {passive: true});
-
- return () => {
- referenceObserver.disconnect();
- popupObserver.disconnect();
- scrollElements.forEach(element => {
- element.removeEventListener('scroll', updatePosition);
- });
- window.removeEventListener('resize', updatePosition);
- };
- }, [referenceElement]);
-
- const handleFocus = () => {
- setIsFocused(true);
- };
-
- const handleBlur = () => {
- setIsFocused(false);
- };
-
- if (isSmallScreen) {
- return null;
- }
-
- return createPortal(
-
-
-
-
- ,
- document.body
- );
-}
-
-const Wrapper = styled(motion.div)<{isFocused?: boolean}>`
- z-index: ${p => (p.isFocused ? p.theme.zIndex.tooltip + 1 : p.theme.zIndex.tooltip)};
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- margin-right: ${p => p.theme.space.md};
- gap: ${p => p.theme.space.md};
- max-width: 300px;
- min-width: 200px;
- position: fixed;
- will-change: transform;
-`;
-
-const ScaleContainer = styled(motion.div)<{isFocused?: boolean}>`
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- transform-origin: top right;
- padding-left: ${p => p.theme.space.xl};
- transform: scale(${p => (p.isFocused ? 1 : 0.9)});
- transition: transform 200ms ease;
-`;
-
-const Container = styled(motion.div, {
- shouldForwardProp: prop => prop !== 'isFocused',
-})<{isFocused?: boolean}>`
- position: relative;
- width: 100%;
- background: ${p => p.theme.tokens.background.primary};
- border-radius: ${p => p.theme.radius.md};
- border: 1px dashed ${p => p.theme.tokens.border.primary};
- overflow: hidden;
- box-shadow: ${p => (p.isFocused ? p.theme.shadow.high : p.theme.shadow.low)};
- transition: box-shadow 200ms ease;
-
- &:before {
- content: '';
- position: absolute;
- inset: 0;
- background: linear-gradient(
- 90deg,
- transparent,
- color-mix(
- in srgb,
- ${p => p.theme.tokens.background.accent.vibrant} 12.5%,
- transparent
- ),
- transparent
- );
- background-size: 2000px 100%;
- pointer-events: none;
- }
-`;
-
-const InputWrapper = styled('form')`
- display: flex;
- padding: ${p => p.theme.space.xs};
- background: ${p => p.theme.tokens.background.secondary};
- position: relative;
-`;
-
-const StyledInput = styled(TextArea)`
- flex-grow: 1;
- border-color: ${p => p.theme.tokens.border.secondary};
- padding-right: ${p => p.theme.space['3xl']};
- padding-top: ${p => p.theme.space.sm};
- padding-bottom: ${p => p.theme.space.sm};
- resize: none;
-
- &:hover {
- border-color: ${p => p.theme.tokens.border.primary};
- }
-`;
-
-const StyledButton = styled(Button)`
- position: absolute;
- right: ${p => p.theme.space.md};
- top: 50%;
- transform: translateY(-50%);
- height: 24px;
- width: 24px;
- margin-right: 0;
- color: ${p => p.theme.tokens.content.secondary};
- z-index: 2;
-`;
-
-const Header = styled('div')`
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: ${p => p.theme.space.md} ${p => p.theme.space.lg};
- background: ${p => p.theme.tokens.background.secondary};
- word-break: break-word;
- overflow-wrap: break-word;
-`;
-
-const SelectedText = styled('div')`
- font-size: ${p => p.theme.font.size.sm};
- color: ${p => p.theme.tokens.content.secondary};
- align-items: center;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-
- span {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-`;
-
-const Arrow = styled('div')`
- position: absolute;
- width: 12px;
- height: 12px;
- background: ${p => p.theme.tokens.background.secondary};
- border: 1px dashed ${p => p.theme.tokens.border.primary};
- border-right: none;
- border-bottom: none;
- top: 20px;
- right: -6px;
- transform: rotate(135deg);
- z-index: 1;
-`;
-
-const MessagesContainer = styled('div')`
- padding: ${p => p.theme.space.md};
- display: flex;
- flex-direction: column;
- gap: ${p => p.theme.space.xs};
- max-height: 200px;
- overflow-y: auto;
- scroll-behavior: smooth;
-`;
-
-const Message = styled('div')<{role: CommentThreadMessage['role']}>`
- display: flex;
- gap: ${p => p.theme.space.md};
- align-items: flex-start;
-`;
-
-const MessageContent = styled('div')`
- flex-grow: 1;
- border-radius: ${p => p.theme.radius.md};
- padding-top: ${p => p.theme.space.xs};
- font-size: ${p => p.theme.font.size.sm};
- color: ${p => p.theme.tokens.content.primary};
- word-break: break-word;
- overflow-wrap: break-word;
- white-space: pre-wrap;
-
- code {
- font-size: ${p => p.theme.font.size.xs};
- background: transparent;
- }
-`;
-
-const CircularSeerIcon = styled('div')`
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- border-radius: 50%;
- background: ${p => p.theme.tokens.background.accent.vibrant};
- flex-shrink: 0;
-
- > svg {
- width: 18px;
- height: 18px;
- color: ${p => p.theme.tokens.content.onVibrant.light};
- }
-`;
-
-const ResolveButton = styled(Button)`
- margin-left: ${p => p.theme.space.md};
-`;
-
-const ReworkHeaderSection = styled('div')`
- display: flex;
- align-items: center;
- justify-content: space-between;
- cursor: pointer;
- transition: opacity 0.2s ease;
- flex: 1;
-`;
-
-const ReworkText = styled('span')`
- font-size: ${p => p.theme.font.size.sm};
- color: ${p => p.theme.tokens.content.secondary};
-
- ${ReworkHeaderSection}:hover & {
- color: ${p => p.theme.tokens.content.primary};
- }
-`;
-
-const ReworkArrow = styled('div')`
- display: flex;
- align-items: center;
- transition: transform 0.2s ease;
-
- ${ReworkHeaderSection}:hover & {
- transform: translateX(2px);
- }
-`;
-
-function getScrollParents(element: HTMLElement): Element[] {
- const scrollParents: Element[] = [];
- let currentElement = element.parentElement;
-
- while (currentElement) {
- const overflow = window.getComputedStyle(currentElement).overflow;
- if (overflow.includes('scroll') || overflow.includes('auto')) {
- scrollParents.push(currentElement);
- }
- currentElement = currentElement.parentElement;
- }
-
- return scrollParents;
-}
diff --git a/static/app/components/events/autofix/autofixHighlightWrapper.tsx b/static/app/components/events/autofix/autofixHighlightWrapper.tsx
deleted file mode 100644
index bc79bb2790615d..00000000000000
--- a/static/app/components/events/autofix/autofixHighlightWrapper.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import {Fragment, useEffect, useRef, useState} from 'react';
-import {css} from '@emotion/react';
-import styled from '@emotion/styled';
-import {AnimatePresence} from 'framer-motion';
-
-import {AutofixHighlightPopup} from 'sentry/components/events/autofix/autofixHighlightPopup';
-import {useTextSelection} from 'sentry/components/events/autofix/useTextSelection';
-import {t} from 'sentry/locale';
-
-interface AutofixHighlightWrapperProps {
- children: React.ReactNode;
- groupId: string;
- runId: string;
- stepIndex: number;
- className?: string;
- displayName?: string;
- isAgentComment?: boolean;
- ref?: React.RefObject;
- retainInsightCardIndex?: number | null;
-}
-
-/**
- * A wrapper component that handles text selection and renders AutofixHighlightPopup
- * when text is selected within its children.
- */
-export function AutofixHighlightWrapper({
- children,
- groupId,
- runId,
- stepIndex,
- retainInsightCardIndex = null,
- isAgentComment = false,
- className,
- displayName,
- ref,
-}: AutofixHighlightWrapperProps) {
- const internalRef = useRef(null);
- const containerRef = ref || internalRef;
- const selection = useTextSelection(containerRef);
-
- const [shouldPersist, setShouldPersist] = useState(false);
- const lastSelectedText = useRef(null);
- const lastReferenceElement = useRef(null);
-
- useEffect(() => {
- if (selection) {
- lastSelectedText.current = selection.selectedText;
- lastReferenceElement.current = selection.referenceElement;
- }
- }, [selection]);
-
- return (
-
-
- {children}
-
-
-
- {(selection || shouldPersist) && (
-
- )}
-
-
- );
-}
-
-const Wrapper = styled('div')<{isSelected: boolean}>`
- &:hover {
- ${p =>
- !p.isSelected &&
- css`
- cursor: pointer;
- `};
- }
-`;
diff --git a/static/app/components/events/autofix/autofixInsightCards.spec.tsx b/static/app/components/events/autofix/autofixInsightCards.spec.tsx
deleted file mode 100644
index 377065e65cb053..00000000000000
--- a/static/app/components/events/autofix/autofixInsightCards.spec.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
-
-import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator';
-import {AutofixInsightCards} from 'sentry/components/events/autofix/insights/autofixInsightCards';
-import type {AutofixInsight} from 'sentry/components/events/autofix/types';
-
-jest.mock('sentry/actionCreators/indicator');
-
-const sampleInsights: AutofixInsight[] = [
- {
- insight: 'Sample insight 1',
- justification: 'Sample justification 1',
- },
- {
- insight: 'User message',
- justification: 'USER',
- },
-];
-
-beforeEach(() => {
- jest.mocked(addLoadingMessage).mockClear();
- jest.mocked(addErrorMessage).mockClear();
- MockApiClient.clearMockResponses();
-});
-
-describe('AutofixInsightCards', () => {
- const renderComponent = (props = {}) => {
- return render(
-
- );
- };
-
- it('renders insights correctly', () => {
- renderComponent();
- expect(screen.getByText('Sample insight 1')).toBeInTheDocument();
- expect(screen.getByText('User message')).toBeInTheDocument();
- });
-
- it('toggles context expansion correctly', async () => {
- renderComponent();
- const contextButton = screen.getByText('Sample insight 1');
-
- await userEvent.click(contextButton);
- await waitFor(() => {
- expect(screen.getByText('Sample justification 1')).toBeInTheDocument();
- });
-
- await userEvent.click(contextButton);
- await waitFor(() => {
- expect(screen.queryByText('Sample justification 1')).not.toBeInTheDocument();
- });
- });
-
- it('renders multiple insights correctly', () => {
- const multipleInsights = [
- ...sampleInsights,
- {
- insight: 'Another insight',
- justification: 'Another justification',
- },
- ];
- renderComponent({insights: multipleInsights});
- expect(screen.getByText('Sample insight 1')).toBeInTheDocument();
- expect(screen.getByText('User message')).toBeInTheDocument();
- expect(screen.getByText('Another insight')).toBeInTheDocument();
- });
-
- it('renders "Edit insight" buttons', () => {
- renderComponent();
- const editButtons = screen.getAllByRole('button', {name: 'Edit insight'});
- expect(editButtons.length).toBeGreaterThan(0);
- });
-
- it('shows edit input overlay when "Edit insight" is clicked', async () => {
- renderComponent();
- const editButton = screen.getAllByRole('button', {name: 'Edit insight'})[0]!;
- await userEvent.click(editButton);
- expect(
- screen.getByPlaceholderText('Share your own insight here...')
- ).toBeInTheDocument();
- });
-
- it('hides edit input when clicked cancel', async () => {
- renderComponent();
- const editButton = screen.getAllByRole('button', {name: 'Edit insight'})[0]!;
- await userEvent.click(editButton);
- expect(
- screen.getByPlaceholderText('Share your own insight here...')
- ).toBeInTheDocument();
-
- await userEvent.click(screen.getByLabelText('Cancel'));
- expect(
- screen.queryByPlaceholderText('Share your own insight here...')
- ).not.toBeInTheDocument();
- });
-
- it('submits edit request when form is submitted', async () => {
- const mockApi = MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/update/',
- method: 'POST',
- });
-
- renderComponent();
- const editButton = screen.getAllByRole('button', {name: 'Edit insight'})[1]!;
- await userEvent.click(editButton);
-
- const input = screen.getByPlaceholderText('Share your own insight here...');
- await userEvent.type(input, 'Here is my insight.');
-
- const submitButton = screen.getByLabelText('Redo work from here');
- await userEvent.click(submitButton);
-
- expect(mockApi).toHaveBeenCalledWith(
- '/organizations/org-slug/issues/1/autofix/update/',
- expect.objectContaining({
- method: 'POST',
- data: expect.objectContaining({
- run_id: '1',
- payload: expect.objectContaining({
- type: 'restart_from_point_with_feedback',
- message: 'Here is my insight.',
- step_index: 0,
- retain_insight_card_index: 1,
- }),
- }),
- })
- );
- });
-
- it('shows loading message after successful edit submission', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/update/',
- method: 'POST',
- });
-
- renderComponent();
- const editButton = screen.getAllByRole('button', {name: 'Edit insight'})[0]!;
- await userEvent.click(editButton);
-
- const input = screen.getByPlaceholderText('Share your own insight here...');
- await userEvent.type(input, 'Here is my insight.');
-
- const submitButton = screen.getByLabelText('Redo work from here');
- await userEvent.click(submitButton);
-
- await waitFor(() => {
- expect(addLoadingMessage).toHaveBeenCalledWith('Rethinking this...');
- });
- });
-
- it('shows error message after failed edit submission', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/update/',
- method: 'POST',
- statusCode: 500,
- });
-
- renderComponent();
- const editButton = screen.getAllByRole('button', {name: 'Edit insight'})[0]!;
- await userEvent.click(editButton);
-
- const input = screen.getByPlaceholderText('Share your own insight here...');
- await userEvent.type(input, 'Here is my insight.');
-
- const submitButton = screen.getByLabelText('Redo work from here');
- await userEvent.click(submitButton);
-
- await waitFor(() => {
- expect(addErrorMessage).toHaveBeenCalledWith(
- 'Something went wrong when sending Seer your message.'
- );
- });
- });
-});
diff --git a/static/app/components/events/autofix/autofixOutputStream.spec.tsx b/static/app/components/events/autofix/autofixOutputStream.spec.tsx
deleted file mode 100644
index 6631b51ef672b0..00000000000000
--- a/static/app/components/events/autofix/autofixOutputStream.spec.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
-
-import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
-
-import {AutofixOutputStream} from './autofixOutputStream';
-
-jest.mock('sentry/actionCreators/indicator');
-
-describe('AutofixOutputStream', () => {
- const mockApi = {
- requestPromise: jest.fn(),
- };
-
- beforeEach(() => {
- mockApi.requestPromise.mockReset();
- jest.mocked(addSuccessMessage).mockClear();
- jest.mocked(addErrorMessage).mockClear();
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- });
- });
-
- it('renders basic stream content', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Hello World')).toBeInTheDocument();
- });
- expect(screen.getByPlaceholderText('Add context...')).toBeInTheDocument();
- });
-
- it('renders active log when provided', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Active log message')).toBeInTheDocument();
- });
- });
-
- it('shows required input placeholder when responseRequired is true', () => {
- render( );
-
- expect(
- screen.getByPlaceholderText('Please answer to continue...')
- ).toBeInTheDocument();
- });
-
- it('prevents empty message submission', async () => {
- render( );
-
- const user = userEvent.setup();
- await user.click(screen.getByRole('button', {name: 'Submit Comment'}));
-
- expect(mockApi.requestPromise).not.toHaveBeenCalled();
- });
-
- it('animates new stream content', async () => {
- const {rerender} = render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Initial')).toBeInTheDocument();
- });
-
- rerender(
-
- );
-
- // Wait for animation to complete
- await waitFor(() => {
- expect(screen.getByText('Initial content updated')).toBeInTheDocument();
- });
- });
-
- it('handles user interruption', async () => {
- render( );
-
- const user = userEvent.setup();
- const input = screen.getByPlaceholderText('Add context...');
-
- await user.type(input, 'Test message');
- await user.click(screen.getByRole('button', {name: 'Submit Comment'}));
-
- await waitFor(() => {
- expect(addSuccessMessage).toHaveBeenCalledWith('Thanks for the input.');
- });
- });
-
- it('shows error message when user interruption fails', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- statusCode: 500,
- });
-
- render( );
-
- const user = userEvent.setup();
- const input = screen.getByPlaceholderText('Add context...');
-
- await user.type(input, 'Test message');
- await user.click(screen.getByRole('button', {name: 'Submit Comment'}));
-
- await waitFor(() => {
- expect(addErrorMessage).toHaveBeenCalledWith(
- 'Something went wrong when sending Seer your message.'
- );
- });
- });
-});
diff --git a/static/app/components/events/autofix/autofixOutputStream.tsx b/static/app/components/events/autofix/autofixOutputStream.tsx
deleted file mode 100644
index 2280e79959812e..00000000000000
--- a/static/app/components/events/autofix/autofixOutputStream.tsx
+++ /dev/null
@@ -1,480 +0,0 @@
-import {startTransition, useEffect, useRef, useState, type FormEvent} from 'react';
-import {keyframes} from '@emotion/react';
-import styled from '@emotion/styled';
-import {useMutation, useQueryClient} from '@tanstack/react-query';
-import {AnimatePresence, motion} from 'framer-motion';
-
-import {Button} from '@sentry/scraps/button';
-import {TextArea} from '@sentry/scraps/textarea';
-import {Tooltip} from '@sentry/scraps/tooltip';
-
-import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
-import {AutofixProgressBar} from 'sentry/components/events/autofix/autofixProgressBar';
-import {FlyingLinesEffect} from 'sentry/components/events/autofix/FlyingLinesEffect';
-import {useUpdateInsightCard} from 'sentry/components/events/autofix/hooks/useUpdateInsightCard';
-import type {AutofixData} from 'sentry/components/events/autofix/types';
-import {AutofixStepType} from 'sentry/components/events/autofix/types';
-import {autofixApiOptions} from 'sentry/components/events/autofix/useAutofix';
-import {useTypingAnimation} from 'sentry/components/events/autofix/useTypingAnimation';
-import {getAutofixRunErrorMessage} from 'sentry/components/events/autofix/utils';
-import {IconRefresh, IconSeer} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {singleLineRenderer} from 'sentry/utils/marked/marked';
-import {useApi} from 'sentry/utils/useApi';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-function StreamContentText({stream}: {stream: string}) {
- const [displayedText, setDisplayedText] = useState('');
-
- const accumulatedTextRef = useRef('');
- const previousStreamPropRef = useRef('');
- const currentIndexRef = useRef(0);
-
- // Animation for stream text
- useEffect(() => {
- const newText = stream;
- const previousStream = previousStreamPropRef.current;
- const separator = '\n\n==========\n\n';
-
- const currentSegmentDisplayed = displayedText.slice(
- accumulatedTextRef.current.length
- );
- const isReset =
- newText !== previousStream && !newText.startsWith(currentSegmentDisplayed);
-
- if (isReset) {
- if (displayedText === previousStream) {
- accumulatedTextRef.current = displayedText + separator;
- currentIndexRef.current = accumulatedTextRef.current.length;
- } else {
- const fullPreviousTextWithSeparator = previousStream + separator;
- startTransition(() => {
- setDisplayedText(fullPreviousTextWithSeparator);
- });
- accumulatedTextRef.current = fullPreviousTextWithSeparator;
- currentIndexRef.current = fullPreviousTextWithSeparator.length;
- }
- }
-
- previousStreamPropRef.current = newText;
-
- const combinedText = accumulatedTextRef.current + newText;
-
- if (currentIndexRef.current > combinedText.length) {
- currentIndexRef.current = combinedText.length;
- if (displayedText !== combinedText) {
- startTransition(() => {
- setDisplayedText(combinedText);
- });
- }
- }
-
- let intervalId: number | undefined;
- if (currentIndexRef.current < combinedText.length) {
- intervalId = window.setInterval(() => {
- if (currentIndexRef.current < combinedText.length) {
- startTransition(() => {
- setDisplayedText(combinedText.slice(0, currentIndexRef.current + 1));
- });
- currentIndexRef.current++;
- } else {
- window.clearInterval(intervalId);
- intervalId = undefined;
- }
- }, 1);
- }
-
- return () => {
- if (intervalId) {
- window.clearInterval(intervalId);
- }
- };
- }, [stream, displayedText]);
-
- return {displayedText} ;
-}
-
-interface Props {
- groupId: string;
- runId: string;
- stream: string;
- activeLog?: string;
- autofixData?: AutofixData;
- isProcessing?: boolean;
- responseRequired?: boolean;
-}
-
-interface ActiveLogDisplayProps {
- groupId: string;
- runId: string;
- activeLog?: string;
- autofixData?: AutofixData;
- isInitializingRun?: boolean;
- seerIconRef?: React.RefObject;
-}
-
-function ActiveLogDisplay({
- activeLog = '',
- isInitializingRun = false,
- seerIconRef,
- autofixData,
- groupId,
- runId,
-}: ActiveLogDisplayProps) {
- const displayedActiveLog =
- useTypingAnimation({
- text: activeLog,
- speed: 200,
- enabled: !!activeLog,
- }) || '';
-
- // special case for errored step
- const errorMessage = getAutofixRunErrorMessage(autofixData);
- const erroredStep = autofixData?.steps?.find(step => step.status === 'ERROR');
- const erroredStepIndex = erroredStep?.index ?? 0;
- let retainInsightCardIndex: number | null = null;
- if (
- erroredStep?.type === AutofixStepType.DEFAULT &&
- Array.isArray((erroredStep as any).insights)
- ) {
- const insights = (erroredStep as any).insights;
- if (insights.length > 0) {
- retainInsightCardIndex = insights.length;
- }
- }
-
- const {mutate: refreshStep, isPending: isRefreshing} = useUpdateInsightCard({
- groupId,
- runId,
- });
-
- if (errorMessage) {
- return (
-
-
-
-
- {errorMessage}
-
- refreshStep({
- message: '',
- step_index: erroredStepIndex,
- retain_insight_card_index: retainInsightCardIndex,
- })
- }
- disabled={isRefreshing}
- style={{marginLeft: 'auto'}}
- >
-
-
-
- );
- }
- if (activeLog) {
- return (
-
-
-
-
- {seerIconRef?.current && isInitializingRun && (
-
- )}
-
-
-
-
- );
- }
- return null;
-}
-
-export function AutofixOutputStream({
- stream,
- activeLog = '',
- groupId,
- runId,
- autofixData,
- responseRequired = false,
-}: Props) {
- const api = useApi({persistInFlight: true});
- const queryClient = useQueryClient();
-
- const [message, setMessage] = useState('');
- const seerIconRef = useRef(null);
-
- const isInitializingRun = activeLog === 'Ingesting Sentry data...';
-
- const orgSlug = useOrganization().slug;
-
- const {mutate: send} = useMutation({
- mutationFn: (params: {message: string}) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'user_message',
- text: params.message,
- },
- },
- }
- );
- },
- onSuccess: _ => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- addSuccessMessage(t('Thanks for the input.'));
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when sending Seer your message.'));
- },
- });
-
- const handleSend = (e: FormEvent) => {
- e.preventDefault();
- if (isInitializingRun) {
- // don't send message during loading state
- return;
- }
- if (message.trim() !== '') {
- send({message});
- setMessage('');
- }
- };
-
- return (
-
-
-
-
-
- {getAutofixRunErrorMessage(autofixData) || activeLog ? (
-
- ) : null}
- {autofixData && (
-
-
-
- )}
- {!responseRequired && stream && }
-
- setMessage(e.target.value)}
- maxLength={4096}
- placeholder={
- responseRequired ? 'Please answer to continue...' : 'Add context...'
- }
- onKeyDown={e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSend(e);
- }
- }}
- maxRows={5}
- size="sm"
- />
-
- {'\u23CE'}
-
-
-
-
-
-
- );
-}
-
-const Wrapper = styled(motion.div)`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- margin-bottom: ${p => p.theme.space.md};
- gap: ${p => p.theme.space.md};
-`;
-
-const ScaleContainer = styled(motion.div)`
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- transform-origin: top left;
-`;
-
-const shimmer = keyframes`
- 0% {
- background-position: -1000px 0;
- }
- 100% {
- background-position: 1000px 0;
- }
-`;
-
-const Container = styled(motion.div)<{required: boolean}>`
- position: relative;
- width: 100%;
- background: ${p => p.theme.tokens.background.primary};
- border-radius: ${p => p.theme.radius.md};
- border: 1px dashed ${p => p.theme.tokens.border.primary};
-
- &:before {
- content: '';
- position: absolute;
- inset: 0;
- background: linear-gradient(
- 90deg,
- transparent,
- color-mix(
- in srgb,
- ${p =>
- p.required
- ? p.theme.colors.pink500
- : p.theme.tokens.background.accent.vibrant}
- 12.5%,
- transparent
- ),
- transparent
- );
- background-size: 2000px 100%;
- border-radius: ${p => p.theme.radius.md};
- animation: ${shimmer} 2s infinite linear;
- pointer-events: none;
- }
-`;
-
-const StreamContent = styled('div')`
- margin: 0;
- padding: ${p => p.theme.space.xl};
- white-space: pre-wrap;
- word-break: break-word;
- color: ${p => p.theme.tokens.content.secondary};
- max-height: 35vh;
- overflow-y: auto;
- display: flex;
- flex-direction: column-reverse;
-`;
-
-const ActiveLogWrapper = styled('div')`
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- padding: ${p => p.theme.space.md};
- background: ${p => p.theme.tokens.background.secondary};
- gap: ${p => p.theme.space.md};
- overflow: visible;
-`;
-
-const ActiveLog = styled('div')`
- flex-grow: 1;
- word-break: break-word;
- margin-top: ${p => p.theme.space['2xs']};
-`;
-
-const VerticalLine = styled('div')`
- width: 0;
- height: ${p => p.theme.space['3xl']};
- border-left: 1px dashed ${p => p.theme.tokens.border.primary};
- margin-left: 33px;
- margin-bottom: -1px;
-`;
-
-const InputWrapper = styled('form')`
- display: flex;
- padding: ${p => p.theme.space.xs};
- position: relative;
-`;
-
-const StyledInput = styled(TextArea)`
- flex-grow: 1;
- border-color: ${p => p.theme.tokens.border.secondary};
- padding-right: ${p => p.theme.space['3xl']};
- resize: none;
-
- &:hover {
- border-color: ${p => p.theme.tokens.border.primary};
- }
-`;
-
-const StyledButton = styled(Button)`
- position: absolute;
- right: ${p => p.theme.space.md};
- top: 50%;
- transform: translateY(-50%);
- height: 24px;
- width: 24px;
- margin-right: 0;
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const SeerIconContainer = styled('div')`
- position: relative;
- flex-shrink: 0;
-`;
-
-const StyledAnimatedSeerIcon = styled(IconSeer)`
- position: relative;
- transition: opacity 0.2s ease;
- top: 0;
- flex-shrink: 0;
- color: ${p => p.theme.tokens.content.primary};
- z-index: 10000;
-`;
-
-const ProgressBarWrapper = styled('div')`
- position: relative;
-`;
diff --git a/static/app/components/events/autofix/autofixProgressBar.tsx b/static/app/components/events/autofix/autofixProgressBar.tsx
deleted file mode 100644
index df2acf0069dd40..00000000000000
--- a/static/app/components/events/autofix/autofixProgressBar.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import {useMemo} from 'react';
-import styled from '@emotion/styled';
-
-import type {AutofixData} from './types';
-import {getAutofixProgressDetails} from './utils';
-
-interface AutofixProgressBarProps {
- autofixData?: AutofixData;
-}
-
-function AutofixProgressBar({autofixData}: AutofixProgressBarProps) {
- const {overallProgress} = useMemo(
- () => getAutofixProgressDetails(autofixData),
- [autofixData]
- );
-
- return (
-
-
-
-
-
-
-
- );
-}
-
-const ProgressBarContainer = styled('div')<{hasData: boolean}>`
- position: sticky;
- top: 0;
- left: 0;
- right: 0;
- width: 100%;
- height: 2px;
- transition: height 0.2s ease-in-out;
-`;
-
-const ProgressBarWrapper = styled('div')`
- position: relative;
- width: 100%;
- height: 100%;
-`;
-
-const ProgressBarTrack = styled('div')`
- position: absolute;
- width: 100%;
- height: 2px;
- background-color: ${p => p.theme.tokens.graphics.neutral.moderate};
-`;
-
-const ProgressBarFill = styled('div')`
- height: 100%;
- background-color: ${p => p.theme.tokens.graphics.accent.vibrant};
- opacity: 0.7;
- transition: width 1s ease-in-out;
-`;
-
-export {AutofixProgressBar};
diff --git a/static/app/components/events/autofix/autofixRootCause.spec.tsx b/static/app/components/events/autofix/autofixRootCause.spec.tsx
deleted file mode 100644
index 5f69003bb62429..00000000000000
--- a/static/app/components/events/autofix/autofixRootCause.spec.tsx
+++ /dev/null
@@ -1,372 +0,0 @@
-import {AutofixRootCauseData} from 'sentry-fixture/autofixRootCauseData';
-
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
-
-import {AutofixRootCause} from 'sentry/components/events/autofix/autofixRootCause';
-import {AutofixStatus} from 'sentry/components/events/autofix/types';
-
-describe('AutofixRootCause', () => {
- beforeEach(() => {
- localStorage.clear();
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/update/',
- method: 'POST',
- body: {success: true},
- });
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {integrations: []},
- });
- });
-
- afterEach(() => {
- localStorage.clear();
- MockApiClient.clearMockResponses();
- jest.clearAllTimers();
- });
-
- const defaultProps = {
- causes: [AutofixRootCauseData()],
- groupId: '1',
- rootCauseSelection: null,
- runId: '101',
- status: AutofixStatus.COMPLETED,
- } satisfies React.ComponentProps;
-
- it('can view a relevant code snippet', async () => {
- render( );
-
- // Wait for initial render and animations
- expect(await screen.findByText('Root Cause')).toBeInTheDocument();
-
- expect(
- await screen.findByText(defaultProps.causes[0]!.root_cause_reproduction![0]!.title)
- ).toBeInTheDocument();
-
- await userEvent.click(screen.getByTestId('autofix-root-cause-timeline-item-0'));
-
- // Wait for code snippet to appear with increased timeout for animation
- expect(
- await screen.findByText(
- defaultProps.causes[0]!.root_cause_reproduction![0]!.code_snippet_and_analysis
- )
- ).toBeInTheDocument();
- });
-
- it('shows graceful error state when there are no causes', async () => {
- render(
-
- );
-
- // Wait for error state to render
- expect(
- await screen.findByText(
- 'No root cause found. The error comes from outside the codebase.'
- )
- ).toBeInTheDocument();
- });
-
- it('shows selected root cause when rootCauseSelection is provided', async () => {
- const selectedCause = AutofixRootCauseData();
- render(
-
- );
-
- // Wait for selected root cause to render
- expect(await screen.findByText('Root Cause')).toBeInTheDocument();
-
- expect(
- await screen.findByText(selectedCause.root_cause_reproduction![0]!.title)
- ).toBeInTheDocument();
- });
-
- it('saves preference when clicking Find Solution with Seer', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: 'cursor-integration-id',
- name: 'Cursor',
- provider: 'cursor',
- },
- ],
- },
- });
-
- render( );
-
- await userEvent.click(
- await screen.findByRole('button', {name: 'Find Solution with Seer'})
- );
-
- expect(JSON.parse(localStorage.getItem('autofix:rootCauseActionPreference')!)).toBe(
- 'seer_solution'
- );
- });
-
- it('saves preference when clicking Cursor agent', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: 'cursor-integration-id',
- name: 'Cursor',
- provider: 'cursor',
- },
- ],
- },
- });
-
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- method: 'POST',
- body: {success: true},
- });
-
- render( );
-
- // Find and open the dropdown
- const dropdownTrigger = await screen.findByRole('button', {
- name: 'More solution options',
- });
- await userEvent.click(dropdownTrigger);
-
- // Click the Cursor option in the dropdown
- await userEvent.click(await screen.findByText('Send to Cursor'));
-
- expect(JSON.parse(localStorage.getItem('autofix:rootCauseActionPreference')!)).toBe(
- 'agent:cursor-integration-id'
- );
- });
-
- it('shows Seer as primary button by default', async () => {
- render( );
-
- expect(
- await screen.findByRole('button', {name: 'Find Solution'})
- ).toBeInTheDocument();
- });
-
- it('shows Seer as primary when preference is seer', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: 'cursor-integration-id',
- name: 'Cursor',
- provider: 'cursor',
- },
- ],
- },
- });
-
- localStorage.setItem(
- 'autofix:rootCauseActionPreference',
- JSON.stringify('seer_solution')
- );
-
- render( );
-
- expect(
- await screen.findByRole('button', {name: 'Find Solution with Seer'})
- ).toBeInTheDocument();
- });
-
- it('shows Cursor as primary when preference is cursor', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: 'cursor-integration-id',
- name: 'Cursor',
- provider: 'cursor',
- },
- ],
- },
- });
-
- localStorage.setItem(
- 'autofix:rootCauseActionPreference',
- JSON.stringify('agent:cursor-integration-id')
- );
-
- render( );
-
- expect(
- await screen.findByRole('button', {name: 'Send to Cursor'})
- ).toBeInTheDocument();
-
- // Verify Seer option is in the dropdown
- const dropdownTrigger = await screen.findByRole('button', {
- name: 'More solution options',
- });
- await userEvent.click(dropdownTrigger);
-
- expect(await screen.findByText('Find Solution with Seer')).toBeInTheDocument();
- });
-
- it('shows Cursor as primary when using legacy cursor: prefix (backwards compatibility)', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: 'cursor-integration-id',
- name: 'Cursor',
- provider: 'cursor',
- },
- ],
- },
- });
-
- // Use the legacy 'cursor:' prefix that existing users may have stored
- localStorage.setItem(
- 'autofix:rootCauseActionPreference',
- JSON.stringify('cursor:cursor-integration-id')
- );
-
- render( );
-
- expect(
- await screen.findByRole('button', {name: 'Send to Cursor'})
- ).toBeInTheDocument();
- });
-
- it('both options accessible in dropdown', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: 'cursor-integration-id',
- name: 'Cursor',
- provider: 'cursor',
- },
- ],
- },
- });
-
- render( );
-
- // Primary button is Seer (when cursor integration exists, show "with Seer" to distinguish)
- expect(
- await screen.findByRole('button', {name: 'Find Solution with Seer'})
- ).toBeInTheDocument();
-
- // Open dropdown to find Cursor option
- const dropdownTrigger = await screen.findByRole('button', {
- name: 'More solution options',
- });
- await userEvent.click(dropdownTrigger);
-
- expect(await screen.findByText('Send to Cursor')).toBeInTheDocument();
- });
-
- it('shows Setup button for integration requiring identity but lacking it', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: null,
- name: 'GitHub Copilot',
- provider: 'github_copilot',
- requires_identity: true,
- has_identity: false,
- },
- ],
- },
- });
-
- localStorage.setItem(
- 'autofix:rootCauseActionPreference',
- JSON.stringify('agent:github_copilot')
- );
-
- render( );
-
- expect(
- await screen.findByRole('button', {name: 'Setup GitHub Copilot'})
- ).toBeInTheDocument();
- });
-
- it('shows Send to button for integration with identity', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: null,
- name: 'GitHub Copilot',
- provider: 'github_copilot',
- requires_identity: true,
- has_identity: true,
- },
- ],
- },
- });
-
- localStorage.setItem(
- 'autofix:rootCauseActionPreference',
- JSON.stringify('agent:github_copilot')
- );
-
- render( );
-
- expect(
- await screen.findByRole('button', {name: 'Send to GitHub Copilot'})
- ).toBeInTheDocument();
- });
-
- it('shows Setup option in dropdown for integration requiring identity but lacking it', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {
- integrations: [
- {
- id: 'cursor-integration-id',
- name: 'Cursor',
- provider: 'cursor',
- },
- {
- id: null,
- name: 'GitHub Copilot',
- provider: 'github_copilot',
- requires_identity: true,
- has_identity: false,
- },
- ],
- },
- });
-
- render( );
-
- // Open dropdown
- const dropdownTrigger = await screen.findByRole('button', {
- name: 'More solution options',
- });
- await userEvent.click(dropdownTrigger);
-
- // GitHub Copilot should show "Setup" since it requires identity but user hasn't authenticated
- expect(await screen.findByText('Setup GitHub Copilot')).toBeInTheDocument();
- // Cursor should show "Send to" since it doesn't require identity
- 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 295669c6ac8c54..382f0aac706764 100644
--- a/static/app/components/events/autofix/autofixRootCause.tsx
+++ b/static/app/components/events/autofix/autofixRootCause.tsx
@@ -1,179 +1,4 @@
-import React, {Fragment, useRef, useState} from 'react';
-import styled from '@emotion/styled';
-import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
-import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion';
-
-import {Alert} from '@sentry/scraps/alert';
-import {Button, ButtonBar} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-import {TextArea} from '@sentry/scraps/textarea';
-
-import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator';
-import {DropdownMenu} from 'sentry/components/dropdownMenu';
-import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper';
-import {AutofixStepFeedback} from 'sentry/components/events/autofix/autofixStepFeedback';
-import {
- AutofixStatus,
- type AutofixRootCauseData,
- type AutofixRootCauseSelection,
- type CodingAgentState,
- type CommentThread,
-} from 'sentry/components/events/autofix/types';
-import {
- autofixApiOptions,
- organizationIntegrationsCodingAgents,
- useLaunchCodingAgent,
- type CodingAgentIntegration,
-} from 'sentry/components/events/autofix/useAutofix';
-import {formatRootCauseWithEvent} from 'sentry/components/events/autofix/utils';
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
-import {IconChat, IconChevron, IconCopy, IconFocus} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {PluginIcon} from 'sentry/plugins/components/pluginIcon';
-import type {Event} from 'sentry/types/event';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {singleLineRenderer} from 'sentry/utils/marked/marked';
-import {useApi} from 'sentry/utils/useApi';
-import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard';
-import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useUser} from 'sentry/utils/useUser';
-
-import {AutofixHighlightPopup} from './autofixHighlightPopup';
-import {AutofixTimeline} from './autofixTimeline';
-function useSelectRootCause({groupId, runId}: {groupId: string; runId: string}) {
- const api = useApi();
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
-
- return useMutation({
- mutationFn: (params: {cause_id: string; instruction?: string}) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'select_root_cause',
- cause_id: params.cause_id,
- instruction: params.instruction || null,
- },
- },
- }
- );
- },
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- addLoadingMessage(t('On it...'));
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when selecting the root cause.'));
- },
- });
-}
-
-type AutofixRootCauseProps = {
- causes: AutofixRootCauseData[];
- groupId: string;
- rootCauseSelection: AutofixRootCauseSelection;
- runId: string;
- status: AutofixStatus;
- agentCommentThread?: CommentThread;
- codingAgents?: Record;
- event?: Event;
- isRootCauseFirstAppearance?: boolean;
- previousDefaultStepIndex?: number;
- previousInsightCount?: number;
- terminationReason?: string;
-};
-
-const cardAnimationProps: MotionNodeAnimationOptions = {
- exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
- initial: {opacity: 0, height: 0, scale: 0.8},
- animate: {opacity: 1, height: 'auto', scale: 1},
- transition: {
- duration: 1,
- height: {
- type: 'spring',
- bounce: 0.2,
- },
- scale: {
- type: 'spring',
- bounce: 0.2,
- },
- y: {
- type: 'tween',
- ease: 'easeOut',
- },
- },
-};
-
-export function replaceHeadersWithBold(markdown: string) {
- const headerRegex = /^(#{1,6})\s+(.*)$/gm;
- const boldMarkdown = markdown.replace(headerRegex, (_match, _hashes, content) => {
- return ` **${content}** `;
- });
-
- return boldMarkdown;
-}
-
-function RootCauseDescription({
- cause,
- groupId,
- runId,
- previousDefaultStepIndex,
- previousInsightCount,
- ref,
-}: {
- cause: AutofixRootCauseData;
- groupId: string;
- runId: string;
- previousDefaultStepIndex?: number;
- previousInsightCount?: number;
- ref?: React.RefObject;
-}) {
- return (
-
- {cause.description && (
- = 0
- ? previousInsightCount
- : null
- }
- >
-
-
- )}
- {cause.root_cause_reproduction && (
- = 0
- ? previousInsightCount
- : null
- }
- />
- )}
-
- );
-}
+import type {AutofixRootCauseData} from 'sentry/components/events/autofix/types';
export function formatRootCauseText(
cause: AutofixRootCauseData | undefined,
@@ -219,565 +44,3 @@ export function formatRootCauseText(
return parts.join('\n\n');
}
-
-function CopyRootCauseButton({
- cause,
- groupId,
- customRootCause,
- event,
-}: {
- groupId: string;
- cause?: AutofixRootCauseData;
- customRootCause?: string;
- event?: Event;
-}) {
- const text = formatRootCauseWithEvent(cause, customRootCause, event);
- const {copy} = useCopyToClipboard();
-
- return (
- copy(text, {successMessage: t('Analysis copied to clipboard.')})}
- analyticsEventName="Autofix: Copy Root Cause as Markdown"
- analyticsEventKey="autofix.root_cause.copy"
- analyticsParams={{group_id: groupId}}
- icon={ }
- >
- {t('Copy')}
-
- );
-}
-
-function SolutionActionButton({
- codingAgentIntegrations,
- preferredAction,
- primaryButtonPriority,
- isSelectingRootCause,
- isLaunchingAgent,
- isLoadingAgents,
- submitFindSolution,
- handleLaunchCodingAgent,
- findSolutionTitle,
-}: {
- codingAgentIntegrations: CodingAgentIntegration[];
- findSolutionTitle: string;
- handleLaunchCodingAgent: (integration: CodingAgentIntegration) => void;
- isLaunchingAgent: boolean;
- isLoadingAgents: boolean;
- isSelectingRootCause: boolean;
- preferredAction: string;
- primaryButtonPriority: React.ComponentProps['variant'];
- submitFindSolution: () => void;
-}) {
- // Support both 'agent:' (new) and 'cursor:' (legacy) prefixes for backwards compatibility
- const isAgentPreference =
- preferredAction.startsWith('agent:') || preferredAction.startsWith('cursor:');
- const preferredIntegration = isAgentPreference
- ? codingAgentIntegrations.find(i => {
- const key = preferredAction.replace(/^(agent|cursor):/, '');
- return i.id === key || (i.id === null && i.provider === key);
- })
- : 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 =
- codingAgentIntegrations.length > 1 &&
- new Set(codingAgentIntegrations.map(i => i.name)).size <
- codingAgentIntegrations.length;
-
- // If no integrations, show simple Seer button
- if (codingAgentIntegrations.length === 0) {
- return (
-
- {t('Find Solution')}
-
- );
- }
-
- const dropdownItems = [
- ...(isSeerPreferred
- ? []
- : [
- {
- key: 'seer_solution',
- label: t('Find Solution with Seer'),
- onAction: submitFindSolution,
- disabled: isSelectingRootCause,
- },
- ]),
- // Show all integrations except the currently preferred one
- ...codingAgentIntegrations
- .filter(integration => {
- // Compare by key to handle both 'agent:' and legacy 'cursor:' prefixes
- const integrationKey = integration.id ?? integration.provider;
- const effectiveKey = effectivePreference.replace(/^(agent|cursor):/, '');
- return integrationKey !== effectiveKey;
- })
- .map(integration => {
- const needsSetup = integration.requires_identity && !integration.has_identity;
- const actionLabel = needsSetup
- ? t('Setup %s', integration.name)
- : t('Send to %s', integration.name);
- const textValue = hasDuplicateNames
- ? `${actionLabel} (${integration.id ?? integration.provider})`
- : actionLabel;
- return {
- key: `agent:${integration.id ?? integration.provider}`,
- textValue,
- label: (
-
-
- {actionLabel}
- {hasDuplicateNames && (
-
- ({integration.id ?? integration.provider})
-
- )}
-
- ),
- onAction: () => handleLaunchCodingAgent(integration),
- disabled: isLoadingAgents || isLaunchingAgent,
- };
- }),
- ];
-
- const preferredNeedsSetup =
- preferredIntegration?.requires_identity && !preferredIntegration?.has_identity;
-
- const primaryButtonLabel = isSeerPreferred
- ? t('Find Solution with Seer')
- : hasDuplicateNames
- ? preferredNeedsSetup
- ? t(
- 'Setup %s (%s)',
- preferredIntegration.name,
- preferredIntegration.id ?? preferredIntegration.provider
- )
- : t(
- 'Send to %s (%s)',
- preferredIntegration!.name,
- preferredIntegration!.id ?? preferredIntegration!.provider
- )
- : preferredNeedsSetup
- ? t('Setup %s', preferredIntegration.name)
- : t('Send to %s', preferredIntegration!.name);
-
- const primaryButtonProps = isSeerPreferred
- ? {
- onClick: submitFindSolution,
- busy: isSelectingRootCause,
- icon: undefined,
- children: primaryButtonLabel,
- }
- : {
- onClick: () => handleLaunchCodingAgent(preferredIntegration!),
- busy: isLaunchingAgent,
- icon: ,
- children: primaryButtonLabel,
- };
-
- return (
-
-
- {primaryButtonProps.children}
-
- (
-
- ) : (
-
- )
- }
- />
- )}
- />
-
- );
-}
-
-function AutofixRootCauseDisplay({
- causes,
- groupId,
- runId,
- rootCauseSelection,
- status,
- previousDefaultStepIndex,
- previousInsightCount,
- agentCommentThread,
- codingAgents,
- event,
-}: AutofixRootCauseProps) {
- const cause = causes[0];
- const organization = useOrganization();
- const user = useUser();
- const iconFocusRef = useRef(null);
- const descriptionRef = useRef(null);
- const [solutionText, setSolutionText] = useState('');
- const {mutate: selectRootCause, isPending: isSelectingRootCause} = useSelectRootCause({
- groupId,
- runId,
- });
- const {data: codingAgentResponse, isLoading: isLoadingAgents} = useQuery(
- organizationIntegrationsCodingAgents(organization)
- );
- const codingAgentIntegrations = codingAgentResponse?.integrations ?? [];
- const {mutate: launchCodingAgent, isPending: isLaunchingAgent} = useLaunchCodingAgent(
- groupId,
- runId
- );
-
- // Stores 'seer_solution' or an integration ID (e.g., 'agent:123')
- const [preferredAction, setPreferredAction] = useLocalStorageState(
- 'autofix:rootCauseActionPreference',
- 'seer_solution'
- );
-
- // Simulate a click on the description to trigger the text selection
- const handleSelectDescription = () => {
- if (descriptionRef.current) {
- const clickEvent = new MouseEvent('click', {
- bubbles: true,
- cancelable: true,
- view: window,
- });
- descriptionRef.current.dispatchEvent(clickEvent);
- }
- };
-
- const submitFindSolution = () => {
- if (cause?.id === undefined || cause.id === null) {
- addErrorMessage(t('No root cause available.'));
- return;
- }
-
- // Save user preference
- setPreferredAction('seer_solution');
-
- const instruction = solutionText.trim();
-
- if (instruction) {
- selectRootCause({
- cause_id: cause.id,
- instruction,
- });
- } else {
- selectRootCause({
- cause_id: cause.id,
- });
- }
-
- setSolutionText('');
-
- trackAnalytics('autofix.root_cause.find_solution', {
- organization,
- group_id: groupId,
- instruction_provided: instruction.length > 0,
- });
- };
-
- const handleLaunchCodingAgent = (integration: CodingAgentIntegration) => {
- // Redirect to OAuth if the integration requires identity but user hasn't authenticated
- if (integration.requires_identity && !integration.has_identity) {
- const currentUrl = window.location.href;
- window.location.href = `/remote/github-copilot/oauth/?next=${encodeURIComponent(currentUrl)}`;
- return;
- }
-
- // Save user preference with specific integration ID
- setPreferredAction(`agent:${integration.id ?? integration.provider}`);
-
- addLoadingMessage(t('Launching %s...', integration.name), {
- duration: 60000,
- });
-
- const instruction = solutionText.trim();
-
- launchCodingAgent({
- integrationId: integration.id,
- provider: integration.provider,
- agentName: integration.name,
- triggerSource: 'root_cause',
- instruction: instruction || undefined,
- });
-
- setSolutionText('');
-
- trackAnalytics('autofix.coding_agent.launch', {
- organization,
- group_id: groupId,
- step: 'root_cause',
- provider: integration.provider,
- });
- trackAnalytics('coding_integration.send_to_agent_clicked', {
- organization,
- group_id: groupId,
- provider: integration.provider,
- source: 'autofix',
- user_id: user.id,
- });
- };
-
- // Shared UI state for solution action controls
- const isRootCauseAlreadySelected = Boolean(
- rootCauseSelection && 'cause_id' in rootCauseSelection
- );
- const hasCodingAgents = Boolean(codingAgents && Object.keys(codingAgents).length > 0);
- const primaryButtonPriority: React.ComponentProps['variant'] =
- isRootCauseAlreadySelected || hasCodingAgents ? 'secondary' : 'primary';
- const findSolutionTitle = t('Let Seer plan a solution to this issue');
-
- if (!cause) {
- return (
-
- {t('No root cause available.')}
-
- );
- }
-
- if (rootCauseSelection && 'custom_root_cause' in rootCauseSelection) {
- return (
-
-
-
-
-
-
-
- {t('Custom Root Cause')}
-
-
- {rootCauseSelection.custom_root_cause}
-
-
-
-
-
- {status === AutofixStatus.COMPLETED && (
-
- )}
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
- {t('Root Cause')}
-
-
-
-
-
-
- {agentCommentThread && iconFocusRef.current && (
- = 0
- ? previousInsightCount
- : null
- }
- isAgentComment
- blockName={t('Seer is uncertain of the root cause...')}
- />
- )}
-
-
-
-
-
-
-
-
- setSolutionText(e.target.value)}
- placeholder={t('Add context for the solution...')}
- maxRows={3}
- size="sm"
- onKeyDown={e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- submitFindSolution();
- }
- }}
- />
-
-
-
-
- {status === AutofixStatus.COMPLETED && (
-
- )}
-
-
- );
-}
-
-export function AutofixRootCause(props: AutofixRootCauseProps) {
- if (props.causes.length === 0) {
- return (
-
-
-
-
-
- {t('No root cause found.\n\n%s', props.terminationReason ?? '')}
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- );
-}
-
-const Description = styled('div')`
- border-bottom: 1px solid ${p => p.theme.tokens.border.secondary};
- padding-bottom: ${p => p.theme.space.xl};
- margin-bottom: ${p => p.theme.space.xl};
-`;
-
-const NoCausesPadding = styled('div')`
- padding: 0 ${p => p.theme.space.xl};
-`;
-
-const CausesContainer = styled('div')`
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- overflow: hidden;
- box-shadow: ${p => p.theme.shadow.medium};
- padding: ${p => p.theme.space.lg};
- background: ${p => p.theme.tokens.background.primary};
-`;
-
-const Content = styled('div')`
- padding: ${p => p.theme.space.md} 0;
-`;
-
-const HeaderText = styled('div')`
- font-weight: ${p => p.theme.font.weight.sans.medium};
- font-size: ${p => p.theme.font.size.lg};
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.md};
-`;
-
-const CustomRootCausePadding = styled('div')`
- padding: ${p => p.theme.space.md} ${p => p.theme.space['2xs']} ${p => p.theme.space.xl}
- ${p => p.theme.space['2xs']};
-`;
-
-const CauseDescription = styled('div')`
- font-size: ${p => p.theme.font.size.md};
- margin-top: ${p => p.theme.space.xs};
-`;
-
-const AnimationWrapper = styled(motion.div)`
- transform-origin: top center;
-`;
-
-const BottomDivider = styled('div')`
- border-top: 1px solid ${p => p.theme.tokens.border.secondary};
-`;
-
-const SolutionInput = styled(TextArea)`
- flex: 1;
- resize: none;
- margin-right: ${p => p.theme.space.lg};
- margin-left: ${p => p.theme.space['3xl']};
- max-width: 250px;
-`;
-
-const DropdownTrigger = styled(Button)`
- box-shadow: none;
- border-radius: 0 ${p => p.theme.radius.md} ${p => p.theme.radius.md} 0;
- border-left: none;
-`;
-
-const SmallIntegrationIdText = styled('div')`
- font-size: ${p => p.theme.font.size.sm};
- color: ${p => p.theme.tokens.content.secondary};
-`;
diff --git a/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx b/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx
deleted file mode 100644
index 138376b87611e0..00000000000000
--- a/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture';
-
-import {act, renderGlobalModal, screen} from 'sentry-test/reactTestingLibrary';
-
-import {openModal} from 'sentry/actionCreators/modal';
-import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
-
-describe('AutofixSetupWriteAccessModal', () => {
- it('displays help text when repos are not all installed', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/setup/',
- match: [MockApiClient.matchQuery({check_write_access: true})],
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {
- ok: false,
- repos: [
- {
- provider: 'integrations:github',
- owner: 'getsentry',
- name: 'sentry',
- ok: true,
- },
- {
- provider: 'integrations:github',
- owner: 'getsentry',
- name: 'seer',
- ok: false,
- },
- ],
- },
- }),
- });
-
- const closeModal = jest.fn();
-
- renderGlobalModal();
-
- act(() => {
- openModal(
- modalProps => ,
- {
- onClose: closeModal,
- }
- );
- });
-
- expect(screen.getByText(/In order to create pull requests/i)).toBeInTheDocument();
- expect(await screen.findByText('getsentry/sentry')).toBeInTheDocument();
- expect(screen.getByText('getsentry/seer')).toBeInTheDocument();
-
- expect(
- screen.getByRole('button', {name: 'Install the Seer GitHub App'})
- ).toHaveAttribute('href', 'https://github.com/apps/seer-by-sentry/installations/new');
- });
-
- it('displays success text when installed repos for github app text', async () => {
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/1/autofix/setup/',
- match: [MockApiClient.matchQuery({check_write_access: true})],
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {
- ok: true,
- repos: [
- {
- provider: 'integrations:github',
- owner: 'getsentry',
- name: 'sentry',
- ok: true,
- },
- {
- provider: 'integrations:github',
- owner: 'getsentry',
- name: 'seer',
- ok: true,
- },
- ],
- },
- }),
- });
-
- const closeModal = jest.fn();
-
- renderGlobalModal();
-
- act(() => {
- openModal(
- modalProps => ,
- {onClose: closeModal}
- );
- });
-
- expect(
- await screen.findByText("You've successfully configured write access!")
- ).toBeInTheDocument();
-
- // Footer with actions should no longer be visible
- expect(screen.queryByRole('button', {name: /install/i})).not.toBeInTheDocument();
- });
-});
diff --git a/static/app/components/events/autofix/autofixSetupWriteAccessModal.tsx b/static/app/components/events/autofix/autofixSetupWriteAccessModal.tsx
deleted file mode 100644
index a63b4f83787479..00000000000000
--- a/static/app/components/events/autofix/autofixSetupWriteAccessModal.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import {Fragment, useEffect, useMemo} from 'react';
-import styled from '@emotion/styled';
-import {useQueryClient} from '@tanstack/react-query';
-
-import {Button, LinkButton} from '@sentry/scraps/button';
-import {Grid} from '@sentry/scraps/layout';
-import {ExternalLink} from '@sentry/scraps/link';
-
-import type {ModalRenderProps} from 'sentry/actionCreators/modal';
-import {autofixApiOptions} from 'sentry/components/events/autofix/useAutofix';
-import {useAutofixSetup} from 'sentry/components/events/autofix/useAutofixSetup';
-import {IconCheckmark} from 'sentry/icons';
-import {t, tct} from 'sentry/locale';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-function GitRepoLink({repo}: {repo: {name: string; owner: string; ok?: boolean}}) {
- return (
-
-
- {repo.owner}/{repo.name}
-
- {repo.ok && }
-
- );
-}
-
-interface AutofixSetupWriteAccessModalProps extends ModalRenderProps {
- groupId: string;
-}
-
-function Content({groupId, closeModal}: {closeModal: () => void; groupId: string}) {
- const {canCreatePullRequests, data} = useAutofixSetup(
- {groupId, checkWriteAccess: true},
- {refetchOnWindowFocus: true} // We want to check each time the user comes back to the tab
- );
-
- const sortedRepos = useMemo(
- () =>
- data?.githubWriteIntegration?.repos.toSorted((a: any, b: any) => {
- if (a.ok === b.ok) {
- return `${a.owner}/${a.name}`.localeCompare(`${b.owner}/${b.name}`);
- }
- return a.ok ? -1 : 1;
- }) ?? [],
- [data]
- );
-
- if (canCreatePullRequests) {
- return (
-
-
- {t("You've successfully configured write access!")}
-
- {t("Let's go")}
-
-
- );
- }
-
- if (sortedRepos.length > 0) {
- return (
-
-
- {tct(
- 'In order to create pull requests, install and grant write access to the [link:Sentry Seer GitHub App] for the following repositories:',
- {
- link: (
-
- ),
- }
- )}
-
-
- {sortedRepos.map((repo: any) => (
-
- ))}
-
-
- );
- }
-
- return (
-
-
- {tct(
- 'In order to create pull requests, install and grant write access to the [link:Sentry Seer GitHub App] for the relevant repositories.',
- {
- link: (
-
- ),
- }
- )}
-
-
- );
-}
-
-export function AutofixSetupWriteAccessModal({
- Header,
- Body,
- Footer,
- groupId,
- closeModal,
-}: AutofixSetupWriteAccessModalProps) {
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
- const {canCreatePullRequests} = useAutofixSetup({groupId, checkWriteAccess: true});
-
- useEffect(() => {
- return () => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- };
- }, [queryClient, orgSlug, groupId]);
-
- return (
-
-
- {t('Allow Seer to Make Pull Requests')}
-
-
-
-
- {!canCreatePullRequests && (
-
-
- {t('Later')}
-
- {t('Install the Seer GitHub App')}
-
-
-
- )}
-
- );
-}
-
-const DoneWrapper = styled('div')`
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- padding: 40px;
- font-size: ${p => p.theme.font.size.lg};
-`;
-
-const DoneIcon = styled(IconCheckmark)`
- margin-bottom: ${p => p.theme.space['3xl']};
-`;
-
-const RepoLinkUl = styled('ul')`
- display: flex;
- flex-direction: column;
- gap: ${p => p.theme.space.xs};
- padding: 0;
-`;
-
-const RepoItem = styled('li')<{isOk?: boolean}>`
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: ${p => p.theme.space.xl};
- padding: ${p => p.theme.space.md};
- margin-bottom: ${p => p.theme.space.xs};
- background-color: ${p => (p.isOk ? p.theme.colors.green100 : 'transparent')};
- border-radius: ${p => p.theme.radius.md};
-`;
diff --git a/static/app/components/events/autofix/autofixSolution.spec.tsx b/static/app/components/events/autofix/autofixSolution.spec.tsx
deleted file mode 100644
index ea94a9847d0777..00000000000000
--- a/static/app/components/events/autofix/autofixSolution.spec.tsx
+++ /dev/null
@@ -1,593 +0,0 @@
-import {
- render,
- screen,
- userEvent,
- waitFor,
- within,
-} from 'sentry-test/reactTestingLibrary';
-
-import {AutofixSolution} from 'sentry/components/events/autofix/autofixSolution';
-import {
- AutofixStatus,
- type AutofixData,
- type AutofixSolutionTimelineEvent,
-} from 'sentry/components/events/autofix/types';
-import {
- useAutofixData,
- useAutofixRepos,
-} from 'sentry/components/events/autofix/useAutofix';
-
-jest.mock('sentry/components/events/autofix/useAutofix');
-
-describe('AutofixSolution', () => {
- const defaultSolution = [
- {
- title: 'Fix the bug',
- code_snippet_and_analysis: 'Some code and analysis',
- timeline_item_type: 'internal_code' as const,
- relevant_code_file: {
- file_path: 'src/file.js',
- repo_name: 'owner/repo',
- url: 'https://github.com/owner/repo/blob/main/src/file.js',
- },
- },
- ];
-
- const defaultProps = {
- solution: defaultSolution,
- groupId: '123',
- runId: 'run-123',
- solutionSelected: false,
- status: AutofixStatus.COMPLETED,
- } satisfies React.ComponentProps;
-
- beforeEach(() => {
- MockApiClient.clearMockResponses();
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- });
- jest.mocked(useAutofixRepos).mockReset();
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [],
- codebases: {},
- });
-
- jest.mocked(useAutofixData).mockReset();
- jest.mocked(useAutofixData).mockReturnValue({
- data: {} as AutofixData,
- isPending: false,
- });
-
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/',
- method: 'GET',
- body: {
- project: {
- slug: 'project-slug',
- },
- },
- });
- });
-
- it('enables Code It Up button when all repos are readable', () => {
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo1',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- {
- name: 'owner/repo2',
- owner: 'owner',
- external_id: 'repo2',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- const codeItUpButton = screen.getByText('Code It Up');
- expect(codeItUpButton).toBeEnabled();
- });
-
- it('disables Code It Up button when all repos are not readable', () => {
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo1',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: false,
- is_writeable: false,
- },
- {
- name: 'owner/repo2',
- owner: 'owner',
- external_id: 'repo2',
- provider: 'github',
- provider_raw: 'github',
- is_readable: false,
- is_writeable: false,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- const codeItUpButton = screen.getByRole('button', {name: 'Code It Up'});
- expect(codeItUpButton).toBeDisabled();
- });
-
- it('enables Code It Up button when at least one repo is readable', () => {
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo1',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- {
- name: 'owner/repo2',
- owner: 'owner',
- external_id: 'repo2',
- provider: 'github',
- provider_raw: 'github',
- is_readable: false,
- is_writeable: false,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- const codeItUpButton = screen.getByText('Code It Up');
- expect(codeItUpButton).toBeEnabled();
- });
-
- it('treats repos with is_readable=null as readable', () => {
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo1',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: undefined,
- is_writeable: undefined,
- },
- {
- name: 'owner/repo2',
- owner: 'owner',
- external_id: 'repo2',
- provider: 'github',
- provider_raw: 'github',
- is_readable: false,
- is_writeable: false,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- const codeItUpButton = screen.getByText('Code It Up');
- expect(codeItUpButton).toBeEnabled();
- });
-
- it('treats repos with is_readable=undefined as readable', () => {
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo1',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: undefined,
- is_writeable: undefined,
- },
- {
- name: 'owner/repo2',
- owner: 'owner',
- external_id: 'repo2',
- provider: 'github',
- provider_raw: 'github',
- is_readable: false,
- is_writeable: false,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- const codeItUpButton = screen.getByText('Code It Up');
- expect(codeItUpButton).toBeEnabled();
- });
-
- it('treats empty repos array as having no repository constraints', () => {
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [],
- codebases: {},
- });
-
- render( );
-
- const codeItUpButton = screen.getByText('Code It Up');
- expect(codeItUpButton).toBeEnabled();
- });
-
- it('renders the solution timeline', () => {
- render( );
-
- expect(screen.getByText('Fix the bug')).toBeInTheDocument();
- });
-
- it('passes the solution array when Code It Up button is clicked', async () => {
- // Mock the API directly before the test
- const mockApi = MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- });
-
- // Use readable repos to enable the button
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo1',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- // Click the Code It Up button
- await userEvent.click(screen.getByRole('button', {name: 'Code It Up'}));
-
- // Wait for API call
- await waitFor(() => {
- expect(mockApi).toHaveBeenCalled();
- });
-
- // Verify payload
- expect(mockApi).toHaveBeenCalledWith(
- '/organizations/org-slug/issues/123/autofix/update/',
- expect.objectContaining({
- data: {
- run_id: 'run-123',
- payload: {
- type: 'select_solution',
- mode: 'fix',
- solution: expect.arrayContaining([
- expect.objectContaining({
- title: 'Fix the bug',
- is_active: true, // should default to true
- }),
- ]),
- },
- },
- })
- );
- });
-
- it('allows toggling solution items active/inactive', async () => {
- // Mock the API directly before the test
- const mockApi = MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- });
-
- // Use readable repos to enable the button
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo1',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- // Find the timeline item
- const timelineItem = screen.getByTestId('autofix-solution-timeline-item-0');
- expect(timelineItem).toBeInTheDocument();
-
- // Find and click the toggle button for deselecting the item
- const toggleButton = within(timelineItem).getByRole('button', {
- name: 'Remove from plan',
- });
- expect(toggleButton).toBeInTheDocument();
- await userEvent.click(toggleButton);
-
- // Click the Code It Up button
- await userEvent.click(screen.getByRole('button', {name: 'Code It Up'}));
-
- // Wait for API call
- await waitFor(() => {
- expect(mockApi).toHaveBeenCalled();
- });
-
- // Verify payload
- expect(mockApi).toHaveBeenCalledWith(
- '/organizations/org-slug/issues/123/autofix/update/',
- expect.objectContaining({
- data: {
- run_id: 'run-123',
- payload: {
- type: 'select_solution',
- mode: 'fix',
- solution: expect.arrayContaining([
- expect.objectContaining({
- title: 'Fix the bug',
- is_active: false,
- }),
- ]),
- },
- },
- })
- );
- });
-
- it('allows adding custom instructions', async () => {
- // Mock the API directly before the test
- const mockApi = MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- });
-
- // Use readable repos to enable the button
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo1',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- // Find and fill the input
- const input = screen.getByPlaceholderText('Add to the solution plan...');
- await userEvent.type(input, 'This is a custom instruction');
-
- // Enable the Add button by typing non-empty text
- const addButton = screen.getByRole('button', {name: 'Add to solution'});
- expect(addButton).toBeEnabled();
-
- // Click Add button
- await userEvent.click(addButton);
-
- // Verify the custom instruction was added
- expect(screen.getByText('This is a custom instruction')).toBeInTheDocument();
-
- // Click Code It Up
- await userEvent.click(screen.getByRole('button', {name: 'Code It Up'}));
-
- // Wait for API call
- await waitFor(() => {
- expect(mockApi).toHaveBeenCalled();
- });
-
- // Verify payload
- expect(mockApi).toHaveBeenCalledWith(
- '/organizations/org-slug/issues/123/autofix/update/',
- expect.objectContaining({
- data: {
- run_id: 'run-123',
- payload: {
- type: 'select_solution',
- mode: 'fix',
- solution: expect.arrayContaining([
- expect.objectContaining({
- title: 'Fix the bug',
- }),
- expect.objectContaining({
- title: 'This is a custom instruction',
- timeline_item_type: 'human_instruction',
- is_active: true,
- }),
- ]),
- },
- },
- })
- );
- });
-
- it('allows adding custom instructions with Enter key', async () => {
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- // Find and fill the input, then press Enter
- const input = screen.getByPlaceholderText('Add to the solution plan...');
- await userEvent.type(input, 'Enter key instruction{Enter}');
-
- // Verify the custom instruction was added
- expect(screen.getByText('Enter key instruction')).toBeInTheDocument();
-
- // Input should be cleared
- expect(input).toHaveValue('');
- });
-
- it('can delete human instructions from solution', async () => {
- const solutionWithHumanInstruction = [
- ...defaultSolution,
- {
- title: 'Human instruction',
- timeline_item_type: 'human_instruction' as const,
- is_active: true,
- },
- ];
-
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- // Find the human instruction item
- const humanInstructionElement = screen.getByText('Human instruction');
- expect(humanInstructionElement).toBeInTheDocument();
-
- // Find the timeline item containing the human instruction
- // https://github.com/typescript-eslint/typescript-eslint/issues/10722
- // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
- const timelineItem = humanInstructionElement.closest(
- '[data-test-id^="autofix-solution-timeline-item-"]'
- ) as HTMLElement;
- expect(timelineItem).not.toBeNull();
-
- // Find the delete button using the updated aria-label
- const deleteButton = within(timelineItem).getByRole('button', {
- name: 'Remove from plan',
- });
- expect(deleteButton).toBeInTheDocument();
-
- // Click the delete button
- await userEvent.click(deleteButton);
-
- // Verify the human instruction was removed
- await waitFor(() => {
- expect(screen.queryByText('Human instruction')).not.toBeInTheDocument();
- });
- });
-
- it('preserves active state of solution items', async () => {
- const solutionWithActiveStates = [
- {
- ...defaultSolution[0],
- is_active: true,
- },
- {
- title: 'Another step',
- code_snippet_and_analysis: 'More code',
- timeline_item_type: 'internal_code' as const,
- is_active: false,
- relevant_code_file: {
- file_path: 'src/another.js',
- repo_name: 'owner/repo',
- },
- },
- ] as AutofixSolutionTimelineEvent[];
-
- // Mock the API directly before the test
- const mockApi = MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/123/autofix/update/',
- method: 'POST',
- });
-
- jest.mocked(useAutofixRepos).mockReturnValue({
- repos: [
- {
- name: 'owner/repo',
- owner: 'owner',
- external_id: 'repo1',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- },
- ],
- codebases: {},
- });
-
- render( );
-
- // Click Code It Up
- await userEvent.click(screen.getByRole('button', {name: 'Code It Up'}));
-
- // Wait for API call
- await waitFor(() => {
- expect(mockApi).toHaveBeenCalled();
- });
-
- // Verify payload
- expect(mockApi).toHaveBeenCalledWith(
- '/organizations/org-slug/issues/123/autofix/update/',
- expect.objectContaining({
- data: {
- run_id: 'run-123',
- payload: {
- type: 'select_solution',
- mode: 'fix',
- solution: expect.arrayContaining([
- expect.objectContaining({
- title: 'Fix the bug',
- is_active: true,
- }),
- expect.objectContaining({
- title: 'Another step',
- is_active: false,
- }),
- ]),
- },
- },
- })
- );
- });
-});
diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx
index 9beee65bd17f38..cc1747e28a2de9 100644
--- a/static/app/components/events/autofix/autofixSolution.tsx
+++ b/static/app/components/events/autofix/autofixSolution.tsx
@@ -1,262 +1,4 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react';
-import styled from '@emotion/styled';
-import {useMutation, useQueryClient} from '@tanstack/react-query';
-import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion';
-
-import {Alert} from '@sentry/scraps/alert';
-import {Button} from '@sentry/scraps/button';
-import {Input} from '@sentry/scraps/input';
-import {Flex, Grid} from '@sentry/scraps/layout';
-import {Link} from '@sentry/scraps/link';
-import {Tooltip} from '@sentry/scraps/tooltip';
-
-import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator';
-import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper';
-import {SolutionEventItem} from 'sentry/components/events/autofix/autofixSolutionEventItem';
-import {AutofixStepFeedback} from 'sentry/components/events/autofix/autofixStepFeedback';
-import {
- AutofixStatus,
- AutofixStepType,
- type AutofixSolutionTimelineEvent,
- type CommentThread,
-} from 'sentry/components/events/autofix/types';
-import {
- autofixApiOptions,
- useAutofixData,
- useAutofixRepos,
-} from 'sentry/components/events/autofix/useAutofix';
-import {formatSolutionWithEvent} from 'sentry/components/events/autofix/utils';
-import {Timeline} from 'sentry/components/timeline';
-import {IconAdd, IconChat, IconCopy, IconFix} from 'sentry/icons';
-import {t, tct} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {singleLineRenderer} from 'sentry/utils/marked/marked';
-import {valueIsEqual} from 'sentry/utils/object/valueIsEqual';
-import {useApi} from 'sentry/utils/useApi';
-import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useGroup} from 'sentry/views/issueDetails/useGroup';
-
-import {AutofixHighlightPopup} from './autofixHighlightPopup';
-
-function useSelectSolution({groupId, runId}: {groupId: string; runId: string}) {
- const api = useApi();
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
-
- return useMutation({
- mutationFn: (params: {
- mode: 'all' | 'fix' | 'test';
- solution: AutofixSolutionTimelineEvent[];
- }) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'select_solution',
- mode: params.mode,
- solution: params.solution,
- },
- },
- }
- );
- },
- onSuccess: (_, params) => {
- queryClient.setQueryData(autofixApiOptions(orgSlug, groupId).queryKey, prev => {
- if (!prev?.json?.autofix) {
- return prev;
- }
-
- return {
- ...prev,
- json: {
- ...prev.json,
- autofix: {
- ...prev.json.autofix,
- status: AutofixStatus.PROCESSING,
- steps: prev.json.autofix.steps?.map(step => {
- if (step.type !== AutofixStepType.SOLUTION) {
- return step;
- }
-
- return {
- ...step,
- selection:
- 'customSolution' in params
- ? {
- custom_solution: params.customSolution,
- }
- : {},
- };
- }),
- },
- },
- };
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- addLoadingMessage('On it.');
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when selecting the solution.'));
- },
- });
-}
-
-type AutofixSolutionProps = {
- groupId: string;
- runId: string;
- solution: AutofixSolutionTimelineEvent[];
- solutionSelected: boolean;
- status: AutofixStatus;
- agentCommentThread?: CommentThread;
- changesDisabled?: boolean;
- customSolution?: string;
- description?: string;
- event?: Event;
- isSolutionFirstAppearance?: boolean;
- previousDefaultStepIndex?: number;
- previousInsightCount?: number;
-};
-
-const cardAnimationProps: MotionNodeAnimationOptions = {
- exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
- initial: {opacity: 0, height: 0, scale: 0.8},
- animate: {opacity: 1, height: 'auto', scale: 1},
- transition: {
- duration: 1,
- height: {
- type: 'spring',
- bounce: 0.2,
- },
- scale: {
- type: 'spring',
- bounce: 0.2,
- },
- y: {
- type: 'tween',
- ease: 'easeOut',
- },
- },
-};
-
-function SolutionDescription({
- solution,
- groupId,
- runId,
- previousDefaultStepIndex,
- previousInsightCount,
- description,
- onDeleteItem,
- onToggleActive,
- ref,
-}: {
- groupId: string;
- onDeleteItem: (index: number) => void;
- onToggleActive: (index: number) => void;
- runId: string;
- solution: AutofixSolutionTimelineEvent[];
- description?: string;
- previousDefaultStepIndex?: number;
- previousInsightCount?: number;
- ref?: React.RefObject;
-}) {
- return (
-
- {description && (
- = 0
- ? previousInsightCount
- : null
- }
- >
-
-
- )}
- = 0
- ? previousInsightCount
- : null
- }
- />
-
- );
-}
-
-const Description = styled('div')`
- border-bottom: 1px solid ${p => p.theme.tokens.border.secondary};
- padding-bottom: ${p => p.theme.space.xl};
- margin-bottom: ${p => p.theme.space.xl};
-`;
-
-type SolutionEventListProps = {
- events: AutofixSolutionTimelineEvent[];
- groupId: string;
- onDeleteItem: (index: number) => void;
- onToggleActive: (index: number) => void;
- runId: string;
- retainInsightCardIndex?: number | null;
- stepIndex?: number;
-};
-
-function SolutionEventList({
- events,
- onDeleteItem,
- onToggleActive,
- groupId,
- runId,
- stepIndex = 0,
- retainInsightCardIndex = null,
-}: SolutionEventListProps) {
- if (!events?.length) {
- return null;
- }
-
- return (
-
- {events.map((event, index) => {
- const isSelected = event.is_active !== false; // Default to true if is_active is undefined
-
- return (
-
- );
- })}
-
- );
-}
+import type {AutofixSolutionTimelineEvent} from 'sentry/components/events/autofix/types';
export function formatSolutionText(
solution: AutofixSolutionTimelineEvent[],
@@ -297,492 +39,3 @@ export function formatSolutionText(
return parts.join('\n\n');
}
-
-function CopySolutionButton({
- solution,
- groupId,
- customSolution,
- event,
- isEditing,
- rootCause,
-}: {
- groupId: string;
- solution: AutofixSolutionTimelineEvent[];
- customSolution?: string;
- event?: Event;
- isEditing?: boolean;
- rootCause?: any;
-}) {
- const text = formatSolutionWithEvent(solution, customSolution, event, rootCause);
- const {copy} = useCopyToClipboard();
-
- if (isEditing) {
- return null;
- }
-
- return (
- copy(text, {successMessage: t('Solution copied to clipboard.')})}
- analyticsEventName="Autofix: Copy Solution as Markdown"
- analyticsEventKey="autofix.solution.copy"
- analyticsParams={{group_id: groupId}}
- icon={ }
- >
- {t('Copy')}
-
- );
-}
-
-function AutofixSolutionDisplay({
- solution,
- description,
- groupId,
- runId,
- status,
- previousDefaultStepIndex,
- previousInsightCount,
- customSolution,
- solutionSelected,
- agentCommentThread,
- event,
-}: Omit) {
- const organization = useOrganization();
- const {data: group} = useGroup({groupId});
- const project = group?.project;
-
- const {repos} = useAutofixRepos(groupId);
- const {data: autofixData} = useAutofixData({groupId});
-
- // Get root cause data from autofix data
- const rootCauseStep = autofixData?.steps?.find(
- step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS
- );
- const rootCause = rootCauseStep?.causes?.[0];
- const {mutate: handleContinue, isPending} = useSelectSolution({groupId, runId});
- const [instructions, setInstructions] = useState('');
- const [solutionItems, setSolutionItems] = useState( // This will become outdated if multiple people use it, but we can ignore this for now.
- () => {
- // Initialize is_active to true for all items that don't have it set for backwards compatibility
- return solution.map(item => ({
- ...item,
- is_active: item.is_active === undefined ? true : item.is_active,
- }));
- }
- );
- const containerRef = useRef(null);
- const iconFixRef = useRef(null);
- const descriptionRef = useRef(null);
-
- const handleSelectDescription = () => {
- if (descriptionRef.current) {
- // Simulate a click on the description to trigger the text selection
- const clickEvent = new MouseEvent('click', {
- bubbles: true,
- cancelable: true,
- view: window,
- });
- descriptionRef.current.dispatchEvent(clickEvent);
- }
- };
-
- const hasNoRepos = repos.length === 0;
- const cantReadRepos = repos.every(repo => repo.is_readable === false);
- const enableSeerCoding = organization.enableSeerCoding !== false;
-
- const handleAddInstruction = () => {
- if (instructions.trim()) {
- // Create a new step from the instructions input
- const newStep: AutofixSolutionTimelineEvent = {
- title: instructions,
- timeline_item_type: 'human_instruction',
- is_most_important_event: false,
- is_active: true,
- };
-
- // Add the new step to the solution
- setSolutionItems([...solutionItems, newStep]);
-
- // Clear the input
- setInstructions('');
-
- trackAnalytics('autofix.solution.add_step', {
- organization,
- group_id: groupId,
- solution: solutionItems,
- newStep,
- });
- }
- };
-
- const handleFormSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- handleAddInstruction();
- };
-
- const handleDeleteItem = useCallback(
- (index: number) => {
- setSolutionItems(current => current.filter((_, i) => i !== index));
-
- trackAnalytics('autofix.solution.delete_step', {
- organization,
- group_id: groupId,
- solution: solutionItems,
- deletedStep: solutionItems[index],
- });
- },
- [organization, groupId, solutionItems]
- );
-
- const handleToggleActive = useCallback(
- (index: number) => {
- setSolutionItems(current =>
- current.map((item, i) =>
- i === index
- ? {...item, is_active: item.is_active === false ? true : false}
- : item
- )
- );
-
- trackAnalytics('autofix.solution.toggle_step', {
- organization,
- group_id: groupId,
- solution: solutionItems,
- toggledStep: solutionItems[index],
- });
- },
- [organization, groupId, solutionItems]
- );
-
- const handleCodeItUp = () => {
- let finalSolutionItems = solutionItems;
-
- // Check if there are instructions typed but not added
- if (instructions.trim()) {
- // Create a new step from the instructions input
- const newStep: AutofixSolutionTimelineEvent = {
- title: instructions,
- timeline_item_type: 'human_instruction',
- is_most_important_event: false,
- is_active: true,
- };
-
- // Add the new step to the solution
- finalSolutionItems = [...solutionItems, newStep];
- setSolutionItems(finalSolutionItems);
-
- // Clear the input
- setInstructions('');
-
- trackAnalytics('autofix.solution.add_step', {
- organization,
- group_id: groupId,
- solution: solutionItems,
- newStep,
- });
- }
-
- handleContinue({
- mode: 'fix',
- solution: finalSolutionItems,
- });
- };
-
- // Check if instructions were provided (either typed in input or already added to solution and active)
- const hasInstructions =
- instructions.trim().length > 0 ||
- solutionItems.some(
- item => item.timeline_item_type === 'human_instruction' && item.is_active !== false
- );
-
- useEffect(() => {
- setSolutionItems(
- solution.map(item => ({
- ...item,
- is_active: item.is_active === undefined ? true : item.is_active,
- }))
- );
- }, [solution]);
-
- if (!solution || solution.length === 0) {
- return (
-
- {t('No solution available.')}
-
- );
- }
-
- if (customSolution) {
- return (
-
-
-
-
-
-
-
- {t('Custom Solution')}
-
-
-
- {customSolution}
-
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
- {t('Solution')}
-
-
-
-
-
-
- {agentCommentThread && iconFixRef.current && (
- = 0
- ? previousInsightCount
- : null
- }
- isAgentComment
- blockName={t('Seer is uncertain of the solution...')}
- />
- )}
-
-
-
-
-
-
-
-
- ) =>
- setInstructions(e.target.value)
- }
- size="sm"
- />
-
-
-
-
-
-
-
-
- ),
- }
- )
- : cantReadRepos
- ? t(
- "Seer can't access any of your selected repos. Check your GitHub integration and make sure Seer has read access."
- )
- : undefined
- : tct(
- '[settings:"Enable Code Generation"] must be enabled by an admin in settings.',
- {
- settings: (
-
- ),
- }
- )
- }
- >
-
- {t('Code It Up')}
-
-
-
- {status === AutofixStatus.COMPLETED && (
-
- )}
-
-
- );
-}
-
-export function AutofixSolution(props: AutofixSolutionProps) {
- if (props.solution.length === 0) {
- return (
-
-
-
- {t('No solution found.')}
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- );
-}
-
-const NoSolutionPadding = styled('div')`
- padding: 0 ${p => p.theme.space.xl};
-`;
-
-const SolutionContainer = styled('div')`
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- overflow: hidden;
- box-shadow: ${p => p.theme.shadow.medium};
- padding: ${p => p.theme.space.lg};
- background: ${p => p.theme.tokens.background.primary};
-`;
-
-const Content = styled('div')`
- padding: ${p => p.theme.space.md} 0 0;
-`;
-
-const HeaderText = styled('div')`
- font-weight: ${p => p.theme.font.weight.sans.medium};
- font-size: ${p => p.theme.font.size.lg};
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.md};
-`;
-
-const SolutionDescriptionWrapper = styled('div')`
- font-size: ${p => p.theme.font.size.md};
- margin-top: ${p => p.theme.space.xs};
-`;
-
-const AnimationWrapper = styled(motion.div)`
- transform-origin: top center;
-`;
-
-const CustomSolutionPadding = styled('div')`
- padding: ${p => p.theme.space.md} ${p => p.theme.space['2xs']} ${p => p.theme.space.xl}
- ${p => p.theme.space['2xs']};
-`;
-
-const InstructionsInputWrapper = styled('form')`
- display: flex;
- position: relative;
- border-radius: ${p => p.theme.radius.md};
- margin-left: ${p => p.theme.space['3xl']};
- width: 250px;
-`;
-
-const InstructionsInput = styled(Input)`
- flex-grow: 1;
- padding-right: ${p => p.theme.space['3xl']};
-
- &::placeholder {
- color: ${p => p.theme.tokens.content.secondary};
- }
-`;
-
-const SubmitButton = styled(Button)`
- position: absolute;
- right: ${p => p.theme.space.md};
- top: 50%;
- transform: translateY(-50%);
- height: 24px;
- width: 24px;
- border-radius: 5px;
-`;
-
-const BottomDivider = styled('div')`
- border-top: 1px solid ${p => p.theme.tokens.border.secondary};
- margin-top: ${p => p.theme.space.lg};
-`;
-
-const AddInstructionWrapper = styled('div')`
- flex: 0;
-`;
diff --git a/static/app/components/events/autofix/autofixSolutionEventItem.tsx b/static/app/components/events/autofix/autofixSolutionEventItem.tsx
deleted file mode 100644
index 26a63e4aa8bee5..00000000000000
--- a/static/app/components/events/autofix/autofixSolutionEventItem.tsx
+++ /dev/null
@@ -1,295 +0,0 @@
-import {useState} from 'react';
-import type {Theme} from '@emotion/react';
-import {useTheme} from '@emotion/react';
-import styled from '@emotion/styled';
-import {AnimatePresence, motion} from 'framer-motion';
-
-import {Flex} from '@sentry/scraps/layout';
-import {Tooltip} from '@sentry/scraps/tooltip';
-
-import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper';
-import {AutofixInsightSources} from 'sentry/components/events/autofix/insights/autofixInsightSources';
-import {type AutofixSolutionTimelineEvent} from 'sentry/components/events/autofix/types';
-import {Timeline, type TimelineItemProps} from 'sentry/components/timeline';
-import {
- IconAdd,
- IconChevron,
- IconClose,
- IconCode,
- IconDelete,
- IconLab,
- IconUser,
-} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-
-function getEventIcon(eventType: string) {
- const iconProps = {
- style: {
- margin: 3,
- },
- };
-
- switch (eventType) {
- case 'internal_code':
- return ;
- case 'human_instruction':
- return ;
- case 'repro_test':
- return ;
- default:
- return ;
- }
-}
-
-function getEventColor(
- theme: Theme,
- isActive?: boolean,
- isSelected?: boolean
-): TimelineItemProps['colorConfig'] {
- return {
- title: theme.tokens.content.primary,
- icon: isSelected
- ? isActive
- ? theme.colors.green500
- : theme.tokens.content.primary
- : theme.tokens.content.secondary,
- iconBorder: isSelected
- ? isActive
- ? theme.colors.green500
- : theme.tokens.content.primary
- : theme.tokens.content.secondary,
- };
-}
-
-interface SolutionEventItemProps {
- event: AutofixSolutionTimelineEvent;
- groupId: string;
- index: number;
- isSelected: boolean;
- onDeleteItem: (index: number) => void;
- onToggleActive: (index: number) => void;
- runId: string;
- stepIndex: number;
- retainInsightCardIndex?: number | null;
-}
-
-export function SolutionEventItem({
- event,
- groupId,
- index,
- isSelected,
- onDeleteItem,
- onToggleActive,
- runId,
- retainInsightCardIndex,
- stepIndex,
-}: SolutionEventItemProps) {
- const theme = useTheme();
- const [isExpanded, setIsExpanded] = useState(false);
- const isHumanAction = event.timeline_item_type === 'human_instruction';
- // XXX: This logic assumes the list length is available, which it isn't here.
- // We might need to pass list length or derive this differently if needed.
- // For now, approximating based on index 0 not being the last.
- const isActive = event.is_most_important_event && index !== 0;
-
- const handleToggleExpand = () => {
- setIsExpanded(e => !e);
- };
-
- const handleItemClick = () => {
- if (!isSelected) {
- // If item is disabled, re-enable it instead of toggling expansion
- onToggleActive(index);
- return;
- }
- if (!isHumanAction && event.code_snippet_and_analysis) {
- handleToggleExpand();
- }
- };
-
- const handleSelectionToggle = (e: React.MouseEvent) => {
- e.stopPropagation();
- onToggleActive(index);
- if (isSelected) {
- setIsExpanded(false);
- }
- };
-
- const handleDeleteClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- onDeleteItem(index);
- };
-
- return (
-
-
-
-
-
- {!isHumanAction && event.code_snippet_and_analysis && isSelected && (
-
- )}
-
-
-
- {isHumanAction ? (
-
- ) : isSelected ? (
-
- ) : (
-
- )}
-
-
-
-
-
- }
- isActive={isActive}
- icon={getEventIcon(event.timeline_item_type)}
- colorConfig={getEventColor(theme, isActive, isSelected)}
- >
- {event.code_snippet_and_analysis && (
-
- {isExpanded && (
-
-
-
-
-
- {event.relevant_code_file?.url && (
-
-
-
- )}
-
-
- )}
-
- )}
-
- );
-}
-
-const SourcesWrapper = styled('div')`
- margin-top: ${p => p.theme.space.xl};
-`;
-
-const StyledIconChevron = styled(IconChevron)`
- color: ${p => p.theme.tokens.content.secondary};
- flex-shrink: 0;
-`;
-
-const SelectionButtonWrapper = styled('div')`
- background: none;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- height: 100%;
-`;
-
-type SelectionButtonProps = React.ButtonHTMLAttributes & {
- actionType: 'delete' | 'close' | 'add';
-};
-
-const SelectionButton = styled('button')`
- background: none;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- color: ${p => p.theme.tokens.content.secondary};
- transition:
- color 0.2s ease,
- background-color 0.2s ease;
- border-radius: 5px;
- padding: 4px;
-
- &:hover {
- color: ${p =>
- p.actionType === 'delete' || p.actionType === 'close'
- ? p.theme.colors.red500
- : p.theme.colors.green500};
- }
-`;
-
-const AnimatedContent = styled(motion.div)`
- overflow: hidden;
-`;
-
-const StyledSpan = styled(MarkedText)`
- & code {
- font-size: ${p => p.theme.font.size.sm};
- background-color: transparent;
- display: inline-block;
- }
-`;
-
-const StyledTimelineHeader = styled('div')<{isSelected: boolean; isActive?: boolean}>`
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding: ${p => p.theme.space['2xs']};
- padding-right: 0;
- border-radius: ${p => p.theme.radius.md};
- cursor: pointer;
- font-weight: ${p => p.theme.font.weight.sans.regular};
- gap: ${p => p.theme.space.md};
- opacity: ${p => (p.isSelected ? 1 : 0.6)};
- text-decoration: ${p =>
- p.isSelected ? (p.isActive ? 'underline dashed' : 'none') : 'line-through'};
- text-decoration-color: ${p =>
- p.isSelected ? p.theme.colors.green400 : p.theme.tokens.content.primary};
- text-decoration-thickness: 1px;
- text-underline-offset: 4px;
- transition: opacity 0.2s ease;
-
- & > div:first-of-type {
- flex: 1;
- min-width: 0;
- margin-right: ${p => p.theme.space.md};
- }
-
- &:hover {
- background-color: ${p =>
- p.theme.tokens.interactive.transparent.neutral.background.hover};
- }
-
- &:active {
- background-color: ${p =>
- p.theme.tokens.interactive.transparent.neutral.background.active};
- }
-`;
diff --git a/static/app/components/events/autofix/autofixStartBox.tsx b/static/app/components/events/autofix/autofixStartBox.tsx
deleted file mode 100644
index 933d48f723d641..00000000000000
--- a/static/app/components/events/autofix/autofixStartBox.tsx
+++ /dev/null
@@ -1,295 +0,0 @@
-import {useCallback, useMemo, useState} from 'react';
-import styled from '@emotion/styled';
-
-import starImage from 'sentry-images/spot/banner-star.svg';
-
-import {Button, ButtonBar} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-import {Link} from '@sentry/scraps/link';
-import {TextArea} from '@sentry/scraps/textarea';
-import {Tooltip} from '@sentry/scraps/tooltip';
-
-import {DropdownMenu} from 'sentry/components/dropdownMenu';
-import {AutofixStoppingPoint} from 'sentry/components/events/autofix/types';
-import {IconArrow, IconChevron, IconSeer} from 'sentry/icons';
-import {t, tct} from 'sentry/locale';
-import type {Organization} from 'sentry/types/organization';
-import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-interface AutofixStartBoxProps {
- groupId: string;
- onSend: (message: string, stoppingPoint?: AutofixStoppingPoint) => void;
-}
-
-function getStoppingPointOptions(organization: Organization) {
- const enableSeerCoding = organization.enableSeerCoding !== false;
- return [
- {
- key: AutofixStoppingPoint.ROOT_CAUSE,
- label: t('Start Root Cause Analysis'),
- value: AutofixStoppingPoint.ROOT_CAUSE,
- disabled: false,
- tooltip: undefined,
- },
- {
- key: AutofixStoppingPoint.SOLUTION,
- label: t('Plan a Solution'),
- value: AutofixStoppingPoint.SOLUTION,
- disabled: false,
- tooltip: undefined,
- },
- {
- key: AutofixStoppingPoint.CODE_CHANGES,
- label: t('Write Code Changes'),
- value: AutofixStoppingPoint.CODE_CHANGES,
- disabled: !enableSeerCoding,
- tooltip: enableSeerCoding
- ? undefined
- : tct(
- '[settings:"Enable Code Generation"] must be enabled by an admin in settings.',
- {
- settings: (
-
- ),
- }
- ),
- },
- {
- key: AutofixStoppingPoint.OPEN_PR,
- label: t('Draft a Pull Request'),
- value: AutofixStoppingPoint.OPEN_PR,
- disabled: !enableSeerCoding,
- tooltip: enableSeerCoding
- ? undefined
- : tct(
- '[settings:"Enable Code Generation"] must be enabled by an admin in settings.',
- {
- settings: (
-
- ),
- }
- ),
- },
- ] as const;
-}
-
-export function AutofixStartBox({onSend, groupId}: AutofixStartBoxProps) {
- const organization = useOrganization();
- const [message, setMessage] = useState('');
- const [selectedStoppingPoint, setSelectedStoppingPoint] = useLocalStorageState(
- 'autofix:selected-stopping-point',
- AutofixStoppingPoint.ROOT_CAUSE
- );
-
- const handleSubmit = useCallback(
- (e: React.FormEvent, stoppingPoint?: AutofixStoppingPoint) => {
- e.preventDefault();
- const finalStoppingPoint = stoppingPoint ?? selectedStoppingPoint;
- setSelectedStoppingPoint(finalStoppingPoint);
- onSend(message, finalStoppingPoint);
- },
- [message, selectedStoppingPoint, onSend, setSelectedStoppingPoint]
- );
-
- const {primaryOption, dropdownOptions} = useMemo(() => {
- const options = getStoppingPointOptions(organization);
- const primary =
- options.find(opt => opt.value === selectedStoppingPoint) ?? options[0];
- const dropdown = options
- .filter(opt => opt.value !== selectedStoppingPoint)
- .map(opt => ({
- key: opt.key,
- label: opt.label,
- disabled: opt.disabled ?? false,
- tooltip: opt.tooltip,
- onAction: () =>
- handleSubmit({preventDefault: () => {}} as React.FormEvent, opt.value),
- }));
- return {primaryOption: primary, dropdownOptions: dropdown};
- }, [organization, selectedStoppingPoint, handleSubmit]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- setMessage(e.target.value)}
- onKeyDown={e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSubmit(e);
- }
- }}
- placeholder="Share helpful context here..."
- maxLength={4096}
- maxRows={10}
- size="sm"
- />
-
-
-
- {primaryOption.label}
-
-
- (
- }
- />
- )}
- />
-
-
-
-
-
- );
-}
-
-const Wrapper = styled('div')`
- display: flex;
- flex-direction: column;
- align-items: center;
- margin: ${p => p.theme.space.md} ${p => p.theme.space['3xl']};
- gap: ${p => p.theme.space.md};
-`;
-
-const ScaleContainer = styled('div')`
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: ${p => p.theme.space.md};
- margin-bottom: 100px;
-`;
-
-const Container = styled('div')`
- position: relative;
- width: 100%;
- border-radius: ${p => p.theme.radius.md};
- background: ${p => p.theme.tokens.background.primary}
- linear-gradient(
- 135deg,
- ${p => p.theme.colors.pink500}08,
- ${p => p.theme.colors.pink500}20
- );
- overflow: visible;
- padding: ${p => p.theme.space.xs};
- border: 1px solid ${p => p.theme.tokens.border.primary};
-`;
-
-const AutofixStartText = styled('div')`
- margin: 0;
- padding: ${p => p.theme.space.md};
- white-space: pre-wrap;
- word-break: break-word;
- font-size: ${p => p.theme.font.size.lg};
- position: relative;
- overflow: hidden;
-`;
-
-const BackgroundStar = styled('img')`
- position: absolute;
- filter: sepia(1) saturate(3) hue-rotate(290deg);
- opacity: 0.7;
- pointer-events: none;
- z-index: 0;
-`;
-
-const StyledArrow = styled(IconArrow)`
- color: ${p => p.theme.tokens.content.secondary};
- opacity: 0.5;
-`;
-
-const InputWrapper = styled('form')`
- display: flex;
- gap: ${p => p.theme.space.lg};
- padding: ${p => p.theme.space.sm} ${p => p.theme.space.lg};
-`;
-
-const StyledInput = styled(TextArea)`
- resize: none;
- background: ${p => p.theme.tokens.background.primary};
-
- border-color: ${p => p.theme.tokens.border.secondary};
- &:hover {
- border-color: ${p => p.theme.tokens.border.primary};
- }
-`;
-
-const StyledButton = styled(Button)`
- flex-shrink: 0;
-`;
-
-const DropdownTrigger = styled(Button)`
- box-shadow: none;
- border-radius: 0 ${p => p.theme.radius.md} ${p => p.theme.radius.md} 0;
- border-left: none;
-`;
diff --git a/static/app/components/events/autofix/autofixStepFeedback.tsx b/static/app/components/events/autofix/autofixStepFeedback.tsx
deleted file mode 100644
index d0dc7d30596e3b..00000000000000
--- a/static/app/components/events/autofix/autofixStepFeedback.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import {useState} from 'react';
-
-import type {ButtonProps} from '@sentry/scraps/button';
-import {Button} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-import {Text} from '@sentry/scraps/text';
-
-import {IconThumb} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useUser} from 'sentry/utils/useUser';
-
-type StepType = 'root_cause' | 'solution' | 'changes';
-
-interface AutofixStepFeedbackProps {
- groupId: string;
- runId: string;
- stepType: StepType;
- buttonSize?: ButtonProps['size'];
- compact?: boolean;
- onFeedbackClick?: (e: React.MouseEvent) => void;
-}
-
-export function AutofixStepFeedback({
- stepType,
- groupId,
- runId,
- buttonSize = 'xs',
- compact = false,
- onFeedbackClick,
-}: AutofixStepFeedbackProps) {
- const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
- const organization = useOrganization();
- const user = useUser();
-
- const handleFeedback = (positive: boolean, e?: React.MouseEvent) => {
- if (onFeedbackClick && e) {
- onFeedbackClick(e);
- }
-
- const analyticsData = {
- step_type: stepType,
- positive,
- group_id: groupId,
- autofix_run_id: runId,
- user_id: user.id,
- organization,
- };
-
- trackAnalytics('seer.autofix.feedback_submitted', analyticsData);
-
- setFeedbackSubmitted(true);
- };
-
- if (feedbackSubmitted) {
- return (
-
-
- {t('Thanks!')}
-
-
- );
- }
-
- const iconSize = buttonSize === 'zero' ? 'xs' : 'sm';
- const gap = compact ? '2xs' : 'xs';
-
- return (
-
- }
- onClick={e => handleFeedback(true, e)}
- aria-label={t('This was helpful')}
- />
- }
- onClick={e => handleFeedback(false, e)}
- aria-label={t('This was not helpful')}
- />
-
- );
-}
diff --git a/static/app/components/events/autofix/autofixSteps.spec.tsx b/static/app/components/events/autofix/autofixSteps.spec.tsx
deleted file mode 100644
index 4193a396fd8645..00000000000000
--- a/static/app/components/events/autofix/autofixSteps.spec.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import {AutofixDataFixture} from 'sentry-fixture/autofixData';
-import {AutofixProgressItemFixture} from 'sentry-fixture/autofixProgressItem';
-import {AutofixStepFixture} from 'sentry-fixture/autofixStep';
-
-import {render, screen} from 'sentry-test/reactTestingLibrary';
-
-import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps';
-import {AutofixStatus, AutofixStepType} from 'sentry/components/events/autofix/types';
-
-describe('AutofixSteps', () => {
- beforeEach(() => {
- MockApiClient.clearMockResponses();
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/coding-agents/',
- body: {integrations: []},
- });
- });
-
- const defaultProps = {
- data: AutofixDataFixture({
- steps: [
- AutofixStepFixture({
- id: '1',
- type: AutofixStepType.DEFAULT,
- status: AutofixStatus.COMPLETED,
- insights: [],
- progress: [],
- index: 0,
- }),
- AutofixStepFixture({
- id: '2',
- type: AutofixStepType.ROOT_CAUSE_ANALYSIS,
- status: AutofixStatus.COMPLETED,
- causes: [
- {
- id: 'cause1',
- root_cause_reproduction: [
- {
- title: 'step 1',
- code_snippet_and_analysis: 'details',
- is_most_important_event: true,
- relevant_code_file: {
- file_path: 'file.py',
- repo_name: 'owner/repo',
- },
- timeline_item_type: 'internal_code',
- },
- ],
- },
- ],
- selection: null,
- progress: [],
- index: 1,
- }),
- ],
- request: {
- repos: [],
- },
- codebases: {},
- last_triggered_at: '2023-01-01T00:00:00Z',
- run_id: '1',
- status: AutofixStatus.PROCESSING,
- }),
- groupId: 'group1',
- runId: 'run1',
- } satisfies React.ComponentProps;
-
- it('renders steps correctly', async () => {
- render( );
-
- expect(await screen.findByText('step 1')).toBeInTheDocument();
- });
-
- it('renders output stream when last step is processing', async () => {
- const propsWithProcessingStep: React.ComponentProps = {
- ...defaultProps,
- data: {
- ...defaultProps.data,
- steps: [
- ...(defaultProps.data.steps ?? []),
- AutofixStepFixture({
- id: '3',
- type: AutofixStepType.DEFAULT,
- status: AutofixStatus.PROCESSING,
- progress: [
- AutofixProgressItemFixture({
- message: 'Processing message',
- timestamp: '2023-01-01T00:00:00Z',
- }),
- ],
- insights: [],
- index: 2,
- }),
- ],
- },
- };
-
- render( );
- expect(
- await screen.findByText('Processing message', undefined, {timeout: 10_000})
- ).toBeInTheDocument();
- }, 10_000);
-
- it('shows error message when previous step errored', async () => {
- const propsWithErroredStep: React.ComponentProps = {
- ...defaultProps,
- data: {
- ...defaultProps.data,
- steps: [
- AutofixStepFixture({
- id: '1',
- type: AutofixStepType.DEFAULT,
- status: AutofixStatus.ERROR,
- insights: [],
- progress: [],
- index: 0,
- }),
- AutofixStepFixture({
- id: '2',
- type: AutofixStepType.DEFAULT,
- status: AutofixStatus.PROCESSING,
- insights: [],
- progress: [],
- index: 1,
- }),
- ],
- },
- };
-
- render( );
- expect(
- await screen.findByText(
- 'Seer encountered an error. Restarting step from scratch...'
- )
- ).toBeInTheDocument();
- });
-});
diff --git a/static/app/components/events/autofix/autofixSteps.tsx b/static/app/components/events/autofix/autofixSteps.tsx
deleted file mode 100644
index 05b86e3a9e481f..00000000000000
--- a/static/app/components/events/autofix/autofixSteps.tsx
+++ /dev/null
@@ -1,348 +0,0 @@
-import {Fragment, useEffect, useRef} from 'react';
-import styled from '@emotion/styled';
-import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion';
-
-import {AutofixChanges} from 'sentry/components/events/autofix/autofixChanges';
-import {AutofixOutputStream} from 'sentry/components/events/autofix/autofixOutputStream';
-import {
- AutofixRootCause,
- replaceHeadersWithBold,
-} from 'sentry/components/events/autofix/autofixRootCause';
-import {AutofixSolution} from 'sentry/components/events/autofix/autofixSolution';
-import {CodingAgentCard} from 'sentry/components/events/autofix/codingAgentCard';
-import {AutofixInsightCards} from 'sentry/components/events/autofix/insights/autofixInsightCards';
-import {
- AutofixStepType,
- type AutofixData,
- type AutofixProgressItem,
- type AutofixStep,
- type SeerRepoDefinition,
-} from 'sentry/components/events/autofix/types';
-import {useAutofixRepos} from 'sentry/components/events/autofix/useAutofix';
-import {getAutofixRunErrorMessage} from 'sentry/components/events/autofix/utils';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-const animationProps: MotionNodeAnimationOptions = {
- exit: {opacity: 0},
- initial: {opacity: 0},
- animate: {opacity: 1},
- transition: {duration: 0.3},
-};
-interface StepProps {
- groupId: string;
- hasErroredStepBefore: boolean;
- hasStepAbove: boolean;
- hasStepBelow: boolean;
- runId: string;
- step: AutofixStep;
- event?: Event;
- isAutoTriggeredRun?: boolean;
- isChangesFirstAppearance?: boolean;
- isRootCauseFirstAppearance?: boolean;
- isSolutionFirstAppearance?: boolean;
- previousDefaultStepIndex?: number;
- previousInsightCount?: number;
- shouldCollapseByDefault?: boolean;
-}
-
-interface AutofixStepsProps {
- data: AutofixData;
- groupId: string;
- runId: string;
- event?: Event;
-}
-
-function isProgressLog(
- item: AutofixProgressItem | AutofixStep
-): item is AutofixProgressItem {
- return 'message' in item && 'timestamp' in item;
-}
-
-function Step({
- step,
- groupId,
- runId,
- hasStepBelow,
- hasStepAbove,
- hasErroredStepBefore,
- previousDefaultStepIndex,
- previousInsightCount,
- isRootCauseFirstAppearance,
- isSolutionFirstAppearance,
- isChangesFirstAppearance,
- isAutoTriggeredRun,
- event,
- codingAgents,
-}: StepProps & {codingAgents?: Record}) {
- return (
-
-
-
-
-
- {hasErroredStepBefore && hasStepAbove && (
-
- {t('Seer encountered an error. Restarting step from scratch...')}
-
- )}
- {step.type === AutofixStepType.DEFAULT && (
-
- )}
- {step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS && (
-
- )}
- {step.type === AutofixStepType.SOLUTION && (
-
- )}
- {step.type === AutofixStepType.CHANGES && (
-
- )}
-
-
-
-
-
- );
-}
-
-export function AutofixSteps({data, groupId, runId, event}: AutofixStepsProps) {
- const organization = useOrganization();
- const enableSeerCoding = organization.enableSeerCoding !== false;
-
- const steps = data.steps;
- const isMountedRef = useRef(false);
- const {repos} = useAutofixRepos(groupId);
-
- const codingAgentData = Object.values(data.coding_agents || {}).map(agent => {
- let repo: SeerRepoDefinition | undefined;
- if (agent.results && agent.results.length > 0) {
- const result = agent.results[0];
- if (result) {
- repo = repos?.find(
- r =>
- result.repo_provider === r.provider &&
- `${r.owner}/${r.name}` === result.repo_full_name
- );
- }
- }
- return {
- codingAgentState: agent,
- repo,
- };
- });
-
- useEffect(() => {
- isMountedRef.current = true;
- return () => {
- isMountedRef.current = false;
- };
- }, []);
-
- if (!steps?.length) {
- return null;
- }
-
- const lastStep = steps[steps.length - 1];
- const logs = lastStep!.progress?.filter(isProgressLog) ?? [];
- const activeLog =
- lastStep!.completedMessage ??
- replaceHeadersWithBold(logs.at(-1)?.message ?? '') ??
- '';
-
- const isInitialMount = !isMountedRef.current;
-
- const shouldShowOutputStream =
- ((activeLog && lastStep!.status === 'PROCESSING') || lastStep!.output_stream) &&
- lastStep!.type !== AutofixStepType.CHANGES;
- const errorMessage = getAutofixRunErrorMessage(data);
- const shouldShowStandaloneError = errorMessage && !shouldShowOutputStream;
-
- const isAutoTriggeredRun = !!data.request.options?.auto_run_source;
-
- return (
-
- {steps.map((step, index) => {
- const previousDefaultStepIndex = steps
- .slice(0, index)
- .findLastIndex(s => s.type === AutofixStepType.DEFAULT);
- const previousDefaultStep =
- previousDefaultStepIndex >= 0 ? steps[previousDefaultStepIndex] : undefined;
- const previousInsightCount =
- previousDefaultStep?.type === AutofixStepType.DEFAULT
- ? previousDefaultStep.insights.length
- : undefined;
-
- const hasSolutionStepBefore = steps
- .slice(0, index)
- .some(s => s.type === AutofixStepType.SOLUTION);
- const hideStep =
- (!enableSeerCoding && hasSolutionStepBefore) ||
- (!enableSeerCoding && step.type === AutofixStepType.CHANGES);
-
- const previousStep = index > 0 ? steps[index - 1] : null;
- const previousStepErrored =
- previousStep !== null &&
- previousStep?.type === step.type &&
- previousStep.status === 'ERROR';
- const nextStep = index + 1 < steps.length ? steps[index + 1] : null;
- const twoInsightStepsInARow =
- nextStep?.type === AutofixStepType.DEFAULT &&
- step.type === AutofixStepType.DEFAULT &&
- step.insights.length > 0 &&
- nextStep.insights.length > 0;
- const stepBelowProcessingAndEmpty =
- nextStep?.type === AutofixStepType.DEFAULT &&
- nextStep?.status === 'PROCESSING' &&
- nextStep?.insights?.length === 0;
-
- if (hideStep) {
- return null;
- }
-
- return (
-
- = 0 ? previousDefaultStepIndex : undefined
- }
- previousInsightCount={previousInsightCount}
- isRootCauseFirstAppearance={
- step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS && !isInitialMount
- }
- isSolutionFirstAppearance={
- step.type === AutofixStepType.SOLUTION && !isInitialMount
- }
- isChangesFirstAppearance={
- step.type === AutofixStepType.CHANGES && !isInitialMount
- }
- isAutoTriggeredRun={isAutoTriggeredRun}
- event={event}
- codingAgents={data.coding_agents}
- />
-
- );
- })}
- {codingAgentData.map(({codingAgentState, repo}) => (
-
- ))}
- {shouldShowOutputStream && (
-
- )}
- {shouldShowStandaloneError && (
-
- {errorMessage}
-
- Just hit "Start Over."
-
- )}
-
- );
-}
-
-const StepMessage = styled('div')`
- overflow: hidden;
- padding: ${p => p.theme.space.md};
- color: ${p => p.theme.tokens.content.secondary};
- font-size: ${p => p.theme.font.size.sm};
- justify-content: flex-start;
- text-align: left;
-`;
-
-const StepCard = styled('div')`
- overflow: hidden;
-
- :last-child {
- margin-bottom: 0;
- }
-`;
-
-const ContentWrapper = styled(motion.div)`
- display: grid;
- grid-template-rows: 1fr;
- transition: grid-template-rows 300ms;
- will-change: grid-template-rows;
-
- > div {
- /* So that focused element outlines don't get cut off */
- padding: 0 1px;
- overflow: hidden;
- }
-`;
-
-const AnimationWrapper = styled(motion.div)``;
-
-const StandaloneErrorMessage = styled('div')`
- margin: ${p => p.theme.space.md} 0;
- padding: ${p => p.theme.space.xl};
- color: ${p => p.theme.tokens.content.secondary};
-`;
diff --git a/static/app/components/events/autofix/autofixTimeline.tsx b/static/app/components/events/autofix/autofixTimeline.tsx
deleted file mode 100644
index 7352ba905f227e..00000000000000
--- a/static/app/components/events/autofix/autofixTimeline.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import {useState} from 'react';
-
-import {AutofixTimelineItem} from 'sentry/components/events/autofix/autofixTimelineItem';
-import {Timeline} from 'sentry/components/timeline';
-
-import type {AutofixTimelineEvent} from './types';
-
-type Props = {
- events: AutofixTimelineEvent[];
- groupId: string;
- runId: string;
- eventCodeUrls?: Array;
- getCustomIcon?: (event: AutofixTimelineEvent) => React.ReactNode;
- retainInsightCardIndex?: number | null;
- stepIndex?: number;
-};
-
-export function AutofixTimeline({
- events,
- getCustomIcon,
- groupId,
- runId,
- stepIndex = 0,
- retainInsightCardIndex = null,
- eventCodeUrls,
-}: Props) {
- const [expandedItemIndex, setExpandedItemIndex] = useState(null);
-
- if (!events?.length) {
- return null;
- }
-
- const handleToggleExpand = (index: number) => {
- setExpandedItemIndex(prevIndex => (prevIndex === index ? null : index));
- };
-
- return (
-
- {events.map((event, index) => {
- const isMostImportantEvent =
- !!event.is_most_important_event && index !== events.length - 1;
-
- return (
-
- );
- })}
-
- );
-}
diff --git a/static/app/components/events/autofix/autofixTimelineItem.tsx b/static/app/components/events/autofix/autofixTimelineItem.tsx
deleted file mode 100644
index 566406d041a737..00000000000000
--- a/static/app/components/events/autofix/autofixTimelineItem.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import {useMemo} from 'react';
-import {useTheme, type Theme} from '@emotion/react';
-import styled from '@emotion/styled';
-import {AnimatePresence, motion} from 'framer-motion';
-
-import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper';
-import {replaceHeadersWithBold} from 'sentry/components/events/autofix/autofixRootCause';
-import {AutofixInsightSources} from 'sentry/components/events/autofix/insights/autofixInsightSources';
-import type {TimelineItemProps} from 'sentry/components/timeline';
-import {Timeline} from 'sentry/components/timeline';
-import {IconBroadcast, IconChevron, IconCode, IconUser} from 'sentry/icons';
-import {singleLineRenderer} from 'sentry/utils/marked/marked';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-
-import type {AutofixTimelineEvent} from './types';
-
-function getEventIcon(eventType: AutofixTimelineEvent['timeline_item_type']) {
- const iconProps = {
- style: {
- margin: 3,
- },
- };
-
- switch (eventType) {
- case 'external_system':
- return ;
- case 'internal_code':
- return ;
- case 'human_action':
- return ;
- default:
- return ;
- }
-}
-
-function getEventColor(
- theme: Theme,
- isActive?: boolean
-): TimelineItemProps['colorConfig'] {
- return {
- title: theme.tokens.content.primary,
- icon: isActive ? theme.colors.pink400 : theme.tokens.content.secondary,
- iconBorder: isActive ? theme.colors.pink400 : theme.tokens.content.secondary,
- };
-}
-
-interface AutofixTimelineItemProps {
- event: AutofixTimelineEvent;
- groupId: string;
- index: number;
- isExpanded: boolean;
- isMostImportantEvent: boolean;
- onToggleExpand: (index: number) => void;
- retainInsightCardIndex: number | null | undefined;
- runId: string;
- stepIndex: number;
- codeUrl?: string | null;
- getCustomIcon?: (event: AutofixTimelineEvent) => React.ReactNode | undefined;
-}
-
-export function AutofixTimelineItem({
- event,
- getCustomIcon,
- groupId,
- index,
- isExpanded,
- isMostImportantEvent,
- onToggleExpand,
- retainInsightCardIndex,
- runId,
- stepIndex,
- codeUrl,
-}: AutofixTimelineItemProps) {
- const theme = useTheme();
-
- const handleToggle = () => {
- onToggleExpand(index);
- };
-
- const titleHtml = useMemo(() => {
- return {__html: singleLineRenderer(event.title)};
- }, [event.title]);
-
- return (
-
-
-
-
-
-
- }
- isActive={isMostImportantEvent}
- icon={getCustomIcon?.(event) ?? getEventIcon(event.timeline_item_type)}
- colorConfig={getEventColor(theme, isMostImportantEvent)}
- >
-
- {isExpanded && (
-
-
-
-
-
- {codeUrl && (
-
-
-
- )}
-
-
- )}
-
-
- );
-}
-
-const AnimatedContent = styled(motion.div)`
- overflow: hidden;
-`;
-
-const StyledSpan = styled(MarkedText)`
- & code {
- font-size: ${p => p.theme.font.size.sm};
- background-color: transparent;
- display: inline-block;
- }
-`;
-
-const SourcesWrapper = styled('div')`
- margin-top: ${p => p.theme.space.xl};
-`;
-
-const StyledTimelineHeader = styled('div')<{isActive?: boolean}>`
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding: ${p => p.theme.space['0']} ${p => p.theme.space.xs};
- border-radius: ${p => p.theme.radius.md};
- cursor: pointer;
- font-weight: ${p => p.theme.font.weight.sans.regular};
- gap: ${p => p.theme.space.md};
- text-decoration: ${p => (p.isActive ? 'underline dashed' : 'none')};
- text-decoration-color: ${p => p.theme.colors.pink400};
- text-decoration-thickness: 1px;
- text-underline-offset: 4px;
-
- & > span:first-of-type {
- flex: 1;
- min-width: 0;
- margin-right: ${p => p.theme.space.md};
- }
-
- &:hover {
- background-color: ${p =>
- p.theme.tokens.interactive.transparent.neutral.background.hover};
- }
-
- &:active {
- background-color: ${p =>
- p.theme.tokens.interactive.transparent.neutral.background.active};
- }
-`;
-
-const StyledIconChevron = styled(IconChevron)`
- color: ${p => p.theme.tokens.content.secondary};
- flex-shrink: 0;
- margin-right: ${p => p.theme.space['2xs']};
-`;
diff --git a/static/app/components/events/autofix/codingAgentCard.tsx b/static/app/components/events/autofix/codingAgentCard.tsx
deleted file mode 100644
index eb8f3259d655ae..00000000000000
--- a/static/app/components/events/autofix/codingAgentCard.tsx
+++ /dev/null
@@ -1,320 +0,0 @@
-import {Fragment} from 'react';
-import styled from '@emotion/styled';
-import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion';
-
-import {Tag, type TagProps} from '@sentry/scraps/badge';
-import {Button} from '@sentry/scraps/button';
-import {Grid, Stack} from '@sentry/scraps/layout';
-import {ExternalLink} from '@sentry/scraps/link';
-import {Text} from '@sentry/scraps/text';
-
-import {DateTime} from 'sentry/components/dateTime';
-import {
- CodingAgentProvider,
- CodingAgentStatus,
- getCodingAgentName,
- getResultButtonLabel,
- type CodingAgentState,
- type SeerRepoDefinition,
-} from 'sentry/components/events/autofix/types';
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
-import {IconCode, IconOpen} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {sanitizedMarkedNoHeadings} from 'sentry/utils/marked/marked';
-
-const animationProps: MotionNodeAnimationOptions = {
- exit: {opacity: 0},
- initial: {opacity: 0},
- animate: {opacity: 1},
- transition: {duration: 0.3},
-};
-
-interface CodingAgentCardProps {
- codingAgentState: CodingAgentState;
- groupId?: string;
- repo?: SeerRepoDefinition;
-}
-
-export function CodingAgentCard({codingAgentState, groupId, repo}: CodingAgentCardProps) {
- const getTagVariant = (status: CodingAgentStatus): TagProps['variant'] => {
- switch (status) {
- case CodingAgentStatus.COMPLETED:
- return 'success';
- case CodingAgentStatus.FAILED:
- return 'danger';
- case CodingAgentStatus.PENDING:
- case CodingAgentStatus.RUNNING:
- default:
- return 'info';
- }
- };
-
- const getStatusText = (status: CodingAgentStatus) => {
- switch (status) {
- case CodingAgentStatus.PENDING:
- return t('Pending...');
- case CodingAgentStatus.RUNNING:
- return t('Running...');
- case CodingAgentStatus.COMPLETED:
- return t('Completed');
- case CodingAgentStatus.FAILED:
- return t('Failed');
- default:
- return status;
- }
- };
-
- const shouldShowSpinner = (status: CodingAgentStatus) => {
- return status === CodingAgentStatus.PENDING || status === CodingAgentStatus.RUNNING;
- };
-
- const hasButtons = Boolean(
- codingAgentState.agent_url || codingAgentState.results?.some(result => result.pr_url)
- );
-
- return (
-
-
-
-
-
-
-
-
-
- {shouldShowSpinner(codingAgentState.status) ? (
-
- ) : (
-
- )}
- {getCodingAgentName(codingAgentState.provider)}
-
-
-
-
-
- {codingAgentState.name}
-
-
- {getStatusText(codingAgentState.status)}
-
-
-
-
-
- {/* Show results for completed or failed agents */}
- {codingAgentState.results && codingAgentState.results.length > 0 && (
-
- {codingAgentState.status === CodingAgentStatus.FAILED && (
- {t('Error')}
- )}
- {codingAgentState.results.map((result, index) => (
-
-
-
-
-
- ))}
-
- )}
-
- {repo && (
-
- {t('Repository')}:
-
- {repo.owner}/{repo.name}
-
-
- )}
-
-
- {t('Started')}
-
-
-
-
- {hasButtons && (
-
-
-
-
- {codingAgentState.agent_url && (
-
- }
- analyticsEventName="Autofix: Open Coding Agent"
- analyticsEventKey="autofix.coding_agent.open"
- analyticsParams={{group_id: groupId}}
- >
- {codingAgentState.provider ===
- CodingAgentProvider.CURSOR_BACKGROUND_AGENT
- ? t('Open in Cursor')
- : codingAgentState.provider ===
- CodingAgentProvider.CLAUDE_CODE_AGENT
- ? t('Open in Claude')
- : t('View Agent')}
-
-
- )}
- {codingAgentState.results
- ?.filter(result => result.pr_url)
- .map(({pr_url}) => (
-
- }
- analyticsEventName="Autofix: Open Coding Agent PR"
- analyticsEventKey="autofix.coding_agent.open_pr"
- analyticsParams={{group_id: groupId}}
- variant="primary"
- >
- {getResultButtonLabel(pr_url)}
-
-
- ))}
-
-
-
- )}
-
-
-
-
-
-
- );
-}
-
-const VerticalLine = styled('div')`
- width: 0;
- height: ${p => p.theme.space.xl};
- border-left: 1px solid ${p => p.theme.tokens.border.primary};
- margin-left: 16px;
- margin-bottom: -1px;
-`;
-
-const StepCard = styled('div')`
- overflow: hidden;
-
- :last-child {
- margin-bottom: 0;
- }
-`;
-
-const ContentWrapper = styled(motion.div)`
- display: grid;
- grid-template-rows: 1fr;
- transition: grid-template-rows 300ms;
- will-change: grid-template-rows;
-
- > div {
- /* So that focused element outlines don't get cut off */
- padding: 0 1px;
- overflow: hidden;
- }
-`;
-
-const StyledCard = styled('div')`
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- overflow: hidden;
- box-shadow: ${p => p.theme.shadow.medium};
- padding-left: ${p => p.theme.space.xl};
- padding-right: ${p => p.theme.space.xl};
- background: ${p => p.theme.tokens.background.primary};
-`;
-
-const HeaderWrapper = styled('div')`
- display: flex;
- justify-content: space-between;
- align-items: center;
- flex-wrap: wrap;
- gap: ${p => p.theme.space.md};
- padding: ${p => p.theme.space.xl} 0 ${p => p.theme.space.md} 0;
-`;
-
-const HeaderText = styled('div')`
- font-weight: bold;
- font-size: ${p => p.theme.font.size.lg};
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.md};
-`;
-
-const Content = styled('div')`
- padding: ${p => p.theme.space.md} 0 ${p => p.theme.space.xl} 0;
-`;
-
-const AgentTitle = styled('h4')`
- margin: 0 0 ${p => p.theme.space.xs} 0;
- font-size: ${p => p.theme.font.size.md};
- color: ${p => p.theme.tokens.content.primary};
-`;
-
-const DetailRow = styled('div')`
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.xs};
- font-size: ${p => p.theme.font.size.sm};
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const Label = styled('span')`
- font-weight: 600;
- color: ${p => p.theme.tokens.content.secondary};
- min-width: 80px;
-`;
-
-const Value = styled('span')`
- color: ${p => p.theme.tokens.content.primary};
- font-family: ${p => p.theme.font.family.mono};
- font-size: ${p => p.theme.font.size.sm};
-`;
-
-const ResultsSection = styled('div')`
- display: flex;
- flex-direction: column;
- gap: ${p => p.theme.space.md};
- margin-bottom: ${p => p.theme.space.md};
-`;
-
-const ResultItem = styled('div')`
- display: flex;
- flex-direction: column;
- gap: ${p => p.theme.space.xs};
- padding: ${p => p.theme.space.md} 0;
- &:not(:last-child) {
- border-bottom: 1px solid ${p => p.theme.tokens.border.secondary};
- }
-`;
-
-const ResultDescription = styled('div')<{status: CodingAgentStatus}>`
- color: ${p =>
- p.status === CodingAgentStatus.FAILED
- ? p.theme.tokens.content.danger
- : p.theme.tokens.content.primary};
-`;
-
-const StyledLoadingIndicator = styled(LoadingIndicator)`
- height: ${p => p.size}px;
- width: ${p => p.size}px;
- margin: 0;
- margin-bottom: ${p => p.theme.space['2xs']};
-`;
-
-const BottomDivider = styled('div')`
- border-top: 1px solid ${p => p.theme.tokens.border.secondary};
-`;
-
-const BottomButtonContainer = styled('div')`
- display: flex;
- justify-content: flex-end;
- padding-top: ${p => p.theme.space.xl};
- padding-bottom: ${p => p.theme.space.xl};
-`;
diff --git a/static/app/components/events/autofix/drawer/drawerHeader.tsx b/static/app/components/events/autofix/drawer/drawerHeader.tsx
deleted file mode 100644
index 3203018382008c..00000000000000
--- a/static/app/components/events/autofix/drawer/drawerHeader.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import styled from '@emotion/styled';
-
-import {ProjectAvatar} from '@sentry/scraps/avatar';
-import {DrawerHeader} from '@sentry/scraps/drawer';
-import {Flex} from '@sentry/scraps/layout';
-import {Text} from '@sentry/scraps/text';
-
-import {Breadcrumbs} from 'sentry/components/breadcrumbs';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {getShortEventId} from 'sentry/utils/events';
-
-interface SeerDrawerHeaderProps {
- event: Event;
- group: Group;
- project: Project;
-}
-
-export function SeerDrawerHeader({group, project, event}: SeerDrawerHeaderProps) {
- return (
-
-
-
-
-
- {group.shortId}
-
-
- ),
- },
- {label: getShortEventId(event.id)},
- {label: t('Seer')},
- ]}
- />
-
-
- );
-}
-
-const NavigationCrumbs = styled(Breadcrumbs)`
- margin: 0;
- padding: 0;
-`;
diff --git a/static/app/components/events/autofix/drawer/drawerNavigator.tsx b/static/app/components/events/autofix/drawer/drawerNavigator.tsx
deleted file mode 100644
index 459f82bf4c9eb8..00000000000000
--- a/static/app/components/events/autofix/drawer/drawerNavigator.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import {Button, LinkButton} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-import {Link} from '@sentry/scraps/link';
-import {Heading} from '@sentry/scraps/text';
-
-import {AiPrivacyNotice} from 'sentry/components/aiPrivacyTooltip';
-import {AutofixFeedback} from 'sentry/components/events/autofix/autofixFeedback';
-import {QuestionTooltip} from 'sentry/components/questionTooltip';
-import {IconCopy} from 'sentry/icons/iconCopy';
-import {IconRefresh} from 'sentry/icons/iconRefresh';
-import {IconSeer} from 'sentry/icons/iconSeer';
-import {IconSettings} from 'sentry/icons/iconSettings';
-import {t, tct} from 'sentry/locale';
-import type {Project} from 'sentry/types/project';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {MIN_NAV_HEIGHT} from 'sentry/views/issueDetails/streamline/eventTitle';
-
-interface SeerDrawerNavigatorProps {
- project: Project;
- onCopyMarkdown?: () => void;
- onReset?: () => void;
- showCopyMarkdown?: boolean;
- showReset?: boolean;
-}
-
-export function SeerDrawerNavigator({
- project,
- onCopyMarkdown,
- onReset,
- showCopyMarkdown = true,
- showReset = true,
-}: SeerDrawerNavigatorProps) {
- const organization = useOrganization();
-
- return (
-
-
-
-
- {t('Seer Autofix')}
-
-
-
-
- {tct('Seer can be turned off in [settingsDocs:Settings].', {
- settingsDocs: (
-
- ),
- })}
-
-
- }
- size="sm"
- />
-
-
- {showReset && (
- }
- onClick={onReset}
- disabled={!onReset}
- aria-label={t('Start a new analysis from scratch')}
- tooltipProps={{title: t('Start a new analysis from scratch')}}
- />
- )}
- {showCopyMarkdown && (
- }
- onClick={onCopyMarkdown}
- disabled={!onCopyMarkdown}
- tooltipProps={{title: t('Copy analysis as Markdown / LLM prompt')}}
- aria-label={t('Copy analysis as Markdown')}
- />
- )}
- }
- />
-
-
-
- );
-}
diff --git a/static/app/components/events/autofix/drawer/welcomeScreen.tsx b/static/app/components/events/autofix/drawer/welcomeScreen.tsx
deleted file mode 100644
index 557d3dfbd1ff0d..00000000000000
--- a/static/app/components/events/autofix/drawer/welcomeScreen.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import {Container, Stack} from '@sentry/scraps/layout';
-
-import {GroupSummary} from 'sentry/components/group/groupSummary';
-import {OverrideOrDefault} from 'sentry/components/overrideOrDefault';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-
-const AiSetupDataConsent = OverrideOrDefault({
- overrideName: 'component:ai-setup-data-consent',
- defaultComponent: () =>
,
-});
-
-export function SeerWelcomeScreen({
- group,
- project,
- event,
-}: {
- event: Event;
- group: Group;
- project: Project;
-}) {
- return (
-
-
-
-
-
-
- );
-}
diff --git a/static/app/components/events/autofix/hooks/useUpdateInsightCard.tsx b/static/app/components/events/autofix/hooks/useUpdateInsightCard.tsx
deleted file mode 100644
index b5b6799ce877e1..00000000000000
--- a/static/app/components/events/autofix/hooks/useUpdateInsightCard.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import {useMutation, useQueryClient} from '@tanstack/react-query';
-
-import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator';
-import {autofixApiOptions} from 'sentry/components/events/autofix/useAutofix';
-import {t} from 'sentry/locale';
-import {useApi} from 'sentry/utils/useApi';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-interface UpdateInsightParams {
- message: string;
- retain_insight_card_index: number | null;
- step_index: number;
-}
-
-/**
- * Hook for updating insight cards with feedback and triggering a rethink.
- */
-export function useUpdateInsightCard({groupId, runId}: {groupId: string; runId: string}) {
- const api = useApi({persistInFlight: true});
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
-
- return useMutation({
- mutationFn: (params: UpdateInsightParams) => {
- return api.requestPromise(
- `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`,
- {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'restart_from_point_with_feedback',
- message: params.message.trim(),
- step_index: params.step_index,
- retain_insight_card_index: params.retain_insight_card_index,
- },
- },
- }
- );
- },
- onSuccess: _ => {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey,
- });
- addLoadingMessage(t('Rethinking this...'));
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when sending Seer your message.'));
- },
- });
-}
diff --git a/static/app/components/events/autofix/insights/autofixInsightCard.tsx b/static/app/components/events/autofix/insights/autofixInsightCard.tsx
deleted file mode 100644
index c80ff3b50552a5..00000000000000
--- a/static/app/components/events/autofix/insights/autofixInsightCard.tsx
+++ /dev/null
@@ -1,404 +0,0 @@
-import {Fragment, useMemo, useState} from 'react';
-import styled from '@emotion/styled';
-import {AnimatePresence, motion} from 'framer-motion';
-
-import {Button, ButtonBar} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-import {TextArea} from '@sentry/scraps/textarea';
-
-import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
-import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper';
-import {replaceHeadersWithBold} from 'sentry/components/events/autofix/autofixRootCause';
-import {useUpdateInsightCard} from 'sentry/components/events/autofix/hooks/useUpdateInsightCard';
-import type {AutofixInsight} from 'sentry/components/events/autofix/types';
-import {useTypingAnimation} from 'sentry/components/events/autofix/useTypingAnimation';
-import {IconChevron, IconClose} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {singleLineRenderer} from 'sentry/utils/marked/marked';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-import {ellipsize} from 'sentry/utils/string/ellipsize';
-
-interface AutofixInsightCardProps {
- groupId: string;
- index: number;
- insight: AutofixInsight;
- isExpanded: boolean;
- isNewInsight: boolean | undefined;
- onToggleExpand: (index: number | null) => void;
- runId: string;
- stepIndex: number;
-}
-
-export const cardAnimationProps = {
- exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
- initial: {opacity: 0, height: 0, scale: 0.8},
- animate: {opacity: 1, height: 'auto', scale: 1},
- transition: {
- duration: 1,
- height: {
- type: 'spring',
- bounce: 0.2,
- },
- scale: {
- type: 'spring',
- bounce: 0.2,
- },
- y: {
- type: 'tween',
- ease: 'easeOut',
- },
- },
-};
-
-export function FlippedReturnIcon(props: React.HTMLAttributes) {
- return {'\u21A9'} ;
-}
-
-export function AutofixInsightCard({
- insight,
- index,
- stepIndex,
- groupId,
- runId,
- isNewInsight,
- isExpanded,
- onToggleExpand,
-}: AutofixInsightCardProps) {
- const isUserMessage = insight.justification === 'USER';
- const [isEditing, setIsEditing] = useState(false);
- const [editText, setEditText] = useState('');
- const {mutate: updateInsight} = useUpdateInsightCard({groupId, runId});
- const displayedInsightTitle = useTypingAnimation({
- text: insight.insight,
- enabled: !!isNewInsight,
- speed: 70,
- });
-
- const toggleExpand = () => {
- onToggleExpand(index);
- };
-
- const handleEdit = (e: React.MouseEvent) => {
- e.stopPropagation();
- setIsEditing(true);
- setEditText('');
- onToggleExpand(null);
- };
-
- const handleCancel = () => {
- setIsEditing(false);
- setEditText('');
- };
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- setIsEditing(false);
- updateInsight({
- message: editText,
- step_index: stepIndex,
- retain_insight_card_index: index,
- });
- };
-
- const insightCardAboveIndex = index - 1 >= 0 ? index - 1 : null;
-
- const newlineIndex = displayedInsightTitle.indexOf('\n');
-
- const truncatedTitleHtml = useMemo(() => {
- let truncatedTitle = displayedInsightTitle;
- if (newlineIndex !== -1 && newlineIndex < displayedInsightTitle.length - 1) {
- truncatedTitle = ellipsize(truncatedTitle, newlineIndex);
- }
- return {
- __html: singleLineRenderer(truncatedTitle),
- };
- }, [displayedInsightTitle, newlineIndex]);
-
- const hasFullJustification = !isUserMessage && insight.justification;
-
- const fullJustificationText = useMemo(() => {
- let fullJustification = isUserMessage ? '' : insight.justification;
- if (newlineIndex !== -1) {
- const excludedText = displayedInsightTitle.substring(newlineIndex + 1);
- const excludedTextWithEllipsis = excludedText ? '...' + excludedText : '';
- fullJustification = excludedTextWithEllipsis + '\n\n' + fullJustification;
- }
- return replaceHeadersWithBold(fullJustification || t('No details here.'));
- }, [displayedInsightTitle, isUserMessage, insight.justification, newlineIndex]);
-
- // Determine if the card is expandable (not just 'No details here.')
- const isExpandable = useMemo(() => {
- // Remove markdown formatting and whitespace for the check
- const plainText = (hasFullJustification ? insight.justification : '').trim();
- // If there is a diff or markdown_snippets, allow expansion
- if (insight.change_diff || insight.markdown_snippets) return true;
- // If the justification is empty or just 'No details here.', not expandable
- return !!plainText && plainText.toLowerCase() !== t('No details here.').toLowerCase();
- }, [
- hasFullJustification,
- insight.justification,
- insight.change_diff,
- insight.markdown_snippets,
- ]);
-
- const renderCardContent = () => (
-
- {isEditing ? (
-
-
-
- ) : (
-
-
-
-
-
-
- {isExpandable && (
-
- }
- aria-label={isExpanded ? t('Hide evidence') : t('Show evidence')}
- />
- )}
- }
- aria-label={t('Edit insight')}
- tooltipProps={{title: t('Rethink the answer from here')}}
- analyticsEventName="Autofix: Insight Card Rethink"
- analyticsEventKey="autofix.insight.rethink"
- analyticsParams={{
- insight_card_index: index,
- step_index: stepIndex,
- group_id: groupId,
- run_id: runId,
- }}
- />
-
-
- )}
-
-
- {isExpanded && isExpandable && (
-
-
-
- {hasFullJustification || !insight.change_diff ? (
-
-
- {insight.markdown_snippets && (
-
- )}
-
- ) : (
-
-
-
- )}
-
-
-
- )}
-
-
- );
-
- return (
-
- {isNewInsight ? (
-
- {renderCardContent()}
-
- ) : (
- {renderCardContent()}
- )}
-
- );
-}
-
-// Styled Components
-const InsightCardRow = styled('div')<{expanded?: boolean; isUserMessage?: boolean}>`
- display: flex;
- justify-content: space-between;
- align-items: stretch;
- cursor: pointer;
-
- &:hover {
- background-color: ${p =>
- p.theme.tokens.interactive.transparent.neutral.background.hover};
- }
-
- &:active {
- background-color: ${p =>
- p.theme.tokens.interactive.transparent.neutral.background.active};
- }
-`;
-
-const ContextMarkedText = styled(MarkedText)`
- font-size: ${p => p.theme.font.size.sm};
- code {
- font-size: ${p => p.theme.font.size.sm};
- }
-`;
-
-const InsightContainer = styled('div')<{expanded?: boolean}>`
- border-radius: ${p => p.theme.radius.md};
- overflow: hidden;
- margin-bottom: 0;
- background: ${p => p.theme.tokens.background.primary};
- border: 1px dashed ${p => p.theme.tokens.border.primary};
- border-color: ${p => (p.expanded ? p.theme.tokens.border.primary : 'transparent')};
-
- box-shadow: ${p => (p.expanded ? p.theme.shadow.medium : 'none')};
-`;
-
-const MiniHeader = styled('p')<{expanded?: boolean}>`
- padding-top: ${p => p.theme.space['2xs']};
- padding-bottom: ${p => p.theme.space['2xs']};
- padding-left: ${p => p.theme.space.md};
- padding-right: ${p => p.theme.space.xl};
- margin: 0;
- flex: 1;
- word-break: break-word;
- color: ${p =>
- p.expanded ? p.theme.tokens.content.primary : p.theme.tokens.content.secondary};
-
- code {
- color: ${p =>
- p.expanded ? p.theme.tokens.content.primary : p.theme.tokens.content.secondary};
- }
-`;
-
-const ContextBody = styled('div')`
- padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0 ${p => p.theme.space.xl};
- background: ${p => p.theme.colors.blue100};
- border-radius: 0 0 ${p => p.theme.radius.md} ${p => p.theme.radius.md};
- overflow: hidden;
- position: relative;
- border-top: 1px dashed ${p => p.theme.tokens.border.secondary};
-
- code {
- white-space: pre-wrap;
- word-break: break-word;
- background-color: transparent;
- }
-`;
-
-const StyledIconChevron = styled(IconChevron)`
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const EditContainer = styled('div')`
- padding: ${p => p.theme.space.md};
- width: 100%;
-`;
-
-const EditInput = styled(TextArea)`
- flex: 1;
- resize: none;
-`;
-
-const EditButton = styled(Button)`
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const DiffContainer = styled('div')`
- margin-left: -${p => p.theme.space.xl};
- margin-right: -${p => p.theme.space.xl};
- margin-top: -${p => p.theme.space.xl};
-`;
-
-const CheckpointIcon = styled('span')`
- transform: scaleY(-1);
- margin-bottom: ${p => p.theme.space.xs};
-`;
diff --git a/static/app/components/events/autofix/insights/autofixInsightCards.tsx b/static/app/components/events/autofix/insights/autofixInsightCards.tsx
deleted file mode 100644
index ee0829800b5f6b..00000000000000
--- a/static/app/components/events/autofix/insights/autofixInsightCards.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import {Fragment, useEffect, useRef, useState} from 'react';
-import styled from '@emotion/styled';
-import {AnimatePresence, motion} from 'framer-motion';
-
-import {Flex} from '@sentry/scraps/layout';
-
-import type {AutofixInsight} from 'sentry/components/events/autofix/types';
-import {t} from 'sentry/locale';
-
-import {AutofixInsightCard} from './autofixInsightCard';
-import {CollapsibleChainLink} from './collapsibleChainLink';
-import {InsightSourcesFooter} from './insightSourcesFooter';
-
-interface AutofixInsightCardsProps {
- groupId: string;
- hasStepBelow: boolean;
- insights: AutofixInsight[];
- runId: string;
- stepIndex: number;
- shouldCollapseByDefault?: boolean;
-}
-
-function AutofixInsightCardsDisplay({
- insights,
- hasStepBelow,
- stepIndex,
- groupId,
- runId,
-}: AutofixInsightCardsProps) {
- const [expandedCardIndex, setExpandedCardIndex] = useState(null);
- const previousInsightsRef = useRef([]);
- const [newInsightIndices, setNewInsightIndices] = useState([]);
- const hasMounted = useRef(false);
-
- useEffect(() => {
- hasMounted.current = true;
- }, []);
-
- // Compare current insights with previous insights to determine which ones are new
- useEffect(() => {
- if (insights.length === previousInsightsRef.current.length + 1) {
- // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state
- setNewInsightIndices([insights.length - 1]);
- } else {
- setNewInsightIndices([]);
- }
- previousInsightsRef.current = [...insights];
- }, [insights]);
-
- const handleToggleExpand = (index: number | null) => {
- setExpandedCardIndex(prevIndex => (prevIndex === index ? null : index));
- };
-
- const validInsightCount = insights.filter(Boolean).length;
-
- return (
-
-
- {insights.length > 0 ? (
-
-
- {t('Reasoning')}
-
-
-
-
-
-
- {insights.map((insight, index) =>
- insight ? (
-
- ) : null
- )}
-
-
-
-
-
-
-
- ) : (
- // When no insights, show only vertical line and add button (no container)
-
- {stepIndex === 0 && !hasStepBelow ? (
-
- ) : (
- hasStepBelow && (
-
- )
- )}
-
- )}
- {hasStepBelow && }
-
- );
-}
-
-export function AutofixInsightCards(props: AutofixInsightCardsProps) {
- return ;
-}
-
-const HeaderText = styled('div')`
- font-weight: ${p => p.theme.font.weight.sans.medium};
- font-size: ${p => p.theme.font.size.lg};
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const NoInsightsYet = styled('div')`
- display: flex;
- justify-content: center;
- flex-direction: column;
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const InsightsContainerWithLines = styled('div')`
- display: flex;
- flex-direction: column;
- position: relative;
- margin-left: ${p => p.theme.space.xl};
- margin-right: ${p => p.theme.space.xl};
-`;
-
-const VerticalLine = styled('div')`
- width: 1px;
- height: ${p => p.theme.space.xl};
- /* eslint-disable-next-line @sentry/scraps/use-semantic-token */
- background-color: ${p => p.theme.tokens.border.primary};
- margin-left: 16px;
-`;
-
-const CardsStack = styled('div')`
- display: flex;
- flex-direction: column;
- gap: 0;
-`;
-
-const InsightsCardContainer = styled('div')`
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- overflow: hidden;
- box-shadow: ${p => p.theme.shadow.medium};
- padding: ${p => p.theme.space.lg};
- background: ${p => p.theme.tokens.background.primary};
- width: 100%;
- padding-bottom: 0;
-`;
-
-const Content = styled('div')`
- padding: ${p => p.theme.space.md} 0;
-`;
diff --git a/static/app/components/events/autofix/insights/autofixInsightSources.tsx b/static/app/components/events/autofix/insights/autofixInsightSources.tsx
deleted file mode 100644
index 3959d4a1f02e0d..00000000000000
--- a/static/app/components/events/autofix/insights/autofixInsightSources.tsx
+++ /dev/null
@@ -1,446 +0,0 @@
-import {useEffect, useRef, useState} from 'react';
-import {createPortal} from 'react-dom';
-import styled from '@emotion/styled';
-
-import {Button} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-
-import type {InsightSources} from 'sentry/components/events/autofix/types';
-import {
- IconChat,
- IconCode,
- IconCommit,
- IconFatal,
- IconGlobe,
- IconList,
- IconProfiling,
- IconSpan,
- IconStack,
-} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {defined} from 'sentry/utils';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-import {ellipsize} from 'sentry/utils/string/ellipsize';
-import {useLocation} from 'sentry/utils/useLocation';
-import {useNavigate} from 'sentry/utils/useNavigate';
-import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
-
-interface AutofixInsightSourcesProps {
- codeUrls?: string[];
- sources?: InsightSources;
- title?: string;
-}
-
-// Helper to extract a meaningful name from a code URL
-function getCodeSourceName(url: string): string {
- try {
- const urlObj = new URL(url);
- // Attempt to get the filename from the path
- const pathParts = urlObj.pathname.split('/');
- const filename = pathParts[pathParts.length - 1];
-
- // Extract line numbers if available in query parameters or hash
- let lineInfo = '';
- const searchParams = new URLSearchParams(urlObj.search);
- const hash = urlObj.hash;
-
- // Check common line number formats in query parameters (L, line, etc.)
- if (searchParams.has('L') || searchParams.has('line')) {
- const lineParam = searchParams.get('L') || searchParams.get('line');
- if (lineParam) {
- lineInfo = `:${lineParam}`;
- }
- }
-
- // Check for GitHub-style line numbers in hash (#L10 or #L10-L20)
- if (!lineInfo && hash) {
- const lineMatch = hash.match(/^#L(\d+)(?:-L(\d+))?$/);
- if (lineMatch) {
- lineInfo = lineMatch[2] ? `:${lineMatch[1]}-${lineMatch[2]}` : `:${lineMatch[1]}`;
- }
- }
-
- if (filename) {
- return filename + lineInfo;
- }
- } catch (e) {
- // Fallback if URL parsing fails or path is simple
- }
- // Fallback to a truncated version of the URL
- return ellipsize(url, 30);
-}
-
-// Helper to extract commit SHA from a commit URL and truncate to 7 characters
-function getCommitSha(url: string): string {
- try {
- const urlObj = new URL(url);
- const pathParts = urlObj.pathname.split('/');
-
- // Look for common commit URL patterns
- // GitHub: /user/repo/commit/SHA
- // GitLab: /user/repo/-/commit/SHA
- // Bitbucket: /user/repo/commits/SHA
- const commitIndex = pathParts.findIndex(
- part => part === 'commit' || part === 'commits'
- );
-
- if (commitIndex !== -1 && commitIndex < pathParts.length - 1) {
- const sha = pathParts[commitIndex + 1];
- // Truncate to first 7 characters
- if (sha) {
- return sha.substring(0, 7);
- }
- }
-
- // Fallback: use the last part of the path
- const lastPart = pathParts[pathParts.length - 1];
- return lastPart ? lastPart.substring(0, 7) : ellipsize(url, 7);
- } catch (e) {
- // Fallback if URL parsing fails
- return ellipsize(url, 7);
- }
-}
-
-export function AutofixInsightSources({
- sources,
- title,
- codeUrls,
-}: AutofixInsightSourcesProps) {
- const [showThoughtsPopup, setShowThoughtsPopup] = useState(false);
- const overlayRef = useRef(null);
- const thoughtsButtonRef = useRef(null);
- const navigate = useNavigate();
- const location = useLocation();
-
- useEffect(() => {
- function handleClickOutside(event: MouseEvent) {
- if (thoughtsButtonRef.current?.contains(event.target as Node)) {
- return;
- }
-
- if (overlayRef.current && !overlayRef.current.contains(event.target as Node)) {
- setShowThoughtsPopup(false);
- }
- }
-
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [overlayRef, thoughtsButtonRef]);
-
- if (!sources && !codeUrls) {
- return null;
- }
-
- // Generate source cards using the reusable function
- const sourceCardData = generateSourceCards(sources, codeUrls, {location, navigate});
-
- // Convert to JSX elements
- const sourceCards = sourceCardData.map(sourceCard => (
-
- {sourceCard.label}
-
- ));
-
- // Add thoughts card separately since it needs special handling (ref and popup)
- if (defined(sources?.thoughts) && sources?.thoughts.length > 0) {
- sourceCards.push(
- {
- setShowThoughtsPopup(prev => !prev);
- }}
- size="xs"
- icon={ }
- >
- {t('Thoughts')}
-
- );
- }
-
- if (sourceCards.length === 0) {
- return null;
- }
-
- return (
-
-
- {sourceCards}
-
- {showThoughtsPopup &&
- sources?.thoughts &&
- document.body &&
- createPortal(
-
-
- {t("Seer's Thoughts")}
- {title && "{title.trim()}" }
-
-
-
-
-
-
- setShowThoughtsPopup(false)}>{t('Close')}
-
-
- ,
- document.body
- )}
-
- );
-}
-
-const SourcesContainer = styled('div')`
- margin-top: -${p => p.theme.space.md};
- padding-bottom: ${p => p.theme.space.md};
- width: 100%;
-`;
-
-export const SourceCard = styled(Button)<{isHighlighted?: boolean}>`
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.xs};
- font-weight: ${p => p.theme.font.weight.sans.regular};
- color: ${p =>
- p.isHighlighted ? p.theme.colors.white : p.theme.tokens.content.secondary};
- white-space: nowrap;
- flex-shrink: 0;
-`;
-
-const ThoughtsOverlay = styled('div')`
- position: fixed;
- bottom: ${p => p.theme.space.xl};
- left: 50%;
- right: ${p => p.theme.space.xl};
- background: ${p => p.theme.tokens.background.primary};
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
- box-shadow: ${p => p.theme.shadow.high};
- z-index: ${p => p.theme.zIndex.tooltip};
- display: flex;
- flex-direction: column;
- max-height: calc(100vh - 18rem);
-
- @media (max-width: ${p => p.theme.breakpoints.sm}) {
- left: ${p => p.theme.space.xl};
- }
-`;
-
-const OverlayHeader = styled('div')`
- padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0;
- border-bottom: 1px solid ${p => p.theme.tokens.border.primary};
-`;
-
-const OverlayContent = styled('div')`
- padding: ${p => p.theme.space.xl};
- overflow-y: auto;
-`;
-
-const OverlayFooter = styled('div')`
- padding: ${p => p.theme.space.md};
- border-top: 1px solid ${p => p.theme.tokens.border.primary};
-`;
-
-const OverlayButtonGroup = styled('div')`
- display: flex;
- justify-content: flex-end;
- gap: ${p => p.theme.space.md};
- font-family: ${p => p.theme.font.family.sans};
-`;
-
-const OverlayTitle = styled('div')`
- font-weight: bold;
- color: ${p => p.theme.tokens.content.primary};
- font-family: ${p => p.theme.font.family.sans};
-`;
-
-const InsightTitle = styled('div')`
- padding-bottom: ${p => p.theme.space.md};
- color: ${p => p.theme.tokens.content.secondary};
- font-family: ${p => p.theme.font.family.sans};
-`;
-
-export function generateSourceCards(
- sources?: InsightSources,
- codeUrls?: string[],
- options?: {
- isPrimary?: boolean;
- location?: ReturnType;
- navigate?: ReturnType;
- }
-) {
- if (!sources && !codeUrls) {
- return [];
- }
-
- const sourceCards = [];
- const {isPrimary = false, location, navigate} = options || {};
-
- // Stacktrace Card
- if (sources?.stacktrace_used) {
- sourceCards.push({
- key: 'stacktrace',
- onClick: () => {
- if (navigate && location) {
- navigate({
- pathname: location.pathname,
- query: location.query,
- hash: SectionKey.EXCEPTION,
- });
- requestAnimationFrame(() => {
- document
- .getElementById(SectionKey.EXCEPTION)
- ?.scrollIntoView({block: 'start', behavior: 'smooth'});
- });
- }
- },
- icon: ,
- label: t('Stacktrace'),
- isPrimary,
- });
- }
-
- // Breadcrumbs Card
- if (sources?.breadcrumbs_used) {
- sourceCards.push({
- key: 'breadcrumbs',
- onClick: () => {
- if (navigate && location) {
- navigate({
- pathname: location.pathname,
- query: location.query,
- hash: SectionKey.REQUEST,
- });
- requestAnimationFrame(() => {
- document
- .getElementById(SectionKey.BREADCRUMBS)
- ?.scrollIntoView({block: 'start', behavior: 'smooth'});
- });
- }
- },
- icon: ,
- label: t('Breadcrumbs'),
- isPrimary,
- });
- }
-
- // HTTP Request Card
- if (sources?.http_request_used) {
- sourceCards.push({
- key: 'http-request',
- onClick: () => {
- if (navigate && location) {
- navigate({
- pathname: location.pathname,
- query: location.query,
- hash: SectionKey.REQUEST,
- });
- requestAnimationFrame(() => {
- document
- .getElementById(SectionKey.REQUEST)
- ?.scrollIntoView({block: 'start', behavior: 'smooth'});
- });
- }
- },
- icon: ,
- label: t('HTTP Request'),
- isPrimary,
- });
- }
-
- // Trace Event Cards
- sources?.trace_event_ids_used?.forEach(id => {
- sourceCards.push({
- key: `trace-${id}`,
- onClick: () => {
- if (sources?.event_trace_id) {
- window.open(
- `/explore/traces/trace/${sources.event_trace_id}/?node=span-${id}×tamp=${sources.event_trace_timestamp?.toString() ?? ''}`,
- '_blank'
- );
- }
- },
- icon: ,
- label: t('Trace: %s', id.substring(0, 7)),
- isPrimary,
- });
- });
-
- // Profile ID Cards
- sources?.profile_ids_used?.forEach(id => {
- sourceCards.push({
- key: `profile-${id}`,
- icon: ,
- onClick: () => window.open(`/explore/profiling/profile/${id}/flamegraph`, '_blank'),
- label: t('Profile: %s', id.substring(0, 7)),
- isPrimary,
- });
- });
-
- // Connected Error ID Cards
- sources?.connected_error_ids_used?.forEach(id => {
- sourceCards.push({
- key: `error-${id}`,
- onClick: () => {
- if (sources?.event_trace_id) {
- window.open(
- `/issues/trace/${sources.event_trace_id}?node=error-${id}`,
- '_blank'
- );
- }
- },
- icon: ,
- label: t('Error: %s', id.substring(0, 7)),
- isPrimary,
- });
- });
-
- // Code URL Cards
- sources?.code_used_urls?.forEach(url => {
- sourceCards.push({
- key: `code-${url}`,
- onClick: () => window.open(url, '_blank'),
- icon: ,
- label: getCodeSourceName(url),
- isPrimary,
- });
- });
-
- if (codeUrls) {
- codeUrls.forEach(url => {
- sourceCards.push({
- key: `passed-code-${url}`,
- onClick: () => window.open(url, '_blank'),
- icon: ,
- label: getCodeSourceName(url),
- isPrimary,
- });
- });
- }
-
- // Diff URL Cards
- sources?.diff_urls?.forEach(url => {
- sourceCards.push({
- key: `diff-${url}`,
- onClick: () => window.open(url, '_blank'),
- icon: ,
- label: t('Commit %s', getCommitSha(url)),
- isPrimary,
- });
- });
-
- return sourceCards;
-}
diff --git a/static/app/components/events/autofix/insights/collapsibleChainLink.tsx b/static/app/components/events/autofix/insights/collapsibleChainLink.tsx
deleted file mode 100644
index 25310dd2c744ed..00000000000000
--- a/static/app/components/events/autofix/insights/collapsibleChainLink.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import React, {useState} from 'react';
-import styled from '@emotion/styled';
-
-import {Button, ButtonBar} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-import {TextArea} from '@sentry/scraps/textarea';
-
-import {useUpdateInsightCard} from 'sentry/components/events/autofix/hooks/useUpdateInsightCard';
-import {IconClose} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-import {FlippedReturnIcon} from './autofixInsightCard';
-
-interface CollapsibleChainLinkProps {
- groupId: string;
- runId: string;
- stepIndex: number;
- insightCount?: number;
- isCollapsed?: boolean;
- isEmpty?: boolean;
- showAddControl?: boolean;
-}
-
-export function CollapsibleChainLink({
- insightCount,
- isCollapsed,
- isEmpty,
- showAddControl,
- stepIndex,
- groupId,
- runId,
-}: CollapsibleChainLinkProps) {
- // Only show the rethink button if there are no insights
- const shouldShowRethinkButton =
- showAddControl && !isCollapsed && !isEmpty && insightCount === 0;
-
- const [isAdding, setIsAdding] = useState(false);
- const [newInsightText, setNewInsightText] = useState('');
- const {mutate: updateInsight} = useUpdateInsightCard({groupId, runId});
-
- const organization = useOrganization();
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- setIsAdding(false);
- updateInsight({
- message: newInsightText,
- step_index: stepIndex,
- retain_insight_card_index:
- insightCount !== undefined && insightCount > 0 ? insightCount : null,
- });
- setNewInsightText('');
-
- trackAnalytics('autofix.step.rethink', {
- step_index: stepIndex,
- group_id: groupId,
- run_id: runId,
- organization,
- });
- };
-
- const handleCancel = () => {
- setIsAdding(false);
- setNewInsightText('');
- };
-
- return (
-
-
- {shouldShowRethinkButton &&
- (isAdding ? (
-
-
-
- ) : (
- setIsAdding(true)}
- tooltipProps={{title: t('Give feedback and rethink the answer')}}
- aria-label={t('Give feedback and rethink the answer')}
- analyticsEventName="Autofix: Step Rethink Open"
- analyticsEventKey="autofix.step.rethink_open"
- analyticsParams={{
- step_index: stepIndex,
- group_id: groupId,
- run_id: runId,
- }}
- >
- {t('Rethink this answer')}
-
-
- ))}
-
-
- );
-}
-
-// Styled Components
-const VerticalLineContainer = styled('div')<{
- isEmpty?: boolean;
-}>`
- position: relative;
- z-index: 1;
- width: 100%;
- display: flex;
- padding: 0;
- min-height: ${p => (p.isEmpty ? p.theme.space['3xl'] : 'auto')};
-`;
-
-const RethinkButtonContainer = styled('div')`
- position: relative;
- display: flex;
- justify-content: flex-end;
- align-items: center;
- width: 100%;
- background: ${p => p.theme.tokens.background.primary};
- border-radius: 0;
- padding: 0;
- z-index: 1;
-`;
-
-const AddEditContainer = styled('div')`
- padding: ${p => p.theme.space.md};
- width: 100%;
- background: ${p => p.theme.tokens.background.primary};
- border-radius: ${p => p.theme.radius.md};
-`;
-
-const EditInput = styled(TextArea)`
- flex: 1;
- resize: none;
-`;
-
-const AddButton = styled(Button)`
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const RethinkLabel = styled('span')`
- display: flex;
- align-items: center;
- font-size: ${p => p.theme.font.size.sm};
- color: ${p => p.theme.tokens.content.secondary};
- margin-right: ${p => p.theme.space.xs};
-`;
diff --git a/static/app/components/events/autofix/insights/insightSourcesFooter.tsx b/static/app/components/events/autofix/insights/insightSourcesFooter.tsx
deleted file mode 100644
index 40b194ed66f0d8..00000000000000
--- a/static/app/components/events/autofix/insights/insightSourcesFooter.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import {Fragment, useMemo, useState} from 'react';
-import styled from '@emotion/styled';
-import {motion} from 'framer-motion';
-
-import {Button} from '@sentry/scraps/button';
-import {Input} from '@sentry/scraps/input';
-import {Flex} from '@sentry/scraps/layout';
-
-import {useUpdateInsightCard} from 'sentry/components/events/autofix/hooks/useUpdateInsightCard';
-import {
- generateSourceCards,
- SourceCard,
-} from 'sentry/components/events/autofix/insights/autofixInsightSources';
-import type {AutofixInsight} from 'sentry/components/events/autofix/types';
-import {
- deduplicateSourcesAndUpdateInsights,
- getExpandedInsightSources,
-} from 'sentry/components/events/autofix/utils/insightUtils';
-import {t} from 'sentry/locale';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {useLocation} from 'sentry/utils/useLocation';
-import {useNavigate} from 'sentry/utils/useNavigate';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-import {cardAnimationProps, FlippedReturnIcon} from './autofixInsightCard';
-
-interface InsightSourcesFooterProps {
- expandedCardIndex: number | null;
- groupId: string;
- insights: AutofixInsight[];
- runId: string;
- stepIndex: number;
-}
-
-export function InsightSourcesFooter({
- insights,
- expandedCardIndex,
- stepIndex,
- groupId,
- runId,
-}: InsightSourcesFooterProps) {
- const navigate = useNavigate();
- const location = useLocation();
- const [newInsightText, setNewInsightText] = useState('');
- const {mutate: updateInsight} = useUpdateInsightCard({groupId, runId});
- const organization = useOrganization();
-
- const {deduplicatedSources, updatedInsights} = useMemo(
- () => deduplicateSourcesAndUpdateInsights(insights),
- [insights]
- );
-
- const expandedSources = useMemo(
- () => getExpandedInsightSources(updatedInsights, expandedCardIndex),
- [updatedInsights, expandedCardIndex]
- );
-
- const sourceCards = useMemo(
- () => generateSourceCards(deduplicatedSources, undefined, {location, navigate}),
- [deduplicatedSources, location, navigate]
- );
-
- const expandedCards = useMemo(
- () =>
- expandedSources
- ? generateSourceCards(expandedSources, undefined, {location, navigate})
- : [],
- [expandedSources, location, navigate]
- );
-
- const renderedSourceCards = useMemo(
- () =>
- sourceCards.map(sourceCard => {
- // Check if this source should be primary (expanded insight contains it)
- const shouldBePrimary = expandedCards.some(
- expandedCard => expandedCard.key === sourceCard.key
- );
-
- return (
-
-
- {sourceCard.label}
-
-
- );
- }),
- [sourceCards, expandedCards]
- );
-
- if (insights.length === 0) {
- return null;
- }
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (!newInsightText.trim()) return;
-
- updateInsight({
- message: newInsightText,
- step_index: stepIndex,
- retain_insight_card_index: insights.length > 0 ? insights.length : null,
- });
- setNewInsightText('');
-
- trackAnalytics('autofix.step.rethink', {
- step_index: stepIndex,
- group_id: groupId,
- run_id: runId,
- organization,
- });
- };
-
- return (
-
-
-
-
-
- {renderedSourceCards}
-
-
-
- setNewInsightText(e.target.value)}
- maxLength={4096}
- placeholder={t('Rethink this answer...')}
- size="xs"
- onKeyDown={e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSubmit(e);
- }
- }}
- />
-
-
-
-
-
-
-
-
- );
-}
-
-// Styled Components
-const BottomDivider = styled('div')`
- margin-top: ${p => p.theme.space.lg};
- border-top: 1px solid ${p => p.theme.tokens.border.secondary};
-`;
-
-const FooterInputContainer = styled('div')`
- width: 50%;
- max-width: 250px;
- align-self: flex-end;
-`;
-
-const FooterInputWrapper = styled('form')`
- display: flex;
- position: relative;
- border-radius: ${p => p.theme.radius.md};
-`;
-
-const FooterInput = styled(Input)`
- padding-right: ${p => p.theme.space['3xl']};
-`;
-
-const FooterSubmitButton = styled(Button)`
- position: absolute;
- right: ${p => p.theme.space.md};
- top: 50%;
- transform: translateY(-50%);
- height: 24px;
- width: 24px;
- border-radius: 5px;
-`;
diff --git a/static/app/components/events/autofix/seerCreateViewButton.tsx b/static/app/components/events/autofix/seerCreateViewButton.tsx
deleted file mode 100644
index f1ba3dd6698e85..00000000000000
--- a/static/app/components/events/autofix/seerCreateViewButton.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import {useMemo} from 'react';
-import {useQuery} from '@tanstack/react-query';
-
-import {Button} from '@sentry/scraps/button';
-
-import {
- addErrorMessage,
- addLoadingMessage,
- addSuccessMessage,
-} from 'sentry/actionCreators/indicator';
-import {t} from 'sentry/locale';
-import type {Project} from 'sentry/types/project';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useCreateGroupSearchView} from 'sentry/views/issueList/mutations/useCreateGroupSearchView';
-import {useUpdateGroupSearchViewStarred} from 'sentry/views/issueList/mutations/useUpdateGroupSearchViewStarred';
-import {groupSearchViewsApiOptions} from 'sentry/views/issueList/queries/useFetchGroupSearchViews';
-import {
- GroupSearchViewCreatedBy,
- type GroupSearchView,
-} from 'sentry/views/issueList/types';
-import {IssueSortOptions} from 'sentry/views/issueList/utils';
-
-interface StarFixabilityViewButtonProps {
- isCompleted: boolean;
- project: Project;
-}
-
-const TARGET_VIEW_PROPERTIES = {
- name: 'Easy Fixes 🤖',
- query: 'is:unresolved issue.seer_actionability:[high,super_high]',
- querySort: IssueSortOptions.DATE,
- projects: [],
- environments: [],
- timeFilters: {
- start: null,
- end: null,
- period: '7d',
- utc: null,
- },
-};
-
-export function StarFixabilityViewButton({
- isCompleted,
- project,
-}: StarFixabilityViewButtonProps) {
- const organization = useOrganization();
-
- const {mutate: createIssueView} = useCreateGroupSearchView({
- onMutate: () => {
- addLoadingMessage(t('Creating view...'));
- },
- onSuccess: () => {
- addSuccessMessage(t('View starred successfully'));
- },
- onError: () => {
- addErrorMessage(t('Failed to create view'));
- },
- });
-
- const {mutate: starExistingView} = useUpdateGroupSearchViewStarred({
- onMutate: () => {
- addLoadingMessage(t('Starring view...'));
- },
- onSuccess: () => {
- addSuccessMessage(t('View starred successfully'));
- },
- onError: () => {
- addErrorMessage(t('Failed to star view'));
- },
- });
-
- // Fetch all views to check for existing ones with our target name
- const {data: othersViews = []} = useQuery(
- groupSearchViewsApiOptions({
- orgSlug: organization.slug,
- limit: 20,
- query: 'Easy Fixes 🤖', // Search by name
- sort: ['-popularity'],
- createdBy: GroupSearchViewCreatedBy.OTHERS,
- })
- );
-
- const {data: myViews = []} = useQuery(
- groupSearchViewsApiOptions({
- orgSlug: organization.slug,
- limit: 20,
- query: 'Easy Fixes 🤖', // Search by name
- sort: ['-popularity'],
- createdBy: GroupSearchViewCreatedBy.ME,
- })
- );
-
- const allViews = useMemo(() => [...othersViews, ...myViews], [othersViews, myViews]);
-
- // Check if an existing view matches our criteria
- const existingMatchingView = useMemo(() => {
- return allViews.find((view: GroupSearchView) => {
- // Must have exact name match
- if (view.name !== TARGET_VIEW_PROPERTIES.name) {
- return false;
- }
-
- // Must query the right field
- if (
- !view.query.includes('issue.seer_actionability:[high,super_high]') &&
- !view.query.includes('issue.seer_actionability:[super_high,high]') &&
- !view.query.includes('issue.seer_actionability:super_high')
- ) {
- return false;
- }
-
- // Check project match - either matches current project or is "All Projects" (empty array)
- const viewHasNoProjects = view.projects.length === 0;
- const viewHasCurrentProject = view.projects.includes(Number(project.id));
- const projectMatches = viewHasNoProjects || viewHasCurrentProject;
-
- if (!projectMatches) {
- return false;
- }
-
- return true;
- });
- }, [allViews, project.id]);
-
- const handleStarFixabilityView = () => {
- if (existingMatchingView) {
- // Star the existing view instead of creating a new one
- starExistingView({
- id: existingMatchingView.id,
- starred: true,
- view: existingMatchingView,
- });
- } else {
- // Create a new view
- createIssueView({
- ...TARGET_VIEW_PROPERTIES,
- starred: true,
- });
- }
- };
-
- return (
-
- {isCompleted ? t('View Already Starred') : t('Star Recommended View')}
-
- );
-}
diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts
index 9154d4f8713bad..4837aa5ddfef41 100644
--- a/static/app/components/events/autofix/types.ts
+++ b/static/app/components/events/autofix/types.ts
@@ -1,6 +1,4 @@
import {t} from 'sentry/locale';
-import type {EventMetadata} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
import type {User} from 'sentry/types/user';
import {isArrayOf} from 'sentry/types/utils';
@@ -91,7 +89,7 @@ export function getResultButtonLabel(url: string | null | undefined): string {
return t('View Pull Request');
}
-export interface CodingAgentState {
+interface CodingAgentState {
id: string;
name: string;
provider: CodingAgentProvider;
@@ -130,14 +128,14 @@ export type AutofixData = {
users?: Record;
};
-export type AutofixProgressItem = {
+type AutofixProgressItem = {
message: string;
timestamp: string;
type: 'INFO' | 'WARNING' | 'ERROR' | 'NEED_MORE_INFORMATION';
data?: any;
};
-export type AutofixStep =
+type AutofixStep =
| AutofixDefaultStep
| AutofixRootCauseStep
| AutofixSolutionStep
@@ -157,19 +155,19 @@ interface BaseStep {
output_stream?: string | null;
}
-export type CommentThread = {
+type CommentThread = {
id: string;
is_completed: boolean;
messages: CommentThreadMessage[];
};
-export interface CommentThreadMessage {
+interface CommentThreadMessage {
content: string;
role: 'user' | 'assistant';
isLoading?: boolean;
}
-export type AutofixInsight = {
+type AutofixInsight = {
insight: string;
justification: string;
change_diff?: FilePatch[];
@@ -178,7 +176,7 @@ export type AutofixInsight = {
type?: 'insight' | 'file_change';
};
-export type InsightSources = {
+type InsightSources = {
breadcrumbs_used: boolean;
code_used_urls: string[];
connected_error_ids_used: string[];
@@ -192,12 +190,12 @@ export type InsightSources = {
event_trace_timestamp?: number;
};
-export interface AutofixDefaultStep extends BaseStep {
+interface AutofixDefaultStep extends BaseStep {
insights: AutofixInsight[];
type: AutofixStepType.DEFAULT;
}
-export type AutofixRootCauseSelection =
+type AutofixRootCauseSelection =
| {
cause_id: string;
}
@@ -219,7 +217,7 @@ interface AutofixSolutionStep extends BaseStep {
description?: string;
}
-export type AutofixCodebaseChange = {
+type AutofixCodebaseChange = {
description: string;
diff: FilePatch[];
repo_name: string;
@@ -231,7 +229,7 @@ export type AutofixCodebaseChange = {
repo_id?: number; // The repo_id is only here for temporary backwards compatibility for LA customers, and we should remove it soon. Use repo_external_id instead.
};
-export interface AutofixChangesStep extends BaseStep {
+interface AutofixChangesStep extends BaseStep {
changes: AutofixCodebaseChange[];
type: AutofixStepType.CHANGES;
termination_reason?: string;
@@ -246,7 +244,7 @@ type AutofixRelevantCodeFileWithUrl = AutofixRelevantCodeFile & {
url?: string;
};
-export type AutofixTimelineEvent = {
+type AutofixTimelineEvent = {
code_snippet_and_analysis: string;
relevant_code_file: AutofixRelevantCodeFile;
timeline_item_type: 'internal_code' | 'external_system' | 'human_action';
@@ -270,14 +268,6 @@ export type AutofixRootCauseData = {
root_cause_reproduction?: AutofixTimelineEvent[];
};
-type EventMetadataWithAutofix = EventMetadata & {
- autofix?: AutofixData;
-};
-
-export type GroupWithAutofix = Group & {
- metadata?: EventMetadataWithAutofix;
-};
-
export type FilePatch = {
added: number;
hunks: Hunk[];
diff --git a/static/app/components/events/autofix/useAutofix.tsx b/static/app/components/events/autofix/useAutofix.tsx
index 035f091dc8805f..023e2553b82d4b 100644
--- a/static/app/components/events/autofix/useAutofix.tsx
+++ b/static/app/components/events/autofix/useAutofix.tsx
@@ -1,40 +1,16 @@
-import {useCallback, useMemo, useState} from 'react';
-import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
+import {useQuery} from '@tanstack/react-query';
-import {useModal} from '@sentry/scraps/modal';
-
-import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
-import {AutofixCursorGithubAccessModal} from 'sentry/components/events/autofix/autofixCursorGithubAccessModal';
-import {AutofixGithubAppPermissionsModal} from 'sentry/components/events/autofix/autofixGithubAppPermissionsModal';
-import {AutofixGithubCopilotPurchaseModal} from 'sentry/components/events/autofix/autofixGithubCopilotPurchaseModal';
-import {
- AutofixStatus,
- AutofixStepType,
- AutofixStoppingPoint,
- CodingAgentStatus,
- type AutofixData,
- type GroupWithAutofix,
-} from 'sentry/components/events/autofix/types';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
+import {type AutofixData} from 'sentry/components/events/autofix/types';
import type {Organization} from 'sentry/types/organization';
import {apiOptions} from 'sentry/utils/api/apiOptions';
-import {fetchMutation} from 'sentry/utils/queryClient';
import type {RequestError} from 'sentry/utils/requestError/requestError';
-import {useApi} from 'sentry/utils/useApi';
import {useOrganization} from 'sentry/utils/useOrganization';
type AutofixResponse = {
autofix: AutofixData | null;
};
-const POLL_INTERVAL = 500;
-
-export function autofixApiOptions(
- orgSlug: string,
- groupId: string,
- isUserWatching = false
-) {
+function autofixApiOptions(orgSlug: string, groupId: string, isUserWatching = false) {
return apiOptions.as()(
'/organizations/$organizationIdOrSlug/issues/$issueId/autofix/',
{
@@ -45,146 +21,6 @@ export function autofixApiOptions(
);
}
-const makeInitialAutofixData = (): AutofixResponse => ({
- autofix: {
- status: AutofixStatus.PROCESSING,
- run_id: '',
- steps: [
- {
- type: AutofixStepType.DEFAULT,
- id: '1',
- index: 0,
- status: AutofixStatus.PROCESSING,
- title: 'Starting Autofix...',
- insights: [],
- progress: [
- {
- message: 'Ingesting Sentry data...',
- timestamp: new Date().toISOString(),
- type: 'INFO',
- },
- ],
- },
- ],
- last_triggered_at: new Date().toISOString(),
- request: {
- repos: [],
- },
- codebases: {},
- },
-});
-
-const makeErrorAutofixData = (errorMessage: string): AutofixResponse => {
- const data = makeInitialAutofixData();
-
- if (data.autofix) {
- data.autofix.status = AutofixStatus.ERROR;
- data.autofix.steps = [
- {
- type: AutofixStepType.DEFAULT,
- id: '1',
- index: 0,
- status: AutofixStatus.ERROR,
- title: 'Something went wrong',
- completedMessage: errorMessage,
- insights: [],
- progress: [],
- },
- ];
- }
-
- return data;
-};
-
-/** Will not poll when the autofix is in an error state or has completed */
-const isPolling = (
- autofixData: AutofixData | null,
- runStarted: boolean,
- isSidebar?: boolean
-) => {
- if (!autofixData && !runStarted) {
- return false;
- }
-
- if (!autofixData?.steps) {
- return true;
- }
-
- if (
- autofixData.status === AutofixStatus.PROCESSING ||
- autofixData?.steps.some(step => step.status === AutofixStatus.PROCESSING)
- ) {
- return true;
- }
-
- // Check if there's any active comment thread that hasn't been completed
- const hasActiveCommentThread = autofixData.steps.some(
- step =>
- (step.active_comment_thread && !step.active_comment_thread.is_completed) ||
- (step.agent_comment_thread && !step.agent_comment_thread.is_completed)
- );
-
- const hasSolutionStep = autofixData.steps.some(
- step => step.type === AutofixStepType.SOLUTION
- );
-
- if (
- !hasSolutionStep &&
- ![AutofixStatus.ERROR, AutofixStatus.CANCELLED, AutofixStatus.COMPLETED].includes(
- autofixData.status
- )
- ) {
- // we want to keep polling until we have a solution step because that's a stopping point
- // we need this explicit check in case we get a state for a fraction of a second where the root cause is complete and there is no step after it started
- return true;
- }
-
- // Continue polling if there's an active comment thread, even if the run is completed
- if (!isSidebar && hasActiveCommentThread) {
- return true;
- }
-
- // Poll while coding agent state is pending or running
- if (
- autofixData.coding_agents &&
- Object.values(autofixData.coding_agents).some(
- agent =>
- agent.status === CodingAgentStatus.PENDING ||
- agent.status === CodingAgentStatus.RUNNING
- )
- ) {
- return true;
- }
-
- return (
- !autofixData ||
- ![
- AutofixStatus.ERROR,
- AutofixStatus.COMPLETED,
- AutofixStatus.CANCELLED,
- AutofixStatus.NEED_MORE_INFORMATION,
- ].includes(autofixData.status)
- );
-};
-
-export const useAutofixRepos = (groupId: string) => {
- const {data} = useAutofixData({groupId, isUserWatching: true});
-
- return useMemo(() => {
- const repos = data?.request?.repos ?? [];
- const codebases = data?.codebases ?? {};
-
- return {
- repos: repos.map(repo => ({
- ...repo,
- is_readable: codebases[repo.external_id]?.is_readable,
- is_writeable: codebases[repo.external_id]?.is_writeable,
- })),
- codebases,
- };
- }, [data]);
-};
-
export const useAutofixData = ({
groupId,
isUserWatching = false,
@@ -202,116 +38,6 @@ export const useAutofixData = ({
return {data: data?.autofix ?? null, isPending};
};
-export const useAiAutofix = (
- group: GroupWithAutofix,
- event: Event,
- options: {
- isSidebar?: boolean;
- pollInterval?: number;
- } = {}
-) => {
- const api = useApi();
- const queryClient = useQueryClient();
- const orgSlug = useOrganization().slug;
- const isUserWatching = !options.isSidebar;
-
- const [isReset, setIsReset] = useState(false);
- const [currentRunId, setCurrentRunId] = useState(null);
- const [waitingForNextRun, setWaitingForNextRun] = useState(false);
-
- const {data: apiData, isPending} = useQuery({
- ...autofixApiOptions(orgSlug, group.id, isUserWatching),
- staleTime: 0,
- retry: false,
- refetchInterval: query => {
- if (
- isPolling(
- query.state.data?.json?.autofix || null,
- !!currentRunId || waitingForNextRun,
- options.isSidebar
- )
- ) {
- return options.pollInterval ?? POLL_INTERVAL;
- }
- return false;
- },
- refetchOnWindowFocus: 'always',
- });
-
- const triggerAutofix = useCallback(
- async (instruction: string, stoppingPoint?: AutofixStoppingPoint) => {
- setIsReset(false);
- setCurrentRunId(null);
- setWaitingForNextRun(true);
- queryClient.setQueryData(
- autofixApiOptions(orgSlug, group.id, isUserWatching).queryKey,
- prev => ({headers: prev?.headers ?? {}, json: makeInitialAutofixData()})
- );
-
- try {
- const response = await api.requestPromise(
- `/organizations/${orgSlug}/issues/${group.id}/autofix/`,
- {
- method: 'POST',
- query: {mode: 'legacy'},
- data: {
- event_id: event.id,
- instruction,
- referrer: 'api.web',
- ...(stoppingPoint && {stopping_point: stoppingPoint}),
- },
- }
- );
- setCurrentRunId(response.run_id ?? null);
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(orgSlug, group.id, isUserWatching).queryKey,
- });
- } catch (e: any) {
- setWaitingForNextRun(false);
- queryClient.setQueryData(
- autofixApiOptions(orgSlug, group.id, isUserWatching).queryKey,
- prev => ({
- headers: prev?.headers ?? {},
- json: makeErrorAutofixData(e?.responseJSON?.detail ?? 'An error occurred'),
- })
- );
- }
- },
- [queryClient, group.id, api, event.id, orgSlug, isUserWatching]
- );
-
- const reset = useCallback(() => {
- setIsReset(true);
- setCurrentRunId(null);
- setWaitingForNextRun(true);
- }, []);
-
- let autofixData = apiData?.autofix ?? null;
- if (waitingForNextRun) {
- autofixData = makeInitialAutofixData().autofix;
- }
- if (isReset) {
- autofixData = null;
- }
-
- if (
- apiData?.autofix?.steps?.length &&
- apiData?.autofix?.steps[0]?.progress.length &&
- waitingForNextRun &&
- apiData?.autofix?.run_id === currentRunId
- ) {
- setWaitingForNextRun(false);
- }
-
- return {
- autofixData,
- isPolling: isPolling(autofixData, !!currentRunId || waitingForNextRun),
- isPending,
- triggerAutofix,
- reset,
- };
-};
-
export type CodingAgentIntegration = {
id: string | null;
name: string;
@@ -329,36 +55,6 @@ export function organizationIntegrationsCodingAgents(organization: Organization)
});
}
-interface LaunchCodingAgentParams {
- agentName: string;
- integrationId: string | null;
- provider: string;
- instruction?: string;
- triggerSource?: 'root_cause' | 'solution';
-}
-
-interface LaunchCodingAgentResponse {
- failed_count: number;
- launched_count: number;
- success: boolean;
- failures?: Array<{
- error_message: string;
- repo_name: string;
- failure_type?: string;
- github_installation_id?: string;
- }>;
-}
-
-function getErrorMessage(error: RequestError, agentName: string): string {
- const detail = error.responseJSON?.detail;
-
- if (detail && typeof detail === 'string') {
- return detail;
- }
-
- return t('Failed to launch %s', agentName);
-}
-
export function needsGitHubAuth(error: RequestError): boolean {
const detail = error.responseJSON?.detail;
return (
@@ -367,107 +63,3 @@ export function needsGitHubAuth(error: RequestError): boolean {
detail.toLowerCase().includes('authorization')
);
}
-
-export function useLaunchCodingAgent(groupId: string, runId: string) {
- const {openModal} = useModal();
-
- const organization = useOrganization();
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (params: LaunchCodingAgentParams) => {
- const data: Record = {
- run_id: parseInt(runId, 10),
- trigger_source: params.triggerSource,
- instruction: params.instruction,
- };
-
- if (params.integrationId === null) {
- data.provider = params.provider;
- } else {
- data.integration_id = parseInt(params.integrationId, 10);
- }
-
- return fetchMutation({
- url: `/organizations/${organization.slug}/integrations/coding-agents/`,
- method: 'POST',
- data,
- });
- },
- onSuccess: (data, params) => {
- if (data.failures && data.failures.length > 0) {
- const permissionFailures = data.failures.filter(
- f => f.failure_type === 'github_app_permissions'
- );
- const copilotLicenseFailures = data.failures.filter(
- f => f.failure_type === 'github_copilot_not_licensed'
- );
- const cursorGithubAccessFailures = data.failures.filter(
- f => f.failure_type === 'cursor_github_access'
- );
- const otherFailures = data.failures.filter(
- f =>
- f.failure_type !== 'github_app_permissions' &&
- f.failure_type !== 'github_copilot_not_licensed' &&
- f.failure_type !== 'cursor_github_access'
- );
-
- if (permissionFailures.length > 0) {
- const installationId = permissionFailures[0]?.github_installation_id;
- const installationUrl = installationId
- ? `https://github.com/settings/installations/${installationId}`
- : undefined;
- openModal(deps => (
-
- ));
- }
-
- if (copilotLicenseFailures.length > 0) {
- openModal(deps => );
- }
-
- if (cursorGithubAccessFailures.length > 0) {
- openModal(deps => );
- }
-
- otherFailures.forEach(failure => {
- addErrorMessage(t('%s: %s', failure.repo_name, failure.error_message));
- });
-
- if (data.launched_count > 0) {
- const successRepoText =
- data.launched_count === 1
- ? t('%s launched for 1 repository', params.agentName)
- : t(
- '%s launched for %s repositories',
- params.agentName,
- data.launched_count
- );
- addSuccessMessage(successRepoText);
- }
- } else {
- addSuccessMessage(t('%s launched successfully', params.agentName));
- }
-
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(organization.slug, groupId, false).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(organization.slug, groupId, true).queryKey,
- });
- },
- onError: (error, params) => {
- if (needsGitHubAuth(error)) {
- const currentUrl = window.location.href;
- const oauthUrl = `/remote/github-copilot/oauth/?next=${encodeURIComponent(currentUrl)}`;
- window.location.href = oauthUrl;
- return;
- }
- const message = getErrorMessage(error, params.agentName);
- addErrorMessage(message);
- },
- });
-}
diff --git a/static/app/components/events/autofix/useTextSelection.tsx b/static/app/components/events/autofix/useTextSelection.tsx
deleted file mode 100644
index b30c52bbdcf9d5..00000000000000
--- a/static/app/components/events/autofix/useTextSelection.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import {useCallback, useEffect, useState} from 'react';
-
-interface TextSelection {
- isRangeSelection: boolean;
- referenceElement: HTMLElement | null;
- selectedText: string;
-}
-
-export function useTextSelection(containerRef: React.RefObject) {
- const [selection, setSelection] = useState(null);
-
- const isClickInPopup = (target: HTMLElement) =>
- target.closest('[data-popup="autofix-highlight"]');
-
- const shouldIgnoreElement = (target: HTMLElement) =>
- target.closest('[data-ignore-autofix-highlight="true"]');
-
- const getSelectedTextWithinContainer = useCallback(() => {
- const container = containerRef.current;
- if (!container) {
- return '';
- }
- const sel = window.getSelection();
- if (!sel || sel.isCollapsed) {
- return '';
- }
- const anchorNode = sel.anchorNode;
- const focusNode = sel.focusNode;
- if (!anchorNode || !focusNode) {
- return '';
- }
-
- const isNodeInside = (node: Node) => {
- const element =
- node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
- return !!element && container.contains(element);
- };
-
- // Only treat as a valid selection if both endpoints are within our container
- if (!isNodeInside(anchorNode) || !isNodeInside(focusNode)) {
- return '';
- }
-
- return sel.toString().trim();
- }, [containerRef]);
-
- const handleMouseUp = useCallback(
- (event: MouseEvent) => {
- const target = event.target as HTMLElement;
-
- // Ignore interactions with the popup or explicitly ignored elements
- if (isClickInPopup(target) || shouldIgnoreElement(target)) {
- return;
- }
-
- // Only react to mouseup inside the container
- if (!containerRef.current?.contains(target)) {
- return;
- }
-
- const selected = getSelectedTextWithinContainer();
- if (selected) {
- setSelection({
- selectedText: selected,
- referenceElement: containerRef.current,
- isRangeSelection: true,
- });
- }
- },
- [containerRef, getSelectedTextWithinContainer]
- );
-
- const handleClick = useCallback(
- (event: MouseEvent) => {
- const target = event.target as HTMLElement;
-
- // If clicking in popup, do nothing
- if (isClickInPopup(target)) {
- return;
- }
-
- // If clicking in an ignored element, do nothing
- if (shouldIgnoreElement(target)) {
- return;
- }
-
- // Check if the click is within our container
- const isContainedWithin = containerRef.current?.contains(target);
- if (!isContainedWithin) {
- setSelection(null);
- return;
- }
-
- // If there is an actual user text selection inside the container, prefer that
- const currentUserSelection = getSelectedTextWithinContainer();
- if (currentUserSelection) {
- setSelection({
- selectedText: currentUserSelection,
- referenceElement: containerRef.current!,
- isRangeSelection: true,
- });
- return;
- }
-
- // If clicking within the same container while already selected (and no range selection), toggle off
- if (selection?.referenceElement === containerRef.current) {
- setSelection(null);
- return;
- }
-
- // Otherwise treat it as a simple click-to-open
- const clickedText = containerRef.current?.textContent?.trim() || '';
- if (!clickedText) {
- setSelection(null);
- return;
- }
-
- setSelection({
- selectedText: clickedText,
- referenceElement: containerRef.current!,
- isRangeSelection: false,
- });
- },
- [containerRef, selection, getSelectedTextWithinContainer]
- );
-
- const clearSelection = useCallback(
- (event: MouseEvent) => {
- const target = event.target as HTMLElement;
-
- // Don't clear if clicking within the popup
- if (isClickInPopup(target)) {
- return;
- }
-
- // Don't clear if clicking the original container that triggered the popup
- if (containerRef.current?.contains(target)) {
- return;
- }
-
- setSelection(null);
- },
- [containerRef]
- );
-
- useEffect(() => {
- document.addEventListener('click', handleClick, true);
- document.addEventListener('mouseup', handleMouseUp, true);
- document.addEventListener('mousedown', clearSelection);
-
- return () => {
- document.removeEventListener('click', handleClick, true);
- document.removeEventListener('mouseup', handleMouseUp, true);
- document.removeEventListener('mousedown', clearSelection);
- };
- }, [handleClick, handleMouseUp, clearSelection]);
-
- return selection;
-}
diff --git a/static/app/components/events/autofix/useTypingAnimation.ts b/static/app/components/events/autofix/useTypingAnimation.ts
deleted file mode 100644
index 312a6228569a23..00000000000000
--- a/static/app/components/events/autofix/useTypingAnimation.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import {useEffect, useRef, useState} from 'react';
-
-interface UseTypingAnimationProps {
- /**
- * The full text to animate.
- */
- text: string;
- /**
- * Whether the animation should run. If false, displays the full text immediately. Defaults to true.
- */
- enabled?: boolean;
- /**
- * Callback fired when the animation completes.
- */
- onComplete?: () => void;
- /**
- * Animation speed in characters per second. Defaults to 50.
- */
- speed?: number;
-}
-
-/**
- * Animates the display of text as if it were being typed.
- */
-export function useTypingAnimation({
- text,
- speed = 50,
- enabled = true,
- onComplete,
-}: UseTypingAnimationProps): string {
- const [displayedText, setDisplayedText] = useState(enabled ? '' : text);
- const currentIndexRef = useRef(enabled ? 0 : text.length);
- const animationFrameRef = useRef(null);
- const lastUpdateTimeRef = useRef(0);
- const onCompleteRef = useRef(onComplete);
-
- // Keep the onComplete callback reference up-to-date
- useEffect(() => {
- onCompleteRef.current = onComplete;
- }, [onComplete]);
-
- useEffect(() => {
- // If disabled, show full text immediately
- if (!enabled) {
- // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state
- setDisplayedText(text);
- currentIndexRef.current = text.length;
- if (animationFrameRef.current) {
- window.cancelAnimationFrame(animationFrameRef.current);
- animationFrameRef.current = null;
- }
- return () => {};
- }
-
- // Reset state for new animation
- setDisplayedText('');
- currentIndexRef.current = 0;
- lastUpdateTimeRef.current = performance.now();
-
- const interval = 1000 / speed; // ms per character
-
- const animate = (timestamp: number) => {
- if (!enabled) return; // Check enabled status
-
- const elapsed = timestamp - lastUpdateTimeRef.current;
- const charsToAdd = Math.floor(elapsed / interval);
-
- if (charsToAdd > 0) {
- const nextIndex = Math.min(text.length, currentIndexRef.current + charsToAdd);
- if (nextIndex > currentIndexRef.current) {
- setDisplayedText(_prev => text.slice(0, nextIndex)); // Use functional update, ignore prev
- currentIndexRef.current = nextIndex;
- lastUpdateTimeRef.current = timestamp;
- }
- }
-
- if (currentIndexRef.current < text.length) {
- animationFrameRef.current = window.requestAnimationFrame(animate);
- } else {
- // Final check to ensure full text is displayed
- setDisplayedText(currentDisplayedText => {
- if (currentDisplayedText !== text) {
- return text;
- }
- return currentDisplayedText;
- });
- animationFrameRef.current = null;
- if (onCompleteRef.current) {
- onCompleteRef.current();
- }
- }
- };
-
- // Clear previous frame before starting
- if (animationFrameRef.current) {
- window.cancelAnimationFrame(animationFrameRef.current);
- }
- animationFrameRef.current = window.requestAnimationFrame(animate);
-
- // Cleanup
- return () => {
- if (animationFrameRef.current) {
- window.cancelAnimationFrame(animationFrameRef.current);
- animationFrameRef.current = null;
- }
- };
- }, [text, speed, enabled]); // Dependencies are correct now
-
- // Effect to immediately set full text if enabled becomes false
- useEffect(() => {
- if (!enabled) {
- // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state
- setDisplayedText(text);
- currentIndexRef.current = text.length;
- }
- }, [enabled, text]);
-
- return displayedText;
-}
diff --git a/static/app/components/events/autofix/utils.tsx b/static/app/components/events/autofix/utils.tsx
index 919f0fdc78a033..9ab6b3654d9217 100644
--- a/static/app/components/events/autofix/utils.tsx
+++ b/static/app/components/events/autofix/utils.tsx
@@ -4,28 +4,11 @@ import {formatRootCauseText} from 'sentry/components/events/autofix/autofixRootC
import {formatSolutionText} from 'sentry/components/events/autofix/autofixSolution';
import {
AUTOFIX_TTL_IN_DAYS,
- AutofixStatus,
AutofixStepType,
- type AutofixCodebaseChange,
type AutofixData,
- type AutofixRootCauseData,
- type AutofixSolutionTimelineEvent,
} from 'sentry/components/events/autofix/types';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
import type {Group} from 'sentry/types/group';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {formatEventToMarkdown} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails';
-
-export function getRootCauseDescription(autofixData: AutofixData) {
- const rootCause = autofixData.steps?.find(
- step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS
- );
- if (!rootCause) {
- return null;
- }
- return rootCause.causes.at(0)?.description ?? null;
-}
export function getRootCauseCopyText(autofixData: AutofixData) {
const rootCause = autofixData.steps?.find(
@@ -44,17 +27,6 @@ export function getRootCauseCopyText(autofixData: AutofixData) {
return formatRootCauseText(cause);
}
-export function getSolutionDescription(autofixData: AutofixData) {
- const solution = autofixData.steps?.find(
- step => step.type === AutofixStepType.SOLUTION
- );
- if (!solution) {
- return null;
- }
-
- return solution.description ?? null;
-}
-
export function getSolutionCopyText(autofixData: AutofixData) {
const solution = autofixData.steps?.find(
step => step.type === AutofixStepType.SOLUTION
@@ -66,127 +38,6 @@ export function getSolutionCopyText(autofixData: AutofixData) {
return formatSolutionText(solution.solution, solution.custom_solution);
}
-export function formatRootCauseWithEvent(
- cause: AutofixRootCauseData | undefined,
- customRootCause: string | undefined,
- event: Event | undefined
-): string {
- const rootCauseText = formatRootCauseText(cause, customRootCause);
-
- if (!event) {
- return rootCauseText;
- }
-
- const eventText = '\n# Raw Event Data\n' + formatEventToMarkdown(event, undefined);
- return rootCauseText + eventText;
-}
-
-export function formatSolutionWithEvent(
- solution: AutofixSolutionTimelineEvent[] | undefined,
- customSolution: string | undefined,
- event: Event | undefined,
- rootCause?: AutofixRootCauseData
-): string {
- let combinedText = '';
-
- if (rootCause) {
- const rootCauseText = formatRootCauseText(rootCause);
- combinedText += rootCauseText + '\n\n';
- }
-
- const solutionText = formatSolutionText(solution || [], customSolution);
- combinedText += solutionText;
-
- if (event) {
- const eventText = '\n# Raw Event Data\n' + formatEventToMarkdown(event, undefined);
- combinedText += eventText;
- }
-
- return combinedText;
-}
-
-export function getSolutionIsLoading(autofixData: AutofixData) {
- const solutionProgressStep = autofixData.steps?.find(
- step => step.key === 'solution_processing'
- );
- return solutionProgressStep?.status === AutofixStatus.PROCESSING;
-}
-
-export function getCodeChangesDescription(autofixData: AutofixData) {
- if (!autofixData) {
- return null;
- }
-
- const changesStep = autofixData.steps?.find(
- step => step.type === AutofixStepType.CHANGES
- );
-
- if (!changesStep) {
- return null;
- }
-
- // If there are changes with PRs, show links to them
- const changesWithPRs = changesStep.changes?.filter(
- (change: AutofixCodebaseChange) => change.pull_request
- );
- if (changesWithPRs?.length) {
- return changesWithPRs
- .map(
- (change: AutofixCodebaseChange) =>
- `[View PR in ${change.repo_name}](${change.pull_request?.pr_url})`
- )
- .join('\n');
- }
-
- // If there are code changes but no PRs yet, show a summary
- if (changesStep.changes?.length) {
- // Group changes by repo
- const changesByRepo: Record = {};
- changesStep.changes.forEach((change: AutofixCodebaseChange) => {
- changesByRepo[change.repo_name] = (changesByRepo[change.repo_name] || 0) + 1;
- });
-
- const changesSummary = Object.entries(changesByRepo)
- .map(([repo, count]) => `${count} ${count === 1 ? 'change' : 'changes'} in ${repo}`)
- .join(', ');
-
- return `Proposed ${changesSummary}.`;
- }
-
- return null;
-}
-
-export const getCodeChangesIsLoading = (autofixData: AutofixData) => {
- if (!autofixData) {
- return false;
- }
-
- // Check if there's a specific changes processing step, similar to solution_processing
- const changesProgressStep = autofixData.steps?.find(step => step.key === 'plan');
- if (changesProgressStep?.status === AutofixStatus.PROCESSING) {
- return true;
- }
-
- // Also check if the changes step itself is in processing state
- const changesStep = autofixData.steps?.find(
- step => step.type === AutofixStepType.CHANGES
- );
-
- return changesStep?.status === AutofixStatus.PROCESSING;
-};
-
-export function hasPullRequest(autofixData: AutofixData | null | undefined): boolean {
- if (!autofixData) {
- return false;
- }
-
- const changesStep = autofixData.steps?.find(
- step => step.type === AutofixStepType.CHANGES
- );
-
- return Boolean(changesStep?.changes?.some(change => change.pull_request));
-}
-
const BASE_SUPPORTED_PROVIDERS = [
'github',
'integrations:github',
@@ -253,46 +104,6 @@ export function useIsSeerSupportedProvider(): (provider: {
);
}
-interface AutofixProgressDetails {
- overallProgress: number;
-}
-
-export function getAutofixProgressDetails(
- autofixData?: AutofixData
-): AutofixProgressDetails {
- if (!autofixData) {
- return {overallProgress: 0};
- }
-
- const steps = autofixData.steps ?? [];
-
- if (autofixData.status === AutofixStatus.COMPLETED) {
- return {overallProgress: 100};
- }
-
- if (
- autofixData.status === AutofixStatus.ERROR ||
- autofixData.status === AutofixStatus.CANCELLED
- ) {
- return {overallProgress: 0};
- }
-
- const processingSteps = steps.filter(step => step.status === AutofixStatus.PROCESSING);
- const lastProcessingStep = processingSteps[processingSteps.length - 1];
-
- if (!lastProcessingStep) {
- return {overallProgress: 0};
- }
-
- const progressCount = lastProcessingStep.progress?.length || 0;
- // Increment by 8% per progress log, max 97%
- const progress = Math.min(progressCount * 8, 97);
-
- return {
- overallProgress: progress,
- };
-}
-
export function getAutofixRunExists(group: Group) {
const autofixLastRunAsDate = group.seerAutofixLastTriggered
? new Date(group.seerAutofixLastTriggered)
@@ -308,44 +119,3 @@ export function getAutofixRunExists(group: Group) {
export function isIssueQuickFixable(group: Group) {
return group.seerFixabilityScore && group.seerFixabilityScore > 0.7;
}
-
-export function getAutofixRunErrorMessage(autofixData: AutofixData | undefined) {
- if (autofixData?.status !== AutofixStatus.ERROR) {
- return null;
- }
-
- const errorStep = autofixData.steps?.find(step => step.status === AutofixStatus.ERROR);
- const errorMessage = errorStep?.completedMessage || t('Something went wrong.');
-
- let customErrorMessage = '';
- if (
- errorMessage.toLowerCase().includes('overloaded') ||
- errorMessage.toLowerCase().includes('no completion tokens') ||
- errorMessage.toLowerCase().includes('exhausted')
- ) {
- customErrorMessage = t(
- 'The robots are having a moment. Our LLM provider is overloaded - please try again soon.'
- );
- } else if (
- errorMessage.toLowerCase().includes('prompt') ||
- errorMessage.toLowerCase().includes('tokens')
- ) {
- customErrorMessage = t(
- "Seer worked so hard that it couldn't fit all its findings in its own memory. Please try again."
- );
- } else if (errorMessage.toLowerCase().includes('iterations')) {
- customErrorMessage = t(
- 'Seer was taking a ton of iterations, so we pulled the plug out of fear it might go rogue. Please try again.'
- );
- } else if (errorMessage.toLowerCase().includes('timeout')) {
- customErrorMessage = t(
- 'Seer was taking way too long, so we pulled the plug to turn it off and on again. Please try again.'
- );
- } else {
- customErrorMessage = t(
- "Oops, Seer went kaput. We've dispatched Seer to fix Seer. In the meantime, try again?"
- );
- }
-
- return customErrorMessage;
-}
diff --git a/static/app/components/events/autofix/utils/insightUtils.tsx b/static/app/components/events/autofix/utils/insightUtils.tsx
deleted file mode 100644
index ad19ae1b6d28e0..00000000000000
--- a/static/app/components/events/autofix/utils/insightUtils.tsx
+++ /dev/null
@@ -1,314 +0,0 @@
-import type {
- AutofixInsight,
- InsightSources,
-} from 'sentry/components/events/autofix/types';
-
-type ParsedCodeUrl = {
- baseUrl: string;
- endLine: number | null;
- startLine: number | null;
-};
-
-/**
- * Parses a code URL to extract the base URL and line range information.
- * Supports GitHub-style URLs with #L10 or #L10-L20 format.
- */
-function parseCodeUrl(url: string): ParsedCodeUrl {
- try {
- const urlObj = new URL(url);
- const hash = urlObj.hash;
-
- // Remove hash from base URL
- const baseUrl = url.replace(hash || '', '');
-
- if (hash) {
- // Check for GitHub-style line numbers in hash (#L10 or #L10-L20)
- const lineMatch = hash.match(/^#L(\d+)(?:-L(\d+))?$/);
- if (lineMatch) {
- const startLine = parseInt(lineMatch[1]!, 10);
- const endLine = lineMatch[2] ? parseInt(lineMatch[2], 10) : startLine;
- return {baseUrl, endLine, startLine};
- }
- }
-
- // Check query parameters for line numbers (L, line, etc.)
- const searchParams = new URLSearchParams(urlObj.search);
- if (searchParams.has('L') || searchParams.has('line')) {
- const lineParam = searchParams.get('L') || searchParams.get('line');
- if (lineParam !== null) {
- const lineNum = parseInt(lineParam, 10);
- if (!isNaN(lineNum)) {
- return {baseUrl: baseUrl.split('?')[0]!, endLine: lineNum, startLine: lineNum};
- }
- }
- }
-
- return {baseUrl, endLine: null, startLine: null};
- } catch (e) {
- return {baseUrl: url, endLine: null, startLine: null};
- }
-}
-
-/**
- * Checks if range1 contains range2 (i.e., range1 is wider or equal).
- */
-function rangeContains(
- range1: {endLine: number; startLine: number},
- range2: {endLine: number; startLine: number}
-): boolean {
- return range1.startLine <= range2.startLine && range1.endLine >= range2.endLine;
-}
-
-/**
- * Creates a merged URL with the wider line range.
- */
-function createMergedUrl(baseUrl: string, startLine: number, endLine: number): string {
- if (startLine === endLine) {
- return `${baseUrl}#L${startLine}`;
- }
- return `${baseUrl}#L${startLine}-L${endLine}`;
-}
-
-/**
- * Merges code URLs with overlapping or containing line ranges.
- * Returns a map of original URLs to their merged URLs.
- */
-function mergeCodeUrls(urls: string[]): Map {
- const urlMapping = new Map();
- const parsedUrls = urls.map(url => ({parsed: parseCodeUrl(url), url}));
-
- // Group URLs by base URL
- const baseUrlGroups = new Map>();
- parsedUrls.forEach(({parsed, url}) => {
- if (!baseUrlGroups.has(parsed.baseUrl)) {
- baseUrlGroups.set(parsed.baseUrl, []);
- }
- baseUrlGroups.get(parsed.baseUrl)!.push({parsed, url});
- });
-
- // Process each group to find containing ranges
- baseUrlGroups.forEach(group => {
- // Separate URLs with and without line ranges
- const withRanges = group.filter(
- item => item.parsed.startLine !== null && item.parsed.endLine !== null
- );
- const withoutRanges = group.filter(
- item => item.parsed.startLine === null || item.parsed.endLine === null
- );
-
- // For URLs without ranges, map them to themselves
- withoutRanges.forEach(item => {
- urlMapping.set(item.url, item.url);
- });
-
- if (withRanges.length === 0) return;
-
- // Sort by range size (largest first) to prioritize wider ranges
- withRanges.sort((a, b) => {
- const sizeA = a.parsed.endLine! - a.parsed.startLine!;
- const sizeB = b.parsed.endLine! - b.parsed.startLine!;
- return sizeB - sizeA;
- });
-
- const processed = new Set();
-
- withRanges.forEach(item => {
- if (processed.has(item.url)) return;
-
- const currentRange = {
- startLine: item.parsed.startLine!,
- endLine: item.parsed.endLine!,
- };
-
- // Find all URLs that this range contains
- const contained = withRanges.filter(
- other =>
- !processed.has(other.url) &&
- other.url !== item.url &&
- rangeContains(currentRange, {
- startLine: other.parsed.startLine!,
- endLine: other.parsed.endLine!,
- })
- );
-
- // Map the main URL and all contained URLs to the wider range
- const mergedUrl = createMergedUrl(
- item.parsed.baseUrl,
- currentRange.startLine,
- currentRange.endLine
- );
-
- urlMapping.set(item.url, mergedUrl);
- processed.add(item.url);
-
- contained.forEach(containedItem => {
- urlMapping.set(containedItem.url, mergedUrl);
- processed.add(containedItem.url);
- });
- });
- });
-
- return urlMapping;
-}
-
-/**
- * Deduplicates sources across multiple insights, combining boolean flags
- * and removing duplicate URLs and IDs. Also merges code URLs with overlapping ranges.
- */
-function deduplicateSources(insights: AutofixInsight[]): InsightSources {
- const allSources: InsightSources = {
- breadcrumbs_used: false,
- code_used_urls: [],
- connected_error_ids_used: [],
- diff_urls: [],
- http_request_used: false,
- profile_ids_used: [],
- stacktrace_used: false,
- thoughts: '',
- trace_event_ids_used: [],
- event_trace_id: undefined,
- event_trace_timestamp: undefined,
- };
-
- const seenIds = new Set();
- const allCodeUrls: string[] = [];
- const allDiffUrls: string[] = [];
-
- insights.forEach(insight => {
- if (!insight?.sources) return;
-
- const sources = insight.sources;
-
- // Boolean flags - OR them together
- allSources.breadcrumbs_used = allSources.breadcrumbs_used || sources.breadcrumbs_used;
- allSources.http_request_used =
- allSources.http_request_used || sources.http_request_used;
- allSources.stacktrace_used = allSources.stacktrace_used || sources.stacktrace_used;
-
- // Use the first available event_trace_id
- if (!allSources.event_trace_id && sources.event_trace_id) {
- allSources.event_trace_id = sources.event_trace_id;
- }
-
- // Use the first available event_trace_timestamp
- if (!allSources.event_trace_timestamp && sources.event_trace_timestamp) {
- allSources.event_trace_timestamp = sources.event_trace_timestamp;
- }
-
- // Collect all URLs for merging
- sources.code_used_urls?.forEach(url => {
- allCodeUrls.push(url);
- });
-
- sources.diff_urls?.forEach(url => {
- allDiffUrls.push(url);
- });
-
- // Deduplicate IDs
- sources.trace_event_ids_used?.forEach(id => {
- if (!seenIds.has(id)) {
- seenIds.add(id);
- allSources.trace_event_ids_used.push(id);
- }
- });
-
- sources.profile_ids_used?.forEach(id => {
- if (!seenIds.has(id)) {
- seenIds.add(id);
- allSources.profile_ids_used.push(id);
- }
- });
-
- sources.connected_error_ids_used?.forEach(id => {
- if (!seenIds.has(id)) {
- seenIds.add(id);
- allSources.connected_error_ids_used.push(id);
- }
- });
- });
-
- // Merge code URLs with overlapping ranges and deduplicate
- const codeUrlMapping = mergeCodeUrls(allCodeUrls);
- const mergedCodeUrls = new Set();
- codeUrlMapping.forEach(mergedUrl => {
- mergedCodeUrls.add(mergedUrl);
- });
- allSources.code_used_urls = Array.from(mergedCodeUrls);
-
- // Merge diff URLs with overlapping ranges and deduplicate
- const diffUrlMapping = mergeCodeUrls(allDiffUrls);
- const mergedDiffUrls = new Set();
- diffUrlMapping.forEach(mergedUrl => {
- mergedDiffUrls.add(mergedUrl);
- });
- allSources.diff_urls = Array.from(mergedDiffUrls);
-
- return allSources;
-}
-
-/**
- * Updates insights to use merged URLs from deduplication.
- * Returns a new array of insights with updated sources.
- */
-function updateInsightsWithMergedUrls(insights: AutofixInsight[]): AutofixInsight[] {
- // Collect all URLs first
- const allCodeUrls: string[] = [];
- const allDiffUrls: string[] = [];
-
- insights.forEach(insight => {
- if (!insight?.sources) return;
- const sources = insight.sources;
-
- sources.code_used_urls?.forEach(url => allCodeUrls.push(url));
- sources.diff_urls?.forEach(url => allDiffUrls.push(url));
- });
-
- // Create mappings for merged URLs
- const codeUrlMapping = mergeCodeUrls(allCodeUrls);
- const diffUrlMapping = mergeCodeUrls(allDiffUrls);
-
- // Update each insight with merged URLs
- return insights.map(insight => {
- if (!insight?.sources) return insight;
-
- const updatedSources: InsightSources = {
- ...insight.sources,
- code_used_urls:
- insight.sources.code_used_urls?.map(url => codeUrlMapping.get(url) || url) || [],
- diff_urls:
- insight.sources.diff_urls?.map(url => diffUrlMapping.get(url) || url) || [],
- };
-
- return {
- ...insight,
- sources: updatedSources,
- };
- });
-}
-
-/**
- * Deduplicates sources and updates insights with merged URLs.
- * Returns both the deduplicated sources and updated insights.
- */
-export function deduplicateSourcesAndUpdateInsights(insights: AutofixInsight[]): {
- deduplicatedSources: InsightSources;
- updatedInsights: AutofixInsight[];
-} {
- const updatedInsights = updateInsightsWithMergedUrls(insights);
- const deduplicatedSources = deduplicateSources(updatedInsights);
-
- return {deduplicatedSources, updatedInsights};
-}
-
-/**
- * Gets the sources for the currently expanded insight card.
- */
-export function getExpandedInsightSources(
- insights: AutofixInsight[],
- expandedCardIndex: number | null
-): InsightSources | undefined {
- if (expandedCardIndex === null || expandedCardIndex >= insights.length) {
- return undefined;
- }
- return insights[expandedCardIndex]?.sources;
-}
diff --git a/static/app/components/events/autofix/v1/body.tsx b/static/app/components/events/autofix/v1/body.tsx
deleted file mode 100644
index d2b6a1e328d69b..00000000000000
--- a/static/app/components/events/autofix/v1/body.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import {useCallback, useEffect, useRef, type ReactNode} from 'react';
-import styled from '@emotion/styled';
-import {mergeRefs} from '@react-aria/utils';
-
-import {DrawerBody} from '@sentry/scraps/drawer';
-
-import {AutofixStepType} from 'sentry/components/events/autofix/types';
-import {type useAiAutofix} from 'sentry/components/events/autofix/useAutofix';
-import {useLocation} from 'sentry/utils/useLocation';
-import {useNavigate} from 'sentry/utils/useNavigate';
-
-interface SeerDrawerBody {
- aiAutofix: ReturnType;
- children: ReactNode;
-}
-
-export function SeerDrawerBody({children, aiAutofix}: SeerDrawerBody) {
- const {scrollContainerRef: scrollToSectionRef} = useScrollToSection({aiAutofix});
-
- const {handleScroll, scrollContainerRef: autoScrollRef} = useAutoScroll({aiAutofix});
-
- const scrollContainerRef = mergeRefs(scrollToSectionRef, autoScrollRef);
-
- return (
-
- {children}
-
- );
-}
-
-function useScrollToSection({aiAutofix}: {aiAutofix: ReturnType}) {
- const location = useLocation();
- const navigate = useNavigate();
-
- const scrollContainerRef = useRef(null);
-
- const autofixDataRef = useRef(aiAutofix.autofixData);
- autofixDataRef.current = aiAutofix.autofixData;
-
- const scrollToSection = useCallback(
- (sectionType: string | null) => {
- if (!scrollContainerRef.current || !autofixDataRef.current) {
- return;
- }
-
- const step = autofixDataRef.current.steps?.find(s => {
- if (sectionType === 'root_cause')
- return s.type === AutofixStepType.ROOT_CAUSE_ANALYSIS;
- if (sectionType === 'solution') return s.type === AutofixStepType.SOLUTION;
- if (sectionType === 'code_changes') return s.type === AutofixStepType.CHANGES;
- return false;
- });
-
- let element = null;
-
- if (step) {
- const elementId = `autofix-step-${step.id}`;
- element = document.getElementById(elementId);
- }
-
- if (element) {
- element.scrollIntoView({behavior: 'smooth'});
- } else {
- // No matching step found, scroll to bottom
- scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
- }
-
- // Clear the scrollTo parameter from the URL after scrolling
- setTimeout(() => {
- navigate(
- {
- pathname: location.pathname,
- query: {
- ...location.query,
- scrollTo: undefined,
- },
- },
- {replace: true}
- );
- }, 200);
- },
- [location, navigate]
- );
-
- useEffect(() => {
- const scrollTo = location.query.scrollTo as string | undefined;
- if (scrollTo) {
- // use a 100ms timeout to allow the page to render first
- const timeoutId = setTimeout(() => {
- scrollToSection(scrollTo);
- }, 100);
- return () => clearTimeout(timeoutId);
- }
- return () => {};
- }, [location.query.scrollTo, scrollToSection]);
-
- return {scrollContainerRef};
-}
-
-function useAutoScroll({aiAutofix}: {aiAutofix: ReturnType}) {
- const scrollContainerRef = useRef(null);
-
- const lastScrollTopRef = useRef(0);
- const shouldAutoScrollRef = useRef(false);
-
- const handleScroll = useCallback(() => {
- const container = scrollContainerRef.current;
- if (!container) {
- return;
- }
-
- // Detect scroll direction
- const scrollingUp = container.scrollTop < lastScrollTopRef.current;
-
- // update the last scroll position, make sure to do so after using the last value
- lastScrollTopRef.current = container.scrollTop;
-
- // Check if we're at the bottom
- const isAtBottom =
- container.scrollHeight - container.scrollTop - container.clientHeight < 1;
-
- // Disable auto-scroll if scrolling up
- if (scrollingUp) {
- shouldAutoScrollRef.current = false;
- }
-
- // Re-enable auto-scroll if we reach the bottom
- if (isAtBottom) {
- shouldAutoScrollRef.current = true;
- }
- }, []);
-
- useEffect(() => {
- // Only auto-scroll if user hasn't manually scrolled
- if (shouldAutoScrollRef.current && scrollContainerRef.current) {
- scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
- }
- }, [aiAutofix.autofixData]);
-
- return {
- handleScroll,
- scrollContainerRef,
- };
-}
-
-const StyledDrawerBody = styled(DrawerBody)`
- overflow-y: scroll;
-`;
diff --git a/static/app/components/events/autofix/v1/content.tsx b/static/app/components/events/autofix/v1/content.tsx
deleted file mode 100644
index f1d0dba9872383..00000000000000
--- a/static/app/components/events/autofix/v1/content.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import {Fragment} from 'react';
-
-import {Container, Flex} from '@sentry/scraps/layout';
-
-import {AutofixStartBox} from 'sentry/components/events/autofix/autofixStartBox';
-import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps';
-import type {useAiAutofix} from 'sentry/components/events/autofix/useAutofix';
-import {GroupSummary} from 'sentry/components/group/groupSummary';
-import {Placeholder} from 'sentry/components/placeholder';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
-import type {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig';
-import {SeerNotices} from 'sentry/views/issueDetails/streamline/sidebar/seerNotices';
-
-interface SeerDrawerContentProps {
- aiAutofix: ReturnType;
- aiConfig: ReturnType;
- event: Event;
- group: Group;
- project: Project;
-}
-
-export function SeerDrawerContent({
- aiAutofix,
- aiConfig,
- event,
- group,
- project,
-}: SeerDrawerContentProps) {
- useRouteAnalyticsParams({autofix_status: aiAutofix.autofixData?.status ?? 'none'});
-
- return (
-
-
- {aiConfig.hasSummary && (
-
-
-
- )}
- {aiConfig.hasAutofix && (
-
- {aiAutofix.isPending ? (
-
-
-
-
- ) : aiAutofix.autofixData ? (
-
- ) : (
-
- )}
-
- )}
-
- );
-}
diff --git a/static/app/components/events/autofix/v1/drawer.tsx b/static/app/components/events/autofix/v1/drawer.tsx
deleted file mode 100644
index 66311a56b6aaa4..00000000000000
--- a/static/app/components/events/autofix/v1/drawer.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import {useMemo} from 'react';
-import {useQuery} from '@tanstack/react-query';
-
-import {Flex} from '@sentry/scraps/layout';
-
-import {SeerDrawerHeader} from 'sentry/components/events/autofix/drawer/drawerHeader';
-import {SeerDrawerNavigator} from 'sentry/components/events/autofix/drawer/drawerNavigator';
-import {SeerWelcomeScreen} from 'sentry/components/events/autofix/drawer/welcomeScreen';
-import {useAiAutofix} from 'sentry/components/events/autofix/useAutofix';
-import {SeerDrawerBody} from 'sentry/components/events/autofix/v1/body';
-import {SeerDrawerContent} from 'sentry/components/events/autofix/v1/content';
-import {AiSetupConfiguration} from 'sentry/components/events/autofix/v2/autofixConfigureSeer';
-import {Placeholder} from 'sentry/components/placeholder';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {getSeerOnboardingCheckQueryOptions} from 'sentry/utils/getSeerOnboardingCheckQueryOptions';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig';
-
-interface SeerDrawerProps {
- event: Event;
- group: Group;
- project: Project;
-}
-
-export function SeerDrawer({event, group, project}: SeerDrawerProps) {
- const aiConfig = useAiConfig(group, project);
- const aiAutofix = useAiAutofix(group, event);
-
- const handleReset = useHandleReset({aiAutofix, aiConfig});
-
- return (
-
-
-
-
-
-
-
- );
-}
-
-interface InnerSeerDrawerProps extends SeerDrawerProps {
- aiAutofix: ReturnType;
- aiConfig: ReturnType;
-}
-
-function InnerSeerDrawer({
- event,
- group,
- project,
- aiAutofix,
- aiConfig,
-}: InnerSeerDrawerProps) {
- const organization = useOrganization();
- const {isPending, data} = useQuery(getSeerOnboardingCheckQueryOptions({organization}));
-
- const seatBasedSeer = organization.features.includes('seat-based-seer-enabled');
-
- const noAutofixQuota =
- !aiConfig.hasAutofixQuota && organization.features.includes('seer-billing');
-
- if (aiConfig.isAutofixSetupLoading || (seatBasedSeer && isPending)) {
- return (
-
-
-
-
-
- );
- }
-
- if (seatBasedSeer) {
- // No easy way to add a hook for only configuring quotas.
- // So the condition here captures all the possible cases
- // that requires some kind of configuration change.
- //
- // Instead, we bundle all the configuration into 1 hook.
- //
- // If the hook is not defined, we always direct them to
- // the seer configs.
- //
- // If the hook is defined, the hook will render a different
- // component as needed to configure quotas.
- if (
- // needs to configure quota
- noAutofixQuota ||
- // needs to configure repos
- !aiConfig.seerReposLinked ||
- !data?.hasSupportedScmIntegration
- ) {
- return ;
- }
- } else if (
- // Handle welcome/consent screen at the top level
- aiConfig.orgNeedsGenAiAcknowledgement ||
- noAutofixQuota
- ) {
- return ;
- }
-
- return (
-
- );
-}
-
-function useHandleReset({
- aiAutofix,
- aiConfig,
-}: {
- aiAutofix: ReturnType;
- aiConfig: ReturnType;
-}) {
- return useMemo(() => {
- if (!aiAutofix.autofixData) {
- return;
- }
- return () => {
- aiAutofix.reset();
- aiConfig.refetchAutofixSetup?.();
- };
- }, [aiAutofix, aiConfig]);
-}
diff --git a/static/app/components/events/autofix/v2/autofixConfigureSeer.tsx b/static/app/components/events/autofix/v2/autofixConfigureSeer.tsx
deleted file mode 100644
index 2d6fe16241d9f6..00000000000000
--- a/static/app/components/events/autofix/v2/autofixConfigureSeer.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import {Fragment, type CSSProperties} from 'react';
-import styled from '@emotion/styled';
-import {useQuery} from '@tanstack/react-query';
-
-import seerConfigSeerImg from 'sentry-images/spot/seer-config-seer.svg';
-import seerConfigShipImg from 'sentry-images/spot/seer-config-ship.svg';
-
-import {LinkButton} from '@sentry/scraps/button';
-import {Image} from '@sentry/scraps/image';
-import {Flex, Stack} from '@sentry/scraps/layout';
-import {Heading, Text} from '@sentry/scraps/text';
-
-import {useGroupSummary} from 'sentry/components/group/groupSummary';
-import {OverrideOrDefault} from 'sentry/components/overrideOrDefault';
-import {Panel} from 'sentry/components/panels/panel';
-import {Placeholder} from 'sentry/components/placeholder';
-import {IconSeer} from 'sentry/icons/iconSeer';
-import {IconWarning} from 'sentry/icons/iconWarning';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {getSeerOnboardingCheckQueryOptions} from 'sentry/utils/getSeerOnboardingCheckQueryOptions';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-export const AiSetupConfiguration = OverrideOrDefault({
- overrideName: 'component:ai-setup-configuration',
- defaultComponent: ({
- event,
- group,
- project,
- }: {
- event: Event;
- group: Group;
- project: Project;
- }) => ,
-});
-
-interface AutofixConfigureSeerProps {
- event: Event;
- group: Group;
- project: Project;
-}
-
-export function AutofixConfigureSeer({event, group, project}: AutofixConfigureSeerProps) {
- const organization = useOrganization();
- const {data: setupCheck} = useQuery(getSeerOnboardingCheckQueryOptions({organization}));
- const {data, isPending, isError} = useGroupSummary(group, event, project);
-
- const orgNeedsToConfigureSeer =
- // needs to enable autofix
- !setupCheck?.isAutofixEnabled ||
- // catch all, ensure seer is configured
- !setupCheck?.isSeerConfigured;
-
- return (
-
-
-
-
-
-
-
-
- {t('Debug Faster with Seer')}
-
-
- {t(
- 'Seer connects to your repos, scans your issues, highlights quick fixes, and proposes solutions.'
- )}
-
-
- {t(
- 'You can even integrate with your favorite agent to implement changes in code.'
- )}
-
-
-
-
-
-
-
-
-
-
- {t('What happened')}
- {isPending ? (
-
- ) : isError ? (
-
-
- {t('Error loading what happened')}
-
- ) : (
- data?.whatsWrong && (
-
-
-
- )
- )}
-
-
-
-
- {t('Initial Guess')}
- {isPending ? (
-
- ) : isError ? (
-
-
- {t('Error loading initial guess')}
-
- ) : (
- data?.possibleCause && (
-
-
-
- )
- )}
-
-
-
-
- {t('Next Steps')}
-
- {t(
- 'This is the initial analysis, but once Seer is configured you’ll be able to see a detailed breakdown of the issue’s root cause, a multi-step solution, and proposed code changes to fix it.'
- )}
-
-
- {orgNeedsToConfigureSeer ? (
- }
- >
- {t('Set Up Seer')}
-
- ) : (
- }
- >
- {t('Set Up Seer for This Project')}
-
- )}
-
-
-
-
-
- );
-}
-
-export const SeerFeaturesPanel = styled(Panel)<{width: CSSProperties['width']}>`
- width: ${p => p.width};
- min-width: ${p => p.width};
- max-width: ${p => p.width};
- margin-bottom: ${p => p.theme.space['2xl']};
-`;
-
-const SeerPreviewPanel = styled(Panel)<{alignSelf: CSSProperties['alignSelf']}>`
- align-self: ${p => p.alignSelf};
- width: 70%;
- min-width: 70%;
- max-width: 70%;
- margin-bottom: ${p => p.theme.space['2xl']};
-`;
-
-const AngledImageContainer = styled('div')`
- position: absolute;
- right: 0px;
- width: 40%;
- max-width: 400px;
- aspect-ratio: 25/12;
- /*
- * Use the top middle point as the origin to
- * 1. Translate the image so the center aligns with the right edge of the containing panel.
- * 2. Totate 45 deg clockwise so the image is rotated anchored at the tip of the pyramid.
- */
- transform-origin: 50% 0%;
- transform: translateX(50%) rotate(45deg);
-`;
-
-const SeerPreviewText = styled('div')`
- p {
- margin-bottom: 0;
- }
-`;
-
-export const ImageContainer = styled(Flex)<{
- aspectRatio?: CSSProperties['aspectRatio'];
-}>`
- ${p => p.aspectRatio && `aspect-ratio: ${p.aspectRatio}`};
-`;
diff --git a/static/app/components/group/groupSummary.spec.tsx b/static/app/components/group/groupSummary.spec.tsx
deleted file mode 100644
index 9ea0626d318d19..00000000000000
--- a/static/app/components/group/groupSummary.spec.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture';
-import {EventFixture} from 'sentry-fixture/event';
-import {GroupFixture} from 'sentry-fixture/group';
-import {OrganizationFixture} from 'sentry-fixture/organization';
-import {DetailedProjectFixture} from 'sentry-fixture/project';
-
-import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
-
-import {GroupSummary} from 'sentry/components/group/groupSummary';
-
-describe('GroupSummary', () => {
- const mockEvent = EventFixture();
- const mockGroup = GroupFixture();
- const mockProject = DetailedProjectFixture();
- const organization = OrganizationFixture({
- hideAiFeatures: false,
- features: ['gen-ai-features'],
- });
-
- const mockSummaryData = {
- groupId: '1',
- whatsWrong: 'Test whats wrong',
- trace: 'Test trace',
- possibleCause: 'Test possible cause',
- headline: 'Test headline',
- scores: {
- possibleCauseConfidence: 0.9,
- possibleCauseNovelty: 0.8,
- },
- };
-
- const mockSummaryDataWithNullScores = {
- groupId: '1',
- whatsWrong: 'Test whats wrong',
- trace: 'Test trace',
- possibleCause: 'Test possible cause',
- headline: 'Test headline',
- scores: null,
- };
-
- beforeEach(() => {
- MockApiClient.clearMockResponses();
-
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- method: 'GET',
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {
- ok: true,
- repos: [
- {
- ok: true,
- owner: 'owner',
- name: 'hello-world',
- provider: 'integrations:github',
- },
- ],
- },
- }),
- });
- });
-
- it('renders the summary with all sections', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: mockSummaryData,
- });
-
- render( , {
- organization,
- });
-
- await waitFor(() => {
- expect(screen.getByText('What Happened')).toBeInTheDocument();
- });
- expect(await screen.findByText('Test whats wrong')).toBeInTheDocument();
- expect(screen.getByText('In the Trace')).toBeInTheDocument();
- expect(screen.getByText('Test trace')).toBeInTheDocument();
- expect(screen.getByText('Initial Guess')).toBeInTheDocument();
- expect(screen.getByText('Test possible cause')).toBeInTheDocument();
- });
-
- it('renders the summary with all sections when scores are null', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: mockSummaryDataWithNullScores,
- });
-
- render( , {
- organization,
- });
-
- await waitFor(() => {
- expect(screen.getByText('What Happened')).toBeInTheDocument();
- });
- expect(await screen.findByText('Test whats wrong')).toBeInTheDocument();
- expect(screen.getByText('In the Trace')).toBeInTheDocument();
- expect(screen.getByText('Test trace')).toBeInTheDocument();
- expect(screen.getByText('Initial Guess')).toBeInTheDocument();
- expect(screen.getByText('Test possible cause')).toBeInTheDocument();
- });
-
- it('shows loading state', () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: {},
- });
-
- render( , {
- organization,
- });
-
- // Should show loading placeholders. Currently we load the headline, whatsWrong, and possibleCause sections
- expect(screen.getAllByTestId('loading-placeholder')).toHaveLength(3);
- });
-
- it('shows error state', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: {},
- statusCode: 400,
- });
-
- render( , {
- organization,
- });
-
- await waitFor(() => {
- expect(screen.getByText('Error loading summary')).toBeInTheDocument();
- });
- });
-
- it('hides cards with no content', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: {
- ...mockSummaryData,
- trace: null,
- },
- });
-
- render( , {
- organization,
- });
-
- await waitFor(() => {
- expect(screen.getByText('What Happened')).toBeInTheDocument();
- });
- expect(await screen.findByText('Test whats wrong')).toBeInTheDocument();
- expect(screen.queryByText('In the Trace')).not.toBeInTheDocument();
- expect(screen.getByText('Initial Guess')).toBeInTheDocument();
- expect(screen.getByText('Test possible cause')).toBeInTheDocument();
- });
-
- it('renders in preview mode', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: mockSummaryData,
- });
-
- render(
- ,
- {organization}
- );
-
- await waitFor(() => {
- expect(screen.getByText('Initial Guess')).toBeInTheDocument();
- });
- expect(await screen.findByText('Test possible cause')).toBeInTheDocument();
- expect(screen.queryByText('What Happened')).not.toBeInTheDocument();
- expect(screen.queryByText('Test whats wrong')).not.toBeInTheDocument();
- expect(screen.queryByText('In the Trace')).not.toBeInTheDocument();
- expect(screen.queryByText('Test trace')).not.toBeInTheDocument();
- });
-});
diff --git a/static/app/components/group/groupSummary.tsx b/static/app/components/group/groupSummary.tsx
index 4b805ce199fb35..bc56de0c7665d4 100644
--- a/static/app/components/group/groupSummary.tsx
+++ b/static/app/components/group/groupSummary.tsx
@@ -1,34 +1,7 @@
-import {isValidElement, useEffect, useLayoutEffect, useState} from 'react';
-import styled from '@emotion/styled';
-import {useQueryClient} from '@tanstack/react-query';
-import {motion} from 'framer-motion';
-
-import {Button} from '@sentry/scraps/button';
-import {Container, Flex, Stack} from '@sentry/scraps/layout';
-import {Text} from '@sentry/scraps/text';
-
-import {AiPrivacyTooltip} from 'sentry/components/aiPrivacyTooltip';
-import {autofixApiOptions} from 'sentry/components/events/autofix/useAutofix';
-import {Placeholder} from 'sentry/components/placeholder';
-import {
- IconChevron,
- IconDocs,
- IconFatal,
- IconFocus,
- IconRefresh,
- IconSpan,
-} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
-import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
-import {MarkedText} from 'sentry/utils/marked/markedText';
import {useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient';
-import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig';
export interface GroupSummaryData {
groupId: string;
@@ -77,447 +50,3 @@ export function useGroupSummaryData(group: Group) {
return {data, isPending};
}
-
-export function useGroupSummary(
- group: Group,
- event: Event | null | undefined,
- project: Project,
- forceEvent = false
-) {
- const organization = useOrganization();
- const aiConfig = useAiConfig(group, project);
- const enabled = aiConfig.hasSummary;
- const queryClient = useQueryClient();
- const queryKey = makeGroupSummaryQueryKey(
- organization.slug,
- group.id,
- forceEvent ? event?.id : undefined
- );
-
- const {data, isLoading, isFetching, isError, refetch} = useApiQuery(
- queryKey,
- {
- staleTime: Infinity,
- enabled,
- }
- );
-
- const refresh = () => {
- queryClient.invalidateQueries({
- queryKey: makeGroupSummaryQueryKey(organization.slug, group.id),
- exact: false,
- });
- refetch();
- };
-
- return {
- data,
- isPending: aiConfig.isAutofixSetupLoading || isLoading || isFetching,
- isError,
- refresh,
- };
-}
-
-export function GroupSummary({
- group,
- event,
- project,
- preview = false,
- collapsed = false,
-}: {
- event: Event | null | undefined;
- group: Group;
- project: Project;
- collapsed?: boolean;
- preview?: boolean;
-}) {
- const queryClient = useQueryClient();
- const organization = useOrganization();
- const [forceEvent, setForceEvent] = useState(false);
- const aiConfig = useAiConfig(group, project);
- const {data, isPending, isError, refresh} = useGroupSummary(
- group,
- event,
- project,
- forceEvent
- );
-
- useEffect(() => {
- if (forceEvent && !isPending) {
- refresh();
- setForceEvent(false);
- }
- }, [forceEvent, isPending, refresh]);
-
- const hasFixabilityScore =
- data?.scores?.fixabilityScore !== null && data?.scores?.fixabilityScore !== undefined;
-
- useEffect(() => {
- if (hasFixabilityScore && !isPending && aiConfig.hasAutofix) {
- queryClient.invalidateQueries({
- queryKey: autofixApiOptions(organization.slug, group.id).queryKey,
- });
- }
- }, [
- hasFixabilityScore,
- isPending,
- aiConfig.hasAutofix,
- group.id,
- queryClient,
- organization.slug,
- ]);
-
- useRouteAnalyticsParams({
- has_summary: Boolean(data && !isPending && !isError),
- });
-
- if (preview) {
- return ;
- }
-
- return (
-
- );
-}
-
-function GroupSummaryPreview({
- data,
- isPending,
- isError,
-}: {
- data: GroupSummaryData | undefined;
- isError: boolean;
- isPending: boolean;
-}) {
- const insightCards = [
- {
- id: 'possible_cause',
- title: t('Initial Guess'),
- insight: data?.possibleCause,
- icon: ,
- showWhenLoading: true,
- },
- ];
-
- return (
-
- {isError ?
{t('Error loading summary')}
: null}
-
-
- {insightCards.map(card => {
- if ((!isPending && !card.insight) || (isPending && !card.showWhenLoading)) {
- return null;
- }
- return (
-
-
- {card.icon}
-
- {card.title}
-
-
-
-
-
-
- {isPending ? (
-
-
-
- ) : (
-
- {card.insight && (
-
- )}
-
- )}
-
-
- );
- })}
-
-
-
- );
-}
-
-function GroupSummaryCollapsed({
- group,
- project,
- event,
- data,
- isPending,
- setForceEvent,
- isError,
- defaultCollapsed = false,
-}: {
- data: GroupSummaryData | undefined;
- event: Event | null | undefined;
- group: Group;
- isError: boolean;
- isPending: boolean;
- project: Project;
- setForceEvent: (v: boolean) => void;
- defaultCollapsed?: boolean;
-}) {
- const [isExpanded, setIsExpanded] = useState(!defaultCollapsed);
-
- const handleToggle = () => {
- setIsExpanded(!isExpanded);
- };
-
- useLayoutEffect(() => {
- setIsExpanded(!defaultCollapsed);
- }, [defaultCollapsed]);
-
- return (
-
- {isError ?
{t('Error loading summary')}
: null}
- {!isError && (
-
-
-
- {isPending ? (
-
- ) : (
-
- {data?.headline || t('Issue Summary')}
-
- )}
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
- )}
-
- );
-}
-
-function GroupSummaryFull({
- group,
- project,
- data,
- isPending,
- isError,
- setForceEvent,
- preview,
- event,
-}: {
- data: GroupSummaryData | undefined;
- event: Event | null | undefined;
- group: Group;
- isError: boolean;
- isPending: boolean;
- preview: boolean;
- project: Project;
- setForceEvent: (v: boolean) => void;
-}) {
- const config = getConfigForIssueType(group, project);
- const shouldShowResources = config.resources && !preview;
-
- const insightCards = [
- {
- id: 'whats_wrong',
- title: t('What Happened'),
- insight: data?.whatsWrong,
- icon: ,
- showWhenLoading: true,
- },
- {
- id: 'trace',
- title: t('In the Trace'),
- insight: data?.trace,
- icon: ,
- showWhenLoading: false,
- },
- {
- id: 'possible_cause',
- title: t('Initial Guess'),
- insight: data?.possibleCause,
- icon: ,
- showWhenLoading: true,
- },
-
- ...(shouldShowResources
- ? [
- {
- id: 'resources',
- title: t('Resources'),
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- insight: `${isValidElement(config.resources?.description) ? '' : (config.resources?.description ?? '')}\n\n${config.resources?.links?.map(link => `[${link.text}](${link.link})`).join(' • ') ?? ''}`,
- insightElement: isValidElement(config.resources?.description)
- ? config.resources?.description
- : null,
- icon: ,
- showWhenLoading: true,
- },
- ]
- : []),
- ];
-
- return (
-
- {isError ? {t('Error loading summary')}
: null}
-
-
- {insightCards.map(card => {
- if ((!isPending && !card.insight) || (isPending && !card.showWhenLoading)) {
- return null;
- }
- return (
-
-
- {card.icon}
- {card.title}
-
-
-
-
-
- {isPending ? (
-
-
-
- ) : (
-
- {card.insightElement}
- {card.insight && (
-
- )}
-
- )}
-
-
- );
- })}
-
- {data?.eventId && !isPending && event && event.id !== data?.eventId && (
-
- setForceEvent(true)}
- disabled={isPending}
- size="xs"
- icon={ }
- >
- {t('Summarize current event')}
-
-
- )}
-
-
- );
-}
-
-const InsightCard = styled('div')`
- display: flex;
- flex-direction: column;
- border-radius: ${p => p.theme.radius.md};
- width: 100%;
- min-height: 0;
-`;
-
-const CardTitle = styled('div')`
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.md};
- color: ${p => p.theme.tokens.content.secondary};
- padding-bottom: ${p => p.theme.space.xs};
-`;
-
-const CardTitleText = styled('p')`
- margin: 0;
- font-size: ${p => p.theme.font.size.md};
- font-weight: ${p => p.theme.font.weight.sans.medium};
-`;
-
-const CardTitleIcon = styled('div')`
- display: flex;
- align-items: center;
- color: ${p => p.theme.tokens.content.secondary};
-`;
-
-const CardLineDecorationWrapper = styled('div')`
- display: flex;
- width: 14px;
- align-self: stretch;
- justify-content: center;
- flex-shrink: 0;
- padding: 0.275rem 0;
-`;
-
-const CardLineDecoration = styled('div')`
- width: 1px;
- align-self: stretch;
- /* eslint-disable-next-line @sentry/scraps/use-semantic-token */
- background-color: ${p => p.theme.tokens.border.primary};
-`;
-
-const CardContent = styled('div')`
- overflow-wrap: break-word;
- word-break: break-word;
- p {
- margin: 0;
- white-space: pre-wrap;
- }
- code {
- word-break: break-all;
- }
- flex: 1;
-`;
-
-const CollapsedHeader = styled('div')`
- cursor: pointer;
- transition: all 0.2s ease-in-out;
-`;
-
-const ChevronIcon = styled('div')`
- display: flex;
- align-items: center;
- color: ${p => p.theme.tokens.content.secondary};
- transition: transform 0.2s ease-in-out;
- flex-shrink: 0;
-`;
-
-const ExpandableContent = styled(motion.div)`
- overflow: hidden;
-`;
diff --git a/static/app/components/group/groupSummaryWithAutofix.spec.tsx b/static/app/components/group/groupSummaryWithAutofix.spec.tsx
deleted file mode 100644
index 037893865c9966..00000000000000
--- a/static/app/components/group/groupSummaryWithAutofix.spec.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-import {GroupFixture} from 'sentry-fixture/group';
-import {OrganizationFixture} from 'sentry-fixture/organization';
-import {UserFixture} from 'sentry-fixture/user';
-
-import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
-
-import {useAutofixData} from 'sentry/components/events/autofix/useAutofix';
-import {AutofixSummary} from 'sentry/components/group/groupSummaryWithAutofix';
-import {ConfigStore} from 'sentry/stores/configStore';
-import {trackAnalytics} from 'sentry/utils/analytics';
-
-jest.mock('sentry/components/events/autofix/useAutofix');
-jest.mock('sentry/utils/analytics');
-
-describe('AutofixSummary', () => {
- const organization = OrganizationFixture();
- const group = GroupFixture({id: '1', shortId: 'TEST-1'});
- const user = UserFixture();
-
- beforeEach(() => {
- jest.clearAllMocks();
- MockApiClient.clearMockResponses();
- ConfigStore.set('user', user);
-
- jest.mocked(useAutofixData).mockReturnValue({
- data: {
- run_id: 'test-run-id',
- status: 'COMPLETED',
- steps: [],
- },
- isPending: false,
- isError: false,
- error: null,
- } as any);
- });
-
- it('renders feedback buttons for root cause card', () => {
- render(
- ,
- {organization}
- );
-
- expect(screen.getByRole('button', {name: 'This was helpful'})).toBeInTheDocument();
- expect(
- screen.getByRole('button', {name: 'This was not helpful'})
- ).toBeInTheDocument();
- });
-
- it('renders feedback buttons for solution card', () => {
- render(
- ,
- {organization}
- );
-
- const helpfulButtons = screen.getAllByRole('button', {name: 'This was helpful'});
- expect(helpfulButtons).toHaveLength(2); // One for root cause, one for solution
- });
-
- it('tracks analytics event when thumbs up is clicked', async () => {
- render(
- ,
- {organization}
- );
-
- const thumbsUpButton = screen.getByRole('button', {name: 'This was helpful'});
- await userEvent.click(thumbsUpButton);
-
- await waitFor(() => {
- expect(trackAnalytics).toHaveBeenCalledWith(
- 'seer.autofix.feedback_submitted',
- expect.objectContaining({
- step_type: 'root_cause',
- positive: true,
- group_id: '1',
- autofix_run_id: 'test-run-id',
- user_id: user.id,
- organization,
- })
- );
- });
- });
-
- it('tracks analytics event when thumbs down is clicked', async () => {
- render(
- ,
- {organization}
- );
-
- const thumbsDownButton = screen.getByRole('button', {
- name: 'This was not helpful',
- });
- await userEvent.click(thumbsDownButton);
-
- await waitFor(() => {
- expect(trackAnalytics).toHaveBeenCalledWith(
- 'seer.autofix.feedback_submitted',
- expect.objectContaining({
- step_type: 'root_cause',
- positive: false,
- group_id: '1',
- autofix_run_id: 'test-run-id',
- user_id: user.id,
- organization,
- })
- );
- });
- });
-
- it('shows "Thanks!" message after feedback is submitted', async () => {
- render(
- ,
- {organization}
- );
-
- const thumbsUpButton = screen.getByRole('button', {name: 'This was helpful'});
- await userEvent.click(thumbsUpButton);
-
- expect(await screen.findByText('Thanks!')).toBeInTheDocument();
- expect(
- screen.queryByRole('button', {name: 'This was helpful'})
- ).not.toBeInTheDocument();
- });
-
- it('does not render feedback buttons when loading', () => {
- render(
- ,
- {organization}
- );
-
- // Root cause should have feedback buttons
- expect(screen.getByRole('button', {name: 'This was helpful'})).toBeInTheDocument();
-
- // Solution should not have feedback buttons because it's loading
- const helpfulButtons = screen.getAllByRole('button', {name: 'This was helpful'});
- expect(helpfulButtons).toHaveLength(1);
- });
-
- it('tracks different step_type for solution feedback', async () => {
- render(
- ,
- {organization}
- );
-
- const thumbsUpButtons = screen.getAllByRole('button', {name: 'This was helpful'});
- // Click the second thumbs up (solution)
- const secondButton = thumbsUpButtons[1];
- if (!secondButton) {
- throw new Error('Second thumbs up button not found');
- }
- await userEvent.click(secondButton);
-
- await waitFor(() => {
- expect(trackAnalytics).toHaveBeenCalledWith(
- 'seer.autofix.feedback_submitted',
- expect.objectContaining({
- step_type: 'solution',
- positive: true,
- })
- );
- });
- });
-
- it('tracks changes step_type for code changes feedback', async () => {
- render(
- ,
- {organization}
- );
-
- const thumbsUpButtons = screen.getAllByRole('button', {name: 'This was helpful'});
- // Click the third thumbs up (code changes)
- const thirdButton = thumbsUpButtons[2];
- if (!thirdButton) {
- throw new Error('Third thumbs up button not found');
- }
- await userEvent.click(thirdButton);
-
- await waitFor(() => {
- expect(trackAnalytics).toHaveBeenCalledWith(
- 'seer.autofix.feedback_submitted',
- expect.objectContaining({
- step_type: 'changes',
- positive: true,
- })
- );
- });
- });
-
- it('does not render feedback buttons when run_id is missing', () => {
- jest.mocked(useAutofixData).mockReturnValue({
- data: null,
- isPending: false,
- isError: false,
- error: null,
- } as any);
-
- render(
- ,
- {organization}
- );
-
- expect(
- screen.queryByRole('button', {name: 'This was helpful'})
- ).not.toBeInTheDocument();
- });
-});
diff --git a/static/app/components/group/groupSummaryWithAutofix.tsx b/static/app/components/group/groupSummaryWithAutofix.tsx
deleted file mode 100644
index e068e0357ff625..00000000000000
--- a/static/app/components/group/groupSummaryWithAutofix.tsx
+++ /dev/null
@@ -1,424 +0,0 @@
-import {Fragment, useMemo} from 'react';
-import styled from '@emotion/styled';
-import type {Variants} from 'framer-motion';
-import {motion} from 'framer-motion';
-
-import {Flex, Stack} from '@sentry/scraps/layout';
-
-import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
-import {AutofixStepFeedback} from 'sentry/components/events/autofix/autofixStepFeedback';
-import {useAutofixData} from 'sentry/components/events/autofix/useAutofix';
-import {
- getAutofixRunExists,
- getCodeChangesDescription,
- getCodeChangesIsLoading,
- getRootCauseCopyText,
- getRootCauseDescription,
- getSolutionCopyText,
- getSolutionDescription,
- getSolutionIsLoading,
- hasPullRequest,
-} from 'sentry/components/events/autofix/utils';
-import {GroupSummary} from 'sentry/components/group/groupSummary';
-import {Placeholder} from 'sentry/components/placeholder';
-import {IconCode, IconFix, IconFocus} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
-import {useLocation} from 'sentry/utils/useLocation';
-import {useNavigate} from 'sentry/utils/useNavigate';
-import {useOrganization} from 'sentry/utils/useOrganization';
-
-const pulseAnimation: Variants = {
- initial: {opacity: 1},
- animate: {
- opacity: 0.6,
- transition: {
- repeat: Infinity,
- repeatType: 'reverse',
- duration: 1,
- },
- },
-};
-
-interface InsightCardObject {
- id: string;
- insight: string | null | undefined;
- title: string;
- copyAnalyticsEventKey?: string;
- copyAnalyticsEventName?: string;
- copyText?: string | null;
- copyTitle?: string | null;
- feedbackType?: 'root_cause' | 'solution' | 'changes';
- icon?: React.ReactNode;
- insightElement?: React.ReactNode;
- isLoading?: boolean;
- onClick?: () => void;
-}
-
-export function GroupSummaryWithAutofix({
- group,
- event,
- project,
- preview = false,
-}: {
- event: Event;
- group: Group;
- project: Project;
- preview?: boolean;
-}) {
- const {data: autofixData, isPending} = useAutofixData({groupId: group.id});
-
- const rootCauseDescription = useMemo(
- () => (autofixData ? getRootCauseDescription(autofixData) : null),
- [autofixData]
- );
-
- const rootCauseCopyText = useMemo(
- () => (autofixData ? getRootCauseCopyText(autofixData) : null),
- [autofixData]
- );
-
- const solutionDescription = useMemo(
- () => (autofixData ? getSolutionDescription(autofixData) : null),
- [autofixData]
- );
-
- const solutionCopyText = useMemo(
- () => (autofixData ? getSolutionCopyText(autofixData) : null),
- [autofixData]
- );
-
- const solutionIsLoading = useMemo(
- () => (autofixData ? getSolutionIsLoading(autofixData) : false),
- [autofixData]
- );
-
- const codeChangesDescription = useMemo(
- () => (autofixData ? getCodeChangesDescription(autofixData) : null),
- [autofixData]
- );
-
- const codeChangesIsLoading = useMemo(
- () => (autofixData ? getCodeChangesIsLoading(autofixData) : false),
- [autofixData]
- );
-
- // Track autofix features analytics
- useRouteAnalyticsParams({
- has_root_cause: Boolean(rootCauseDescription),
- has_solution: Boolean(solutionDescription),
- has_coded_solution: Boolean(codeChangesDescription),
- has_pr: hasPullRequest(autofixData),
- });
-
- if (isPending && getAutofixRunExists(group)) {
- return ;
- }
-
- if (rootCauseDescription) {
- return (
-
- );
- }
-
- return ;
-}
-
-export function AutofixSummary({
- group,
- rootCauseDescription,
- solutionDescription,
- solutionIsLoading,
- codeChangesDescription,
- codeChangesIsLoading,
- rootCauseCopyText,
- solutionCopyText,
-}: {
- codeChangesDescription: string | null;
- codeChangesIsLoading: boolean;
- group: Group;
- rootCauseCopyText: string | null;
- rootCauseDescription: string | null;
- solutionCopyText: string | null;
- solutionDescription: string | null;
- solutionIsLoading: boolean;
-}) {
- const organization = useOrganization();
- const navigate = useNavigate();
- const location = useLocation();
- const {data: autofixData} = useAutofixData({groupId: group.id});
-
- const seerLink = {
- pathname: location.pathname,
- query: {
- ...location.query,
- seerDrawer: true,
- },
- };
-
- const insightCards: InsightCardObject[] = [
- {
- id: 'root_cause_description',
- title: t('Root Cause'),
- insight: rootCauseDescription,
- icon: ,
- feedbackType: 'root_cause',
- onClick: () => {
- trackAnalytics('autofix.summary_root_cause_clicked', {
- organization,
- group_id: group.id,
- });
- navigate({
- ...seerLink,
- query: {
- ...seerLink.query,
- scrollTo: 'root_cause',
- },
- });
- },
- copyTitle: t('Copy root cause as Markdown'),
- copyText: rootCauseCopyText,
- copyAnalyticsEventName: 'Autofix: Copy Root Cause as Markdown',
- copyAnalyticsEventKey: 'autofix.root_cause.copy',
- },
-
- ...(solutionDescription || solutionIsLoading
- ? [
- {
- id: 'solution_description',
- title: t('Solution'),
- insight: solutionDescription,
- icon: ,
- isLoading: solutionIsLoading,
- feedbackType: 'solution' as const,
- onClick: () => {
- trackAnalytics('autofix.summary_solution_clicked', {
- organization,
- group_id: group.id,
- });
- navigate({
- ...seerLink,
- query: {
- ...seerLink.query,
- scrollTo: 'solution',
- },
- });
- },
- copyTitle: t('Copy solution as Markdown'),
- copyText: solutionCopyText,
- copyAnalyticsEventName: 'Autofix: Copy Solution as Markdown',
- copyAnalyticsEventKey: 'autofix.solution.copy',
- },
- ]
- : []),
-
- ...(codeChangesDescription || codeChangesIsLoading
- ? [
- {
- id: 'code_changes',
- title: t('Code Changes'),
- insight: codeChangesDescription,
- icon: ,
- isLoading: codeChangesIsLoading,
- feedbackType: 'changes' as const,
- onClick: () => {
- trackAnalytics('autofix.summary_code_changes_clicked', {
- organization,
- group_id: group.id,
- });
- navigate({
- ...seerLink,
- query: {
- ...seerLink.query,
- scrollTo: 'code_changes',
- },
- });
- },
- },
- ]
- : []),
- ];
-
- return (
-
-
-
- {insightCards.map(card => {
- if (!card.isLoading && !card.insight) {
- return null;
- }
-
- return (
-
-
-
-
- {card.icon}
- {card.title}
-
-
- {!card.isLoading && card.feedbackType && autofixData?.run_id && (
- e.stopPropagation()}
- />
- )}
- {card.copyText && card.copyTitle && (
- {
- e.stopPropagation();
- }}
- analyticsEventName={card.copyAnalyticsEventName}
- analyticsEventKey={card.copyAnalyticsEventKey}
- />
- )}
-
-
-
- {card.isLoading ? (
-
-
-
- ) : (
-
- {card.insightElement}
- {card.insight && (
- {
- // Stop propagation if the click is directly on a link
- if ((e.target as HTMLElement).tagName === 'A') {
- e.stopPropagation();
- }
- }}
- text={
- card.isLoading
- ? card.insight.replace(/\*\*/g, '')
- : card.insight
- }
- />
- )}
-
- )}
-
-
-
- );
- })}
-
-
-
- );
-}
-
-const InsightCardButton = styled(motion.div)`
- border-radius: ${p => p.theme.radius.md};
- border: 1px solid ${p => p.theme.tokens.border.primary};
- width: 100%;
- min-height: 0;
- position: relative;
- overflow: hidden;
- cursor: pointer;
- padding: 0;
- box-shadow: ${p => p.theme.shadow.low};
- background-color: ${p => p.theme.tokens.background.primary};
-
- &:hover {
- background-color: ${p =>
- p.theme.tokens.interactive.transparent.neutral.background.hover};
- }
-`;
-
-const InsightGrid = styled('div')`
- display: flex;
- flex-direction: column;
- gap: ${p => p.theme.space.lg};
- position: relative;
-
- &:before {
- content: '';
- position: absolute;
- left: ${p => p.theme.space['2xl']};
- top: ${p => p.theme.space['3xl']};
- bottom: ${p => p.theme.space.xl};
- width: 1px;
- /* eslint-disable-next-line @sentry/scraps/use-semantic-token */
- background: ${p => p.theme.tokens.border.primary};
- z-index: 0;
- }
-`;
-
-const CardTitle = styled('div')<{preview?: boolean}>`
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.md};
- color: ${p => p.theme.tokens.content.primary};
- padding: ${p => p.theme.space.xs} ${p => p.theme.space.xs} 0 ${p => p.theme.space.md};
- justify-content: space-between;
-`;
-
-const CardTitleText = styled('p')`
- margin: 0;
- font-size: ${p => p.theme.font.size.md};
- font-weight: ${p => p.theme.font.weight.sans.medium};
- margin-top: 1px;
-`;
-
-const CardTitleIcon = styled('div')`
- display: flex;
- align-items: center;
- color: ${p => p.theme.tokens.content.primary};
-`;
-
-const CardContent = styled('div')`
- overflow-wrap: break-word;
- word-break: break-word;
- padding: ${p => p.theme.space.xs} ${p => p.theme.space.md} ${p => p.theme.space.md}
- ${p => p.theme.space.md};
- text-align: left;
- flex: 1;
-
- p {
- margin: 0;
- white-space: pre-wrap;
- }
-
- code {
- word-break: break-all;
- }
-
- a {
- color: ${p => p.theme.tokens.interactive.link.accent.rest};
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
-`;
diff --git a/static/app/router/routes.spec.tsx b/static/app/router/routes.spec.tsx
index c777bc1c6576b1..c648394a29b302 100644
--- a/static/app/router/routes.spec.tsx
+++ b/static/app/router/routes.spec.tsx
@@ -1,4 +1,4 @@
-import type {RouteObject} from 'react-router-dom';
+import {matchRoutes, type RouteObject} from 'react-router-dom';
import * as constants from 'sentry/constants';
import {buildRoutes} from 'sentry/router/routes';
@@ -65,6 +65,17 @@ function extractRoutes(rootRoute: RouteObject[]): Set {
return routes;
}
+/**
+ * Returns the paths of all matched route segments for a given URL.
+ */
+function getMatchedPaths(routes: RouteObject[], url: string): string[] {
+ const matches = matchRoutes(routes, url);
+ if (!matches) {
+ return [];
+ }
+ return matches.map(m => m.route.path ?? '(layout)');
+}
+
describe('buildRoutes()', () => {
// Until customer-domains is enabled for single-tenant, self-hosted and path
// based slug routes are removed we need to ensure
@@ -109,4 +120,23 @@ describe('buildRoutes()', () => {
);
}
});
+
+ describe('explore route catch-all', () => {
+ it('catches unknown subpaths under /explore/', () => {
+ const spy = jest.spyOn(constants, 'USING_CUSTOMER_DOMAIN', 'get');
+
+ spy.mockReturnValue(true);
+ let routes = buildRoutes();
+ let matchedPaths = getMatchedPaths(routes, '/explore/nonexistent-page/');
+ expect(matchedPaths).toContain(':catchAll/');
+
+ spy.mockReturnValue(false);
+ routes = buildRoutes();
+ matchedPaths = getMatchedPaths(
+ routes,
+ '/organizations/test-org/explore/nonexistent-page/also-nonexistent-page/'
+ );
+ expect(matchedPaths).toContain(':catchAll/*');
+ });
+ });
});
diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx
index 0a9e7dfe2cb7be..11666b6aa293fc 100644
--- a/static/app/router/routes.tsx
+++ b/static/app/router/routes.tsx
@@ -2349,6 +2349,16 @@ function buildRoutes(): RouteObject[] {
path: 'saved-queries/',
component: make(() => import('sentry/views/explore/savedQueries')),
},
+ // These two routes have to be placed at the end of the exploreChildren
+ // array to avoid being overridden by the other routes.
+ {
+ path: ':catchAll/',
+ component: make(() => import('sentry/views/explore/indexRedirect')),
+ },
+ {
+ path: ':catchAll/*',
+ component: make(() => import('sentry/views/explore/indexRedirect')),
+ },
];
const exploreRoutes: SentryRouteObject = {
path: '/explore/',
diff --git a/static/app/types/overrides.tsx b/static/app/types/overrides.tsx
index 798dee1bd8a691..7276f6385dfc29 100644
--- a/static/app/types/overrides.tsx
+++ b/static/app/types/overrides.tsx
@@ -11,8 +11,6 @@ import type {DateRange} from 'sentry/components/timeRangeSelector/dateRange';
import type {SelectorItems} from 'sentry/components/timeRangeSelector/selectorItems';
import type {SentryRouteObject} from 'sentry/router/types';
import type {DataCategory} from 'sentry/types/core';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
import type {DetailedProject, Project} from 'sentry/types/project';
import type {UseReplayForCriticalFlowOptions} from 'sentry/utils/replays/useReplayForCriticalFlow';
import type {UseExperimentOptions, UseExperimentResult} from 'sentry/utils/useExperiment';
@@ -71,12 +69,6 @@ type RouteOverrides = {
'routes:subscription-settings': RouteObjectOverride;
};
-type AiSetupConfigrationProps = {
- event: Event;
- group: Group;
- project: Project;
-};
-
type AiSetupDataConsentProps = {
groupId: string;
};
@@ -194,7 +186,6 @@ type DashboardLimitProviderProps = {
*/
type ComponentOverrides = {
'component:ai-configure-seer-quota-sidebar': () => React.ComponentType;
- 'component:ai-setup-configuration': () => React.ComponentType;
'component:ai-setup-data-consent': () => React.ComponentType | null;
'component:codecov-integration-settings-link': () => React.ComponentType;
'component:confirm-account-close': () => React.ComponentType;
diff --git a/static/app/utils/marked/marked.spec.tsx b/static/app/utils/marked/marked.spec.tsx
index 1da0feee5fa043..c3d2ba875c1213 100644
--- a/static/app/utils/marked/marked.spec.tsx
+++ b/static/app/utils/marked/marked.spec.tsx
@@ -3,7 +3,6 @@
import {
asyncSanitizedMarked,
sanitizedMarked,
- sanitizedMarkedNoHeadings,
singleLineRenderer,
} from 'sentry/utils/marked/marked';
import {loadPrismLanguage} from 'sentry/utils/prism';
@@ -89,20 +88,6 @@ describe('marked', () => {
);
});
- it('sanitizedMarkedNoHeadings renders headings as bold text', () => {
- expect(sanitizedMarkedNoHeadings('# Heading 1')).toBe('Heading 1 ');
- expect(sanitizedMarkedNoHeadings('## Heading 2')).toBe('Heading 2 ');
- expect(sanitizedMarkedNoHeadings('### Heading 3')).toBe('Heading 3 ');
- });
-
- it('sanitizedMarkedNoHeadings renders non-heading markdown normally', () => {
- expect(sanitizedMarkedNoHeadings('**bold**')).toBe('bold
\n');
- expect(sanitizedMarkedNoHeadings('`code`')).toBe('code
\n');
- expect(sanitizedMarkedNoHeadings('[link](https://example.com)')).toBe(
- 'link
\n'
- );
- });
-
it('single line renderer should not render paragraphs', () => {
expect(singleLineRenderer('foo')).toBe('foo');
expect(sanitizedMarked('foo')).toBe('foo
\n');
diff --git a/static/app/utils/marked/marked.tsx b/static/app/utils/marked/marked.tsx
index 218d447046198b..00f3522dcd4de1 100644
--- a/static/app/utils/marked/marked.tsx
+++ b/static/app/utils/marked/marked.tsx
@@ -43,13 +43,6 @@ class SafeRenderer extends marked.Renderer {
}
}
-class NoHeadingRenderer extends SafeRenderer {
- heading(tokens: Tokens.Heading) {
- // Render headings as bold text instead of h1-h6 elements
- return super.strong({...tokens, type: 'strong'});
- }
-}
-
class NoParagraphRenderer extends SafeRenderer {
paragraph(tokens: Tokens.Paragraph) {
// Do not render the paragraph but still render sub-tokens
@@ -178,17 +171,6 @@ export const sanitizedMarked = (src: string): string => {
return noHighlightingMarked.parse(src, {async: false});
};
-/**
- * Renders markdown without any heading tags applied.
- * WARNING: Does not apply any syntax highlighting.
- */
-export const sanitizedMarkedNoHeadings = (src: string): string => {
- return noHighlightingMarked.parse(src, {
- async: false,
- renderer: new NoHeadingRenderer(),
- });
-};
-
/**
* Renders a single line of markdown not wrapped in a paragraph tag.
* WARNING: Does not apply any syntax highlighting.
diff --git a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx
index e63bb9f822c887..37ddc7aa4b24ca 100644
--- a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx
+++ b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx
@@ -1,6 +1,6 @@
import 'echarts/lib/chart/heatmap';
-import {useRef} from 'react';
+import {Fragment, useRef, type ReactNode} from 'react';
import {useTheme} from '@emotion/react';
import type {
TooltipFormatterCallback,
@@ -8,18 +8,25 @@ import type {
} from 'echarts/types/dist/shared';
import {Flex} from '@sentry/scraps/layout';
+import {useRenderToString} from '@sentry/scraps/renderToString';
import {BaseChart} from 'sentry/components/charts/baseChart';
-import {getFormatter} from 'sentry/components/charts/components/tooltip';
-import {isChartHovered, truncationFormatter} from 'sentry/components/charts/utils';
+import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip';
+import {isChartHovered} from 'sentry/components/charts/utils';
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
+import {t} from 'sentry/locale';
import type {ReactEchartsRef} from 'sentry/types/echarts';
+import {defined} from 'sentry/utils';
+import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
+import {ECHARTS_MISSING_DATA_VALUE} from 'sentry/utils/timeSeries/timeSeriesItemToEChartsDataPoint';
+import {useOrganization} from 'sentry/utils/useOrganization';
import {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings';
import {plottablesCanBeVisualized} from 'sentry/views/dashboards/widgets/plottablesCanBeVisualized';
import {formatTooltipValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTooltipValue';
import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp';
import {formatYAxisValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue';
import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/settings';
+import {getExploreUrl, type GetExploreUrlArgs} from 'sentry/views/explore/utils';
import {HeatMap} from './plottables/heatMap';
import type {HeatMapPlottable} from './plottables/heatMapPlottable';
@@ -34,11 +41,17 @@ interface HeatMapWidgetVisualizationProps {
* Experimental! Specify the Z-axis scale type. Logarithmic scales can be much more useful for values with a high range.
*/
scale?: 'linear' | 'log';
+ /**
+ * getExploreUrl props that will be used to generate an explore link for the tooltip. Omitting this will not generate an explore link.
+ */
+ tooltipExploreUrlArgs?: Omit;
}
export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProps) {
const {plottables} = props;
const theme = useTheme();
+ const organization = useOrganization();
+ const renderToString = useRenderToString();
const pageFilters = usePageFilters();
const {start, end, period, utc} = pageFilters.selection.datetime;
@@ -68,43 +81,155 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp
const Zmax =
scale === 'log' ? Math.log1p(heatMapPlottable.Zend) : heatMapPlottable.Zend;
+ /** Extract the numeric value from ECharts tooltip param.value. */
+ function extractValue(data: unknown): number | null {
+ // param.value can be either:
+ // 1. The numeric value directly (for heatmap charts with axis trigger)
+ // 2. An object {name, value} (depends on series config)
+ if (typeof data === 'number') {
+ return data;
+ }
+
+ const value = (data as {value?: unknown} | null | undefined)?.value;
+ return typeof value === 'number' ? value : null;
+ }
+
// Create tooltip formatter
- const formatTooltip: TooltipFormatterCallback = (
- params,
- asyncTicket
- ) => {
+ const formatTooltip: TooltipFormatterCallback = params => {
// Only show the tooltip of the current chart. Otherwise, all tooltips
// in the chart group appear.
if (!isChartHovered(chartRef?.current)) {
return '';
}
- return getFormatter({
- isGroupedByDate: true,
- showTimeInTooltip: true,
- nameFormatter: function (seriesName, _nameFormatterParams) {
- return truncationFormatter(seriesName, true, false);
- },
- valueFormatter: function (value, _field, _valueFormatterParams) {
- const bucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize;
- const fieldType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE;
-
- const yAxisMinValueFormatted = formatTooltipValue(
- value,
- fieldType,
- heatMapPlottable.yAxisValueUnit ?? undefined
- );
- const yAxisMaxValueFormatted = formatTooltipValue(
- value + bucketSize,
- fieldType,
- heatMapPlottable.yAxisValueUnit ?? undefined
- );
-
- return `${yAxisMinValueFormatted} – ${yAxisMaxValueFormatted}`;
- },
- truncate: false,
- utc: utc ?? false,
- })(params, asyncTicket);
+ const seriesParams = Array.isArray(params) ? params : [params];
+
+ // Filter null values from tooltip
+ const filteredParams = seriesParams.filter(param => {
+ // @ts-expect-error ECharts types param.value as unknown, but we know it's [xAxis, yAxis, zAxis] from our HeatMap plottable
+ const value = extractValue(param.value[2]);
+ return value !== null;
+ });
+
+ let formattedXValue = ECHARTS_MISSING_DATA_VALUE;
+
+ const xAxisBucketSize = heatMapPlottable.heatMapSeries.meta.xAxis.bucketSize;
+ const yAxisBucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize;
+ const yAxisUnit = heatMapPlottable?.yAxisValueUnit;
+ const yAxisValueType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE;
+
+ return renderToString(
+
+
+ {filteredParams.map(param => {
+ let rawXValue: number | undefined;
+ let rawYValue: number | undefined;
+
+ let formattedYValue = ECHARTS_MISSING_DATA_VALUE;
+ let formattedZValue = ECHARTS_MISSING_DATA_VALUE;
+ if (Array.isArray(param.value) && param.value.length === 3) {
+ const [xValue, yValue, zValue] = param.value;
+
+ if (defined(xValue) && typeof xValue === 'number') {
+ rawXValue = xValue;
+ // bucket size seems to be in seconds but we need to convert to milliseconds
+ formattedXValue = defaultFormatAxisLabel(
+ xValue,
+ true,
+ utc ?? false,
+ true,
+ false,
+ xAxisBucketSize * 1000
+ ).toString();
+ }
+
+ if (defined(yValue) && typeof yValue === 'number') {
+ rawYValue = yValue;
+ const yAxisMinValueFormatted = formatTooltipValue(
+ yValue,
+ yAxisValueType,
+ yAxisUnit ?? undefined
+ );
+
+ if (yAxisBucketSize === 0) {
+ formattedYValue = yAxisMinValueFormatted;
+ } else {
+ const yAxisMaxValueFormatted = formatTooltipValue(
+ yValue + yAxisBucketSize,
+ yAxisValueType,
+ yAxisUnit ?? undefined
+ );
+
+ formattedYValue = `${yAxisMinValueFormatted} – ${yAxisMaxValueFormatted}`;
+ }
+ }
+
+ if (defined(zValue) && typeof zValue === 'number') {
+ formattedZValue = formatAbbreviatedNumber(zValue, 4, false);
+ }
+ }
+
+ let exploreLink: ReactNode;
+
+ if (defined(rawXValue) && defined(rawYValue) && props.tooltipExploreUrlArgs) {
+ const xAxisMaxValue = rawXValue + xAxisBucketSize * 1000;
+ const yAxisMaxValue = rawYValue + yAxisBucketSize;
+
+ const exploreUrlProps: GetExploreUrlArgs = {
+ organization,
+ ...props.tooltipExploreUrlArgs,
+ selection: {
+ ...pageFilters.selection,
+ datetime: {
+ ...pageFilters.selection.datetime,
+ start: new Date(rawXValue),
+ end: new Date(xAxisMaxValue),
+ period: null,
+ },
+ },
+ // TODO(nikki): we're only handling metrics for now but if we're looking to support other explore
+ // surfaces then we'll need to add more logic here
+ crossEvents: props.tooltipExploreUrlArgs?.crossEvents?.map(crossEvent => {
+ if (crossEvent.type === 'metrics') {
+ return {
+ ...crossEvent,
+ query:
+ yAxisBucketSize === 0
+ ? `value:<=${rawYValue}`
+ : `value:>=${rawYValue} value:<${yAxisMaxValue}`,
+ };
+ }
+ return crossEvent;
+ }),
+ };
+
+ const tracesLink = getExploreUrl(exploreUrlProps);
+ exploreLink =
{t('View related traces')} ;
+ }
+
+ return (
+
+
+
+ {formattedYValue}
+ {' '}
+ {formattedZValue}
+
+ {exploreLink && (
+
+
+ {exploreLink}
+
+
+ )}
+
+ );
+ })}
+
+ {formattedXValue}
+
+
+ );
};
return (
@@ -118,6 +243,8 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp
ref={chartRef}
tooltip={{
show: true,
+ enterable: true,
+ extraCssText: `box-shadow: 0 0 0 1px ${theme.tokens.border.transparent.neutral.muted}, ${theme.shadow.high}; z-index: ${theme.zIndex.tooltip} !important; pointer-events: auto !important;`,
axisPointer: {
show: false,
},
diff --git a/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx b/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx
index 07c71d97c08025..bafdcee0e01f0e 100644
--- a/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx
+++ b/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx
@@ -49,7 +49,7 @@ export class HeatMap implements HeatMapPlottable {
toSeries(plottingOptions: HeatMapPlottingOptions): SeriesOption[] {
const {heatMapSeries} = this;
- const {scale = 'linear'} = plottingOptions;
+ const {scale = 'linear', theme} = plottingOptions;
return [
{
@@ -64,7 +64,10 @@ export class HeatMap implements HeatMapPlottable {
];
}),
emphasis: {
- disabled: true,
+ itemStyle: {
+ borderColor: theme.tokens.border.onVibrant.dark,
+ borderWidth: parseInt(theme.border.xl.replace('px', ''), 10),
+ },
},
},
];
diff --git a/static/app/views/explore/metrics/metricsHeatMap.tsx b/static/app/views/explore/metrics/metricsHeatMap.tsx
index 22403d0732c1e3..b973762558c6ee 100644
--- a/static/app/views/explore/metrics/metricsHeatMap.tsx
+++ b/static/app/views/explore/metrics/metricsHeatMap.tsx
@@ -1,3 +1,4 @@
+import {useMemo} from 'react';
import type {UseQueryResult} from '@tanstack/react-query';
import {t} from 'sentry/locale';
@@ -12,9 +13,10 @@ import {
useMetricName,
useMetricVisualize,
useMetricVisualizes,
+ useTraceMetric,
} from 'sentry/views/explore/metrics/metricsQueryParams';
import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings';
-import {prettifyAggregation} from 'sentry/views/explore/utils';
+import {prettifyAggregation, type GetExploreUrlArgs} from 'sentry/views/explore/utils';
interface MetricsHeatMapProps {
actions: React.ReactNode;
@@ -27,6 +29,7 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr
const visualizes = useMetricVisualizes();
const metricLabel = useMetricLabel();
const metricName = useMetricName();
+ const metric = useTraceMetric();
const {data: heatMapSeries, isPending, error} = heatmapResult;
@@ -36,6 +39,18 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr
? metricName
: (title ?? metricLabel ?? prettifyAggregation(aggregate) ?? aggregate);
+ const tooltipExploreUrlArgs: Omit = useMemo(() => {
+ return {
+ crossEvents: [
+ {
+ type: 'metrics',
+ query: '',
+ metric,
+ },
+ ],
+ };
+ }, [metric]);
+
return (
)
}
diff --git a/static/app/views/issueDetails/actions/seerCommandPaletteActions.tsx b/static/app/views/issueDetails/actions/seerCommandPaletteActions.tsx
index 6f7e750086b0f2..042fc46e6973f0 100644
--- a/static/app/views/issueDetails/actions/seerCommandPaletteActions.tsx
+++ b/static/app/views/issueDetails/actions/seerCommandPaletteActions.tsx
@@ -29,12 +29,11 @@ import {useOpenSeerDrawer} from 'sentry/views/issueDetails/streamline/sidebar/se
function useSeerState(group: Group, project: Project) {
const organization = useOrganization();
const aiConfig = useAiConfig(group, project);
- const isExplorer = organization.features.includes('autofix-on-explorer');
const issueTypeConfig = getConfigForIssueType(group, project);
const issueTypeSupportsSeer = issueTypeConfig.autofix || issueTypeConfig.issueSummary;
const autofix = useExplorerAutofix(group.id, {
- enabled: aiConfig.areAiFeaturesAllowed && isExplorer,
+ enabled: aiConfig.areAiFeaturesAllowed,
});
const sections = useMemo(
@@ -56,7 +55,6 @@ function useSeerState(group: Group, project: Project) {
return {
organization,
aiConfig,
- isExplorer,
issueTypeSupportsSeer,
autofix,
completedRootCause,
@@ -80,7 +78,6 @@ export function SeerCommandPaletteActions({
const {
organization,
aiConfig,
- isExplorer,
issueTypeSupportsSeer,
autofix,
completedRootCause,
@@ -96,7 +93,7 @@ export function SeerCommandPaletteActions({
);
const codingAgentIntegrations = codingAgentResponse?.integrations;
- if (!aiConfig.areAiFeaturesAllowed || !isExplorer || !issueTypeSupportsSeer || !event) {
+ if (!aiConfig.areAiFeaturesAllowed || !issueTypeSupportsSeer || !event) {
return null;
}
diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx
index b2f42716b88ab6..85bd72e37344cd 100644
--- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx
+++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx
@@ -79,10 +79,7 @@ function formatStacktraceToMarkdown(stacktrace: StacktraceType): string {
return markdownText;
}
-export function formatEventToMarkdown(
- event: Event,
- activeThreadId: number | undefined
-): string {
+function formatEventToMarkdown(event: Event, activeThreadId: number | undefined): string {
let markdownText = '';
// Add tags
diff --git a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx
index f1fc39993bf4d6..ba934a29da9bbe 100644
--- a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx
+++ b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx
@@ -1,8 +1,4 @@
-import {AutofixDataFixture} from 'sentry-fixture/autofixData';
import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture';
-import {AutofixStepFixture} from 'sentry-fixture/autofixStep';
-import {EventFixture} from 'sentry-fixture/event';
-import {FrameFixture} from 'sentry-fixture/frame';
import {GroupFixture} from 'sentry-fixture/group';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {DetailedProjectFixture} from 'sentry-fixture/project';
@@ -15,92 +11,60 @@ import {
waitForElementToBeRemoved,
} from 'sentry-test/reactTestingLibrary';
-import {EntryType} from 'sentry/types/event';
import {SeerDrawer} from 'sentry/views/issueDetails/streamline/sidebar/seerDrawer';
+function makeExplorerBlock({
+ id = 'block-1',
+ content = 'Analysis content',
+ step,
+ loading = false,
+ artifacts,
+}: {
+ artifacts?: Array<{data: Record; key: string; reason: string}>;
+ content?: string;
+ id?: string;
+ loading?: boolean;
+ step?: string;
+} = {}) {
+ return {
+ id,
+ message: {
+ role: 'assistant' as const,
+ content,
+ metadata: step ? {step} : null,
+ },
+ timestamp: '2024-01-01T00:00:00Z',
+ loading,
+ artifacts: artifacts ?? [],
+ };
+}
+
+function makeExplorerAutofixData({
+ blocks = [makeExplorerBlock()],
+ status = 'completed' as const,
+ run_id = 1,
+}: {
+ blocks?: Array>;
+ run_id?: number;
+ status?: 'processing' | 'completed' | 'error' | 'awaiting_user_input';
+} = {}) {
+ return {
+ run_id,
+ blocks,
+ status,
+ updated_at: '2024-01-01T00:00:00Z',
+ };
+}
+
describe('SeerDrawer', () => {
const organization = OrganizationFixture({
hideAiFeatures: false,
features: ['gen-ai-features'],
});
- const mockEvent = EventFixture({
- entries: [
- {
- type: EntryType.EXCEPTION,
- data: {values: [{stacktrace: {frames: [FrameFixture()]}}]},
- },
- ],
- });
const mockGroup = GroupFixture();
const mockProject = DetailedProjectFixture();
- const mockAutofixData = AutofixDataFixture({steps: [AutofixStepFixture()]});
-
- // Create autofix data with various repository configurations for testing notices
- const mockAutofixWithReadableRepos = AutofixDataFixture({
- steps: [AutofixStepFixture()],
- request: {
- repos: [
- {
- name: 'org/repo',
- provider: 'github',
- owner: 'org',
- external_id: 'repo-123',
- },
- ],
- },
- codebases: {
- 'repo-123': {
- repo_external_id: 'repo-123',
- is_readable: true,
- is_writeable: true,
- },
- },
- });
-
- const mockAutofixWithUnreadableGithubRepos = AutofixDataFixture({
- steps: [AutofixStepFixture()],
- request: {
- repos: [
- {
- name: 'org/repo',
- provider: 'github',
- owner: 'org',
- external_id: 'repo-123',
- },
- ],
- },
- codebases: {
- 'repo-123': {
- repo_external_id: 'repo-123',
- is_readable: false,
- is_writeable: false,
- },
- },
- });
-
- const mockAutofixWithUnreadableNonGithubRepos = AutofixDataFixture({
- steps: [AutofixStepFixture()],
- request: {
- repos: [
- {
- name: 'org/gitlab-repo',
- provider: 'gitlab',
- owner: 'org',
- external_id: 'repo-123',
- },
- ],
- },
- codebases: {
- 'repo-123': {
- repo_external_id: 'repo-123',
- is_readable: false,
- is_writeable: false,
- },
- },
- });
-
beforeEach(() => {
MockApiClient.clearMockResponses();
localStorage.clear();
@@ -112,16 +76,6 @@ describe('SeerDrawer', () => {
githubWriteIntegration: {ok: true, repos: []},
}),
});
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: {
- whatsWrong: 'Test whats wrong',
- trace: 'Test trace',
- possibleCause: 'Test possible cause',
- headline: 'Test headline',
- },
- });
MockApiClient.addMockResponse({
url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/seer/preferences/`,
body: {
@@ -164,51 +118,13 @@ describe('SeerDrawer', () => {
});
});
- it('renders issue summary if consent flow is removed and there is no autofix quota', async () => {
- const orgWithConsentFlowRemoved = OrganizationFixture({
- hideAiFeatures: false,
- features: ['seer-billing', 'gen-ai-features'],
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: false, reason: null},
- githubWriteIntegration: {ok: false, repos: []},
- billing: {hasAutofixQuota: false},
- }),
- });
+ it('renders loading state while autofix setup is pending', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
body: {autofix: null},
});
- render( , {
- organization: orgWithConsentFlowRemoved,
- });
-
- expect(screen.getByTestId('ai-setup-loading-indicator')).toBeInTheDocument();
-
- await waitForElementToBeRemoved(() =>
- screen.queryByTestId('ai-setup-loading-indicator')
- );
-
- // Issue summary fields are rendered
- expect(screen.getByText('Test whats wrong')).toBeInTheDocument();
- expect(screen.getByText('Test trace')).toBeInTheDocument();
- expect(screen.getByText('Test possible cause')).toBeInTheDocument();
- expect(screen.getByText('Test headline')).toBeInTheDocument();
-
- // Should display the seer purchase flow
- expect(screen.getByTestId('ai-setup-data-consent')).toBeInTheDocument();
- });
-
- it('renders initial state with Start Root Cause Analysis button', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: null},
- });
-
- render( , {
+ render( , {
organization,
});
@@ -217,28 +133,15 @@ describe('SeerDrawer', () => {
await waitForElementToBeRemoved(() =>
screen.queryByTestId('ai-setup-loading-indicator')
);
-
- expect(screen.getByRole('heading', {name: 'Seer Autofix'})).toBeInTheDocument();
-
- // Verify the Start Root Cause Analysis button is available
- const startButton = screen.getByRole('button', {name: 'Start Root Cause Analysis'});
- expect(startButton).toBeInTheDocument();
});
- it('renders GitHub integration setup notice when missing GitHub integration', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: false, reason: null},
- githubWriteIntegration: {ok: false, repos: []},
- }),
- });
+ it('renders Seer Autofix header text after loading', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
body: {autofix: null},
});
- render( , {
+ render( , {
organization,
});
@@ -246,113 +149,37 @@ describe('SeerDrawer', () => {
screen.queryByTestId('ai-setup-loading-indicator')
);
- expect(screen.getByText('Set Up the GitHub Integration')).toBeInTheDocument();
- expect(screen.getByText('Set Up Integration')).toBeInTheDocument();
-
- const startButton = screen.getByRole('button', {name: 'Start Root Cause Analysis'});
- expect(startButton).toBeInTheDocument();
+ expect(screen.getByText('Seer Autofix')).toBeInTheDocument();
});
- it('triggers Seer on clicking the Start button', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- method: 'POST',
- body: {autofix: null},
- });
+ it('shows reset button that is always enabled', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- method: 'GET',
body: {autofix: null},
});
- render( , {
+ render( , {
organization,
});
- expect(screen.getByTestId('ai-setup-loading-indicator')).toBeInTheDocument();
-
await waitForElementToBeRemoved(() =>
screen.queryByTestId('ai-setup-loading-indicator')
);
- let resetButton = await screen.findByRole('button', {
- name: 'Start a new analysis from scratch',
- });
- expect(resetButton).toBeInTheDocument();
- expect(resetButton).toBeDisabled();
-
- const startButton = screen.getByRole('button', {name: 'Start Root Cause Analysis'});
- await userEvent.click(startButton);
-
- resetButton = await screen.findByRole('button', {
+ const resetButton = screen.getByRole('button', {
name: 'Start a new analysis from scratch',
});
expect(resetButton).toBeInTheDocument();
expect(resetButton).toBeEnabled();
});
- it('shows disabled reset button when hasAutofix is false', async () => {
- // Mock AI consent as okay but no autofix capability
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: null},
- });
-
- // Use jest.spyOn instead of jest.mock inside the test
- const issueTypeConfigModule = require('sentry/utils/issueTypeConfig');
- const spy = jest
- .spyOn(issueTypeConfigModule, 'getConfigForIssueType')
- .mockImplementation(() => ({
- autofix: false,
- issueSummary: {enabled: true},
- resources: null,
- }));
-
- render( , {
- organization,
- });
-
- await waitForElementToBeRemoved(() =>
- screen.queryByTestId('ai-setup-loading-indicator')
- );
-
- const resetButton = await screen.findByRole('button', {
- name: 'Start a new analysis from scratch',
- });
- expect(resetButton).toBeInTheDocument();
- expect(resetButton).toBeDisabled();
-
- // But the Start Root Cause Analysis button should not be visible
- expect(
- screen.queryByRole('button', {name: 'Start Root Cause Analysis'})
- ).not.toBeInTheDocument();
-
- // Restore the original implementation
- spy.mockRestore();
- });
-
- it('shows disabled reset button when hasAutofix is true but no autofixData', async () => {
- // Mock everything as ready for autofix but no data
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
+ it('shows copy button disabled when no autofix run exists', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
body: {autofix: null},
});
- render( , {
+ render( , {
organization,
});
@@ -360,29 +187,22 @@ describe('SeerDrawer', () => {
screen.queryByTestId('ai-setup-loading-indicator')
);
- // Reset button should be visible and enabled (onReset is always provided)
- const resetButton = screen.getByRole('button', {
- name: 'Start a new analysis from scratch',
+ const copyButton = screen.getByRole('button', {
+ name: 'Copy analysis as Markdown',
});
- expect(resetButton).toBeInTheDocument();
- expect(resetButton).toBeDisabled();
+ expect(copyButton).toBeInTheDocument();
+ expect(copyButton).toBeDisabled();
});
- it('shows enabled reset button when hasAutofix and autofixData are both true', async () => {
- // Mock everything as ready with existing autofix data
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
+ it('shows copy button enabled when autofix run exists', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: mockAutofixData},
+ body: {
+ autofix: makeExplorerAutofixData(),
+ },
});
- render( , {
+ render( , {
organization,
});
@@ -390,21 +210,22 @@ describe('SeerDrawer', () => {
screen.queryByTestId('ai-setup-loading-indicator')
);
- // Reset button should be visible and enabled
- const resetButton = screen.getByRole('button', {
- name: 'Start a new analysis from scratch',
+ const copyButton = screen.getByRole('button', {
+ name: 'Copy analysis as Markdown',
});
- expect(resetButton).toBeInTheDocument();
- expect(resetButton).toBeEnabled();
+ expect(copyButton).toBeInTheDocument();
+ expect(copyButton).toBeEnabled();
});
- it('displays reset button with autofix data', async () => {
+ it('renders reset button enabled with autofix data', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: mockAutofixData},
+ body: {
+ autofix: makeExplorerAutofixData(),
+ },
});
- render( , {
+ render( , {
organization,
});
@@ -419,37 +240,21 @@ describe('SeerDrawer', () => {
expect(resetButton).toBeEnabled();
});
- it('displays reset button even without autofix data', async () => {
+ it('clicking reset triggers a new root cause analysis', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: null},
- });
-
- render( , {
- organization,
- });
-
- await waitForElementToBeRemoved(() =>
- screen.queryByTestId('ai-setup-loading-indicator')
- );
-
- const resetButton = await screen.findByRole('button', {
- name: 'Start a new analysis from scratch',
+ body: {
+ autofix: makeExplorerAutofixData(),
+ },
});
- expect(resetButton).toBeInTheDocument();
- expect(resetButton).toBeDisabled();
- expect(
- await screen.findByRole('button', {name: 'Start Root Cause Analysis'})
- ).toBeInTheDocument();
- });
- it('resets autofix on clicking the reset button', async () => {
- MockApiClient.addMockResponse({
+ const postMock = MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: mockAutofixData},
+ method: 'POST',
+ body: {run_id: 2},
});
- render( , {
+ render( , {
organization,
});
@@ -457,198 +262,51 @@ describe('SeerDrawer', () => {
screen.queryByTestId('ai-setup-loading-indicator')
);
- const resetButton = await screen.findByRole('button', {
+ const resetButton = screen.getByRole('button', {
name: 'Start a new analysis from scratch',
});
- expect(resetButton).toBeInTheDocument();
- expect(resetButton).toBeEnabled();
await userEvent.click(resetButton);
await waitFor(() => {
- expect(
- screen.getByRole('button', {name: 'Start Root Cause Analysis'})
- ).toBeInTheDocument();
- });
- });
-
- it('shows setup instructions when GitHub integration setup is needed', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: false, reason: null},
- githubWriteIntegration: {ok: false, repos: []},
- }),
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: null},
- });
- MockApiClient.addMockResponse({
- url: '/organizations/org-slug/integrations/?provider_key=github&includeConfig=0',
- body: [],
+ expect(postMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ method: 'POST',
+ data: expect.objectContaining({step: 'root_cause'}),
+ })
+ );
});
-
- render( , {
- organization,
- });
-
- expect(screen.getByTestId('ai-setup-loading-indicator')).toBeInTheDocument();
-
- await waitForElementToBeRemoved(() =>
- screen.queryByTestId('ai-setup-loading-indicator')
- );
-
- expect(screen.getByRole('heading', {name: 'Seer Autofix'})).toBeInTheDocument();
-
- // Since "Install the GitHub Integration" text isn't found, let's check for
- // the "Set Up the GitHub Integration" text which is what the component is actually showing
- expect(screen.getByText('Set Up the GitHub Integration')).toBeInTheDocument();
- expect(screen.getByText('Set Up Integration')).toBeInTheDocument();
});
- it('does not render SeerNotices when all repositories are readable', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
+ it('renders root cause section when blocks contain root cause step', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: mockAutofixWithReadableRepos},
- });
-
- render( , {
- organization,
- });
-
- await waitForElementToBeRemoved(() =>
- screen.queryByTestId('ai-setup-loading-indicator')
- );
-
- // We don't expect to see any notice about repositories since all are readable
- expect(screen.queryByText(/Seer can't access/)).not.toBeInTheDocument();
- });
-
- it('renders warning for unreadable GitHub repository', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: mockAutofixWithUnreadableGithubRepos},
- });
-
- render( , {
- organization,
- });
-
- await waitForElementToBeRemoved(() =>
- screen.queryByTestId('ai-setup-loading-indicator')
- );
-
- expect(screen.getByText(/Seer can't access the/)).toBeInTheDocument();
- expect(screen.getByText('org/repo')).toBeInTheDocument();
- expect(screen.getByText(/GitHub integration/)).toBeInTheDocument();
- });
-
- it('renders warning for unreadable non-GitHub repository', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {autofix: mockAutofixWithUnreadableNonGithubRepos},
- });
-
- render( , {
- organization,
- });
-
- await waitForElementToBeRemoved(() =>
- screen.queryByTestId('ai-setup-loading-indicator')
- );
-
- expect(screen.getByText(/Seer can't access the/)).toBeInTheDocument();
- expect(screen.getByText('org/gitlab-repo')).toBeInTheDocument();
- expect(
- 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,
+ autofix: makeExplorerAutofixData({
+ blocks: [
+ makeExplorerBlock({
+ id: 'rc-1',
+ step: 'root_cause',
+ content: 'Root cause analysis result',
+ artifacts: [
+ {
+ key: 'root_cause',
+ reason: 'Analysis complete',
+ data: {
+ one_line_description: 'A null pointer dereference in the auth module',
+ five_whys: ['First why', 'Second why'],
+ },
+ },
+ ],
+ }),
+ ],
+ status: 'completed',
+ }),
},
});
- 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', 'issue-views'],
- }),
+ render( , {
+ organization,
});
await waitForElementToBeRemoved(() =>
@@ -656,87 +314,7 @@ describe('SeerDrawer', () => {
);
expect(
- await screen.findByText('Hand Off to Cursor Cloud Agents')
- ).toBeInTheDocument();
- expect(
- screen.getByRole('button', {name: 'Set Seer to hand off to Cursor'})
+ await screen.findByText('A null pointer dereference in the auth module')
).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', '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 Cloud Agents')).not.toBeInTheDocument();
- });
});
diff --git a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx
index f5d0186a7d244d..2cc6a8a20792fb 100644
--- a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx
+++ b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx
@@ -2,8 +2,7 @@ import {useCallback, useRef} from 'react';
import {useDrawer} from '@sentry/scraps/drawer';
-import {SeerDrawer as LegacySeerDrawer} from 'sentry/components/events/autofix/v1/drawer';
-import {SeerDrawer as ExplorerSeerDrawer} from 'sentry/components/events/autofix/v3/drawer';
+import {SeerDrawer} from 'sentry/components/events/autofix/v3/drawer';
import {t} from 'sentry/locale';
import type {Event} from 'sentry/types/event';
import type {Group} from 'sentry/types/group';
@@ -12,22 +11,7 @@ import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import {useOrganization} from 'sentry/utils/useOrganization';
-
-interface SeerDrawerProps {
- event: Event;
- group: Group;
- project: Project;
-}
-
-export function SeerDrawer({group, project, event}: SeerDrawerProps) {
- const organization = useOrganization();
-
- if (organization.features.includes('autofix-on-explorer')) {
- return ;
- }
-
- return ;
-}
+export {SeerDrawer} from 'sentry/components/events/autofix/v3/drawer';
export const useOpenSeerDrawer = ({
group,
@@ -59,7 +43,7 @@ export const useOpenSeerDrawer = ({
`/organizations/${organization.slug}/issues/${group.id}/`
);
- openDrawer(() => , {
+ openDrawer(() => , {
ariaLabel: t('Seer drawer'),
drawerKey: 'seer-autofix-drawer',
resizable: true,
diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx
deleted file mode 100644
index 34917dfda1be52..00000000000000
--- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx
+++ /dev/null
@@ -1,298 +0,0 @@
-import {GroupSearchViewFixture} from 'sentry-fixture/groupSearchView';
-import {OrganizationFixture} from 'sentry-fixture/organization';
-import {DetailedProjectFixture} from 'sentry-fixture/project';
-
-import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
-
-import {CodingAgentProvider} from 'sentry/components/events/autofix/types';
-import {SeerNotices} from 'sentry/views/issueDetails/streamline/sidebar/seerNotices';
-
-describe('SeerNotices', () => {
- const createRepository = (overrides = {}) => ({
- external_id: 'repo-123',
- name: 'org/repo',
- owner: 'org',
- provider: 'github',
- provider_raw: 'github',
- is_readable: true,
- is_writeable: true,
- ...overrides,
- });
-
- function getProjectWithAutomation(
- automationTuning = 'off' as 'off' | 'low' | 'medium' | 'high' | 'always'
- ) {
- return {
- ...DetailedProjectFixture(),
- autofixAutomationTuning: automationTuning,
- organization: {
- ...DetailedProjectFixture().organization,
- },
- };
- }
-
- const organization = OrganizationFixture();
-
- beforeEach(() => {
- MockApiClient.clearMockResponses();
- MockApiClient.addMockResponse({
- url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/seer/preferences/`,
- body: {
- code_mapping_repos: [],
- preference: null,
- },
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/group-search-views/starred/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- url: `/projects/${organization.slug}/${DetailedProjectFixture().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 () => {
- MockApiClient.addMockResponse({
- method: 'GET',
- url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/`,
- body: {
- autofixAutomationTuning: 'off',
- },
- });
- const project = {
- ...DetailedProjectFixture(),
- organization: {
- ...DetailedProjectFixture().organization,
- features: [],
- },
- };
- render( , {
- organization,
- });
- await waitFor(() => {
- expect(screen.getByText('Unleash Automation')).toBeInTheDocument();
- });
- });
-
- it('shows fixability view step if automation is allowed and view not starred', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/group-search-views/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- method: 'GET',
- url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/`,
- body: {
- autofixAutomationTuning: 'medium',
- },
- });
- const project = getProjectWithAutomation('high');
- render( , {
- organization: {
- ...organization,
- features: ['issue-views'],
- },
- });
- await waitFor(() => {
- expect(screen.getByText('Get Some Quick Wins')).toBeInTheDocument();
- });
- });
-
- 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}/${DetailedProjectFixture().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}/${DetailedProjectFixture().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: [],
- },
- });
- await waitFor(() => {
- expect(screen.getByText('Hand Off to Cursor Cloud 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:${DetailedProjectFixture().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}/${DetailedProjectFixture().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: [],
- },
- });
-
- // Should not show the cursor step since it was skipped
- expect(screen.queryByText('Hand Off to Cursor Cloud Agents')).not.toBeInTheDocument();
-
- // Clean up localStorage
- localStorage.removeItem(
- `seer-onboarding-cursor-skipped:${DetailedProjectFixture().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}/${DetailedProjectFixture().slug}/seer/preferences/`,
- body: {
- code_mapping_repos: [],
- preference: {
- repositories: [],
- automated_run_stopping_point: 'root_cause',
- automation_handoff: {
- handoff_point: 'root_cause',
- target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT,
- integration_id: 123,
- },
- },
- },
- });
- MockApiClient.addMockResponse({
- method: 'GET',
- url: `/projects/${organization.slug}/${DetailedProjectFixture().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: [],
- },
- });
-
- // Should not show the cursor step since handoff is already configured
- expect(screen.queryByText('Hand Off to Cursor Cloud Agents')).not.toBeInTheDocument();
- });
-
- it('does not render guided steps if all onboarding steps are complete', () => {
- MockApiClient.addMockResponse({
- method: 'GET',
- url: `/projects/${organization.slug}/${DetailedProjectFixture().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,
- },
- });
- // Should not find any step titles
- expect(screen.queryByText('Set Up the GitHub Integration')).not.toBeInTheDocument();
- 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 Cloud Agents')).not.toBeInTheDocument();
- });
-});
diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx
deleted file mode 100644
index 12bd76ff0f93ca..00000000000000
--- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx
+++ /dev/null
@@ -1,666 +0,0 @@
-import {Fragment, useCallback} from 'react';
-import styled from '@emotion/styled';
-import {useQuery} 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 {Alert} from '@sentry/scraps/alert';
-import {Button, LinkButton} from '@sentry/scraps/button';
-import {Flex, Stack, type FlexProps, type StackProps} from '@sentry/scraps/layout';
-import {ExternalLink, Link} from '@sentry/scraps/link';
-
-import {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 {CodingAgentProvider} from 'sentry/components/events/autofix/types';
-import {
- organizationIntegrationsCodingAgents,
- useAutofixRepos,
-} 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 type {Project} from 'sentry/types/project';
-import {FieldKey} from 'sentry/utils/fields';
-import {useDetailedProject} from 'sentry/utils/project/useDetailedProject';
-import {useUpdateProject} from 'sentry/utils/project/useUpdateProject';
-import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useHasIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useHasIssueViews';
-import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useStarredIssueViews';
-
-interface SeerNoticesProps {
- groupId: string;
- project: Project;
- hasGithubIntegration?: boolean;
-}
-
-function CustomSkipButton({...props}: Partial>) {
- const {currentStep, setCurrentStep, totalSteps} = useGuidedStepsContext();
-
- if (currentStep >= totalSteps) {
- return null;
- }
-
- const handleSkip = () => {
- setCurrentStep(currentStep + 1);
- };
-
- return (
-
- {t('Skip')}
-
- );
-}
-
-function CustomStepButtons({
- showBack,
- showNext,
- showSkip,
- onSkip,
- children,
-}: {
- showBack: boolean;
- showNext: boolean;
- showSkip: boolean;
- children?: React.ReactNode;
- onSkip?: () => void;
-}) {
- return (
-
- {showBack && }
- {showNext && }
- {showSkip && (
-
- {t('Skip for Now')}
-
- )}
- {children}
-
- );
-}
-
-export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNoticesProps) {
- const organization = useOrganization();
- const {repos} = useAutofixRepos(groupId);
- const {data, isLoading: isLoadingPreferences} = useProjectSeerPreferences(project);
- const {preference, code_mapping_repos: codeMappingRepos} = data ?? {};
- const {mutate: updateProjectSeerPreferences} = useUpdateProjectSeerPreferences(project);
- const {mutateAsync: updateProjectAutomation} = useUpdateProject(project);
- const {data: codingAgentIntegrations} = useQuery(
- organizationIntegrationsCodingAgents(organization)
- );
- const {starredViews: views} = useStarredIssueViews();
-
- const {data: projectDetails, isPending: isLoadingProject} = useDetailedProject({
- orgSlug: organization.slug,
- projectSlug: project.slug,
- });
-
- const hasIssueViews = useHasIssueViews();
- const isStarredViewAllowed = hasIssueViews;
-
- const cursorIntegration = codingAgentIntegrations?.integrations.find(
- integration => integration.provider === '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(
- repo => !repo.provider.includes('github')
- );
-
- // Onboarding conditions
- const needsGithubIntegration = !hasGithubIntegration;
- const needsRepoSelection =
- repos.length === 0 && !preference?.repositories?.length && !codeMappingRepos?.length;
- const needsAutomation =
- projectDetails !== undefined &&
- (projectDetails.autofixAutomationTuning === 'off' ||
- projectDetails.autofixAutomationTuning === undefined ||
- projectDetails.seerScannerAutomation === false);
- const needsFixabilityView =
- !views.some(view => view.query.includes(FieldKey.ISSUE_SEER_ACTIONABILITY)) &&
- isStarredViewAllowed;
-
- // Warning conditions
- const hasMultipleUnreadableRepos = unreadableRepos.length > 1;
- const hasSingleUnreadableRepo = unreadableRepos.length === 1;
-
- // 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 = [
- needsGithubIntegration,
- needsRepoSelection,
- needsAutomation,
- needsFixabilityView,
- needsCursorIntegration,
- ];
-
- const handleSetupCursorHandoff = async () => {
- if (!cursorIntegration?.id || !projectDetails) {
- return;
- }
-
- const isAutomationDisabled =
- projectDetails.seerScannerAutomation === false ||
- projectDetails.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: CodingAgentProvider.CURSOR_BACKGROUND_AGENT,
- integration_id: parseInt(cursorIntegration.id, 10),
- },
- });
- };
-
- const handleSkipCursorStep = useCallback(() => {
- setCursorStepSkipped(true);
- setStepsCollapsed(true);
- }, [setCursorStepSkipped, setStepsCollapsed]);
- const incompleteStepIndices = stepConditions
- .map((needed, idx) => (needed ? idx : null))
- .filter(idx => idx !== null);
- const firstIncompleteIdx = incompleteStepIndices[0];
- const lastIncompleteIdx = incompleteStepIndices[incompleteStepIndices.length - 1];
- const anyStepIncomplete = incompleteStepIndices.length > 0;
- const showOnboardingSteps =
- !isLoadingPreferences && !isLoadingProject && anyStepIncomplete;
- const showCollapsedSummary = showOnboardingSteps && stepsCollapsed;
- const showFullGuidedSteps = showOnboardingSteps && !stepsCollapsed;
-
- return (
-
- {/* Collapsed summary */}
- {showCollapsedSummary && (
- setStepsCollapsed(false)}>
-
-
- {t(
- 'Only %s step%s left to get the most out of Seer.',
- incompleteStepIndices.length,
- incompleteStepIndices.length === 1 ? '' : 's'
- )}
-
-
-
- )}
- {/* Full guided steps */}
- {showFullGuidedSteps && (
-
-
-
-
- Debug Faster with Seer
-
-
- {/* Step 1: GitHub Integration */}
-
-
-
-
-
- {tct(
- 'Seer is [bold:a lot better] when it has your codebase as context.',
- {
- bold: ,
- }
- )}
-
-
- {tct(
- 'Set up the [integrationLink:GitHub Integration] to allow Seer to find the most accurate root causes, solutions, and code changes for your issues.',
- {
- integrationLink: (
-
- ),
- }
- )}
-
-
- {tct(
- 'Support for other source code providers are coming soon. You can keep up with progress on these GitHub issues: [bitbucketLink:BitBucket], [gitlabLink:GitLab], and [azureDevopsLink:Azure DevOps].',
- {
- bitbucketLink: (
-
- ),
- gitlabLink: (
-
- ),
- azureDevopsLink: (
-
- ),
- }
- )}
-
-
-
-
-
-
-
-
-
- {t('Set Up Integration')}
-
-
-
-
- {/* Step 2: Repo Selection */}
-
-
-
-
-
- {t('Select the repos Seer can explore in this project.')}
-
-
- {t(
- 'You can also configure working branches and custom instructions so Seer fits your unique workflow.'
- )}
-
-
-
-
-
-
-
- setStepsCollapsed(true)}
- >
-
- {t('Configure Repos')}
-
-
-
-
- {/* Step 3: Unleash Automation */}
-
-
-
-
-
- {t(
- 'Let Seer automatically deep dive into incoming issues, so you wake up to solutions, not headaches.'
- )}
-
-
-
-
-
-
-
- setStepsCollapsed(true)}
- >
-
- {t('Enable Automation')}
-
-
-
-
- {/* Step 4: Fixability View */}
- {isStarredViewAllowed && (
-
-
-
-
-
- {t(
- 'Seer scans all your issues and highlights the ones that are likely quick to fix.'
- )}
-
-
- {t(
- 'Star the recommended issue view to keep an eye on quick debugging opportunities. You can customize the view later.'
- )}
-
-
-
-
-
-
-
- setStepsCollapsed(true)}
- >
-
-
-
- )}
-
- {/* Step 5: Cursor Integration */}
-
-
-
-
- {t('Hand Off to Cursor Cloud Agents')}
-
- }
- isCompleted={!needsCursorIntegration}
- >
-
-
-
- {cursorIntegration ? (
-
-
- {t(
- 'Enable Seer automation and set up handoff to Cursor Cloud Agents when Seer identifies a root cause.'
- )}
-
-
- {tct(
- 'During automation, Seer will trigger Cursor Cloud 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 Cloud 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('Set Seer to hand off to Cursor')}
-
- ) : (
-
- {t('Install Cursor Integration')}
-
- )}
-
-
-
-
-
-
- )}
- {/* Banners for unreadable repos */}
- {hasMultipleUnreadableRepos && (
-
- {tct("Seer can't access these repositories: [repoList].", {
- repoList: {unreadableRepos.map(repo => repo.name).join(', ')} ,
- })}
- {githubRepos.length > 0 && (
-
- {' '}
- {tct(
- 'For best performance, enable the [integrationLink:GitHub integration].',
- {
- integrationLink: (
-
- ),
- }
- )}
-
- )}
- {nonGithubRepos.length > 0 && (
- {t('Seer currently only supports GitHub repositories.')}
- )}
-
- )}
- {hasSingleUnreadableRepo && (
-
- {unreadableRepos[0]?.provider.includes('github')
- ? tct(
- "Seer can't access the [repo] repository, make sure the [integrationLink:GitHub integration] is correctly set up.",
- {
- repo: {unreadableRepos[0]?.name} ,
- integrationLink: (
-
- ),
- }
- )
- : tct(
- "Seer can't access the [repo] repository. It currently only supports GitHub repositories.",
- {repo: {unreadableRepos[0]?.name} }
- )}
-
- )}
-
- );
-}
-
-const StyledGuidedSteps = styled(GuidedSteps)`
- background: transparent;
-`;
-
-const StyledAlert = styled(Alert)`
- margin-bottom: ${p => p.theme.space.xl};
-`;
-
-function CardDescription(props: StackProps) {
- return (
-
- {props.children}
-
- );
-}
-
-const CardIllustration = styled('img')`
- width: 100%;
- max-width: 200px;
- min-width: 100px;
- height: auto;
- object-fit: contain;
- margin-bottom: -6px;
- margin-right: 10px;
-`;
-
-const CursorCardIllustration = styled(CardIllustration)`
- max-width: 160px;
-`;
-
-const CursorPluginIcon = styled('div')`
- transform: translateY(3px);
-`;
-
-function StepContentRow(props: FlexProps) {
- return (
-
- {props.children}
-
- );
-}
-
-function StepTextCol(props: StackProps) {
- return (
-
- {props.children}
-
- );
-}
-
-function StepImageCol(props: FlexProps) {
- return (
-
- {props.children}
-
- );
-}
-
-const StepsHeader = styled('h3')`
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.md};
- font-size: ${p => p.theme.font.size.xl};
- margin-bottom: ${p => p.theme.space.xs};
- margin-left: 1px;
-`;
-
-const StepsDivider = styled('hr')`
- border: none;
- border-top: 1px solid ${p => p.theme.tokens.border.primary};
- margin: ${p => p.theme.space['2xl']} 0;
-`;
-
-const CollapsedSummaryCard = styled('div')`
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.md};
- background: ${p => p.theme.colors.pink500}10;
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: 6px;
- padding: ${p => p.theme.space.md};
- margin-bottom: ${p => p.theme.space.xl};
- cursor: pointer;
- font-size: ${p => p.theme.font.size.md};
- font-weight: 500;
- color: ${p => p.theme.tokens.content.primary};
- transition: box-shadow 0.2s;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
- &:hover {
- background: ${p => p.theme.colors.pink500}20;
- }
-`;
diff --git a/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx
deleted file mode 100644
index a6478e0163e1e2..00000000000000
--- a/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture';
-import {EventFixture} from 'sentry-fixture/event';
-import {FrameFixture} from 'sentry-fixture/frame';
-import {GroupFixture} from 'sentry-fixture/group';
-import {OrganizationFixture} from 'sentry-fixture/organization';
-import {DetailedProjectFixture} from 'sentry-fixture/project';
-
-import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
-
-import {EntryType} from 'sentry/types/event';
-import {IssueCategory, type Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {SeerSection} from 'sentry/views/issueDetails/streamline/sidebar/seerSection';
-
-jest.mock('sentry/utils/regions');
-
-describe('SeerSection', () => {
- const mockEvent = EventFixture({
- entries: [
- {
- type: EntryType.EXCEPTION,
- data: {values: [{stacktrace: {frames: [FrameFixture()]}}]},
- },
- ],
- });
- let mockGroup!: ReturnType;
- const mockProject = DetailedProjectFixture();
- const organization = OrganizationFixture({
- hideAiFeatures: false,
- features: ['gen-ai-features'],
- });
-
- beforeEach(() => {
- mockGroup = GroupFixture();
- MockApiClient.clearMockResponses();
-
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
-
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`,
- body: {steps: []},
- });
- });
-
- it('renders summary when AI features are enabled and data is available', async () => {
- const mockWhatHappened = 'This is a test what happened';
- const mockTrace = 'This is a test trace';
- const mockCause = 'This is a test cause';
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: {possibleCause: mockCause, whatsWrong: mockWhatHappened, trace: mockTrace},
- });
-
- render( , {
- organization,
- });
-
- await waitFor(() => {
- expect(screen.getByText(mockCause)).toBeInTheDocument();
- });
- expect(screen.queryByText(mockWhatHappened)).not.toBeInTheDocument();
- expect(screen.queryByText(mockTrace)).not.toBeInTheDocument();
- });
-
- it('renders resources section when AI features are disabled', () => {
- const customOrganization = OrganizationFixture({
- hideAiFeatures: true,
- features: ['gen-ai-features'],
- });
-
- const disabledIssueSummaryGroup: Group = {
- ...mockGroup,
- issueCategory: IssueCategory.PERFORMANCE,
- title: 'ChunkLoadError',
- platform: 'javascript',
- };
-
- const javascriptProject: Project = {...mockProject, platform: 'javascript'};
-
- render(
- ,
- {organization: customOrganization}
- );
-
- expect(screen.getByText('Resources')).toBeInTheDocument();
-
- expect(
- screen.getByRole('button', {name: 'How to fix ChunkLoadErrors'})
- ).toBeInTheDocument();
- });
-
- describe('Seer button text', () => {
- it('shows issue summary and "Fix with Seer" when consent flow is removed and there is no autofix quota', async () => {
- const orgWithConsentFlowRemoved = OrganizationFixture({
- hideAiFeatures: false,
- features: ['gen-ai-features'],
- });
-
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- billing: {hasAutofixQuota: false},
- }),
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: {whatsWrong: 'Test summary', possibleCause: 'You did it wrong'},
- });
-
- render( , {
- organization: orgWithConsentFlowRemoved,
- });
-
- await waitFor(() => {
- expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument();
- });
-
- expect(screen.getByText(/initial guess/i)).toBeInTheDocument();
- // Should show issue summary
- expect(await screen.findByText('You did it wrong')).toBeInTheDocument();
- expect(screen.getByRole('button', {name: 'Fix with Seer'})).toBeInTheDocument();
- });
-
- it('shows "Find Root Cause" even when autofix needs setup', async () => {
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: false, reason: null},
- githubWriteIntegration: {ok: false, repos: []},
- }),
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: {whatsWrong: 'Test summary'},
- });
-
- render( , {
- organization,
- });
-
- await waitFor(() => {
- expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument();
- });
-
- expect(screen.getByRole('button', {name: 'Find Root Cause'})).toBeInTheDocument();
- });
-
- it('shows "Find Root Cause" when autofix is available', async () => {
- // Mock successful autofix setup but disable resources
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
-
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`,
- method: 'POST',
- body: {whatsWrong: 'Test summary'},
- });
-
- render( , {
- organization,
- });
-
- await waitFor(() => {
- expect(screen.getByRole('button', {name: 'Find Root Cause'})).toBeInTheDocument();
- });
- });
-
- it('shows resource link when available', () => {
- const disabledIssueSummaryGroup: Group = {
- ...mockGroup,
- issueCategory: IssueCategory.PERFORMANCE,
- title: 'ChunkLoadError',
- platform: 'javascript',
- };
-
- const javascriptProject: Project = {...mockProject, platform: 'javascript'};
-
- // Mock config with autofix disabled
- MockApiClient.addMockResponse({
- url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`,
- body: AutofixSetupFixture({
- integration: {ok: true, reason: null},
- githubWriteIntegration: {ok: true, repos: []},
- }),
- });
-
- render(
- ,
- {organization}
- );
-
- expect(
- screen.getByRole('button', {name: 'How to fix ChunkLoadErrors'})
- ).toBeInTheDocument();
- });
- });
-});
diff --git a/static/app/views/issueDetails/streamline/sidebar/seerSection.tsx b/static/app/views/issueDetails/streamline/sidebar/seerSection.tsx
deleted file mode 100644
index f405b1d327cd35..00000000000000
--- a/static/app/views/issueDetails/streamline/sidebar/seerSection.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-import styled from '@emotion/styled';
-
-import autofixSetupImg from 'sentry-images/features/autofix-setup.svg';
-
-import {Stack} from '@sentry/scraps/layout';
-import {Text} from '@sentry/scraps/text';
-
-import {GroupSummary} from 'sentry/components/group/groupSummary';
-import {GroupSummaryWithAutofix} from 'sentry/components/group/groupSummaryWithAutofix';
-import {Placeholder} from 'sentry/components/placeholder';
-import {IconSeer} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
-import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
-import {SidebarFoldSection} from 'sentry/views/issueDetails/streamline/foldSection';
-import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig';
-import {Resources} from 'sentry/views/issueDetails/streamline/sidebar/resources';
-
-import {SeerSectionCtaButton} from './seerSectionCtaButton';
-
-function SeerWelcomeEntrypoint() {
- return (
-
-
- {t('Meet Seer, the AI debugging agent.')}
-
-
-
-
-
-
- {t(
- 'Find the root cause of the issue, and even open a PR to fix it, in minutes.'
- )}
-
-
-
- );
-}
-
-function SeerSectionContent({
- group,
- project,
- event,
-}: {
- event: Event | undefined;
- group: Group;
- project: Project;
-}) {
- const aiConfig = useAiConfig(group, project);
-
- if (!event && !aiConfig.isAutofixSetupLoading) {
- return {t('No event to analyze.')} ;
- }
- if (!event || aiConfig.isAutofixSetupLoading) {
- return ;
- }
-
- if (aiConfig.hasSummary) {
- if (aiConfig.hasAutofix) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
- }
-
- return null;
-}
-
-export function SeerSection({
- group,
- project,
- event,
-}: {
- event: Event | undefined;
- group: Group;
- project: Project;
-}) {
- const aiConfig = useAiConfig(group, project);
- const issueTypeConfig = getConfigForIssueType(group, project);
-
- const issueTypeDoesntHaveSeer =
- !issueTypeConfig.autofix && !issueTypeConfig.issueSummary;
-
- if (
- (!aiConfig.areAiFeaturesAllowed || issueTypeDoesntHaveSeer) &&
- !aiConfig.hasResources
- ) {
- return null;
- }
-
- const showCtaButton =
- aiConfig.orgNeedsGenAiAcknowledgement ||
- aiConfig.hasAutofix ||
- (aiConfig.hasSummary && aiConfig.hasResources);
-
- const onlyHasResources =
- issueTypeDoesntHaveSeer ||
- (!aiConfig.orgNeedsGenAiAcknowledgement &&
- !aiConfig.hasSummary &&
- !aiConfig.hasAutofix &&
- aiConfig.hasResources);
-
- const titleComponent = onlyHasResources ? (
- {t('Resources')}
- ) : (
-
- {t('Seer Autofix')}
-
-
- );
-
- // Determine what content to show in the section body
- const renderSectionContent = () => {
- // Welcome entrypoint for orgs that need consent
- if (aiConfig.orgNeedsGenAiAcknowledgement && !aiConfig.isAutofixSetupLoading) {
- return ;
- }
-
- // Default: show group summary
- if (aiConfig.hasAutofix || aiConfig.hasSummary) {
- return ;
- }
-
- // Resources only
- if (issueTypeConfig.resources) {
- return (
-
-
-
-
-
- );
- }
-
- return null;
- };
-
- return (
-
-
- {renderSectionContent()}
- {event && showCtaButton && (
-
- )}
-
-
- );
-}
-
-const Summary = styled('div')`
- margin-bottom: ${p => p.theme.space.xs};
- position: relative;
-`;
-
-const ResourcesWrapper = styled('div')`
- position: relative;
- margin-bottom: ${p => p.theme.space.md};
-`;
-
-const ResourcesContent = styled('div')`
- position: relative;
- padding-bottom: ${p => p.theme.space.xl};
-`;
-
-const HeaderContainer = styled('div')`
- font-size: ${p => p.theme.font.size.md};
- display: flex;
- align-items: center;
- gap: ${p => p.theme.space.xs};
-`;
-
-const StyledP = styled('p')`
- margin-bottom: ${p => p.theme.space.md};
-`;
-
-const WelcomeContainer = styled('div')`
- margin-bottom: ${p => p.theme.space.lg};
-`;
-
-const WelcomeImageContainer = styled('div')`
- margin-bottom: ${p => p.theme.space.lg};
- margin-top: ${p => p.theme.space.lg};
-
- img {
- max-width: 100%;
- height: auto;
- }
-`;
diff --git a/static/app/views/issueDetails/streamline/sidebar/seerSectionCtaButton.tsx b/static/app/views/issueDetails/streamline/sidebar/seerSectionCtaButton.tsx
deleted file mode 100644
index 1b46d40de942a1..00000000000000
--- a/static/app/views/issueDetails/streamline/sidebar/seerSectionCtaButton.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-import {useEffect, useRef} from 'react';
-import styled from '@emotion/styled';
-// eslint-disable-next-line no-restricted-imports
-import color from 'color';
-
-import {LinkButton} from '@sentry/scraps/button';
-import {Flex} from '@sentry/scraps/layout';
-
-import {addSuccessMessage} from 'sentry/actionCreators/indicator';
-import {
- AutofixStatus,
- AutofixStepType,
- type AutofixStep,
-} from 'sentry/components/events/autofix/types';
-import {useAiAutofix, useAutofixData} from 'sentry/components/events/autofix/useAutofix';
-import {
- getAutofixRunExists,
- getCodeChangesDescription,
- getRootCauseDescription,
- getSolutionDescription,
- hasPullRequest,
-} from 'sentry/components/events/autofix/utils';
-import {useGroupSummaryData} from 'sentry/components/group/groupSummary';
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
-import {Placeholder} from 'sentry/components/placeholder';
-import {IconChevron} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {useLocation} from 'sentry/utils/useLocation';
-import type {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig';
-import {useOpenSeerDrawer} from 'sentry/views/issueDetails/streamline/sidebar/seerDrawer';
-
-interface Props {
- aiConfig: ReturnType;
- event: Event;
- group: Group;
- hasStreamlinedUI: boolean;
- project: Project;
-}
-
-export function SeerSectionCtaButton({
- aiConfig,
- event,
- group,
- project,
- hasStreamlinedUI,
-}: Props) {
- const location = useLocation();
- const seerLink = {
- pathname: location.pathname,
- query: {
- ...location.query,
- seerDrawer: true,
- },
- };
-
- const openButtonRef = useRef(null);
- const isDrawerOpenRef = useRef(false);
-
- const {isPending: isAutofixPending} = useAutofixData({groupId: group.id});
- const {autofixData} = useAiAutofix(group, event, {
- isSidebar: !isDrawerOpenRef.current,
- pollInterval: 1500,
- });
-
- const {data: summaryData, isPending: isSummaryPending} = useGroupSummaryData(group);
-
- const {openSeerDrawer} = useOpenSeerDrawer({
- group,
- project,
- event,
- buttonRef: openButtonRef,
- });
-
- // Keep isDrawerOpenRef in sync with the Seer drawer state (based on URL query)
- useEffect(() => {
- isDrawerOpenRef.current = !!location.query.seerDrawer;
- }, [location.query.seerDrawer]);
-
- // Keep track of previous steps to detect state transitions and notify the user
- const prevStepsRef = useRef(null);
- const prevRunIdRef = useRef(null);
- useEffect(() => {
- if (isDrawerOpenRef.current) {
- return;
- }
-
- if (!autofixData?.steps || !prevStepsRef.current) {
- prevStepsRef.current = autofixData?.steps ?? null;
- prevRunIdRef.current = autofixData?.run_id ?? null;
- return;
- }
-
- const prevSteps = prevStepsRef.current;
- const currentSteps = autofixData.steps;
-
- // Don't show notifications if the run_id has changed
- if (
- prevStepsRef.current !== currentSteps &&
- autofixData?.run_id !== prevRunIdRef.current
- ) {
- prevStepsRef.current = currentSteps;
- prevRunIdRef.current = autofixData?.run_id;
- return;
- }
-
- // Find the most recent step
- const processingStep = currentSteps.findLast(
- step => step.type === AutofixStepType.DEFAULT
- );
-
- if (processingStep?.status === AutofixStatus.COMPLETED) {
- // Check if this is a new completion (wasn't completed in previous state)
- const prevProcessingStep = prevSteps.findLast(
- step => step.type === AutofixStepType.DEFAULT
- );
- if (prevProcessingStep && prevProcessingStep.status !== AutofixStatus.COMPLETED) {
- if (currentSteps.some(step => step.type === AutofixStepType.CHANGES)) {
- addSuccessMessage(t('Seer has finished coding.'));
- } else if (currentSteps.some(step => step.type === AutofixStepType.SOLUTION)) {
- addSuccessMessage(t('Seer has found a solution.'));
- } else if (
- currentSteps.some(step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS)
- ) {
- addSuccessMessage(t('Seer has found the root cause.'));
- }
- }
- }
-
- prevStepsRef.current = autofixData?.steps ?? null;
- prevRunIdRef.current = autofixData?.run_id ?? null;
- }, [autofixData?.steps, autofixData?.run_id]);
-
- // Update drawer state when opening
- const handleOpenDrawer = () => {
- openSeerDrawer();
- };
-
- const showCtaButton =
- aiConfig.orgNeedsGenAiAcknowledgement ||
- aiConfig.hasAutofix ||
- (aiConfig.hasSummary && aiConfig.hasResources);
- const isButtonLoading =
- aiConfig.isAutofixSetupLoading || (isAutofixPending && getAutofixRunExists(group));
-
- const lastStep = autofixData?.steps?.[autofixData.steps.length - 1];
- const isAutofixInProgress = lastStep?.status === AutofixStatus.PROCESSING;
- const isAutofixCompleted = lastStep?.status === AutofixStatus.COMPLETED;
- const isAutofixWaitingForUser =
- autofixData?.status === AutofixStatus.WAITING_FOR_USER_RESPONSE;
-
- const hasStepType = (type: AutofixStepType) =>
- autofixData?.steps?.some(step => step.type === type);
-
- const rootCauseDescription = autofixData ? getRootCauseDescription(autofixData) : null;
- const solutionDescription = autofixData ? getSolutionDescription(autofixData) : null;
- const codeChangesDescription = autofixData
- ? getCodeChangesDescription(autofixData)
- : null;
- const hasPr = hasPullRequest(autofixData);
-
- const getButtonText = () => {
- if (!aiConfig.hasAutofix) {
- return t('Open Resources');
- }
-
- if (
- (aiConfig.orgNeedsGenAiAcknowledgement || !aiConfig.hasAutofixQuota) &&
- !aiConfig.isAutofixSetupLoading
- ) {
- return t('Fix with Seer');
- }
-
- if (!lastStep) {
- return t('Find Root Cause');
- }
-
- if (isAutofixWaitingForUser) {
- return t('Waiting for Your Input');
- }
-
- if (isAutofixInProgress) {
- if (!hasStepType(AutofixStepType.ROOT_CAUSE_ANALYSIS)) {
- return t('Finding Root Cause');
- }
- if (!hasStepType(AutofixStepType.SOLUTION)) {
- return t('Finding Solution');
- }
- if (!hasStepType(AutofixStepType.CHANGES)) {
- return t('Writing Code');
- }
- }
-
- if (isAutofixCompleted) {
- if (lastStep.type === AutofixStepType.SOLUTION) {
- return t('Fix with Seer');
- }
- return t('Open Autofix');
- }
-
- return t('Fix with Seer');
- };
-
- if (isButtonLoading) {
- return ;
- }
-
- if (!showCtaButton) {
- return null;
- }
-
- return (
-
- {getButtonText()}
-
- {isAutofixInProgress ? (
-
- ) : (
-
- )}
-
-
- );
-}
-
-const StyledButton = styled(LinkButton)`
- margin-top: ${p => p.theme.space.md};
- width: 100%;
-`;
-
-const StyledLoadingIndicator = styled(LoadingIndicator)`
- position: relative;
- margin-left: ${p => p.theme.space.md};
-
- .loading-indicator {
- border-color: ${p => color(p.theme.colors.white).alpha(0.35).string()};
- border-left-color: ${p => p.theme.colors.white};
- }
-`;
-
-const ButtonPlaceholder = styled(Placeholder)`
- width: 100%;
- height: 38px;
- border-radius: ${p => p.theme.radius.md};
- margin-top: ${p => p.theme.space.md};
-`;
diff --git a/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx
index 98754fa1faef92..5c150d059959fe 100644
--- a/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx
+++ b/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx
@@ -67,6 +67,16 @@ describe('StreamlinedSidebar', () => {
body: {steps: []},
});
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/seer/onboarding-check/`,
+ body: {
+ hasSupportedScmIntegration: true,
+ isAutofixEnabled: true,
+ isCodeReviewEnabled: true,
+ isSeerConfigured: true,
+ },
+ });
+
mockFirstLastRelease = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues/${group.id}/first-last-release/`,
method: 'GET',
diff --git a/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx b/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx
index 75b4a7aef1e3b6..63e08407f08588 100644
--- a/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx
+++ b/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx
@@ -28,7 +28,6 @@ import {ExternalIssueSidebarList} from 'sentry/views/issueDetails/streamline/sid
import {FirstLastSeenSection} from 'sentry/views/issueDetails/streamline/sidebar/firstLastSeenSection';
import {MergedIssuesSidebarSection} from 'sentry/views/issueDetails/streamline/sidebar/mergedSidebarSection';
import {PeopleSection} from 'sentry/views/issueDetails/streamline/sidebar/peopleSection';
-import {SeerSection} from 'sentry/views/issueDetails/streamline/sidebar/seerSection';
import {SimilarIssuesSidebarSection} from 'sentry/views/issueDetails/streamline/sidebar/similarIssuesSidebarSection';
import {SupergroupSection} from 'sentry/views/issueDetails/streamline/sidebar/supergroupSection';
@@ -96,11 +95,7 @@ export function StreamlinedSidebar({group, event, project}: Props) {
{showSeerSection && (
- {organization.features.includes('autofix-on-explorer') ? (
-
- ) : (
-
- )}
+
)}
{event && (
diff --git a/static/app/views/issueList/pages/autofix/recentlyRun.tsx b/static/app/views/issueList/pages/autofix/recentlyRun.tsx
index 1f538bacf8f303..e6f9e31d9db119 100644
--- a/static/app/views/issueList/pages/autofix/recentlyRun.tsx
+++ b/static/app/views/issueList/pages/autofix/recentlyRun.tsx
@@ -1,4 +1,3 @@
-import Feature from 'sentry/components/acl/feature';
import {NoProjectMessage} from 'sentry/components/noProjectMessage';
import {PageFiltersContainer} from 'sentry/components/pageFilters/container';
import {t} from 'sentry/locale';
@@ -13,18 +12,16 @@ export default function AutofixRecentlyRunPage() {
const organization = useOrganization();
return (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
index b232114def5e9a..881c86cb3fef15 100644
--- a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
+++ b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
@@ -65,24 +65,20 @@ export function IssuesSecondaryNavigation() {
- {organization.features.includes('autofix-on-explorer') && (
-
-
-
-
-
-
- {t('Recently Run')}
-
-
-
-
-
- )}
+
+
+
+
+
+ {t('Recently Run')}
+
+
+
+
diff --git a/static/gsApp/components/ai/aiSetupConfiguration.tsx b/static/gsApp/components/ai/aiSetupConfiguration.tsx
deleted file mode 100644
index cd86b4dd685707..00000000000000
--- a/static/gsApp/components/ai/aiSetupConfiguration.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-import {Fragment, type CSSProperties} from 'react';
-import styled from '@emotion/styled';
-
-import seerConfigCheckImg from 'sentry-images/spot/seer-config-check.svg';
-import seerConfigConnectImg from 'sentry-images/spot/seer-config-connect-2.svg';
-import seerConfigMainImg from 'sentry-images/spot/seer-config-main.svg';
-import seerConfigShipImg from 'sentry-images/spot/seer-config-ship.svg';
-
-import {Alert} from '@sentry/scraps/alert';
-import {LinkButton} from '@sentry/scraps/button';
-import {Image as ImageBase} from '@sentry/scraps/image';
-import {Stack} from '@sentry/scraps/layout';
-import {Heading, Text} from '@sentry/scraps/text';
-
-import {
- AutofixConfigureSeer,
- ImageContainer,
- SeerFeaturesPanel,
-} from 'sentry/components/events/autofix/v2/autofixConfigureSeer';
-import {Panel} from 'sentry/components/panels/panel';
-import {IconUpgrade} from 'sentry/icons/iconUpgrade';
-import {t} from 'sentry/locale';
-import type {Event} from 'sentry/types/event';
-import type {Group} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig';
-
-import {useSubscription} from 'getsentry/hooks/useSubscription';
-import {hasAccessToSubscriptionOverview} from 'getsentry/utils/billing';
-
-interface AiSetupConfigurationProps {
- event: Event;
- group: Group;
- project: Project;
-}
-
-export function AiSetupConfiguration({event, group, project}: AiSetupConfigurationProps) {
- const organization = useOrganization();
- const aiConfig = useAiConfig(group, project);
- if (organization.features.includes('seer-billing') && !aiConfig.hasAutofixQuota) {
- return ;
- }
- return ;
-}
-
-function AutofixConfigureQuota() {
- const organization = useOrganization();
- const subscription = useSubscription();
- return (
-
-
-
-
-
-
- {t('Meet Seer')}
-
-
- {t(
- 'Debug faster with Seer. It will connect to your repositories, scan all of your issues, highlight the ones that are quick to fix, and propose solutions. You can even integrate with your favorite coding agent to implement changes in code. '
- )}
-
-
- {hasAccessToSubscriptionOverview(subscription, organization) ? (
- }
- >
- {t('Try Out Seer Now')}
-
- ) : (
-
- {t(
- 'You need to be a billing member to try out Seer. Please contact your organization owner to upgrade your plan.'
- )}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t('Root Cause Analysis & Code Fixes')}
-
- {t(
- 'Seer analyzes the root cause of an issue and propose fixes ready to merge as draft PRs.'
- )}
-
-
-
-
-
-
-
-
-
-
- {t('AI Code Review')}
- {t('Seer catches bugs in your PRs before you ship them.')}
-
-
-
-
-
-
- );
-}
-
-const HeroImage = styled(ImageBase)`
- position: absolute;
- z-index: ${p => p.theme.zIndex.initial};
- min-width: 150%;
- left: 50%;
- transform: translateX(-47%) translateY(-35%);
-`;
-
-const Image = styled(ImageBase)<{alignSelf?: CSSProperties['alignSelf']}>`
- align-self: ${p => p.alignSelf ?? 'center'};
-`;
-
-const MeetSeerPanel = styled(Panel)`
- margin-top: 32%;
-`;
diff --git a/static/gsApp/registerOverrides.tsx b/static/gsApp/registerOverrides.tsx
index 431cc1cd48818a..e746420eac73c5 100644
--- a/static/gsApp/registerOverrides.tsx
+++ b/static/gsApp/registerOverrides.tsx
@@ -7,7 +7,6 @@ import type {Overrides} from 'sentry/types/overrides';
import type {OrganizationStatsProps} from 'sentry/views/organizationStats';
import {AiConfigureSeerQuotaSidebar} from 'getsentry/components/ai/aiConfigureSeerQuotaSidebar';
-import {AiSetupConfiguration} from 'getsentry/components/ai/aiSetupConfiguration';
import {AiSetupDataConsent} from 'getsentry/components/ai/AiSetupDataConsent';
import CronsBillingBanner from 'getsentry/components/crons/cronsBillingBanner';
import {DashboardBanner} from 'getsentry/components/dashboardBanner';
@@ -236,7 +235,6 @@ const GETSENTRY_OVERRIDES: Partial = {
'component:insights-date-range-query-limit-footer': () =>
InsightsDateRangeQueryLimitFooter,
'component:ai-configure-seer-quota-sidebar': () => AiConfigureSeerQuotaSidebar,
- 'component:ai-setup-configuration': () => AiSetupConfiguration,
'component:ai-setup-data-consent': () => AiSetupDataConsent,
'component:codecov-integration-settings-link': () => CodecovSettingsLink,
'component:continuous-profiling-billing-requirement-banner': () =>
diff --git a/tests/js/fixtures/autofixCodebaseChangeData.ts b/tests/js/fixtures/autofixCodebaseChangeData.ts
deleted file mode 100644
index c37cce7dd29c13..00000000000000
--- a/tests/js/fixtures/autofixCodebaseChangeData.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import {AutofixDiffFilePatch} from 'sentry-fixture/autofixDiffFilePatch';
-
-import type {AutofixCodebaseChange} from 'sentry/components/events/autofix/types';
-
-export function AutofixCodebaseChangeData(
- params: Partial = {}
-): AutofixCodebaseChange {
- return {
- description: '',
- diff: [AutofixDiffFilePatch()],
- repo_external_id: '100',
- repo_name: 'owner/hello-world',
- title: 'Add error handling',
- pull_request: {
- pr_number: 200,
- pr_url: 'https://github.com/owner/hello-world/pull/200',
- },
- ...params,
- };
-}
diff --git a/tests/js/fixtures/autofixData.ts b/tests/js/fixtures/autofixData.ts
deleted file mode 100644
index aab7f33610ceb4..00000000000000
--- a/tests/js/fixtures/autofixData.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import type {AutofixData} from 'sentry/components/events/autofix/types';
-import {AutofixStatus} from 'sentry/components/events/autofix/types';
-
-export function AutofixDataFixture(params: Partial): AutofixData {
- return {
- run_id: '1',
- status: AutofixStatus.PROCESSING,
- completed_at: '',
- last_triggered_at: '',
- steps: [],
- request: {
- repos: [],
- },
- codebases: {},
- ...params,
- };
-}
diff --git a/tests/js/fixtures/autofixDiffFilePatch.ts b/tests/js/fixtures/autofixDiffFilePatch.ts
deleted file mode 100644
index 71b92ed516c9e7..00000000000000
--- a/tests/js/fixtures/autofixDiffFilePatch.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import type {FilePatch} from 'sentry/components/events/autofix/types';
-import {DiffFileType, DiffLineType} from 'sentry/components/events/autofix/types';
-
-export function AutofixDiffFilePatch(params: Partial = {}): FilePatch {
- return {
- added: 1,
- path: 'src/sentry/processing/backpressure/memory.py',
- removed: 1,
- source_file: 'src/sentry/processing/backpressure/memory.py',
- target_file: 'src/sentry/processing/backpressure/memory.py',
- type: DiffFileType.MODIFIED,
- hunks: [
- {
- lines: [
- {
- line_type: DiffLineType.CONTEXT,
- diff_line_no: 6,
- source_line_no: 47,
- target_line_no: 47,
- value: ' # or alternatively: `used_memory_rss`?\n',
- },
- {
- line_type: DiffLineType.CONTEXT,
- diff_line_no: 7,
- source_line_no: 48,
- target_line_no: 48,
- value: ' memory_used = info.get("used_memory", 0)\n',
- },
- {
- line_type: DiffLineType.CONTEXT,
- diff_line_no: 8,
- source_line_no: 49,
- target_line_no: 49,
- value: ' # `maxmemory` might be 0 in development\n',
- },
- {
- line_type: DiffLineType.REMOVED,
- diff_line_no: 9,
- source_line_no: 50,
- target_line_no: null,
- value:
- ' memory_available = info.get("maxmemory", 0) or info["total_system_memory"]\n',
- },
- {
- line_type: DiffLineType.ADDED,
- diff_line_no: 10,
- source_line_no: null,
- target_line_no: 50,
- value:
- ' memory_available = info.get("maxmemory", 0) or info.get("total_system_memory", 0)\n',
- },
- {
- line_type: DiffLineType.CONTEXT,
- diff_line_no: 11,
- source_line_no: 51,
- target_line_no: 51,
- value: '\n',
- },
- {
- line_type: DiffLineType.CONTEXT,
- diff_line_no: 12,
- source_line_no: 52,
- target_line_no: 52,
- value: ' return ServiceMemory(node_id, memory_used, memory_available)\n',
- },
- {
- line_type: DiffLineType.CONTEXT,
- diff_line_no: 13,
- source_line_no: 53,
- target_line_no: 53,
- value: '\n',
- },
- ],
- section_header:
- 'def get_memory_usage(node_id: str, info: Mapping[str, Any]) -> ServiceMemory:',
- source_length: 7,
- source_start: 47,
- target_length: 7,
- target_start: 47,
- },
- ],
- ...params,
- };
-}
diff --git a/tests/js/fixtures/autofixProgressItem.ts b/tests/js/fixtures/autofixProgressItem.ts
deleted file mode 100644
index 8544ae9c04ed85..00000000000000
--- a/tests/js/fixtures/autofixProgressItem.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type {AutofixProgressItem} from 'sentry/components/events/autofix/types';
-
-export function AutofixProgressItemFixture(
- params: Partial
-): AutofixProgressItem {
- return {
- message: 'Example log message',
- timestamp: '2024-01-01T00:00:00',
- type: 'INFO',
- data: null,
- ...params,
- };
-}
diff --git a/tests/js/fixtures/autofixRootCauseData.ts b/tests/js/fixtures/autofixRootCauseData.ts
deleted file mode 100644
index bacaec4a95176e..00000000000000
--- a/tests/js/fixtures/autofixRootCauseData.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type {AutofixRootCauseData} from 'sentry/components/events/autofix/types';
-
-export function AutofixRootCauseData(
- params: Partial = {}
-): AutofixRootCauseData {
- return {
- id: '100',
- root_cause_reproduction: [
- {
- code_snippet_and_analysis:
- 'This is the code snippet and analysis of a root cause.',
- relevant_code_file: {
- file_path: 'src/file.py',
- repo_name: 'owner/repo',
- },
- timeline_item_type: 'internal_code',
- title: 'This is the title of a root cause.',
- is_most_important_event: true,
- },
- ],
- ...params,
- };
-}
diff --git a/tests/js/fixtures/autofixStep.ts b/tests/js/fixtures/autofixStep.ts
deleted file mode 100644
index 5b0b4545dcc1f3..00000000000000
--- a/tests/js/fixtures/autofixStep.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type {
- AutofixDefaultStep,
- AutofixStep,
-} from 'sentry/components/events/autofix/types';
-import {AutofixStepType} from 'sentry/components/events/autofix/types';
-
-export function AutofixStepFixture(params: Partial = {}): AutofixStep {
- return {
- type: AutofixStepType.DEFAULT,
- id: '1',
- index: 1,
- title: 'I am processing',
- status: 'PROCESSING',
- progress: [],
- insights: [],
- ...params,
- } as AutofixDefaultStep;
-}
diff --git a/tests/sentry/seer/agent/test_agent_client.py b/tests/sentry/seer/agent/test_agent_client.py
index 7319535b815498..1c479457fe59de 100644
--- a/tests/sentry/seer/agent/test_agent_client.py
+++ b/tests/sentry/seer/agent/test_agent_client.py
@@ -75,6 +75,8 @@ def test_start_run_basic(self, mock_collect_context, mock_post, mock_access):
assert run_id == 123
mock_collect_context.assert_called_once_with(self.user, self.organization, request=None)
assert mock_post.called
+ body = mock_post.call_args[0][0]
+ assert "enable_frontend_code_search" not in body
@patch("sentry.seer.agent.client.has_seer_access_with_detail")
@patch("sentry.seer.agent.client.make_agent_chat_request")
@@ -139,12 +141,14 @@ def test_start_run_with_categories(self, mock_collect_context, mock_post, mock_a
client = SeerAgentClient(
self.organization, self.user, category_key="bug-fixer", category_value="issue-123"
)
- run_id = client.start_run("Fix bug")
+ with self.feature("organizations:seer-agent-source-code-search"):
+ run_id = client.start_run("Fix bug")
assert run_id == 999
body = mock_post.call_args[0][0]
assert body["category_key"] == "bug-fixer"
assert body["category_value"] == "issue-123"
+ assert body["enable_frontend_code_search"] is True
@patch("sentry.seer.agent.client.has_seer_access_with_detail")
def test_init_category_key_only_raises_error(self, mock_access):
@@ -274,6 +278,8 @@ def test_continue_run_basic(self, mock_post, mock_access):
assert run_id == 456
assert mock_post.called
+ body = mock_post.call_args[0][0]
+ assert "enable_frontend_code_search" not in body
@patch("sentry.seer.agent.client.has_seer_access_with_detail")
@patch("sentry.seer.agent.client.make_agent_chat_request")
@@ -286,11 +292,14 @@ def test_continue_run_with_all_params(self, mock_post, mock_access):
mock_post.return_value = mock_response
client = SeerAgentClient(self.organization, self.user)
- run_id = client.continue_run(789, "Follow up", insert_index=2, on_page_context="context")
+ with self.feature("organizations:seer-agent-source-code-search"):
+ run_id = client.continue_run(
+ 789, "Follow up", insert_index=2, on_page_context="context"
+ )
assert run_id == 789
- call_args = mock_post.call_args
- assert call_args is not None
+ body = mock_post.call_args[0][0]
+ assert body["enable_frontend_code_search"] is True
@patch("sentry.seer.agent.client.has_seer_access_with_detail")
@patch("sentry.seer.agent.client.make_agent_chat_request")
diff --git a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py
index 6a02f7039dc9ca..b9f6434c62fd60 100644
--- a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py
+++ b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py
@@ -63,6 +63,16 @@ def test_org_level_method_get_organization_project_ids(self) -> None:
project_ids = [p["id"] for p in response.data["projects"]]
assert self.project.id in project_ids
+ @with_feature("organizations:seer-public-rpc")
+ def test_org_level_method_get_organization_features(self) -> None:
+ """Test that get_organization_features returns the features key"""
+ path = self._get_path("get_organization_features")
+ response = self.client.post(path, data={"args": {}}, format="json")
+
+ assert response.status_code == 200
+ assert "features" in response.data
+ assert isinstance(response.data["features"], list)
+
@with_feature("organizations:seer-public-rpc")
def test_org_level_method_get_dsn(self) -> None:
project = self.create_project(organization=self.organization, slug="wordcraft")
diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py
index 5ee70fbad72fad..f56be2ee5ceb51 100644
--- a/tests/sentry/seer/endpoints/test_seer_rpc.py
+++ b/tests/sentry/seer/endpoints/test_seer_rpc.py
@@ -25,6 +25,7 @@
generate_request_signature,
get_attributes_for_span,
get_github_enterprise_integration_config,
+ get_organization_features,
get_project_preferences,
get_repo_installation_id,
has_repo_code_mappings,
@@ -69,6 +70,17 @@ def test_404(self) -> None:
)
assert response.status_code == 404
+ def test_get_organization_features_registered_on_internal_rpc(self) -> None:
+ org = self.create_organization()
+ path = self._get_path("get_organization_features")
+ data: dict[str, Any] = {"args": {"org_id": org.id}, "meta": {}}
+ response = self.client.post(
+ path, data=data, HTTP_AUTHORIZATION=self.auth_header(path, data)
+ )
+ assert response.status_code == 200
+ assert "features" in response.data
+ assert isinstance(response.data["features"], list)
+
def test_snuba_rate_limit_returns_429(self) -> None:
"""Test that SnubaRPCRateLimitExceeded returns 429 to Seer for retry."""
path = self._get_path("get_trace_waterfall")
@@ -1616,6 +1628,62 @@ def test_bulk_get_project_preferences_returns_empty_for_no_projects(self) -> Non
assert result == {}
+# Two real api_expose=True flags used as a controlled feature set for
+# get_organization_features tests. Mocking features.all to this subset keeps
+# each test deterministic instead of iterating all 100+ registered flags.
+_ORG_FEATURES_TEST_SET = {
+ "organizations:seer-agent-source-code-search": object(),
+ "organizations:seer-explorer-chat-coding": object(),
+}
+
+
+class TestGetOrganizationFeatures(APITestCase):
+ def setUp(self) -> None:
+ super().setUp()
+ self.organization = self.create_organization(owner=self.user)
+
+ @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET)
+ def test_returns_active_flags_without_prefix(self, _mock_all: object) -> None:
+ with self.feature("organizations:seer-agent-source-code-search"):
+ result = get_organization_features(org_id=self.organization.id)
+ assert result == {"features": ["seer-agent-source-code-search"]}
+
+ @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET)
+ def test_excludes_inactive_flags(self, _mock_all: object) -> None:
+ result = get_organization_features(org_id=self.organization.id)
+ assert result == {"features": []}
+
+ @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET)
+ def test_returns_sorted_list(self, _mock_all: object) -> None:
+ with self.feature(
+ {
+ "organizations:seer-agent-source-code-search": True,
+ "organizations:seer-explorer-chat-coding": True,
+ }
+ ):
+ result = get_organization_features(org_id=self.organization.id)
+ # "seer-agent-..." < "seer-explorer-..." alphabetically
+ assert result == {
+ "features": ["seer-agent-source-code-search", "seer-explorer-chat-coding"]
+ }
+
+ def test_org_not_found_returns_empty(self) -> None:
+ result = get_organization_features(org_id=0)
+ assert result == {"features": []}
+
+ @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET)
+ def test_uses_user_as_actor_when_provided(self, _mock_all: object) -> None:
+ with self.feature("organizations:seer-agent-source-code-search"):
+ result = get_organization_features(org_id=self.organization.id, user_id=self.user.id)
+ assert result == {"features": ["seer-agent-source-code-search"]}
+
+ @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET)
+ def test_unknown_user_id_falls_back_to_no_actor(self, _mock_all: object) -> None:
+ with self.feature("organizations:seer-agent-source-code-search"):
+ result = get_organization_features(org_id=self.organization.id, user_id=0)
+ assert result == {"features": ["seer-agent-source-code-search"]}
+
+
class TestTriggerCodingAgentLaunch:
@patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run")
def test_not_found_returns_integration_not_found_error_code(self, mock_launch):