From 13eb77b8348a712f81e0af7dd21743e8f478c8bd Mon Sep 17 00:00:00 2001 From: Ogi <86684834+obostjancic@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:58:14 +0200 Subject: [PATCH 1/3] feat(ai): Improve AI content rendering in trace drawer Add AIContentRenderer that auto-detects content type (JSON, Python dicts, markdown, XML tags) and renders appropriately in the AI spans tab. Inline XML tags render as italic markdown within text flow. Block-level XML tags render with a grey bordered sidebar. Nested XML tags are handled recursively. Includes raw/pretty toggle for markdown-with-xml content. Co-Authored-By: Claude Opus 4.6 --- .../eapSections/aiContentDetection.spec.ts | 80 ++++++++++++++++--- .../span/eapSections/aiContentDetection.ts | 37 +++++---- .../eapSections/aiContentRenderer.spec.tsx | 22 ++++- .../span/eapSections/aiContentRenderer.tsx | 18 ++--- 4 files changed, 112 insertions(+), 45 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts index 720e165b7609d2..478f1343de7e2b 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts @@ -1,6 +1,7 @@ import { detectAIContentType, parseXmlTagSegments, + preprocessInlineXmlTags, tryParsePythonDict, } from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection'; @@ -127,39 +128,77 @@ describe('tryParsePythonDict', () => { }); }); +describe('preprocessInlineXmlTags', () => { + it('replaces inline XML tags with italic markdown', () => { + const text = 'Before inner thought After'; + expect(preprocessInlineXmlTags(text)).toBe( + 'Before *thinking: inner thought* After' + ); + }); + + it('leaves block-level tags at start of text untouched', () => { + const text = 'step 1 then more'; + expect(preprocessInlineXmlTags(text)).toBe('step 1 then more'); + }); + + it('leaves block-level tags after newline untouched', () => { + const text = 'Some text\ndeep thought'; + expect(preprocessInlineXmlTags(text)).toBe(text); + }); + + it('handles mixed inline and block tags', () => { + const text = + 'See the details here.\n\ndeep thought\n'; + expect(preprocessInlineXmlTags(text)).toBe( + 'See the *code: details* here.\n\ndeep thought\n' + ); + }); + + it('trims whitespace from inline tag content', () => { + const text = 'before spaced after'; + expect(preprocessInlineXmlTags(text)).toBe('before *tag: spaced* after'); + }); +}); + describe('parseXmlTagSegments', () => { - it('splits text with XML tags into segments', () => { + it('marks inline XML tags with isBlock false', () => { const text = 'Before inner thought After'; const segments = parseXmlTagSegments(text); expect(segments).toEqual([ {type: 'text', content: 'Before '}, - {type: 'xml-tag', tagName: 'thinking', content: 'inner thought'}, + {type: 'xml-tag', tagName: 'thinking', content: 'inner thought', isBlock: false}, {type: 'text', content: ' After'}, ]); }); - it('handles multiple XML tags', () => { + it('marks tag at start of text as block', () => { const text = 'step 1 then done'; const segments = parseXmlTagSegments(text); expect(segments).toEqual([ - {type: 'xml-tag', tagName: 'plan', content: 'step 1'}, + {type: 'xml-tag', tagName: 'plan', content: 'step 1', isBlock: true}, {type: 'text', content: ' then '}, - {type: 'xml-tag', tagName: 'result', content: 'done'}, + {type: 'xml-tag', tagName: 'result', content: 'done', isBlock: false}, ]); }); - it('handles multiline content inside tags', () => { - const text = '\nline1\nline2\n'; + it('marks tag preceded by newline as block', () => { + const text = 'Some text\n\nline1\nline2\n'; const segments = parseXmlTagSegments(text); expect(segments).toEqual([ - {type: 'xml-tag', tagName: 'thinking', content: '\nline1\nline2\n'}, + {type: 'text', content: 'Some text\n'}, + { + type: 'xml-tag', + tagName: 'thinking', + content: '\nline1\nline2\n', + isBlock: true, + }, ]); }); it('returns single text segment when no XML tags', () => { - const text = 'just plain text'; - const segments = parseXmlTagSegments(text); - expect(segments).toEqual([{type: 'text', content: 'just plain text'}]); + expect(parseXmlTagSegments('just plain text')).toEqual([ + {type: 'text', content: 'just plain text'}, + ]); }); it('handles empty string', () => { @@ -168,7 +207,22 @@ describe('parseXmlTagSegments', () => { it('handles tags with hyphens in names', () => { const text = 'content'; - const segments = parseXmlTagSegments(text); - expect(segments).toEqual([{type: 'xml-tag', tagName: 'my-tag', content: 'content'}]); + expect(parseXmlTagSegments(text)).toEqual([ + {type: 'xml-tag', tagName: 'my-tag', content: 'content', isBlock: true}, + ]); + }); + + it('extracts outer tag with nested tags preserved in content', () => { + const text = + '\nfile.ts\na bug\n'; + expect(parseXmlTagSegments(text)).toEqual([ + { + type: 'xml-tag', + tagName: 'bug_report', + content: + '\nfile.ts\na bug\n', + isBlock: true, + }, + ]); }); }); diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts index 9e1161b576672d..17fc3e127c0fa6 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts @@ -10,7 +10,7 @@ type AIContentType = type ContentSegment = | {content: string; type: 'text'} - | {content: string; tagName: string; type: 'xml-tag'}; + | {content: string; isBlock: boolean; tagName: string; type: 'xml-tag'}; interface AIContentDetectionResult { type: AIContentType; @@ -18,10 +18,7 @@ interface AIContentDetectionResult { wasFixed?: boolean; } -/** - * Best-effort conversion of a Python dict literal to a JSON-parseable string. - * Returns the parsed object on success, or null if conversion fails. - */ +/** Best-effort conversion of a Python dict literal to a JSON-parseable string. */ export function tryParsePythonDict(text: string): Record | null { const trimmed = text.trim(); if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { @@ -50,10 +47,7 @@ export function tryParsePythonDict(text: string): Record | } } -/** - * Splits text into segments of plain text and XML-like tag blocks. - * Matches `...` patterns (including multiline content). - */ +/** Splits text into segments of plain text and XML-like tag blocks. */ export function parseXmlTagSegments(text: string): ContentSegment[] { const segments: ContentSegment[] = []; const xmlTagRegex = /<([a-zA-Z][\w-]*)>([\s\S]*?)<\/\1>/g; @@ -63,10 +57,12 @@ export function parseXmlTagSegments(text: string): ContentSegment[] { if (match.index > lastIndex) { segments.push({type: 'text', content: text.slice(lastIndex, match.index)}); } + const isBlock = match.index === 0 || /\n\s*$/.test(text.slice(0, match.index)); segments.push({ type: 'xml-tag', tagName: match[1]!, content: match[2]!, + isBlock, }); lastIndex = match.index + match[0].length; } @@ -78,6 +74,18 @@ export function parseXmlTagSegments(text: string): ContentSegment[] { return segments; } +/** Replaces inline XML tags with italic markdown, leaves block-level tags untouched. */ +export function preprocessInlineXmlTags(text: string): string { + const xmlTagRegex = /<([a-zA-Z][\w-]*)>([\s\S]*?)<\/\1>/g; + return text.replace(xmlTagRegex, (match, tagName, content, offset) => { + const isBlock = offset === 0 || /\n\s*$/.test(text.slice(0, offset)); + if (isBlock) { + return match; + } + return `*${tagName}: ${content.trim()}*`; + }); +} + const XML_TAG_REGEX = /<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>/; const MARKDOWN_INDICATORS = [ @@ -91,16 +99,7 @@ const MARKDOWN_INDICATORS = [ /^```/m, // code fences ]; -/** - * Detects the content type of an AI response string. - * Short-circuits on first match in priority order: - * 1. Valid JSON (object/array) - * 2. Python dict - * 3. Partial/truncated JSON (fixable) - * 4. Markdown with XML tags - * 5. Markdown - * 6. Plain text - */ +/** Detects AI content type: JSON, Python dict, markdown-with-xml, markdown, or plain text. */ export function detectAIContentType(text: string): AIContentDetectionResult { const trimmed = text.trim(); if (!trimmed) { diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx index 561f2389c8037d..82118cef34efb8 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx @@ -28,16 +28,32 @@ describe('AIContentRenderer', () => { expect(screen.getByText('name')).toBeInTheDocument(); }); - it('renders XML tags with styled wrappers', () => { + it('renders inline XML tags as italic text within the flow', () => { render(); + expect(screen.getByText(/thinking: inner thought/)).toBeInTheDocument(); + }); + + it('renders block XML tags with styled wrappers', () => { + render(inner thought'} />); expect(screen.getByText('thinking')).toBeInTheDocument(); }); - it('renders XML tags with styled wrappers when inline', () => { + it('renders inline XML tags as italic text when inline', () => { render( ); - expect(screen.getByText('thinking')).toBeInTheDocument(); + expect(screen.getByText(/thinking: inner thought/)).toBeInTheDocument(); + }); + + it('renders nested XML tags recursively', () => { + const text = + '\nfile.ts\na bug\n'; + render(); + expect(screen.getByText('bug_report')).toBeInTheDocument(); + expect(screen.getByText('location')).toBeInTheDocument(); + expect(screen.getByText('description')).toBeInTheDocument(); + expect(screen.getByText('file.ts')).toBeInTheDocument(); + expect(screen.getByText('a bug')).toBeInTheDocument(); }); it('wraps plain text in MultilineText by default', () => { diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx index 3b5e501a382ed3..b591d91e92760f 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx @@ -9,12 +9,12 @@ import {MarkedText} from 'sentry/utils/marked/markedText'; import { detectAIContentType, parseXmlTagSegments, + preprocessInlineXmlTags, } from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection'; import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles'; interface AIContentRendererProps { text: string; - /** When true, renders content directly without a wrapper or raw/pretty toggle. */ inline?: boolean; maxJsonDepth?: number; } @@ -27,22 +27,23 @@ function XmlTagBlock({tagName, content}: {content: string; tagName: string}) { direction="column" padding="0 0 0 md" margin="sm 0" - style={{borderLeft: `3px solid ${theme.tokens.border.accent.moderate}`}} + style={{borderLeft: `2px solid ${theme.tokens.border.primary}`}} > {tagName} - - - + ); } function MarkdownWithXmlRenderer({text}: {text: string}) { - const segments = useMemo(() => parseXmlTagSegments(text), [text]); + const segments = useMemo( + () => parseXmlTagSegments(preprocessInlineXmlTags(text)), + [text] + ); return ( @@ -61,10 +62,7 @@ function MarkdownWithXmlRenderer({text}: {text: string}) { ); } -/** - * Unified AI content renderer that auto-detects content type and renders appropriately. - * Handles JSON, Python dicts, partial JSON, markdown with XML tags, markdown, and plain text. - */ +/** Auto-detects AI content type and renders appropriately. */ export function AIContentRenderer({ text, inline = false, From c32c82b607498813feb58357353544d233b931fa Mon Sep 17 00:00:00 2001 From: Ogi <86684834+obostjancic@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:25:40 +0200 Subject: [PATCH 2/3] fix(ai): Address review bot feedback for XML tag renderer Strip nested XML tags from inline tag content to prevent broken markdown with unmatched asterisks. Remove unused isBlock property from parseXmlTagSegments since preprocessInlineXmlTags handles the distinction. Co-Authored-By: Claude Opus 4.6 --- .../eapSections/aiContentDetection.spec.ts | 32 +++++++++---------- .../span/eapSections/aiContentDetection.ts | 7 ++-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts index 478f1343de7e2b..c34604b3f4f584 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts @@ -158,40 +158,41 @@ describe('preprocessInlineXmlTags', () => { const text = 'before spaced after'; expect(preprocessInlineXmlTags(text)).toBe('before *tag: spaced* after'); }); + + it('strips nested XML tags from inline tag content', () => { + const text = 'Text before nested after more'; + expect(preprocessInlineXmlTags(text)).toBe( + 'Text *outer: before nested after* more' + ); + }); }); describe('parseXmlTagSegments', () => { - it('marks inline XML tags with isBlock false', () => { + it('splits text with XML tags into segments', () => { const text = 'Before inner thought After'; const segments = parseXmlTagSegments(text); expect(segments).toEqual([ {type: 'text', content: 'Before '}, - {type: 'xml-tag', tagName: 'thinking', content: 'inner thought', isBlock: false}, + {type: 'xml-tag', tagName: 'thinking', content: 'inner thought'}, {type: 'text', content: ' After'}, ]); }); - it('marks tag at start of text as block', () => { + it('handles multiple XML tags', () => { const text = 'step 1 then done'; const segments = parseXmlTagSegments(text); expect(segments).toEqual([ - {type: 'xml-tag', tagName: 'plan', content: 'step 1', isBlock: true}, + {type: 'xml-tag', tagName: 'plan', content: 'step 1'}, {type: 'text', content: ' then '}, - {type: 'xml-tag', tagName: 'result', content: 'done', isBlock: false}, + {type: 'xml-tag', tagName: 'result', content: 'done'}, ]); }); - it('marks tag preceded by newline as block', () => { - const text = 'Some text\n\nline1\nline2\n'; + it('handles multiline content inside tags', () => { + const text = '\nline1\nline2\n'; const segments = parseXmlTagSegments(text); expect(segments).toEqual([ - {type: 'text', content: 'Some text\n'}, - { - type: 'xml-tag', - tagName: 'thinking', - content: '\nline1\nline2\n', - isBlock: true, - }, + {type: 'xml-tag', tagName: 'thinking', content: '\nline1\nline2\n'}, ]); }); @@ -208,7 +209,7 @@ describe('parseXmlTagSegments', () => { it('handles tags with hyphens in names', () => { const text = 'content'; expect(parseXmlTagSegments(text)).toEqual([ - {type: 'xml-tag', tagName: 'my-tag', content: 'content', isBlock: true}, + {type: 'xml-tag', tagName: 'my-tag', content: 'content'}, ]); }); @@ -221,7 +222,6 @@ describe('parseXmlTagSegments', () => { tagName: 'bug_report', content: '\nfile.ts\na bug\n', - isBlock: true, }, ]); }); diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts index 17fc3e127c0fa6..bb39264a545e9f 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts @@ -10,7 +10,7 @@ type AIContentType = type ContentSegment = | {content: string; type: 'text'} - | {content: string; isBlock: boolean; tagName: string; type: 'xml-tag'}; + | {content: string; tagName: string; type: 'xml-tag'}; interface AIContentDetectionResult { type: AIContentType; @@ -57,12 +57,10 @@ export function parseXmlTagSegments(text: string): ContentSegment[] { if (match.index > lastIndex) { segments.push({type: 'text', content: text.slice(lastIndex, match.index)}); } - const isBlock = match.index === 0 || /\n\s*$/.test(text.slice(0, match.index)); segments.push({ type: 'xml-tag', tagName: match[1]!, content: match[2]!, - isBlock, }); lastIndex = match.index + match[0].length; } @@ -82,7 +80,8 @@ export function preprocessInlineXmlTags(text: string): string { if (isBlock) { return match; } - return `*${tagName}: ${content.trim()}*`; + const stripped = content.replace(/<\/?[a-zA-Z][\w-]*>/g, '').trim(); + return `*${tagName}: ${stripped}*`; }); } From 616159c2f69a31f2025d8e53c57ba7cbde4693bc Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:27:37 +0000 Subject: [PATCH 3/3] :hammer_and_wrench: apply pre-commit fixes --- .../span/eapSections/aiContentDetection.spec.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts index c34604b3f4f584..2866d3702eeea3 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts @@ -131,9 +131,7 @@ describe('tryParsePythonDict', () => { describe('preprocessInlineXmlTags', () => { it('replaces inline XML tags with italic markdown', () => { const text = 'Before inner thought After'; - expect(preprocessInlineXmlTags(text)).toBe( - 'Before *thinking: inner thought* After' - ); + expect(preprocessInlineXmlTags(text)).toBe('Before *thinking: inner thought* After'); }); it('leaves block-level tags at start of text untouched', () => { @@ -161,9 +159,7 @@ describe('preprocessInlineXmlTags', () => { it('strips nested XML tags from inline tag content', () => { const text = 'Text before nested after more'; - expect(preprocessInlineXmlTags(text)).toBe( - 'Text *outer: before nested after* more' - ); + expect(preprocessInlineXmlTags(text)).toBe('Text *outer: before nested after* more'); }); }); @@ -220,8 +216,7 @@ describe('parseXmlTagSegments', () => { { type: 'xml-tag', tagName: 'bug_report', - content: - '\nfile.ts\na bug\n', + content: '\nfile.ts\na bug\n', }, ]); });