From e336a81c79fcd9ae0460526ae49c9cc11f10bc85 Mon Sep 17 00:00:00 2001 From: jerryzhou196 Date: Mon, 3 Nov 2025 09:44:20 -0500 Subject: [PATCH 01/51] feat(replays): Enhance replay details navigation with start and end timestamps Added functionality to display the start and end timestamps of replays in the ReplayTable and ReplayDetails components. This allows users to navigate between replays with context on their timing, improving the overall user experience. Updated ReplayDetailsUserBadge to include navigation buttons for previous and next replays. --- .../components/replays/table/replayTable.tsx | 27 +++++++++ .../replays/table/replayTableColumns.tsx | 22 ++++--- .../detail/header/replayDetailsUserBadge.tsx | 38 +++++++++++- static/app/views/replays/details.tsx | 58 ++++++++++++++++++- 4 files changed, 132 insertions(+), 13 deletions(-) diff --git a/static/app/components/replays/table/replayTable.tsx b/static/app/components/replays/table/replayTable.tsx index bc648a9ffa121a..c4259acf83f169 100644 --- a/static/app/components/replays/table/replayTable.tsx +++ b/static/app/components/replays/table/replayTable.tsx @@ -1,4 +1,5 @@ import type {RefObject} from 'react'; +import {useMemo} from 'react'; import styled from '@emotion/styled'; import {Alert} from 'sentry/components/core/alert'; @@ -42,6 +43,30 @@ export default function ReplayTable({ const gridTemplateColumns = columns.map(col => col.width ?? 'max-content').join(' '); const hasInteractiveColumn = columns.some(col => col.interactive); + const {start, end} = useMemo(() => { + const earliestReplayStartedAt = replays.reduce( + (acc: number, replay) => Math.min(replay.started_at?.getTime() ?? 0, acc), + Infinity + ); + + const latestReplayStartedAt = replays.reduce( + (acc: number, replay) => Math.max(replay.started_at?.getTime() ?? 0, acc), + 0 + ); + + const result = + replays.length > 0 + ? { + start: new Date(earliestReplayStartedAt).toISOString(), + end: new Date(latestReplayStartedAt).toISOString(), + } + : { + start: undefined, + end: undefined, + }; + return result; + }, [replays]); + if (isPending) { return ( diff --git a/static/app/components/replays/table/replayTableColumns.tsx b/static/app/components/replays/table/replayTableColumns.tsx index 759a5eb90cd4de..dbf02418e35f73 100644 --- a/static/app/components/replays/table/replayTableColumns.tsx +++ b/static/app/components/replays/table/replayTableColumns.tsx @@ -53,9 +53,11 @@ interface HeaderProps { interface CellProps { columnIndex: number; + end: string; replay: ListRecord; rowIndex: number; showDropdownFilters: boolean; + start: string; } export interface ReplayTableColumn { @@ -505,7 +507,7 @@ export const ReplaySessionColumn: ReplayTableColumn = { interactive: true, sortKey: 'started_at', width: 'minmax(150px, 1fr)', - Component: ({replay}) => { + Component: ({replay, start, end}) => { const routes = useRoutes(); const location = useLocation(); const organization = useOrganization(); @@ -527,13 +529,17 @@ export const ReplaySessionColumn: ReplayTableColumn = { organization, }); - const detailsTab = () => ({ - pathname: replayDetailsPathname, - query: { - referrer, - ...eventView.generateQueryStringObject(), - }, - }); + const detailsTab = () => { + return { + pathname: replayDetailsPathname, + query: { + referrer, + ...eventView.generateQueryStringObject(), + start, + end, + }, + }; + }; const trackNavigationEvent = () => trackAnalytics('replay.list-navigate-to-details', { project_id: project?.id, diff --git a/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx b/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx index bc11d03bcc1382..50eaa93fcf43b9 100644 --- a/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx +++ b/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx @@ -5,6 +5,7 @@ import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import {Button} from 'sentry/components/core/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Link} from 'sentry/components/core/link'; import UserBadge from 'sentry/components/idBadge/userBadge'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -17,7 +18,7 @@ import { LiveIndicator, } from 'sentry/components/replays/replayLiveIndicator'; import TimeSince from 'sentry/components/timeSince'; -import {IconCalendar, IconRefresh} from 'sentry/icons'; +import {IconCalendar, IconChevron, IconRefresh} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -25,15 +26,23 @@ import {useQueryClient} from 'sentry/utils/queryClient'; import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader'; import usePollReplayRecord from 'sentry/utils/replays/hooks/usePollReplayRecord'; import {useReplayProjectSlug} from 'sentry/utils/replays/hooks/useReplayProjectSlug'; +import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {useReplaySummaryContext} from 'sentry/views/replays/detail/ai/replaySummaryContext'; import {makeReplaysPathname} from 'sentry/views/replays/pathnames'; +import type {ReplayListRecord} from 'sentry/views/replays/types'; interface Props { + nextReplay: ReplayListRecord | null; + previousReplay: ReplayListRecord | null; readerResult: ReturnType; } -export default function ReplayDetailsUserBadge({readerResult}: Props) { +export default function ReplayDetailsUserBadge({ + readerResult, + nextReplay, + previousReplay, +}: Props) { const organization = useOrganization(); const replayRecord = readerResult.replayRecord; @@ -87,6 +96,7 @@ export default function ReplayDetailsUserBadge({readerResult}: Props) { const isReplayExpired = Date.now() > getReplayExpiresAtMs(replayRecord?.started_at ?? null); + const location = useLocation(); const polledReplayRecord = usePollReplayRecord({ enabled: !isReplayExpired, @@ -159,6 +169,30 @@ export default function ReplayDetailsUserBadge({readerResult}: Props) { ) : null} + + } + disabled={!previousReplay} + to={{ + pathname: previousReplay + ? `/explore/replays/${previousReplay.id}/` + : undefined, + query: {...location.query}, + }} + borderless + /> + } + borderless + disabled={!nextReplay} + to={{ + pathname: nextReplay ? `/explore/replays/${nextReplay.id}/` : undefined, + query: {...location.query}, + }} + /> + } user={{ diff --git a/static/app/views/replays/details.tsx b/static/app/views/replays/details.tsx index eee7adeaf6bd5f..bb065b695ccf62 100644 --- a/static/app/views/replays/details.tsx +++ b/static/app/views/replays/details.tsx @@ -1,18 +1,23 @@ -import {Fragment} from 'react'; +import {Fragment, useMemo} from 'react'; import styled from '@emotion/styled'; import invariant from 'invariant'; import AnalyticsArea from 'sentry/components/analyticsArea'; import FullViewport from 'sentry/components/layouts/fullViewport'; import * as Layout from 'sentry/components/layouts/thirds'; +import useReplayTableSort from 'sentry/components/replays/table/useReplayTableSort'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; -import {decodeScalar} from 'sentry/utils/queryString'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader'; +import useReplayListQueryKey from 'sentry/utils/replays/hooks/useReplayListQueryKey'; import useReplayPageview from 'sentry/utils/replays/hooks/useReplayPageview'; +import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils'; import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; +import useLocationQuery from 'sentry/utils/url/useLocationQuery'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; @@ -23,6 +28,7 @@ import ReplayDetailsMetadata from 'sentry/views/replays/detail/header/replayDeta import ReplayDetailsPageBreadcrumbs from 'sentry/views/replays/detail/header/replayDetailsPageBreadcrumbs'; import ReplayDetailsUserBadge from 'sentry/views/replays/detail/header/replayDetailsUserBadge'; import ReplayDetailsPage from 'sentry/views/replays/detail/page'; +import type {ReplayListRecord} from 'sentry/views/replays/types'; export default function ReplayDetails() { const user = useUser(); @@ -41,6 +47,48 @@ export default function ReplayDetails() { }); const {replay, replayRecord} = readerResult; + const {sortQuery} = useReplayTableSort(); + + const query = useLocationQuery({ + fields: { + cursor: decodeScalar, + end: decodeScalar, + environment: decodeList, + project: decodeList, + query: decodeScalar, + start: decodeScalar, + statsPeriod: decodeScalar, + utc: decodeScalar, + started_at: decodeScalar, + }, + }); + const queryKey = useReplayListQueryKey({ + options: {query: {...query, sort: sortQuery}}, + organization, + queryReferrer: 'replayList', + }); + const {data} = useApiQuery<{ + data: ReplayListRecord[]; + enabled: true; + }>(queryKey, {staleTime: 0}); + + const replays = useMemo(() => data?.data?.map(mapResponseToReplayRecord) ?? [], [data]); + + const currentReplayIndex = useMemo( + () => replays.findIndex(r => r.id === replayRecord?.id), + [replays, replayRecord] + ); + + const nextReplay = useMemo( + () => + currentReplayIndex < replays.length - 1 ? replays[currentReplayIndex + 1] : null, + [replays, currentReplayIndex] + ); + const previousReplay = useMemo( + () => (currentReplayIndex > 0 ? replays[currentReplayIndex - 1] : null), + [replays, currentReplayIndex] + ); + useReplayPageview('replay.details-time-spent'); useRouteAnalyticsEventNames('replay_details.viewed', 'Replay Details: Viewed'); useRouteAnalyticsParams({ @@ -60,7 +108,11 @@ export default function ReplayDetails() {
- +
From 76b424d1d61b82f58718dc3eae6fed15d0870d38 Mon Sep 17 00:00:00 2001 From: jerryzhou196 Date: Mon, 3 Nov 2025 16:49:57 -0500 Subject: [PATCH 02/51] ref(replays): Simplify ReplayDetailsUserBadge component. Removed unused props for next and previous replays in the ReplayDetailsUserBadge component, streamlining the code. Adjusted the loading placeholder height for better visual consistency. This change enhances the component's clarity and performance by eliminating unnecessary complexity --- .../detail/header/replayDetailsUserBadge.tsx | 40 ++----------------- 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx b/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx index 50eaa93fcf43b9..757712554b1dfd 100644 --- a/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx +++ b/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx @@ -5,7 +5,6 @@ import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import {Button} from 'sentry/components/core/button'; -import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Link} from 'sentry/components/core/link'; import UserBadge from 'sentry/components/idBadge/userBadge'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -18,7 +17,7 @@ import { LiveIndicator, } from 'sentry/components/replays/replayLiveIndicator'; import TimeSince from 'sentry/components/timeSince'; -import {IconCalendar, IconChevron, IconRefresh} from 'sentry/icons'; +import {IconCalendar, IconRefresh} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -26,23 +25,15 @@ import {useQueryClient} from 'sentry/utils/queryClient'; import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader'; import usePollReplayRecord from 'sentry/utils/replays/hooks/usePollReplayRecord'; import {useReplayProjectSlug} from 'sentry/utils/replays/hooks/useReplayProjectSlug'; -import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {useReplaySummaryContext} from 'sentry/views/replays/detail/ai/replaySummaryContext'; import {makeReplaysPathname} from 'sentry/views/replays/pathnames'; -import type {ReplayListRecord} from 'sentry/views/replays/types'; interface Props { - nextReplay: ReplayListRecord | null; - previousReplay: ReplayListRecord | null; readerResult: ReturnType; } -export default function ReplayDetailsUserBadge({ - readerResult, - nextReplay, - previousReplay, -}: Props) { +export default function ReplayDetailsUserBadge({readerResult}: Props) { const organization = useOrganization(); const replayRecord = readerResult.replayRecord; @@ -96,7 +87,6 @@ export default function ReplayDetailsUserBadge({ const isReplayExpired = Date.now() > getReplayExpiresAtMs(replayRecord?.started_at ?? null); - const location = useLocation(); const polledReplayRecord = usePollReplayRecord({ enabled: !isReplayExpired, @@ -169,30 +159,6 @@ export default function ReplayDetailsUserBadge({ ) : null} - - } - disabled={!previousReplay} - to={{ - pathname: previousReplay - ? `/explore/replays/${previousReplay.id}/` - : undefined, - query: {...location.query}, - }} - borderless - /> - } - borderless - disabled={!nextReplay} - to={{ - pathname: nextReplay ? `/explore/replays/${nextReplay.id}/` : undefined, - query: {...location.query}, - }} - /> - } user={{ @@ -213,7 +179,7 @@ export default function ReplayDetailsUserBadge({ renderError={() => null} renderThrottled={() => null} renderLoading={() => - replayRecord ? badge : + replayRecord ? badge : } renderMissing={() => null} renderProcessingError={() => badge} From 81f8b7602074ab09bf4c146dfa19d6177acb87f5 Mon Sep 17 00:00:00 2001 From: jerryzhou196 Date: Mon, 3 Nov 2025 16:51:00 -0500 Subject: [PATCH 03/51] ref(replays): Update ReplayDetails and Breadcrumbs for improved navigation Refactored the ReplayDetails component to use 'undefined' instead of 'null' for next and previous replay checks, enhancing clarity. Updated ReplayDetailsPageBreadcrumbs to include navigation buttons for previous and next replays, improving user experience by allowing seamless navigation between replays. This change streamlines the code and enhances the overall functionality of the replay details view. --- .../header/replayDetailsPageBreadcrumbs.tsx | 73 ++++++++++++------- static/app/views/replays/details.tsx | 12 +-- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx b/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx index 626e803804b67f..fcf5e22d365ce6 100644 --- a/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx +++ b/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx @@ -1,14 +1,14 @@ -import {useState} from 'react'; import styled from '@emotion/styled'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import {Button} from 'sentry/components/core/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Flex} from 'sentry/components/core/layout'; import {Tooltip} from 'sentry/components/core/tooltip'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import Placeholder from 'sentry/components/placeholder'; import {useReplayContext} from 'sentry/components/replays/replayContext'; -import {IconCopy} from 'sentry/icons'; +import {IconChevron, IconCopy} from 'sentry/icons'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils'; import EventView from 'sentry/utils/discover/eventView'; @@ -19,18 +19,24 @@ import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import useProjectFromId from 'sentry/utils/useProjectFromId'; import {makeReplaysPathname} from 'sentry/views/replays/pathnames'; +import type {ReplayListRecord} from 'sentry/views/replays/types'; interface Props { + nextReplay: ReplayListRecord | undefined; + previousReplay: ReplayListRecord | undefined; readerResult: ReturnType; } -export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { +export default function ReplayDetailsPageBreadcrumbs({ + readerResult, + nextReplay, + previousReplay, +}: Props) { const replayRecord = readerResult.replayRecord; const organization = useOrganization(); const location = useLocation(); const eventView = EventView.fromLocation(location); const project = useProjectFromId({project_id: replayRecord?.project_id ?? undefined}); - const [isHovered, setIsHovered] = useState(false); const {currentTime} = useReplayContext(); // Create URL with current timestamp for copying @@ -74,12 +80,7 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { const replayCrumb = { label: replayRecord ? ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > +
copy(replayUrlWithTimestamp, { @@ -89,21 +90,43 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { > {getShortEventId(replayRecord?.id)}
- {isHovered && ( - -