Skip to content

Commit aab7c4a

Browse files
obostjancicclaude
andcommitted
feat(conversations): Add conversation detail page with new design
Add standalone conversation detail page with breadcrumb navigation, summary header with aggregated stats, and split-panel conversation view. Refactor conversation drawer into reusable ConversationView component shared between the detail page and trace drawer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 18c64fa commit aab7c4a

16 files changed

+871
-514
lines changed

static/app/router/routes.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2321,6 +2321,14 @@ function buildRoutes(): RouteObject[] {
23212321
() => import('sentry/views/insights/pages/conversations/overview')
23222322
),
23232323
},
2324+
{
2325+
path: ':conversationId/',
2326+
component: make(
2327+
() => import('sentry/views/insights/pages/conversations/conversationDetail')
2328+
),
2329+
},
2330+
transactionSummaryRoute,
2331+
traceView,
23242332
],
23252333
},
23262334
{
Lines changed: 41 additions & 225 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,30 @@
1-
import {memo, useCallback, useEffect, useMemo, useState} from 'react';
1+
import {memo, useCallback, useEffect} from 'react';
22
import {css} from '@emotion/react';
33
import styled from '@emotion/styled';
44

5-
import {Container, Flex} from '@sentry/scraps/layout';
6-
import {TabList, TabPanels, Tabs} from '@sentry/scraps/tabs';
5+
import {Button} from '@sentry/scraps/button';
6+
import {Flex} from '@sentry/scraps/layout';
77

8-
import {EmptyMessage} from 'sentry/components/emptyMessage';
9-
import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components';
10-
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
8+
import {
9+
DrawerBody,
10+
DrawerHeader,
11+
useDrawerContentContext,
12+
} from 'sentry/components/globalDrawer/components';
13+
import {IconClose} from 'sentry/icons';
1114
import {t} from 'sentry/locale';
1215
import {trackAnalytics} from 'sentry/utils/analytics';
1316
import type {ConversationDrawerOpenSource} from 'sentry/utils/analytics/conversationsAnalyticsEvents';
1417
import {useOrganization} from 'sentry/utils/useOrganization';
15-
import {AISpanList} from 'sentry/views/insights/pages/agents/components/aiSpanList';
16-
import {getDefaultSelectedNode} from 'sentry/views/insights/pages/agents/utils/getDefaultSelectedNode';
17-
import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types';
1818
import {ConversationSummary} from 'sentry/views/insights/pages/conversations/components/conversationSummary';
19-
import {MessagesPanel} from 'sentry/views/insights/pages/conversations/components/messagesPanel';
19+
import {ConversationViewContent} from 'sentry/views/insights/pages/conversations/components/conversationView';
2020
import {
2121
useConversation,
2222
type UseConversationsOptions,
2323
} from 'sentry/views/insights/pages/conversations/hooks/useConversation';
24-
import {useFocusedToolSpan} from 'sentry/views/insights/pages/conversations/hooks/useFocusedToolSpan';
2524
import {useUrlConversationDrawer} from 'sentry/views/insights/pages/conversations/hooks/useUrlConversationDrawer';
26-
import {extractMessagesFromNodes} from 'sentry/views/insights/pages/conversations/utils/conversationMessages';
2725
import {useConversationDrawerQueryState} from 'sentry/views/insights/pages/conversations/utils/urlParams';
28-
import {DEFAULT_TRACE_VIEW_PREFERENCES} from 'sentry/views/performance/newTraceDetails/traceState/tracePreferences';
29-
import {TraceStateProvider} from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider';
30-
31-
const LEFT_PANEL_WIDTH = 400;
32-
const DETAILS_PANEL_WIDTH = 500;
33-
const DRAWER_WIDTH = LEFT_PANEL_WIDTH + DETAILS_PANEL_WIDTH;
3426

35-
type ConversationTab = 'messages' | 'trace';
27+
const DRAWER_WIDTH = 900;
3628

3729
interface UseConversationViewDrawerProps {
3830
onClose?: () => void;
@@ -43,96 +35,49 @@ const ConversationDrawerContent = memo(function ConversationDrawerContent({
4335
}: {
4436
conversation: UseConversationsOptions;
4537
}) {
46-
const organization = useOrganization();
47-
const {nodes, nodeTraceMap, isLoading, error} = useConversation(conversation);
38+
const {nodes, isLoading} = useConversation(conversation);
4839
const [conversationDrawerQueryState, setConversationDrawerQueryState] =
4940
useConversationDrawerQueryState();
50-
const selectedNodeKey = conversationDrawerQueryState.spanId;
41+
const selectedSpanId = conversationDrawerQueryState.spanId;
5142
const focusedTool = conversationDrawerQueryState.focusedTool;
5243

53-
useFocusedToolSpan({
54-
nodes,
55-
focusedTool,
56-
isLoading,
57-
onSpanFound: useCallback(
58-
(spanId: string) => {
59-
setConversationDrawerQueryState({
60-
spanId,
61-
focusedTool: null,
62-
});
63-
},
64-
[setConversationDrawerQueryState]
65-
),
66-
});
67-
68-
const handleSelectNode = useCallback(
69-
(node: AITraceSpanNode) => {
44+
const handleSelectSpan = useCallback(
45+
(spanId: string) => {
7046
setConversationDrawerQueryState({
71-
spanId: node.id,
47+
spanId,
7248
focusedTool: null,
7349
});
74-
trackAnalytics('conversations.drawer.span-select', {
75-
organization,
76-
});
7750
},
78-
[setConversationDrawerQueryState, organization]
51+
[setConversationDrawerQueryState]
7952
);
8053

81-
const defaultNodeId = useMemo(() => {
82-
const messages = extractMessagesFromNodes(nodes);
83-
const firstAssistant = messages.find(m => m.role === 'assistant');
84-
return firstAssistant?.nodeId ?? getDefaultSelectedNode(nodes)?.id;
85-
}, [nodes]);
86-
87-
const selectedNode = useMemo(() => {
88-
return (
89-
nodes.find(node => node.id === selectedNodeKey) ??
90-
nodes.find(node => node.id === defaultNodeId)
91-
);
92-
}, [nodes, selectedNodeKey, defaultNodeId]);
93-
94-
useEffect(() => {
95-
if (isLoading || !defaultNodeId || focusedTool) {
96-
return;
97-
}
98-
99-
const isCurrentSpanValid =
100-
selectedNodeKey && nodes.some(node => node.id === selectedNodeKey);
101-
102-
if (!isCurrentSpanValid) {
103-
setConversationDrawerQueryState({
104-
spanId: defaultNodeId,
105-
});
106-
}
107-
}, [
108-
isLoading,
109-
defaultNodeId,
110-
selectedNodeKey,
111-
nodes,
112-
setConversationDrawerQueryState,
113-
focusedTool,
114-
]);
54+
const {onClose} = useDrawerContentContext();
11555

11656
return (
11757
<Flex direction="column" height="100%">
118-
<DrawerHeader>
119-
<ConversationSummary
120-
nodes={nodes}
121-
conversationId={conversation.conversationId}
122-
isLoading={isLoading}
123-
/>
124-
</DrawerHeader>
125-
<StyledDrawerBody>
126-
<TraceStateProvider initialPreferences={DEFAULT_TRACE_VIEW_PREFERENCES}>
127-
<ConversationView
58+
<StyledDrawerHeader hideCloseButton>
59+
<Flex flex={1} justify="space-between" align="flex-start">
60+
<ConversationSummary
12861
nodes={nodes}
129-
nodeTraceMap={nodeTraceMap}
130-
selectedNode={selectedNode}
131-
onSelectNode={handleSelectNode}
62+
conversationId={conversation.conversationId}
13263
isLoading={isLoading}
133-
error={error}
13464
/>
135-
</TraceStateProvider>
65+
<Button
66+
priority="transparent"
67+
size="xs"
68+
aria-label={t('Close Drawer')}
69+
icon={<IconClose />}
70+
onClick={onClose}
71+
/>
72+
</Flex>
73+
</StyledDrawerHeader>
74+
<StyledDrawerBody>
75+
<ConversationViewContent
76+
conversation={conversation}
77+
selectedSpanId={selectedSpanId}
78+
onSelectSpan={handleSelectSpan}
79+
focusedTool={focusedTool}
80+
/>
13681
</StyledDrawerBody>
13782
</Flex>
13883
);
@@ -200,106 +145,9 @@ export function useConversationViewDrawer({
200145
};
201146
}
202147

203-
function ConversationView({
204-
nodes,
205-
nodeTraceMap,
206-
selectedNode,
207-
onSelectNode,
208-
isLoading,
209-
error,
210-
}: {
211-
error: boolean;
212-
isLoading: boolean;
213-
nodeTraceMap: Map<string, string>;
214-
nodes: AITraceSpanNode[];
215-
onSelectNode: (node: AITraceSpanNode) => void;
216-
selectedNode: AITraceSpanNode | undefined;
217-
}) {
218-
const organization = useOrganization();
219-
const [activeTab, setActiveTab] = useState<ConversationTab>('messages');
220-
221-
const handleTabChange = useCallback(
222-
(newTab: ConversationTab) => {
223-
if (activeTab !== newTab) {
224-
trackAnalytics('conversations.drawer.tab-switch', {
225-
organization,
226-
fromTab: activeTab,
227-
toTab: newTab,
228-
});
229-
}
230-
setActiveTab(newTab);
231-
},
232-
[organization, activeTab]
233-
);
234-
235-
if (isLoading) {
236-
return (
237-
<Flex justify="center" align="center" flex="1" height="100%">
238-
<LoadingIndicator size={32}>{t('Loading conversation...')}</LoadingIndicator>
239-
</Flex>
240-
);
241-
}
242-
243-
if (error) {
244-
return <EmptyMessage>{t('Failed to load conversation')}</EmptyMessage>;
245-
}
246-
247-
if (nodes.length === 0) {
248-
return <EmptyMessage>{t('No AI spans found in this conversation')}</EmptyMessage>;
249-
}
250-
251-
return (
252-
<Flex flex="1" minHeight="0">
253-
<LeftPanel>
254-
<StyledTabs
255-
value={activeTab}
256-
onChange={key => handleTabChange(key as ConversationTab)}
257-
>
258-
<Container paddingTop="lg" borderBottom="primary">
259-
<TabList>
260-
<TabList.Item key="messages">{t('Messages')}</TabList.Item>
261-
<TabList.Item key="trace">{t('AI Spans')}</TabList.Item>
262-
</TabList>
263-
</Container>
264-
<Flex flex="1" minHeight="0" width="100%" overflowX="hidden" overflowY="auto">
265-
<FullWidthTabPanels>
266-
<TabPanels.Item key="messages">
267-
<MessagesPanel
268-
nodes={nodes}
269-
selectedNodeId={selectedNode?.id ?? null}
270-
onSelectNode={onSelectNode}
271-
/>
272-
</TabPanels.Item>
273-
<TabPanels.Item key="trace">
274-
<Container padding="md lg md lg">
275-
<AISpanList
276-
nodes={nodes}
277-
selectedNodeKey={selectedNode?.id ?? nodes[0]?.id ?? ''}
278-
onSelectNode={onSelectNode}
279-
compressGaps
280-
/>
281-
</Container>
282-
</TabPanels.Item>
283-
</FullWidthTabPanels>
284-
</Flex>
285-
</StyledTabs>
286-
</LeftPanel>
287-
<DetailsPanel>
288-
{selectedNode?.renderDetails({
289-
node: selectedNode,
290-
manager: null,
291-
onParentClick: () => {},
292-
onTabScrollToNode: () => {},
293-
organization,
294-
replay: null,
295-
traceId: nodeTraceMap.get(selectedNode.id) ?? '',
296-
hideNodeActions: true,
297-
initiallyCollapseAiIO: true,
298-
})}
299-
</DetailsPanel>
300-
</Flex>
301-
);
302-
}
148+
const StyledDrawerHeader = styled(DrawerHeader)`
149+
padding-left: ${p => p.theme.space.xl};
150+
`;
303151

304152
const StyledDrawerBody = styled(DrawerBody)`
305153
padding: 0;
@@ -308,35 +156,3 @@ const StyledDrawerBody = styled(DrawerBody)`
308156
display: flex;
309157
flex-direction: column;
310158
`;
311-
312-
const LeftPanel = styled('div')`
313-
flex: 1;
314-
min-width: ${LEFT_PANEL_WIDTH}px;
315-
min-height: 0;
316-
border-right: 1px solid ${p => p.theme.tokens.border.primary};
317-
display: flex;
318-
flex-direction: column;
319-
overflow: hidden;
320-
`;
321-
322-
const StyledTabs = styled(Tabs)`
323-
min-height: 0;
324-
`;
325-
326-
const FullWidthTabPanels = styled(TabPanels)`
327-
width: 100%;
328-
padding: 0;
329-
330-
> [role='tabpanel'] {
331-
width: 100%;
332-
}
333-
`;
334-
335-
const DetailsPanel = styled('div')`
336-
width: ${DETAILS_PANEL_WIDTH}px;
337-
min-width: ${DETAILS_PANEL_WIDTH}px;
338-
min-height: 0;
339-
background-color: ${p => p.theme.tokens.background.primary};
340-
overflow-y: auto;
341-
overflow-x: hidden;
342-
`;

0 commit comments

Comments
 (0)