Skip to content

Commit 7cc0623

Browse files
billyvgcursoragentgetsantry[bot]michellewzhang
authored
feat(replay): Add AI Summary tab for replay details [INTERNAL] (#93224)
A new "AI Summary" tab was added to the Replay Details page for internal testing only. It is only visible to the Replay team. This will not be the final design. ![image](https://github.com/user-attachments/assets/7719aa52-1100-4548-ae97-9ebff845bcfa) * The `TabKey` enum in `static/app/utils/replays/hooks/useActiveReplayTab.tsx` was updated to include `AI_SUMMARY`, and it was added to `supportedVideoTabs` for mobile compatibility. * In `static/app/views/replays/detail/layout/focusTabs.tsx`, the `getReplayTabs` function was modified to conditionally render the "AI Summary" tab based on the `organizations:replay-ai-summaries` feature flag, positioning it before the `Breadcrumbs` tab. * A new `AISummary` component was created in `static/app/views/replays/detail/aiSummary/index.tsx`. This component: * Makes a POST request to `/organizations/{organization_slug}/replays/summary/` on initial load. * Displays a `LoadingIndicator` while the request is in flight. * Shows an error message if the request fails. * Renders the `summary` content upon successful completion. * The `static/app/views/replays/detail/layout/focusArea.tsx` file was updated to import and render the new `AISummary` component when the `AI_SUMMARY` tab is active. Closes REPLAY-388 --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> Co-authored-by: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com>
1 parent f007d68 commit 7cc0623

File tree

4 files changed

+275
-1
lines changed

4 files changed

+275
-1
lines changed

static/app/utils/replays/hooks/useActiveReplayTab.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {useCallback} from 'react';
33
import useUrlParams from 'sentry/utils/useUrlParams';
44

55
export enum TabKey {
6+
AI = 'ai',
67
BREADCRUMBS = 'breadcrumbs',
78
CONSOLE = 'console',
89
ERRORS = 'errors',
@@ -14,6 +15,7 @@ export enum TabKey {
1415

1516
function isReplayTab({tab, isVideoReplay}: {isVideoReplay: boolean; tab: string}) {
1617
const supportedVideoTabs = [
18+
TabKey.AI,
1719
TabKey.TAGS,
1820
TabKey.ERRORS,
1921
TabKey.BREADCRUMBS,
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import styled from '@emotion/styled';
2+
3+
import {Alert} from 'sentry/components/core/alert';
4+
import {Badge} from 'sentry/components/core/badge';
5+
import {Button} from 'sentry/components/core/button';
6+
import EmptyMessage from 'sentry/components/emptyMessage';
7+
import ErrorBoundary from 'sentry/components/errorBoundary';
8+
import LoadingIndicator from 'sentry/components/loadingIndicator';
9+
import {useReplayContext} from 'sentry/components/replays/replayContext';
10+
import {t} from 'sentry/locale';
11+
import {space} from 'sentry/styles/space';
12+
import type {ApiQueryKey} from 'sentry/utils/queryClient';
13+
import {useApiQuery} from 'sentry/utils/queryClient';
14+
import {decodeScalar} from 'sentry/utils/queryString';
15+
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
16+
import useOrganization from 'sentry/utils/useOrganization';
17+
import useProjectFromId from 'sentry/utils/useProjectFromId';
18+
import BreadcrumbRow from 'sentry/views/replays/detail/breadcrumbs/breadcrumbRow';
19+
import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
20+
import TabItemContainer from 'sentry/views/replays/detail/tabItemContainer';
21+
import TimestampButton from 'sentry/views/replays/detail/timestampButton';
22+
import type {ReplayRecord} from 'sentry/views/replays/types';
23+
24+
interface Props {
25+
replayRecord: ReplayRecord | undefined;
26+
}
27+
28+
interface SummaryResponse {
29+
data: {
30+
summary: string;
31+
time_ranges: Array<{period_end: number; period_start: number; period_title: string}>;
32+
title: string;
33+
};
34+
}
35+
36+
function createAISummaryQueryKey(
37+
orgSlug: string,
38+
projectSlug: string | undefined,
39+
replayId: string
40+
): ApiQueryKey {
41+
return [
42+
`/projects/${orgSlug}/${projectSlug}/replays/${replayId}/summarize/breadcrumbs/`,
43+
];
44+
}
45+
46+
export default function Ai({replayRecord}: Props) {
47+
return (
48+
<PaddedFluidHeight>
49+
<TabItemContainer data-test-id="replay-details-ai-summary-tab">
50+
<ErrorBoundary mini>
51+
<AiContent replayRecord={replayRecord} />
52+
</ErrorBoundary>
53+
</TabItemContainer>
54+
</PaddedFluidHeight>
55+
);
56+
}
57+
58+
function AiContent({replayRecord}: Props) {
59+
const {replay} = useReplayContext();
60+
const organization = useOrganization();
61+
const {project: project_id} = useLocationQuery({
62+
fields: {project: decodeScalar},
63+
});
64+
const project = useProjectFromId({project_id});
65+
66+
const {
67+
data: summaryData,
68+
isPending,
69+
isError,
70+
isRefetching,
71+
refetch,
72+
} = useApiQuery<SummaryResponse>(
73+
createAISummaryQueryKey(organization.slug, project?.slug, replayRecord?.id ?? ''),
74+
{
75+
staleTime: 0,
76+
enabled: Boolean(
77+
replayRecord?.id &&
78+
project?.slug &&
79+
organization.features.includes('replay-ai-summary')
80+
),
81+
retry: false,
82+
}
83+
);
84+
85+
if (!organization.features.includes('replay-ai-summary')) {
86+
return (
87+
<SummaryContainer>
88+
<Alert type="info">
89+
{t('Replay AI summary is not available for this organization.')}
90+
</Alert>
91+
</SummaryContainer>
92+
);
93+
}
94+
95+
if (isPending || isRefetching) {
96+
return (
97+
<LoadingContainer>
98+
<LoadingIndicator />
99+
</LoadingContainer>
100+
);
101+
}
102+
103+
if (isError) {
104+
return (
105+
<SummaryContainer>
106+
<Alert type="error">{t('Failed to load AI summary')}</Alert>;
107+
</SummaryContainer>
108+
);
109+
}
110+
111+
if (!summaryData) {
112+
return (
113+
<SummaryContainer>
114+
<Alert type="info">{t('No summary available for this replay.')}</Alert>
115+
</SummaryContainer>
116+
);
117+
}
118+
119+
const chapterData = summaryData?.data.time_ranges.map(
120+
({period_title, period_start, period_end}) => ({
121+
title: period_title,
122+
start: period_start * 1000,
123+
end: period_end * 1000,
124+
breadcrumbs:
125+
replay
126+
?.getChapterFrames()
127+
.filter(
128+
breadcrumb =>
129+
breadcrumb.timestampMs >= period_start * 1000 &&
130+
breadcrumb.timestampMs <= period_end * 1000
131+
) ?? [],
132+
})
133+
);
134+
135+
return (
136+
<ErrorBoundary mini>
137+
<SummaryContainer>
138+
<SummaryHeader>
139+
<SummaryHeaderTitle>
140+
<span>{t('Replay Summary')}</span>
141+
<Badge type="internal">{t('Internal')}</Badge>
142+
</SummaryHeaderTitle>
143+
<Button priority="primary" size="xs" onClick={() => refetch()}>
144+
{t('Regenerate')}
145+
</Button>
146+
</SummaryHeader>
147+
<SummaryText>{summaryData.data.summary}</SummaryText>
148+
<div>
149+
{chapterData.map(({title, start, breadcrumbs}, i) => (
150+
<Details key={i}>
151+
<Summary>
152+
<SummaryTitle>
153+
<span>{title}</span>
154+
155+
<ReplayTimestamp>
156+
<TimestampButton
157+
startTimestampMs={replay?.getStartTimestampMs() ?? 0}
158+
timestampMs={start}
159+
/>
160+
</ReplayTimestamp>
161+
</SummaryTitle>
162+
</Summary>
163+
<div>
164+
{!breadcrumbs.length && (
165+
<EmptyMessage>{t('No breadcrumbs for this chapter')}</EmptyMessage>
166+
)}
167+
{breadcrumbs.map((breadcrumb, j) => (
168+
<BreadcrumbRow
169+
frame={breadcrumb}
170+
index={j}
171+
onClick={() => {}}
172+
onInspectorExpanded={() => {}}
173+
onShowSnippet={() => {}}
174+
showSnippet={false}
175+
allowShowSnippet={false}
176+
startTimestampMs={breadcrumb.timestampMs}
177+
key={`breadcrumb-${j}`}
178+
style={{}}
179+
/>
180+
))}
181+
</div>
182+
</Details>
183+
))}
184+
</div>
185+
</SummaryContainer>
186+
</ErrorBoundary>
187+
);
188+
}
189+
190+
const PaddedFluidHeight = styled(FluidHeight)`
191+
padding-top: ${space(1)};
192+
`;
193+
194+
const LoadingContainer = styled('div')`
195+
display: flex;
196+
justify-content: center;
197+
padding: ${space(4)};
198+
`;
199+
200+
const SummaryContainer = styled('div')`
201+
padding: ${space(2)};
202+
overflow: auto;
203+
`;
204+
205+
const SummaryHeader = styled('h3')`
206+
display: flex;
207+
align-items: center;
208+
gap: ${space(1)};
209+
justify-content: space-between;
210+
`;
211+
212+
const SummaryHeaderTitle = styled('div')`
213+
display: flex;
214+
align-items: center;
215+
gap: ${space(1)};
216+
`;
217+
218+
const Details = styled('details')`
219+
&[open] > summary::before {
220+
content: '-';
221+
}
222+
`;
223+
224+
const Summary = styled('summary')`
225+
cursor: pointer;
226+
display: list-item;
227+
padding: ${space(1)} 0;
228+
font-size: ${p => p.theme.fontSizeLarge};
229+
230+
/* sorry */
231+
&:focus-visible {
232+
outline: none;
233+
}
234+
235+
list-style-type: none;
236+
&::-webkit-details-marker {
237+
display: none;
238+
}
239+
&::before {
240+
content: '+';
241+
float: left;
242+
display: inline-block;
243+
width: 14px;
244+
margin-right: ${space(1)};
245+
font-size: ${p => p.theme.fontSizeExtraLarge};
246+
}
247+
`;
248+
249+
const SummaryTitle = styled('div')`
250+
display: flex;
251+
align-items: center;
252+
gap: ${space(1)};
253+
justify-content: space-between;
254+
`;
255+
256+
const SummaryText = styled('p')`
257+
line-height: 1.6;
258+
white-space: pre-wrap;
259+
`;
260+
261+
// Copied from breadcrumbItem
262+
const ReplayTimestamp = styled('div')`
263+
color: ${p => p.theme.textColor};
264+
font-size: ${p => p.theme.fontSizeSmall};
265+
`;

static/app/views/replays/detail/layout/focusArea.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import useActiveReplayTab, {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
2+
import Ai from 'sentry/views/replays/detail/ai';
23
import Breadcrumbs from 'sentry/views/replays/detail/breadcrumbs';
34
import Console from 'sentry/views/replays/detail/console';
45
import ErrorList from 'sentry/views/replays/detail/errorList/index';
@@ -18,6 +19,8 @@ export default function FocusArea({
1819
const {getActiveTab} = useActiveReplayTab({isVideoReplay});
1920

2021
switch (getActiveTab()) {
22+
case TabKey.AI:
23+
return <Ai replayRecord={replayRecord} />;
2124
case TabKey.NETWORK:
2225
return <NetworkList />;
2326
case TabKey.TRACE:

static/app/views/replays/detail/layout/focusTabs.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,21 @@ import styled from '@emotion/styled';
44
import {TabList, Tabs} from 'sentry/components/core/tabs';
55
import {t} from 'sentry/locale';
66
import {space} from 'sentry/styles/space';
7+
import type {Organization} from 'sentry/types/organization';
78
import {trackAnalytics} from 'sentry/utils/analytics';
89
import useActiveReplayTab, {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
910
import useOrganization from 'sentry/utils/useOrganization';
1011

1112
function getReplayTabs({
1213
isVideoReplay,
14+
organization,
1315
}: {
1416
isVideoReplay: boolean;
17+
organization: Organization;
1518
}): Record<TabKey, ReactNode> {
1619
// For video replays, we hide the memory tab (not applicable for mobile)
1720
return {
21+
[TabKey.AI]: organization.features.includes('replay-ai-summaries') ? t('AI') : null,
1822
[TabKey.BREADCRUMBS]: t('Breadcrumbs'),
1923
[TabKey.CONSOLE]: t('Console'),
2024
[TabKey.NETWORK]: t('Network'),
@@ -34,7 +38,7 @@ function FocusTabs({isVideoReplay}: Props) {
3438
const {getActiveTab, setActiveTab} = useActiveReplayTab({isVideoReplay});
3539
const activeTab = getActiveTab();
3640

37-
const tabs = Object.entries(getReplayTabs({isVideoReplay})).filter(
41+
const tabs = Object.entries(getReplayTabs({isVideoReplay, organization})).filter(
3842
([_, v]) => v !== null
3943
);
4044

0 commit comments

Comments
 (0)