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..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
@@ -1,6 +1,7 @@
import {
detectAIContentType,
parseXmlTagSegments,
+ preprocessInlineXmlTags,
tryParsePythonDict,
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection';
@@ -127,6 +128,41 @@ 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');
+ });
+
+ 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('splits text with XML tags into segments', () => {
const text = 'Before inner thought After';
@@ -157,9 +193,9 @@ describe('parseXmlTagSegments', () => {
});
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 +204,20 @@ 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'},
+ ]);
+ });
+
+ 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',
+ },
+ ]);
});
});
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..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
@@ -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;
@@ -78,6 +72,19 @@ 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;
+ }
+ const stripped = content.replace(/<\/?[a-zA-Z][\w-]*>/g, '').trim();
+ return `*${tagName}: ${stripped}*`;
+ });
+}
+
const XML_TAG_REGEX = /<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>/;
const MARKDOWN_INDICATORS = [
@@ -91,16 +98,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,