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..ca62328450fd7e --- /dev/null +++ b/static/app/views/insights/pages/conversations/components/conversationLayout.tsx @@ -0,0 +1,214 @@ +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 DEFAULT_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, + sizeStorageKey = DEFAULT_STORAGE_KEY, +}: { + left: React.ReactNode; + right: React.ReactNode; + sizeStorageKey?: string; +}) { + 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..768134743e6e63 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceAiConversations.tsx @@ -0,0 +1,259 @@ +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)); + setSelectedSpanId(null); + }, []); + + 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=${encodeURIComponent(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'), }, };