Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Text } from '@nvidia/foundations-react-core';
import type {
ChatMessage,
ChatToolCall,
} from '@studio/components/IntakeDetail/IntakeComponents/openaiChat';
import { PayloadPending } from '@studio/components/IntakeDetail/IntakeComponents/PayloadPending';
import { ChevronRight } from 'lucide-react';
import { type FC, lazy, type ReactNode, Suspense } from 'react';

// The same lazy chunk the markdown format uses; message bodies are markdown too.
const MarkdownContent = lazy(() =>
import('@nemo/common/src/components/MarkdownContent').then((module) => ({
default: module.MarkdownContent,
}))
);

/** Roles whose turn is context rather than conversation, so it starts folded away. */
const COLLAPSED_ROLES = new Set(['system', 'developer', 'tool']);

const ROLE_LABELS: Record<string, string> = {
system: 'System prompt',
developer: 'Developer prompt',
tool: 'Tool result',
user: 'User',
assistant: 'Assistant',
};

const roleLabel = (role: string) => ROLE_LABELS[role] ?? role;

interface DisclosureProps {
summary: string;
/** Rendered verbatim after the summary — a tool name is an identifier, not a label. */
detail?: string;
children: ReactNode;
}

/**
* Native `<details>`, so a folded turn costs no state and still opens under
* find-in-page. Nesting inside the accordion's own `<details>` is valid.
*/
const Disclosure: FC<DisclosureProps> = ({ summary, detail, children }) => (
<details
className="group rounded-md border border-base bg-surface-raised"
data-testid="chat-turn-detail"
>
<summary className="flex cursor-pointer list-none items-center gap-density-xs px-density-md py-density-sm text-secondary [&::-webkit-details-marker]:hidden">
<ChevronRight
size={14}
className="shrink-0 transition-transform group-open:rotate-90"
aria-hidden
/>
<Text kind="label/regular/xs" className="uppercase">
{summary}
</Text>
{detail ? (
<Text kind="label/regular/xs" className="truncate font-mono text-subtle">
{detail}
</Text>
) : null}
</summary>
<div className="border-t border-base px-density-md py-density-sm">{children}</div>
</details>
);

const ToolCallBlock: FC<{ call: ChatToolCall }> = ({ call }) => (
<Disclosure summary="Tool call" detail={call.name}>
<pre className="overflow-x-auto text-xs whitespace-pre-wrap text-secondary">
{call.arguments || '(no arguments)'}
</pre>
</Disclosure>
);

const MessageBody: FC<{ text: string }> = ({ text }) => (
<div className="[&_pre]:whitespace-pre-wrap [&_table]:block [&_table]:overflow-x-auto">
<MarkdownContent content={text} />
</div>
);

const ChatTurn: FC<{ message: ChatMessage }> = ({ message }) => {
const label = roleLabel(message.role);

if (COLLAPSED_ROLES.has(message.role)) {
return (
<Disclosure summary={label}>
<MessageBody text={message.content} />
</Disclosure>
);
}

const fromUser = message.role === 'user';

return (
<div className={`flex flex-col gap-density-xs ${fromUser ? 'items-end' : 'items-start'}`}>
<Text kind="label/regular/xs" className="uppercase text-subtle">
{label}
</Text>
<div
className={`flex max-w-[85%] min-w-0 flex-col gap-density-sm rounded-lg border px-density-lg py-density-md ${
fromUser ? 'border-strong bg-surface-sunken' : 'border-base bg-surface-raised'
}`}
>
{message.reasoning ? (
<Disclosure summary="Reasoning">
<MessageBody text={message.reasoning} />
</Disclosure>
) : null}
{message.toolCalls?.map((call, index) => (
<ToolCallBlock key={call.id ?? `${call.name}-${index}`} call={call} />
))}
{message.content ? <MessageBody text={message.content} /> : null}
</div>
</div>
);
};

interface SpanPayloadChatViewProps {
messages: ChatMessage[];
}

/**
* An OpenAI-compatible payload read as the conversation it describes. System,
* tool, and reasoning turns start collapsed so the user and assistant exchange
* is what the section shows.
*/
export const SpanPayloadChatView: FC<SpanPayloadChatViewProps> = ({ messages }) => (
<Suspense fallback={<PayloadPending />}>
<div
className="flex max-h-[420px] flex-col gap-density-lg overflow-auto rounded-md border border-base bg-surface-base p-density-lg"
data-testid="span-payload-chat"
>
{messages.map((message, index) => (
<ChatTurn key={`${message.role}-${index}`} message={message} />
))}
</div>
</Suspense>
);
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const FORMAT_OPTIONS: readonly { format: SpanPayloadFormat; label: string; name:
{ format: 'raw', label: 'raw', name: 'raw text' },
{ format: 'md', label: 'md', name: 'markdown' },
{ format: 'json', label: 'json', name: 'JSON' },
{ format: 'chat', label: 'chat', name: 'a chat' },
];

interface SpanPayloadFormatToggleProps {
Expand All @@ -38,7 +39,13 @@ export const SpanPayloadFormatToggle: FC<SpanPayloadFormatToggleProps> = ({
return (
<Flex align="center" gap="density-xs" className="shrink-0">
{FORMAT_OPTIONS.map(({ format, label, name }) => {
const unavailable = format === 'json' && !state.isJson;
const unavailableReason =
format === 'json' && !state.isJson
? `This ${payloadLabel} is not valid JSON`
: format === 'chat' && !state.isChat
? `This ${payloadLabel} is not an OpenAI chat payload`
: null;
const unavailable = unavailableReason !== null;
const active = state.format === format;
const button = (
<Button
Expand All @@ -56,11 +63,7 @@ export const SpanPayloadFormatToggle: FC<SpanPayloadFormatToggleProps> = ({
</Button>
);
return (
<Tooltip
key={format}
side="top"
slotContent={unavailable ? `This ${payloadLabel} is not valid JSON` : `View as ${name}`}
>
<Tooltip key={format} side="top" slotContent={unavailableReason ?? `View as ${name}`}>
{/* A disabled button fires no hover or focus events, so its
tooltip needs a focusable wrapper. */}
{unavailable ? <span tabIndex={0}>{button}</span> : button}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,96 @@ describe('SpanPayloadFormatToggle', () => {
expect(screen.getByText(EMPTY_MESSAGE)).toBeInTheDocument();
});
});

describe('SpanPayloadChatView', () => {
const CHAT_PAYLOAD = JSON.stringify({
content: {
messages: [
{ role: 'system', content: 'You are a careful assistant.' },
{ role: 'user', content: 'What changed?' },
{
role: 'assistant',
content: '',
tool_calls: [{ id: 'call-1', function: { name: 'search', arguments: '{"q":"diff"}' } }],
},
{ role: 'tool', tool_call_id: 'call-1', content: 'Two files.' },
],
},
choices: [
{
finish_reason: 'stop',
message: { content: 'Two files changed.', reasoning_content: 'Read the tool result.' },
},
],
});

/** Every turn still folded away, in the order the conversation renders them. */
const folded = () =>
screen.getAllByTestId('chat-turn-detail').filter((detail) => !detail.hasAttribute('open'));

it('opens an OpenAI chat payload as a conversation', async () => {
renderRoute(<PayloadSection value={CHAT_PAYLOAD} />);

expect(await screen.findByRole('button', { name: 'View input as a chat' })).toHaveAttribute(
'aria-pressed',
'true'
);
// Generous timeout: message bodies render through the lazily imported markdown chunk.
expect(await screen.findByText('What changed?', {}, { timeout: 5_000 })).toBeInTheDocument();
expect(screen.getByText('Two files changed.')).toBeInTheDocument();
});

it('folds the system, tool, and reasoning turns away until they are opened', async () => {
renderRoute(<SpanPayloadView value={CHAT_PAYLOAD} emptyMessage={EMPTY_MESSAGE} />);

await screen.findByText('System prompt', {}, { timeout: 5_000 });
expect(folded().map((detail) => detail.textContent)).toEqual([
expect.stringContaining('System prompt'),
expect.stringContaining('search'),
expect.stringContaining('Tool result'),
expect.stringContaining('Reasoning'),
]);

// The user and assistant exchange is the part that needs no click.
expect(screen.getByText('What changed?')).toBeVisible();
});

it('reveals a folded turn when it is opened', async () => {
const user = userEvent.setup();
renderRoute(<SpanPayloadView value={CHAT_PAYLOAD} emptyMessage={EMPTY_MESSAGE} />);

await user.click(await screen.findByText('Reasoning', {}, { timeout: 5_000 }));

expect(folded().map((detail) => detail.textContent)).not.toContainEqual(
expect.stringContaining('Reasoning')
);
expect(screen.getByText('Read the tool result.')).toBeInTheDocument();
});

it('disables the chat view for payloads that are not a conversation', async () => {
renderRoute(<PayloadSection value='{"message":"just prose"}' />);

expect(await screen.findByRole('button', { name: 'View input as a chat' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'View input as JSON' })).toHaveAttribute(
'aria-pressed',
'true'
);
});

it('still offers the JSON view of a chat payload', async () => {
const user = userEvent.setup();
renderRoute(<PayloadSection value={CHAT_PAYLOAD} />);

await user.click(await screen.findByRole('button', { name: 'View input as JSON' }));

await waitFor(() => expect(codeText()).toHaveTextContent('"role": "user"'));
});

it('falls back to the default when a chat view is requested for other JSON', async () => {
renderRoute(<SpanPayloadView value='{"a":1}' format="chat" emptyMessage={EMPTY_MESSAGE} />);

expect(screen.queryByTestId('span-payload-chat')).not.toBeInTheDocument();
// Awaited, so CodeSnippet's async highlight settles inside the test that caused it.
await waitFor(() => expect(codeText()).toHaveTextContent('"a": 1'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

import { CodeSnippet, Text } from '@nvidia/foundations-react-core';
import { PayloadPending } from '@studio/components/IntakeDetail/IntakeComponents/PayloadPending';
import { SpanPayloadChatView } from '@studio/components/IntakeDetail/IntakeComponents/SpanPayloadChatView';
import {
autoFormat,
parseChatPayload,
parseJsonPayload,
type SpanPayloadFormat,
} from '@studio/components/IntakeDetail/IntakeComponents/spanPayloadFormat';
Expand All @@ -28,8 +30,10 @@ interface SpanPayloadViewProps {
export const SpanPayloadView: FC<SpanPayloadViewProps> = ({ 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 chat = useMemo(() => parseChatPayload(value), [value]);
// A caller can ask for a format on a payload that stopped supporting it.
const unsupported = (format === 'json' && json === null) || (format === 'chat' && chat === null);
const resolved = format && !unsupported ? format : autoFormat(!!json, !!chat);
const text = resolved === 'json' && json !== null ? json : payload;

// Very large payloads hold the main thread long enough to look blank.
Expand Down Expand Up @@ -71,6 +75,10 @@ export const SpanPayloadView: FC<SpanPayloadViewProps> = ({ value, emptyMessage,
return <PayloadPending />;
}

if (resolved === 'chat' && chat !== null) {
return <SpanPayloadChatView messages={chat} />;
}

if (resolved === 'md') {
return (
<Suspense fallback={<PayloadPending />}>
Expand Down
Loading
Loading