Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions static/app/utils/analytics/conversationsAnalyticsEvents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ type ConversationOpenSource = 'table_conversation_id' | 'table_input' | 'table_o
export type ConversationsEventParameters = {
'conversations.detail.click-errors-link': Record<string, unknown>;
'conversations.detail.click-trace-link': Record<string, unknown>;
'conversations.detail.copy-conversation': Record<string, unknown>;
'conversations.detail.copy-conversation-id': Record<string, unknown>;
'conversations.detail.page-view': Record<string, unknown>;
'conversations.detail.select-span': Record<string, unknown>;
Expand All @@ -28,6 +29,7 @@ export const conversationsEventMap: Record<keyof ConversationsEventParameters, s
'conversations.detail.page-view': 'Conversations: Detail Page View',
'conversations.detail.tab-switch': 'Conversations: Detail Tab Switch',
'conversations.detail.select-span': 'Conversations: Detail Select Span',
'conversations.detail.copy-conversation': 'Conversations: Detail Copy Conversation',
'conversations.detail.copy-conversation-id':
'Conversations: Detail Copy Conversation ID',
'conversations.detail.click-trace-link': 'Conversations: Detail Click Trace Link',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {memo, useEffect, useState} from 'react';
import {memo, useEffect, useMemo, useState} from 'react';
import * as Sentry from '@sentry/react';

import {Container, Flex} from '@sentry/scraps/layout';
import {TabList, Tabs} from '@sentry/scraps/tabs';

import {CopyAsDropdown} from 'sentry/components/copyAsDropdown';
import {EmptyMessage} from 'sentry/components/emptyMessage';
import {t} from 'sentry/locale';
import {trackAnalytics} from 'sentry/utils/analytics';
Expand All @@ -20,6 +21,10 @@ import {
type UseConversationsOptions,
} from 'sentry/views/explore/conversations/hooks/useConversation';
import {useConversationSelection} from 'sentry/views/explore/conversations/hooks/useConversationSelection';
import {
extractMessagesFromNodes,
messagesToMarkdown,
} from 'sentry/views/explore/conversations/utils/conversationMessages';
import {AISpanList} from 'sentry/views/insights/pages/agents/components/aiSpanList';
import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types';
import {DEFAULT_TRACE_VIEW_PREFERENCES} from 'sentry/views/performance/newTraceDetails/traceState/tracePreferences';
Expand Down Expand Up @@ -105,6 +110,8 @@ function ConversationView({
setActiveTab(newTab);
};

const messages = useMemo(() => extractMessagesFromNodes(nodes), [nodes]);

if (isLoading) {
return <ConversationViewSkeleton />;
}
Expand All @@ -122,14 +129,37 @@ function ConversationView({
left={
<ConversationLeftPanel>
<Flex direction="column" flex="1" minHeight="0" width="100%" overflow="hidden">
<Container flexShrink={0} borderBottom="primary" background="primary">
<Tabs value={activeTab} onChange={handleTabChange}>
<TabList>
<TabList.Item key="messages">{t('Chat')}</TabList.Item>
<TabList.Item key="trace">{t('Spans')}</TabList.Item>
</TabList>
</Tabs>
</Container>
<Flex
flexShrink={0}
align="center"
gap="sm"
borderBottom="primary"
background="primary"
>
<Flex flex={1}>
<Tabs value={activeTab} onChange={handleTabChange}>
<TabList>
<TabList.Item key="messages">{t('Chat')}</TabList.Item>
<TabList.Item key="trace">{t('Spans')}</TabList.Item>
</TabList>
</Tabs>
</Flex>
{activeTab === 'messages' && messages.length > 0 && (
<CopyAsDropdown
size="xs"
items={CopyAsDropdown.makeDefaultCopyAsOptions({
markdown: () => {
trackAnalytics('conversations.detail.copy-conversation', {
organization,
});
return messagesToMarkdown(messages);
},
text: undefined,
json: undefined,
})}
/>
)}
</Flex>
<Flex
flex="1"
minHeight="0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
extractMessagesFromNodes,
getNodeTimestamp,
mergeEmptyTurns,
messagesToMarkdown,
parseAssistantContent,
parseUserContent,
partitionSpansByType,
Expand Down Expand Up @@ -1055,4 +1056,119 @@ describe('conversationMessages utilities', () => {
expect(extractMessagesFromNodes([tool as any])).toEqual([]);
});
});

describe('messagesToMarkdown', () => {
it('formats user messages with email', () => {
const result = messagesToMarkdown([
{
id: 'user-1',
role: 'user',
content: 'Hello world',
timestamp: 1000,
nodeId: 'n1',
userEmail: 'dev@example.com',
},
]);
expect(result).toBe('### dev@example.com\n\nHello world');
});

it('formats user messages without email as User', () => {
const result = messagesToMarkdown([
{
id: 'user-1',
role: 'user',
content: 'Hello',
timestamp: 1000,
nodeId: 'n1',
},
]);
expect(result).toBe('### User\n\nHello');
});

it('formats assistant messages with model and duration', () => {
const result = messagesToMarkdown([
{
id: 'assistant-1',
role: 'assistant',
content: 'Here is the answer',
timestamp: 1000,
nodeId: 'n1',
modelName: 'claude-sonnet-4-20250514',
duration: 2.5,
},
]);
expect(result).toBe('### claude-sonnet-4-20250514 — 2.5s\n\nHere is the answer');
});

it('formats assistant messages with agent name', () => {
const result = messagesToMarkdown([
{
id: 'assistant-1',
role: 'assistant',
content: 'Done',
timestamp: 1000,
nodeId: 'n1',
agentName: 'My Agent',
},
]);
expect(result).toBe('### My Agent\n\nDone');
});

it('includes tool calls', () => {
const result = messagesToMarkdown([
{
id: 'assistant-1',
role: 'assistant',
content: 'I ran the tools',
timestamp: 1000,
nodeId: 'n1',
toolCalls: [
{name: 'bash', nodeId: 't1', hasError: false},
{name: 'read', nodeId: 't2', hasError: false},
],
},
]);
expect(result).toContain('> Called tools: `bash`, `read`');
expect(result).toContain('I ran the tools');
});

it('formats a full conversation with separators between messages', () => {
const result = messagesToMarkdown([
{
id: 'user-1',
role: 'user',
content: 'What files?',
timestamp: 1000,
nodeId: 'n1',
userEmail: 'dev@example.com',
},
{
id: 'assistant-1',
role: 'assistant',
content: 'Here they are',
timestamp: 1001,
nodeId: 'n1',
modelName: 'gpt-4o',
duration: 1.2,
},
]);
expect(result).toBe(
[
'### dev@example.com',
'',
'What files?',
'',
'---',
'',
'### gpt-4o — 1.2s',
'',
'Here they are',
].join('\n')
);
});

it('returns empty string for empty messages', () => {
expect(messagesToMarkdown([])).toBe('');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {getDuration} from 'sentry/utils/duration/getDuration';
import {
extractAssistantOutput,
normalizeToMessages,
Expand Down Expand Up @@ -307,3 +308,33 @@ function getNodeEndTimestamp(node: AITraceSpanNode): number {
function getGenAiOpType(node: AITraceSpanNode): string | undefined {
return getStringAttr(node, SpanFields.GEN_AI_OPERATION_TYPE);
}

export function messagesToMarkdown(messages: ConversationMessage[]): string {
const blocks: string[] = [];

for (const message of messages) {
const lines: string[] = [];

if (message.role === 'user') {
const sender = message.userEmail || 'User';
lines.push(`### ${sender}`);
} else {
const sender = message.agentName || message.modelName || 'Assistant';
const durationStr =
message.duration !== undefined && message.duration > 0
? ` — ${getDuration(message.duration, 1, true)}`
: '';
lines.push(`### ${sender}${durationStr}`);

if (message.toolCalls && message.toolCalls.length > 0) {
const toolNames = message.toolCalls.map(tc => `\`${tc.name}\``).join(', ');
lines.push(`> Called tools: ${toolNames}`);
}
}

lines.push(message.content);
blocks.push(lines.join('\n\n'));
}

return blocks.join('\n\n---\n\n');
}
Loading