Skip to content

Commit 13eb77b

Browse files
obostjancicclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 52883b6 commit 13eb77b

File tree

4 files changed

+112
-45
lines changed

4 files changed

+112
-45
lines changed

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
detectAIContentType,
33
parseXmlTagSegments,
4+
preprocessInlineXmlTags,
45
tryParsePythonDict,
56
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection';
67

@@ -127,39 +128,77 @@ describe('tryParsePythonDict', () => {
127128
});
128129
});
129130

131+
describe('preprocessInlineXmlTags', () => {
132+
it('replaces inline XML tags with italic markdown', () => {
133+
const text = 'Before <thinking>inner thought</thinking> After';
134+
expect(preprocessInlineXmlTags(text)).toBe(
135+
'Before *thinking: inner thought* After'
136+
);
137+
});
138+
139+
it('leaves block-level tags at start of text untouched', () => {
140+
const text = '<plan>step 1</plan> then more';
141+
expect(preprocessInlineXmlTags(text)).toBe('<plan>step 1</plan> then more');
142+
});
143+
144+
it('leaves block-level tags after newline untouched', () => {
145+
const text = 'Some text\n<thinking>deep thought</thinking>';
146+
expect(preprocessInlineXmlTags(text)).toBe(text);
147+
});
148+
149+
it('handles mixed inline and block tags', () => {
150+
const text =
151+
'See the <code>details</code> here.\n<thinking>\ndeep thought\n</thinking>';
152+
expect(preprocessInlineXmlTags(text)).toBe(
153+
'See the *code: details* here.\n<thinking>\ndeep thought\n</thinking>'
154+
);
155+
});
156+
157+
it('trims whitespace from inline tag content', () => {
158+
const text = 'before <tag> spaced </tag> after';
159+
expect(preprocessInlineXmlTags(text)).toBe('before *tag: spaced* after');
160+
});
161+
});
162+
130163
describe('parseXmlTagSegments', () => {
131-
it('splits text with XML tags into segments', () => {
164+
it('marks inline XML tags with isBlock false', () => {
132165
const text = 'Before <thinking>inner thought</thinking> After';
133166
const segments = parseXmlTagSegments(text);
134167
expect(segments).toEqual([
135168
{type: 'text', content: 'Before '},
136-
{type: 'xml-tag', tagName: 'thinking', content: 'inner thought'},
169+
{type: 'xml-tag', tagName: 'thinking', content: 'inner thought', isBlock: false},
137170
{type: 'text', content: ' After'},
138171
]);
139172
});
140173

141-
it('handles multiple XML tags', () => {
174+
it('marks tag at start of text as block', () => {
142175
const text = '<plan>step 1</plan> then <result>done</result>';
143176
const segments = parseXmlTagSegments(text);
144177
expect(segments).toEqual([
145-
{type: 'xml-tag', tagName: 'plan', content: 'step 1'},
178+
{type: 'xml-tag', tagName: 'plan', content: 'step 1', isBlock: true},
146179
{type: 'text', content: ' then '},
147-
{type: 'xml-tag', tagName: 'result', content: 'done'},
180+
{type: 'xml-tag', tagName: 'result', content: 'done', isBlock: false},
148181
]);
149182
});
150183

151-
it('handles multiline content inside tags', () => {
152-
const text = '<thinking>\nline1\nline2\n</thinking>';
184+
it('marks tag preceded by newline as block', () => {
185+
const text = 'Some text\n<thinking>\nline1\nline2\n</thinking>';
153186
const segments = parseXmlTagSegments(text);
154187
expect(segments).toEqual([
155-
{type: 'xml-tag', tagName: 'thinking', content: '\nline1\nline2\n'},
188+
{type: 'text', content: 'Some text\n'},
189+
{
190+
type: 'xml-tag',
191+
tagName: 'thinking',
192+
content: '\nline1\nline2\n',
193+
isBlock: true,
194+
},
156195
]);
157196
});
158197

159198
it('returns single text segment when no XML tags', () => {
160-
const text = 'just plain text';
161-
const segments = parseXmlTagSegments(text);
162-
expect(segments).toEqual([{type: 'text', content: 'just plain text'}]);
199+
expect(parseXmlTagSegments('just plain text')).toEqual([
200+
{type: 'text', content: 'just plain text'},
201+
]);
163202
});
164203

165204
it('handles empty string', () => {
@@ -168,7 +207,22 @@ describe('parseXmlTagSegments', () => {
168207

169208
it('handles tags with hyphens in names', () => {
170209
const text = '<my-tag>content</my-tag>';
171-
const segments = parseXmlTagSegments(text);
172-
expect(segments).toEqual([{type: 'xml-tag', tagName: 'my-tag', content: 'content'}]);
210+
expect(parseXmlTagSegments(text)).toEqual([
211+
{type: 'xml-tag', tagName: 'my-tag', content: 'content', isBlock: true},
212+
]);
213+
});
214+
215+
it('extracts outer tag with nested tags preserved in content', () => {
216+
const text =
217+
'<bug_report>\n<location>file.ts</location>\n<description>a bug</description>\n</bug_report>';
218+
expect(parseXmlTagSegments(text)).toEqual([
219+
{
220+
type: 'xml-tag',
221+
tagName: 'bug_report',
222+
content:
223+
'\n<location>file.ts</location>\n<description>a bug</description>\n',
224+
isBlock: true,
225+
},
226+
]);
173227
});
174228
});

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,15 @@ type AIContentType =
1010

1111
type ContentSegment =
1212
| {content: string; type: 'text'}
13-
| {content: string; tagName: string; type: 'xml-tag'};
13+
| {content: string; isBlock: boolean; tagName: string; type: 'xml-tag'};
1414

1515
interface AIContentDetectionResult {
1616
type: AIContentType;
1717
parsedData?: unknown;
1818
wasFixed?: boolean;
1919
}
2020

21-
/**
22-
* Best-effort conversion of a Python dict literal to a JSON-parseable string.
23-
* Returns the parsed object on success, or null if conversion fails.
24-
*/
21+
/** Best-effort conversion of a Python dict literal to a JSON-parseable string. */
2522
export function tryParsePythonDict(text: string): Record<PropertyKey, unknown> | null {
2623
const trimmed = text.trim();
2724
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
@@ -50,10 +47,7 @@ export function tryParsePythonDict(text: string): Record<PropertyKey, unknown> |
5047
}
5148
}
5249

53-
/**
54-
* Splits text into segments of plain text and XML-like tag blocks.
55-
* Matches `<tagname>...</tagname>` patterns (including multiline content).
56-
*/
50+
/** Splits text into segments of plain text and XML-like tag blocks. */
5751
export function parseXmlTagSegments(text: string): ContentSegment[] {
5852
const segments: ContentSegment[] = [];
5953
const xmlTagRegex = /<([a-zA-Z][\w-]*)>([\s\S]*?)<\/\1>/g;
@@ -63,10 +57,12 @@ export function parseXmlTagSegments(text: string): ContentSegment[] {
6357
if (match.index > lastIndex) {
6458
segments.push({type: 'text', content: text.slice(lastIndex, match.index)});
6559
}
60+
const isBlock = match.index === 0 || /\n\s*$/.test(text.slice(0, match.index));
6661
segments.push({
6762
type: 'xml-tag',
6863
tagName: match[1]!,
6964
content: match[2]!,
65+
isBlock,
7066
});
7167
lastIndex = match.index + match[0].length;
7268
}
@@ -78,6 +74,18 @@ export function parseXmlTagSegments(text: string): ContentSegment[] {
7874
return segments;
7975
}
8076

77+
/** Replaces inline XML tags with italic markdown, leaves block-level tags untouched. */
78+
export function preprocessInlineXmlTags(text: string): string {
79+
const xmlTagRegex = /<([a-zA-Z][\w-]*)>([\s\S]*?)<\/\1>/g;
80+
return text.replace(xmlTagRegex, (match, tagName, content, offset) => {
81+
const isBlock = offset === 0 || /\n\s*$/.test(text.slice(0, offset));
82+
if (isBlock) {
83+
return match;
84+
}
85+
return `*${tagName}: ${content.trim()}*`;
86+
});
87+
}
88+
8189
const XML_TAG_REGEX = /<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>/;
8290

8391
const MARKDOWN_INDICATORS = [
@@ -91,16 +99,7 @@ const MARKDOWN_INDICATORS = [
9199
/^```/m, // code fences
92100
];
93101

94-
/**
95-
* Detects the content type of an AI response string.
96-
* Short-circuits on first match in priority order:
97-
* 1. Valid JSON (object/array)
98-
* 2. Python dict
99-
* 3. Partial/truncated JSON (fixable)
100-
* 4. Markdown with XML tags
101-
* 5. Markdown
102-
* 6. Plain text
103-
*/
102+
/** Detects AI content type: JSON, Python dict, markdown-with-xml, markdown, or plain text. */
104103
export function detectAIContentType(text: string): AIContentDetectionResult {
105104
const trimmed = text.trim();
106105
if (!trimmed) {

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,32 @@ describe('AIContentRenderer', () => {
2828
expect(screen.getByText('name')).toBeInTheDocument();
2929
});
3030

31-
it('renders XML tags with styled wrappers', () => {
31+
it('renders inline XML tags as italic text within the flow', () => {
3232
render(<AIContentRenderer text="Before <thinking>inner thought</thinking> After" />);
33+
expect(screen.getByText(/thinking: inner thought/)).toBeInTheDocument();
34+
});
35+
36+
it('renders block XML tags with styled wrappers', () => {
37+
render(<AIContentRenderer text={'Text\n<thinking>inner thought</thinking>'} />);
3338
expect(screen.getByText('thinking')).toBeInTheDocument();
3439
});
3540

36-
it('renders XML tags with styled wrappers when inline', () => {
41+
it('renders inline XML tags as italic text when inline', () => {
3742
render(
3843
<AIContentRenderer text="Before <thinking>inner thought</thinking> After" inline />
3944
);
40-
expect(screen.getByText('thinking')).toBeInTheDocument();
45+
expect(screen.getByText(/thinking: inner thought/)).toBeInTheDocument();
46+
});
47+
48+
it('renders nested XML tags recursively', () => {
49+
const text =
50+
'<bug_report>\n<location>file.ts</location>\n<description>a bug</description>\n</bug_report>';
51+
render(<AIContentRenderer text={text} />);
52+
expect(screen.getByText('bug_report')).toBeInTheDocument();
53+
expect(screen.getByText('location')).toBeInTheDocument();
54+
expect(screen.getByText('description')).toBeInTheDocument();
55+
expect(screen.getByText('file.ts')).toBeInTheDocument();
56+
expect(screen.getByText('a bug')).toBeInTheDocument();
4157
});
4258

4359
it('wraps plain text in MultilineText by default', () => {

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import {MarkedText} from 'sentry/utils/marked/markedText';
99
import {
1010
detectAIContentType,
1111
parseXmlTagSegments,
12+
preprocessInlineXmlTags,
1213
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection';
1314
import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles';
1415

1516
interface AIContentRendererProps {
1617
text: string;
17-
/** When true, renders content directly without a wrapper or raw/pretty toggle. */
1818
inline?: boolean;
1919
maxJsonDepth?: number;
2020
}
@@ -27,22 +27,23 @@ function XmlTagBlock({tagName, content}: {content: string; tagName: string}) {
2727
direction="column"
2828
padding="0 0 0 md"
2929
margin="sm 0"
30-
style={{borderLeft: `3px solid ${theme.tokens.border.accent.moderate}`}}
30+
style={{borderLeft: `2px solid ${theme.tokens.border.primary}`}}
3131
>
3232
<Container margin="0 0 xs 0">
3333
<Text size="xs" variant="muted">
3434
{tagName}
3535
</Text>
3636
</Container>
37-
<Text italic>
38-
<MarkedText as={TraceDrawerComponents.MarkdownContainer} text={content} />
39-
</Text>
37+
<MarkdownWithXmlRenderer text={content} />
4038
</Flex>
4139
);
4240
}
4341

4442
function MarkdownWithXmlRenderer({text}: {text: string}) {
45-
const segments = useMemo(() => parseXmlTagSegments(text), [text]);
43+
const segments = useMemo(
44+
() => parseXmlTagSegments(preprocessInlineXmlTags(text)),
45+
[text]
46+
);
4647

4748
return (
4849
<Fragment>
@@ -61,10 +62,7 @@ function MarkdownWithXmlRenderer({text}: {text: string}) {
6162
);
6263
}
6364

64-
/**
65-
* Unified AI content renderer that auto-detects content type and renders appropriately.
66-
* Handles JSON, Python dicts, partial JSON, markdown with XML tags, markdown, and plain text.
67-
*/
65+
/** Auto-detects AI content type and renders appropriately. */
6866
export function AIContentRenderer({
6967
text,
7068
inline = false,

0 commit comments

Comments
 (0)