diff --git a/web/packages/studio/src/components/IntakeTraceDetailBody/index.tsx b/web/packages/studio/src/components/IntakeTraceDetailBody/index.tsx new file mode 100644 index 0000000000..f933b0443a --- /dev/null +++ b/web/packages/studio/src/components/IntakeTraceDetailBody/index.tsx @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KVPair } from '@nemo/common/src/components/KVPair'; +import { formatAbsoluteTimestamp } from '@nemo/common/src/components/RelativeTime/util'; +import { useGetTrace } from '@nemo/sdk/generated/platform/api'; +import { Grid, Panel, Stack, StatusMessage, Text } from '@nvidia/foundations-react-core'; +import { IntakeSpansTable } from '@studio/components/IntakeSpansTable'; +import { IntakeTelemetryStatusBadge } from '@studio/components/IntakeTelemetryStatusBadge'; +import { Loading } from '@studio/components/Layouts/Loading'; +import { NotFound } from '@studio/components/Layouts/NotFound'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { getIntakeSpanRoute } from '@studio/routes/utils'; +import { + EMPTY_VALUE, + formatCost, + formatDurationMs, + formatInteger, + formatMaybe, +} from '@studio/util/intakeTelemetry'; +import { Activity, CircleAlert, Hash } from 'lucide-react'; +import { type FC } from 'react'; +import { Link } from 'react-router-dom'; + +const TRACE_SPANS_PAGE_SIZE = 1000; + +export interface IntakeTraceDetailBodyProps { + traceId: string; + filterTogglePortalTargetId?: string; + showSpans?: boolean; +} + +export const IntakeTraceDetailBody: FC = ({ + traceId, + filterTogglePortalTargetId, + showSpans = true, +}) => { + const workspace = useWorkspaceFromPath(); + + const { + data: trace, + error, + isLoading, + } = useGetTrace(workspace, traceId, { + mode: 'detailed', + }); + + if (error?.response?.status === 404) { + return ( + + ); + } + + if (isLoading) { + return ; + } + + if (error) { + return ( + } + slotHeading="Error loading trace" + slotSubheading={error.message} + /> + ); + } + + if (!trace) { + return null; + } + + const showExperimentContext = Boolean( + trace.experiment_context?.experiment_id || trace.experiment_context?.test_case_id + ); + const showSpanLimitMessage = + trace.span_count !== undefined && trace.span_count > TRACE_SPANS_PAGE_SIZE; + const wrappingValueAttributes = { + value: { + className: 'block min-w-0 max-w-full break-all', + }, + }; + + const summaryPanels = ( + + } + slotHeading="Trace Summary" + className="min-w-0 overflow-hidden" + > + + + + + + + + + + + + + {trace.root_span_id} + + ) : ( + EMPTY_VALUE + ) + } + orientation="vertical" + attributes={wrappingValueAttributes} + /> + } + orientation="vertical" + /> + + + + + {showExperimentContext && ( + } + slotHeading="Experiment Context" + className="min-w-0 overflow-hidden" + > + + + + + + + )} + + ); + + if (!showSpans) { + return summaryPanels; + } + + return ( + + + {showSpanLimitMessage && ( + + Showing first {TRACE_SPANS_PAGE_SIZE.toLocaleString()} of{' '} + {trace.span_count?.toLocaleString()} spans. Parent spans outside this page are marked in + the hierarchy. + + )} + + + {summaryPanels} + + ); +}; diff --git a/web/packages/studio/src/components/dataViews/ExperimentSessionsDataView/index.tsx b/web/packages/studio/src/components/dataViews/ExperimentSessionsDataView/index.tsx index 61a05fdb85..8c8801ab46 100644 --- a/web/packages/studio/src/components/dataViews/ExperimentSessionsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/ExperimentSessionsDataView/index.tsx @@ -16,13 +16,14 @@ import type { ExperimentSessionFilter, ExperimentSessionResponse, } from '@nemo/sdk/generated/platform/schema'; -import { Text, Tooltip } from '@nvidia/foundations-react-core'; +import { Button, Stack, Text, Tooltip } from '@nvidia/foundations-react-core'; import { Empty } from '@studio/components/dataViews/ExperimentSessionsDataView/Empty'; +import { IntakeTraceDetailBody } from '@studio/components/IntakeTraceDetailBody'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { tooltipClassName } from '@studio/styles/common'; import { keepPreviousData } from '@tanstack/react-query'; -import { Columns3 } from 'lucide-react'; -import { type ComponentProps, type FC, useMemo } from 'react'; +import { Columns3, X } from 'lucide-react'; +import { type ComponentProps, type FC, useMemo, useState } from 'react'; type SessionRow = ExperimentSessionResponse & { _rowId: string }; @@ -41,6 +42,7 @@ export const ExperimentSessionsDataView: FC = ( experimentGroupName, }) => { const workspace = useWorkspaceFromPath(); + const [selectedTraceId, setSelectedTraceId] = useState(null); const dataViewState = useStudioDataViewState({ columnVisibility: {} }); const { data: experiment } = useGetExperiment(workspace, experimentName); @@ -210,62 +212,91 @@ export const ExperimentSessionsDataView: FC = ( ), ]; + const showTraceDetail = selectedTraceId !== null; + return ( - } - > - <> - - Columns - - - } - attributes={{ - DataViewRoot: { - data: visibleTableData, - totalCount, - requestStatus: isLoading && !sessionsData ? 'loading' : undefined, - }, - DataViewSearchBar: { placeholder: 'Search case...' }, - DataViewTableContent: { - renderEmptyState: () => { - const hasActiveFilters = - !!dataViewState.searchBar.state || dataViewState.columnFiltering.state.length > 0; - if (hasActiveFilters) { - return ( - - Change your filters and try again, or{' '} - - . - - } - /> - ); - } - return ( - '} - /> - ); - }, - }, - }} - /> +
+
+ { + if (row.trace_id) setSelectedTraceId(row.trace_id); + }} + toolbarSlotEnd={ + } + > + <> + + Columns + + + } + attributes={{ + DataViewRoot: { + data: visibleTableData, + totalCount, + requestStatus: isLoading && !sessionsData ? 'loading' : undefined, + }, + DataViewSearchBar: { placeholder: 'Search case...' }, + DataViewTableContent: { + renderEmptyState: () => { + const hasActiveFilters = + !!dataViewState.searchBar.state || dataViewState.columnFiltering.state.length > 0; + if (hasActiveFilters) { + return ( + + Change your filters and try again, or{' '} + + . + + } + /> + ); + } + return ( + '} + /> + ); + }, + }, + }} + /> +
+ +
+ +
+ {selectedTraceId !== null && ( + + )} +
+
); }; diff --git a/web/packages/studio/src/routes/IntakeTraceDetailRoute/index.spec.tsx b/web/packages/studio/src/routes/IntakeTraceDetailRoute/index.spec.tsx index e1286d5879..59501675c0 100644 --- a/web/packages/studio/src/routes/IntakeTraceDetailRoute/index.spec.tsx +++ b/web/packages/studio/src/routes/IntakeTraceDetailRoute/index.spec.tsx @@ -19,8 +19,8 @@ describe('IntakeTraceDetailRoute', () => { it('renders experiment context only when present and filters spans to the trace', async () => { renderTraceDetail('trace-agent-run-001'); - expect(await screen.findByText('Trace Answer customer policy question')).toBeInTheDocument(); - expect(screen.getByText('Experiment Context')).toBeInTheDocument(); + expect(await screen.findByText('Trace trace-agent-run-001')).toBeInTheDocument(); + expect(await screen.findByText('Experiment Context')).toBeInTheDocument(); expect(await screen.findByText('Generate final response')).toBeInTheDocument(); expect(screen.queryByText('Retrieve deployment troubleshooting steps')).not.toBeInTheDocument(); }); @@ -28,9 +28,7 @@ describe('IntakeTraceDetailRoute', () => { it('omits experiment context when the trace has none', async () => { renderTraceDetail('trace-agent-run-002'); - expect( - await screen.findByText('Trace Retrieve deployment troubleshooting steps') - ).toBeInTheDocument(); + expect(await screen.findByText('Trace trace-agent-run-002')).toBeInTheDocument(); expect(screen.queryByText('Experiment Context')).not.toBeInTheDocument(); }); }); diff --git a/web/packages/studio/src/routes/IntakeTraceDetailRoute/index.tsx b/web/packages/studio/src/routes/IntakeTraceDetailRoute/index.tsx index 54e80010dd..e3f80a69cc 100644 --- a/web/packages/studio/src/routes/IntakeTraceDetailRoute/index.tsx +++ b/web/packages/studio/src/routes/IntakeTraceDetailRoute/index.tsx @@ -1,39 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { KVPair } from '@nemo/common/src/components/KVPair'; -import { formatAbsoluteTimestamp } from '@nemo/common/src/components/RelativeTime/util'; -import { useGetTrace } from '@nemo/sdk/generated/platform/api'; -import { - Grid, - PageHeader, - Panel, - Stack, - StatusMessage, - Text, -} from '@nvidia/foundations-react-core'; +import { PageHeader, Stack } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; -import { IntakeSpansTable } from '@studio/components/IntakeSpansTable'; -import { IntakeTelemetryStatusBadge } from '@studio/components/IntakeTelemetryStatusBadge'; -import { Loading } from '@studio/components/Layouts/Loading'; +import { IntakeTraceDetailBody } from '@studio/components/IntakeTraceDetailBody'; import { NotFound } from '@studio/components/Layouts/NotFound'; import { ROUTE_PARAMS } from '@studio/constants/routes'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { getIntakeSpanRoute, getIntakeTracesRoute } from '@studio/routes/utils'; -import { - EMPTY_VALUE, - formatCost, - formatDurationMs, - formatInteger, - formatMaybe, - getTraceDisplayName, -} from '@studio/util/intakeTelemetry'; -import { Activity, CircleAlert, Hash } from 'lucide-react'; +import { getIntakeTracesRoute } from '@studio/routes/utils'; import { type FC, useEffect } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; -const TRACE_SPANS_PAGE_SIZE = 1000; const TRACE_DETAIL_SPANS_FILTER_TARGET_ID = 'trace-detail-spans-filter-action-target'; type TraceRouteParams = Record; @@ -56,17 +34,7 @@ interface IntakeTraceDetailContentProps { const IntakeTraceDetailContent: FC = ({ traceId }) => { const workspace = useWorkspaceFromPath(); - - const { - data: trace, - error, - isLoading, - } = useGetTrace(workspace, traceId, { - mode: 'detailed', - }); - const { setBreadcrumbs } = useBreadcrumbs(); - const traceBreadcrumbLabel = trace ? getTraceDisplayName(trace) : traceId; useEffect(() => { setBreadcrumbs([ @@ -75,58 +43,17 @@ const IntakeTraceDetailContent: FC = ({ traceId } href: getIntakeTracesRoute(workspace), }, { - slotLabel: `Trace ${traceBreadcrumbLabel}`, + slotLabel: `Trace ${traceId}`, }, ]); - }, [setBreadcrumbs, traceBreadcrumbLabel, workspace]); - - if (error?.response?.status === 404) { - return ( - - ); - } - - if (isLoading) { - return ; - } - - if (error) { - return ( - } - slotHeading="Error loading trace" - slotSubheading={error.message} - /> - ); - } - - if (!trace) { - return null; - } - - const title = getTraceDisplayName(trace); - const showExperimentContext = Boolean( - trace.experiment_context?.experiment_id || trace.experiment_context?.test_case_id - ); - const showSpanLimitMessage = - trace.span_count !== undefined && trace.span_count > TRACE_SPANS_PAGE_SIZE; - const wrappingValueAttributes = { - value: { - className: 'block min-w-0 max-w-full break-all', - }, - }; + }, [setBreadcrumbs, traceId, workspace]); return ( - + = ({ traceId } /> } /> - - - {showSpanLimitMessage && ( - - Showing first {TRACE_SPANS_PAGE_SIZE.toLocaleString()} of{' '} - {trace.span_count?.toLocaleString()} spans. Parent spans outside this page are - marked in the hierarchy. - - )} - - - - } - slotHeading="Trace Summary" - className="min-w-0 overflow-hidden" - > - - - - - - - - - - - - - {trace.root_span_id} - - ) : ( - EMPTY_VALUE - ) - } - orientation="vertical" - attributes={wrappingValueAttributes} - /> - } - orientation="vertical" - /> - - - - - {showExperimentContext && ( - } - slotHeading="Experiment Context" - className="min-w-0 overflow-hidden" - > - - - - - - - )} - - + );