From c8b37071e6423869b74dfcb36c466fec10e0f8c5 Mon Sep 17 00:00:00 2001 From: Ogi <86684834+obostjancic@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:51:22 +0200 Subject: [PATCH 1/2] feat(insights): Integrate conversation details into trace AI tab Decompose ConversationView into composable building blocks (ConversationSplitLayout, ConversationLeftPanel, ConversationDetailPanel, useConversationSelection) so both the conversation detail page and trace view can share the same presentation layer. The trace AI tab now detects conversations via gen_ai.conversation.id on spans. When conversations exist (gated behind gen-ai-conversations flag), it shows Chat/Spans sub-tabs with the full message view filtered to the current trace. Multiple conversations render as labeled tabs. A resizable split panel (matching the trace drawer divider style) replaces the fixed two-column layout everywhere. Co-Authored-By: Claude Opus 4.6 --- .../pages/agents/hooks/useAITrace.tsx | 1 + .../components/conversationLayout.tsx | 212 +++++++++++++++ .../components/conversationSummary.tsx | 128 +++++---- .../components/conversationView.tsx | 254 ++++------------- .../components/messagesPanel.tsx | 1 + .../hooks/useConversationSelection.tsx | 76 ++++++ .../performance/newTraceDetails/index.tsx | 4 +- .../traceDrawer/tabs/traceAiConversations.tsx | 257 ++++++++++++++++++ .../traceDrawer/tabs/traceAiSpans.tsx | 99 ++++++- .../traceDrawer/tabs/traceAiTab.tsx | 50 ++++ .../newTraceDetails/useTraceLayoutTabs.tsx | 2 +- 11 files changed, 820 insertions(+), 264 deletions(-) create mode 100644 static/app/views/insights/pages/conversations/components/conversationLayout.tsx create mode 100644 static/app/views/insights/pages/conversations/hooks/useConversationSelection.tsx create mode 100644 static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiConversations.tsx create mode 100644 static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiTab.tsx diff --git a/static/app/views/insights/pages/agents/hooks/useAITrace.tsx b/static/app/views/insights/pages/agents/hooks/useAITrace.tsx index 5719cb10df91f7..cc5f3cc8619cc9 100644 --- a/static/app/views/insights/pages/agents/hooks/useAITrace.tsx +++ b/static/app/views/insights/pages/agents/hooks/useAITrace.tsx @@ -33,6 +33,7 @@ const AI_TRACE_BASE_ATTRIBUTES = [ SpanFields.GEN_AI_TOOL_NAME, SpanFields.GEN_AI_OPERATION_TYPE, SpanFields.GEN_AI_OPERATION_NAME, + SpanFields.GEN_AI_CONVERSATION_ID, SpanFields.SPAN_STATUS, 'status', 'gen_ai.tool.call.arguments', diff --git a/static/app/views/insights/pages/conversations/components/conversationLayout.tsx b/static/app/views/insights/pages/conversations/components/conversationLayout.tsx new file mode 100644 index 00000000000000..9a35c828c31346 --- /dev/null +++ b/static/app/views/insights/pages/conversations/components/conversationLayout.tsx @@ -0,0 +1,212 @@ +import type React from 'react'; +import {useRef} from 'react'; +import styled from '@emotion/styled'; + +import {Container, Flex} from '@sentry/scraps/layout'; + +import {Placeholder} from 'sentry/components/placeholder'; +import {SplitPanel} from 'sentry/components/splitPanel'; +import {useDimensions} from 'sentry/utils/useDimensions'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; + +const LEFT_PANEL_MIN = 400; +const RIGHT_PANEL_MIN = 400; +const DIVIDER_WIDTH = 1; +const STORAGE_KEY = 'conversation-split-size'; + +/** + * Minimal resize divider matching the trace drawer style: + * a 1px border line with an invisible wider hit area for dragging. + */ +const BorderDivider = styled( + ({ + icon: _icon, + ...props + }: { + 'data-is-held': boolean; + 'data-slide-direction': 'leftright' | 'updown'; + onDoubleClick: React.MouseEventHandler; + onMouseDown: React.MouseEventHandler; + icon?: React.ReactNode; + }) =>
+)` + width: ${DIVIDER_WIDTH}px; + height: 100%; + position: relative; + user-select: none; + background: ${p => p.theme.tokens.border.primary}; + + /* Invisible wider hit area for dragging */ + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -5px; + width: 11px; + cursor: ew-resize; + z-index: 1; + } + + &[data-is-held='true'] { + background: ${p => p.theme.tokens.border.accent.moderate}; + } +`; + +/** + * Resizable two-column layout for conversation views. + * Left panel holds messages/spans, right panel holds span details. + * Uses SplitPanel for drag-to-resize with persisted size. + */ +export function ConversationSplitLayout({ + left, + right, +}: { + left: React.ReactNode; + right: React.ReactNode; +}) { + const measureRef = useRef(null); + const {width} = useDimensions({elementRef: measureRef}); + + const hasSize = width > 0; + const maxLeft = Math.max(LEFT_PANEL_MIN, width - RIGHT_PANEL_MIN - DIVIDER_WIDTH); + const defaultLeft = Math.min( + maxLeft, + Math.max(LEFT_PANEL_MIN, (width - DIVIDER_WIDTH) * 0.5) + ); + + return ( + + {hasSize ? ( + + ) : null} + + ); +} + +export function ConversationLeftPanel({children}: {children: React.ReactNode}) { + return ( + + {children} + + ); +} + +export function ConversationDetailPanel({ + selectedNode, + nodeTraceMap, +}: { + nodeTraceMap: Map; + selectedNode?: AITraceSpanNode; +}) { + const organization = useOrganization(); + return ( + + {selectedNode?.renderDetails({ + node: selectedNode, + manager: null, + onParentClick: () => {}, + onTabScrollToNode: () => {}, + organization, + replay: null, + traceId: nodeTraceMap.get(selectedNode.id) ?? '', + hideNodeActions: true, + initiallyCollapseAiIO: true, + })} + + ); +} + +export function ConversationViewSkeleton() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + right={ + + + + + + + + + + + + + + + + + + + + + + + } + /> + ); +} diff --git a/static/app/views/insights/pages/conversations/components/conversationSummary.tsx b/static/app/views/insights/pages/conversations/components/conversationSummary.tsx index ba169269d2796f..5e7cf4dfb7734b 100644 --- a/static/app/views/insights/pages/conversations/components/conversationSummary.tsx +++ b/static/app/views/insights/pages/conversations/components/conversationSummary.tsx @@ -90,6 +90,77 @@ function calculateAggregates(nodes: AITraceSpanNode[]): ConversationAggregates { }; } +/** + * Aggregate metrics row for a conversation (LLM Calls, Errors, Tokens, Cost, Tools). + * Used standalone in the trace AI tab, and as part of ConversationSummary on the detail page. + */ +export function ConversationAggregatesBar({ + nodes, + conversationId, + isLoading, +}: { + conversationId: string; + nodes: AITraceSpanNode[]; + isLoading?: boolean; +}) { + const organization = useOrganization(); + const {selection} = usePageFilters(); + const aggregates = useMemo(() => calculateAggregates(nodes), [nodes]); + + const errorsUrl = getExploreUrl({ + organization, + selection, + query: `gen_ai.conversation.id:${conversationId} span.status:internal_error`, + }); + + return ( + + } + isLoading={isLoading} + /> + } + to={aggregates.errorCount > 0 ? errorsUrl : undefined} + isLoading={isLoading} + /> + } + isLoading={isLoading} + /> + + {isLoading ? ( + + + {t('Used Tools')} + + + + ) : ( + aggregates.toolNames.length > 0 && ( + + + {t('Used Tools')} + + {aggregates.toolNames.map(name => ( + + {name} + + ))} + + ) + )} + + ); +} + export function ConversationSummary({ nodes, conversationId, @@ -97,8 +168,6 @@ export function ConversationSummary({ nodeTraceMap, }: ConversationSummaryProps) { const organization = useOrganization(); - const {selection} = usePageFilters(); - const aggregates = useMemo(() => calculateAggregates(nodes), [nodes]); const handleCopyConversationId = useCallback(() => { copyToClipboard(conversationId, { @@ -119,12 +188,6 @@ export function ConversationSummary({ return Array.from(seen, ([traceId, spanId]) => ({traceId, spanId})); }, [nodeTraceMap]); - const errorsUrl = getExploreUrl({ - organization, - selection, - query: `gen_ai.conversation.id:${conversationId} span.status:internal_error`, - }); - return ( @@ -164,50 +227,11 @@ export function ConversationSummary({ )} - - } - isLoading={isLoading} - /> - } - to={aggregates.errorCount > 0 ? errorsUrl : undefined} - isLoading={isLoading} - /> - } - isLoading={isLoading} - /> - - {isLoading ? ( - - - {t('Used Tools')} - - - - ) : ( - aggregates.toolNames.length > 0 && ( - - - {t('Used Tools')} - - {aggregates.toolNames.map(name => ( - - {name} - - ))} - - ) - )} - + ); } diff --git a/static/app/views/insights/pages/conversations/components/conversationView.tsx b/static/app/views/insights/pages/conversations/components/conversationView.tsx index 347581ad99a482..b2fc8fcc50e55d 100644 --- a/static/app/views/insights/pages/conversations/components/conversationView.tsx +++ b/static/app/views/insights/pages/conversations/components/conversationView.tsx @@ -1,25 +1,27 @@ -import type React from 'react'; -import {memo, useCallback, useEffect, useMemo, useState} from 'react'; +import {memo, useCallback, useState} from 'react'; import styled from '@emotion/styled'; import {Container, Flex} from '@sentry/scraps/layout'; import {TabList, TabPanels, Tabs} from '@sentry/scraps/tabs'; import {EmptyMessage} from 'sentry/components/emptyMessage'; -import {Placeholder} from 'sentry/components/placeholder'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; import {AISpanList} from 'sentry/views/insights/pages/agents/components/aiSpanList'; -import {getDefaultSelectedNode} from 'sentry/views/insights/pages/agents/utils/getDefaultSelectedNode'; import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; +import { + ConversationDetailPanel, + ConversationLeftPanel, + ConversationSplitLayout, + ConversationViewSkeleton, +} from 'sentry/views/insights/pages/conversations/components/conversationLayout'; import {MessagesPanel} from 'sentry/views/insights/pages/conversations/components/messagesPanel'; import { useConversation, type UseConversationsOptions, } from 'sentry/views/insights/pages/conversations/hooks/useConversation'; -import {useFocusedToolSpan} from 'sentry/views/insights/pages/conversations/hooks/useFocusedToolSpan'; -import {extractMessagesFromNodes} from 'sentry/views/insights/pages/conversations/utils/conversationMessages'; +import {useConversationSelection} from 'sentry/views/insights/pages/conversations/hooks/useConversationSelection'; import {DEFAULT_TRACE_VIEW_PREFERENCES} from 'sentry/views/performance/newTraceDetails/traceState/tracePreferences'; import {TraceStateProvider} from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider'; @@ -44,54 +46,14 @@ export const ConversationViewContent = memo(function ConversationViewContent({ focusedTool, }: ConversationViewContentProps) { const {nodes, nodeTraceMap, isLoading, error} = useConversation(conversation); - - const handleSpanFound = useCallback( - (spanId: string) => { - onSelectSpan?.(spanId); - }, - [onSelectSpan] - ); - - useFocusedToolSpan({ + const {selectedNode, handleSelectNode} = useConversationSelection({ nodes, - focusedTool: focusedTool ?? null, + selectedSpanId, + onSelectSpan, + focusedTool, isLoading, - onSpanFound: handleSpanFound, }); - const handleSelectNode = useCallback( - (node: AITraceSpanNode) => { - onSelectSpan?.(node.id); - }, - [onSelectSpan] - ); - - const defaultNodeId = useMemo(() => { - const messages = extractMessagesFromNodes(nodes); - const firstAssistant = messages.find(m => m.role === 'assistant'); - return firstAssistant?.nodeId ?? getDefaultSelectedNode(nodes)?.id; - }, [nodes]); - - const selectedNode = useMemo(() => { - return ( - nodes.find(node => node.id === selectedSpanId) ?? - nodes.find(node => node.id === defaultNodeId) - ); - }, [nodes, selectedSpanId, defaultNodeId]); - - useEffect(() => { - if (isLoading || !defaultNodeId || focusedTool) { - return; - } - - const isCurrentSpanValid = - selectedSpanId && nodes.some(node => node.id === selectedSpanId); - - if (!isCurrentSpanValid) { - onSelectSpan?.(defaultNodeId); - } - }, [isLoading, defaultNodeId, selectedSpanId, nodes, onSelectSpan, focusedTool]); - return ( - - handleTabChange(key as ConversationTab)} - > - - - {t('Chat')} - {t('Spans')} - - - - - - - - - - + handleTabChange(key as ConversationTab)} + > + + + {t('Chat')} + {t('Spans')} + + + + + + - - - - - - - - {selectedNode?.renderDetails({ - node: selectedNode, - manager: null, - onParentClick: () => {}, - onTabScrollToNode: () => {}, - organization, - replay: null, - traceId: nodeTraceMap.get(selectedNode.id) ?? '', - hideNodeActions: true, - initiallyCollapseAiIO: true, - })} - - - ); -} - -function ConversationViewSkeleton() { - return ( - - - - - - - - - - {/* User message skeleton */} - - - - - {/* Assistant message skeleton */} - - - - - - - - - - - - - - - {/* Another user message */} - - - - - {/* Another assistant message */} - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -function LeftPanel({children}: {children: React.ReactNode}) { - return ( - - {children} - + + + } + right={ + + } + /> ); } @@ -310,18 +175,3 @@ const FullWidthTabPanels = styled(TabPanels)` width: 100%; } `; - -function DetailsPanel({children}: {children: React.ReactNode}) { - return ( - - {children} - - ); -} diff --git a/static/app/views/insights/pages/conversations/components/messagesPanel.tsx b/static/app/views/insights/pages/conversations/components/messagesPanel.tsx index 1ef5a7ef36902f..1771ad4fc1c5a3 100644 --- a/static/app/views/insights/pages/conversations/components/messagesPanel.tsx +++ b/static/app/views/insights/pages/conversations/components/messagesPanel.tsx @@ -78,6 +78,7 @@ export function MessagesPanel({nodes, selectedNodeId, onSelectNode}: MessagesPan padding="lg lg md md" background="secondary" minHeight="100%" + width="100%" > {messages.map((message, index) => { diff --git a/static/app/views/insights/pages/conversations/hooks/useConversationSelection.tsx b/static/app/views/insights/pages/conversations/hooks/useConversationSelection.tsx new file mode 100644 index 00000000000000..85f4780bcb545b --- /dev/null +++ b/static/app/views/insights/pages/conversations/hooks/useConversationSelection.tsx @@ -0,0 +1,76 @@ +import {useCallback, useEffect, useMemo} from 'react'; + +import {getDefaultSelectedNode} from 'sentry/views/insights/pages/agents/utils/getDefaultSelectedNode'; +import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; +import {useFocusedToolSpan} from 'sentry/views/insights/pages/conversations/hooks/useFocusedToolSpan'; +import {extractMessagesFromNodes} from 'sentry/views/insights/pages/conversations/utils/conversationMessages'; + +interface UseConversationSelectionOptions { + isLoading: boolean; + nodes: AITraceSpanNode[]; + focusedTool?: string | null; + onSelectSpan?: (spanId: string) => void; + selectedSpanId?: string | null; +} + +/** + * Manages node selection state for conversation views. + * Handles default selection, focused tool auto-selection, + * and keeping selection in sync when nodes change. + */ +export function useConversationSelection({ + nodes, + selectedSpanId, + onSelectSpan, + focusedTool, + isLoading, +}: UseConversationSelectionOptions) { + const handleSpanFound = useCallback( + (spanId: string) => { + onSelectSpan?.(spanId); + }, + [onSelectSpan] + ); + + useFocusedToolSpan({ + nodes, + focusedTool: focusedTool ?? null, + isLoading, + onSpanFound: handleSpanFound, + }); + + const handleSelectNode = useCallback( + (node: AITraceSpanNode) => { + onSelectSpan?.(node.id); + }, + [onSelectSpan] + ); + + const defaultNodeId = useMemo(() => { + const messages = extractMessagesFromNodes(nodes); + const firstAssistant = messages.find(m => m.role === 'assistant'); + return firstAssistant?.nodeId ?? getDefaultSelectedNode(nodes)?.id; + }, [nodes]); + + const selectedNode = useMemo(() => { + return ( + nodes.find(node => node.id === selectedSpanId) ?? + nodes.find(node => node.id === defaultNodeId) + ); + }, [nodes, selectedSpanId, defaultNodeId]); + + useEffect(() => { + if (isLoading || !defaultNodeId || focusedTool) { + return; + } + + const isCurrentSpanValid = + selectedSpanId && nodes.some(node => node.id === selectedSpanId); + + if (!isCurrentSpanValid) { + onSelectSpan?.(defaultNodeId); + } + }, [isLoading, defaultNodeId, selectedSpanId, nodes, onSelectSpan, focusedTool]); + + return {selectedNode, handleSelectNode}; +} diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index 3775bee5ecbe9b..0ab6a371173891 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -12,7 +12,7 @@ import {useParams} from 'sentry/utils/useParams'; import {useLogsPageDataQueryResult} from 'sentry/views/explore/contexts/logs/logsPageData'; import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; -import {TraceAiSpans} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceAiSpans'; +import {TraceAiTab} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceAiTab'; import {TraceProfiles} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceProfiles'; import { TraceViewMetricsProviderWrapper, @@ -208,7 +208,7 @@ function TraceViewImpl({traceSlug}: {traceSlug: string}) { ) : null} {currentTab === TraceLayoutTabKeys.AI_SPANS ? ( - + ) : null} diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiConversations.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiConversations.tsx new file mode 100644 index 00000000000000..2c16d403e07964 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiConversations.tsx @@ -0,0 +1,257 @@ +import {type Key, useCallback, useMemo, useState} from 'react'; +import styled from '@emotion/styled'; + +import {LinkButton} from '@sentry/scraps/button'; +import {Container, Flex} from '@sentry/scraps/layout'; +import {TabList, TabPanels, Tabs} from '@sentry/scraps/tabs'; + +import {EmptyMessage} from 'sentry/components/emptyMessage'; +import {t} from 'sentry/locale'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; +import { + ConversationDetailPanel, + ConversationLeftPanel, + ConversationSplitLayout, + ConversationViewSkeleton, +} from 'sentry/views/insights/pages/conversations/components/conversationLayout'; +import {ConversationAggregatesBar} from 'sentry/views/insights/pages/conversations/components/conversationSummary'; +import {MessagesPanel} from 'sentry/views/insights/pages/conversations/components/messagesPanel'; +import {useConversation} from 'sentry/views/insights/pages/conversations/hooks/useConversation'; +import {useConversationSelection} from 'sentry/views/insights/pages/conversations/hooks/useConversationSelection'; +import {CONVERSATIONS_LANDING_SUB_PATH} from 'sentry/views/insights/pages/conversations/settings'; +import {AiSpansSplitView} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceAiSpans'; +import {DEFAULT_TRACE_VIEW_PREFERENCES} from 'sentry/views/performance/newTraceDetails/traceState/tracePreferences'; +import {TraceStateProvider} from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider'; + +interface TraceAiConversationsProps { + allAiNodes: AITraceSpanNode[]; + conversationIds: string[]; + traceSlug: string; +} + +export function TraceAiConversations({ + conversationIds, + allAiNodes, + traceSlug, +}: TraceAiConversationsProps) { + const organization = useOrganization(); + const [activeSubTab, setActiveSubTab] = useState(`chat-${conversationIds[0]}`); + const [selectedSpanId, setSelectedSpanId] = useState(null); + + const handleTabChange = useCallback((key: Key) => { + setActiveSubTab(String(key)); + }, []); + + const handleSelectSpan = useCallback((spanId: string) => { + setSelectedSpanId(spanId); + }, []); + + const activeConversationId = activeSubTab.startsWith('chat-') + ? activeSubTab.slice('chat-'.length) + : null; + + const { + nodes: conversationNodes, + nodeTraceMap, + isLoading, + error, + } = useConversation({ + conversationId: activeConversationId ?? '', + }); + + const traceNodes = useMemo( + () => conversationNodes.filter(n => nodeTraceMap.get(n.id) === traceSlug), + [conversationNodes, nodeTraceMap, traceSlug] + ); + + const tabItems = useMemo(() => { + const items: Array<{conversationId: string | null; key: string; label: string}> = + conversationIds.map(id => ({ + key: `chat-${id}`, + label: conversationIds.length === 1 ? t('Chat') : t('Chat %s', id.slice(0, 8)), + conversationId: id, + })); + items.push({key: 'spans', label: t('Spans'), conversationId: null}); + return items; + }, [conversationIds]); + + const conversationUrl = activeConversationId + ? normalizeUrl( + `/organizations/${organization.slug}/explore/${CONVERSATIONS_LANDING_SUB_PATH}/${activeConversationId}/${selectedSpanId ? `?spanId=${selectedSpanId}` : ''}` + ) + : null; + + return ( + + + {activeConversationId && conversationUrl && ( + + )} + + + + {tabItems.map(item => ( + {item.label} + ))} + + + + {tabItems.map(item => + item.conversationId ? ( + + + + ) : ( + + + + ) + )} + + + + + ); +} + +function TraceConversationHeader({ + conversationId, + conversationUrl, + nodes, + isLoading, +}: { + conversationId: string; + conversationUrl: string; + isLoading: boolean; + nodes: AITraceSpanNode[]; +}) { + return ( + + + + + + {t('Show full conversation')} + + + + + ); +} + +function TraceConversationChat({ + conversationId, + nodes, + nodeTraceMap, + isLoading, + error, + selectedSpanId, + onSelectSpan, +}: { + conversationId: string; + error: boolean; + isLoading: boolean; + nodeTraceMap: Map; + nodes: AITraceSpanNode[]; + onSelectSpan: (spanId: string) => void; + selectedSpanId: string | null; +}) { + const organization = useOrganization(); + + const {selectedNode, handleSelectNode} = useConversationSelection({ + nodes, + selectedSpanId, + onSelectSpan, + isLoading, + }); + + if (isLoading) { + return ; + } + + if (error) { + return {t('Failed to load conversation')}; + } + + if (nodes.length === 0) { + const conversationUrl = normalizeUrl( + `/organizations/${organization.slug}/explore/${CONVERSATIONS_LANDING_SUB_PATH}/${conversationId}/` + ); + return ( + + {t('Show full conversation')} + + } + > + {t('No chat messages in this portion of the conversation')} + + ); + } + + return ( + + + + + + + } + right={ + + } + /> + + ); +} + +const StyledTabs = styled(Tabs)` + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; +`; + +const FullHeightTabPanels = styled(TabPanels)` + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0; + + > [role='tabpanel'] { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } +`; diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiSpans.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiSpans.tsx index 7672032d5d46bb..6e399a55f568af 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiSpans.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiSpans.tsx @@ -2,6 +2,7 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {LinkButton} from '@sentry/scraps/button'; +import {Container, Flex} from '@sentry/scraps/layout'; import {EmptyMessage} from 'sentry/components/emptyMessage'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -14,15 +15,20 @@ import {AISpanList} from 'sentry/views/insights/pages/agents/components/aiSpanLi import {useAITrace} from 'sentry/views/insights/pages/agents/hooks/useAITrace'; import {getDefaultSelectedNode} from 'sentry/views/insights/pages/agents/utils/getDefaultSelectedNode'; import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; +import { + ConversationDetailPanel, + ConversationLeftPanel, + ConversationSplitLayout, +} from 'sentry/views/insights/pages/conversations/components/conversationLayout'; import type {TraceTree} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; import {TraceLayoutTabKeys} from 'sentry/views/performance/newTraceDetails/useTraceLayoutTabs'; import {getScrollToPath} from 'sentry/views/performance/newTraceDetails/useTraceScrollToPath'; -export function TraceAiSpans({traceSlug}: {traceSlug: string}) { +function useAiSpanSelection(nodes: AITraceSpanNode[]) { const organization = useOrganization(); const navigate = useNavigate(); const location = useLocation(); - const {nodes, isLoading, error} = useAITrace(traceSlug); + const [selectedNodeKey, setSelectedNodeKey] = useState(() => { const path = getScrollToPath()?.path; const lastSpan = path?.findLast(item => item.startsWith('span-')); @@ -53,7 +59,6 @@ export function TraceAiSpans({traceSlug}: {traceSlug: string}) { organization, }); - // Update the node path url param to keep the trace waterfal in sync const nodeIdentifier: TraceTree.NodePath = `span-${eventId}`; navigate( { @@ -69,6 +74,36 @@ export function TraceAiSpans({traceSlug}: {traceSlug: string}) { [location, navigate, organization] ); + return {selectedNode, handleSelectNode}; +} + +interface TraceAiSpansProps { + traceSlug: string; + error?: boolean; + isLoading?: boolean; + nodes?: AITraceSpanNode[]; +} + +/** + * Standalone AI spans view with full chrome (border, header, "View in Full Trace"). + * Used when there are no conversations in the trace. + */ +export function TraceAiSpans({ + traceSlug, + nodes: externalNodes, + isLoading: externalIsLoading, + error: externalError, +}: TraceAiSpansProps) { + const organization = useOrganization(); + const location = useLocation(); + + const aiTrace = useAITrace(traceSlug); + const nodes = externalNodes ?? aiTrace.nodes; + const isLoading = externalIsLoading ?? aiTrace.isLoading; + const error = externalError ?? aiTrace.error; + + const {selectedNode, handleSelectNode} = useAiSpanSelection(nodes); + const handleViewFullTraceClick = useCallback(() => { trackAnalytics('agent-monitoring.trace.view-full-trace-click', { organization, @@ -79,14 +114,14 @@ export function TraceAiSpans({traceSlug}: {traceSlug: string}) { return ; } - if (nodes.length === 0) { - return {t('No AI spans found')}; - } - if (error) { return
{t('Failed to load trace')}
; } + if (nodes.length === 0) { + return {t('No AI spans found')}; + } + return ( {t('Abbreviated Trace')} @@ -129,6 +164,56 @@ export function TraceAiSpans({traceSlug}: {traceSlug: string}) { ); } +/** + * AI spans content in a resizable split layout, without chrome. + * Used inside the conversations tabs for consistent layout. + */ +export function AiSpansSplitView({ + nodes, + traceSlug, +}: { + nodes: AITraceSpanNode[]; + traceSlug: string; +}) { + const {selectedNode, handleSelectNode} = useAiSpanSelection(nodes); + + const nodeTraceMap = useMemo(() => { + const map = new Map(); + for (const node of nodes) { + map.set(node.id, traceSlug); + } + return map; + }, [nodes, traceSlug]); + + if (nodes.length === 0) { + return {t('No AI spans found')}; + } + + return ( + + + + + + + + } + right={ + + } + /> + ); +} + const Wrapper = styled('div')` display: grid; grid-template-columns: minmax(300px, 400px) 1fr; diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiTab.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiTab.tsx new file mode 100644 index 00000000000000..56ed2bc0d90a4d --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiTab.tsx @@ -0,0 +1,50 @@ +import {useMemo} from 'react'; + +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useAITrace} from 'sentry/views/insights/pages/agents/hooks/useAITrace'; +import {getStringAttr} from 'sentry/views/insights/pages/agents/utils/aiTraceNodes'; +import {hasGenAiConversationsFeature} from 'sentry/views/insights/pages/agents/utils/features'; +import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; +import {SpanFields} from 'sentry/views/insights/types'; +import {TraceAiConversations} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceAiConversations'; +import {TraceAiSpans} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceAiSpans'; + +function getConversationIds(nodes: AITraceSpanNode[]): string[] { + const ids = new Set(); + for (const node of nodes) { + const convId = getStringAttr(node, SpanFields.GEN_AI_CONVERSATION_ID); + if (convId) { + ids.add(convId); + } + } + return Array.from(ids); +} + +export function TraceAiTab({traceSlug}: {traceSlug: string}) { + const organization = useOrganization(); + const {nodes, isLoading, error} = useAITrace(traceSlug); + + const conversationIds = useMemo(() => getConversationIds(nodes), [nodes]); + + const hasConversations = + hasGenAiConversationsFeature(organization) && conversationIds.length > 0; + + if (hasConversations) { + return ( + + ); + } + + return ( + + ); +} diff --git a/static/app/views/performance/newTraceDetails/useTraceLayoutTabs.tsx b/static/app/views/performance/newTraceDetails/useTraceLayoutTabs.tsx index 28aa9d62eb7a26..805e238d31db55 100644 --- a/static/app/views/performance/newTraceDetails/useTraceLayoutTabs.tsx +++ b/static/app/views/performance/newTraceDetails/useTraceLayoutTabs.tsx @@ -44,7 +44,7 @@ const TAB_DEFINITIONS: Record = { [TraceLayoutTabKeys.SUMMARY]: {slug: TraceLayoutTabKeys.SUMMARY, label: t('Summary')}, [TraceLayoutTabKeys.AI_SPANS]: { slug: TraceLayoutTabKeys.AI_SPANS, - label: t('AI Spans'), + label: t('AI'), }, }; From eceab557d8393a2aeb503bffd2f9400ae1802734 Mon Sep 17 00:00:00 2001 From: Ogi <86684834+obostjancic@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:16:35 +0200 Subject: [PATCH 2/2] fix(insights): Address review bot feedback on trace AI tab - Use distinct localStorage keys per context for split panel size - Reset selectedSpanId when switching conversation tabs - URL-encode selectedSpanId in conversation link query string Co-Authored-By: Claude Opus 4.6 --- .../pages/conversations/components/conversationLayout.tsx | 6 ++++-- .../traceDrawer/tabs/traceAiConversations.tsx | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/static/app/views/insights/pages/conversations/components/conversationLayout.tsx b/static/app/views/insights/pages/conversations/components/conversationLayout.tsx index 9a35c828c31346..ca62328450fd7e 100644 --- a/static/app/views/insights/pages/conversations/components/conversationLayout.tsx +++ b/static/app/views/insights/pages/conversations/components/conversationLayout.tsx @@ -13,7 +13,7 @@ import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/typ const LEFT_PANEL_MIN = 400; const RIGHT_PANEL_MIN = 400; const DIVIDER_WIDTH = 1; -const STORAGE_KEY = 'conversation-split-size'; +const DEFAULT_STORAGE_KEY = 'conversation-split-size'; /** * Minimal resize divider matching the trace drawer style: @@ -62,9 +62,11 @@ const BorderDivider = styled( export function ConversationSplitLayout({ left, right, + sizeStorageKey = DEFAULT_STORAGE_KEY, }: { left: React.ReactNode; right: React.ReactNode; + sizeStorageKey?: string; }) { const measureRef = useRef(null); const {width} = useDimensions({elementRef: measureRef}); @@ -81,7 +83,7 @@ export function ConversationSplitLayout({ {hasSize ? ( { setActiveSubTab(String(key)); + setSelectedSpanId(null); }, []); const handleSelectSpan = useCallback((spanId: string) => { @@ -79,7 +80,7 @@ export function TraceAiConversations({ const conversationUrl = activeConversationId ? normalizeUrl( - `/organizations/${organization.slug}/explore/${CONVERSATIONS_LANDING_SUB_PATH}/${activeConversationId}/${selectedSpanId ? `?spanId=${selectedSpanId}` : ''}` + `/organizations/${organization.slug}/explore/${CONVERSATIONS_LANDING_SUB_PATH}/${activeConversationId}/${selectedSpanId ? `?spanId=${encodeURIComponent(selectedSpanId)}` : ''}` ) : null; @@ -212,6 +213,7 @@ function TraceConversationChat({ return (