From 4d06987e8ffb7d2f00e809ee112f6e698bf0af99 Mon Sep 17 00:00:00 2001 From: Rob Rhyne Date: Thu, 20 Aug 2026 13:12:04 -0400 Subject: [PATCH 1/3] feat(studio): add raw/md/json format toggle to span payloads Span Input/Output payloads rendered one fixed way, which made JSON payloads hard to scan and markdown payloads render as escaped text. Replace SpanPayloadBlock with SpanPayloadView, which renders a payload as verbatim text, rendered markdown, or pretty-printed JSON, plus a SpanPayloadFormatToggle for the section trigger. The two share state through useSpanPayloadFormat, called in SpanMetadataAccordions because the toggle renders in slotEnd while the payload renders in slotContent. Types and helpers sit in spanPayloadFormat, so each module exports one kind of thing. Payloads open in json when they parse as JSON and raw otherwise, so the common case needs no click. The toggle hides itself for empty payloads, disables json (with a tooltip) for payloads that are not JSON, opens a collapsed section on selection, and scopes the choice to the payload it was made for so another span re-derives the default. The json view re-indents the payload text with jsonc-parser rather than round-tripping it through JSON.stringify(JSON.parse(...)), which routes every number through a float64: an int64 span id such as 9007199254740993 rendered as 9007199254740992, 1.0 as 1, and duplicate keys collapsed to the last. Values are never converted, so the view is lossless. jsonc-parser's own applyEdits is quadratic (~6s for a 585KB payload), so the edits it produces are applied in a single pass. Payloads at or above 20,000 characters paint a spinner for one frame and skip Shiki highlighting so the full text always appears. Deferral is decided during render, since an effect runs only after a commit has already mounted the renderer with the new payload. CodeSnippet keeps highlighted markup in state and never clears it, so switching from json to raw left the pretty-printed markup on screen. Key CodeSnippet on the language it is given so it remounts with clean state on every format change. react-markdown and its remark chain are ~100KB for a format most readers never select, so the markdown renderer loads on demand and Suspense falls back to the same spinner the large-payload path already shows. Signed-off-by: Ryan Rhyne Signed-off-by: Rob Rhyne Co-Authored-By: Claude Opus 5 (1M context) --- web/packages/studio/package.json | 3 +- .../SpanPayloadBlock.test.tsx | 32 --- .../IntakeComponents/SpanPayloadBlock.tsx | 80 ------ .../SpanPayloadFormatToggle.tsx | 72 +++++ .../IntakeComponents/SpanPayloadView.test.tsx | 251 ++++++++++++++++++ .../IntakeComponents/SpanPayloadView.tsx | 113 ++++++++ .../IntakeComponents/spanPayloadFormat.ts | 58 ++++ .../IntakeComponents/useSpanPayloadFormat.ts | 45 ++++ .../src/components/IntakeDetail/README.md | 10 +- .../IntakeDetail/SpanMetadataAccordions.tsx | 82 ++++-- .../SpanTemplates/RetrieverSpanContent.tsx | 4 +- web/pnpm-lock.yaml | 9 +- 12 files changed, 620 insertions(+), 139 deletions(-) delete mode 100644 web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadBlock.test.tsx delete mode 100644 web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadBlock.tsx create mode 100644 web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle.tsx create mode 100644 web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx create mode 100644 web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx create mode 100644 web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts create mode 100644 web/packages/studio/src/components/IntakeDetail/IntakeComponents/useSpanPayloadFormat.ts diff --git a/web/packages/studio/package.json b/web/packages/studio/package.json index aba8d58f11..bc56f8eaf9 100644 --- a/web/packages/studio/package.json +++ b/web/packages/studio/package.json @@ -67,6 +67,7 @@ "classnames": "catalog:", "handlebars": "catalog:", "hyparquet": "catalog:", + "jsonc-parser": "3.3.1", "lucide-react": "catalog:", "modern-tour": "^0.1.1", "oidc-client-ts": "catalog:", @@ -114,7 +115,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 +123,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/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..f31ec1a125 --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx @@ -0,0 +1,251 @@ +// 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('keeps every entry of a payload that repeats a key', () => { + renderRoute( + + ); + + expect(codeText()).toHaveTextContent('"a": 1'); + expect(codeText()).toHaveTextContent('"a": 2'); + }); + + it('renders markdown payloads as formatted content once the renderer loads', async () => { + renderRoute(); + + expect(await screen.findByRole('heading', { name: 'Findings' })).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' })).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..8e06d2a5d8 --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx @@ -0,0 +1,113 @@ +// 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 { + 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, + })) +); + +const PayloadPending: FC = () => ( + + + +); + +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..3c6996eee6 --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type Edit, format } from 'jsonc-parser'; + +export type SpanPayloadFormat = 'raw' | 'md' | 'json'; + +export interface SpanPayloadFormatState { + format: SpanPayloadFormat; + select: (format: SpanPayloadFormat) => void; + isJson: boolean; + isEmpty: boolean; +} + +const JSON_INDENT_OPTIONS = { tabSize: 2, insertSpaces: true, eol: '\n' }; + +// Not jsonc-parser's applyEdits: it rebuilds the document per edit, so a 585KB +// payload takes ~6s there against ~3ms here. +const applyEdits = (text: string, edits: Edit[]): string => { + const parts: string[] = []; + let cursor = 0; + for (const edit of [...edits].sort((a, b) => a.offset - b.offset)) { + parts.push(text.slice(cursor, edit.offset), edit.content); + cursor = edit.offset + edit.length; + } + parts.push(text.slice(cursor)); + return parts.join(''); +}; + +/** Whether a JSON view applies, without paying to build one. */ +export const isJsonPayload = (value: string | null | undefined): boolean => + jsonSource(value) !== null; + +const jsonSource = (value: string | null | undefined): string | null => { + const trimmed = value?.trim(); + if (!trimmed || !(trimmed.startsWith('{') || trimmed.startsWith('['))) { + return null; + } + try { + // Validity gate only; the result is discarded. Re-indenting the source is + // what keeps the view lossless — a JSON.stringify(JSON.parse(...)) round + // trip reformats every number through a float64 and drops duplicate keys. + JSON.parse(trimmed); + return trimmed; + } catch { + return null; + } +}; + +/** Re-indented `value` when it is a JSON object or array, else `null`. */ +export const parseJsonPayload = (value: string | null | undefined): string | null => { + const source = jsonSource(value); + return source === null + ? null + : applyEdits(source, format(source, undefined, JSON_INDENT_OPTIONS)); +}; + +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..00279b8d2b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -703,6 +703,9 @@ importers: hyparquet: specifier: 'catalog:' version: 1.25.1 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 lucide-react: specifier: 'catalog:' version: 1.30.0(react@19.2.7) @@ -11664,7 +11667,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 +11706,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 +11761,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 +15795,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: From d506ca17ee7bd76fd7f9d81bcf3de111fb588dd8 Mon Sep 17 00:00:00 2001 From: Rob Rhyne Date: Thu, 20 Aug 2026 14:10:13 -0400 Subject: [PATCH 2/3] refactor(studio): preserve JSON number literals without jsonc-parser Replaces the jsonc-parser source re-indenting with JSON source text access, dropping the dependency. Parse with a reviver that re-emits each number as the literal it was parsed from, via JSON.rawJSON and the reviver's context.source, then pretty-print as before. Integers past 2^53 such as an int64 span id, along with 1.0, 1e2, and -0, survive unchanged. No feature detection: engines without JSON source text access (before Chrome 114, Firefox 135, Safari 18.4) pass no context, so the reviver falls through to the parsed value and the view degrades to the reformatted numbers. TypeScript 6.0.3 declares neither API, so both are typed locally. This gives up one case jsonc-parser covered: a payload repeating a key keeps only the last, since the object is built before any reviver runs. Noted on parseJsonPayload, with raw as the exact view, and its test is removed. Signed-off-by: Ryan Rhyne Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Rob Rhyne --- web/packages/studio/package.json | 1 - .../IntakeComponents/SpanPayloadView.test.tsx | 9 --- .../IntakeComponents/spanPayloadFormat.ts | 59 +++++++++---------- web/pnpm-lock.yaml | 3 - 4 files changed, 27 insertions(+), 45 deletions(-) diff --git a/web/packages/studio/package.json b/web/packages/studio/package.json index bc56f8eaf9..d75cac6a3e 100644 --- a/web/packages/studio/package.json +++ b/web/packages/studio/package.json @@ -67,7 +67,6 @@ "classnames": "catalog:", "handlebars": "catalog:", "hyparquet": "catalog:", - "jsonc-parser": "3.3.1", "lucide-react": "catalog:", "modern-tour": "^0.1.1", "oidc-client-ts": "catalog:", diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx index f31ec1a125..360abe734a 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx @@ -127,15 +127,6 @@ describe('SpanPayloadView', () => { expect(codeText()).toHaveTextContent('"delta": -0'); }); - it('keeps every entry of a payload that repeats a key', () => { - renderRoute( - - ); - - expect(codeText()).toHaveTextContent('"a": 1'); - expect(codeText()).toHaveTextContent('"a": 2'); - }); - it('renders markdown payloads as formatted content once the renderer loads', async () => { renderRoute(); diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts index 3c6996eee6..003cd2dcd9 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { type Edit, format } from 'jsonc-parser'; - export type SpanPayloadFormat = 'raw' | 'md' | 'json'; export interface SpanPayloadFormatState { @@ -12,47 +10,44 @@ export interface SpanPayloadFormatState { isEmpty: boolean; } -const JSON_INDENT_OPTIONS = { tabSize: 2, insertSpaces: true, eol: '\n' }; - -// Not jsonc-parser's applyEdits: it rebuilds the document per edit, so a 585KB -// payload takes ~6s there against ~3ms here. -const applyEdits = (text: string, edits: Edit[]): string => { - const parts: string[] = []; - let cursor = 0; - for (const edit of [...edits].sort((a, b) => a.offset - b.offset)) { - parts.push(text.slice(cursor, edit.offset), edit.content); - cursor = edit.offset + edit.length; - } - parts.push(text.slice(cursor)); - return parts.join(''); -}; - -/** Whether a JSON view applies, without paying to build one. */ -export const isJsonPayload = (value: string | null | undefined): boolean => - jsonSource(value) !== null; - -const jsonSource = (value: string | null | undefined): string | null => { +// 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 { - // Validity gate only; the result is discarded. Re-indenting the source is - // what keeps the view lossless — a JSON.stringify(JSON.parse(...)) round - // trip reformats every number through a float64 and drops duplicate keys. - JSON.parse(trimmed); - return trimmed; + return JSON.parse(trimmed, keepNumberSource as (key: string, value: unknown) => unknown); } catch { return null; } }; -/** Re-indented `value` when it is a JSON object or array, else `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 source = jsonSource(value); - return source === null - ? null - : applyEdits(source, format(source, undefined, JSON_INDENT_OPTIONS)); + 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/pnpm-lock.yaml b/web/pnpm-lock.yaml index 00279b8d2b..8aa7837d01 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -703,9 +703,6 @@ importers: hyparquet: specifier: 'catalog:' version: 1.25.1 - jsonc-parser: - specifier: 3.3.1 - version: 3.3.1 lucide-react: specifier: 'catalog:' version: 1.30.0(react@19.2.7) From 221a6c957b3f3322867c81c5398950fae7fb24ae Mon Sep 17 00:00:00 2001 From: Rob Rhyne Date: Thu, 20 Aug 2026 16:08:12 -0400 Subject: [PATCH 3/3] refactor(studio): move PayloadPending into its own file SpanPayloadView owned the spinner placeholder it renders in two places, which left two components in one module. Give it its own file, which also drops Flex and Spinner from the view's imports. Also raise the timeout on the two assertions that wait for the markdown renderer. It arrives through a dynamic import, and on a cold module cache that can outlast findBy's 1s default, so a fresh checkout could fail the suite where a warm one passed. Signed-off-by: Ryan Rhyne Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Rob Rhyne --- .../IntakeComponents/PayloadPending.tsx | 16 ++++++++++++++++ .../IntakeComponents/SpanPayloadView.test.tsx | 10 ++++++++-- .../IntakeComponents/SpanPayloadView.tsx | 13 ++----------- 3 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 web/packages/studio/src/components/IntakeDetail/IntakeComponents/PayloadPending.tsx 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/SpanPayloadView.test.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx index 360abe734a..543eb5aaa9 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx @@ -130,7 +130,11 @@ describe('SpanPayloadView', () => { it('renders markdown payloads as formatted content once the renderer loads', async () => { renderRoute(); - expect(await screen.findByRole('heading', { name: 'Findings' })).toBeInTheDocument(); + // 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', () => { @@ -174,7 +178,9 @@ describe('SpanPayloadFormatToggle', () => { await user.click(screen.getByRole('button', { name: 'View input as markdown' })); - expect(await screen.findByRole('heading', { name: 'Findings' })).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: 'Findings' }, { timeout: 5_000 }) + ).toBeInTheDocument(); }); it('drops the JSON formatting when the raw view is selected', async () => { diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx index 8e06d2a5d8..8b0a37cf88 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx @@ -1,7 +1,8 @@ // 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 { CodeSnippet, Text } from '@nvidia/foundations-react-core'; +import { PayloadPending } from '@studio/components/IntakeDetail/IntakeComponents/PayloadPending'; import { autoFormat, parseJsonPayload, @@ -18,16 +19,6 @@ const MarkdownContent = lazy(() => })) ); -const PayloadPending: FC = () => ( - - - -); - interface SpanPayloadViewProps { value: string | null | undefined; emptyMessage: string;