From d15361e775f4687a017748ed0d203f02f21d90c7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 15 May 2026 21:10:28 +0200 Subject: [PATCH 1/9] fix(frontend): rewrite BeautifiedJsonView as collapsible tree with accessibility Replace the flat field-per-key layout with a recursive tree renderer that supports collapse/expand, indent guides, type-colored scalars, hover-to-copy, and shallow chat detection. Fix hierarchy-collapsing bug caused by deep extractChatMessages search, while preserving OpenAI choices format and sender/author role alias detection from the old version. --- .../DrillInView/BeautifiedJsonView.tsx | 820 +++++++++++------- 1 file changed, 499 insertions(+), 321 deletions(-) diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index 4ada672cfb..c051b8321b 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -1,4 +1,4 @@ -import {memo, useEffect, useLayoutEffect, useMemo} from "react" +import {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react" import { Editor as EditorWrapper, @@ -7,7 +7,7 @@ import { SET_MARKDOWN_VIEW, } from "@agenta/ui" import { - extractChatMessages, + isChatMessagesArray, normalizeChatMessages, ROLE_COLOR_CLASSES, DEFAULT_ROLE_COLOR_CLASS, @@ -16,37 +16,92 @@ import { /** * "Beautified JSON" view. * - * ## What this mode does - * * Reshapes data for readability and renders it OUTSIDE the JSON editor as a * component tree: * - * - chat-like arrays and single messages → chat bubbles (role label + content + * - chat-like arrays and single messages -> chat bubbles (role label + content * editor with markdown support) - * - plain objects → per-key labeled variable fields (recursive; short leaves - * inline as `key: value`) + * - plain objects -> per-key labeled fields with collapse/expand, count badges, + * indent guides, and hover-to-copy + * - short leaf values (null, bool, number, short string) inline as + * `key: value` with type-colored values * - known envelope patterns (AI SDK `{type: "text"|"tool-call"|"tool-result"}`) * are unwrapped into their payload - * - noisy provider-metadata keys (`providerOptions`, `rawHeaders`, `rawCall`, - * `rawResponse`, `logprobs`, etc.) are stripped from objects - * - * ## What this mode is NOT + * - noisy provider-metadata keys (`providerOptions`, `rawHeaders`, etc.) are + * stripped from objects * - * "Beautified JSON" is not JSON — it hides fields, restructures values, and + * "Beautified JSON" is not JSON -- it hides fields, restructures values, and * renders via custom React components. When the exact shape of the wire data * matters, use "JSON" (faithful) or "Decoded JSON" (faithful shape with * string decoding, see `decodedJsonHelpers.ts`). - * - * This mode is the default when `viewModePreset="message"`, because that - * preset is used specifically for chat-style data where the reshape is - * desirable. Everywhere else, "Decoded JSON" is the default. - * - * ## Authoritative reference - * - * `VIEW_MODES.md` in this folder documents every view mode and the rules - * for choosing a default. Keep it in sync when you change behavior here. */ +// Keep in sync with MESSAGE_KEY_HINTS in messagePanels.ts +const CHAT_ARRAY_KEYS = new Set([ + "prompt", + "input_messages", + "completion", + "output_messages", + "responses", + "messages", + "message_history", + "history", + "chat", + "conversation", + "logs", +]) + +// Matches isChatEntry in @agenta/ui/cell-renderers — accept the same role +// aliases (sender, author) and content aliases (text, message, parts, etc.) +const isSingleMessage = (value: unknown): boolean => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const obj = value as Record + const hasRole = + typeof obj.role === "string" || + typeof obj.sender === "string" || + typeof obj.author === "string" + if (!hasRole) return false + return ( + obj.content !== undefined || + obj.text !== undefined || + obj.message !== undefined || + Array.isArray(obj.content) || + Array.isArray(obj.parts) || + Array.isArray(obj.tool_calls) || + typeof (obj.delta as Record)?.content === "string" + ) +} + +const shallowExtractChatMessages = ( + value: unknown, +): {messages: unknown[]; viaKey?: string} | null => { + if (Array.isArray(value) && isChatMessagesArray(value)) { + return {messages: value} + } + if (isSingleMessage(value)) { + return {messages: [value]} + } + if (value && typeof value === "object" && !Array.isArray(value)) { + const obj = value as Record + for (const key of CHAT_ARRAY_KEYS) { + const arr = obj[key] + if (Array.isArray(arr) && isChatMessagesArray(arr)) { + return {messages: arr, viaKey: key} + } + } + // OpenAI choices format: {choices: [{message: {role, content}}, ...]} + if (Array.isArray(obj.choices)) { + const extracted = (obj.choices as Record[]) + .map((c) => c?.message ?? c?.delta) + .filter(Boolean) + if (extracted.length > 0 && isChatMessagesArray(extracted)) { + return {messages: extracted, viaKey: "choices"} + } + } + } + return null +} + const METADATA_NOISE_KEYS = new Set([ "providerOptions", "experimental_providerMetadata", @@ -59,26 +114,27 @@ const METADATA_NOISE_KEYS = new Set([ "logprobs", ]) -const EDITOR_RESET_CLASSES = - "!min-h-0 [&_.editor-inner]:!border-0 [&_.editor-inner]:!rounded-none [&_.editor-inner]:!min-h-0 [&_.editor-container]:!bg-transparent [&_.editor-container]:!min-h-0 [&_.editor-input]:!min-h-0 [&_.editor-input]:!px-0 [&_.editor-input]:!py-0 [&_.editor-paragraph]:!mb-1 [&_.editor-paragraph:last-child]:!mb-0 [&_.agenta-editor-wrapper]:!min-h-0" +const EDITOR_RESET_CLASSES = [ + "!min-h-0", + "[&_.editor-inner]:!border-0 [&_.editor-inner]:!rounded-none [&_.editor-inner]:!min-h-0", + "[&_.editor-container]:!bg-transparent [&_.editor-container]:!min-h-0", + "[&_.editor-input]:!min-h-0 [&_.editor-input]:!px-0 [&_.editor-input]:!py-0", + "[&_.editor-paragraph]:!mb-1 [&_.editor-paragraph:last-child]:!mb-0", + "[&_.agenta-editor-wrapper]:!min-h-0", +].join(" ") const DEFAULT_MAX_RENDER_DEPTH = 5 +const DEFAULT_EXPAND_DEPTH = 2 -/** - * Simplify a value by unwrapping known envelope patterns. - * Returns the simplified value, or the original if no simplification applies. - */ const simplifyValue = (value: unknown): unknown => { if (!value || typeof value !== "object") return value const rec = value as Record - // AI SDK text part: {type: "text", text: "hello"} → "hello" if (rec.type === "text" && typeof rec.text === "string") { return rec.text } - // AI SDK tool-call: {type: "tool-call", toolName: "fn", input: {...}} → "fn({...})" if (rec.type === "tool-call" && typeof rec.toolName === "string") { const args = rec.input ?? rec.args if (!args || (typeof args === "object" && Object.keys(args as object).length === 0)) { @@ -91,7 +147,6 @@ const simplifyValue = (value: unknown): unknown => { } } - // AI SDK tool-result envelope: {type: "tool-result", output: {type: "json", value: X}} → X if (rec.type === "tool-result" && rec.output !== undefined) { const output = rec.output as Record | undefined if (output && typeof output === "object" && output.value !== undefined) { @@ -100,13 +155,11 @@ const simplifyValue = (value: unknown): unknown => { return rec.output } - // Single-element array of a simplifiable item if (Array.isArray(value) && value.length === 1) { const simplified = simplifyValue(value[0]) if (simplified !== value[0]) return simplified } - // Multi-element array: simplify each element if (Array.isArray(value) && value.length > 1) { const simplified = value.map(simplifyValue) const changed = simplified.some((s, i) => s !== value[i]) @@ -118,7 +171,6 @@ const simplifyValue = (value: unknown): unknown => { } } - // Strip metadata noise keys from objects if (!Array.isArray(value)) { const keys = Object.keys(rec) const noiseKeys = keys.filter((k) => METADATA_NOISE_KEYS.has(k)) @@ -129,10 +181,6 @@ const simplifyValue = (value: unknown): unknown => { cleaned[k] = rec[k] } } - const cleanedKeys = Object.keys(cleaned) - if (cleanedKeys.length === 1) { - return cleaned[cleanedKeys[0]] - } return cleaned } } @@ -140,12 +188,6 @@ const simplifyValue = (value: unknown): unknown => { return value } -const formatLabel = (key: string): string => - key - .replace(/_/g, " ") - .replace(/([a-z])([A-Z])/g, "$1 $2") - .replace(/\b\w/g, (c) => c.toUpperCase()) - const isShortLeaf = (value: unknown): boolean => { if (value === null || value === undefined) return true if (typeof value === "boolean" || typeof value === "number") return true @@ -153,21 +195,6 @@ const isShortLeaf = (value: unknown): boolean => { return false } -const InlineKeyValue = memo(function InlineKeyValue({ - label, - value, -}: { - label: string - value: string -}) { - return ( -
- {label} - {value || "—"} -
- ) -}) - const MarkdownModeSync = ({isMarkdownView}: {isMarkdownView: boolean}) => { const [editor] = useLexicalComposerContext() @@ -233,53 +260,6 @@ const getMessageText = (content: unknown): string => { } } -const RenderedChatMessages = memo(function RenderedChatMessages({ - messages, - keyPrefix, -}: { - messages: unknown[] - keyPrefix: string -}) { - const normalized = useMemo(() => normalizeChatMessages(messages), [messages]) - - return ( -
- {normalized.map((msg, i) => { - const roleColor = - ROLE_COLOR_CLASSES[msg.role.toLowerCase()] ?? DEFAULT_ROLE_COLOR_CLASS - const text = getMessageText(msg.content) - const editorId = `${keyPrefix}-msg-${i}` - - return ( -
- - {msg.role} - - - - - -
- ) - })} -
- ) -}) - const valueToString = (value: unknown): string => { if (value === null || value === undefined) return "" if (typeof value === "string") return value @@ -291,70 +271,224 @@ const valueToString = (value: unknown): string => { } } -const ReadOnlyVariableField = memo(function ReadOnlyVariableField({ - label, +// ── Icons (static, no props — defined as elements to skip component overhead) ─ + +const CHEVRON_ICON = ( + +) + +const COPY_ICON = ( + +) + +// ── Small components ──────────────────────────────────────────────────── + +const ScalarValue = ({value}: {value: unknown}) => { + if (value === null || value === undefined) { + return ( + + null + + ) + } + if (typeof value === "boolean") { + return ( + + {String(value)} + + ) + } + if (typeof value === "number") { + return ( + + {String(value)} + + ) + } + if (typeof value === "string") { + return {value} + } + return null +} + +const CopyButton = ({value}: {value: unknown}) => { + const [copied, setCopied] = useState(false) + const timerRef = useRef>() + + useEffect(() => () => clearTimeout(timerRef.current), []) + + const handleCopy = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) || "" + navigator.clipboard + ?.writeText(text) + ?.then(() => { + setCopied(true) + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setCopied(false), 1200) + }) + ?.catch(() => {}) + }, + [value], + ) + + return ( + + ) +} + +// ── NodeRow: unified row structure ────────────────────────────────────── +// +// Every row has the same layout: +// [chevron 14px] [key (mono)] [meta or inline value] [copy-on-hover] +// so keys align in a single column at every depth. Containers get an +// interactive chevron; leaves get an invisible 14px spacer. + +const NodeRow = memo(function NodeRow({ + keyLabel, + meta, + inlineValue, + body, + collapsible, + defaultOpen = true, value, - editorId, + isSection, + isMessage, }: { - label: string - value: string - editorId: string + keyLabel: React.ReactNode + meta?: string + inlineValue?: React.ReactNode + body?: React.ReactNode + collapsible?: boolean + defaultOpen?: boolean + value?: unknown + isSection?: boolean + isMessage?: boolean }) { + const [open, setOpen] = useState(defaultOpen) + const toggle = useCallback(() => setOpen((o) => !o), []) + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setOpen((o) => !o) + } + }, []) + return ( - - -
- - {label} +
+
+ + {collapsible && ( + + {CHEVRON_ICON} + + )} - + + {isMessage ? ( + {keyLabel} + ) : ( + + {keyLabel} + + )} + + {meta ? ( + + {meta} + + ) : null} + + {inlineValue ? ( + + {inlineValue} + + ) : null} + + {value !== undefined ? : null}
- + + {body && open ? ( + // border-0 then border-l then border-solid: Ant Design's CSS layer + // overrides Tailwind preflight, so border-style must be set explicitly. +
+ {body} +
+ ) : null} +
) }) -const RenderedValueBlock = memo(function RenderedValueBlock({ - value: rawValue, +// ── MessageNodeRow ────────────────────────────────────────────────────── + +const MessageNodeRow = memo(function MessageNodeRow({ + msg, + index, keyPrefix, - depth = 0, - maxDepth = DEFAULT_MAX_RENDER_DEPTH, }: { - value: unknown + msg: {role: string; content: unknown} + index: number keyPrefix: string - depth?: number - maxDepth?: number }) { - const value = useMemo(() => simplifyValue(rawValue), [rawValue]) - - const chatMessages = useMemo(() => extractChatMessages(value), [value]) - - if (chatMessages && chatMessages.length > 0) { - return - } - - if (value === null || value === undefined) { - return - } + const role = (msg.role || "").toLowerCase() + const roleColor = ROLE_COLOR_CLASSES[role] ?? DEFAULT_ROLE_COLOR_CLASS + const text = getMessageText(msg.content) + const editorId = `${keyPrefix}-msg-${index}` + + const label = useMemo( + () => {msg.role || "—"}, + [msg.role, roleColor], + ) - if (typeof value === "string") { - return ( + const body = useMemo( + () => ( + ), + [editorId, text], + ) + + return ( + + ) +}) + +// ── RecursiveNode ─────────────────────────────────────────────────────── + +const RecursiveNode = memo(function RecursiveNode({ + name, + value: rawValue, + keyPrefix, + depth = 0, + maxDepth = DEFAULT_MAX_RENDER_DEPTH, + expandDepth = DEFAULT_EXPAND_DEPTH, + parentIsArray = false, + isSection = false, +}: { + name: string | number + value: unknown + keyPrefix: string + depth?: number + maxDepth?: number + expandDepth?: number + parentIsArray?: boolean + isSection?: boolean +}) { + const value = useMemo(() => simplifyValue(rawValue), [rawValue]) + const keyLabel = parentIsArray ? `[${name}]` : String(name) + const nodePrefix = `${keyPrefix}-${name}` + + const chatResult = useMemo(() => shallowExtractChatMessages(value), [value]) + if (chatResult) { + const normalized = normalizeChatMessages(chatResult.messages) + return ( + + {normalized.map((msg, i) => ( + + ))} +
+ } + /> ) } - if (Array.isArray(value) && value.length === 0) { - return + if (isShortLeaf(value)) { + return ( + } + collapsible={false} + value={value} + isSection={isSection} + /> + ) } - if ( - depth < maxDepth && - Array.isArray(value) && - value.length > 0 && - value.some((item) => item && typeof item === "object") - ) { + if (typeof value === "string") { return ( -
- {value.map((item, i) => { - const simplified = simplifyValue(item) - if ( - typeof simplified === "string" || - typeof simplified === "number" || - typeof simplified === "boolean" - ) { - return ( - - ) - } - if (simplified && typeof simplified === "object") { - return ( -
-
- -
-
- ) - } - return ( - + + - ) - })} -
+
+ } + /> ) } - if (value && typeof value === "object" && !Array.isArray(value)) { - const entries = Object.entries(value as Record) + if (value && typeof value === "object") { + const isArray = Array.isArray(value) + const entries: [string, unknown][] = isArray + ? value.map((v, i) => [String(i), v]) + : Object.entries(value as Record) + const count = entries.length + const meta = isArray + ? `[${count} ${count === 1 ? "item" : "items"}]` + : `{${count} ${count === 1 ? "key" : "keys"}}` + + if (depth >= maxDepth) { + return ( + + ) + } + return ( -
- {entries.map(([k, v]) => { - const simplified = simplifyValue(v) - const nestedChat = extractChatMessages(simplified) - if (nestedChat && nestedChat.length > 0) { - return ( -
- - {formatLabel(k)} - - -
- ) - } - - if (isShortLeaf(simplified)) { - return ( - - ) - } - - if (depth < maxDepth && simplified && typeof simplified === "object") { - return ( -
- - {formatLabel(k)} - -
- -
-
- ) - } - - return ( - - ) - })} -
+ 0} + defaultOpen={depth < expandDepth} + value={value} + isSection={isSection} + body={ + count > 0 + ? entries.map(([k, v]) => ( + + )) + : undefined + } + /> ) } return ( - - - - + + {valueToString(value)} + + } + collapsible={false} + value={value} + isSection={isSection} + /> ) }) -/** - * Beautified JSON view: renders a value as per-key fields or chat messages - * (opt-in display mode; do not use as the default when faithful JSON is - * required — use the JSON code viewer for that). - */ +// ── Top-level entry point ─────────────────────────────────────────────── + export const BeautifiedJsonView = memo(function BeautifiedJsonView({ data: rawData, keyPrefix, @@ -529,61 +688,80 @@ export const BeautifiedJsonView = memo(function BeautifiedJsonView({ keyPrefix: string }) { const data = useMemo(() => simplifyValue(rawData), [rawData]) + const topChatResult = useMemo(() => shallowExtractChatMessages(data), [data]) - const isDirectChat = useMemo(() => { - if (typeof data === "string") return false - if (Array.isArray(data)) return !!extractChatMessages(data) - if (data && typeof data === "object" && "role" in (data as Record)) { - return !!extractChatMessages(data) + const agDataExpandKeys = useMemo(() => { + if (!data || typeof data !== "object" || Array.isArray(data)) return null + const rec = data as Record + const keys = new Set() + const hasNestedData = (obj: unknown): boolean => { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return false + const o = obj as Record + return o.data !== undefined && typeof o.data === "object" && !Array.isArray(o.data) } - return false + if (rec.ag && hasNestedData(rec.ag)) { + keys.add("ag") + } + if ( + rec.attributes && + typeof rec.attributes === "object" && + !Array.isArray(rec.attributes) + ) { + const attrs = rec.attributes as Record + if (attrs.ag && hasNestedData(attrs.ag)) { + keys.add("attributes") + } + } + return keys.size > 0 ? keys : null }, [data]) - const directChatMessages = useMemo( - () => (isDirectChat ? extractChatMessages(data) : null), - [isDirectChat, data], - ) - - const entries = useMemo(() => { - if (typeof data === "string") return null - if (isDirectChat) return null - if (!data || typeof data !== "object" || Array.isArray(data)) return null - return Object.entries(data as Record) - }, [data, isDirectChat]) - if (typeof data === "string") { + if (topChatResult) { + const normalized = normalizeChatMessages(topChatResult.messages) return ( -
- +
+
+ {normalized.map((msg, i) => ( + + ))} +
) } - if (isDirectChat && directChatMessages && directChatMessages.length > 0) { + if (typeof data === "string") { return ( -
- +
+
) } - if (entries) { + if (data && typeof data === "object" && !Array.isArray(data)) { + const entries = Object.entries(data as Record) return ( -
+
{entries.map(([key, value]) => ( -
- {key} - -
+ ))}
) } return ( -
- +
+
) }) - -BeautifiedJsonView.displayName = "BeautifiedJsonView" From 5ada4161b49f0f379f42d360f9364d28dd4952c3 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 15 May 2026 21:27:43 +0200 Subject: [PATCH 2/9] fix(frontend): address review comments on BeautifiedJsonView - Preserve sibling fields when extracting chat from wrapper objects (e.g. {messages: [...], usage, model} now shows usage and model alongside the chat, not just the messages) - Fix metadata noise guard to also clean all-noise objects - Use async/await for clipboard instead of .then() chains - Initialize timer ref with null for React 19 compatibility --- .../DrillInView/BeautifiedJsonView.tsx | 152 ++++++++++++++++-- 1 file changed, 138 insertions(+), 14 deletions(-) diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index c051b8321b..a4b11ed5f0 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -174,7 +174,7 @@ const simplifyValue = (value: unknown): unknown => { if (!Array.isArray(value)) { const keys = Object.keys(rec) const noiseKeys = keys.filter((k) => METADATA_NOISE_KEYS.has(k)) - if (noiseKeys.length > 0 && noiseKeys.length < keys.length) { + if (noiseKeys.length > 0) { const cleaned: Record = {} for (const k of keys) { if (!METADATA_NOISE_KEYS.has(k)) { @@ -332,22 +332,26 @@ const ScalarValue = ({value}: {value: unknown}) => { const CopyButton = ({value}: {value: unknown}) => { const [copied, setCopied] = useState(false) - const timerRef = useRef>() + const timerRef = useRef | null>(null) - useEffect(() => () => clearTimeout(timerRef.current), []) + useEffect( + () => () => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + }, + [], + ) const handleCopy = useCallback( - (e: React.MouseEvent) => { + async (e: React.MouseEvent) => { e.stopPropagation() const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) || "" - navigator.clipboard - ?.writeText(text) - ?.then(() => { - setCopied(true) - clearTimeout(timerRef.current) - timerRef.current = setTimeout(() => setCopied(false), 1200) - }) - ?.catch(() => {}) + if (!navigator.clipboard) return + try { + await navigator.clipboard.writeText(text) + setCopied(true) + if (timerRef.current !== null) clearTimeout(timerRef.current) + timerRef.current = window.setTimeout(() => setCopied(false), 1200) + } catch {} }, [value], ) @@ -547,7 +551,18 @@ const RecursiveNode = memo(function RecursiveNode({ const nodePrefix = `${keyPrefix}-${name}` const chatResult = useMemo(() => shallowExtractChatMessages(value), [value]) - if (chatResult) { + + const siblingEntries = useMemo(() => { + if (!chatResult?.viaKey || !value || typeof value !== "object" || Array.isArray(value)) + return null + const entries = Object.entries(value as Record).filter( + ([k]) => + k !== chatResult.viaKey && (chatResult.viaKey !== "choices" || k !== "choices"), + ) + return entries.length > 0 ? entries : null + }, [chatResult, value]) + + if (chatResult && !chatResult.viaKey) { const normalized = normalizeChatMessages(chatResult.messages) return ( ) + : [] + const count = entries.length + const meta = `{${count} ${count === 1 ? "key" : "keys"}}` + return ( + + + {normalized.map((msg, i) => ( + + ))} +
+ } + /> + {siblingEntries?.map(([k, v]) => ( + + ))} + + } + /> + ) + } + if (isShortLeaf(value)) { return ( 0 ? keys : null }, [data]) - if (topChatResult) { + const topSiblingEntries = useMemo(() => { + if (!topChatResult?.viaKey || !data || typeof data !== "object" || Array.isArray(data)) + return null + const entries = Object.entries(data as Record).filter( + ([k]) => + k !== topChatResult.viaKey && + (topChatResult.viaKey !== "choices" || k !== "choices"), + ) + return entries.length > 0 ? entries : null + }, [topChatResult, data]) + + if (topChatResult && !topChatResult.viaKey) { const normalized = normalizeChatMessages(topChatResult.messages) return (
@@ -728,6 +808,50 @@ export const BeautifiedJsonView = memo(function BeautifiedJsonView({ ) } + if (topChatResult && topChatResult.viaKey) { + const normalized = normalizeChatMessages(topChatResult.messages) + const chatKey = topChatResult.viaKey + return ( +
+ + {normalized.map((msg, i) => ( + + ))} +
+ } + /> + {topSiblingEntries?.map(([key, value]) => ( + + ))} +
+ ) + } + if (typeof data === "string") { return (
From c22edf12e7713e1fdaf1578c61f82aea02675519 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 15 May 2026 22:06:18 +0200 Subject: [PATCH 3/9] fix(frontend): reset view mode when available modes change on span navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useState initializer only runs on mount — navigating from a text-only parent span to a child span with structured messages kept the stale "text" mode instead of defaulting to "beautified-json". --- web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx b/web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx index 66f37edc6f..8b0a80871a 100644 --- a/web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx +++ b/web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx @@ -389,6 +389,11 @@ export const TraceSpanDrillInView = memo( getDefaultJsonViewMode(availableViewModes), ) + const viewModeKey = availableViewModes.join(",") + useEffect(() => { + setViewMode(getDefaultJsonViewMode(availableViewModes)) + }, [viewModeKey]) + const isCodeMode = viewMode === "json" || viewMode === "yaml" || viewMode === "decoded-json" const isBeautifiedJson = viewMode === "beautified-json" From f19463fefcb28fd0e89b739c30709b3a0c8e0ed2 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 18 May 2026 20:23:26 +0200 Subject: [PATCH 4/9] fix(frontend): reset editor when message content changes on span navigation EditorProvider uses initialValue which only sets content on mount. When navigating between spans, the Lexical editor kept stale content from the previous span. Adding key={text} forces a remount when the underlying text changes. --- web/oss/src/components/DrillInView/BeautifiedJsonView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index a4b11ed5f0..149f82aa32 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -491,6 +491,7 @@ const MessageNodeRow = memo(function MessageNodeRow({ const body = useMemo( () => ( Date: Mon, 18 May 2026 20:26:27 +0200 Subject: [PATCH 5/9] feat(frontend): add fade+pill truncation and tool call rendering for chat messages Truncation: - Long message bodies are clamped at 160px with a fade gradient overlay - Centered "Show more" pill expands to full height, "Show less" collapses - ResizeObserver detects when truncation is needed - CSS max-height transition for smooth expand/collapse animation Tool calls: - Assistant messages with tool_calls now render each call as a collapsible tree node showing function name and arguments - Meta badge shows "N tool calls" instead of "0 chars" for tool-only messages - Handles both OpenAI format (function.name/function.arguments) and direct format (name/arguments) Also carries forward key={text} on EditorProvider to fix stale content when navigating between spans. --- .../DrillInView/BeautifiedJsonView.tsx | 188 +++++++++++++++--- 1 file changed, 161 insertions(+), 27 deletions(-) diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index 149f82aa32..08fc37a54b 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -467,6 +467,140 @@ const NodeRow = memo(function NodeRow({ ) }) +// ── TruncatedMessageBody ─────────────────────────────────────────────── +// +// Wraps the editor for a chat message. When the content exceeds +// TRUNCATE_HEIGHT_PX the body is clamped with a bottom fade overlay and +// a centered "Show more" pill. Clicking the pill expands to full height +// and shows a "Show less" pill at the bottom. + +const TRUNCATE_HEIGHT_PX = 160 +const EXPAND_SENTINEL_PX = 9999 + +const PILL_BUTTON_CLASSES = + "text-[11px] font-medium text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-quaternary)] hover:bg-[var(--ant-color-fill-tertiary)] border border-solid border-[var(--ant-color-border-secondary)] rounded-full px-3 py-0.5 cursor-pointer focus-visible:ring-1 focus-visible:ring-[var(--ant-color-primary)] focus-visible:outline-none motion-safe:transition-colors" + +const TruncatedMessageBody = memo(function TruncatedMessageBody({ + editorId, + text, +}: { + editorId: string + text: string +}) { + const measureRef = useRef(null) + const [needsTruncation, setNeedsTruncation] = useState(false) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + setExpanded(false) + const el = measureRef.current + if (!el) return + const check = () => setNeedsTruncation(el.scrollHeight > TRUNCATE_HEIGHT_PX + 8) + check() + const observer = new ResizeObserver(check) + observer.observe(el) + return () => observer.disconnect() + }, [text]) + + const toggle = useCallback(() => setExpanded((v) => !v), []) + + const isTruncated = needsTruncation && !expanded + + return ( +
+
+
+ + + + +
+
+ + {isTruncated ? ( +
+ + ) : null} + + {needsTruncation && expanded ? ( +
+ +
+ ) : null} +
+ ) +}) + +// ── Tool call helpers ─────────────────────────────────────────────────── + +const getToolCallName = (tc: unknown): string => { + const obj = tc as Record | null + const fn = obj?.function as Record | undefined + return String(fn?.name || obj?.name || "tool") +} + +const getToolCallArgs = (tc: unknown): unknown => { + const obj = tc as Record | null + const fn = obj?.function as Record | undefined + const raw = fn?.arguments ?? obj?.arguments ?? obj?.input ?? obj?.args + if (typeof raw === "string") { + try { + return JSON.parse(raw) + } catch { + return raw + } + } + return raw +} + +const getMessageMeta = (text: string, toolCalls?: unknown[]): string => { + const parts: string[] = [] + if (text) parts.push(`${text.length} chars`) + if (toolCalls?.length) { + parts.push(`${toolCalls.length} tool ${toolCalls.length === 1 ? "call" : "calls"}`) + } + return parts.join(", ") || "empty" +} + // ── MessageNodeRow ────────────────────────────────────────────────────── const MessageNodeRow = memo(function MessageNodeRow({ @@ -474,7 +608,7 @@ const MessageNodeRow = memo(function MessageNodeRow({ index, keyPrefix, }: { - msg: {role: string; content: unknown} + msg: {role: string; content: unknown; tool_calls?: unknown[]} index: number keyPrefix: string }) { @@ -482,45 +616,45 @@ const MessageNodeRow = memo(function MessageNodeRow({ const roleColor = ROLE_COLOR_CLASSES[role] ?? DEFAULT_ROLE_COLOR_CLASS const text = getMessageText(msg.content) const editorId = `${keyPrefix}-msg-${index}` + const toolCalls = msg.tool_calls const label = useMemo( () => {msg.role || "—"}, [msg.role, roleColor], ) - const body = useMemo( - () => ( - - - - - ), - [editorId, text], - ) + const body = useMemo(() => { + const hasText = text.length > 0 + const hasToolCalls = toolCalls && toolCalls.length > 0 + + if (!hasText && !hasToolCalls) return null + + return ( +
+ {hasText && } + {hasToolCalls && + toolCalls.map((tc, i) => ( + + ))} +
+ ) + }, [text, toolCalls, editorId]) return ( ) From 75437ecbd49315908197ea473b266a676d063006 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 18 May 2026 20:31:50 +0200 Subject: [PATCH 6/9] feat(frontend): render tool-role message content as collapsible JSON tree Tool messages (role=tool) often contain JSON responses from function calls. Instead of showing raw JSON text in the editor, parse the content and render it as a RecursiveNode tree with the same collapse/expand and copy behavior as tool call arguments. --- .../DrillInView/BeautifiedJsonView.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index 08fc37a54b..185c910949 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -623,10 +623,34 @@ const MessageNodeRow = memo(function MessageNodeRow({ [msg.role, roleColor], ) + const parsedContent = useMemo(() => { + if (role !== "tool" || !text) return null + try { + const parsed = JSON.parse(text) + return typeof parsed === "object" && parsed !== null ? parsed : null + } catch { + return null + } + }, [role, text]) + const body = useMemo(() => { - const hasText = text.length > 0 const hasToolCalls = toolCalls && toolCalls.length > 0 + if (parsedContent) { + return ( +
+ +
+ ) + } + + const hasText = text.length > 0 if (!hasText && !hasToolCalls) return null return ( @@ -645,7 +669,7 @@ const MessageNodeRow = memo(function MessageNodeRow({ ))}
) - }, [text, toolCalls, editorId]) + }, [text, toolCalls, editorId, parsedContent]) return ( Date: Mon, 18 May 2026 20:51:37 +0200 Subject: [PATCH 7/9] fix(frontend): restore simplifyValue inline comments for readability --- web/oss/src/components/DrillInView/BeautifiedJsonView.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index 149f82aa32..f8f2b35dd7 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -131,10 +131,12 @@ const simplifyValue = (value: unknown): unknown => { const rec = value as Record + // AI SDK text part: {type: "text", text: "hello"} -> "hello" if (rec.type === "text" && typeof rec.text === "string") { return rec.text } + // AI SDK tool-call: {type: "tool-call", toolName: "fn", input: {...}} -> "fn({...})" if (rec.type === "tool-call" && typeof rec.toolName === "string") { const args = rec.input ?? rec.args if (!args || (typeof args === "object" && Object.keys(args as object).length === 0)) { @@ -147,6 +149,7 @@ const simplifyValue = (value: unknown): unknown => { } } + // AI SDK tool-result envelope: {type: "tool-result", output: {value: X}} -> X if (rec.type === "tool-result" && rec.output !== undefined) { const output = rec.output as Record | undefined if (output && typeof output === "object" && output.value !== undefined) { @@ -155,11 +158,13 @@ const simplifyValue = (value: unknown): unknown => { return rec.output } + // Single-element array of a simplifiable item if (Array.isArray(value) && value.length === 1) { const simplified = simplifyValue(value[0]) if (simplified !== value[0]) return simplified } + // Multi-element array: simplify each element, join strings if (Array.isArray(value) && value.length > 1) { const simplified = value.map(simplifyValue) const changed = simplified.some((s, i) => s !== value[i]) @@ -171,6 +176,7 @@ const simplifyValue = (value: unknown): unknown => { } } + // Strip metadata noise keys from objects if (!Array.isArray(value)) { const keys = Object.keys(rec) const noiseKeys = keys.filter((k) => METADATA_NOISE_KEYS.has(k)) From f993c065bdada2b6677efd5170df1544a5239edd Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 18 May 2026 21:05:50 +0200 Subject: [PATCH 8/9] fix(frontend): render trace message parts structurally --- .../DrillInView/BeautifiedJsonView.tsx | 325 ++++++++++++++---- 1 file changed, 253 insertions(+), 72 deletions(-) diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index 2233abac68..b83e13f615 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -126,36 +126,215 @@ const EDITOR_RESET_CLASSES = [ const DEFAULT_MAX_RENDER_DEPTH = 5 const DEFAULT_EXPAND_DEPTH = 2 +const tryParseJsonString = (value: string): unknown | null => { + const trimmed = value.trim() + if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return null + try { + const parsed = JSON.parse(trimmed) + return typeof parsed === "object" && parsed !== null ? parsed : null + } catch { + return null + } +} + +const getTextPartContent = (rec: Record): string | null => { + if (rec.type !== "text") return null + if (typeof rec.text === "string") return rec.text + if (typeof rec.content === "string") return rec.content + return null +} + +const getNormalizedToolArgs = (raw: unknown): unknown => { + if (typeof raw === "string") { + return tryParseJsonString(raw) ?? raw + } + return raw +} + +const getNormalizedToolResult = (raw: unknown): unknown => { + if (typeof raw === "string") { + return tryParseJsonString(raw) ?? raw + } + return raw +} + +const formatToolCall = (name: string, rawArgs: unknown): string => { + const args = getNormalizedToolArgs(rawArgs) + if (!args || (typeof args === "object" && Object.keys(args as object).length === 0)) { + return `${name}()` + } + try { + return `${name}(${JSON.stringify(args, null, 2)})` + } catch { + return `${name}(...)` + } +} + +const getToolCallPartText = (rec: Record): string | null => { + if (rec.type !== "tool-call" && rec.type !== "tool_call") return null + const name = rec.toolName ?? rec.name + if (typeof name !== "string") return null + return formatToolCall(name, rec.input ?? rec.args ?? rec.arguments) +} + +const getToolResultPartText = (rec: Record): string | null => { + if (rec.type !== "tool-result" && rec.type !== "tool_result") return null + const output = rec.output as Record | undefined + const value = getNormalizedToolResult(output?.value ?? output ?? rec.result ?? rec.content) + return typeof value === "string" ? value : JSON.stringify(value, null, 2) +} + +interface StructuredMessagePart { + kind: "tool-call" | "tool-result" + name: string + value: unknown +} + +const isToolCallPart = (rec: Record): boolean => + rec.type === "tool-call" || rec.type === "tool_call" + +const isToolResultPart = (rec: Record): boolean => + rec.type === "tool-result" || + rec.type === "tool_result" || + rec.type === "tool-call-response" || + rec.type === "tool_call_response" + +const getStructuredMessagePart = (part: unknown): StructuredMessagePart | null => { + if (!part || typeof part !== "object" || Array.isArray(part)) return null + + const rec = part as Record + if (isToolCallPart(rec)) { + const name = rec.toolName ?? rec.name + const rawArgs = rec.input ?? rec.args ?? rec.arguments + return { + kind: "tool-call", + name: typeof name === "string" && name ? name : "tool call", + value: getNormalizedToolArgs(rawArgs), + } + } + + if (isToolResultPart(rec)) { + const output = rec.output as Record | undefined + const name = rec.toolName ?? rec.name + const result = getNormalizedToolResult(output?.value ?? output ?? rec.result ?? rec.content) + return { + kind: "tool-result", + name: `${typeof name === "string" && name ? name : "tool"} result`, + value: result, + } + } + + return null +} + +const getMessagePartText = (part: unknown): string | null => { + if (!part || typeof part !== "object" || Array.isArray(part)) { + return part === null || part === undefined ? "" : String(part) + } + + const rec = part as Record + return ( + getTextPartContent(rec) ?? + getToolCallPartText(rec) ?? + getToolResultPartText(rec) ?? + (typeof rec.text === "string" ? rec.text : null) ?? + (typeof rec.content === "string" ? rec.content : null) + ) +} + +const getStructuredMessageText = (content: unknown): string | null => { + if (!content || typeof content !== "object") return null + + if (Array.isArray(content)) { + const parts = content + .map(getMessagePartText) + .filter((part): part is string => part !== null) + return parts.length > 0 ? parts.join("\n") : null + } + + return getMessagePartText(content) +} + +const getMessageContentParts = (content: unknown): unknown[] | null => { + if (Array.isArray(content)) return content + + if (typeof content === "string") { + const parsed = tryParseJsonString(content) + if (Array.isArray(parsed)) return parsed + if ( + parsed && + typeof parsed === "object" && + Array.isArray((parsed as Record).parts) + ) { + return (parsed as Record).parts as unknown[] + } + } + + if (content && typeof content === "object" && !Array.isArray(content)) { + const rec = content as Record + if (Array.isArray(rec.parts)) return rec.parts + } + + return null +} + +const getMessageContentDisplay = ( + content: unknown, +): {text: string; structuredParts: StructuredMessagePart[]} => { + const parts = getMessageContentParts(content) + if (!parts) { + return {text: getMessageText(content), structuredParts: []} + } + + const textParts: string[] = [] + const structuredParts: StructuredMessagePart[] = [] + + for (const part of parts) { + const structuredPart = getStructuredMessagePart(part) + if (structuredPart) { + structuredParts.push(structuredPart) + continue + } + + const textPart = getMessagePartText(part) + if (textPart) textParts.push(textPart) + } + + return {text: textParts.join("\n"), structuredParts} +} + const simplifyValue = (value: unknown): unknown => { if (!value || typeof value !== "object") return value const rec = value as Record - // AI SDK text part: {type: "text", text: "hello"} -> "hello" - if (rec.type === "text" && typeof rec.text === "string") { - return rec.text + // Text part: AI SDK uses {text}; PydanticAI/OpenAI parts may use {content}. + const textPartContent = getTextPartContent(rec) + if (textPartContent !== null) { + return textPartContent } - // AI SDK tool-call: {type: "tool-call", toolName: "fn", input: {...}} -> "fn({...})" - if (rec.type === "tool-call" && typeof rec.toolName === "string") { - const args = rec.input ?? rec.args - if (!args || (typeof args === "object" && Object.keys(args as object).length === 0)) { - return `${rec.toolName}()` - } - try { - return `${rec.toolName}(${JSON.stringify(args, null, 2)})` - } catch { - return `${rec.toolName}(...)` + // Tool-call part: AI SDK uses tool-call/toolName/input; PydanticAI uses tool_call/name/arguments. + if (isToolCallPart(rec)) { + return { + ...(rec.id ? {id: rec.id} : {}), + name: rec.toolName ?? rec.name ?? "tool", + arguments: getNormalizedToolArgs(rec.input ?? rec.args ?? rec.arguments), } } - // AI SDK tool-result envelope: {type: "tool-result", output: {value: X}} -> X - if (rec.type === "tool-result" && rec.output !== undefined) { + // Tool result/response envelope: unwrap to its structured result payload. + if (isToolResultPart(rec)) { const output = rec.output as Record | undefined - if (output && typeof output === "object" && output.value !== undefined) { - return output.value + const result = getNormalizedToolResult(output?.value ?? output ?? rec.result ?? rec.content) + if (rec.id || rec.name || rec.toolName) { + return { + ...(rec.id ? {id: rec.id} : {}), + name: rec.toolName ?? rec.name ?? "tool", + result, + } } - return rec.output + return result } // Single-element array of a simplifiable item @@ -220,43 +399,21 @@ const MarkdownModeSync = ({isMarkdownView}: {isMarkdownView: boolean}) => { const getMessageText = (content: unknown): string => { if (content === null || content === undefined) return "" - if (typeof content === "string") return content + if (typeof content === "string") { + const parsed = tryParseJsonString(content) + const parsedText = parsed ? getStructuredMessageText(parsed) : null + return parsedText ?? content + } if (content && typeof content === "object" && !Array.isArray(content)) { const rec = content as Record - if (rec.type === "text" && typeof rec.text === "string") return rec.text - if (rec.type === "tool-call" && typeof rec.toolName === "string") { - const args = rec.args ?? rec.input - return args ? `${rec.toolName}(${JSON.stringify(args, null, 2)})` : String(rec.toolName) - } - if (rec.type === "tool-result") { - const output = rec.output as Record | undefined - const value = output?.value ?? output ?? rec.result - return typeof value === "string" ? value : JSON.stringify(value, null, 2) - } + const partText = getMessagePartText(rec) + if (partText !== null) return partText } if (Array.isArray(content)) { - const parts: string[] = [] - for (const c of content) { - const rec = c as Record | null - if (!rec || typeof rec !== "object") { - parts.push(String(c)) - continue - } - if (rec.type === "text" && typeof rec.text === "string") { - parts.push(rec.text) - } else if (rec.type === "tool-call" && typeof rec.toolName === "string") { - parts.push(`[tool: ${rec.toolName}]`) - } else if (rec.type === "tool-result") { - const output = rec.output as Record | undefined - const value = output?.value ?? output ?? rec.result - parts.push(typeof value === "string" ? value : JSON.stringify(value, null, 2)) - } else if (typeof rec.text === "string") { - parts.push(rec.text) - } - } - if (parts.length > 0) return parts.join("\n") + const partsText = getStructuredMessageText(content) + if (partsText !== null) return partsText } try { @@ -338,11 +495,11 @@ const ScalarValue = ({value}: {value: unknown}) => { const CopyButton = ({value}: {value: unknown}) => { const [copied, setCopied] = useState(false) - const timerRef = useRef | null>(null) + const timerRef = useRef(null) useEffect( () => () => { - if (timerRef.current !== null) clearTimeout(timerRef.current) + if (timerRef.current !== null) window.clearTimeout(timerRef.current) }, [], ) @@ -355,19 +512,24 @@ const CopyButton = ({value}: {value: unknown}) => { try { await navigator.clipboard.writeText(text) setCopied(true) - if (timerRef.current !== null) clearTimeout(timerRef.current) + if (timerRef.current !== null) window.clearTimeout(timerRef.current) timerRef.current = window.setTimeout(() => setCopied(false), 1200) } catch {} }, [value], ) + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + e.stopPropagation() + }, []) + return (