diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx
index 76f3c08ab11e75..5a76a6e29b2107 100644
--- a/static/app/router/routes.tsx
+++ b/static/app/router/routes.tsx
@@ -2697,6 +2697,11 @@ function buildRoutes(): RouteObject[] {
() => import('sentry/views/issueList/issueViews/issueViewsList/issueViewsList')
),
},
+ {
+ path: 'dynamic-groups/',
+ component: make(() => import('sentry/views/issueList/pages/dynamicGrouping')),
+ deprecatedRouteProps: true,
+ },
{
path: 'views/:viewId/',
component: errorHandler(OverviewWrapper),
diff --git a/static/app/views/issueList/pages/dynamicGrouping.tsx b/static/app/views/issueList/pages/dynamicGrouping.tsx
new file mode 100644
index 00000000000000..a723b63c3b6bba
--- /dev/null
+++ b/static/app/views/issueList/pages/dynamicGrouping.tsx
@@ -0,0 +1,726 @@
+import {Fragment, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {Container, Flex} from '@sentry/scraps/layout';
+import {Heading, Text} from '@sentry/scraps/text';
+
+import {Breadcrumbs} from 'sentry/components/breadcrumbs';
+import {Alert} from 'sentry/components/core/alert';
+import {Button} from 'sentry/components/core/button';
+import {Checkbox} from 'sentry/components/core/checkbox';
+import {Disclosure} from 'sentry/components/core/disclosure';
+import {NumberInput} from 'sentry/components/core/input/numberInput';
+import {Link} from 'sentry/components/core/link';
+import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
+import EventMessage from 'sentry/components/events/eventMessage';
+import TimesTag from 'sentry/components/group/inboxBadges/timesTag';
+import UnhandledTag from 'sentry/components/group/inboxBadges/unhandledTag';
+import IssueReplayCount from 'sentry/components/group/issueReplayCount';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Placeholder from 'sentry/components/placeholder';
+import Redirect from 'sentry/components/redirect';
+import {IconChat, IconStar} from 'sentry/icons';
+import {t, tn} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Group} from 'sentry/types/group';
+import {defined} from 'sentry/utils';
+import {getMessage, getTitle} from 'sentry/utils/events';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useReplayCountForIssues from 'sentry/utils/replayCount/useReplayCountForIssues';
+import {projectCanLinkToReplay} from 'sentry/utils/replays/projectSupportsReplay';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useUser} from 'sentry/utils/useUser';
+import {useUserTeams} from 'sentry/utils/useUserTeams';
+
+const STORAGE_KEY = 'dynamic-grouping-cluster-data';
+
+interface AssignedEntity {
+ email: string | null;
+ id: string;
+ name: string;
+ type: string;
+}
+
+interface ClusterSummary {
+ assignedTo: AssignedEntity[];
+ cluster_avg_similarity: number | null;
+ cluster_id: number;
+ cluster_min_similarity: number | null;
+ cluster_size: number | null;
+ description: string;
+ fixability_score: number | null;
+ group_ids: number[];
+ issue_titles: string[];
+ project_ids: number[];
+ tags: string[];
+ title: string;
+}
+
+// Compact issue preview for dynamic grouping - no short ID or quick fix icon
+function CompactIssuePreview({group}: {group: Group}) {
+ const organization = useOrganization();
+ const {getReplayCountForIssue} = useReplayCountForIssues();
+
+ const showReplayCount =
+ organization.features.includes('session-replay') &&
+ projectCanLinkToReplay(organization, group.project) &&
+ group.issueCategory &&
+ !!getReplayCountForIssue(group.id, group.issueCategory);
+
+ const issuesPath = `/organizations/${organization.slug}/issues/`;
+ const {subtitle} = getTitle(group);
+
+ const items = [
+ group.project ? (
+
+ ) : null,
+ group.isUnhandled ? : null,
+ group.count ? (
+ {tn('%s event', '%s events', group.count)}
+ ) : null,
+ group.lifetime || group.firstSeen || group.lastSeen ? (
+
+ ) : null,
+ group.numComments > 0 ? (
+
+
+ {group.numComments}
+
+ ) : null,
+ showReplayCount ? : null,
+ ].filter(defined);
+
+ return (
+
+
+ {group.isBookmarked && (
+
+
+
+ )}
+
+
+
+ {subtitle && {subtitle}}
+ {items.length > 0 && (
+
+ {items.map((item, i) => (
+
+ {item}
+ {i < items.length - 1 ? : null}
+
+ ))}
+
+ )}
+
+ );
+}
+
+// Fetch and display actual issues from the group_ids
+function ClusterIssues({groupIds}: {groupIds: number[]}) {
+ const organization = useOrganization();
+
+ // Only fetch first 3 issues for preview
+ const previewGroupIds = groupIds.slice(0, 3);
+
+ const {data: groups, isPending} = useApiQuery(
+ [
+ `/organizations/${organization.slug}/issues/`,
+ {
+ query: {
+ group: previewGroupIds,
+ query: `issue.id:[${previewGroupIds.join(',')}]`,
+ },
+ },
+ ],
+ {
+ staleTime: 60000, // Cache for 1 minute
+ }
+ );
+
+ if (isPending) {
+ return (
+
+ {[0, 1, 2].map(i => (
+
+ ))}
+
+ );
+ }
+
+ if (!groups || groups.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {groups.map(group => (
+
+
+
+ ))}
+
+ );
+}
+
+// Individual cluster card
+function ClusterCard({
+ cluster,
+ onRemove,
+ isRemoving,
+}: {
+ cluster: ClusterSummary;
+ isRemoving: boolean;
+ onRemove: (clusterId: number) => void;
+}) {
+ const organization = useOrganization();
+
+ return (
+
+
+
+ {cluster.title}
+ {cluster.fixability_score !== null && (
+
+ {t('%s%% confidence', Math.round(cluster.fixability_score * 100))}
+
+ )}
+
+
+ {cluster.cluster_size ?? cluster.group_ids.length}
+
+ {tn('issue', 'issues', cluster.cluster_size ?? cluster.group_ids.length)}
+
+
+
+
+
+
+ {cluster.description}
+
+
+
+
+ {cluster.group_ids.length > 3 && (
+
+ {t('+ %s more similar issues', cluster.group_ids.length - 3)}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function DynamicGrouping() {
+ const organization = useOrganization();
+ const user = useUser();
+ const {teams} = useUserTeams();
+ const [jsonInput, setJsonInput] = useState('');
+ const [clusterData, setClusterData] = useState([]);
+ const [parseError, setParseError] = useState(null);
+ const [showInput, setShowInput] = useState(true);
+ const [filterByAssignedToMe, setFilterByAssignedToMe] = useState(true);
+ const [minFixabilityScore, setMinFixabilityScore] = useState(50);
+ const [removingClusterId, setRemovingClusterId] = useState(null);
+
+ // Load from localStorage on mount
+ useEffect(() => {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ try {
+ const parsed = JSON.parse(stored);
+ setClusterData(parsed);
+ setJsonInput(stored);
+ setShowInput(false);
+ } catch {
+ // If stored data is invalid, clear it
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }
+ }, []);
+
+ const handleJsonSubmit = () => {
+ try {
+ const parsed = JSON.parse(jsonInput);
+ if (!Array.isArray(parsed)) {
+ setParseError(t('JSON must be an array of cluster summaries'));
+ return;
+ }
+ setClusterData(parsed);
+ setParseError(null);
+ setShowInput(false);
+ localStorage.setItem(STORAGE_KEY, jsonInput);
+ } catch (error) {
+ setParseError(error instanceof Error ? error.message : t('Invalid JSON format'));
+ }
+ };
+
+ const handleClear = () => {
+ setClusterData([]);
+ setJsonInput('');
+ setParseError(null);
+ setShowInput(true);
+ localStorage.removeItem(STORAGE_KEY);
+ };
+
+ const handleRemoveCluster = (clusterId: number) => {
+ // Start animation
+ setRemovingClusterId(clusterId);
+
+ // Wait for animation to complete before removing
+ setTimeout(() => {
+ const updatedClusters = clusterData.filter(
+ cluster => cluster.cluster_id !== clusterId
+ );
+ setClusterData(updatedClusters);
+ setRemovingClusterId(null);
+
+ // Update localStorage with the new data
+ if (updatedClusters.length > 0) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedClusters));
+ } else {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }, 300); // Match the animation duration
+ };
+
+ // Check if a cluster has at least one issue assigned to the current user or their teams
+ const isClusterAssignedToMe = useMemo(() => {
+ const userId = user.id;
+ const teamIds = teams.map(team => team.id);
+
+ return (cluster: ClusterSummary) => {
+ // If no assignedTo data, include the cluster
+ if (!cluster.assignedTo || cluster.assignedTo.length === 0) {
+ return false;
+ }
+
+ // Check if any assigned entity matches the user or their teams
+ return cluster.assignedTo.some(entity => {
+ if (entity.type === 'user' && entity.id === userId) {
+ return true;
+ }
+ if (entity.type === 'team' && teamIds.includes(entity.id)) {
+ return true;
+ }
+ return false;
+ });
+ };
+ }, [user.id, teams]);
+
+ // Filter and sort clusters by fixability score (descending)
+ const filteredAndSortedClusters = useMemo(() => {
+ return [...clusterData]
+ .filter((cluster: ClusterSummary) => {
+ // Filter by fixability score - hide clusters below threshold
+ const fixabilityScore = (cluster.fixability_score ?? 0) * 100;
+ if (fixabilityScore < minFixabilityScore) {
+ return false;
+ }
+
+ // If "Assigned to Me" filter is enabled, only show clusters assigned to the user
+ if (filterByAssignedToMe) {
+ return isClusterAssignedToMe(cluster);
+ }
+ return true;
+ })
+ .sort(
+ (a: ClusterSummary, b: ClusterSummary) =>
+ (b.fixability_score ?? 0) - (a.fixability_score ?? 0)
+ );
+ }, [clusterData, filterByAssignedToMe, minFixabilityScore, isClusterAssignedToMe]);
+
+ const totalIssues = useMemo(() => {
+ return filteredAndSortedClusters.reduce(
+ (sum: number, c: ClusterSummary) => sum + (c.cluster_size ?? c.group_ids.length),
+ 0
+ );
+ }, [filteredAndSortedClusters]);
+
+ const hasTopIssuesUI = organization.features.includes('top-issues-ui');
+ if (!hasTopIssuesUI) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+ {t('Top Issues')}
+
+ {clusterData.length > 0 && !showInput && (
+
+
+
+
+ )}
+
+
+
+ {showInput || clusterData.length === 0 ? (
+
+
+
+ {t('Paste cluster summaries JSON data below:')}
+
+ setJsonInput(e.target.value)}
+ placeholder={t('Paste JSON array here...')}
+ rows={10}
+ />
+ {parseError && {parseError}}
+
+
+ {clusterData.length > 0 && (
+
+ )}
+
+
+
+ ) : (
+
+
+
+ {tn(
+ 'Viewing %s issue in %s cluster',
+ 'Viewing %s issues across %s clusters',
+ totalIssues,
+ filteredAndSortedClusters.length
+ )}
+
+
+
+
+
+
+
+ {t('More Filters')}
+
+
+
+
+
+ setFilterByAssignedToMe(e.target.checked)}
+ aria-label={t('Show only issues assigned to me')}
+ size="sm"
+ />
+
+ {t('Only show issues assigned to me')}
+
+
+
+
+ {t('Minimum fixability score (%)')}
+
+ setMinFixabilityScore(value ?? 0)}
+ aria-label={t('Minimum fixability score')}
+ size="sm"
+ />
+
+
+
+
+
+
+
+ {filteredAndSortedClusters.map((cluster: ClusterSummary) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+// Styled Components
+const PageContainer = styled('div')`
+ padding: ${space(3)} ${space(4)};
+ max-width: 1600px;
+ margin: 0 auto;
+`;
+
+const PageHeader = styled('div')`
+ margin-bottom: ${space(2)};
+`;
+
+const CardsGrid = styled('div')`
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
+ gap: ${space(3)};
+`;
+
+const CardContainer = styled('div')<{isRemoving: boolean}>`
+ background: ${p => p.theme.background};
+ border: 1px solid ${p => p.theme.border};
+ border-radius: ${p => p.theme.borderRadius};
+ padding: ${space(3)};
+ display: flex;
+ flex-direction: column;
+ gap: ${space(2)};
+ opacity: ${p => (p.isRemoving ? 0 : 1)};
+ transform: ${p => (p.isRemoving ? 'scale(0.95)' : 'scale(1)')};
+ transition:
+ opacity 0.3s ease,
+ transform 0.3s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+
+ &:hover {
+ border-color: ${p => p.theme.purple300};
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ }
+`;
+
+const ClusterTitle = styled(Heading)`
+ flex: 1;
+ line-height: 1.3;
+ color: ${p => p.theme.headingColor};
+ display: flex;
+ flex-direction: column;
+ gap: ${space(0.5)};
+`;
+
+const IssueCount = styled('div')`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: ${space(1)} ${space(1.5)};
+ background: ${p => p.theme.purple100};
+ border-radius: ${p => p.theme.borderRadius};
+ min-width: 60px;
+ flex-shrink: 0;
+`;
+
+const CountNumber = styled('div')`
+ font-size: 24px;
+ font-weight: 600;
+ color: ${p => p.theme.purple400};
+ line-height: 1;
+`;
+
+const CountLabel = styled(Text)`
+ margin-top: ${space(0.25)};
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 500;
+`;
+
+const IssuePreviewContainer = styled(Link)`
+ display: block;
+ padding: ${space(1.5)} ${space(2)};
+ background: ${p => p.theme.background};
+ border: 1px solid ${p => p.theme.border};
+ border-radius: ${p => p.theme.borderRadius};
+ transition: all 0.15s ease;
+
+ &:hover {
+ border-color: ${p => p.theme.purple300};
+ background: ${p => p.theme.backgroundElevated};
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+ transform: translateX(2px);
+ }
+`;
+
+const CompactIssueWrapper = styled('div')`
+ display: flex;
+ flex-direction: column;
+ gap: ${space(0.75)};
+`;
+
+const CompactTitle = styled('div')`
+ font-size: ${p => p.theme.fontSize.md};
+ font-weight: ${p => p.theme.fontWeight.bold};
+ color: ${p => p.theme.textColor};
+ line-height: 1.4;
+ ${p => p.theme.overflowEllipsis};
+
+ & em {
+ font-size: ${p => p.theme.fontSize.sm};
+ font-style: normal;
+ font-weight: ${p => p.theme.fontWeight.normal};
+ color: ${p => p.theme.subText};
+ }
+`;
+
+const CompactMessage = styled(EventMessage)`
+ margin: 0;
+ font-size: ${p => p.theme.fontSize.sm};
+ color: ${p => p.theme.subText};
+ line-height: 1.4;
+`;
+
+const CompactLocation = styled('div')`
+ font-size: ${p => p.theme.fontSize.sm};
+ color: ${p => p.theme.subText};
+ line-height: 1.3;
+ ${p => p.theme.overflowEllipsis};
+`;
+
+const CompactExtra = styled('div')`
+ display: inline-grid;
+ grid-auto-flow: column dense;
+ gap: ${space(0.75)};
+ justify-content: start;
+ align-items: center;
+ color: ${p => p.theme.subText};
+ font-size: ${p => p.theme.fontSize.xs};
+ line-height: 1.2;
+
+ & > a {
+ color: ${p => p.theme.subText};
+ }
+`;
+
+const CompactSeparator = styled('div')`
+ height: 10px;
+ width: 1px;
+ background-color: ${p => p.theme.innerBorder};
+ border-radius: 1px;
+`;
+
+const ShadowlessProjectBadge = styled(ProjectBadge)`
+ * > img {
+ box-shadow: none;
+ }
+`;
+
+const CommentsLink = styled(Link)`
+ display: inline-grid;
+ gap: ${space(0.5)};
+ align-items: center;
+ grid-auto-flow: column;
+ color: ${p => p.theme.textColor};
+`;
+
+const IconWrapper = styled('span')`
+ display: inline-flex;
+ margin-right: ${space(0.5)};
+`;
+
+const EventCount = styled('span')`
+ color: ${p => p.theme.textColor};
+ font-weight: ${p => p.theme.fontWeight.bold};
+`;
+
+const JsonTextarea = styled('textarea')`
+ width: 100%;
+ min-height: 300px;
+ padding: ${space(1.5)};
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ background: ${p => p.theme.backgroundSecondary};
+ border: 1px solid ${p => p.theme.border};
+ border-radius: ${p => p.theme.borderRadius};
+ color: ${p => p.theme.textColor};
+ resize: vertical;
+
+ &:focus {
+ outline: none;
+ border-color: ${p => p.theme.purple300};
+ box-shadow: 0 0 0 1px ${p => p.theme.purple300};
+ }
+
+ &::placeholder {
+ color: ${p => p.theme.subText};
+ }
+`;
+
+const AdvancedFilterContent = styled('div')`
+ padding: ${space(2)} 0;
+ display: flex;
+ flex-direction: column;
+ gap: ${space(1.5)};
+`;
+
+export default DynamicGrouping;
diff --git a/static/app/views/nav/secondary/sections/issues/issuesSecondaryNav.tsx b/static/app/views/nav/secondary/sections/issues/issuesSecondaryNav.tsx
index 9117caa9302dc1..55ae44c31e76fd 100644
--- a/static/app/views/nav/secondary/sections/issues/issuesSecondaryNav.tsx
+++ b/static/app/views/nav/secondary/sections/issues/issuesSecondaryNav.tsx
@@ -17,6 +17,7 @@ export function IssuesSecondaryNav() {
const organization = useOrganization();
const sectionRef = useRef(null);
const baseUrl = `/organizations/${organization.slug}/issues`;
+ const hasTopIssuesUI = organization.features.includes('top-issues-ui');
return (
@@ -57,6 +58,16 @@ export function IssuesSecondaryNav() {
+ {hasTopIssuesUI && (
+
+
+ {t('Top Issues')}
+
+
+ )}