diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index d55a776caa5241..858deb0a38ecee 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -338,6 +338,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get
/static/app/views/explore/spans/spansTabSeerComboBox.tsx @getsentry/explore @getsentry/machine-learning-ai
/static/app/views/traces/ @getsentry/explore
/static/app/components/quickTrace/ @getsentry/explore
+/static/app/components/dnd/ @getsentry/explore
/src/sentry/insights/ @getsentry/data-browsing
/static/app/views/performance/ @getsentry/data-browsing
/static/app/components/performance/ @getsentry/data-browsing
diff --git a/.github/workflows/scripts/compute-sentry-selected-tests.py b/.github/workflows/scripts/compute-sentry-selected-tests.py
index 65bbcd1a6ba034..24d1c626f8caad 100644
--- a/.github/workflows/scripts/compute-sentry-selected-tests.py
+++ b/.github/workflows/scripts/compute-sentry-selected-tests.py
@@ -135,6 +135,11 @@ def main() -> int:
required=True,
help="Space-separated changed files relative to sentry repo root",
)
+ parser.add_argument(
+ "--previous-filenames",
+ default="",
+ help="Space-separated previous filenames for renamed files (queried against coverage DB)",
+ )
parser.add_argument("--output", help="Output file path for selected test files (one per line)")
parser.add_argument("--github-output", action="store_true", help="Write to GITHUB_OUTPUT")
args = parser.parse_args()
@@ -145,6 +150,7 @@ def main() -> int:
return 1
changed = [f.strip() for f in args.changed_files.split() if f.strip()]
+ previous_filenames = [f.strip() for f in args.previous_filenames.split() if f.strip()]
selective_applied = False
@@ -152,8 +158,9 @@ def main() -> int:
print("No changed files provided, running full test suite")
affected_test_files: set[str] = set()
else:
+ all_paths = changed + previous_filenames
triggered_by = [
- f for f in changed if any(_matches_trigger(f, t) for t in FULL_SUITE_TRIGGERS)
+ f for f in all_paths if any(_matches_trigger(f, t) for t in FULL_SUITE_TRIGGERS)
]
if triggered_by:
print(f"Full test suite triggered by: {', '.join(triggered_by)}")
@@ -161,8 +168,12 @@ def main() -> int:
else:
selective_applied = True
- # Map repo-relative paths to DB format (add ../sentry/ prefix)
+ # Map repo-relative paths to DB format (add ../sentry/ prefix).
+ # Include previous filenames for renames so the coverage DB
+ # (which still stores the old path) can find the right tests.
db_paths = [DB_PREFIX + f for f in changed]
+ for old_name in previous_filenames:
+ db_paths.append(DB_PREFIX + old_name)
print(f"Computing selected tests for {len(changed)} changed files...")
try:
diff --git a/.github/workflows/scripts/getsentry-dispatch.js b/.github/workflows/scripts/getsentry-dispatch.js
index 285be77daec73e..b0cb8caaba5440 100644
--- a/.github/workflows/scripts/getsentry-dispatch.js
+++ b/.github/workflows/scripts/getsentry-dispatch.js
@@ -20,6 +20,7 @@ export async function dispatch({
fileChanges,
mergeCommitSha,
sentryChangedFiles,
+ sentryPreviousFilenames,
targetWorkflow,
}) {
core.startGroup('Dispatching request to getsentry.');
@@ -42,6 +43,7 @@ export async function dispatch({
// Changed files for selective testing. Empty string means full suite.
'sentry-changed-files': sentryChangedFiles || '',
+ 'sentry-previous-filenames': sentryPreviousFilenames || '',
};
core.info(
diff --git a/.github/workflows/scripts/test_compute_sentry_selected_tests.py b/.github/workflows/scripts/test_compute_sentry_selected_tests.py
index d2af3dd08d5dae..5020d39dc96b9c 100644
--- a/.github/workflows/scripts/test_compute_sentry_selected_tests.py
+++ b/.github/workflows/scripts/test_compute_sentry_selected_tests.py
@@ -285,6 +285,72 @@ def test_zero_tests_signals_selective_applied(self, tmp_path):
assert "test-count=0" in gh
assert output.read_text() == ""
+ def test_renamed_file_queries_old_path(self, tmp_path):
+ """When a file is renamed, the old path should be queried against the coverage DB."""
+ db_path = tmp_path / "coverage.db"
+ _create_coverage_db(
+ str(db_path),
+ {
+ # Coverage DB still has the old filename
+ "../sentry/src/sentry/models/old_name.py": [
+ "../sentry/tests/sentry/test_old_name.py::T::test|run",
+ ],
+ },
+ )
+ output = tmp_path / "output.txt"
+ gh_output = tmp_path / "gh_output"
+ gh_output.write_text("")
+
+ with mock.patch("compute_sentry_selected_tests.Path.exists", return_value=True):
+ _run(
+ [
+ "--coverage-db",
+ str(db_path),
+ "--changed-files",
+ "src/sentry/models/new_name.py",
+ "--previous-filenames",
+ "src/sentry/models/old_name.py",
+ "--output",
+ str(output),
+ "--github-output",
+ ],
+ {"GITHUB_OUTPUT": str(gh_output)},
+ )
+
+ gh = gh_output.read_text()
+ assert "has-selected-tests=true" in gh
+ assert "test-count=1" in gh
+ assert output.read_text().strip() == "tests/sentry/test_old_name.py"
+
+ def test_renamed_file_without_previous_misses_coverage(self, tmp_path):
+ """Without --previous-filenames, a renamed file gets no coverage hits."""
+ db_path = tmp_path / "coverage.db"
+ _create_coverage_db(
+ str(db_path),
+ {
+ "../sentry/src/sentry/models/old_name.py": [
+ "../sentry/tests/sentry/test_old_name.py::T::test|run",
+ ],
+ },
+ )
+ gh_output = tmp_path / "gh_output"
+ gh_output.write_text("")
+
+ _run(
+ [
+ "--coverage-db",
+ str(db_path),
+ "--changed-files",
+ "src/sentry/models/new_name.py",
+ "--github-output",
+ ],
+ {"GITHUB_OUTPUT": str(gh_output)},
+ )
+
+ gh = gh_output.read_text()
+ assert "has-selected-tests=true" in gh
+ assert "test-count=0" in gh
+
def test_missing_db_returns_error(self):
ret = _run(["--coverage-db", "/nonexistent/coverage.db", "--changed-files", "foo.py"])
assert ret == 1
diff --git a/knip.config.ts b/knip.config.ts
index 83730053fb429e..b0b8657a864274 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -21,6 +21,8 @@ const productionEntryPoints = [
'static/app/chartcuterie/**/*.{js,ts,tsx}',
// TODO: Remove when used
'static/app/components/pipeline/**/*.{js,ts,tsx}',
+ // TODO: Remove when used
+ 'static/app/views/seerExplorer/contexts/**/*.{js,ts,tsx}',
];
const testingEntryPoints = [
diff --git a/src/sentry/analytics/events/ai_autofix_pr_events.py b/src/sentry/analytics/events/ai_autofix_pr_events.py
index 71a5e3c9068423..e9330c6764fce5 100644
--- a/src/sentry/analytics/events/ai_autofix_pr_events.py
+++ b/src/sentry/analytics/events/ai_autofix_pr_events.py
@@ -9,6 +9,7 @@ class AiAutofixPrEvent(analytics.Event):
run_id: int
integration: str
github_app: str
+ referrer: str | None = None
@analytics.eventclass("ai.autofix.pr.closed")
diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py
index b06d6aa591ff27..1f68aa0fc4f336 100644
--- a/src/sentry/api/serializers/models/organization.py
+++ b/src/sentry/api/serializers/models/organization.py
@@ -58,6 +58,7 @@
ROLLBACK_ENABLED_DEFAULT,
SAMPLING_MODE_DEFAULT,
SCRAPE_JAVASCRIPT_DEFAULT,
+ SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT,
SEER_DEFAULT_CODING_AGENT_DEFAULT,
TARGET_SAMPLE_RATE_DEFAULT,
ObjectStatus,
@@ -558,8 +559,9 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp
defaultSeerScannerAutomation: bool
enableSeerEnhancedAlerts: bool
enableSeerCoding: bool
- defaultCodingAgent: str | None
+ defaultCodingAgent: str
defaultCodingAgentIntegrationId: int | None
+ defaultAutomatedRunStoppingPoint: str
autoEnableCodeReview: bool
autoOpenPrs: bool
defaultCodeReviewTriggers: list[str]
@@ -734,12 +736,14 @@ def serialize( # type: ignore[override]
)
),
"defaultCodingAgent": obj.get_option(
- "sentry:seer_default_coding_agent",
- SEER_DEFAULT_CODING_AGENT_DEFAULT,
+ "sentry:seer_default_coding_agent", SEER_DEFAULT_CODING_AGENT_DEFAULT
),
"defaultCodingAgentIntegrationId": obj.get_option(
- "sentry:seer_default_coding_agent_integration_id",
- None,
+ "sentry:seer_default_coding_agent_integration_id", None
+ ),
+ "defaultAutomatedRunStoppingPoint": obj.get_option(
+ "sentry:default_automated_run_stopping_point",
+ SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT,
),
"autoOpenPrs": bool(
obj.get_option(
diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py
index 67acd600769107..fd238fc93f95ce 100644
--- a/src/sentry/apidocs/examples/organization_examples.py
+++ b/src/sentry/apidocs/examples/organization_examples.py
@@ -304,8 +304,9 @@ class OrganizationExamples:
"enableSeerCoding": True,
"enableSeerEnhancedAlerts": True,
"autoOpenPrs": False,
- "defaultCodingAgent": None,
+ "defaultCodingAgent": "seer",
"defaultCodingAgentIntegrationId": None,
+ "defaultAutomatedRunStoppingPoint": "code_changes",
"issueAlertsThreadFlag": True,
"metricAlertsThreadFlag": True,
"trustedRelays": [],
diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py
index cbd1bd453e7908..c83313afaa50ff 100644
--- a/src/sentry/conf/server.py
+++ b/src/sentry/conf/server.py
@@ -1013,10 +1013,6 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
"task": "workflow_engine:sentry.workflow_engine.tasks.workflows.schedule_delayed_workflows",
"schedule": timedelta(seconds=15),
},
- "prune-old-fire-history": {
- "task": "workflow_engine:sentry.workflow_engine.tasks.cleanup.prune_old_fire_history",
- "schedule": timedelta(minutes=2),
- },
"resolve-stale-sourcemap-detectors": {
"task": "workflow_engine:sentry.processing_errors.tasks.resolve_stale_sourcemap_detectors",
"schedule": crontab("*/5", "*", "*", "*", "*"),
diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py
index 5eecb3461606ec..a3c7fd1f39b75a 100644
--- a/src/sentry/core/endpoints/organization_details.py
+++ b/src/sentry/core/endpoints/organization_details.py
@@ -68,6 +68,7 @@
ROLLBACK_ENABLED_DEFAULT,
SAMPLING_MODE_DEFAULT,
SCRAPE_JAVASCRIPT_DEFAULT,
+ SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT,
SEER_DEFAULT_CODING_AGENT_DEFAULT,
TARGET_SAMPLE_RATE_DEFAULT,
ObjectStatus,
@@ -254,12 +255,17 @@
None,
),
(
- # Informs UI default for automated_run_stopping_point in project preferences
"autoOpenPrs",
"sentry:auto_open_prs",
bool,
AUTO_OPEN_PRS_DEFAULT,
),
+ (
+ "defaultAutomatedRunStoppingPoint",
+ "sentry:default_automated_run_stopping_point",
+ str,
+ SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT,
+ ),
(
"autoEnableCodeReview",
"sentry:auto_enable_code_review",
@@ -371,8 +377,15 @@ class OrganizationSerializer(BaseOrganizationSerializer):
dashboardsAsyncQueueParallelLimit = serializers.IntegerField(required=False, min_value=1)
enableSeerEnhancedAlerts = serializers.BooleanField(required=False)
enableSeerCoding = serializers.BooleanField(required=False)
- defaultCodingAgent = serializers.CharField(required=False, allow_null=True)
+ defaultCodingAgent = serializers.ChoiceField(
+ choices=["seer", "cursor", "claude_code", "cursor_background_agent", "claude_code_agent"],
+ required=False,
+ allow_null=True,
+ )
defaultCodingAgentIntegrationId = serializers.IntegerField(required=False, allow_null=True)
+ defaultAutomatedRunStoppingPoint = serializers.ChoiceField(
+ choices=["code_changes", "open_pr"], required=False
+ )
autoOpenPrs = serializers.BooleanField(required=False)
autoEnableCodeReview = serializers.BooleanField(required=False)
defaultCodeReviewTriggers = serializers.ListField(
@@ -401,6 +414,15 @@ def validate_relayPiiConfig(self, value):
organization = self.context["organization"]
return validate_pii_config_update(organization, value)
+ def validate_defaultCodingAgent(self, value: str | None) -> str:
+ if value is None:
+ return SEER_DEFAULT_CODING_AGENT_DEFAULT
+ coding_agent_aliases: dict[str, str] = {
+ "cursor": "cursor_background_agent",
+ "claude_code": "claude_code_agent",
+ }
+ return coding_agent_aliases.get(value, value)
+
def validate_defaultCodingAgentIntegrationId(self, value: int | None) -> int | None:
if value is None:
return None
diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py
index ae2d9b8b254cc8..b82545bfdbb5d4 100644
--- a/src/sentry/core/endpoints/team_projects.py
+++ b/src/sentry/core/endpoints/team_projects.py
@@ -31,8 +31,8 @@
from sentry.models.team import Team
from sentry.seer.similarity.utils import (
project_is_seer_eligible,
- set_default_project_auto_open_prs,
set_default_project_autofix_automation_tuning,
+ set_default_project_seer_preferences,
set_default_project_seer_scanner_automation,
)
from sentry.signals import project_created
@@ -56,7 +56,7 @@ def apply_default_project_settings(organization: Organization, project: Project)
set_default_project_autofix_automation_tuning(organization, project)
set_default_project_seer_scanner_automation(organization, project)
- set_default_project_auto_open_prs(organization, project)
+ set_default_project_seer_preferences(organization, project)
class ProjectPostSerializer(serializers.Serializer):
diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py
index 12e4d5feb2092a..fba791d6413418 100644
--- a/src/sentry/seer/autofix/utils.py
+++ b/src/sentry/seer/autofix/utils.py
@@ -15,6 +15,7 @@
from sentry import features, options, ratelimits
from sentry.constants import (
+ AUTO_OPEN_PRS_DEFAULT,
SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT,
DataCategory,
ObjectStatus,
@@ -42,6 +43,10 @@
SeerProjectRepository,
SeerProjectRepositoryBranchOverride,
)
+from sentry.seer.models.seer_api_models import (
+ AutofixHandoffPoint,
+ SeerAutomationHandoffConfiguration,
+)
from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request
from sentry.utils.cache import cache
from sentry.utils.outcomes import Outcome, track_outcome
@@ -393,6 +398,38 @@ def default_seer_project_preference(project: Project) -> SeerProjectPreference:
)
+def get_org_default_seer_automation_handoff(
+ organization: Organization,
+) -> tuple[str, SeerAutomationHandoffConfiguration | None]:
+ """Get the default stopping point and automation handoff for an organization."""
+ stopping_point = organization.get_option(
+ "sentry:default_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT
+ )
+
+ auto_open_prs = organization.get_option("sentry:auto_open_prs", AUTO_OPEN_PRS_DEFAULT)
+
+ automation_handoff: SeerAutomationHandoffConfiguration | None = None
+ coding_agent = organization.get_option("sentry:seer_default_coding_agent")
+ coding_agent_integration_id = organization.get_option(
+ "sentry:seer_default_coding_agent_integration_id"
+ )
+ if coding_agent and coding_agent != "seer" and coding_agent_integration_id is not None:
+ automation_handoff = SeerAutomationHandoffConfiguration(
+ handoff_point=AutofixHandoffPoint.ROOT_CAUSE,
+ target=coding_agent,
+ integration_id=coding_agent_integration_id,
+ auto_create_pr=auto_open_prs,
+ )
+ # If Seer agent and auto open PRs, we can run up to open_pr.
+ elif auto_open_prs:
+ stopping_point = "open_pr"
+ # If Seer agent and no auto open PRs, we shouldn't go past code_changes.
+ elif stopping_point == "open_pr":
+ stopping_point = "code_changes"
+
+ return stopping_point, automation_handoff
+
+
def get_project_seer_preferences(project_id: int) -> SeerRawPreferenceResponse:
"""
Fetch Seer project preferences from the Seer API.
diff --git a/src/sentry/seer/autofix/webhooks.py b/src/sentry/seer/autofix/webhooks.py
index 3f6e4f4ad5a15d..f61384333cf681 100644
--- a/src/sentry/seer/autofix/webhooks.py
+++ b/src/sentry/seer/autofix/webhooks.py
@@ -11,8 +11,10 @@
AiAutofixPrOpenedEvent,
)
from sentry.integrations.types import IntegrationProviderSlug
+from sentry.models.group import Group
from sentry.models.organization import Organization
from sentry.seer.autofix.utils import get_autofix_state_from_pr_id
+from sentry.seer.explorer.client_utils import get_explorer_state_from_pr_id
from sentry.utils import metrics
AnalyticAction = Literal["opened", "closed", "merged"]
@@ -43,24 +45,57 @@ def handle_github_pr_webhook_for_autofix(
if action not in ["opened", "closed"]:
return None
+ try:
+ record_pr_action_analytic(org, action, pull_request, github_app)
+ except Exception as e:
+ sentry_sdk.capture_exception(e)
+
+
+def record_pr_action_analytic(
+ org: Organization, action: str, pull_request: dict[str, Any], github_app: str
+) -> None:
+ analytic_action: AnalyticAction = "opened" if action == "opened" else "closed"
+ if pull_request["merged"]:
+ analytic_action = "merged"
+
autofix_state = get_autofix_state_from_pr_id("integrations:github", pull_request["id"])
if autofix_state:
- analytic_action: AnalyticAction = "opened" if action == "opened" else "closed"
- if pull_request["merged"]:
- analytic_action = "merged"
-
- try:
- analytics.record(
- ACTION_TO_EVENTS[analytic_action](
- organization_id=org.id,
- integration=IntegrationProviderSlug.GITHUB.value,
- project_id=autofix_state.request.project_id,
- group_id=autofix_state.request.issue["id"],
- run_id=autofix_state.run_id,
- github_app=github_app,
- )
+ analytics.record(
+ ACTION_TO_EVENTS[analytic_action](
+ organization_id=org.id,
+ integration=IntegrationProviderSlug.GITHUB.value,
+ project_id=autofix_state.request.project_id,
+ group_id=autofix_state.request.issue["id"],
+ run_id=autofix_state.run_id,
+ github_app=github_app,
)
- except Exception as e:
- sentry_sdk.capture_exception(e)
+ )
metrics.incr(f"ai.autofix.pr.{analytic_action}")
+ return
+
+ explorer_state = get_explorer_state_from_pr_id(
+ org.id, "integrations:github", pull_request["id"]
+ )
+ if explorer_state:
+ group_id = explorer_state.metadata.get("group_id") if explorer_state.metadata else None
+ if group_id is None:
+ raise ValueError(f"Missing group id in explorer run {explorer_state.run_id}")
+ group = Group.objects.get(id=group_id, project__organization_id=org.id)
+
+ analytics.record(
+ ACTION_TO_EVENTS[analytic_action](
+ organization_id=org.id,
+ integration=IntegrationProviderSlug.GITHUB.value,
+ project_id=group.project.id,
+ group_id=group.id,
+ run_id=explorer_state.run_id,
+ github_app=github_app,
+ referrer=explorer_state.metadata.get("referrer")
+ if explorer_state.metadata
+ else None,
+ )
+ )
+
+ metrics.incr(f"ai.autofix.pr.{analytic_action}", tags={"mode": "explorer"})
+ return
diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py
index cf699f2ffb3f05..fd5aa32171957b 100644
--- a/src/sentry/seer/explorer/client_utils.py
+++ b/src/sentry/seer/explorer/client_utils.py
@@ -88,6 +88,12 @@ class ExplorerUpdateRequest(TypedDict):
payload: NotRequired[dict[str, Any]]
+class ExplorerPrStateRequest(TypedDict):
+ organization_id: int
+ provider: str
+ pr_id: int
+
+
def make_explorer_state_request(
body: ExplorerStateRequest,
connection_pool: HTTPConnectionPool | None = None,
@@ -140,6 +146,39 @@ def make_explorer_update_request(
)
+def make_explorer_state_pr_request(
+ body: ExplorerPrStateRequest,
+ connection_pool: HTTPConnectionPool | None = None,
+ viewer_context: SeerViewerContext | None = None,
+) -> BaseHTTPResponse:
+ return make_signed_seer_api_request(
+ connection_pool or explorer_connection_pool,
+ "/v1/automation/explorer/state/pr",
+ body=orjson.dumps(body, option=orjson.OPT_NON_STR_KEYS),
+ viewer_context=viewer_context,
+ )
+
+
+def get_explorer_state_from_pr_id(
+ organization_id: int, provider: str, pr_id: int
+) -> SeerRunState | None:
+ body = ExplorerPrStateRequest(organization_id=organization_id, provider=provider, pr_id=pr_id)
+ response = make_explorer_state_pr_request(body)
+
+ if response.status >= 400:
+ raise SeerApiError("Seer request failed", response.status)
+
+ result = response.json()
+ if not result:
+ return None
+
+ session = result.get("session")
+ if session is None:
+ return None
+
+ return SeerRunState(**session)
+
+
def has_seer_explorer_access_with_detail(
organization: Organization, actor: SentryUser | AnonymousUser | RpcUser | None = None
) -> tuple[bool, str | None]:
diff --git a/src/sentry/seer/similarity/utils.py b/src/sentry/seer/similarity/utils.py
index 448ab305a0e13f..ff530ed98c12dd 100644
--- a/src/sentry/seer/similarity/utils.py
+++ b/src/sentry/seer/similarity/utils.py
@@ -9,7 +9,9 @@
from tokenizers import Tokenizer
from sentry import features, options
-from sentry.constants import DATA_ROOT
+from sentry.constants import (
+ DATA_ROOT,
+)
from sentry.grouping.api import get_contributing_variant_and_component
from sentry.grouping.grouping_info import get_grouping_info_from_variants_legacy
from sentry.grouping.variants import BaseVariant
@@ -18,12 +20,14 @@
from sentry.models.project import Project
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
from sentry.seer.autofix.utils import (
- AutofixStoppingPoint,
+ get_org_default_seer_automation_handoff,
is_seer_seat_based_tier_enabled,
set_project_seer_preference,
write_preference_to_sentry_db,
)
-from sentry.seer.models import SeerProjectPreference
+from sentry.seer.models import (
+ SeerProjectPreference,
+)
from sentry.seer.similarity.types import GroupingVersion
from sentry.services.eventstore.models import Event, GroupEvent
from sentry.utils import metrics
@@ -563,14 +567,14 @@ def set_default_project_seer_scanner_automation(
project.update_option("sentry:seer_scanner_automation", org_default)
-def set_default_project_auto_open_prs(organization: Organization, project: Project) -> None:
- """Called once at project creation time to set the initial auto open PRs."""
+def set_default_project_seer_preferences(organization: Organization, project: Project) -> None:
+ """Called once at project creation time to set the initial automated run stopping
+ point and automation handoff.
+ """
if not is_seer_seat_based_tier_enabled(organization):
return
- stopping_point = AutofixStoppingPoint.CODE_CHANGES
- if organization.get_option("sentry:auto_open_prs"):
- stopping_point = AutofixStoppingPoint.OPEN_PR
+ stopping_point, automation_handoff = get_org_default_seer_automation_handoff(organization)
# We need to make an API call to Seer to set this preference
preference = SeerProjectPreference(
@@ -578,7 +582,9 @@ def set_default_project_auto_open_prs(organization: Organization, project: Proje
project_id=project.id,
repositories=[],
automated_run_stopping_point=stopping_point,
+ automation_handoff=automation_handoff,
)
+
try:
set_project_seer_preference(preference)
except Exception as e:
diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py
index 8671d98bee3281..1d4ac97d8abcb2 100644
--- a/src/sentry/tasks/seer/autofix.py
+++ b/src/sentry/tasks/seer/autofix.py
@@ -8,7 +8,9 @@
from sentry import analytics, features
from sentry.analytics.events.autofix_automation_events import AiAutofixAutomationEvent
-from sentry.constants import ObjectStatus
+from sentry.constants import (
+ ObjectStatus,
+)
from sentry.models.group import Group
from sentry.models.organization import Organization
from sentry.models.project import Project
@@ -24,6 +26,7 @@
deduplicate_repositories,
get_autofix_repos_from_project_code_mappings,
get_autofix_state,
+ get_org_default_seer_automation_handoff,
get_seer_seat_based_tier_cache_key,
resolve_repository_ids,
)
@@ -238,34 +241,49 @@ def configure_seer_for_existing_org(organization_id: int) -> None:
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
)
+ default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization)
+ default_handoff_dict = default_handoff.dict() if default_handoff else None
+
+ valid_stopping_points = {"open_pr", "code_changes"}
+
preferences_by_id = bulk_get_project_preferences(organization_id, project_ids)
# Determine which projects need updates
preferences_to_set = []
projects_by_id = {p.id: p for p in projects}
for project_id in project_ids:
+ stopping_point = default_stopping_point
+ handoff = default_handoff_dict
+
existing_pref = preferences_by_id.get(str(project_id))
if not existing_pref:
# No existing preferences, get repositories from code mappings
repositories = get_autofix_repos_from_project_code_mappings(projects_by_id[project_id])
else:
- # Skip projects that already have an acceptable stopping point configured
- if existing_pref.get("automated_run_stopping_point") in ("open_pr", "code_changes"):
- continue
repositories = existing_pref.get("repositories") or []
- repositories = deduplicate_repositories(repositories)
+ existing_stopping_point = existing_pref.get("automated_run_stopping_point")
+ existing_handoff = existing_pref.get("automation_handoff")
+
+ # Skip projects that a) already have an acceptable stopping point configured
+ # AND b) already have a handoff configured or no org default handoff.
+ if existing_stopping_point in valid_stopping_points and (
+ existing_handoff or default_handoff_dict is None
+ ):
+ continue
+
+ if existing_stopping_point in valid_stopping_points:
+ stopping_point = existing_stopping_point
+ if existing_handoff:
+ handoff = existing_handoff
- # Preserve existing repositories and automation_handoff, only update the stopping point
preferences_to_set.append(
{
"organization_id": organization_id,
"project_id": project_id,
- "repositories": repositories or [],
- "automated_run_stopping_point": "code_changes",
- "automation_handoff": (
- existing_pref.get("automation_handoff") if existing_pref else None
- ),
+ "repositories": deduplicate_repositories(repositories) or [],
+ "automated_run_stopping_point": stopping_point,
+ "automation_handoff": handoff,
}
)
diff --git a/static/app/components/events/autofix/v3/nextStep.spec.tsx b/static/app/components/events/autofix/v3/nextStep.spec.tsx
index 5a9f7bf457c79d..5efc1cfecc76f8 100644
--- a/static/app/components/events/autofix/v3/nextStep.spec.tsx
+++ b/static/app/components/events/autofix/v3/nextStep.spec.tsx
@@ -234,6 +234,33 @@ describe('SeerDrawerNextStep', () => {
await screen.findByRole('button', {name: 'More code fix options'})
).toBeInTheDocument();
});
+
+ it('shows Add Integration link in dropdown footer', async () => {
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/integrations/coding-agents/',
+ body: {
+ integrations: [
+ {id: '1', name: 'Copilot', provider: 'github', requires_identity: false},
+ ],
+ },
+ });
+ const autofix = makeAutofix();
+ render(
+
+ );
+ await userEvent.click(
+ await screen.findByRole('button', {name: 'More code fix options'})
+ );
+ const addIntegrationLink = screen.getByRole('button', {name: 'Add Integration'});
+ expect(addIntegrationLink).toHaveAttribute(
+ 'href',
+ '/settings/org-slug/integrations/?category=coding%20agent'
+ );
+ });
});
describe('SolutionNextStep', () => {
diff --git a/static/app/components/events/autofix/v3/nextStep.tsx b/static/app/components/events/autofix/v3/nextStep.tsx
index 2f3df3bcc24323..f5c5579f4f9059 100644
--- a/static/app/components/events/autofix/v3/nextStep.tsx
+++ b/static/app/components/events/autofix/v3/nextStep.tsx
@@ -1,11 +1,13 @@
import {useCallback, useMemo, useState, type ReactNode} from 'react';
import {Button, ButtonBar} from '@sentry/scraps/button';
+import {MenuComponents} from '@sentry/scraps/compactSelect';
import {Flex} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
import {TextArea} from '@sentry/scraps/textarea';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
+import {DropdownMenuFooter} from 'sentry/components/dropdownMenu/footer';
import {
organizationIntegrationsCodingAgents,
type CodingAgentIntegration,
@@ -18,6 +20,7 @@ import {
type AutofixSection,
type useExplorerAutofix,
} from 'sentry/components/events/autofix/useExplorerAutofix';
+import {IconAdd} from 'sentry/icons/iconAdd';
import {IconChevron} from 'sentry/icons/iconChevron';
import {t} from 'sentry/locale';
import {PluginIcon} from 'sentry/plugins/components/pluginIcon';
@@ -275,6 +278,8 @@ function NextStepTemplate({
codingAgentIntegrations,
onCodingAgentHandoff,
}: NextStepTemplateProps) {
+ const organization = useOrganization();
+
const codingAgentOptions = useMemo(() => {
return (codingAgentIntegrations ?? []).map(integration => {
const actionLabel =
@@ -349,6 +354,16 @@ function NextStepTemplate({
/>
)}
position="bottom-end"
+ menuFooter={
+
+ }
+ to={`/settings/${organization.slug}/integrations/?category=coding%20agent`}
+ >
+ {t('Add Integration')}
+
+
+ }
/>
) : null}
diff --git a/static/app/views/app/index.tsx b/static/app/views/app/index.tsx
index 8c33108514b4d9..38ab3b139210f5 100644
--- a/static/app/views/app/index.tsx
+++ b/static/app/views/app/index.tsx
@@ -36,6 +36,7 @@ import {AsyncSDKIntegrationContextProvider} from 'sentry/views/app/asyncSDKInteg
import {LastKnownRouteContextProvider} from 'sentry/views/lastKnownRouteContextProvider';
import {OrganizationContextProvider} from 'sentry/views/organizationContext';
import {RouteAnalyticsContextProvider} from 'sentry/views/routeAnalyticsContextProvider';
+import {LLMContextProvider} from 'sentry/views/seerExplorer/contexts/llmContext';
import {ExplorerPanel} from 'sentry/views/seerExplorer/explorerPanel';
import {ExplorerPanelProvider} from 'sentry/views/seerExplorer/useExplorerPanel';
@@ -241,12 +242,14 @@ export function App() {
-
-
-
-
- {renderBody()}
-
+
+
+
+
+
+ {renderBody()}
+
+
diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx
index d2237fa8b42e37..7f9554dfda4e0e 100644
--- a/static/app/views/automations/list.tsx
+++ b/static/app/views/automations/list.tsx
@@ -23,6 +23,7 @@ import {AutomationSearch} from 'sentry/views/automations/components/automationLi
import {AUTOMATION_LIST_PAGE_LIMIT} from 'sentry/views/automations/constants';
import {useAutomationsQuery} from 'sentry/views/automations/hooks';
import {makeAutomationCreatePathname} from 'sentry/views/automations/pathnames';
+import {AlertsRedirectNotice} from 'sentry/views/detectors/list/common/alertsRedirectNotice';
export default function AutomationsList() {
const location = useLocation();
@@ -85,6 +86,9 @@ export default function AutomationsList() {
)}
docsUrl="https://docs.sentry.io/product/new-monitors-and-alerts/alerts/"
>
+
+ {t('Alert Rules have been moved to Monitors and Alerts.')}
+
-
-
- {t('Select monitor type')}
-
-
- {tct(
- 'Do you want to alert existing issues? Create a [newAlertLink:new alert], or [connectAlertLink:connect an existing one].',
- {
- newAlertLink: ,
- connectAlertLink: (
-
- ),
- }
- )}
-
-
+
+ {tct('Want to just alert on an existing issue? [link:Create an issue alert].', {
+ link: ,
+ })}
+
+
+ {t(
+ 'If you’re looking for an Error Monitors, those are created by Sentry. To customize an error monitor, click into an existing one.'
+ )}
+
);
}
@@ -94,7 +85,9 @@ function MonitorTypeField() {
{
id: 'metric_issue',
name: getDetectorTypeLabel('metric_issue'),
- description: t('Monitor error counts, transaction duration, and more!'),
+ description: t(
+ 'Monitor error counts, logs, custom metrics, span duration, crash rates, and more. '
+ ),
visualization: ,
infoBanner: canCreateMetricDetector ? undefined : (
diff --git a/static/app/views/detectors/list/allMonitors.tsx b/static/app/views/detectors/list/allMonitors.tsx
index e9b38236c92529..8e61ebc506ceb1 100644
--- a/static/app/views/detectors/list/allMonitors.tsx
+++ b/static/app/views/detectors/list/allMonitors.tsx
@@ -1,7 +1,6 @@
import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle';
import {WorkflowEngineListLayout} from 'sentry/components/workflowEngine/layout/list';
import {t} from 'sentry/locale';
-import {AlertsRedirectNotice} from 'sentry/views/detectors/list/common/alertsRedirectNotice';
import {DetectorListActions} from 'sentry/views/detectors/list/common/detectorListActions';
import {DetectorListContent} from 'sentry/views/detectors/list/common/detectorListContent';
import {DetectorListHeader} from 'sentry/views/detectors/list/common/detectorListHeader';
@@ -24,9 +23,6 @@ export default function AllMonitors() {
description={DESCRIPTION}
docsUrl={DOCS_URL}
>
-
- {t('Alert Rules have been moved to Monitors and Alerts.')}
-
diff --git a/static/app/views/detectors/new.tsx b/static/app/views/detectors/new.tsx
index d563ddbb2ac723..212b96bed9661f 100644
--- a/static/app/views/detectors/new.tsx
+++ b/static/app/views/detectors/new.tsx
@@ -2,12 +2,14 @@ import {useTheme} from '@emotion/react';
import {parseAsString, useQueryState} from 'nuqs';
import {Button, LinkButton} from '@sentry/scraps/button';
+import {ExternalLink} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
import {Breadcrumbs} from 'sentry/components/breadcrumbs';
import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle';
import {EditLayout} from 'sentry/components/workflowEngine/layout/edit';
import {useWorkflowEngineFeatureGate} from 'sentry/components/workflowEngine/useWorkflowEngineFeatureGate';
-import {t} from 'sentry/locale';
+import {t, tct} from 'sentry/locale';
import {useNavigate} from 'sentry/utils/useNavigate';
import {useOrganization} from 'sentry/utils/useOrganization';
import {
@@ -43,8 +45,6 @@ export default function DetectorNew() {
const [detectorType] = useDetectorTypeQueryState();
const [projectId] = useQueryState('project', parseAsString);
- const newMonitorName = t('New Monitor');
-
const formProps = {
onSubmit: () => {
navigate({
@@ -62,12 +62,21 @@ export default function DetectorNew() {
return (
-
-
+
-
+
+
+ {tct(
+ 'Monitors detect problems in your application and send alerts when they occur. [docsLink:Read the Docs].',
+ {
+ docsLink: (
+
+ ),
+ }
+ )}
+
diff --git a/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx b/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx
index 5416d04c20e570..93566027385de7 100644
--- a/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx
+++ b/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx
@@ -382,7 +382,6 @@ function AutofixPreviews({
size="md"
icon={
}
aria-label={t('Open Seer')}
- tooltipProps={{title: t('Open Seer')}}
priority="primary"
onClick={openSeerDrawer}
analyticsEventKey="issue_details.seer_opened"
diff --git a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
index 74abd32fd23cc6..0922df43bc238e 100644
--- a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
+++ b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
@@ -4,7 +4,7 @@ import styled from '@emotion/styled';
import {t} from 'sentry/locale';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames';
+import {makeAutomationBasePathname} from 'sentry/views/automations/pathnames';
import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies';
import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext';
import {SecondaryNavigation} from 'sentry/views/navigation/secondary/components';
@@ -127,7 +127,7 @@ function ConfigureSection({baseUrl}: {baseUrl: string}) {
!hasRedirectOptOut && organization.features.includes('workflow-engine-ui');
const alertsLink = shouldRedirectToWorkflowEngineUI
- ? `${makeMonitorBasePathname(organization.slug)}?alertsRedirect=true`
+ ? `${makeAutomationBasePathname(organization.slug)}?alertsRedirect=true`
: `${baseUrl}/alerts/rules/`;
return (
diff --git a/static/app/views/seerExplorer/contexts/llmContext.spec.tsx b/static/app/views/seerExplorer/contexts/llmContext.spec.tsx
new file mode 100644
index 00000000000000..faa19cf9bf38c0
--- /dev/null
+++ b/static/app/views/seerExplorer/contexts/llmContext.spec.tsx
@@ -0,0 +1,339 @@
+import type {ReactNode} from 'react';
+
+import {render, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {LLMContextProvider, useLLMContext} from './llmContext';
+import type {LLMContextSnapshot} from './llmContextTypes';
+import {registerLLMContext} from './registerLLMContext';
+
+// ---------------------------------------------------------------------------
+// Test helper: ContextCapture
+//
+// Renders nothing but stores a reference to the getSnapshot function.
+// Since the context value is memoized (stable), this component only renders
+// once. However, getSnapshot() always reads stateRef.current (fresh), so
+// calling capturedRef.current() in waitFor gives live data.
+// ---------------------------------------------------------------------------
+
+function makeContextCapture() {
+ const ref: {current: ((componentOnly?: boolean) => LLMContextSnapshot) | null} = {
+ current: null,
+ };
+
+ function ContextCapture() {
+ const {getLLMContext} = useLLMContext();
+ ref.current = getLLMContext;
+ return null;
+ }
+
+ function getSnapshot(componentOnly?: boolean): LLMContextSnapshot {
+ if (!ref.current) throw new Error('ContextCapture not mounted');
+ return ref.current(componentOnly);
+ }
+
+ return {ContextCapture, getSnapshot};
+}
+
+// ---------------------------------------------------------------------------
+// Test fixtures
+// ---------------------------------------------------------------------------
+
+function DummyChart({label}: {label?: string}) {
+ useLLMContext({label: label ?? 'chart'});
+ return
{label ?? 'chart'}
;
+}
+
+function DummyWidget({title, children}: {children?: ReactNode; title?: string}) {
+ useLLMContext({title: title ?? 'widget', type: 'timeseries', unit: 'ms'});
+ return (
+
+ {title ?? 'widget'}
+ {children}
+
+ );
+}
+
+function DummyDashboard({name, children}: {children?: ReactNode; name?: string}) {
+ useLLMContext({name: name ?? 'dashboard'});
+ return (
+
+ {name ?? 'dashboard'}
+ {children}
+
+ );
+}
+
+const ContextChart = registerLLMContext('chart', DummyChart);
+const ContextWidget = registerLLMContext('widget', DummyWidget);
+const ContextDashboard = registerLLMContext('dashboard', DummyDashboard);
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('LLMContextProvider — empty state', () => {
+ it('returns an empty snapshot when no nodes are registered', async () => {
+ const {ContextCapture, getSnapshot} = makeContextCapture();
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(getSnapshot().nodes).toEqual([]);
+ });
+ });
+});
+
+describe('registerLLMContext — nesting', () => {
+ it('nests Chart inside Widget inside Dashboard in the snapshot', async () => {
+ const {ContextCapture, getSnapshot} = makeContextCapture();
+
+ render(
+
+
+
+
+
+
+
+
+ );
+
+ // Wait for the cascade of registration effects to settle, then assert the
+ // entire nested shape in one pass so the failure message shows the full tree.
+ await waitFor(() => {
+ expect(getSnapshot()).toEqual({
+ version: expect.any(Number),
+ nodes: [
+ {
+ nodeType: 'dashboard',
+ data: {name: 'Backend Health'},
+ children: [
+ {
+ nodeType: 'widget',
+ data: {title: 'Error Rate', type: 'timeseries', unit: 'ms'},
+ children: [
+ {
+ nodeType: 'chart',
+ data: {label: 'p99'},
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ });
+ });
+});
+
+describe('registerLLMContext — unmount cleanup', () => {
+ it('removes the node from the tree when the component unmounts', async () => {
+ const {ContextCapture, getSnapshot} = makeContextCapture();
+
+ const {rerender} = render(
+
+
+
+
+ );
+
+ // Node should be present after mount
+ await waitFor(() => {
+ expect(getSnapshot().nodes).toHaveLength(1);
+ });
+
+ // Unmount the widget
+ rerender(
+
+
+
+ );
+
+ // Node should be gone
+ await waitFor(() => {
+ expect(getSnapshot().nodes).toHaveLength(0);
+ });
+ });
+});
+
+describe('useLLMContext — data updates', () => {
+ it('writes data into the node and updates it on re-render', async () => {
+ const {ContextCapture, getSnapshot} = makeContextCapture();
+
+ function Gauge({value}: {value: number}) {
+ useLLMContext({value});
+ return
{value}
;
+ }
+ const ContextGauge = registerLLMContext('widget', Gauge);
+
+ const {rerender} = render(
+
+
+
+
+ );
+
+ // Initial data written after HOC registers and inner component re-renders
+ await waitFor(() => {
+ expect(getSnapshot().nodes[0]?.data).toEqual({value: 1});
+ });
+
+ // Update the prop
+ rerender(
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(getSnapshot().nodes[0]?.data).toEqual({value: 2});
+ });
+ });
+
+ it('handles non-object data types', async () => {
+ const {ContextCapture, getSnapshot} = makeContextCapture();
+
+ function Label({text}: {text: string}) {
+ useLLMContext(text);
+ return
{text}
;
+ }
+ const ContextLabel = registerLLMContext('widget', Label);
+
+ render(
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(getSnapshot().nodes[0]?.data).toBe('hello');
+ });
+ });
+
+ it('handles array data', async () => {
+ const {ContextCapture, getSnapshot} = makeContextCapture();
+
+ function Tags({items}: {items: string[]}) {
+ useLLMContext(items);
+ return
{items.join(',')}
;
+ }
+ const ContextTags = registerLLMContext('widget', Tags);
+
+ render(
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(getSnapshot().nodes[0]?.data).toEqual(['a', 'b', 'c']);
+ });
+ });
+
+ it('handles numeric data', async () => {
+ const {ContextCapture, getSnapshot} = makeContextCapture();
+
+ function Score({value}: {value: number}) {
+ useLLMContext(value);
+ return
{value}
;
+ }
+ const ContextScore = registerLLMContext('widget', Score);
+
+ render(
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(getSnapshot().nodes[0]?.data).toBe(42);
+ });
+ });
+});
+
+describe('getLLMContext — full tree vs componentOnly', () => {
+ it('getLLMContext() returns full tree including sibling branches', async () => {
+ const {ContextCapture, getSnapshot} = makeContextCapture();
+
+ function Widget1() {
+ useLLMContext({id: 'w1'});
+ return
w1
;
+ }
+ function Widget2() {
+ useLLMContext({id: 'w2'});
+ return
w2
;
+ }
+ const CW1 = registerLLMContext('widget', Widget1);
+ const CW2 = registerLLMContext('widget', Widget2);
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ const snapshot = getSnapshot();
+ expect(snapshot.nodes).toHaveLength(2);
+ const types = snapshot.nodes.map(n => n.nodeType);
+ expect(types).toEqual(['widget', 'widget']);
+ });
+ });
+
+ it('getLLMContext(true) returns only the current component subtree', async () => {
+ // We need a capture inside the dashboard to test componentOnly
+ const innerRef: {
+ current: ((c?: boolean) => LLMContextSnapshot) | null;
+ } = {current: null};
+
+ function DashboardWithCapture({name}: {name: string}) {
+ useLLMContext({name});
+ const {getLLMContext} = useLLMContext();
+ innerRef.current = getLLMContext;
+ return (
+
+
+
+ );
+ }
+ const ContextDashboardWithCapture = registerLLMContext(
+ 'dashboard',
+ DashboardWithCapture
+ );
+
+ function SiblingDashboard() {
+ useLLMContext({name: 'sibling'});
+ return
sibling
;
+ }
+ const ContextSiblingDashboard = registerLLMContext('dashboard', SiblingDashboard);
+
+ render(
+
+
+
+
+ );
+
+ // componentOnly snapshot should contain only the dashboard + its inner widget,
+ // not the sibling dashboard
+ await waitFor(() => {
+ if (!innerRef.current) throw new Error('not mounted');
+ const snapshot = innerRef.current(true); // componentOnly
+ expect(snapshot.nodes).toHaveLength(1);
+ expect(snapshot.nodes[0]?.nodeType).toBe('dashboard');
+ expect(snapshot.nodes[0]?.children).toHaveLength(1);
+ expect(snapshot.nodes[0]?.children[0]?.nodeType).toBe('widget');
+ });
+ });
+});
diff --git a/static/app/views/seerExplorer/contexts/llmContext.tsx b/static/app/views/seerExplorer/contexts/llmContext.tsx
new file mode 100644
index 00000000000000..9919867ccb132f
--- /dev/null
+++ b/static/app/views/seerExplorer/contexts/llmContext.tsx
@@ -0,0 +1,251 @@
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+} from 'react';
+
+import {createDefinedContext} from 'sentry/utils/performance/contexts/utils';
+
+import type {
+ LLMContextInternalValue,
+ LLMContextNode,
+ LLMContextNodeSnapshot,
+ LLMContextSnapshot,
+ LLMContextState,
+} from './llmContextTypes';
+
+// ---------------------------------------------------------------------------
+// Internal context — holds the registry operations (registerNode, etc.)
+// ---------------------------------------------------------------------------
+
+const [_LLMContextProvider, _useLLMContextValue] =
+ createDefinedContext
({
+ name: 'LLMContext',
+ strict: true,
+ });
+
+/**
+ * Hook for internal use by registerLLMContext and useLLMContext to access
+ * the registry operations (registerNode, unregisterNode, updateNodeData, getSnapshot).
+ * Throws if called outside an LLMContextProvider.
+ */
+export const useLLMContextRegistry = _useLLMContextValue;
+
+// ---------------------------------------------------------------------------
+// LLMNodeContext — carries the current component's nodeId down the tree
+// so child registerLLMContext wrappers can declare their parentId immediately
+// during render (before any effects have fired).
+// Default undefined = no parent (root level).
+// ---------------------------------------------------------------------------
+
+export const LLMNodeContext = createContext(undefined);
+
+// ---------------------------------------------------------------------------
+// Tree assembly helpers — convert the flat node map to a nested snapshot.
+// Data is read from nodeData (imperative ref) rather than the reducer state
+// so that writes from useLLMContext(data) are visible immediately even
+// before the HOC's registerNode effect has fired.
+// ---------------------------------------------------------------------------
+
+function collectDescendantIds(
+ nodes: Map,
+ nodeId: string,
+ result = new Set()
+): Set {
+ result.add(nodeId);
+ for (const [id, node] of nodes) {
+ if (node.parentId === nodeId) {
+ collectDescendantIds(nodes, id, result);
+ }
+ }
+ return result;
+}
+
+function buildTree(
+ nodes: LLMContextState['nodes'],
+ nodeData: Map,
+ parentId: string | undefined
+): LLMContextNodeSnapshot[] {
+ const children: LLMContextNodeSnapshot[] = [];
+ for (const [id, node] of nodes) {
+ if (node.parentId === parentId) {
+ children.push({
+ nodeType: node.nodeType,
+ data: nodeData.has(id) ? nodeData.get(id) : {},
+ children: buildTree(nodes, nodeData, id),
+ });
+ }
+ }
+ return children;
+}
+
+function serializeState(
+ state: LLMContextState,
+ nodeData: Map,
+ fromNodeId?: string
+): LLMContextSnapshot {
+ if (fromNodeId) {
+ const node = state.nodes.get(fromNodeId);
+ if (!node) {
+ return {version: state.version, nodes: []};
+ }
+ return {
+ version: state.version,
+ nodes: [
+ {
+ nodeType: node.nodeType,
+ data: nodeData.has(fromNodeId) ? nodeData.get(fromNodeId) : {},
+ children: buildTree(state.nodes, nodeData, fromNodeId),
+ },
+ ],
+ };
+ }
+ return {
+ version: state.version,
+ nodes: buildTree(state.nodes, nodeData, undefined),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// LLMContextProvider — root of the entire context tree
+// ---------------------------------------------------------------------------
+
+interface LLMContextProviderProps {
+ children: ReactNode;
+}
+
+const INITIAL_STATE: LLMContextState = {
+ nodes: new Map(),
+ version: 0,
+};
+
+export function LLMContextProvider({children}: LLMContextProviderProps) {
+ // All state lives in refs — no re-renders needed. Consumers read
+ // the latest data imperatively via getSnapshot().
+ const stateRef = useRef(INITIAL_STATE);
+ const nodeDataRef = useRef