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: