From ce1f905d8c833d1d0dc4de9979039b4022f204f5 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Tue, 24 Mar 2026 11:03:12 -0700 Subject: [PATCH 1/8] feat(issues): explore bottom-bar stacking indicator for supergroups Co-authored-by: Claude --- .../components/stream/stackIndicatorBar.tsx | 83 +++++++++++++++++++ static/app/views/issueList/groupListBody.tsx | 44 +++++++--- 2 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 static/app/components/stream/stackIndicatorBar.tsx diff --git a/static/app/components/stream/stackIndicatorBar.tsx b/static/app/components/stream/stackIndicatorBar.tsx new file mode 100644 index 00000000000000..e84f559221b9cb --- /dev/null +++ b/static/app/components/stream/stackIndicatorBar.tsx @@ -0,0 +1,83 @@ +import styled from '@emotion/styled'; + +import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; +import {Text} from '@sentry/scraps/text'; + +import {useDrawer} from 'sentry/components/globalDrawer'; +import {IconStack} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer'; +import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; + +interface Props { + otherCount: number; + supergroup: SupergroupDetail; +} + +export function StackIndicatorBar({supergroup, otherCount}: Props) { + const {openDrawer} = useDrawer(); + + const handleClick = () => { + openDrawer(() => , { + ariaLabel: t('Supergroup details'), + drawerKey: 'supergroup-drawer', + }); + }; + + return ( + + + + + + {t('%s other issues', otherCount)} + + + + {supergroup.title} + + + ); +} + +const Bar = styled('button')` + position: relative; + display: flex; + align-items: center; + gap: ${p => p.theme.space.sm}; + width: 100%; + padding: ${p => p.theme.space.sm} ${p => p.theme.space.xl}; + background: ${p => p.theme.tokens.background.secondary}; + border: none; + border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; + cursor: pointer; + color: ${p => p.theme.tokens.content.secondary}; + font-family: inherit; + font-size: ${p => p.theme.font.size.sm}; + line-height: 1.4; + text-align: left; +`; + +const CurvedArrow = styled('span')` + font-size: ${p => p.theme.font.size.lg}; + line-height: 1; +`; + +const StyledIconStack = styled(IconStack)` + flex-shrink: 0; +`; + +const Dot = styled('div')` + width: 3px; + height: 3px; + border-radius: 50%; + background: ${p => p.theme.tokens.border.secondary}; + flex-shrink: 0; +`; + +const Title = styled(Text)` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +`; diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index b7fe8cd7f099ad..772187e9d1ec22 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -1,3 +1,4 @@ +import {Fragment} from 'react'; import {useTheme} from '@emotion/react'; import type {IndexedMembersByProject} from 'sentry/actionCreators/members'; @@ -5,8 +6,10 @@ import type {GroupListColumn} from 'sentry/components/issues/groupList'; import {LoadingError} from 'sentry/components/loadingError'; import {PanelBody} from 'sentry/components/panels/panelBody'; import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; +import {StackIndicatorBar} from 'sentry/components/stream/stackIndicatorBar'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; +import {useSuperGroupForIssues} from 'sentry/utils/supergroup/useSuperGroupForIssues'; import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -132,6 +135,8 @@ function GroupList({ onActionTaken, }: GroupListProps) { const theme = useTheme(); + const organization = useOrganization(); + const {getSuperGroupForIssue} = useSuperGroupForIssues(); const [isSavedSearchesOpen] = useSyncedLocalStorageState( SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY, false @@ -141,6 +146,9 @@ function GroupList({ `(width < ${isSavedSearchesOpen ? theme.breakpoints.xl : theme.breakpoints.md})` ); + const hasTopIssuesUI = organization.features.includes('top-issues-ui'); + const seenSupergroups = new Set(); + return ( {groupIds.map(id => { @@ -151,20 +159,30 @@ function GroupList({ return null; } + const sg = hasTopIssuesUI ? getSuperGroupForIssue(id) : null; + const showStackBar = sg && sg.group_ids.length > 1 && !seenSupergroups.has(sg.id); + if (sg) { + seenSupergroups.add(sg.id); + } + return ( - onActionTaken([id], {priority})} - withColumns={COLUMNS} - /> + + onActionTaken([id], {priority})} + withColumns={COLUMNS} + /> + {showStackBar && ( + + )} + ); })} From 46ca7f1c3027fc396a30fb7f1e13de923c34bf15 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Tue, 24 Mar 2026 11:15:09 -0700 Subject: [PATCH 2/8] feat(issues): expand stack bar inline with lazily fetched sibling issues Co-authored-by: Claude --- .../components/stream/stackIndicatorBar.tsx | 103 ++++++++++++++---- static/app/views/issueList/groupListBody.tsx | 6 +- 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/static/app/components/stream/stackIndicatorBar.tsx b/static/app/components/stream/stackIndicatorBar.tsx index e84f559221b9cb..99d9dacdbe4986 100644 --- a/static/app/components/stream/stackIndicatorBar.tsx +++ b/static/app/components/stream/stackIndicatorBar.tsx @@ -1,42 +1,88 @@ +import {Fragment, useState} from 'react'; import styled from '@emotion/styled'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; import {Text} from '@sentry/scraps/text'; -import {useDrawer} from 'sentry/components/globalDrawer'; -import {IconStack} from 'sentry/icons'; +import type {GroupListColumn} from 'sentry/components/issues/groupList'; +import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; +import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; +import {IconChevron, IconStack} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer'; +import type {Group} from 'sentry/types/group'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import {useOrganization} from 'sentry/utils/useOrganization'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; +const EXPANDED_COLUMNS: GroupListColumn[] = ['event', 'users', 'priority', 'assignee']; + interface Props { + currentGroupId: string; otherCount: number; supergroup: SupergroupDetail; } -export function StackIndicatorBar({supergroup, otherCount}: Props) { - const {openDrawer} = useDrawer(); +export function StackIndicatorBar({supergroup, otherCount, currentGroupId}: Props) { + const [expanded, setExpanded] = useState(false); + const organization = useOrganization(); + + const otherIds = supergroup.group_ids.map(String).filter(id => id !== currentGroupId); + + const issueIdQuery = + otherIds.length === 1 + ? `issue.id:${otherIds[0]}` + : `issue.id:[${otherIds.join(',')}]`; - const handleClick = () => { - openDrawer(() => , { - ariaLabel: t('Supergroup details'), - drawerKey: 'supergroup-drawer', - }); - }; + const {data: groups, isPending} = useApiQuery( + [ + getApiUrl('/organizations/$organizationIdOrSlug/issues/', { + path: {organizationIdOrSlug: organization.slug}, + }), + {query: {query: issueIdQuery, limit: 25, project: ALL_ACCESS_PROJECTS}}, + ], + {staleTime: 30_000, enabled: expanded && otherIds.length > 0} + ); return ( - - - - - - {t('%s other issues', otherCount)} - - - - {supergroup.title} - - + + setExpanded(prev => !prev)}> + + + + + {t('%s related issues', otherCount)} + + + + {supergroup.title} + + + + + {expanded && ( + + {isPending + ? Array.from({length: Math.min(otherCount, 5)}).map((_, i) => ( + + )) + : groups?.map(group => ( + + ))} + + )} + ); } @@ -81,3 +127,14 @@ const Title = styled(Text)` text-overflow: ellipsis; min-width: 0; `; + +const ExpandIcon = styled(IconChevron)` + flex-shrink: 0; + margin-left: auto; +`; + +const ExpandedContent = styled('div')` + border-left: 3px solid ${p => p.theme.tokens.border.primary}; + background: ${p => p.theme.tokens.background.secondary}; + border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; +`; diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index 772187e9d1ec22..85084224e16602 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -180,7 +180,11 @@ function GroupList({ withColumns={COLUMNS} /> {showStackBar && ( - + )} ); From 22e1b4b0e475ad629bd3a142c2c5f7c0aa867f16 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Tue, 24 Mar 2026 11:30:14 -0700 Subject: [PATCH 3/8] feat(issues): collapse duplicate supergroup members from search results Co-authored-by: Claude --- .github/CODEOWNERS | 1 + static/app/views/issueList/groupListBody.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2c63852ce151ea..0c88e5a8c3201b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -662,6 +662,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/components/events/highlights/ @getsentry/issue-workflow /static/app/components/issues/ @getsentry/issue-workflow /static/app/components/stackTrace/ @getsentry/issue-workflow +/static/app/components/stream/stackIndicatorBar.tsx @getsentry/issue-detection-frontend /static/app/views/issueList/ @getsentry/issue-workflow /static/app/views/issueList/pages/supergroups.tsx @getsentry/issue-detection-frontend /static/app/views/issueList/supergroups/ @getsentry/issue-detection-frontend diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index 85084224e16602..ba3ab0b8da2550 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -160,11 +160,17 @@ function GroupList({ } const sg = hasTopIssuesUI ? getSuperGroupForIssue(id) : null; - const showStackBar = sg && sg.group_ids.length > 1 && !seenSupergroups.has(sg.id); + const isFirstInStack = + sg && sg.group_ids.length > 1 && !seenSupergroups.has(sg.id); if (sg) { seenSupergroups.add(sg.id); } + // Collapse duplicate supergroup members — they're accessible via the stack bar + if (sg && sg.group_ids.length > 1 && !isFirstInStack) { + return null; + } + return ( onActionTaken([id], {priority})} withColumns={COLUMNS} /> - {showStackBar && ( + {isFirstInStack && ( Date: Wed, 25 Mar 2026 13:13:25 -0700 Subject: [PATCH 4/8] feat(issues): render supergroup as single rich row in issue feed When issues belong to a supergroup, the supergroup summary renders as a single row matching the issue row layout (error type, title, code area). Matched issues are collapsed into the row showing 'X / Y issues matched'. Co-authored-by: Claude --- .github/CODEOWNERS | 1 + .../components/stream/stackIndicatorBar.tsx | 41 +++--- .../app/components/stream/supergroupRow.tsx | 134 ++++++++++++++++++ static/app/views/issueList/groupListBody.tsx | 134 ++++++++++++------ 4 files changed, 247 insertions(+), 63 deletions(-) create mode 100644 static/app/components/stream/supergroupRow.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0c88e5a8c3201b..ba7b8ac9b7f475 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -663,6 +663,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/components/issues/ @getsentry/issue-workflow /static/app/components/stackTrace/ @getsentry/issue-workflow /static/app/components/stream/stackIndicatorBar.tsx @getsentry/issue-detection-frontend +/static/app/components/stream/supergroupRow.tsx @getsentry/issue-detection-frontend /static/app/views/issueList/ @getsentry/issue-workflow /static/app/views/issueList/pages/supergroups.tsx @getsentry/issue-detection-frontend /static/app/views/issueList/supergroups/ @getsentry/issue-detection-frontend diff --git a/static/app/components/stream/stackIndicatorBar.tsx b/static/app/components/stream/stackIndicatorBar.tsx index 99d9dacdbe4986..59fe8b66127d5d 100644 --- a/static/app/components/stream/stackIndicatorBar.tsx +++ b/static/app/components/stream/stackIndicatorBar.tsx @@ -18,16 +18,17 @@ import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; const EXPANDED_COLUMNS: GroupListColumn[] = ['event', 'users', 'priority', 'assignee']; interface Props { - currentGroupId: string; + excludeGroupIds: string[]; otherCount: number; supergroup: SupergroupDetail; } -export function StackIndicatorBar({supergroup, otherCount, currentGroupId}: Props) { +export function StackIndicatorBar({supergroup, otherCount, excludeGroupIds}: Props) { const [expanded, setExpanded] = useState(false); const organization = useOrganization(); - const otherIds = supergroup.group_ids.map(String).filter(id => id !== currentGroupId); + const excludeSet = new Set(excludeGroupIds); + const otherIds = supergroup.group_ids.map(String).filter(id => !excludeSet.has(id)); const issueIdQuery = otherIds.length === 1 @@ -44,23 +45,28 @@ export function StackIndicatorBar({supergroup, otherCount, currentGroupId}: Prop {staleTime: 30_000, enabled: expanded && otherIds.length > 0} ); + const hasMore = otherCount > 0; + return ( - setExpanded(prev => !prev)}> - - + setExpanded(prev => !prev) : undefined}> + {hasMore && } - - {t('%s related issues', otherCount)} - - + {hasMore ? ( + + + {t('%s related issues', otherCount)} + + + + ) : null} {supergroup.title} - + {hasMore && } - {expanded && ( + {expanded && hasMore && ( {isPending ? Array.from({length: Math.min(otherCount, 5)}).map((_, i) => ( @@ -86,17 +92,14 @@ export function StackIndicatorBar({supergroup, otherCount, currentGroupId}: Prop ); } -const Bar = styled('button')` +const Bar = styled('div')` position: relative; display: flex; align-items: center; gap: ${p => p.theme.space.sm}; width: 100%; padding: ${p => p.theme.space.sm} ${p => p.theme.space.xl}; - background: ${p => p.theme.tokens.background.secondary}; - border: none; border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; - cursor: pointer; color: ${p => p.theme.tokens.content.secondary}; font-family: inherit; font-size: ${p => p.theme.font.size.sm}; @@ -104,11 +107,6 @@ const Bar = styled('button')` text-align: left; `; -const CurvedArrow = styled('span')` - font-size: ${p => p.theme.font.size.lg}; - line-height: 1; -`; - const StyledIconStack = styled(IconStack)` flex-shrink: 0; `; @@ -135,6 +133,5 @@ const ExpandIcon = styled(IconChevron)` const ExpandedContent = styled('div')` border-left: 3px solid ${p => p.theme.tokens.border.primary}; - background: ${p => p.theme.tokens.background.secondary}; border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; `; diff --git a/static/app/components/stream/supergroupRow.tsx b/static/app/components/stream/supergroupRow.tsx new file mode 100644 index 00000000000000..9a846f9068fe13 --- /dev/null +++ b/static/app/components/stream/supergroupRow.tsx @@ -0,0 +1,134 @@ +import styled from '@emotion/styled'; + +import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; +import {Text} from '@sentry/scraps/text'; + +import {useDrawer} from 'sentry/components/globalDrawer'; +import {PanelItem} from 'sentry/components/panels/panelItem'; +import {IconStack} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer'; +import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; + +interface SupergroupRowProps { + matchedCount: number; + supergroup: SupergroupDetail; +} + +export function SupergroupRow({supergroup, matchedCount}: SupergroupRowProps) { + const {openDrawer} = useDrawer(); + const issueCount = supergroup.group_ids.length; + + const handleClick = () => { + openDrawer(() => , { + ariaLabel: t('Supergroup details'), + drawerKey: 'supergroup-drawer', + }); + }; + + return ( + + + + + + + {/* Line 1: error type — mirrors issue's error type title */} + {supergroup.error_type ? ( + {supergroup.error_type} + ) : null} + {/* Line 2: supergroup title — mirrors issue's error message */} + + {supergroup.title} + + {/* Line 3: metadata — mirrors issue's shortId | location | badges */} + + {supergroup.code_area ? ( + + {supergroup.code_area} + + ) : null} + {supergroup.code_area && matchedCount > 0 ? : null} + {matchedCount > 0 ? ( + + + {matchedCount} + + {' / '} + + {issueCount} + + {' '} + {t('issues matched')} + + ) : null} + + + + ); +} + +const Wrapper = styled(PanelItem)` + position: relative; + padding: ${p => p.theme.space.lg} ${p => p.theme.space.xl}; + cursor: pointer; + gap: ${p => p.theme.space.lg}; + min-height: 82px; + align-items: flex-start; +`; + +const IconArea = styled('div')` + padding-top: ${p => p.theme.space.xs}; + flex-shrink: 0; +`; + +const AccentIcon = styled(IconStack)` + color: ${p => p.theme.tokens.graphics.accent.vibrant}; +`; + +const Content = styled('div')` + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +`; + +const ErrorType = styled(Text)` + margin-bottom: ${p => p.theme.space.xs}; + font-weight: ${p => p.theme.font.weight.sans.medium}; +`; + +const Subtitle = styled(Text)` + margin-bottom: 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const MetaRow = styled('div')` + display: inline-grid; + grid-auto-flow: column dense; + gap: ${p => p.theme.space.sm}; + justify-content: start; + align-items: center; + color: ${p => p.theme.tokens.content.secondary}; + font-size: ${p => p.theme.font.size.sm}; + white-space: nowrap; + line-height: 1.2; + min-height: ${p => p.theme.space.xl}; +`; + +const MetaText = styled(Text)` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +`; + +const Dot = styled('div')` + width: 3px; + height: 3px; + border-radius: 50%; + background: ${p => p.theme.tokens.border.secondary}; + flex-shrink: 0; +`; diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index ba3ab0b8da2550..96ac032402050c 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -1,4 +1,3 @@ -import {Fragment} from 'react'; import {useTheme} from '@emotion/react'; import type {IndexedMembersByProject} from 'sentry/actionCreators/members'; @@ -6,7 +5,7 @@ import type {GroupListColumn} from 'sentry/components/issues/groupList'; import {LoadingError} from 'sentry/components/loadingError'; import {PanelBody} from 'sentry/components/panels/panelBody'; import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; -import {StackIndicatorBar} from 'sentry/components/stream/stackIndicatorBar'; +import {SupergroupRow} from 'sentry/components/stream/supergroupRow'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; import {useSuperGroupForIssues} from 'sentry/utils/supergroup/useSuperGroupForIssues'; @@ -14,6 +13,7 @@ import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; +import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; import {NoGroupsHandler} from './noGroupsHandler'; @@ -53,6 +53,12 @@ const COLUMNS: GroupListColumn[] = [ 'lastTriggered', ]; +const NESTED_COLUMNS: GroupListColumn[] = ['event', 'users', 'priority', 'assignee']; + +type RenderItem = + | {id: string; type: 'issue'} + | {matchingIds: string[]; supergroup: SupergroupDetail; type: 'supergroup'}; + function LoadingSkeleton({ pageSize, displayReprocessingLayout, @@ -126,6 +132,58 @@ export function GroupListBody({ ); } +/** + * Build a render plan that groups issues by supergroup. + * Issues sharing a supergroup are collected into a single entry + * positioned where the first member appears. + */ +function buildRenderItems( + groupIds: string[], + getSuperGroupForIssue: (id: string) => SupergroupDetail | null | undefined, + enabled: boolean +): RenderItem[] { + if (!enabled) { + return groupIds.map(id => ({type: 'issue' as const, id})); + } + + const sgForId = new Map(); + const sgMembers = new Map(); + + for (const id of groupIds) { + const sg = getSuperGroupForIssue(id); + if (sg && sg.group_ids.length > 1) { + sgForId.set(id, sg); + if (!sgMembers.has(sg.id)) { + sgMembers.set(sg.id, []); + } + sgMembers.get(sg.id)!.push(id); + } else { + sgForId.set(id, null); + } + } + + const seen = new Set(); + const items: RenderItem[] = []; + + for (const id of groupIds) { + const sg = sgForId.get(id); + if (sg) { + if (!seen.has(sg.id)) { + seen.add(sg.id); + items.push({ + type: 'supergroup', + supergroup: sg, + matchingIds: sgMembers.get(sg.id)!, + }); + } + } else { + items.push({type: 'issue', id}); + } + } + + return items; +} + function GroupList({ groupIds, memberList, @@ -147,54 +205,48 @@ function GroupList({ ); const hasTopIssuesUI = organization.features.includes('top-issues-ui'); - const seenSupergroups = new Set(); + const renderItems = buildRenderItems(groupIds, getSuperGroupForIssue, hasTopIssuesUI); + + const renderStreamGroup = (id: string, columns: GroupListColumn[]) => { + const group = GroupStore.get(id) as Group | undefined; + if (!group) { + return null; + } + return ( + onActionTaken([id], {priority})} + withColumns={columns} + /> + ); + }; return ( - {groupIds.map(id => { - const hasGuideAnchor = id === topIssue; - const group = GroupStore.get(id) as Group | undefined; - - if (!group) { - return null; + {renderItems.map(item => { + if (item.type === 'issue') { + return renderStreamGroup(item.id, COLUMNS); } - const sg = hasTopIssuesUI ? getSuperGroupForIssue(id) : null; - const isFirstInStack = - sg && sg.group_ids.length > 1 && !seenSupergroups.has(sg.id); - if (sg) { - seenSupergroups.add(sg.id); - } - - // Collapse duplicate supergroup members — they're accessible via the stack bar - if (sg && sg.group_ids.length > 1 && !isFirstInStack) { - return null; - } + const {supergroup, matchingIds} = item; return ( - - onActionTaken([id], {priority})} - withColumns={COLUMNS} - /> - {isFirstInStack && ( - - )} - + ); })} ); } + From 85222b622bd50ec83d02d05ea8afb84675d0d9f4 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 27 Mar 2026 09:40:13 -0700 Subject: [PATCH 5/8] feat(issues): Prefetch supergroup data and add stats columns to supergroup rows Fetch supergroup assignments eagerly in overview.tsx via useSuperGroups hook, blocking the issue stream until data is available to prevent pop-in when issues are regrouped. Supergroup rows now display aggregated stats (last seen, age, trend chart, event/user counts) from member groups and align to the same grid as regular issue rows. Co-Authored-By: Claude --- .../app/components/stream/supergroupRow.tsx | 250 ++++++++++++++---- .../supergroup/aggregateSupergroupStats.ts | 57 ++++ .../app/utils/supergroup/useSuperGroups.tsx | 58 ++++ static/app/views/issueList/groupListBody.tsx | 18 +- static/app/views/issueList/issueListTable.tsx | 4 + static/app/views/issueList/overview.tsx | 7 +- 6 files changed, 334 insertions(+), 60 deletions(-) create mode 100644 static/app/utils/supergroup/aggregateSupergroupStats.ts create mode 100644 static/app/utils/supergroup/useSuperGroups.tsx diff --git a/static/app/components/stream/supergroupRow.tsx b/static/app/components/stream/supergroupRow.tsx index 9a846f9068fe13..bd0e3119bac7fb 100644 --- a/static/app/components/stream/supergroupRow.tsx +++ b/static/app/components/stream/supergroupRow.tsx @@ -1,108 +1,162 @@ +import {useState} from 'react'; import styled from '@emotion/styled'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; import {Text} from '@sentry/scraps/text'; +import {GroupStatusChart} from 'sentry/components/charts/groupStatusChart'; +import {Count} from 'sentry/components/count'; import {useDrawer} from 'sentry/components/globalDrawer'; import {PanelItem} from 'sentry/components/panels/panelItem'; +import {Placeholder} from 'sentry/components/placeholder'; +import {TimeSince} from 'sentry/components/timeSince'; import {IconStack} from 'sentry/icons'; import {t} from 'sentry/locale'; +import type {AggregatedSupergroupStats} from 'sentry/utils/supergroup/aggregateSupergroupStats'; +import {COLUMN_BREAKPOINTS} from 'sentry/views/issueList/actions/utils'; import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; interface SupergroupRowProps { matchedCount: number; supergroup: SupergroupDetail; + aggregatedStats?: AggregatedSupergroupStats | null; } -export function SupergroupRow({supergroup, matchedCount}: SupergroupRowProps) { - const {openDrawer} = useDrawer(); +export function SupergroupRow({ + supergroup, + matchedCount, + aggregatedStats, +}: SupergroupRowProps) { + const {openDrawer, isDrawerOpen} = useDrawer(); + const [isActive, setIsActive] = useState(false); const issueCount = supergroup.group_ids.length; const handleClick = () => { + setIsActive(true); openDrawer(() => , { ariaLabel: t('Supergroup details'), drawerKey: 'supergroup-drawer', + onClose: () => setIsActive(false), }); }; + const highlighted = isActive && isDrawerOpen; + return ( - + - - {/* Line 1: error type — mirrors issue's error type title */} + {supergroup.error_type ? ( - {supergroup.error_type} + + {supergroup.error_type} + ) : null} - {/* Line 2: supergroup title — mirrors issue's error message */} - + {supergroup.title} - - {/* Line 3: metadata — mirrors issue's shortId | location | badges */} + {supergroup.code_area ? ( - + {supergroup.code_area} - + ) : null} {supergroup.code_area && matchedCount > 0 ? : null} {matchedCount > 0 ? ( - - {matchedCount} - - {' / '} - - {issueCount} - - {' '} - {t('issues matched')} + {matchedCount} / {issueCount} {t('issues matched')} ) : null} - + + + + {aggregatedStats?.lastSeen ? ( + + ) : ( + + )} + + + + {aggregatedStats?.firstSeen ? ( + + ) : ( + + )} + + + + {aggregatedStats?.mergedStats && aggregatedStats.mergedStats.length > 0 ? ( + + ) : ( + + )} + + + + {aggregatedStats ? ( + + ) : ( + + )} + + + + {aggregatedStats ? ( + + ) : ( + + )} + + + + ); } -const Wrapper = styled(PanelItem)` +const Wrapper = styled(PanelItem)<{highlighted: boolean}>` position: relative; - padding: ${p => p.theme.space.lg} ${p => p.theme.space.xl}; + line-height: 1.1; + padding: ${p => p.theme.space.md} 0; cursor: pointer; - gap: ${p => p.theme.space.lg}; min-height: 82px; - align-items: flex-start; -`; - -const IconArea = styled('div')` - padding-top: ${p => p.theme.space.xs}; - flex-shrink: 0; -`; - -const AccentIcon = styled(IconStack)` - color: ${p => p.theme.tokens.graphics.accent.vibrant}; + background: ${p => + p.highlighted ? p.theme.tokens.background.secondary : 'transparent'}; `; -const Content = styled('div')` +const Summary = styled('div')` + overflow: hidden; + margin-left: ${p => p.theme.space.md}; + margin-right: ${p => p.theme.space['3xl']}; + flex: 1; display: flex; flex-direction: column; - min-width: 0; - flex: 1; + justify-content: center; + gap: ${p => p.theme.space.xs}; + font-size: ${p => p.theme.font.size.md}; `; -const ErrorType = styled(Text)` - margin-bottom: ${p => p.theme.space.xs}; - font-weight: ${p => p.theme.font.weight.sans.medium}; +const IconArea = styled('div')` + align-self: flex-start; + width: 32px; + display: flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; + padding-top: ${p => p.theme.space.sm}; `; -const Subtitle = styled(Text)` - margin-bottom: 5px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +const AccentIcon = styled(IconStack)` + color: ${p => p.theme.tokens.graphics.accent.vibrant}; `; const MetaRow = styled('div')` @@ -118,17 +172,105 @@ const MetaRow = styled('div')` min-height: ${p => p.theme.space.xl}; `; -const MetaText = styled(Text)` - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -`; - const Dot = styled('div')` width: 3px; height: 3px; border-radius: 50%; - background: ${p => p.theme.tokens.border.secondary}; + background: currentcolor; flex-shrink: 0; `; + +const LastSeenColumn = styled('div')` + display: flex; + align-items: center; + justify-content: flex-end; + width: 86px; + padding-right: ${p => p.theme.space.xl}; + margin-right: ${p => p.theme.space.xl}; + + @container (width < ${COLUMN_BREAKPOINTS.LAST_SEEN}) { + display: none; + } +`; + +const FirstSeenColumn = styled('div')` + display: flex; + align-items: center; + justify-content: flex-end; + width: 50px; + padding-right: ${p => p.theme.space.xl}; + margin-right: ${p => p.theme.space.xl}; + + @container (width < ${COLUMN_BREAKPOINTS.FIRST_SEEN}) { + display: none; + } +`; + +const ChartColumn = styled('div')` + width: 175px; + align-self: center; + margin-right: ${p => p.theme.space.xl}; + + @container (width < ${COLUMN_BREAKPOINTS.TREND}) { + display: none; + } +`; + +const EventsColumn = styled('div')` + display: flex; + justify-content: flex-end; + text-align: right; + align-items: center; + align-self: center; + padding-right: ${p => p.theme.space.xl}; + margin-right: ${p => p.theme.space.xl}; + width: 60px; + + @container (width < ${COLUMN_BREAKPOINTS.EVENTS}) { + display: none; + } +`; + +const UsersColumn = styled('div')` + display: flex; + justify-content: flex-end; + text-align: right; + align-items: center; + align-self: center; + padding-right: ${p => p.theme.space.xl}; + margin-right: ${p => p.theme.space.xl}; + width: 60px; + + @container (width < ${COLUMN_BREAKPOINTS.USERS}) { + display: none; + } +`; + +const PrimaryCount = styled(Count)` + font-size: ${p => p.theme.font.size.md}; + display: flex; + justify-content: right; + margin-bottom: ${p => p.theme.space['2xs']}; + font-variant-numeric: tabular-nums; +`; + +// Empty spacers to match StreamGroup column widths and keep alignment +const PrioritySpacer = styled('div')` + width: 64px; + padding-right: ${p => p.theme.space.xl}; + margin-right: ${p => p.theme.space.xl}; + + @container (width < ${COLUMN_BREAKPOINTS.PRIORITY}) { + display: none; + } +`; + +const AssigneeSpacer = styled('div')` + width: 66px; + padding-right: ${p => p.theme.space.xl}; + margin-right: ${p => p.theme.space.xl}; + + @media (max-width: ${COLUMN_BREAKPOINTS.ASSIGNEE}) { + display: none; + } +`; diff --git a/static/app/utils/supergroup/aggregateSupergroupStats.ts b/static/app/utils/supergroup/aggregateSupergroupStats.ts new file mode 100644 index 00000000000000..7e7400c5610a40 --- /dev/null +++ b/static/app/utils/supergroup/aggregateSupergroupStats.ts @@ -0,0 +1,57 @@ +import type {Group, TimeseriesValue} from 'sentry/types/group'; + +export interface AggregatedSupergroupStats { + eventCount: number; + firstSeen: string | null; + lastSeen: string | null; + mergedStats: TimeseriesValue[]; + userCount: number; +} + +/** + * Aggregate stats from member groups for display in a supergroup row. + * Sums event/user counts, takes min firstSeen and max lastSeen, + * and point-wise sums the trend data. + */ +export function aggregateSupergroupStats( + groups: Group[], + statsPeriod: string +): AggregatedSupergroupStats | null { + if (groups.length === 0) { + return null; + } + + let eventCount = 0; + let userCount = 0; + let firstSeen: string | null = null; + let lastSeen: string | null = null; + let mergedStats: TimeseriesValue[] = []; + + for (const group of groups) { + eventCount += parseInt(group.count, 10) || 0; + userCount += group.userCount || 0; + + const gFirstSeen = group.lifetime?.firstSeen ?? group.firstSeen; + if (gFirstSeen && (!firstSeen || gFirstSeen < firstSeen)) { + firstSeen = gFirstSeen; + } + + const gLastSeen = group.lifetime?.lastSeen ?? group.lastSeen; + if (gLastSeen && (!lastSeen || gLastSeen > lastSeen)) { + lastSeen = gLastSeen; + } + + const stats = group.stats?.[statsPeriod]; + if (stats) { + if (mergedStats.length === 0) { + mergedStats = stats.map(([ts, val]) => [ts, val] as TimeseriesValue); + } else { + for (let i = 0; i < Math.min(mergedStats.length, stats.length); i++) { + mergedStats[i] = [mergedStats[i][0], mergedStats[i][1] + stats[i][1]]; + } + } + } + } + + return {eventCount, userCount, firstSeen, lastSeen, mergedStats}; +} diff --git a/static/app/utils/supergroup/useSuperGroups.tsx b/static/app/utils/supergroup/useSuperGroups.tsx new file mode 100644 index 00000000000000..664ea33df0a662 --- /dev/null +++ b/static/app/utils/supergroup/useSuperGroups.tsx @@ -0,0 +1,58 @@ +import {useMemo} from 'react'; + +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; + +export type SupergroupLookup = Record; + +/** + * Fetch supergroup assignments for a batch of group IDs. + * Returns a lookup map and loading state so callers can block rendering + * until the data is available, preventing pop-in when issues are regrouped. + */ +export function useSuperGroups(groupIds: string[]): { + data: SupergroupLookup; + isLoading: boolean; +} { + const organization = useOrganization(); + const hasTopIssuesUI = organization.features.includes('top-issues-ui'); + const enabled = hasTopIssuesUI && groupIds.length > 0; + + const {data: response, isLoading} = useApiQuery<{data: SupergroupDetail[]}>( + [ + getApiUrl('/organizations/$organizationIdOrSlug/seer/supergroups/by-group/', { + path: {organizationIdOrSlug: organization.slug}, + }), + { + query: { + group_id: groupIds, + }, + }, + ], + { + staleTime: 30_000, + enabled, + } + ); + + const lookup = useMemo(() => { + if (!response?.data) { + return {}; + } + + const result: SupergroupLookup = Object.fromEntries(groupIds.map(id => [id, null])); + for (const sg of response.data) { + for (const groupId of sg.group_ids) { + result[String(groupId)] = sg; + } + } + return result; + }, [response, groupIds]); + + return { + data: lookup, + isLoading: enabled && isLoading, + }; +} diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index 96ac032402050c..14a0bd3d071b77 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -8,7 +8,8 @@ import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; import {SupergroupRow} from 'sentry/components/stream/supergroupRow'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; -import {useSuperGroupForIssues} from 'sentry/utils/supergroup/useSuperGroupForIssues'; +import {aggregateSupergroupStats} from 'sentry/utils/supergroup/aggregateSupergroupStats'; +import type {SupergroupLookup} from 'sentry/utils/supergroup/useSuperGroups'; import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -31,6 +32,7 @@ type GroupListBodyProps = { query: string; refetchGroups: () => void; selectedProjectIds: number[]; + supergroupLookup?: SupergroupLookup; }; type GroupListProps = { @@ -40,6 +42,7 @@ type GroupListProps = { memberList: IndexedMembersByProject; onActionTaken: (itemIds: string[], data: IssueUpdateData) => void; query: string; + supergroupLookup?: SupergroupLookup; }; const COLUMNS: GroupListColumn[] = [ @@ -53,8 +56,6 @@ const COLUMNS: GroupListColumn[] = [ 'lastTriggered', ]; -const NESTED_COLUMNS: GroupListColumn[] = ['event', 'users', 'priority', 'assignee']; - type RenderItem = | {id: string; type: 'issue'} | {matchingIds: string[]; supergroup: SupergroupDetail; type: 'supergroup'}; @@ -91,6 +92,7 @@ export function GroupListBody({ selectedProjectIds, pageSize, onActionTaken, + supergroupLookup, }: GroupListBodyProps) { const api = useApi(); const organization = useOrganization(); @@ -128,6 +130,7 @@ export function GroupListBody({ displayReprocessingLayout={displayReprocessingLayout} groupStatsPeriod={groupStatsPeriod} onActionTaken={onActionTaken} + supergroupLookup={supergroupLookup} /> ); } @@ -191,10 +194,10 @@ function GroupList({ displayReprocessingLayout, groupStatsPeriod, onActionTaken, + supergroupLookup, }: GroupListProps) { const theme = useTheme(); const organization = useOrganization(); - const {getSuperGroupForIssue} = useSuperGroupForIssues(); const [isSavedSearchesOpen] = useSyncedLocalStorageState( SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY, false @@ -205,6 +208,7 @@ function GroupList({ ); const hasTopIssuesUI = organization.features.includes('top-issues-ui'); + const getSuperGroupForIssue = (id: string) => supergroupLookup?.[id] ?? null; const renderItems = buildRenderItems(groupIds, getSuperGroupForIssue, hasTopIssuesUI); const renderStreamGroup = (id: string, columns: GroupListColumn[]) => { @@ -237,16 +241,20 @@ function GroupList({ } const {supergroup, matchingIds} = item; + const memberGroups = matchingIds + .map(id => GroupStore.get(id) as Group | undefined) + .filter((g): g is Group => g !== undefined); + const stats = aggregateSupergroupStats(memberGroups, groupStatsPeriod); return ( ); })} ); } - diff --git a/static/app/views/issueList/issueListTable.tsx b/static/app/views/issueList/issueListTable.tsx index c3419f993bbe0b..b81579c6ed50ac 100644 --- a/static/app/views/issueList/issueListTable.tsx +++ b/static/app/views/issueList/issueListTable.tsx @@ -10,6 +10,7 @@ import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; import {DemoTourElement, DemoTourStep} from 'sentry/utils/demoMode/demoTours'; import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; +import type {SupergroupLookup} from 'sentry/utils/supergroup/useSuperGroups'; import {useLocation} from 'sentry/utils/useLocation'; import {IssueListActions} from 'sentry/views/issueList/actions'; import {GroupListBody} from 'sentry/views/issueList/groupListBody'; @@ -40,6 +41,7 @@ interface IssueListTableProps { selection: PageFilters; statsLoading: boolean; statsPeriod: string; + supergroupLookup?: SupergroupLookup; } export function IssueListTable({ @@ -64,6 +66,7 @@ export function IssueListTable({ paginationAnalyticsEvent, issuesSuccessfullyLoaded, pageSize, + supergroupLookup, }: IssueListTableProps) { const location = useLocation(); @@ -124,6 +127,7 @@ export function IssueListTable({ pageSize={pageSize} refetchGroups={refetchGroups} onActionTaken={onActionTaken} + supergroupLookup={supergroupLookup} /> diff --git a/static/app/views/issueList/overview.tsx b/static/app/views/issueList/overview.tsx index bbcb40783135f3..46511ee0b38bc0 100644 --- a/static/app/views/issueList/overview.tsx +++ b/static/app/views/issueList/overview.tsx @@ -37,6 +37,7 @@ import type {RequestError} from 'sentry/utils/requestError/requestError'; import {useDisableRouteAnalytics} from 'sentry/utils/routeAnalytics/useDisableRouteAnalytics'; import {useRouteAnalyticsEventNames} from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; +import {useSuperGroups} from 'sentry/utils/supergroup/useSuperGroups'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useApi} from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; @@ -163,6 +164,9 @@ function IssueListOverview({ useIssuesINPObserver(); + const {data: supergroupLookup, isLoading: supergroupsLoading} = + useSuperGroups(groupIds); + const onRealtimePoll = useCallback( (data: any, {queryCount: newQueryCount}: {queryCount: number}) => { // Note: We do not update state with cursors from polling, @@ -899,8 +903,9 @@ function IssueListOverview({ displayReprocessingActions={displayReprocessingActions} memberList={memberList} selectedProjectIds={selection.projects} - issuesLoading={issuesLoading} + issuesLoading={issuesLoading || supergroupsLoading} statsLoading={statsLoading} + supergroupLookup={supergroupLookup} error={error} refetchGroups={fetchData} paginationCaption={ From d5f78ad38df6252904ec33deef8a12d941d1e6d6 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 27 Mar 2026 13:55:40 -0700 Subject: [PATCH 6/8] cleanup and remove previous experiement --- .../app/components/group/issueSuperGroup.tsx | 109 -------------- static/app/components/groupMetaRow.tsx | 8 - .../components/stream/stackIndicatorBar.tsx | 137 ------------------ .../supergroup/useSuperGroupForIssues.tsx | 61 -------- .../supergroups/supergroupDrawer.tsx | 5 +- 5 files changed, 1 insertion(+), 319 deletions(-) delete mode 100644 static/app/components/group/issueSuperGroup.tsx delete mode 100644 static/app/components/stream/stackIndicatorBar.tsx delete mode 100644 static/app/utils/supergroup/useSuperGroupForIssues.tsx diff --git a/static/app/components/group/issueSuperGroup.tsx b/static/app/components/group/issueSuperGroup.tsx deleted file mode 100644 index 62a717a650a618..00000000000000 --- a/static/app/components/group/issueSuperGroup.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import styled from '@emotion/styled'; - -import {Container, Flex, Stack} from '@sentry/scraps/layout'; -import {Text} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {useDrawer} from 'sentry/components/globalDrawer'; -import {IconFocus, IconStack} from 'sentry/icons'; -import {t, tn} from 'sentry/locale'; -import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer'; -import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; - -interface Props { - supergroup: SupergroupDetail; -} - -/** - * Show a badge indicating an issue belongs to a supergroup. - */ -export function IssueSuperGroup({supergroup}: Props) { - const {openDrawer} = useDrawer(); - - const tooltipTitle = ( - - - - {supergroup.title} - - - {tn('%s issue', '%s issues', supergroup.group_ids.length)} - - - - - {supergroup.error_type ? ( - - - {t('Error:')} - - - {supergroup.error_type} - - - ) : null} - {supergroup.code_area ? ( - - - {t('Location:')} - - - {supergroup.code_area} - - - ) : null} - - - {supergroup.summary ? ( - - - - - - {t('Root Cause')} - - - - {supergroup.summary} - - - - ) : null} - - ); - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - openDrawer(() => , { - ariaLabel: t('Supergroup details'), - drawerKey: 'supergroup-drawer', - }); - }; - - return ( - - - - {supergroup.group_ids.length > 50 ? '50+' : supergroup.group_ids.length} - - - ); -} - -const SuperGroupButton = styled('button')` - display: inline-flex; - align-items: center; - background: none; - border: none; - padding: 0; - cursor: pointer; - color: ${p => p.theme.colors.gray500}; - font-size: ${p => p.theme.font.size.sm}; - gap: 0 ${p => p.theme.space.xs}; - position: relative; - - &:hover { - color: ${p => p.theme.tokens.interactive.link.accent.hover}; - } -`; diff --git a/static/app/components/groupMetaRow.tsx b/static/app/components/groupMetaRow.tsx index 1a4eac3eac4821..125c66974d7f49 100644 --- a/static/app/components/groupMetaRow.tsx +++ b/static/app/components/groupMetaRow.tsx @@ -13,7 +13,6 @@ import {TimesTag} from 'sentry/components/group/inboxBadges/timesTag'; import {UnhandledTag} from 'sentry/components/group/inboxBadges/unhandledTag'; import {IssueReplayCount} from 'sentry/components/group/issueReplayCount'; import {IssueSeerBadge} from 'sentry/components/group/issueSeerBadge'; -import {IssueSuperGroup} from 'sentry/components/group/issueSuperGroup'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import {extractSelectionParameters} from 'sentry/components/pageFilters/parse'; import {Placeholder} from 'sentry/components/placeholder'; @@ -24,7 +23,6 @@ import {defined} from 'sentry/utils'; import {getTitle} from 'sentry/utils/events'; import {useReplayCountForIssues} from 'sentry/utils/replayCount/useReplayCountForIssues'; import {projectCanLinkToReplay} from 'sentry/utils/replays/projectSupportsReplay'; -import {useSuperGroupForIssues} from 'sentry/utils/supergroup/useSuperGroupForIssues'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -75,7 +73,6 @@ export function GroupMetaRow({data, showAssignee, showLifetime = true}: Props) { const issuesPath = `/organizations/${organization.slug}/issues/`; const {getReplayCountForIssue} = useReplayCountForIssues(); - const {getSuperGroupForIssue} = useSuperGroupForIssues(); const showReplayCount = organization.features.includes('session-replay') && @@ -83,10 +80,6 @@ export function GroupMetaRow({data, showAssignee, showLifetime = true}: Props) { data.issueCategory && !!getReplayCountForIssue(data.id, data.issueCategory); - const supergroup = organization.features.includes('top-issues-ui') - ? getSuperGroupForIssue(data.id) - : undefined; - const autofixRunExists = getAutofixRunExists(data); const seerFixable = isIssueQuickFixable(data); const showSeer = @@ -126,7 +119,6 @@ export function GroupMetaRow({data, showAssignee, showLifetime = true}: Props) { ) : null, showReplayCount ? : null, - supergroup ? : null, showSeer ? : null, logger ? ( diff --git a/static/app/components/stream/stackIndicatorBar.tsx b/static/app/components/stream/stackIndicatorBar.tsx deleted file mode 100644 index 59fe8b66127d5d..00000000000000 --- a/static/app/components/stream/stackIndicatorBar.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import {Fragment, useState} from 'react'; -import styled from '@emotion/styled'; - -import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; -import {Text} from '@sentry/scraps/text'; - -import type {GroupListColumn} from 'sentry/components/issues/groupList'; -import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; -import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; -import {IconChevron, IconStack} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {Group} from 'sentry/types/group'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; - -const EXPANDED_COLUMNS: GroupListColumn[] = ['event', 'users', 'priority', 'assignee']; - -interface Props { - excludeGroupIds: string[]; - otherCount: number; - supergroup: SupergroupDetail; -} - -export function StackIndicatorBar({supergroup, otherCount, excludeGroupIds}: Props) { - const [expanded, setExpanded] = useState(false); - const organization = useOrganization(); - - const excludeSet = new Set(excludeGroupIds); - const otherIds = supergroup.group_ids.map(String).filter(id => !excludeSet.has(id)); - - const issueIdQuery = - otherIds.length === 1 - ? `issue.id:${otherIds[0]}` - : `issue.id:[${otherIds.join(',')}]`; - - const {data: groups, isPending} = useApiQuery( - [ - getApiUrl('/organizations/$organizationIdOrSlug/issues/', { - path: {organizationIdOrSlug: organization.slug}, - }), - {query: {query: issueIdQuery, limit: 25, project: ALL_ACCESS_PROJECTS}}, - ], - {staleTime: 30_000, enabled: expanded && otherIds.length > 0} - ); - - const hasMore = otherCount > 0; - - return ( - - setExpanded(prev => !prev) : undefined}> - {hasMore && } - - {hasMore ? ( - - - {t('%s related issues', otherCount)} - - - - ) : null} - - {supergroup.title} - - {hasMore && } - - - {expanded && hasMore && ( - - {isPending - ? Array.from({length: Math.min(otherCount, 5)}).map((_, i) => ( - - )) - : groups?.map(group => ( - - ))} - - )} - - ); -} - -const Bar = styled('div')` - position: relative; - display: flex; - align-items: center; - gap: ${p => p.theme.space.sm}; - width: 100%; - padding: ${p => p.theme.space.sm} ${p => p.theme.space.xl}; - border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; - color: ${p => p.theme.tokens.content.secondary}; - font-family: inherit; - font-size: ${p => p.theme.font.size.sm}; - line-height: 1.4; - text-align: left; -`; - -const StyledIconStack = styled(IconStack)` - flex-shrink: 0; -`; - -const Dot = styled('div')` - width: 3px; - height: 3px; - border-radius: 50%; - background: ${p => p.theme.tokens.border.secondary}; - flex-shrink: 0; -`; - -const Title = styled(Text)` - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -`; - -const ExpandIcon = styled(IconChevron)` - flex-shrink: 0; - margin-left: auto; -`; - -const ExpandedContent = styled('div')` - border-left: 3px solid ${p => p.theme.tokens.border.primary}; - border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; -`; diff --git a/static/app/utils/supergroup/useSuperGroupForIssues.tsx b/static/app/utils/supergroup/useSuperGroupForIssues.tsx deleted file mode 100644 index bab747aae8994b..00000000000000 --- a/static/app/utils/supergroup/useSuperGroupForIssues.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import {useCallback} from 'react'; - -import type {ApiResult} from 'sentry/api'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useAggregatedQueryKeys} from 'sentry/utils/api/useAggregatedQueryKeys'; -import type {ApiQueryKey} from 'sentry/utils/queryClient'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; - -type SupergroupState = Record; - -function supergroupReducer( - prevState: undefined | SupergroupState, - response: ApiResult<{data: SupergroupDetail[]}>, - aggregates: readonly string[] -): undefined | SupergroupState { - const defaults = Object.fromEntries( - aggregates.map(id => [id, null]) - ) as SupergroupState; - const supergroups = response[0].data; - const byGroupId: SupergroupState = Object.fromEntries( - supergroups.flatMap(sg => sg.group_ids.map(groupId => [String(groupId), sg])) - ); - return {...defaults, ...prevState, ...byGroupId}; -} - -/** - * Query results for whether an Issue/Group belongs to a supergroup. - */ -export function useSuperGroupForIssues() { - const organization = useOrganization(); - - const cache = useAggregatedQueryKeys({ - cacheKey: `/organizations/${organization.slug}/seer/supergroups/by-group/`, - bufferLimit: 25, - getQueryKey: useCallback( - (ids: readonly string[]): ApiQueryKey => [ - getApiUrl('/organizations/$organizationIdOrSlug/seer/supergroups/by-group/', { - path: {organizationIdOrSlug: organization.slug}, - }), - { - query: { - group_id: ids, - }, - }, - ], - [organization.slug] - ), - responseReducer: supergroupReducer, - }); - - const getSuperGroupForIssue = useCallback( - (id: string): SupergroupDetail | null | undefined => { - cache.buffer([id]); - return cache.data?.[id]; - }, - [cache] - ); - - return {getSuperGroupForIssue}; -} diff --git a/static/app/views/issueList/supergroups/supergroupDrawer.tsx b/static/app/views/issueList/supergroups/supergroupDrawer.tsx index a238000fad6326..885bdd9524464a 100644 --- a/static/app/views/issueList/supergroups/supergroupDrawer.tsx +++ b/static/app/views/issueList/supergroups/supergroupDrawer.tsx @@ -22,10 +22,7 @@ import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; export function SupergroupDetailDrawer({supergroup}: {supergroup: SupergroupDetail}) { const organization = useOrganization(); const placeholderRows = Math.min(supergroup.group_ids.length, 10); - const issueIdQuery = - supergroup.group_ids.length === 1 - ? `issue.id:${supergroup.group_ids[0]}` - : `issue.id:[${supergroup.group_ids.join(',')}]`; + const issueIdQuery = `issue.id:[${supergroup.group_ids.join(',')}]`; return ( From ee155b4c966da2a4038ee0fb5630ca3a8d601760 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 27 Mar 2026 14:00:23 -0700 Subject: [PATCH 7/8] codeowner --- .github/CODEOWNERS | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 88a4eb1fb4b1cd..52d6c230c62dbe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -673,8 +673,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/components/events/highlights/ @getsentry/issue-workflow /static/app/components/issues/ @getsentry/issue-workflow /static/app/components/stackTrace/ @getsentry/issue-workflow -/static/app/components/stream/stackIndicatorBar.tsx @getsentry/issue-detection-frontend -/static/app/components/stream/supergroupRow.tsx @getsentry/issue-detection-frontend +/static/app/components/stream/supergroupRow.tsx @getsentry/issue-detection-frontend /static/app/views/issueList/ @getsentry/issue-workflow /static/app/views/issueList/issueListSeerComboBox.tsx @getsentry/issue-workflow @getsentry/machine-learning-ai /static/app/views/issueList/pages/supergroups.tsx @getsentry/issue-detection-frontend From b61e554e8acd12368a99e39cb03674d54885900b Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 27 Mar 2026 14:16:28 -0700 Subject: [PATCH 8/8] ref: Simplify supergroup components and fix type errors Fix TypeScript errors in aggregateSupergroupStats by importing TimeseriesValue from the correct module and adding non-null assertions for bounded array access. Simplify supergroupRow by extracting shared DataColumn base for EventsColumn/UsersColumn, fixing AssigneeSpacer to use @container query consistently with other columns. Simplify buildRenderItems from two passes to a single pass and memoize the result with useMemo. Co-Authored-By: Claude Opus 4.6 --- .../app/components/stream/supergroupRow.tsx | 21 +++----- .../supergroup/aggregateSupergroupStats.ts | 5 +- static/app/views/issueList/groupListBody.tsx | 51 +++++++------------ 3 files changed, 28 insertions(+), 49 deletions(-) diff --git a/static/app/components/stream/supergroupRow.tsx b/static/app/components/stream/supergroupRow.tsx index bd0e3119bac7fb..6f07d00c579e51 100644 --- a/static/app/components/stream/supergroupRow.tsx +++ b/static/app/components/stream/supergroupRow.tsx @@ -30,8 +30,6 @@ export function SupergroupRow({ }: SupergroupRowProps) { const {openDrawer, isDrawerOpen} = useDrawer(); const [isActive, setIsActive] = useState(false); - const issueCount = supergroup.group_ids.length; - const handleClick = () => { setIsActive(true); openDrawer(() => , { @@ -67,7 +65,7 @@ export function SupergroupRow({ {supergroup.code_area && matchedCount > 0 ? : null} {matchedCount > 0 ? ( - {matchedCount} / {issueCount} {t('issues matched')} + {matchedCount} / {supergroup.group_ids.length} {t('issues matched')} ) : null} @@ -216,7 +214,7 @@ const ChartColumn = styled('div')` } `; -const EventsColumn = styled('div')` +const DataColumn = styled('div')` display: flex; justify-content: flex-end; text-align: right; @@ -225,22 +223,15 @@ const EventsColumn = styled('div')` padding-right: ${p => p.theme.space.xl}; margin-right: ${p => p.theme.space.xl}; width: 60px; +`; +const EventsColumn = styled(DataColumn)` @container (width < ${COLUMN_BREAKPOINTS.EVENTS}) { display: none; } `; -const UsersColumn = styled('div')` - display: flex; - justify-content: flex-end; - text-align: right; - align-items: center; - align-self: center; - padding-right: ${p => p.theme.space.xl}; - margin-right: ${p => p.theme.space.xl}; - width: 60px; - +const UsersColumn = styled(DataColumn)` @container (width < ${COLUMN_BREAKPOINTS.USERS}) { display: none; } @@ -270,7 +261,7 @@ const AssigneeSpacer = styled('div')` padding-right: ${p => p.theme.space.xl}; margin-right: ${p => p.theme.space.xl}; - @media (max-width: ${COLUMN_BREAKPOINTS.ASSIGNEE}) { + @container (width < ${COLUMN_BREAKPOINTS.ASSIGNEE}) { display: none; } `; diff --git a/static/app/utils/supergroup/aggregateSupergroupStats.ts b/static/app/utils/supergroup/aggregateSupergroupStats.ts index 7e7400c5610a40..09e7013875168f 100644 --- a/static/app/utils/supergroup/aggregateSupergroupStats.ts +++ b/static/app/utils/supergroup/aggregateSupergroupStats.ts @@ -1,4 +1,5 @@ -import type {Group, TimeseriesValue} from 'sentry/types/group'; +import type {TimeseriesValue} from 'sentry/types/core'; +import type {Group} from 'sentry/types/group'; export interface AggregatedSupergroupStats { eventCount: number; @@ -47,7 +48,7 @@ export function aggregateSupergroupStats( mergedStats = stats.map(([ts, val]) => [ts, val] as TimeseriesValue); } else { for (let i = 0; i < Math.min(mergedStats.length, stats.length); i++) { - mergedStats[i] = [mergedStats[i][0], mergedStats[i][1] + stats[i][1]]; + mergedStats[i] = [mergedStats[i]![0], mergedStats[i]![1] + stats[i]![1]]; } } } diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index 14a0bd3d071b77..8690b712f67d45 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -1,3 +1,4 @@ +import {useMemo} from 'react'; import {useTheme} from '@emotion/react'; import type {IndexedMembersByProject} from 'sentry/actionCreators/members'; @@ -135,11 +136,6 @@ export function GroupListBody({ ); } -/** - * Build a render plan that groups issues by supergroup. - * Issues sharing a supergroup are collected into a single entry - * positioned where the first member appears. - */ function buildRenderItems( groupIds: string[], getSuperGroupForIssue: (id: string) => SupergroupDetail | null | undefined, @@ -149,35 +145,19 @@ function buildRenderItems( return groupIds.map(id => ({type: 'issue' as const, id})); } - const sgForId = new Map(); - const sgMembers = new Map(); + const seen = new Map(); + const items: RenderItem[] = []; for (const id of groupIds) { const sg = getSuperGroupForIssue(id); if (sg && sg.group_ids.length > 1) { - sgForId.set(id, sg); - if (!sgMembers.has(sg.id)) { - sgMembers.set(sg.id, []); - } - sgMembers.get(sg.id)!.push(id); - } else { - sgForId.set(id, null); - } - } - - const seen = new Set(); - const items: RenderItem[] = []; - - for (const id of groupIds) { - const sg = sgForId.get(id); - if (sg) { - if (!seen.has(sg.id)) { - seen.add(sg.id); - items.push({ - type: 'supergroup', - supergroup: sg, - matchingIds: sgMembers.get(sg.id)!, - }); + const existing = seen.get(sg.id); + if (existing) { + existing.push(id); + } else { + const matchingIds = [id]; + seen.set(sg.id, matchingIds); + items.push({type: 'supergroup', supergroup: sg, matchingIds}); } } else { items.push({type: 'issue', id}); @@ -208,8 +188,15 @@ function GroupList({ ); const hasTopIssuesUI = organization.features.includes('top-issues-ui'); - const getSuperGroupForIssue = (id: string) => supergroupLookup?.[id] ?? null; - const renderItems = buildRenderItems(groupIds, getSuperGroupForIssue, hasTopIssuesUI); + const renderItems = useMemo( + () => + buildRenderItems( + groupIds, + (id: string) => supergroupLookup?.[id] ?? null, + hasTopIssuesUI + ), + [groupIds, supergroupLookup, hasTopIssuesUI] + ); const renderStreamGroup = (id: string, columns: GroupListColumn[]) => { const group = GroupStore.get(id) as Group | undefined;