diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadChatView.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadChatView.tsx new file mode 100644 index 0000000000..9dd03cd85b --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadChatView.tsx @@ -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 = { + 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 `
`, so a folded turn costs no state and still opens under + * find-in-page. Nesting inside the accordion's own `
` is valid. + */ +const Disclosure: FC = ({ summary, detail, children }) => ( +
+ + + + {summary} + + {detail ? ( + + {detail} + + ) : null} + +
{children}
+
+); + +const ToolCallBlock: FC<{ call: ChatToolCall }> = ({ call }) => ( + +
+      {call.arguments || '(no arguments)'}
+    
+
+); + +const MessageBody: FC<{ text: string }> = ({ text }) => ( +
+ +
+); + +const ChatTurn: FC<{ message: ChatMessage }> = ({ message }) => { + const label = roleLabel(message.role); + + if (COLLAPSED_ROLES.has(message.role)) { + return ( + + + + ); + } + + const fromUser = message.role === 'user'; + + return ( +
+ + {label} + +
+ {message.reasoning ? ( + + + + ) : null} + {message.toolCalls?.map((call, index) => ( + + ))} + {message.content ? : null} +
+
+ ); +}; + +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 = ({ messages }) => ( + }> +
+ {messages.map((message, index) => ( + + ))} +
+
+); diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle.tsx index 5cdc55f154..95c9d416e4 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle.tsx +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadFormatToggle.tsx @@ -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 { @@ -38,7 +39,13 @@ export const SpanPayloadFormatToggle: FC = ({ return ( {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 = ( ); 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 index 543eb5aaa9..9dabe62f1e 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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')); + }); +}); diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx index 8b0a37cf88..4a351f4c92 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/SpanPayloadView.tsx @@ -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'; @@ -28,8 +30,10 @@ interface SpanPayloadViewProps { 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 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. @@ -71,6 +75,10 @@ export const SpanPayloadView: FC = ({ value, emptyMessage, return ; } + if (resolved === 'chat' && chat !== null) { + return ; + } + if (resolved === 'md') { return ( }> diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/openaiChat.test.ts b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/openaiChat.test.ts new file mode 100644 index 0000000000..a9e397e8dc --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/openaiChat.test.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { toChatMessages } from '@studio/components/IntakeDetail/IntakeComponents/openaiChat'; + +describe('toChatMessages', () => { + it('reads a request transcript', () => { + expect( + toChatMessages({ + model: 'gpt-4o', + messages: [ + { role: 'system', content: 'Be brief.' }, + { role: 'user', content: 'Hello' }, + ], + }) + ).toEqual([ + expect.objectContaining({ role: 'system', content: 'Be brief.' }), + expect.objectContaining({ role: 'user', content: 'Hello' }), + ]); + }); + + it('reads a request recorded as an HTTP body', () => { + // Some observers store the whole request, putting the body under `content`. + expect( + toChatMessages({ + headers: { authorization: 'redacted' }, + content: { messages: [{ role: 'user', content: 'Hello' }] }, + }) + ).toEqual([expect.objectContaining({ role: 'user', content: 'Hello' })]); + }); + + it('reads a bare array of messages', () => { + expect(toChatMessages([{ role: 'user', content: 'Hello' }])).toEqual([ + expect.objectContaining({ role: 'user', content: 'Hello' }), + ]); + }); + + it('assumes the assistant for a choice that omits its role', () => { + expect( + toChatMessages({ + choices: [ + { finish_reason: 'stop', message: { content: 'Hi', reasoning_content: 'Greet them.' } }, + ], + }) + ).toEqual([ + expect.objectContaining({ + role: 'assistant', + content: 'Hi', + reasoning: 'Greet them.', + finishReason: 'stop', + }), + ]); + }); + + it('reads a single assistant message response', () => { + expect( + toChatMessages({ + assistant_message: { role: 'assistant', content: 'Done', tool_calls: [] }, + finish_reason: 'stop', + }) + ).toEqual([expect.objectContaining({ role: 'assistant', content: 'Done' })]); + }); + + it('joins a recorded request and its reply into one conversation', () => { + const messages = toChatMessages({ + content: { messages: [{ role: 'user', content: 'Hello' }] }, + choices: [{ message: { role: 'assistant', content: 'Hi' } }], + }); + + expect(messages?.map((message) => message.role)).toEqual(['user', 'assistant']); + }); + + it('keeps tool calls and the id a tool result answers', () => { + const messages = toChatMessages({ + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { name: 'search', arguments: '{"q":"a"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call-1', content: '{"hits":0}' }, + ], + }); + + expect(messages?.[0].toolCalls).toEqual([ + { id: 'call-1', name: 'search', arguments: '{"q":"a"}' }, + ]); + expect(messages?.[1]).toMatchObject({ role: 'tool', toolCallId: 'call-1' }); + }); + + it('flattens multi-part content and names the parts that carry no text', () => { + const messages = toChatMessages({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this' }, + { type: 'image_url', image_url: { url: 'https://example.test/a.png' } }, + ], + }, + ], + }); + + expect(messages?.[0].content).toBe('Describe this\n\n[image_url]'); + }); + + it('rejects a payload whose `message` is prose rather than a message object', () => { + expect(toChatMessages({ message: 'I could not find a booking system.' })).toBeNull(); + }); + + it('rejects an array that is not entirely messages', () => { + expect(toChatMessages([{ role: 'user', content: 'Hello' }, { step: 1 }])).toBeNull(); + }); + + it('rejects payloads with no conversation in them', () => { + expect(toChatMessages({ error: { type: 'APIError', message: 'overloaded' } })).toBeNull(); + expect(toChatMessages({ messages: [] })).toBeNull(); + expect(toChatMessages('plain text')).toBeNull(); + expect(toChatMessages(null)).toBeNull(); + }); +}); diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/openaiChat.ts b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/openaiChat.ts new file mode 100644 index 0000000000..e0c99713fb --- /dev/null +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/openaiChat.ts @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** One tool invocation an assistant turn asked for. */ +export interface ChatToolCall { + id?: string; + name: string; + /** Argument JSON as the model emitted it, not re-parsed, so malformed arguments still show. */ + arguments: string; +} + +export interface ChatMessage { + /** `system`, `developer`, `user`, `assistant`, `tool` — or whatever the payload carried. */ + role: string; + content: string; + /** Provider-returned chain of thought (`reasoning_content`). */ + reasoning?: string; + toolCalls?: ChatToolCall[]; + /** Ties a tool result back to the call that asked for it. */ + toolCallId?: string; + finishReason?: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const nonEmptyString = (value: unknown): string | undefined => + typeof value === 'string' && value.trim() ? value : undefined; + +/** Content is a string, `null` for a tool-call-only turn, or an array of typed parts. */ +const contentText = (content: unknown): string => { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + return ''; + } + return content + .map((part) => { + if (typeof part === 'string') { + return part; + } + if (!isRecord(part)) { + return ''; + } + // Image and audio parts carry no text; name the kind so the turn is not blank. + return typeof part.text === 'string' + ? part.text + : typeof part.type === 'string' + ? `[${part.type}]` + : ''; + }) + .filter(Boolean) + .join('\n\n'); +}; + +const toToolCalls = (value: unknown): ChatToolCall[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + const calls = value.flatMap((call) => { + if (!isRecord(call)) { + return []; + } + // The OpenAI shape nests name/arguments under `function`; some gateways flatten them. + const fn = isRecord(call.function) ? call.function : call; + const name = nonEmptyString(fn.name); + if (!name) { + return []; + } + const args = fn.arguments; + return [ + { + id: nonEmptyString(call.id), + name, + arguments: + typeof args === 'string' + ? args + : args === undefined || args === null + ? '' + : JSON.stringify(args, null, 2), + }, + ]; + }); + return calls.length ? calls : undefined; +}; + +const buildMessage = (value: Record, role: string): ChatMessage => ({ + role, + content: contentText(value.content), + reasoning: nonEmptyString(value.reasoning_content) ?? nonEmptyString(value.reasoning), + toolCalls: toToolCalls(value.tool_calls), + toolCallId: nonEmptyString(value.tool_call_id), +}); + +/** A request-side message, which always names its own role. */ +const toMessage = (value: unknown): ChatMessage | null => { + if (!isRecord(value)) { + return null; + } + const role = nonEmptyString(value.role); + return role ? buildMessage(value, role) : null; +}; + +/** + * A response-side message, where the role is implied by position. Inferring it + * would match almost any object, so this one has to carry something readable. + */ +const toResponseMessage = (value: unknown, finishReason?: string): ChatMessage | null => { + if (!isRecord(value)) { + return null; + } + const message = buildMessage(value, nonEmptyString(value.role) ?? 'assistant'); + if (!message.content && !message.reasoning && !message.toolCalls) { + return null; + } + return { ...message, finishReason }; +}; + +/** A transcript only, so one stray element rejects the whole array. */ +const toTranscript = (value: unknown): ChatMessage[] | null => { + if (!Array.isArray(value) || value.length === 0) { + return null; + } + const messages = value.map(toMessage); + return messages.every((message) => message !== null) ? (messages as ChatMessage[]) : null; +}; + +const requestMessages = (root: Record): ChatMessage[] | null => { + // Some observers record the whole HTTP body, putting the request under `content`. + const body = isRecord(root.content) ? root.content : root; + return toTranscript(body.messages); +}; + +const responseMessages = (root: Record): ChatMessage[] | null => { + if (Array.isArray(root.choices)) { + const choices = root.choices.flatMap((choice) => { + if (!isRecord(choice)) { + return []; + } + // `delta` is the streaming spelling of `message`. + const message = toResponseMessage( + choice.message ?? choice.delta, + nonEmptyString(choice.finish_reason) + ); + return message ? [message] : []; + }); + if (choices.length) { + return choices; + } + } + const single = toResponseMessage( + root.assistant_message ?? root.message, + nonEmptyString(root.finish_reason) + ); + return single ? [single] : null; +}; + +/** + * The chat turns in an OpenAI-compatible payload, or `null` when it is not one. + * + * Covers a bare transcript, a request (`messages`, optionally wrapped in the + * recorded body), and a response (`choices[].message`, `assistant_message`). A + * payload holding both — a recorded request/response pair — reads as one + * conversation ending in the reply. + */ +export const toChatMessages = (parsed: unknown): ChatMessage[] | null => { + const transcript = toTranscript(parsed); + if (transcript) { + return transcript; + } + if (!isRecord(parsed)) { + return null; + } + const request = requestMessages(parsed); + const response = responseMessages(parsed); + if (request && response) { + return [...request, ...response]; + } + return request ?? response; +}; diff --git a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts index 003cd2dcd9..bb2a3468a4 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/spanPayloadFormat.ts @@ -1,12 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export type SpanPayloadFormat = 'raw' | 'md' | 'json'; +import { + toChatMessages, + type ChatMessage, +} from '@studio/components/IntakeDetail/IntakeComponents/openaiChat'; + +export type SpanPayloadFormat = 'raw' | 'md' | 'json' | 'chat'; export interface SpanPayloadFormatState { format: SpanPayloadFormat; select: (format: SpanPayloadFormat) => void; isJson: boolean; + isChat: boolean; isEmpty: boolean; } @@ -36,9 +42,17 @@ const parseJson = (value: string | null | undefined): unknown => { } }; -/** Whether a JSON view applies, without paying to build one. */ -export const isJsonPayload = (value: string | null | undefined): boolean => - parseJson(value) !== null; +/** The chat turns in `value`, or `null` when it is not an OpenAI-compatible payload. */ +export const parseChatPayload = (value: string | null | undefined): ChatMessage[] | null => + toChatMessages(parseJson(value)); + +/** Which views a payload supports, from a single parse. */ +export const readPayloadFormats = ( + value: string | null | undefined +): { isJson: boolean; isChat: boolean } => { + const parsed = parseJson(value); + return { isJson: parsed !== null, isChat: toChatMessages(parsed) !== null }; +}; /** * Pretty-printed `value` when it is a JSON object or array, else `null`. A @@ -50,4 +64,6 @@ export const parseJsonPayload = (value: string | null | undefined): string | nul return parsed === null ? null : JSON.stringify(parsed, null, 2); }; -export const autoFormat = (isJson: boolean): SpanPayloadFormat => (isJson ? 'json' : 'raw'); +/** A chat payload opens as a conversation; the structure is the point of reading it. */ +export const autoFormat = (isJson: boolean, isChat = false): SpanPayloadFormat => + isChat ? 'chat' : 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 index 54d76c5f9c..a75a801eb3 100644 --- a/web/packages/studio/src/components/IntakeDetail/IntakeComponents/useSpanPayloadFormat.ts +++ b/web/packages/studio/src/components/IntakeDetail/IntakeComponents/useSpanPayloadFormat.ts @@ -3,7 +3,7 @@ import { autoFormat, - isJsonPayload, + readPayloadFormats, type SpanPayloadFormat, type SpanPayloadFormatState, } from '@studio/components/IntakeDetail/IntakeComponents/spanPayloadFormat'; @@ -19,7 +19,7 @@ export const useSpanPayloadFormat = ( value: string | null | undefined, onSelect?: () => void ): SpanPayloadFormatState => { - const isJson = useMemo(() => isJsonPayload(value), [value]); + const { isJson, isChat } = useMemo(() => readPayloadFormats(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. @@ -37,9 +37,10 @@ export const useSpanPayloadFormat = ( ); return { - format: selection && selection.value === value ? selection.format : autoFormat(isJson), + format: selection && selection.value === value ? selection.format : autoFormat(isJson, isChat), select, isJson, + isChat, isEmpty: !value?.trim(), }; }; diff --git a/web/packages/studio/src/components/IntakeDetail/README.md b/web/packages/studio/src/components/IntakeDetail/README.md index 5818583076..d3645682d4 100644 --- a/web/packages/studio/src/components/IntakeDetail/README.md +++ b/web/packages/studio/src/components/IntakeDetail/README.md @@ -124,7 +124,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 | `SpanPayloadView` + `raw`/`md`/`json` toggle on the trigger | +| `input` / `output` | Input / Output | `SpanPayloadView` + `raw`/`md`/`json`/`chat` 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 | @@ -135,9 +135,11 @@ Expand/collapse-all from the trace toolbar drives section state via `expandToken ### 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. +Every payload renders through `SpanPayloadView` in one of four formats: `raw` (verbatim text), `md` (rendered markdown), `json` (pretty-printed and syntax-highlighted), or `chat` (an OpenAI-compatible payload read as the conversation it describes). A payload opens in `chat` when it holds a conversation, `json` when it merely 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. +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` and `chat` (each with a tooltip saying why) for payloads that cannot support them. 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. + +`openaiChat.ts` decides what counts as a conversation: a bare transcript, a request (`messages`, also when the observer recorded the whole body and it sits under `content`), or a response (`choices[].message`, `assistant_message`). A payload holding a request and its reply reads as one conversation ending in the answer. Detection is deliberately strict — request-side turns must name a `role`, and a response-side message, whose role is implied by position, must carry content, reasoning, or a tool call — so a payload like `{"message": "some prose"}` stays JSON. `SpanPayloadChatView` renders user and assistant turns as bubbles with markdown bodies and folds the context away in native `
`: system and developer prompts, tool results, assistant reasoning (`reasoning_content`), and each tool call's arguments all start collapsed. 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.