diff --git a/static/app/views/explore/conversations/components/conversationLayout.tsx b/static/app/views/explore/conversations/components/conversationLayout.tsx index 746fa8d57f44..5599db4d027d 100644 --- a/static/app/views/explore/conversations/components/conversationLayout.tsx +++ b/static/app/views/explore/conversations/components/conversationLayout.tsx @@ -1,7 +1,7 @@ import type React from 'react'; import {useRef} from 'react'; -import {Container, Flex} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {SplitPanel} from '@sentry/scraps/splitPanel'; import {Placeholder} from 'sentry/components/placeholder'; @@ -83,6 +83,92 @@ export function ConversationLeftPanel({children}: {children: React.ReactNode}) { ); } +export function SpanDetailCard({ + children, + embedded, + ref, +}: { + children: React.ReactNode; + embedded?: boolean; + ref?: React.Ref; +}) { + return ( + + {children} + + ); +} + +export function ConversationTimelineLayout({ + left, + right, + leftPadding = 'md', +}: { + left: React.ReactNode; + leftPadding?: React.ComponentProps['padding']; + right?: React.ReactNode; +}) { + return ( + + + + + + {left} + + {right ? ( + + {right} + + ) : null} + + + + + ); +} + export function ConversationDetailPanel({ selectedNode, nodeTraceMap, diff --git a/static/app/views/explore/conversations/components/conversationSpanDetail.tsx b/static/app/views/explore/conversations/components/conversationSpanDetail.tsx index 692e7f5c60f7..16a68b733368 100644 --- a/static/app/views/explore/conversations/components/conversationSpanDetail.tsx +++ b/static/app/views/explore/conversations/components/conversationSpanDetail.tsx @@ -16,6 +16,7 @@ import {capitalize} from 'sentry/utils/string/capitalize'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; +import {SpanDetailCard} from 'sentry/views/explore/conversations/components/conversationLayout'; import {useTraceItemDetails} from 'sentry/views/explore/hooks/useTraceItemDetails'; import {TraceItemDataset} from 'sentry/views/explore/types'; import {getNodeTimeBounds} from 'sentry/views/insights/pages/agents/components/aiSpanList'; @@ -58,7 +59,6 @@ type SpanAttributes = Parameters[1]; interface ConversationSpanDetailProps { activeTab: DetailTab; - node: AITraceSpanNode; onTabChange: (tab: DetailTab) => void; traceId: string; /** @@ -71,6 +71,10 @@ interface ConversationSpanDetailProps { * border and radius. Off, it renders as a standalone bordered card. */ embedded?: boolean; + /** Renders the loading skeleton while the conversation is still fetching. */ + isLoading?: boolean; + /** The span to show. May be undefined while loading. */ + node?: AITraceSpanNode; /** When provided, a close button is shown in the header. */ onClose?: () => void; /** Scrolls the panel back to the top whenever this value changes. */ @@ -85,6 +89,7 @@ export function ConversationSpanDetail({ onClose, avgDuration, embedded, + isLoading, scrollResetKey, }: ConversationSpanDetailProps) { const theme = useTheme(); @@ -96,8 +101,12 @@ export function ConversationSpanDetail({ // Full attributes (tool inputs/results, the complete attribute list) aren't // returned by the conversation list endpoint, so they're fetched per span. - const eapValue = isEAPSpanNode(node) ? node.value : null; - const {data, isLoading, isError} = useTraceItemDetails({ + const eapValue = node && isEAPSpanNode(node) ? node.value : null; + const { + data, + isLoading: isAttributesLoading, + isError, + } = useTraceItemDetails({ traceItemId: eapValue?.event_id ?? '', projectId: eapValue ? eapValue.project_id.toString() : '', traceId, @@ -106,6 +115,10 @@ export function ConversationSpanDetail({ timestamp: eapValue?.start_timestamp, enabled: Boolean(eapValue), }); + if (isLoading || !node) { + return ; + } + const attributes = data?.attributes; const title = node.op || node.description || t('Span'); @@ -118,20 +131,7 @@ export function ConversationSpanDetail({ ); return ( - + @@ -197,7 +197,7 @@ export function ConversationSpanDetail({ @@ -205,7 +205,7 @@ export function ConversationSpanDetail({ @@ -213,14 +213,14 @@ export function ConversationSpanDetail({ - + ); } @@ -459,3 +459,29 @@ function EmptyTab({message}: {message: string}) { ); } + +function SpanDetailSkeleton({embedded}: {embedded?: boolean}) { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/static/app/views/explore/conversations/components/conversationSummaryNew.tsx b/static/app/views/explore/conversations/components/conversationSummaryNew.tsx index 787b73f4fc80..41a9baf9f652 100644 --- a/static/app/views/explore/conversations/components/conversationSummaryNew.tsx +++ b/static/app/views/explore/conversations/components/conversationSummaryNew.tsx @@ -112,6 +112,10 @@ export function ConversationSummaryNew({ + + + + diff --git a/static/app/views/explore/conversations/components/conversationViewNew.tsx b/static/app/views/explore/conversations/components/conversationViewNew.tsx index 39b3bf8e1098..cc4b970e02fd 100644 --- a/static/app/views/explore/conversations/components/conversationViewNew.tsx +++ b/static/app/views/explore/conversations/components/conversationViewNew.tsx @@ -2,22 +2,14 @@ import {useCallback, useEffect, useRef} from 'react'; import * as Sentry from '@sentry/react'; import {parseAsBoolean, parseAsStringLiteral, useQueryStates} from 'nuqs'; -import {Container, Flex} from '@sentry/scraps/layout'; - import {EmptyMessage} from 'sentry/components/emptyMessage'; import {t} from 'sentry/locale'; -import { - ConversationLeftPanel, - ConversationViewSkeleton, -} from 'sentry/views/explore/conversations/components/conversationLayout'; +import {ConversationTimelineLayout} from 'sentry/views/explore/conversations/components/conversationLayout'; import { CONVERSATION_SPAN_DETAIL_TABS, ConversationSpanDetail, } from 'sentry/views/explore/conversations/components/conversationSpanDetail'; -import { - MessagesPanelNew, - MessagesPanelSkeleton, -} from 'sentry/views/explore/conversations/components/messagesPanelNew'; +import {MessagesPanelNew} from 'sentry/views/explore/conversations/components/messagesPanelNew'; import { useConversation, type UseConversationsOptions, @@ -96,12 +88,6 @@ export function ConversationViewContentNew({ const isTranscript = !isTimeline; - // The transcript renders its own chat-shaped skeleton inside the layout below; - // the timeline tab keeps the legacy span-detail skeleton. - if (isLoading && !isTranscript) { - return ; - } - if (error) { return {t('Failed to load conversation')}; } @@ -112,76 +98,43 @@ export function ConversationViewContentNew({ return ( - - - - - - {isTranscript ? ( - isLoading ? ( - - ) : ( - - ) - ) : ( - - )} - - {detailState.detailOpen && selectedNode ? ( - - setDetailState({detailTab})} - onClose={() => setDetailState({detailOpen: false, detailTab: null})} - /> - - ) : null} - - - - + + ) : ( + + ) + } + right={ + // The timeline auto-selects a span, so its detail pane skeletons while loading. + detailState.detailOpen && (isLoading ? isTimeline : Boolean(selectedNode)) ? ( + setDetailState({detailTab})} + onClose={() => setDetailState({detailOpen: false, detailTab: null})} + /> + ) : null + } + /> ); } diff --git a/static/app/views/explore/conversations/components/messagesPanelNew.tsx b/static/app/views/explore/conversations/components/messagesPanelNew.tsx index 55c1a954d8e4..0a1fa2af7475 100644 --- a/static/app/views/explore/conversations/components/messagesPanelNew.tsx +++ b/static/app/views/explore/conversations/components/messagesPanelNew.tsx @@ -36,6 +36,7 @@ interface MessagesPanelNewProps { nodes: AITraceSpanNode[]; onSelectNode: (node: AITraceSpanNode) => void; selectedNodeId: string | null; + isLoading?: boolean; } /** @@ -48,6 +49,7 @@ export function MessagesPanelNew({ selectedNodeId, onSelectNode, nodeTraceMap, + isLoading, }: MessagesPanelNewProps) { const organization = useOrganization(); const messages = useMemo(() => extractMessagesFromNodes(nodes), [nodes]); @@ -80,6 +82,10 @@ export function MessagesPanelNew({ } }; + if (isLoading) { + return ; + } + if (messages.length === 0) { return ( diff --git a/static/app/views/insights/pages/agents/components/aiSpanTimeline.tsx b/static/app/views/insights/pages/agents/components/aiSpanTimeline.tsx index fa743bb6c42a..0c1b7e2fb144 100644 --- a/static/app/views/insights/pages/agents/components/aiSpanTimeline.tsx +++ b/static/app/views/insights/pages/agents/components/aiSpanTimeline.tsx @@ -8,6 +8,7 @@ import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import {Count} from 'sentry/components/count'; +import {Placeholder} from 'sentry/components/placeholder'; import {IconFire} from 'sentry/icons'; import {t} from 'sentry/locale'; import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; @@ -50,11 +51,13 @@ export function AiSpanTimeline({ onSelectNode, nodeTraceMap, compressGaps = false, + isLoading = false, }: { nodes: AITraceSpanNode[]; onSelectNode: (node: AITraceSpanNode) => void; selectedNodeKey: string | null; compressGaps?: boolean; + isLoading?: boolean; nodeTraceMap?: Map; }) { const compressedBounds = useMemo( @@ -80,6 +83,10 @@ export function AiSpanTimeline({ return parents; }, [nodes]); + if (isLoading) { + return ; + } + return ( {nodes.map(node => { @@ -102,6 +109,51 @@ export function AiSpanTimeline({ ); } +const TIMELINE_SKELETON_ROWS: Array<{ + title: string; + indent?: boolean; + secondary?: string; +}> = [ + {title: '28%', secondary: '13%'}, + {title: '26%', secondary: '12%', indent: true}, + {title: '32%', indent: true}, + {title: '24%', secondary: '20%', indent: true}, + {title: '30%', secondary: '11%', indent: true}, + {title: '33%', indent: true}, + {title: '31%', secondary: '14%'}, + {title: '28%', secondary: '15%', indent: true}, +]; + +function TimelineSkeleton() { + return ( + + {TIMELINE_SKELETON_ROWS.map((row, index) => ( + + + + + + {row.secondary ? : null} + + + + + + + + + + + + ))} + + ); +} + const TimelineRow = memo(function TimelineRow({ node, onSelectNode,