diff --git a/web/packages/studio/package.json b/web/packages/studio/package.json index aba8d58f11..d75cac6a3e 100644 --- a/web/packages/studio/package.json +++ b/web/packages/studio/package.json @@ -114,7 +114,6 @@ "@vitest/ui": "catalog:", "blob-polyfill": "^9.0.20240710", "globals": "^17.7.0", - "rolldown": "^1.1.4", "happy-dom": "catalog:", "js-yaml": "^4.3.0", "jsdom": "catalog:", @@ -123,6 +122,7 @@ "postcss-prefix-selector": "^2.1.1", "prettier": "^3.2.5", "qs": "catalog:", + "rolldown": "^1.1.4", "rollup-plugin-license": "^3.6.0", "rollup-plugin-visualizer": "^5.12.0", "storybook": "catalog:", diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/PayloadPending.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/PayloadPending.tsx new file mode 100644 index 0000000000..b63836576c --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/PayloadPending.tsx @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex, Spinner } from '@nvidia/foundations-react-core'; +import type { FC } from 'react'; + +/** Placeholder while a payload is too large to render immediately, or its renderer is loading. */ +export const PayloadPending: FC = () => ( + + + +); diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadBlock.test.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadBlock.test.tsx deleted file mode 100644 index b47fc6b352..0000000000 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadBlock.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { SpanPayloadBlock } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadBlock'; -import { renderRoute, screen, waitFor } from '@studio/tests/util/render'; - -describe('SpanPayloadBlock', () => { - it('renders small payloads immediately', () => { - renderRoute(); - - expect(screen.queryByLabelText('Rendering payload')).not.toBeInTheDocument(); - expect(screen.getByText('small payload')).toBeInTheDocument(); - }); - - it('shows a loader before rendering large payloads', async () => { - const payload = 'x'.repeat(20_001); - - renderRoute(); - - expect(screen.getByLabelText('Rendering payload')).toBeInTheDocument(); - await waitFor(() => - expect(screen.queryByLabelText('Rendering payload')).not.toBeInTheDocument() - ); - expect(screen.getByTestId('nv-code-snippet-code')).toHaveTextContent(payload); - }); - - it('renders the empty state for blank payloads', () => { - renderRoute(); - - expect(screen.getByText('No payload')).toBeInTheDocument(); - }); -}); diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadBlock.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadBlock.tsx deleted file mode 100644 index c8dbfcc50f..0000000000 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadBlock.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { CodeSnippet, Flex, Spinner, Text } from '@nvidia/foundations-react-core'; -import { type FC, useEffect, useState } from 'react'; - -const LARGE_PAYLOAD_RENDER_DEFER_CHAR_LIMIT = 20_000; - -/** - * Shared renderer for span request/response payloads (the Input/Output sections - * and any kind-specific payload, e.g. a retriever query). A scrollable code - * block without copy/collapse controls, or a dashed empty state. Keeping this in - * one place ensures every payload renders identically. - */ -export const SpanPayloadBlock: FC<{ value: string | null | undefined; emptyMessage: string }> = ({ - value, - emptyMessage, -}) => { - // Trim only to decide emptiness; render the original payload unchanged. - const payload = value && value.trim() ? value : null; - // Very large payloads can make the code renderer hold the main thread long - // enough that the section looks blank. For those payloads, paint a spinner - // first, then mount the renderer on the next macrotask. - const shouldDeferRender = - payload !== null && payload.length >= LARGE_PAYLOAD_RENDER_DEFER_CHAR_LIMIT; - const [showPayload, setShowPayload] = useState(!shouldDeferRender); - - useEffect(() => { - if (!shouldDeferRender) { - setShowPayload(true); - return; - } - - setShowPayload(false); - // `setTimeout(..., 0)` gives React one committed paint with the spinner - // before the large CodeSnippet mounts. This is render backpressure, not a - // network loading state. - const timeout = setTimeout(() => setShowPayload(true), 0); - return () => clearTimeout(timeout); - }, [payload, shouldDeferRender]); - - if (payload) { - if (!showPayload) { - return ( - - - - ); - } - - return ( - - ); - } - - return ( -
- - {emptyMessage} - -
- ); -}; diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle.tsx new file mode 100644 index 0000000000..5cdc55f154 --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle.tsx @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Button, Flex, Tooltip } from '@nvidia/foundations-react-core'; +import type { + SpanPayloadFormat, + SpanPayloadFormatState, +} from '@studio/components/IntakeDetail/IntakeComponents/spanPayloadFormat'; +import { type FC, type MouseEvent } from 'react'; + +const FORMAT_OPTIONS: readonly { format: SpanPayloadFormat; label: string; name: string }[] = [ + { format: 'raw', label: 'raw', name: 'raw text' }, + { format: 'md', label: 'md', name: 'markdown' }, + { format: 'json', label: 'json', name: 'JSON' }, +]; + +interface SpanPayloadFormatToggleProps { + state: SpanPayloadFormatState; + /** Names the payload in labels and tooltips, e.g. `input` → "View input as JSON". */ + payloadLabel: string; +} + +export const SpanPayloadFormatToggle: FC = ({ + state, + payloadLabel, +}) => { + if (state.isEmpty) { + return null; + } + + // The trigger row is a ; keep clicks from toggling/collapsing it. + const withoutToggle = (handler: () => void) => (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + handler(); + }; + + return ( + + {FORMAT_OPTIONS.map(({ format, label, name }) => { + const unavailable = format === 'json' && !state.isJson; + const active = state.format === format; + const button = ( + + ); + return ( + + {/* A disabled button fires no hover or focus events, so its + tooltip needs a focusable wrapper. */} + {unavailable ? {button} : button} + + ); + })} + + ); +}; diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx new file mode 100644 index 0000000000..543eb5aaa9 --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SpanPayloadFormatToggle } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle'; +import { SpanPayloadView } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadView'; +import { useSpanPayloadFormat } from '@studio/components/IntakeDetail/IntakeComponents/useSpanPayloadFormat'; +import { fireEvent, renderRoute, screen, waitFor } from '@studio/tests/util/render'; +import userEvent from '@testing-library/user-event'; +import { type FC, useState } from 'react'; + +const EMPTY_MESSAGE = 'No payload'; + +// Wires the toggle to the body the way a payload accordion section does: shared +// state in the parent, control on the trigger, payload in the content slot. +const PayloadSection: FC<{ value: string | null | undefined; onSelect?: () => void }> = ({ + value, + onSelect, +}) => { + const format = useSpanPayloadFormat(value, onSelect); + return ( + <> + + + + ); +}; + +// Mirrors the tree view, where selecting another span swaps the payload into the +// same mounted section rather than remounting it. +const SwitchingSection: FC<{ first: string; second: string }> = ({ first, second }) => { + const [value, setValue] = useState(first); + return ( + <> + + + + ); +}; + +const codeText = () => screen.getByTestId('nv-code-snippet-code'); + +describe('SpanPayloadView', () => { + it('renders small payloads immediately', () => { + renderRoute(); + + expect(screen.queryByLabelText('Rendering payload')).not.toBeInTheDocument(); + expect(screen.getByText('small payload')).toBeInTheDocument(); + }); + + it('shows a loader before rendering large payloads', async () => { + const payload = 'x'.repeat(20_001); + + renderRoute(); + + expect(screen.getByLabelText('Rendering payload')).toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByLabelText('Rendering payload')).not.toBeInTheDocument() + ); + expect(codeText()).toHaveTextContent(payload); + }); + + it('defers rendering when a rerender swaps in a large payload', () => { + const payload = 'x'.repeat(20_001); + renderRoute(); + + expect(screen.queryByLabelText('Rendering payload')).not.toBeInTheDocument(); + + // fireEvent, not userEvent: awaiting a click lets the 0ms timer fire, which + // would hide the very spinner this asserts on. + fireEvent.click(screen.getByRole('button', { name: 'Select next span' })); + + // Guards that deferral is re-armed on rerender at all; it cannot observe the + // wasted mount itself, since React replaces that commit before it is queried. + expect(screen.getByLabelText('Rendering payload')).toBeInTheDocument(); + }); + + it('defers rendering when a large payload switches renderers', async () => { + const payload = 'x'.repeat(20_001); + renderRoute(); + + // Let the payload get past the initial spinner first, or the assertion below + // passes on a spinner that was never hidden. + await waitFor(() => + expect(screen.queryByLabelText('Rendering payload')).not.toBeInTheDocument() + ); + + // fireEvent, not userEvent: awaiting a click lets the 0ms timer fire, which + // would hide the very spinner this asserts on. + fireEvent.click(screen.getByRole('button', { name: 'View input as markdown' })); + + // The text is unchanged, so deferral has to key on the renderer too: markdown + // mounts a different one over the same 20k characters. + expect(screen.getByLabelText('Rendering payload')).toBeInTheDocument(); + }); + + it('renders the empty state for blank payloads', () => { + renderRoute(); + + expect(screen.getByText(EMPTY_MESSAGE)).toBeInTheDocument(); + }); + + it('pretty-prints JSON payloads', () => { + renderRoute( + + ); + + expect(codeText()).toHaveTextContent('"role": "user"'); + }); + + it('indents JSON payloads without rewriting their literals', () => { + renderRoute( + + ); + + // A JSON.stringify(JSON.parse(...)) round trip renders these as + // 9007199254740992, 1, 100, and 0. + expect(codeText()).toHaveTextContent('"span_id": 9007199254740993'); + expect(codeText()).not.toHaveTextContent('9007199254740992'); + expect(codeText()).toHaveTextContent('"score": 1.0'); + expect(codeText()).toHaveTextContent('"temp": 1e2'); + expect(codeText()).toHaveTextContent('"delta": -0'); + }); + + it('renders markdown payloads as formatted content once the renderer loads', async () => { + renderRoute(); + + // Generous timeout: the renderer is a dynamic import, so a cold module cache + // can take longer than findBy's 1s default. + expect( + await screen.findByRole('heading', { name: 'Findings' }, { timeout: 5_000 }) + ).toBeInTheDocument(); + }); + + it('falls back to raw when JSON is requested for a payload that is not JSON', () => { + renderRoute(); + + expect(codeText()).toHaveTextContent('plain text'); + }); +}); + +describe('SpanPayloadFormatToggle', () => { + it('opens JSON payloads in the JSON view', async () => { + renderRoute(); + + // findBy, not getBy: awaiting keeps CodeSnippet's async highlight inside + // the test that caused it. + expect(await screen.findByRole('button', { name: 'View input as JSON' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(codeText()).toHaveTextContent('"role": "user"'); + }); + + it('opens plain-text payloads in the raw view', () => { + renderRoute(); + + expect(screen.getByRole('button', { name: 'View input as raw text' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('disables the JSON view for payloads that are not JSON', () => { + renderRoute(); + + expect(screen.getByRole('button', { name: 'View input as JSON' })).toBeDisabled(); + }); + + it('switches the payload to the selected format', async () => { + const user = userEvent.setup(); + renderRoute(); + + await user.click(screen.getByRole('button', { name: 'View input as markdown' })); + + expect( + await screen.findByRole('heading', { name: 'Findings' }, { timeout: 5_000 }) + ).toBeInTheDocument(); + }); + + it('drops the JSON formatting when the raw view is selected', async () => { + const user = userEvent.setup(); + renderRoute(); + + // Wait for the pretty-printed JSON to actually paint: the stale-markup bug + // this guards only shows up once the JSON view has rendered. + await waitFor(() => expect(codeText()).toHaveTextContent('"a": 1')); + + await user.click(screen.getByRole('button', { name: 'View input as raw text' })); + + await waitFor(() => expect(codeText().textContent).toBe('{"a":1}')); + }); + + it('reports the selection so a collapsed section can open', async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + renderRoute(); + + await user.click(screen.getByRole('button', { name: 'View input as markdown' })); + + expect(onSelect).toHaveBeenCalledOnce(); + }); + + it('re-derives the default when another span swaps in a different payload', async () => { + const user = userEvent.setup(); + renderRoute(); + + await user.click(screen.getByRole('button', { name: 'View input as raw text' })); + expect(screen.getByRole('button', { name: 'View input as raw text' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + + await user.click(screen.getByRole('button', { name: 'Select next span' })); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'View input as JSON' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + ); + }); + + it('keeps the selection when another span carries an identical payload', async () => { + const user = userEvent.setup(); + renderRoute(); + + await user.click(screen.getByRole('button', { name: 'View input as raw text' })); + await user.click(screen.getByRole('button', { name: 'Select next span' })); + + expect(screen.getByRole('button', { name: 'View input as raw text' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('renders nothing when there is no payload to format', () => { + renderRoute(); + + expect(screen.queryByRole('button', { name: /^View input as/ })).not.toBeInTheDocument(); + expect(screen.getByText(EMPTY_MESSAGE)).toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx new file mode 100644 index 0000000000..8b0a37cf88 --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CodeSnippet, Text } from '@nvidia/foundations-react-core'; +import { PayloadPending } from '@studio/components/IntakeDetail/IntakeComponents/PayloadPending'; +import { + autoFormat, + parseJsonPayload, + type SpanPayloadFormat, +} from '@studio/components/IntakeDetail/IntakeComponents/spanPayloadFormat'; +import { type FC, lazy, Suspense, useEffect, useMemo, useState } from 'react'; + +const LARGE_PAYLOAD_RENDER_DEFER_CHAR_LIMIT = 20_000; + +// ~100KB of react-markdown, for a format most readers never select. +const MarkdownContent = lazy(() => + import('@nemo/common/src/components/MarkdownContent').then((module) => ({ + default: module.MarkdownContent, + })) +); + +interface SpanPayloadViewProps { + value: string | null | undefined; + emptyMessage: string; + format?: SpanPayloadFormat; +} + +export const SpanPayloadView: FC = ({ value, emptyMessage, format }) => { + const payload = value && value.trim() ? value : null; + const json = useMemo(() => parseJsonPayload(value), [value]); + // A caller can ask for JSON on a payload that stopped being JSON. + const resolved = format && !(format === 'json' && json === null) ? format : autoFormat(!!json); + const text = resolved === 'json' && json !== null ? json : payload; + + // Very large payloads hold the main thread long enough to look blank. + const shouldDeferRender = text !== null && text.length >= LARGE_PAYLOAD_RENDER_DEFER_CHAR_LIMIT; + + // Reset during render, not in an effect: an effect runs only after a commit + // has already mounted the renderer with the new payload — the work deferral + // exists to postpone. `resolved` counts, since identical text remounts anyway + // when it moves between renderers. + const [deferred, setDeferred] = useState(() => ({ text, resolved, show: !shouldDeferRender })); + + if (deferred.text !== text || deferred.resolved !== resolved) { + setDeferred({ text, resolved, show: !shouldDeferRender }); + } + + useEffect(() => { + if (deferred.show) { + return; + } + // One committed paint with the spinner first. Render backpressure, not a + // network loading state. + const timeout = setTimeout(() => setDeferred((current) => ({ ...current, show: true })), 0); + return () => clearTimeout(timeout); + }, [deferred]); + + const showPayload = deferred.show && deferred.text === text && deferred.resolved === resolved; + + if (text === null) { + return ( +
+ + {emptyMessage} + +
+ ); + } + + if (!showPayload) { + return ; + } + + if (resolved === 'md') { + return ( + }> +
+ +
+
+ ); + } + + // Skip async Shiki highlighting on large payloads so the full text appears. + const language = resolved === 'json' && !shouldDeferRender ? 'json' : 'text'; + + return ( + + ); +}; diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts new file mode 100644 index 0000000000..003cd2dcd9 --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type SpanPayloadFormat = 'raw' | 'md' | 'json'; + +export interface SpanPayloadFormatState { + format: SpanPayloadFormat; + select: (format: SpanPayloadFormat) => void; + isJson: boolean; + isEmpty: boolean; +} + +// TypeScript's lib does not declare JSON source text access yet. +type SourceTextReviver = (key: string, value: unknown, context?: { source?: string }) => unknown; +const rawJSON = (JSON as typeof JSON & { rawJSON?: (text: string) => unknown }).rawJSON; + +// Re-emits each number as the literal it was parsed from, so an int64 span id +// such as 9007199254740993 is not rounded to ...992 by a float64 round trip, +// and 1.0, 1e2, and -0 keep the form they were written in. Engines without +// JSON source text access pass no context and fall through to the parsed value. +const keepNumberSource: SourceTextReviver = (_key, value, context) => + rawJSON && typeof value === 'number' && context?.source !== undefined + ? rawJSON(context.source) + : value; + +// Objects and arrays are never null, so null means "not a JSON payload". +const parseJson = (value: string | null | undefined): unknown => { + const trimmed = value?.trim(); + if (!trimmed || !(trimmed.startsWith('{') || trimmed.startsWith('['))) { + return null; + } + try { + return JSON.parse(trimmed, keepNumberSource as (key: string, value: unknown) => unknown); + } catch { + return null; + } +}; + +/** Whether a JSON view applies, without paying to build one. */ +export const isJsonPayload = (value: string | null | undefined): boolean => + parseJson(value) !== null; + +/** + * Pretty-printed `value` when it is a JSON object or array, else `null`. A + * payload that repeats a key keeps only the last, which the reviver cannot + * reach — `raw` remains the exact view. + */ +export const parseJsonPayload = (value: string | null | undefined): string | null => { + const parsed = parseJson(value); + return parsed === null ? null : JSON.stringify(parsed, null, 2); +}; + +export const autoFormat = (isJson: boolean): SpanPayloadFormat => (isJson ? 'json' : 'raw'); diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/useSpanPayloadFormat.ts b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/useSpanPayloadFormat.ts new file mode 100644 index 0000000000..54d76c5f9c --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/useSpanPayloadFormat.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + autoFormat, + isJsonPayload, + type SpanPayloadFormat, + type SpanPayloadFormatState, +} from '@studio/components/IntakeDetail/IntakeComponents/spanPayloadFormat'; +import { useCallback, useMemo, useState } from 'react'; + +/** + * Call this in the component owning both slots of an accordion item. + * + * @param onSelect Runs when a format is picked — e.g. to open the section, + * since the toggle sits on a trigger that may be collapsed. + */ +export const useSpanPayloadFormat = ( + value: string | null | undefined, + onSelect?: () => void +): SpanPayloadFormatState => { + const isJson = useMemo(() => isJsonPayload(value), [value]); + + // Keyed by the payload it was made for, so different text re-derives the + // default instead of keeping a JSON view it cannot satisfy. + const [selection, setSelection] = useState<{ + value: string | null | undefined; + format: SpanPayloadFormat; + } | null>(null); + + const select = useCallback( + (format: SpanPayloadFormat) => { + setSelection({ value, format }); + onSelect?.(); + }, + [value, onSelect] + ); + + return { + format: selection && selection.value === value ? selection.format : autoFormat(isJson), + select, + isJson, + isEmpty: !value?.trim(), + }; +}; diff --git a/web/packages/studio/src/components/IntakeDetail/README.md b/web/packages/studio/src/components/IntakeDetail/README.md index aecbc29539..461cfaaf9c 100644 --- a/web/packages/studio/src/components/IntakeDetail/README.md +++ b/web/packages/studio/src/components/IntakeDetail/README.md @@ -121,7 +121,7 @@ Templates read `raw_attributes` through `SpanTemplates/rawAttributes.ts` (`parse | Section | Accordion label | Body | | ------------------ | --------------- | ------------------------------------------------------------------------ | | `llm` | Usage | Token/cost grid (`buildSpanLlmEntries`, minus model params in kind body) | -| `input` / `output` | Input / Output | `SpanPayloadBlock` | +| `input` / `output` | Input / Output | `SpanPayloadView` + `raw`/`md`/`json` toggle on the trigger | | `metadata` | Metadata | `buildSpanSummaryEntries` via `KeyValueRows` | | `annotations` | Annotations | `AnnotationsPanel` (+ count badge on trigger) | | _(custom)_ | _(per kind)_ | `template.customSections(span)` — open by default | @@ -130,6 +130,14 @@ Templates read `raw_attributes` through `SpanTemplates/rawAttributes.ts` (`parse Expand/collapse-all from the trace toolbar drives section state via `expandToken` / `collapseToken` props (tree view). "Add note" on a row opens the Annotations section and focuses its note field via `focusNoteNonce`. +### Payload formats + +Every payload renders through `SpanPayloadView` in one of three formats: `raw` (verbatim text), `md` (rendered markdown), or `json` (pretty-printed and syntax-highlighted). A payload opens in `json` when it parses as JSON and `raw` otherwise, so the common case needs no click. + +Input and Output pair the view with `SpanPayloadFormatToggle` on the section trigger. The two share state through `useSpanPayloadFormat`, called in `SpanMetadataAccordions` because the toggle renders in `slotEnd` while the payload renders in `slotContent`. The control hides itself when the span has no payload and disables `json` (with a tooltip) for payloads that are not JSON. Selecting a format on a collapsed section also opens it. The choice is scoped to the payload text it was made for, so selecting a span with a different payload re-derives the default rather than keeping a view that payload cannot satisfy. A span whose payload is byte-identical keeps the selection, since either one renders the same text the same way. The trigger is a ``, so each button suppresses the row toggle. + +Payloads at or above 20,000 characters paint a spinner for one frame before mounting the renderer, and skip Shiki highlighting so the full text always appears. Kind-specific payloads (e.g. the retriever query) use `SpanPayloadView` without a toggle and take the same default. + ### Metadata catchall Metadata is **maintenance-free**: whatever is not shown elsewhere. diff --git a/web/packages/studio/src/components/IntakeDetail/SpanMetadataAccordions.tsx b/web/packages/studio/src/components/IntakeDetail/SpanMetadataAccordions.tsx index 80b67b3fdd..751be886be 100644 --- a/web/packages/studio/src/components/IntakeDetail/SpanMetadataAccordions.tsx +++ b/web/packages/studio/src/components/IntakeDetail/SpanMetadataAccordions.tsx @@ -17,10 +17,13 @@ import { buildSpanLlmEntries, buildSpanSummaryEntries, } from '@studio/components/IntakeDetail/IntakeComponents/spanKeyValues'; -import { SpanPayloadBlock } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadBlock'; +import type { SpanPayloadFormatState } from '@studio/components/IntakeDetail/IntakeComponents/spanPayloadFormat'; +import { SpanPayloadFormatToggle } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle'; +import { SpanPayloadView } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadView'; +import { useSpanPayloadFormat } from '@studio/components/IntakeDetail/IntakeComponents/useSpanPayloadFormat'; import { getSpanTemplate } from '@studio/components/IntakeDetail/SpanTemplates/registry'; import type { SpanSectionId } from '@studio/components/IntakeDetail/SpanTemplates/types'; -import { type FC, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; // Entry ids that belong to the kind "Model & parameters" section, not Usage. const LLM_PARAMETER_KEYS: ReadonlySet = new Set(['model', 'provider', 'prompt_id']); @@ -39,6 +42,10 @@ interface SpanSectionContext { usageEntries: readonly KeyValueEntry[]; /** Bumped to focus the Annotations note field. */ focusNoteNonce?: number; + /** Shared raw/md/json state for the Input section's toggle and body. */ + inputFormat: SpanPayloadFormatState; + /** Shared raw/md/json state for the Output section's toggle and body. */ + outputFormat: SpanPayloadFormatState; } // ── Generic section bodies (render the same way for every kind) ────────────── @@ -56,24 +63,34 @@ const UsageSection: FC = ({ usageEntries }) => ( ); -const InputSection: FC = ({ span }) => ( +const InputSection: FC = ({ span, inputFormat }) => ( - ); -const OutputSection: FC = ({ span }) => ( +const InputFormatSlot: FC = ({ inputFormat }) => ( + +); + +const OutputSection: FC = ({ span, outputFormat }) => ( - ); +const OutputFormatSlot: FC = ({ outputFormat }) => ( + +); + const MetadataSection: FC = ({ summaryEntries }) => ( @@ -94,11 +111,17 @@ const AnnotationsSection: FC = ({ span, workspace, focusNote /** The shared (non-kind) sections: a stable id → accordion value/label/body catalog. */ const SECTIONS: Record< GenericSectionId, - { value: string; label: string; Body: FC } + { + value: string; + label: string; + Body: FC; + /** Trailing trigger content, e.g. the payload format toggle. */ + End?: FC; + } > = { llm: { value: 'span-llm', label: 'Usage', Body: UsageSection }, - input: { value: 'span-input', label: 'Input', Body: InputSection }, - output: { value: 'span-output', label: 'Output', Body: OutputSection }, + input: { value: 'span-input', label: 'Input', Body: InputSection, End: InputFormatSlot }, + output: { value: 'span-output', label: 'Output', Body: OutputSection, End: OutputFormatSlot }, metadata: { value: 'span-summary', label: 'Attributes', Body: MetadataSection }, annotations: { value: 'span-annotations', label: 'Annotations', Body: AnnotationsSection }, }; @@ -201,18 +224,43 @@ export const SpanMetadataAccordions: FC = ({ [sectionDefaultOpenValues, customValues] ); + // Controlled so the toolbar's expand/collapse can drive every section at once + // while individual rows stay independently toggleable. Re-seeds when the span + // changes (a new span may expose a different set of sections). + const [openSections, setOpenSections] = useState(defaultOpenValues); + useEffect(() => setOpenSections(defaultOpenValues), [span.span_id, defaultOpenValues]); + + const openSection = useCallback((value: string) => { + setOpenSections((open) => (open.includes(value) ? open : [...open, value])); + }, []); + + // Payload format lives here, not in the section bodies: the toggle renders on + // the section trigger and the payload in its content slot. Picking a format on + // a collapsed section also opens it, so the choice is visible immediately. + const openInput = useCallback(() => openSection(SECTIONS.input.value), [openSection]); + const openOutput = useCallback(() => openSection(SECTIONS.output.value), [openSection]); + const inputFormat = useSpanPayloadFormat(span.input, openInput); + const outputFormat = useSpanPayloadFormat(span.output, openOutput); + const context: SpanSectionContext = { span, workspace, summaryEntries, usageEntries, focusNoteNonce, + inputFormat, + outputFormat, }; const genericItems: IntakeAccordionItem[] = accordionSectionIds.map((id) => { - const { value, label, Body } = SECTIONS[id]; + const { value, label, Body, End } = SECTIONS[id]; const slotLabel = id === 'annotations' ? annotationsSectionLabel(label, annotationCount) : sectionLabel(label); - return { value, slotLabel, slotContent: }; + return { + value, + slotLabel, + slotEnd: End ? : undefined, + slotContent: , + }; }); // Annotations leads, then the kind's custom sections, then the remaining // generic sections (e.g. Metadata). @@ -223,12 +271,6 @@ export const SpanMetadataAccordions: FC = ({ ...genericItems.slice(leadingAnnotations), ]; - // Controlled so the toolbar's expand/collapse can drive every section at once - // while individual rows stay independently toggleable. Re-seeds when the span - // changes (a new span may expose a different set of sections). - const [openSections, setOpenSections] = useState(defaultOpenValues); - useEffect(() => setOpenSections(defaultOpenValues), [span.span_id, defaultOpenValues]); - // Bumping a token broadcasts "open/close everything"; guard on the previous // value so re-renders that don't change the token leave selections alone. const prevExpand = useRef(expandToken); @@ -253,11 +295,9 @@ export const SpanMetadataAccordions: FC = ({ if (focusNoteNonce === undefined || focusNoteNonce === prevFocusNote.current) return; prevFocusNote.current = focusNoteNonce; if (allValues.includes(annotationsValue)) { - setOpenSections((open) => - open.includes(annotationsValue) ? open : [...open, annotationsValue] - ); + openSection(annotationsValue); } - }, [focusNoteNonce, allValues, annotationsValue]); + }, [focusNoteNonce, allValues, annotationsValue, openSection]); return ( diff --git a/web/packages/studio/src/components/IntakeDetail/SpanTemplates/RetrieverSpanContent.tsx b/web/packages/studio/src/components/IntakeDetail/SpanTemplates/RetrieverSpanContent.tsx index 010282e3db..482d609f62 100644 --- a/web/packages/studio/src/components/IntakeDetail/SpanTemplates/RetrieverSpanContent.tsx +++ b/web/packages/studio/src/components/IntakeDetail/SpanTemplates/RetrieverSpanContent.tsx @@ -8,7 +8,7 @@ import type { IntakeAccordionItem } from '@nemo/common/src/components/IntakeAccordion'; import type { Span } from '@nemo/sdk/generated/platform/schema'; import { Text } from '@nvidia/foundations-react-core'; -import { SpanPayloadBlock } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadBlock'; +import { SpanPayloadView } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadView'; import { extractRetrievedDocuments, readRawAttribute, @@ -56,7 +56,7 @@ export const retrieverCustomSections = (span: Span): IntakeAccordionItem[] => { value: QUERY_SECTION, slotLabel: sectionLabel('Query'), slotContent: ( - + ), }, { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 187f9b06d3..8aa7837d01 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11664,7 +11664,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@24.12.0)(@typescript/typescript6@6.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@24.12.0)(typescript@7.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)) '@vitest/eslint-plugin@1.6.16(@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.6.1)))(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.6.1)))(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.6.1))(vitest@4.1.10)': dependencies: @@ -11703,6 +11703,7 @@ snapshots: optionalDependencies: msw: 2.13.3(@types/node@24.12.0)(@typescript/typescript6@6.0.2) vite: 8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3) + optional: true '@vitest/mocker@4.1.10(msw@2.13.3(@types/node@24.12.0)(typescript@7.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3))': dependencies: @@ -11757,7 +11758,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@24.12.0)(@typescript/typescript6@6.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@24.12.0)(typescript@7.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)) '@vitest/utils@3.2.4': dependencies: @@ -15791,6 +15792,7 @@ snapshots: jsdom: 29.1.1 transitivePeerDependencies: - msw + optional: true vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@24.12.0)(typescript@7.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)): dependencies: