From 3a3cefc1e839f74aae0969a96da1909ef2f9e7ca Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 15 Apr 2026 14:21:53 -0400 Subject: [PATCH 1/4] ref(nav): extract header actions, use pageframe slots --- .../actions/newIssueExperienceButton.tsx | 4 +- .../issueDetails/streamline/header/header.tsx | 188 +++++++++--------- 2 files changed, 97 insertions(+), 95 deletions(-) diff --git a/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx b/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx index 5d85faf2691b9c..8f3147c9ab0ba3 100644 --- a/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx +++ b/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx @@ -20,6 +20,7 @@ import { ISSUE_DETAILS_TOUR_GUIDE_KEY, useIssueDetailsTour, } from 'sentry/views/issueDetails/issueDetailsTour'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; /** * This hook will cause the promotional modal to appear if: @@ -118,6 +119,7 @@ function useIssueDetailsPromoModal() { export function NewIssueExperienceButton() { const organization = useOrganization(); const isSuperUser = isActiveSuperuser(); + const hasPageFrameFeature = useHasPageFrameFeature(); const { startTour, isRegistered: isTourRegistered, @@ -188,7 +190,7 @@ export function NewIssueExperienceButton() { description={t('Click here to take the tour or share feedback with the team.')} actions={ { trackAnalytics('issue_details.tour.reminder', { organization, diff --git a/static/app/views/issueDetails/streamline/header/header.tsx b/static/app/views/issueDetails/streamline/header/header.tsx index fbdfb544a6cde1..6cf3e8092d672e 100644 --- a/static/app/views/issueDetails/streamline/header/header.tsx +++ b/static/app/views/issueDetails/streamline/header/header.tsx @@ -1,4 +1,4 @@ -import {Fragment} from 'react'; +import {Fragment, type ComponentProps, type ReactNode} from 'react'; import styled from '@emotion/styled'; // eslint-disable-next-line no-restricted-imports import color from 'color'; @@ -85,73 +85,26 @@ export function StreamlinedGroupHeader({event, group, project}: GroupHeaderProps const hasErrorUpsampling = project.features.includes('error-upsampling'); const isAIDetectedIssue = AI_DETECTED_ISSUE_TYPES.has(group.issueType); - const hasFeedbackForm = - group.issueType === IssueType.QUERY_INJECTION_VULNERABILITY || - group.issueType === IssueType.PERFORMANCE_N_PLUS_ONE_API_CALLS || - isAIDetectedIssue; - const feedbackSource = - group.issueType === IssueType.QUERY_INJECTION_VULNERABILITY - ? 'issue_details_query_injection' - : isAIDetectedIssue - ? 'issue_details_ai_detected' - : 'issue_details_n_plus_one_api_calls'; - const {feedback} = useFeedbackSDKIntegration(); - const hasPageFrameFeature = useHasPageFrameFeature(); - - const feedbackOptions = { - messagePlaceholder: t('Please provide feedback on the issue Sentry detected.'), - tags: { - ['feedback.source']: feedbackSource, - }, - }; const statusProps = getBadgeProperties(group.status, group.substatus); const issueTypeConfig = getConfigForIssueType(group, project); - const hasOnlyOneUIOption = defined(organization.streamlineOnly); - const [showLearnMore, setShowLearnMore] = useLocalStorageState( - 'issue-details-learn-more', - true - ); + const crumbs = [ + { + label: 'Issues', + to: {pathname: `/organizations/${organization.slug}/issues/`, query}, + }, + {label: }, + ]; return (
- {hasPageFrameFeature ? ( - - , - }, - ]} - /> - - ) : ( - , - }, - ]} - /> - )} + + + {hasErrorUpsampling && ( - {!hasOnlyOneUIOption && !hasFeedbackForm && ( - } - analyticsEventKey="issue_details.streamline_ui_learn_more" - analyticsEventName="Issue Details: Streamline UI Learn More" - analyticsParams={{show_learn_more: showLearnMore}} - onClick={() => setShowLearnMore(false)} - > - {showLearnMore ? t("See What's New") : null} - - )} - {hasFeedbackForm && feedback ? ( - hasPageFrameFeature ? ( - - - {null} - - - ) : ( - - ) - ) : ( - - )} + @@ -329,6 +247,88 @@ export function StreamlinedGroupHeader({event, group, project}: GroupHeaderProps ); } +function MaybeTopBarSlot({ + name, + children, +}: { + children: ReactNode; + name: ComponentProps['name']; +}) { + const hasPageFrameFeature = useHasPageFrameFeature(); + if (hasPageFrameFeature) { + return {children}; + } + return children; +} + +function HeaderActions({group}: {group: Group}) { + const organization = useOrganization(); + const hasPageFrameFeature = useHasPageFrameFeature(); + const {feedback} = useFeedbackSDKIntegration(); + const [showLearnMore, setShowLearnMore] = useLocalStorageState( + 'issue-details-learn-more', + true + ); + + const hasOnlyOneUIOption = defined(organization.streamlineOnly); + const isAIDetectedIssue = AI_DETECTED_ISSUE_TYPES.has(group.issueType); + const hasFeedbackForm = + group.issueType === IssueType.QUERY_INJECTION_VULNERABILITY || + group.issueType === IssueType.PERFORMANCE_N_PLUS_ONE_API_CALLS || + isAIDetectedIssue; + const feedbackSource = + group.issueType === IssueType.QUERY_INJECTION_VULNERABILITY + ? 'issue_details_query_injection' + : isAIDetectedIssue + ? 'issue_details_ai_detected' + : 'issue_details_n_plus_one_api_calls'; + const feedbackOptions = { + messagePlaceholder: t('Please provide feedback on the issue Sentry detected.'), + tags: {['feedback.source']: feedbackSource}, + }; + + if (!hasOnlyOneUIOption && !hasFeedbackForm) { + return ( + + } + analyticsEventKey="issue_details.streamline_ui_learn_more" + analyticsEventName="Issue Details: Streamline UI Learn More" + analyticsParams={{show_learn_more: showLearnMore}} + onClick={() => setShowLearnMore(false)} + > + {showLearnMore ? t("See What's New") : null} + + + ); + } + + if (hasFeedbackForm && feedback) { + return ( + + + {null} + + + ); + } + + return ( + + + + ); +} + const Header = styled('header')` background-color: ${p => p.theme.tokens.background.primary}; padding: ${p => p.theme.space.md} ${p => p.theme.space['2xl']}; From 6ba85d823d9fdd77cb5969e4fded6eedf8d4713e Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 15 Apr 2026 15:30:58 -0400 Subject: [PATCH 2/4] ref(issueDetails): remove new issue experience button --- .../actions/newIssueExperienceButton.spec.tsx | 65 ----- .../actions/newIssueExperienceButton.tsx | 234 ------------------ .../issueDetails/streamline/header/header.tsx | 40 +-- 3 files changed, 2 insertions(+), 337 deletions(-) delete mode 100644 static/app/views/issueDetails/actions/newIssueExperienceButton.spec.tsx delete mode 100644 static/app/views/issueDetails/actions/newIssueExperienceButton.tsx diff --git a/static/app/views/issueDetails/actions/newIssueExperienceButton.spec.tsx b/static/app/views/issueDetails/actions/newIssueExperienceButton.spec.tsx deleted file mode 100644 index ec2ceae6243152..00000000000000 --- a/static/app/views/issueDetails/actions/newIssueExperienceButton.spec.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {mockTour} from 'sentry/components/tours/testUtils'; -import {NewIssueExperienceButton} from 'sentry/views/issueDetails/actions/newIssueExperienceButton'; - -const mockFeedbackForm = jest.fn(); -jest.mock('sentry/utils/useFeedbackForm', () => ({ - useFeedbackForm: () => mockFeedbackForm(), -})); - -jest.mock('sentry/views/issueDetails/issueDetailsTour', () => ({ - ...jest.requireActual('sentry/views/issueDetails/issueDetailsTour'), - useIssueDetailsTour: () => mockTour(), -})); - -describe('NewIssueExperienceButton', () => { - const organization = OrganizationFixture({streamlineOnly: null}); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('is hidden when no streamlined actions are available', () => { - const {unmount: unmountOptionTrue} = render( -
- -
, - { - organization: { - ...organization, - streamlineOnly: true, - }, - } - ); - expect(screen.getByTestId('test-id')).toBeEmptyDOMElement(); - unmountOptionTrue(); - - const {unmount: unmountOptionFalse} = render( -
- -
, - { - organization: { - ...organization, - streamlineOnly: false, - }, - } - ); - expect(screen.getByTestId('test-id')).toBeEmptyDOMElement(); - unmountOptionFalse(); - }); - - it('appears when feedback action is available', () => { - mockFeedbackForm.mockReturnValue(jest.fn()); - render( -
- -
, - {organization} - ); - expect(screen.getByTestId('test-id')).not.toBeEmptyDOMElement(); - }); -}); diff --git a/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx b/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx deleted file mode 100644 index 8f3147c9ab0ba3..00000000000000 --- a/static/app/views/issueDetails/actions/newIssueExperienceButton.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; -import styled from '@emotion/styled'; - -import issueDetailsPreview from 'sentry-images/issue_details/issue-details-preview.png'; - -import {openModal} from 'sentry/actionCreators/modal'; -import {DropdownButton} from 'sentry/components/dropdownButton'; -import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {TourAction, TourGuide} from 'sentry/components/tours/components'; -import {StartTourModal, startTourModalCss} from 'sentry/components/tours/startTour'; -import {useMutateAssistant} from 'sentry/components/tours/useAssistant'; -import {IconLab} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; -import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; -import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import { - ISSUE_DETAILS_TOUR_GUIDE_KEY, - useIssueDetailsTour, -} from 'sentry/views/issueDetails/issueDetailsTour'; -import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; - -/** - * This hook will cause the promotional modal to appear if: - * - All the steps have been registered - * - The tour has not been completed - * - The tour is not currently active - * - The streamline UI is enabled - * - The user's browser has not stored that they've seen the promo - * - * Returns a function that can be used to reset the modal. - */ -function useIssueDetailsPromoModal() { - const organization = useOrganization(); - const {mutate: mutateAssistant} = useMutateAssistant(); - const { - startTour, - endTour, - currentStepId, - isRegistered: isTourRegistered, - isCompleted: isTourCompleted, - } = useIssueDetailsTour(); - - const [localTourState, setLocalTourState] = useLocalStorageState( - ISSUE_DETAILS_TOUR_GUIDE_KEY, - {hasSeen: false} - ); - - const isPromoVisible = - isTourRegistered && - !isTourCompleted && - currentStepId === null && - !localTourState.hasSeen; - - const handleEndTour = useCallback(() => { - setLocalTourState({hasSeen: true}); - mutateAssistant({guide: ISSUE_DETAILS_TOUR_GUIDE_KEY, status: 'dismissed'}); - endTour(); - trackAnalytics('issue_details.tour.skipped', {organization}); - }, [mutateAssistant, organization, endTour, setLocalTourState]); - - useEffect(() => { - if (isPromoVisible) { - openModal( - props => ( - { - handleEndTour(); - }} - onStartTour={() => { - setLocalTourState({hasSeen: true}); - startTour(); - trackAnalytics('issue_details.tour.started', { - organization, - method: 'modal', - }); - }} - /> - ), - { - modalCss: startTourModalCss, - onClose: reason => { - if (reason) { - handleEndTour(); - } - }, - } - ); - } - }, [ - isPromoVisible, - mutateAssistant, - organization, - endTour, - startTour, - setLocalTourState, - handleEndTour, - ]); - - const resetModal = useCallback(() => { - setLocalTourState({hasSeen: false}); - mutateAssistant({guide: ISSUE_DETAILS_TOUR_GUIDE_KEY, status: 'restart'}); - }, [mutateAssistant, setLocalTourState]); - - return {resetModal}; -} - -export function NewIssueExperienceButton() { - const organization = useOrganization(); - const isSuperUser = isActiveSuperuser(); - const hasPageFrameFeature = useHasPageFrameFeature(); - const { - startTour, - isRegistered: isTourRegistered, - isCompleted: isTourCompleted, - } = useIssueDetailsTour(); - const {resetModal} = useIssueDetailsPromoModal(); - - // XXX: We use a ref to track the previous state of tour completion - // since we only show the banner when the tour goes from incomplete to complete - const isTourCompletedRef = useRef(isTourCompleted); - const [isReminderVisible, setIsReminderVisible] = useState(false); - useEffect(() => { - // If the tour becomes completed, and started off incomplete, show the reminder. - let timeout: NodeJS.Timeout | undefined; - if (isTourCompleted && !isTourCompletedRef.current) { - setIsReminderVisible(true); - // Auto-dismiss after 5 seconds - timeout = setTimeout(() => { - setIsReminderVisible(false); - trackAnalytics('issue_details.tour.reminder', {organization, method: 'timeout'}); - }, 5000); - } - isTourCompletedRef.current = isTourCompleted; - return () => clearTimeout(timeout); - }, [isTourCompleted, organization]); - - const openForm = useFeedbackForm(); - - const items = [ - { - key: 'take-tour', - label: t('Take a tour'), - hidden: !isTourRegistered, - onAction: () => { - trackAnalytics('issue_details.tour.started', {organization, method: 'dropdown'}); - startTour(); - }, - }, - { - key: 'give-feedback', - label: t('Give feedback on the UI'), - hidden: !openForm, - onAction: () => { - openForm?.({ - messagePlaceholder: t('Tell us what you think about the new UI'), - tags: { - ['feedback.source']: 'streamlined_issue_details', - ['feedback.owner']: 'issues', - }, - }); - }, - }, - { - key: 'reset-tour-modal', - label: t('Reset tour modal (Superuser only)'), - hidden: !isSuperUser || !isTourCompleted, - onAction: resetModal, - }, - ]; - - if (items.every(item => item.hidden)) { - return null; - } - - return ( - { - trackAnalytics('issue_details.tour.reminder', { - organization, - method: 'dismissed', - }); - setIsReminderVisible(false); - }} - > - {t('Got it')} - - } - isOpen={isReminderVisible} - > - {tourProps => ( -
- ( - - {/* Passing icon as child to avoid extra icon margin */} - - - )} - items={items} - position="bottom-end" - /> -
- )} -
- ); -} - -const StyledDropdownButton = styled(DropdownButton)` - color: ${p => p.theme.colors.blue400}; - :hover { - color: ${p => p.theme.colors.blue400}; - } -`; diff --git a/static/app/views/issueDetails/streamline/header/header.tsx b/static/app/views/issueDetails/streamline/header/header.tsx index 6cf3e8092d672e..b2e8b7bba08cf4 100644 --- a/static/app/views/issueDetails/streamline/header/header.tsx +++ b/static/app/views/issueDetails/streamline/header/header.tsx @@ -4,7 +4,6 @@ import styled from '@emotion/styled'; import color from 'color'; import {FeatureBadge, Tag} from '@sentry/scraps/badge'; -import {LinkButton} from '@sentry/scraps/button'; import {Flex, Grid} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -19,21 +18,17 @@ import {getBadgeProperties} from 'sentry/components/group/inboxBadges/statusBadg import {UnhandledTag} from 'sentry/components/group/inboxBadges/unhandledTag'; import {TourElement} from 'sentry/components/tours/components'; import {MAX_PICKABLE_DAYS} from 'sentry/constants'; -import {IconInfo} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {HookStore} from 'sentry/stores/hookStore'; import type {Event} from 'sentry/types/event'; import type {Group} from 'sentry/types/group'; import {AI_DETECTED_ISSUE_TYPES, IssueType} from 'sentry/types/group'; import type {Project} from 'sentry/types/project'; -import {defined} from 'sentry/utils'; import {getMessage, getTitle} from 'sentry/utils/events'; import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; -import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; import {GroupActions} from 'sentry/views/issueDetails/actions/index'; -import {NewIssueExperienceButton} from 'sentry/views/issueDetails/actions/newIssueExperienceButton'; import {Divider} from 'sentry/views/issueDetails/divider'; import {GroupPriority} from 'sentry/views/issueDetails/groupPriority'; import { @@ -262,15 +257,9 @@ function MaybeTopBarSlot({ } function HeaderActions({group}: {group: Group}) { - const organization = useOrganization(); const hasPageFrameFeature = useHasPageFrameFeature(); const {feedback} = useFeedbackSDKIntegration(); - const [showLearnMore, setShowLearnMore] = useLocalStorageState( - 'issue-details-learn-more', - true - ); - const hasOnlyOneUIOption = defined(organization.streamlineOnly); const isAIDetectedIssue = AI_DETECTED_ISSUE_TYPES.has(group.issueType); const hasFeedbackForm = group.issueType === IssueType.QUERY_INJECTION_VULNERABILITY || @@ -287,27 +276,6 @@ function HeaderActions({group}: {group: Group}) { tags: {['feedback.source']: feedbackSource}, }; - if (!hasOnlyOneUIOption && !hasFeedbackForm) { - return ( - - } - analyticsEventKey="issue_details.streamline_ui_learn_more" - analyticsEventName="Issue Details: Streamline UI Learn More" - analyticsParams={{show_learn_more: showLearnMore}} - onClick={() => setShowLearnMore(false)} - > - {showLearnMore ? t("See What's New") : null} - - - ); - } - if (hasFeedbackForm && feedback) { return ( @@ -316,17 +284,13 @@ function HeaderActions({group}: {group: Group}) { size={hasPageFrameFeature ? undefined : 'xs'} feedbackOptions={feedbackOptions} > - {null} + {hasPageFrameFeature ? null : t('Give Feedback')} ); } - return ( - - - - ); + return null; } const Header = styled('header')` From d3957a8dfc27fa329456a8cf0f1d91fbb3536250 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 15 Apr 2026 15:51:36 -0400 Subject: [PATCH 3/4] ref(issueDetails): remove unused hook --- static/app/views/issueDetails/issueDetailsTour.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/static/app/views/issueDetails/issueDetailsTour.tsx b/static/app/views/issueDetails/issueDetailsTour.tsx index e112555988062f..8a26ed4764eeb1 100644 --- a/static/app/views/issueDetails/issueDetailsTour.tsx +++ b/static/app/views/issueDetails/issueDetailsTour.tsx @@ -1,4 +1,4 @@ -import {createContext, useContext} from 'react'; +import {createContext} from 'react'; import type {TourContextType} from 'sentry/components/tours/tourContext'; @@ -30,11 +30,3 @@ export const ISSUE_DETAILS_TOUR_GUIDE_KEY = 'tour.issue_details'; export const IssueDetailsTourContext = createContext | null>(null); - -export function useIssueDetailsTour(): TourContextType { - const tourContext = useContext(IssueDetailsTourContext); - if (!tourContext) { - throw new Error('Must be used within a TourContextProvider'); - } - return tourContext; -} From 910fa44adea976ca6996e303cd01fa768c872032 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 15 Apr 2026 16:05:04 -0400 Subject: [PATCH 4/4] ref(issueDetails): remove outdated assertions --- .../streamline/header/header.spec.tsx | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/static/app/views/issueDetails/streamline/header/header.spec.tsx b/static/app/views/issueDetails/streamline/header/header.spec.tsx index 43470e46504e76..f593252eae6e09 100644 --- a/static/app/views/issueDetails/streamline/header/header.spec.tsx +++ b/static/app/views/issueDetails/streamline/header/header.spec.tsx @@ -101,30 +101,10 @@ describe('StreamlinedGroupHeader', () => { screen.getByRole('button', {name: 'Modify issue assignee'}) ).toBeInTheDocument(); expect(screen.getByText('Leander')).toBeInTheDocument(); - expect( - screen.getByRole('button', {name: 'Manage issue experience'}) - ).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Resolve'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Archive'})).toBeInTheDocument(); }); - it('displays new experience button if flag is set', async () => { - render( - , - { - organization, - } - ); - expect( - await screen.findByRole('button', {name: 'Manage issue experience'}) - ).toBeInTheDocument(); - }); - it('displays share icon if issue has been shared', async () => { render(