From b2e4ee8d6d43421783876315ad31e0e57a918196 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:23:03 -0400 Subject: [PATCH 01/10] feat(seer): add the conversation embed entry points and links Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../components/conversation/conversation.tsx | 17 +++++ .../conversation/conversationLink.tsx | 72 +++++++++++++++++++ .../conversation/conversationsQuery.tsx | 17 +++++ .../conversation/conversationsQueryLink.tsx | 67 +++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/conversation/conversationsQuery.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryLink.tsx diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx new file mode 100644 index 000000000000..176321fe44a6 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx @@ -0,0 +1,17 @@ +import {lazy} from 'react'; + +import {LazyLoad} from 'sentry/components/lazyLoad'; +import {ConversationLink} from 'sentry/components/seer/markdown/embeds/components/conversation/conversationLink'; +import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; + +const LazyConversationBlock = lazy(() => import('./conversationBlock')); + +export const Conversation = defineSeerEmbed({ + name: 'conversation', + render(props, level) { + if (level === 'block') { + return ; + } + return ; + }, +}); diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx new file mode 100644 index 000000000000..4270c3baaf43 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx @@ -0,0 +1,72 @@ +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {IconChat} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import { + CONVERSATIONS_DETAIL_SUB_PATH, + EXPLORE_AGENTS_SUB_PATH, +} from 'sentry/views/explore/conversations/settings'; + +export type ConversationData = EmbedOutput<'conversation'>; + +/** + * The detail view scopes its span query to the URL's time range, so the window + * is padded either side of the conversation's own timestamps -- the same hour + * `getConversationDetailUrl` pads by for a row in the conversations table. + */ +const CONVERSATION_WINDOW_PADDING_MS = 60 * 60 * 1000; + +/** + * `getConversationDetailUrl` needs a full `Conversation` row, which an embed + * never has -- the tag carries an id and, at best, the conversation's first and + * last span timestamps. Build the same path from those instead. + */ +export function getConversationHref( + data: ConversationData, + organizationSlug: string +): string { + const basePath = `/organizations/${organizationSlug}/explore/${EXPLORE_AGENTS_SUB_PATH}/${CONVERSATIONS_DETAIL_SUB_PATH}/${encodeURIComponent(data.id)}/`; + + const params = new URLSearchParams(); + if (data.start) { + params.set( + 'start', + new Date(Date.parse(data.start) - CONVERSATION_WINDOW_PADDING_MS).toISOString() + ); + } + if (data.end) { + params.set( + 'end', + new Date(Date.parse(data.end) + CONVERSATION_WINDOW_PADDING_MS).toISOString() + ); + } + for (const project of data.projects ?? []) { + params.append('project', String(project)); + } + params.set('referrer', 'seer-conversation-embed'); + + return normalizeUrl(`${basePath}?${params.toString()}`); +} + +interface ConversationLinkProps { + data: ConversationData; + /** + * Overrides the tag's title. The block passes the API-provided title once it + * has loaded, which is fresher than whatever the model wrote into the tag. + */ + title?: string | null; +} + +export function ConversationLink({data, title}: ConversationLinkProps) { + const organization = useOrganization(); + + return ( + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationsQuery.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationsQuery.tsx new file mode 100644 index 000000000000..7ecab0234fe0 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationsQuery.tsx @@ -0,0 +1,17 @@ +import {lazy} from 'react'; + +import {LazyLoad} from 'sentry/components/lazyLoad'; +import {ConversationsQueryLink} from 'sentry/components/seer/markdown/embeds/components/conversation/conversationsQueryLink'; +import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; + +const LazyConversationsQueryBlock = lazy(() => import('./conversationsQueryBlock')); + +export const ConversationsQuery = defineSeerEmbed({ + name: 'conversationsQuery', + render(props, level) { + if (level === 'block') { + return ; + } + return ; + }, +}); diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryLink.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryLink.tsx new file mode 100644 index 000000000000..3c6cb1dfe2aa --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryLink.tsx @@ -0,0 +1,67 @@ +import queryString from 'query-string'; + +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {IconChat} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {EXPLORE_AGENTS_SUB_PATH} from 'sentry/views/explore/conversations/settings'; +import {getAgentNamesFilter} from 'sentry/views/insights/pages/agents/utils/query'; + +export type ConversationsQueryData = EmbedOutput<'conversationsQuery'>; + +/** + * The agents list keeps the agent filter in its own `agent` param and folds it + * into the span query itself only when it calls the API -- see + * `useCombinedQuery`. Mirror both halves so the link and the block preview + * below it are filtered the same way. + */ +export function combineAgentQuery(query: string, agents?: string[]): string { + const agentQuery = getAgentNamesFilter(agents ?? []); + if (!agentQuery) { + return query; + } + if (!query) { + return agentQuery; + } + return `(${agentQuery}) and (${query})`; +} + +export function getConversationsQueryHref( + data: ConversationsQueryData, + organization: Organization +): string { + const {query, agents, projects, environments, statsPeriod, start, end} = data; + + return queryString.stringifyUrl({ + url: normalizeUrl( + `/organizations/${organization.slug}/explore/${EXPLORE_AGENTS_SUB_PATH}/` + ), + query: { + query, + project: projects, + environment: environments, + statsPeriod, + start, + end, + // The list reads `agent` as a single comma-separated value, not repeated + // params. + agent: agents?.length ? agents.join(',') : undefined, + referrer: 'seer-conversations-query-embed', + }, + }); +} + +export function ConversationsQueryLink({data}: {data: ConversationsQueryData}) { + const organization = useOrganization(); + + return ( + + ); +} From c03216063b084806f10da49916e44a58be034abe Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:23:46 -0400 Subject: [PATCH 02/10] feat(seer): add the conversation embed blocks Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../conversation/conversationBlock.tsx | 75 +++++++ .../conversation/conversationsQueryBlock.tsx | 193 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryBlock.tsx diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx new file mode 100644 index 000000000000..b9dacbb5fa65 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx @@ -0,0 +1,75 @@ +import {useState} from 'react'; + +import {Container, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import { + ConversationLink, + type ConversationData, +} from 'sentry/components/seer/markdown/embeds/components/conversation/conversationLink'; +import {QueryEmbedCard} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedCard'; +import {t} from 'sentry/locale'; +import {ConversationAggregatesBar} from 'sentry/views/explore/conversations/components/conversationSummary'; +import { + MessagesPanel, + MessagesPanelSkeleton, +} from 'sentry/views/explore/conversations/components/messagesPanel'; +import {useConversation} from 'sentry/views/explore/conversations/hooks/useConversation'; + +/** Keeps a long transcript from pushing the rest of the answer off screen. */ +const TRANSCRIPT_MAX_HEIGHT = '400px'; + +function toTimestampMs(isoTimestamp: string | undefined): number | undefined { + if (!isoTimestamp) { + return undefined; + } + const parsed = Date.parse(isoTimestamp); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export default function ConversationBlock({data}: {data: ConversationData}) { + // Selection lives here rather than in the URL: an embed must not be able to + // change the host page's shareable state (see the embeds README). + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const {nodes, isLoading, error, title} = useConversation({ + conversationId: data.id, + startTimestamp: toTimestampMs(data.start), + endTimestamp: toTimestampMs(data.end), + }); + + return ( + } + testId="seer-conversation-embed" + > + + + {isLoading ? ( + + ) : error ? ( + {t('Unable to load conversation.')} + ) : nodes.length === 0 ? ( + {t('No messages in this conversation')} + ) : ( + + setSelectedNodeId(node.id)} + /> + + )} + + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryBlock.tsx new file mode 100644 index 000000000000..c135f447b95f --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryBlock.tsx @@ -0,0 +1,193 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {Count} from 'sentry/components/count'; +import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse'; +import {PerformanceDuration} from 'sentry/components/performanceDuration'; +import {QueryEmbedCard} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedCard'; +import {QUERY_EMBED_ROW_LIMIT} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedConstants'; +import { + QueryEmbedTable, + type QueryEmbedColumn, +} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedTable'; +import {toPageFilters} from 'sentry/components/seer/markdown/embeds/components/queryEmbedParams'; +import {t} from 'sentry/locale'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {markdownToPlainText} from 'sentry/utils/marked/marked'; +import {ellipsize} from 'sentry/utils/string/ellipsize'; +import {isUUID} from 'sentry/utils/string/isUUID'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type { + Conversation, + ConversationUser, +} from 'sentry/views/explore/conversations/hooks/useConversations'; +import {LLMCosts} from 'sentry/views/insights/pages/agents/components/llmCosts'; + +import { + combineAgentQuery, + ConversationsQueryLink, + type ConversationsQueryData, +} from './conversationsQueryLink'; + +/** + * The list endpoint's raw rows. `useConversations` flattens the two content + * fields before handing them out, and this block calls the endpoint directly + * (that hook takes its filters from the router), so it flattens them itself. + */ +type ConversationApiRow = Omit & { + firstInput?: Array<{text: string; type: string}> | string | null; + lastOutput?: Array<{text: string; type: string}> | string | null; +}; + +/** A preview line is one table cell wide, so it is cut well short of the table's limit. */ +const PREVIEW_MAX_CHARS = 200; + +function flattenContent(content: ConversationApiRow['firstInput']): string | null { + if (typeof content === 'string') { + return content; + } + return content?.find(part => part.type === 'text')?.text ?? null; +} + +/** + * A title is model-written markdown and a first input is whatever the user + * typed, so both are flattened to a single line before they go in a cell. + */ +function getConversationLabel(row: ConversationApiRow): string | null { + const raw = row.title ?? flattenContent(row.firstInput); + if (!raw) { + return null; + } + const plainText = ellipsize( + markdownToPlainText(raw).replace(/\s+/g, ' ').trim(), + PREVIEW_MAX_CHARS + ); + return plainText.length > 0 ? plainText : null; +} + +/** `useConversations` renders the SDK's literal "none" as no user at all. */ +function getUserLabel(user: ConversationUser | null): string | null { + const fields = [user?.email, user?.username, user?.ip_address, user?.id]; + return fields.find(value => value && value.toLowerCase() !== 'none') ?? null; +} + +function getConversationIdLabel(conversationId: string): string { + // UUIDs are long and opaque, so show a short prefix; other id formats + // (e.g. `resp_...`, `slack:1234`) are already short enough. + return isUUID(conversationId) ? conversationId.slice(0, 8) : conversationId; +} + +const COLUMNS: Array> = [ + { + key: 'conversation', + label: t('Conversation'), + render: row => { + const label = getConversationLabel(row); + const user = getUserLabel(row.user); + return ( + + + {label ?? {t('Untitled conversation')}} + + + + {getConversationIdLabel(row.conversationId)} + + {user ? ( + + {user} + + ) : null} + + + ); + }, + }, + { + key: 'duration', + label: t('Duration'), + // The generation duration, not the wall-clock span of the conversation: + // a conversation can sit idle for hours between two messages. + render: row => ( + + + + ), + }, + { + key: 'messages', + label: t('Messages'), + render: row => ( + + + + ), + }, + { + key: 'errors', + label: t('Errors'), + render: row => ( + 0 ? 'danger' : undefined}> + + + ), + }, + { + key: 'cost', + label: t('Cost'), + render: row => ( + + + + ), + }, +]; + +export default function ConversationsQueryBlock({data}: {data: ConversationsQueryData}) { + const organization = useOrganization(); + const selection = toPageFilters(data); + + const conversationsQuery = useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/agents/conversations/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + query: combineAgentQuery(data.query, data.agents), + project: selection.projects, + environment: selection.environments, + per_page: QUERY_EMBED_ROW_LIMIT, + ...normalizeDateTimeParams(selection.datetime), + }, + staleTime: 30_000, + } + ), + retry: false, + }); + + // The endpoint orders by relevance rather than recency; the list view sorts + // newest-first before rendering, so the preview shows the same five rows. + const rows = (conversationsQuery.data ?? []).toSorted( + (a, b) => b.endTimestamp - a.endTimestamp + ); + + return ( + } + query={data.query} + testId="seer-conversations-query-embed" + > + row.conversationId} + rows={rows} + /> + + ); +} From 3000043dbec2b766e2831c26004a9b38d7fbd33d Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:24:33 -0400 Subject: [PATCH 03/10] test(seer): cover the conversation embeds Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../conversation/conversation.spec.tsx | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx new file mode 100644 index 000000000000..2cfbc723ad68 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx @@ -0,0 +1,316 @@ +import {act, screen, userEvent, waitFor, within} from 'sentry-test/reactTestingLibrary'; + +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import { + getEmbedLinkHref, + renderEmbed, +} from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; + +const CONVERSATION_ID = 'conv-1'; +const LIST_URL = '/organizations/org-slug/agents/conversations/'; +const DETAIL_URL = `/organizations/org-slug/agents/conversations/${CONVERSATION_ID}/`; + +/** Shaped as the conversation detail endpoint returns it: flat spans. */ +function spanFixture(overrides: Record) { + return { + 'gen_ai.conversation.id': CONVERSATION_ID, + parent_span: 'parent-1', + project: 'test-project', + 'project.id': 1, + 'span.status': 'ok', + trace: 'trace-1', + 'gen_ai.operation.type': 'ai_client', + ...overrides, + }; +} + +/** Shaped as the list endpoint returns it: `firstInput` may be content parts. */ +function conversationFixture(overrides: Record = {}) { + return { + conversationId: '8f0e1f6a-1f2b-4d3c-9e5a-0b1c2d3e4f5a', + title: 'Refund request escalated', + firstInput: [{type: 'text', text: 'I want a refund'}], + lastOutput: 'Escalating to a human', + duration: 120_000, + generationDuration: 4200, + llmCalls: 3, + toolCalls: 2, + toolErrors: 0, + toolNames: ['search'], + errors: 1, + inputTokens: 100, + outputTokens: 200, + totalTokens: 300, + totalCost: 0.42, + startTimestamp: 1_700_000_000_000, + endTimestamp: 1_700_000_120_000, + traceCount: 1, + traceIds: ['trace-1'], + projectId: 1, + user: {email: 'user@example.com', id: '1', ip_address: null, username: null}, + ...overrides, + }; +} + +function searchParams(href: string) { + return new URLSearchParams(href.split('?')[1] ?? ''); +} + +describe('Seer conversation embeds', () => { + beforeAll(async () => { + // Both blocks are behind `lazy()`. Compiling the transcript's module graph + // on first render costs more than a `findBy*` will wait for, so pay it + // here instead of inside the first block assertion. + await import('./conversationBlock'); + await import('./conversationsQueryBlock'); + }, 60_000); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + act(() => { + PageFiltersStore.reset(); + PageFiltersStore.init(); + }); + }); + + describe('conversation', () => { + it('links to the conversation detail view inline', () => { + const href = getEmbedLinkHref('conversation', 'Refund request escalated', { + id: CONVERSATION_ID, + title: 'Refund request escalated', + projects: ['1'], + start: '2026-08-25T16:37:12Z', + end: '2026-08-25T16:39:02Z', + }); + + expect(href.split('?')[0]).toBe( + `/organizations/org-slug/explore/agents/conversations/${CONVERSATION_ID}/` + ); + const params = searchParams(href); + // The detail view scopes its span query to this window, so it is padded + // an hour either side of the conversation's own timestamps. + expect(params.get('start')).toBe('2026-08-25T15:37:12.000Z'); + expect(params.get('end')).toBe('2026-08-25T17:39:02.000Z'); + expect(params.getAll('project')).toEqual(['1']); + expect(params.get('referrer')).toBe('seer-conversation-embed'); + }); + + it('falls back to the id when the tag carries no title', () => { + const href = getEmbedLinkHref('conversation', `Conversation ${CONVERSATION_ID}`, { + id: CONVERSATION_ID, + }); + + expect(href).toBe( + `/organizations/org-slug/explore/agents/conversations/${CONVERSATION_ID}/?referrer=seer-conversation-embed` + ); + }); + + it('renders the transcript and the aggregates', async () => { + const request = MockApiClient.addMockResponse({ + url: DETAIL_URL, + body: { + conversationId: CONVERSATION_ID, + title: 'Out of memory investigation', + spans: [ + spanFixture({ + span_id: 'span-a', + 'span.name': 'first turn', + 'precise.start_ts': 1000, + 'precise.finish_ts': 1000.5, + 'gen_ai.request.messages': JSON.stringify([ + {role: 'user', content: 'Why did the job fail?'}, + ]), + 'gen_ai.response.text': 'The worker ran out of memory.', + 'gen_ai.usage.total_tokens': 1200, + }), + ], + }, + }); + + renderEmbed({ + name: 'conversation', + data: { + id: CONVERSATION_ID, + title: 'Stale title', + start: '2026-08-25T16:37:12Z', + end: '2026-08-25T16:39:02Z', + }, + }); + + expect( + await screen.findByText('The worker ran out of memory.') + ).toBeInTheDocument(); + expect(screen.getByText('Why did the job fail?')).toBeInTheDocument(); + expect(screen.getByText('LLM Calls')).toBeInTheDocument(); + expect(screen.getByText('Tokens')).toBeInTheDocument(); + // The API title wins over whatever the model wrote into the tag. + expect( + screen.getByRole('link', {name: 'Out of memory investigation'}) + ).toBeInTheDocument(); + expect(screen.queryByText('Stale title')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + DETAIL_URL, + expect.objectContaining({ + query: expect.objectContaining({ + // The tag's ISO timestamps scope the query instead of the + // default page-filter window. + start: '2026-08-25T15:37:12.000Z', + end: '2026-08-25T17:39:02.000Z', + }), + }) + ); + }); + }); + + it('keeps message selection inside the embed', async () => { + MockApiClient.addMockResponse({ + url: DETAIL_URL, + body: { + conversationId: CONVERSATION_ID, + title: null, + spans: [ + spanFixture({ + span_id: 'span-a', + 'span.name': 'first turn', + 'precise.start_ts': 1000, + 'precise.finish_ts': 1000.5, + 'gen_ai.request.messages': JSON.stringify([ + {role: 'user', content: 'Why did the job fail?'}, + ]), + 'gen_ai.response.text': 'The worker ran out of memory.', + }), + ], + }, + }); + + const {router} = renderEmbed({ + name: 'conversation', + data: {id: CONVERSATION_ID}, + }); + const initialLocation = router.location; + + await userEvent.click(await screen.findByText('The worker ran out of memory.')); + + // Selecting a message is embed-local state: it must not touch the host + // page's URL (see the embeds README). + expect(router.location.pathname).toBe(initialLocation.pathname); + expect(router.location.query).toEqual(initialLocation.query); + }); + + it('shows an error when the conversation cannot be loaded', async () => { + MockApiClient.addMockResponse({url: DETAIL_URL, statusCode: 500, body: {}}); + + renderEmbed({name: 'conversation', data: {id: CONVERSATION_ID}}); + + expect(await screen.findByText('Unable to load conversation.')).toBeInTheDocument(); + }); + }); + + describe('conversationsQuery', () => { + it('links to the agents list inline', () => { + const href = getEmbedLinkHref('conversationsQuery', 'Conversations using tools', { + query: 'gen_ai.tool.name:*', + statsPeriod: '24h', + projects: ['1', '2'], + environments: ['prod'], + agents: ['support-bot', 'triage-bot'], + title: 'Conversations using tools', + }); + + expect(href.split('?')[0]).toBe('/organizations/org-slug/explore/agents/'); + const params = searchParams(href); + expect(params.get('query')).toBe('gen_ai.tool.name:*'); + expect(params.get('statsPeriod')).toBe('24h'); + expect(params.getAll('project')).toEqual(['1', '2']); + expect(params.getAll('environment')).toEqual(['prod']); + // The list reads `agent` as one comma-separated value. + expect(params.get('agent')).toBe('support-bot,triage-bot'); + expect(params.get('referrer')).toBe('seer-conversations-query-embed'); + }); + + it('falls back to a generic title', () => { + const href = getEmbedLinkHref('conversationsQuery', 'Conversation search', { + query: '', + }); + + expect(href).toContain('/organizations/org-slug/explore/agents/'); + }); + + it('previews matching conversations', async () => { + const request = MockApiClient.addMockResponse({ + url: LIST_URL, + body: [ + conversationFixture({ + conversationId: 'older', + title: 'Older conversation', + endTimestamp: 1_600_000_000_000, + }), + conversationFixture(), + ], + }); + + renderEmbed({ + name: 'conversationsQuery', + data: { + query: 'gen_ai.request.model:gpt-4o', + statsPeriod: '24h', + agents: ['support-bot'], + }, + }); + + expect(await screen.findByText('Refund request escalated')).toBeInTheDocument(); + + const row = screen.getByRole('row', {name: /Refund request escalated/}); + // A UUID id is shown as a short prefix. + expect(within(row).getByText('8f0e1f6a')).toBeInTheDocument(); + expect(within(row).getByText('user@example.com')).toBeInTheDocument(); + expect(within(row).getByText('3')).toBeInTheDocument(); + expect(within(row).getByText('1')).toBeInTheDocument(); + expect(within(row).getByText('$0.42')).toBeInTheDocument(); + + // Newest-first, the way the list view orders its rows. + const rows = screen.getAllByRole('row'); + expect(rows[1]).toHaveTextContent('Refund request escalated'); + expect(rows[2]).toHaveTextContent('Older conversation'); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + LIST_URL, + expect.objectContaining({ + query: expect.objectContaining({ + // The agent filter is a URL param on the list view, but part of + // the span query when the endpoint is called directly. + query: expect.stringContaining('support-bot'), + statsPeriod: '24h', + per_page: 5, + }), + }) + ); + }); + expect(request.mock.calls[0][1].query.query).toContain( + 'gen_ai.request.model:gpt-4o' + ); + }); + + it('flattens a content-part first input when there is no title', async () => { + MockApiClient.addMockResponse({ + url: LIST_URL, + body: [conversationFixture({title: null})], + }); + + renderEmbed({name: 'conversationsQuery', data: {query: ''}}); + + expect(await screen.findByText('I want a refund')).toBeInTheDocument(); + }); + + it('shows an empty state when nothing matches', async () => { + MockApiClient.addMockResponse({url: LIST_URL, body: []}); + + renderEmbed({name: 'conversationsQuery', data: {query: 'gen_ai.tool.name:*'}}); + + expect(await screen.findByText('No matching conversations')).toBeInTheDocument(); + }); + }); +}); From cb2b03662800844e842e3be18548eb275c2246d2 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:25:14 -0400 Subject: [PATCH 04/10] feat(seer): register the conversation embeds and add their stories Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- static/app/components/seer/markdown/embeds/index.ts | 4 ++++ static/app/components/seer/markdown/seerMarkdown.mdx | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/static/app/components/seer/markdown/embeds/index.ts b/static/app/components/seer/markdown/embeds/index.ts index ca31b55e1875..68bf4c86f568 100644 --- a/static/app/components/seer/markdown/embeds/index.ts +++ b/static/app/components/seer/markdown/embeds/index.ts @@ -2,6 +2,8 @@ import {AgentWriteApprovalEmbed} from './components/agentWriteApproval'; import {Alert} from './components/alert/alert'; import {Autofix, AutofixRef} from './components/autofix'; import {Chart} from './components/chart'; +import {Conversation} from './components/conversation/conversation'; +import {ConversationsQuery} from './components/conversation/conversationsQuery'; import {Dashboard} from './components/dashboard'; import {Docs} from './components/docs'; import {Dsn} from './components/dsn'; @@ -29,6 +31,8 @@ const embeds = [ Autofix, AutofixRef, Chart, + Conversation, + ConversationsQuery, Dashboard, Docs, Dsn, diff --git a/static/app/components/seer/markdown/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index 76226ee2ccf8..0808b043122c 100644 --- a/static/app/components/seer/markdown/seerMarkdown.mdx +++ b/static/app/components/seer/markdown/seerMarkdown.mdx @@ -137,6 +137,14 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a +### conversation + + + +### conversationsQuery + + + ## Embed Architecture The embed system has four parts: From 1f66f252ea84f6f76dec089e4376cdb69e62b4a8 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:27:21 -0400 Subject: [PATCH 05/10] feat(seer): add the conversation embed schemas Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../seer/markdown/embeds/schemas.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/static/app/components/seer/markdown/embeds/schemas.ts b/static/app/components/seer/markdown/embeds/schemas.ts index 8f9f369d7ec3..dd567a81a3d1 100644 --- a/static/app/components/seer/markdown/embeds/schemas.ts +++ b/static/app/components/seer/markdown/embeds/schemas.ts @@ -680,6 +680,70 @@ export const SEER_EMBED_SCHEMAS = { }, ], }, + conversation: { + description: + 'The ONLY way to reference a single AI agent conversation (Explore > Agents). ' + + 'Use the `conversationId` exactly as the agents conversations API returns it. ' + + 'Include the API-provided `title` when available, and `start`/`end` (the ' + + "conversation's own first and last span timestamps) so the embed can scope " + + 'its query instead of scanning the default window. ' + + 'Inline: renders a compact link. ' + + 'Block: renders the conversation transcript with its LLM call, token, cost, ' + + 'and tool totals. Do not duplicate the messages or those totals as text. ' + + 'Never use a markdown link for conversation references.', + featureFlag: 'organizations:gen-ai-conversations', + level: ['inline', 'block'], + schema: z.object({ + id: z.string().min(1), + title: z.string().min(1).optional(), + projects: z.array(idString).optional(), + start: isoTimestampSchema.optional(), + end: isoTimestampSchema.optional(), + }), + examples: [ + { + label: 'Conversation', + data: { + id: '4821', + title: 'Refund request escalated to a human', + start: '2026-08-25T16:37:12Z', + end: '2026-08-25T16:39:02Z', + }, + }, + ], + }, + conversationsQuery: { + description: + 'Preview the AI agent conversations list (Explore > Agents) filtered by a ' + + 'search query. Use this when pointing the user at a SET of conversations — ' + + 'if you have a specific conversation ID, use the `conversation` embed instead. ' + + '`query` uses span search syntax over gen_ai spans, e.g. ' + + '"gen_ai.request.model:gpt-4o". Negation is not supported. ' + + 'Use `agents` to filter to specific agent names. ' + + 'Inline renders a link; block renders the first five matching conversations ' + + 'with their duration, message count, errors and cost.', + featureFlag: 'organizations:gen-ai-conversations', + level: ['inline', 'block'], + schema: z.object({ + ...pageFilterFields, + query: z.string().default(''), + agents: z + .array(z.string()) + .optional() + .describe('Filter to these agent names, as reported by gen_ai.agent.name.'), + title: z.string().min(1).optional(), + }), + examples: [ + { + label: 'Conversations with tool errors', + data: { + query: 'gen_ai.tool.name:*', + statsPeriod: '24h', + title: 'Conversations using tools', + }, + }, + ], + }, replaysQuery: { description: 'Preview the Session Replay list filtered by a search query. ' + From f6e6d11e099e47b05d2416dca9f4db04309047c6 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:31:10 -0400 Subject: [PATCH 06/10] feat(seer): regenerate embed widgets for the conversation embeds Ran `pnpm gen:embed-widgets`. CI regenerates this file and fails if it is out of sync with schemas.ts. Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../seer/agent/embed_widgets.generated.json | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/sentry/seer/agent/embed_widgets.generated.json b/src/sentry/seer/agent/embed_widgets.generated.json index c4706cfec7a3..3e90f7da2d15 100644 --- a/src/sentry/seer/agent/embed_widgets.generated.json +++ b/src/sentry/seer/agent/embed_widgets.generated.json @@ -1113,6 +1113,136 @@ } ] }, + { + "name": "conversation", + "description": "The ONLY way to reference a single AI agent conversation (Explore > Agents). Use the `conversationId` exactly as the agents conversations API returns it. Include the API-provided `title` when available, and `start`/`end` (the conversation's own first and last span timestamps) so the embed can scope its query instead of scanning the default window. Inline: renders a compact link. Block: renders the conversation transcript with its LLM call, token, cost, and tool totals. Do not duplicate the messages or those totals as text. Never use a markdown link for conversation references.", + "level": ["inline", "block"], + "body": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "projects": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "start": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "end": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "examples": [ + { + "label": "Conversation", + "data": { + "id": "4821", + "title": "Refund request escalated to a human", + "start": "2026-08-25T16:37:12Z", + "end": "2026-08-25T16:39:02Z" + } + } + ], + "featureFlag": "organizations:gen-ai-conversations" + }, + { + "name": "conversationsQuery", + "description": "Preview the AI agent conversations list (Explore > Agents) filtered by a search query. Use this when pointing the user at a SET of conversations — if you have a specific conversation ID, use the `conversation` embed instead. `query` uses span search syntax over gen_ai spans, e.g. \"gen_ai.request.model:gpt-4o\". Negation is not supported. Use `agents` to filter to specific agent names. Inline renders a link; block renders the first five matching conversations with their duration, message count, errors and cost.", + "level": ["inline", "block"], + "body": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "projects": { + "description": "Project IDs. Omit for the \"My Projects\" selection.", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "environments": { + "type": "array", + "items": { + "type": "string" + } + }, + "statsPeriod": { + "description": "Relative time range, e.g. \"24h\" or \"7d\". Mutually exclusive with start/end.", + "type": "string", + "pattern": "^\\d+[smhdw]$" + }, + "start": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "end": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "query": { + "default": "", + "type": "string" + }, + "agents": { + "description": "Filter to these agent names, as reported by gen_ai.agent.name.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string", + "minLength": 1 + } + }, + "required": ["query"], + "additionalProperties": false + }, + "examples": [ + { + "label": "Conversations with tool errors", + "data": { + "query": "gen_ai.tool.name:*", + "statsPeriod": "24h", + "title": "Conversations using tools" + } + } + ], + "featureFlag": "organizations:gen-ai-conversations" + }, { "name": "replaysQuery", "description": "Preview the Session Replay list filtered by a search query. Use this when pointing the user at a SET of replays — if you have a specific replay ID, use the `replay` embed instead. `query` uses replay search syntax, e.g. \"user.email:user@example.com\". Inline renders a link; block renders the first five matching replays with their duration, error count and rage clicks.", From 4de9cd97f56ece922008180da5076bbcb5b26e07 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:07:58 -0400 Subject: [PATCH 07/10] feat(seer): add a conversation embed story with real data The generic `` renders the schema's hardcoded example id, which resolves to nothing in a real org, so the storybook entry showed an empty transcript. Fetch a recent conversation from the agents conversations list instead and feed its id, title and time bounds into the tag -- the same pattern the monitor, replay, trace and saved issue view embeds already use. Claude-Session: https://claude.ai/code/session_01DwM6SuBXUTSXciHNCM59iR --- .../conversationEmbedStory.spec.tsx | 106 ++++++++++++++++++ .../__stories__/conversationEmbedStory.tsx | 78 +++++++++++++ .../components/seer/markdown/seerMarkdown.mdx | 3 +- 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 static/app/components/seer/markdown/__stories__/conversationEmbedStory.spec.tsx create mode 100644 static/app/components/seer/markdown/__stories__/conversationEmbedStory.tsx diff --git a/static/app/components/seer/markdown/__stories__/conversationEmbedStory.spec.tsx b/static/app/components/seer/markdown/__stories__/conversationEmbedStory.spec.tsx new file mode 100644 index 000000000000..b7135e3176d8 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/conversationEmbedStory.spec.tsx @@ -0,0 +1,106 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import type {Conversation} from 'sentry/views/explore/conversations/hooks/useConversations'; + +import {ConversationEmbedStory} from './conversationEmbedStory'; + +jest.mock('sentry/components/seer/markdown', () => ({ + SeerMarkdown: ({raw}: {raw: string}) =>
{raw}
, +})); + +function createConversation(conversation: Partial): Conversation { + return { + conversationId: 'conv-1', + duration: 1000, + endTimestamp: Date.UTC(2026, 7, 25, 16, 39, 2), + errors: 0, + firstInput: 'Where is my refund?', + generationDuration: 800, + inputTokens: 100, + lastOutput: 'Escalating to a human.', + llmCalls: 3, + outputTokens: 50, + projectId: 11, + startTimestamp: Date.UTC(2026, 7, 25, 16, 37, 12), + title: null, + toolCalls: 1, + toolErrors: 0, + toolNames: ['lookup_order'], + totalCost: 0.02, + totalTokens: 150, + traceCount: 1, + traceIds: ['11111111111111111111111111111111'], + user: null, + ...conversation, + }; +} + +describe('ConversationEmbedStory', () => { + it('prefers a titled conversation and passes its ISO time bounds', async () => { + const untitled = createConversation({conversationId: 'untitled-conversation'}); + const titled = createConversation({ + conversationId: 'titled-conversation', + title: 'Refund request escalated to a human', + }); + const conversationsRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/agents/conversations/', + body: [untitled, titled], + match: [ + MockApiClient.matchQuery({ + project: [-1], + per_page: 25, + statsPeriod: '14d', + }), + ], + }); + + render(); + + const renderedMarkdown = await screen.findByLabelText('Rendered markdown'); + expect(renderedMarkdown).toHaveTextContent(titled.conversationId); + expect(renderedMarkdown).toHaveTextContent('Refund request escalated to a human'); + expect(renderedMarkdown).toHaveTextContent( + new Date(titled.startTimestamp).toISOString() + ); + expect(renderedMarkdown).toHaveTextContent( + new Date(titled.endTimestamp).toISOString() + ); + expect(renderedMarkdown).not.toHaveTextContent(untitled.conversationId); + expect(conversationsRequest).toHaveBeenCalled(); + }); + + it('falls back to the most recent conversation when none has a title', async () => { + const older = createConversation({ + conversationId: 'older-conversation', + endTimestamp: Date.UTC(2026, 7, 24, 10, 0, 0), + }); + const newer = createConversation({ + conversationId: 'newer-conversation', + endTimestamp: Date.UTC(2026, 7, 26, 10, 0, 0), + }); + // The endpoint orders by relevance, so the newest row is not necessarily first. + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/agents/conversations/', + body: [older, newer], + }); + + render(); + + const renderedMarkdown = await screen.findByLabelText('Rendered markdown'); + expect(renderedMarkdown).toHaveTextContent(newer.conversationId); + expect(renderedMarkdown).not.toHaveTextContent(older.conversationId); + }); + + it('renders a message when the organization has no conversations', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/agents/conversations/', + body: [], + }); + + render(); + + expect( + await screen.findByText('No conversation is available for this organization.') + ).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/__stories__/conversationEmbedStory.tsx b/static/app/components/seer/markdown/__stories__/conversationEmbedStory.tsx new file mode 100644 index 000000000000..5098837cad81 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/conversationEmbedStory.tsx @@ -0,0 +1,78 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type {Conversation} from 'sentry/views/explore/conversations/hooks/useConversations'; + +import {EmbedStory, EmbedVariant} from './embedStory'; + +/** Enough rows to find one with a title without paging the whole list. */ +const STORY_CONVERSATION_LIMIT = 25; + +/** + * The schema's `start`/`end` are ISO strings, but a conversation row carries + * epoch milliseconds. + */ +function toIsoTimestamp(timestamp: number | null | undefined): string | undefined { + return timestamp ? new Date(timestamp).toISOString() : undefined; +} + +/** + * `useConversations` reads its filters from the router, which a story has no + * business driving, so the list is fetched directly -- the same call the + * `conversationsQuery` block makes. + */ +export function ConversationEmbedStory() { + const organization = useOrganization(); + const {data, isError, isPending} = useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/agents/conversations/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + project: [ALL_ACCESS_PROJECTS], + per_page: STORY_CONVERSATION_LIMIT, + statsPeriod: '14d', + }, + staleTime: 30_000, + } + ), + retry: false, + }); + + // The endpoint orders by relevance rather than recency, and a titled + // conversation shows off the embed better than an untitled one. + const conversations = (data ?? []).toSorted((a, b) => b.endTimestamp - a.endTimestamp); + const conversation = conversations.find(row => row.title) ?? conversations[0]; + + return ( + + {isPending ? ( + + ) : isError ? ( + Unable to load a conversation example. + ) : conversation ? ( + + ) : ( + No conversation is available for this organization. + )} + + ); +} diff --git a/static/app/components/seer/markdown/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index 0808b043122c..7280e8c99e90 100644 --- a/static/app/components/seer/markdown/seerMarkdown.mdx +++ b/static/app/components/seer/markdown/seerMarkdown.mdx @@ -10,6 +10,7 @@ import * as Storybook from 'sentry/stories'; import {BasicDemo, LinkifyDemo, StreamingEmbedExamples} from './__stories__/components'; import {AlertEmbedStory} from './__stories__/alertEmbedStory'; +import {ConversationEmbedStory} from './__stories__/conversationEmbedStory'; import {DashboardEmbedStory} from './__stories__/dashboardEmbedStory'; import {EmbedStory} from './__stories__/embedStory'; import {MonitorEmbedStory} from './__stories__/monitorEmbedStory'; @@ -139,7 +140,7 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a ### conversation - + ### conversationsQuery From 76cc66436aa0ecb276723bbceeb9e48976b5ca2f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:27:56 -0400 Subject: [PATCH 08/10] ref(seer): show conversation totals only and link query rows Three changes to the conversation embeds: The conversation block dropped its transcript. The embed renders inside an agent conversation, so a nested transcript reads as part of the surrounding answer; the LLM call, token, cost and error totals say what the reader needs, and the link goes to the full detail view. Message selection went with it, so the block is no longer interactive at all. The conversationsQuery rows now link to their conversations, through the same `getConversationHref` the conversation embed uses. They open in a new tab so following one cannot replace the page holding the answer. Layout now matches the other embeds: `conversationsQuery` is flat in `components/` like every other query embed, `conversation/` keeps the single-resource embed like `monitor/` and `alert/`, each embed has its own colocated spec, and sibling imports are relative. Claude-Session: https://claude.ai/code/session_01DwM6SuBXUTSXciHNCM59iR --- .../conversation/conversation.spec.tsx | 347 +++++------------- .../components/conversation/conversation.tsx | 3 +- .../conversation/conversationBlock.tsx | 52 +-- .../conversation/conversationLink.tsx | 5 +- .../components/conversationsQuery.spec.tsx | 180 +++++++++ .../{conversation => }/conversationsQuery.tsx | 3 +- .../conversationsQueryBlock.tsx | 71 ++-- .../conversationsQueryLink.tsx | 0 .../components/seer/markdown/embeds/index.ts | 2 +- 9 files changed, 341 insertions(+), 322 deletions(-) create mode 100644 static/app/components/seer/markdown/embeds/components/conversationsQuery.spec.tsx rename static/app/components/seer/markdown/embeds/components/{conversation => }/conversationsQuery.tsx (80%) rename static/app/components/seer/markdown/embeds/components/{conversation => }/conversationsQueryBlock.tsx (77%) rename static/app/components/seer/markdown/embeds/components/{conversation => }/conversationsQueryLink.tsx (100%) diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx index 2cfbc723ad68..f2764a5c99a2 100644 --- a/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx @@ -1,4 +1,4 @@ -import {act, screen, userEvent, waitFor, within} from 'sentry-test/reactTestingLibrary'; +import {act, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import { @@ -7,7 +7,6 @@ import { } from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; const CONVERSATION_ID = 'conv-1'; -const LIST_URL = '/organizations/org-slug/agents/conversations/'; const DETAIL_URL = `/organizations/org-slug/agents/conversations/${CONVERSATION_ID}/`; /** Shaped as the conversation detail endpoint returns it: flat spans. */ @@ -24,45 +23,16 @@ function spanFixture(overrides: Record) { }; } -/** Shaped as the list endpoint returns it: `firstInput` may be content parts. */ -function conversationFixture(overrides: Record = {}) { - return { - conversationId: '8f0e1f6a-1f2b-4d3c-9e5a-0b1c2d3e4f5a', - title: 'Refund request escalated', - firstInput: [{type: 'text', text: 'I want a refund'}], - lastOutput: 'Escalating to a human', - duration: 120_000, - generationDuration: 4200, - llmCalls: 3, - toolCalls: 2, - toolErrors: 0, - toolNames: ['search'], - errors: 1, - inputTokens: 100, - outputTokens: 200, - totalTokens: 300, - totalCost: 0.42, - startTimestamp: 1_700_000_000_000, - endTimestamp: 1_700_000_120_000, - traceCount: 1, - traceIds: ['trace-1'], - projectId: 1, - user: {email: 'user@example.com', id: '1', ip_address: null, username: null}, - ...overrides, - }; -} - function searchParams(href: string) { return new URLSearchParams(href.split('?')[1] ?? ''); } -describe('Seer conversation embeds', () => { +describe('conversation embed', () => { beforeAll(async () => { - // Both blocks are behind `lazy()`. Compiling the transcript's module graph - // on first render costs more than a `findBy*` will wait for, so pay it - // here instead of inside the first block assertion. + // The block is behind `lazy()`. Compiling its module graph on first render + // costs more than a `findBy*` will wait for, so pay it here instead of + // inside the first block assertion. await import('./conversationBlock'); - await import('./conversationsQueryBlock'); }, 60_000); beforeEach(() => { @@ -73,244 +43,107 @@ describe('Seer conversation embeds', () => { }); }); - describe('conversation', () => { - it('links to the conversation detail view inline', () => { - const href = getEmbedLinkHref('conversation', 'Refund request escalated', { - id: CONVERSATION_ID, - title: 'Refund request escalated', - projects: ['1'], - start: '2026-08-25T16:37:12Z', - end: '2026-08-25T16:39:02Z', - }); - - expect(href.split('?')[0]).toBe( - `/organizations/org-slug/explore/agents/conversations/${CONVERSATION_ID}/` - ); - const params = searchParams(href); - // The detail view scopes its span query to this window, so it is padded - // an hour either side of the conversation's own timestamps. - expect(params.get('start')).toBe('2026-08-25T15:37:12.000Z'); - expect(params.get('end')).toBe('2026-08-25T17:39:02.000Z'); - expect(params.getAll('project')).toEqual(['1']); - expect(params.get('referrer')).toBe('seer-conversation-embed'); + it('links to the conversation detail view inline', () => { + const href = getEmbedLinkHref('conversation', 'Refund request escalated', { + id: CONVERSATION_ID, + title: 'Refund request escalated', + projects: ['1'], + start: '2026-08-25T16:37:12Z', + end: '2026-08-25T16:39:02Z', }); - it('falls back to the id when the tag carries no title', () => { - const href = getEmbedLinkHref('conversation', `Conversation ${CONVERSATION_ID}`, { - id: CONVERSATION_ID, - }); - - expect(href).toBe( - `/organizations/org-slug/explore/agents/conversations/${CONVERSATION_ID}/?referrer=seer-conversation-embed` - ); - }); - - it('renders the transcript and the aggregates', async () => { - const request = MockApiClient.addMockResponse({ - url: DETAIL_URL, - body: { - conversationId: CONVERSATION_ID, - title: 'Out of memory investigation', - spans: [ - spanFixture({ - span_id: 'span-a', - 'span.name': 'first turn', - 'precise.start_ts': 1000, - 'precise.finish_ts': 1000.5, - 'gen_ai.request.messages': JSON.stringify([ - {role: 'user', content: 'Why did the job fail?'}, - ]), - 'gen_ai.response.text': 'The worker ran out of memory.', - 'gen_ai.usage.total_tokens': 1200, - }), - ], - }, - }); - - renderEmbed({ - name: 'conversation', - data: { - id: CONVERSATION_ID, - title: 'Stale title', - start: '2026-08-25T16:37:12Z', - end: '2026-08-25T16:39:02Z', - }, - }); - - expect( - await screen.findByText('The worker ran out of memory.') - ).toBeInTheDocument(); - expect(screen.getByText('Why did the job fail?')).toBeInTheDocument(); - expect(screen.getByText('LLM Calls')).toBeInTheDocument(); - expect(screen.getByText('Tokens')).toBeInTheDocument(); - // The API title wins over whatever the model wrote into the tag. - expect( - screen.getByRole('link', {name: 'Out of memory investigation'}) - ).toBeInTheDocument(); - expect(screen.queryByText('Stale title')).not.toBeInTheDocument(); - - await waitFor(() => { - expect(request).toHaveBeenCalledWith( - DETAIL_URL, - expect.objectContaining({ - query: expect.objectContaining({ - // The tag's ISO timestamps scope the query instead of the - // default page-filter window. - start: '2026-08-25T15:37:12.000Z', - end: '2026-08-25T17:39:02.000Z', - }), - }) - ); - }); - }); - - it('keeps message selection inside the embed', async () => { - MockApiClient.addMockResponse({ - url: DETAIL_URL, - body: { - conversationId: CONVERSATION_ID, - title: null, - spans: [ - spanFixture({ - span_id: 'span-a', - 'span.name': 'first turn', - 'precise.start_ts': 1000, - 'precise.finish_ts': 1000.5, - 'gen_ai.request.messages': JSON.stringify([ - {role: 'user', content: 'Why did the job fail?'}, - ]), - 'gen_ai.response.text': 'The worker ran out of memory.', - }), - ], - }, - }); - - const {router} = renderEmbed({ - name: 'conversation', - data: {id: CONVERSATION_ID}, - }); - const initialLocation = router.location; - - await userEvent.click(await screen.findByText('The worker ran out of memory.')); + expect(href.split('?')[0]).toBe( + `/organizations/org-slug/explore/agents/conversations/${CONVERSATION_ID}/` + ); + const params = searchParams(href); + // The detail view scopes its span query to this window, so it is padded + // an hour either side of the conversation's own timestamps. + expect(params.get('start')).toBe('2026-08-25T15:37:12.000Z'); + expect(params.get('end')).toBe('2026-08-25T17:39:02.000Z'); + expect(params.getAll('project')).toEqual(['1']); + expect(params.get('referrer')).toBe('seer-conversation-embed'); + }); - // Selecting a message is embed-local state: it must not touch the host - // page's URL (see the embeds README). - expect(router.location.pathname).toBe(initialLocation.pathname); - expect(router.location.query).toEqual(initialLocation.query); + it('falls back to the id when the tag carries no title', () => { + const href = getEmbedLinkHref('conversation', `Conversation ${CONVERSATION_ID}`, { + id: CONVERSATION_ID, }); - it('shows an error when the conversation cannot be loaded', async () => { - MockApiClient.addMockResponse({url: DETAIL_URL, statusCode: 500, body: {}}); - - renderEmbed({name: 'conversation', data: {id: CONVERSATION_ID}}); - - expect(await screen.findByText('Unable to load conversation.')).toBeInTheDocument(); - }); + expect(href).toBe( + `/organizations/org-slug/explore/agents/conversations/${CONVERSATION_ID}/?referrer=seer-conversation-embed` + ); }); - describe('conversationsQuery', () => { - it('links to the agents list inline', () => { - const href = getEmbedLinkHref('conversationsQuery', 'Conversations using tools', { - query: 'gen_ai.tool.name:*', - statsPeriod: '24h', - projects: ['1', '2'], - environments: ['prod'], - agents: ['support-bot', 'triage-bot'], - title: 'Conversations using tools', - }); - - expect(href.split('?')[0]).toBe('/organizations/org-slug/explore/agents/'); - const params = searchParams(href); - expect(params.get('query')).toBe('gen_ai.tool.name:*'); - expect(params.get('statsPeriod')).toBe('24h'); - expect(params.getAll('project')).toEqual(['1', '2']); - expect(params.getAll('environment')).toEqual(['prod']); - // The list reads `agent` as one comma-separated value. - expect(params.get('agent')).toBe('support-bot,triage-bot'); - expect(params.get('referrer')).toBe('seer-conversations-query-embed'); + it('renders the aggregates but not the transcript', async () => { + const request = MockApiClient.addMockResponse({ + url: DETAIL_URL, + body: { + conversationId: CONVERSATION_ID, + title: 'Out of memory investigation', + spans: [ + spanFixture({ + span_id: 'span-a', + 'span.name': 'first turn', + 'precise.start_ts': 1000, + 'precise.finish_ts': 1000.5, + 'gen_ai.request.messages': JSON.stringify([ + {role: 'user', content: 'Why did the job fail?'}, + ]), + 'gen_ai.response.text': 'The worker ran out of memory.', + 'gen_ai.usage.total_tokens': 1200, + }), + ], + }, }); - it('falls back to a generic title', () => { - const href = getEmbedLinkHref('conversationsQuery', 'Conversation search', { - query: '', - }); - - expect(href).toContain('/organizations/org-slug/explore/agents/'); + renderEmbed({ + name: 'conversation', + data: { + id: CONVERSATION_ID, + title: 'Stale title', + start: '2026-08-25T16:37:12Z', + end: '2026-08-25T16:39:02Z', + }, }); - it('previews matching conversations', async () => { - const request = MockApiClient.addMockResponse({ - url: LIST_URL, - body: [ - conversationFixture({ - conversationId: 'older', - title: 'Older conversation', - endTimestamp: 1_600_000_000_000, + // The aggregates bar renders its labels while loading too, so the API + // title -- which only arrives with the response -- is the loaded signal. + // The API title also wins over whatever the model wrote into the tag. + expect( + await screen.findByRole('link', {name: 'Out of memory investigation'}) + ).toBeInTheDocument(); + expect(screen.queryByText('Stale title')).not.toBeInTheDocument(); + + expect(screen.getByText('LLM Calls')).toBeInTheDocument(); + expect(screen.getByText('Errors')).toBeInTheDocument(); + expect(screen.getByText('Tokens')).toBeInTheDocument(); + expect(screen.getByText('Cost')).toBeInTheDocument(); + + // The embed renders inside an agent conversation, so it deliberately shows + // the totals only -- a nested transcript reads as part of the answer. + expect(screen.queryByText('The worker ran out of memory.')).not.toBeInTheDocument(); + expect(screen.queryByText('Why did the job fail?')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + DETAIL_URL, + expect.objectContaining({ + query: expect.objectContaining({ + // The tag's ISO timestamps scope the query instead of the + // default page-filter window. + start: '2026-08-25T15:37:12.000Z', + end: '2026-08-25T17:39:02.000Z', }), - conversationFixture(), - ], - }); - - renderEmbed({ - name: 'conversationsQuery', - data: { - query: 'gen_ai.request.model:gpt-4o', - statsPeriod: '24h', - agents: ['support-bot'], - }, - }); - - expect(await screen.findByText('Refund request escalated')).toBeInTheDocument(); - - const row = screen.getByRole('row', {name: /Refund request escalated/}); - // A UUID id is shown as a short prefix. - expect(within(row).getByText('8f0e1f6a')).toBeInTheDocument(); - expect(within(row).getByText('user@example.com')).toBeInTheDocument(); - expect(within(row).getByText('3')).toBeInTheDocument(); - expect(within(row).getByText('1')).toBeInTheDocument(); - expect(within(row).getByText('$0.42')).toBeInTheDocument(); - - // Newest-first, the way the list view orders its rows. - const rows = screen.getAllByRole('row'); - expect(rows[1]).toHaveTextContent('Refund request escalated'); - expect(rows[2]).toHaveTextContent('Older conversation'); - - await waitFor(() => { - expect(request).toHaveBeenCalledWith( - LIST_URL, - expect.objectContaining({ - query: expect.objectContaining({ - // The agent filter is a URL param on the list view, but part of - // the span query when the endpoint is called directly. - query: expect.stringContaining('support-bot'), - statsPeriod: '24h', - per_page: 5, - }), - }) - ); - }); - expect(request.mock.calls[0][1].query.query).toContain( - 'gen_ai.request.model:gpt-4o' + }) ); }); + }); - it('flattens a content-part first input when there is no title', async () => { - MockApiClient.addMockResponse({ - url: LIST_URL, - body: [conversationFixture({title: null})], - }); - - renderEmbed({name: 'conversationsQuery', data: {query: ''}}); - - expect(await screen.findByText('I want a refund')).toBeInTheDocument(); - }); - - it('shows an empty state when nothing matches', async () => { - MockApiClient.addMockResponse({url: LIST_URL, body: []}); + it('shows an error when the conversation cannot be loaded', async () => { + MockApiClient.addMockResponse({url: DETAIL_URL, statusCode: 500, body: {}}); - renderEmbed({name: 'conversationsQuery', data: {query: 'gen_ai.tool.name:*'}}); + renderEmbed({name: 'conversation', data: {id: CONVERSATION_ID}}); - expect(await screen.findByText('No matching conversations')).toBeInTheDocument(); - }); + expect(await screen.findByText('Unable to load conversation.')).toBeInTheDocument(); }); }); diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx index 176321fe44a6..569e64aa6499 100644 --- a/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx @@ -1,9 +1,10 @@ import {lazy} from 'react'; import {LazyLoad} from 'sentry/components/lazyLoad'; -import {ConversationLink} from 'sentry/components/seer/markdown/embeds/components/conversation/conversationLink'; import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; +import {ConversationLink} from './conversationLink'; + const LazyConversationBlock = lazy(() => import('./conversationBlock')); export const Conversation = defineSeerEmbed({ diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx index b9dacbb5fa65..671127736960 100644 --- a/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx @@ -1,23 +1,11 @@ -import {useState} from 'react'; - -import {Container, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; -import { - ConversationLink, - type ConversationData, -} from 'sentry/components/seer/markdown/embeds/components/conversation/conversationLink'; import {QueryEmbedCard} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedCard'; import {t} from 'sentry/locale'; import {ConversationAggregatesBar} from 'sentry/views/explore/conversations/components/conversationSummary'; -import { - MessagesPanel, - MessagesPanelSkeleton, -} from 'sentry/views/explore/conversations/components/messagesPanel'; import {useConversation} from 'sentry/views/explore/conversations/hooks/useConversation'; -/** Keeps a long transcript from pushing the rest of the answer off screen. */ -const TRANSCRIPT_MAX_HEIGHT = '400px'; +import {ConversationLink, type ConversationData} from './conversationLink'; function toTimestampMs(isoTimestamp: string | undefined): number | undefined { if (!isoTimestamp) { @@ -27,11 +15,13 @@ function toTimestampMs(isoTimestamp: string | undefined): number | undefined { return Number.isNaN(parsed) ? undefined : parsed; } +/** + * Deliberately shows the conversation's totals and not its transcript: the + * embed is itself rendered inside an agent conversation, so a nested transcript + * reads as part of the surrounding answer. The link goes to the full detail + * view for anyone who wants the messages. + */ export default function ConversationBlock({data}: {data: ConversationData}) { - // Selection lives here rather than in the URL: an embed must not be able to - // change the host page's shareable state (see the embeds README). - const [selectedNodeId, setSelectedNodeId] = useState(null); - const {nodes, isLoading, error, title} = useConversation({ conversationId: data.id, startTimestamp: toTimestampMs(data.start), @@ -43,33 +33,17 @@ export default function ConversationBlock({data}: {data: ConversationData}) { link={} testId="seer-conversation-embed" > - + {error ? ( + {t('Unable to load conversation.')} + ) : !isLoading && nodes.length === 0 ? ( + {t('No messages in this conversation')} + ) : ( - {isLoading ? ( - - ) : error ? ( - {t('Unable to load conversation.')} - ) : nodes.length === 0 ? ( - {t('No messages in this conversation')} - ) : ( - - setSelectedNodeId(node.id)} - /> - - )} - + )} ); } diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx index 4270c3baaf43..3e5445bd2d1c 100644 --- a/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx @@ -25,7 +25,8 @@ const CONVERSATION_WINDOW_PADDING_MS = 60 * 60 * 1000; */ export function getConversationHref( data: ConversationData, - organizationSlug: string + organizationSlug: string, + referrer = 'seer-conversation-embed' ): string { const basePath = `/organizations/${organizationSlug}/explore/${EXPLORE_AGENTS_SUB_PATH}/${CONVERSATIONS_DETAIL_SUB_PATH}/${encodeURIComponent(data.id)}/`; @@ -45,7 +46,7 @@ export function getConversationHref( for (const project of data.projects ?? []) { params.append('project', String(project)); } - params.set('referrer', 'seer-conversation-embed'); + params.set('referrer', referrer); return normalizeUrl(`${basePath}?${params.toString()}`); } diff --git a/static/app/components/seer/markdown/embeds/components/conversationsQuery.spec.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQuery.spec.tsx new file mode 100644 index 000000000000..bb016ba8708f --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversationsQuery.spec.tsx @@ -0,0 +1,180 @@ +import {act, screen, waitFor, within} from 'sentry-test/reactTestingLibrary'; + +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import { + getEmbedLinkHref, + renderEmbed, +} from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; + +const LIST_URL = '/organizations/org-slug/agents/conversations/'; + +/** Shaped as the list endpoint returns it: `firstInput` may be content parts. */ +function conversationFixture(overrides: Record = {}) { + return { + conversationId: '8f0e1f6a-1f2b-4d3c-9e5a-0b1c2d3e4f5a', + title: 'Refund request escalated', + firstInput: [{type: 'text', text: 'I want a refund'}], + lastOutput: 'Escalating to a human', + duration: 120_000, + generationDuration: 4200, + llmCalls: 3, + toolCalls: 2, + toolErrors: 0, + toolNames: ['search'], + errors: 1, + inputTokens: 100, + outputTokens: 200, + totalTokens: 300, + totalCost: 0.42, + startTimestamp: 1_700_000_000_000, + endTimestamp: 1_700_000_120_000, + traceCount: 1, + traceIds: ['trace-1'], + projectId: 1, + user: {email: 'user@example.com', id: '1', ip_address: null, username: null}, + ...overrides, + }; +} + +function searchParams(href: string) { + return new URLSearchParams(href.split('?')[1] ?? ''); +} + +describe('conversationsQuery embed', () => { + beforeAll(async () => { + // The block is behind `lazy()`; compile its module graph up front rather + // than inside the first `findBy*`. + await import('./conversationsQueryBlock'); + }, 60_000); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + act(() => { + PageFiltersStore.reset(); + PageFiltersStore.init(); + }); + }); + + it('links to the agents list inline', () => { + const href = getEmbedLinkHref('conversationsQuery', 'Conversations using tools', { + query: 'gen_ai.tool.name:*', + statsPeriod: '24h', + projects: ['1', '2'], + environments: ['prod'], + agents: ['support-bot', 'triage-bot'], + title: 'Conversations using tools', + }); + + expect(href.split('?')[0]).toBe('/organizations/org-slug/explore/agents/'); + const params = searchParams(href); + expect(params.get('query')).toBe('gen_ai.tool.name:*'); + expect(params.get('statsPeriod')).toBe('24h'); + expect(params.getAll('project')).toEqual(['1', '2']); + expect(params.getAll('environment')).toEqual(['prod']); + // The list reads `agent` as one comma-separated value. + expect(params.get('agent')).toBe('support-bot,triage-bot'); + expect(params.get('referrer')).toBe('seer-conversations-query-embed'); + }); + + it('falls back to a generic title', () => { + const href = getEmbedLinkHref('conversationsQuery', 'Conversation search', { + query: '', + }); + + expect(href).toContain('/organizations/org-slug/explore/agents/'); + }); + + it('previews matching conversations', async () => { + const request = MockApiClient.addMockResponse({ + url: LIST_URL, + body: [ + conversationFixture({ + conversationId: 'older', + title: 'Older conversation', + endTimestamp: 1_600_000_000_000, + }), + conversationFixture(), + ], + }); + + renderEmbed({ + name: 'conversationsQuery', + data: { + query: 'gen_ai.request.model:gpt-4o', + statsPeriod: '24h', + agents: ['support-bot'], + }, + }); + + expect(await screen.findByText('Refund request escalated')).toBeInTheDocument(); + + const row = screen.getByRole('row', {name: /Refund request escalated/}); + // A UUID id is shown as a short prefix. + expect(within(row).getByText('8f0e1f6a')).toBeInTheDocument(); + expect(within(row).getByText('user@example.com')).toBeInTheDocument(); + expect(within(row).getByText('3')).toBeInTheDocument(); + expect(within(row).getByText('1')).toBeInTheDocument(); + expect(within(row).getByText('$0.42')).toBeInTheDocument(); + + // Newest-first, the way the list view orders its rows. + const rows = screen.getAllByRole('row'); + expect(rows[1]).toHaveTextContent('Refund request escalated'); + expect(rows[2]).toHaveTextContent('Older conversation'); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + LIST_URL, + expect.objectContaining({ + query: expect.objectContaining({ + // The agent filter is a URL param on the list view, but part of + // the span query when the endpoint is called directly. + query: expect.stringContaining('support-bot'), + statsPeriod: '24h', + per_page: 5, + }), + }) + ); + }); + expect(request.mock.calls[0][1].query.query).toContain('gen_ai.request.model:gpt-4o'); + }); + + it('links each row to its conversation in a new tab', async () => { + MockApiClient.addMockResponse({url: LIST_URL, body: [conversationFixture()]}); + + renderEmbed({name: 'conversationsQuery', data: {query: ''}}); + + const link = await screen.findByRole('link', {name: 'Refund request escalated'}); + + // A new tab keeps the answer the embed is rendered into on screen. + expect(link).toHaveAttribute('target', '_blank'); + const href = link.getAttribute('href') ?? ''; + expect(href.split('?')[0]).toBe( + '/organizations/org-slug/explore/agents/conversations/8f0e1f6a-1f2b-4d3c-9e5a-0b1c2d3e4f5a/' + ); + const params = searchParams(href); + // The row's own timestamps, padded the same hour the detail view expects. + expect(params.get('start')).toBe('2023-11-14T21:13:20.000Z'); + expect(params.get('end')).toBe('2023-11-14T23:15:20.000Z'); + expect(params.getAll('project')).toEqual(['1']); + expect(params.get('referrer')).toBe('seer-conversations-query-embed'); + }); + + it('flattens a content-part first input when there is no title', async () => { + MockApiClient.addMockResponse({ + url: LIST_URL, + body: [conversationFixture({title: null})], + }); + + renderEmbed({name: 'conversationsQuery', data: {query: ''}}); + + expect(await screen.findByText('I want a refund')).toBeInTheDocument(); + }); + + it('shows an empty state when nothing matches', async () => { + MockApiClient.addMockResponse({url: LIST_URL, body: []}); + + renderEmbed({name: 'conversationsQuery', data: {query: 'gen_ai.tool.name:*'}}); + + expect(await screen.findByText('No matching conversations')).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationsQuery.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQuery.tsx similarity index 80% rename from static/app/components/seer/markdown/embeds/components/conversation/conversationsQuery.tsx rename to static/app/components/seer/markdown/embeds/components/conversationsQuery.tsx index 7ecab0234fe0..45583d2d403b 100644 --- a/static/app/components/seer/markdown/embeds/components/conversation/conversationsQuery.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversationsQuery.tsx @@ -1,9 +1,10 @@ import {lazy} from 'react'; import {LazyLoad} from 'sentry/components/lazyLoad'; -import {ConversationsQueryLink} from 'sentry/components/seer/markdown/embeds/components/conversation/conversationsQueryLink'; import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; +import {ConversationsQueryLink} from './conversationsQueryLink'; + const LazyConversationsQueryBlock = lazy(() => import('./conversationsQueryBlock')); export const ConversationsQuery = defineSeerEmbed({ diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQueryBlock.tsx similarity index 77% rename from static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryBlock.tsx rename to static/app/components/seer/markdown/embeds/components/conversationsQueryBlock.tsx index c135f447b95f..d76401e26775 100644 --- a/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversationsQueryBlock.tsx @@ -1,6 +1,7 @@ import {useQuery} from '@tanstack/react-query'; import {Flex, Stack} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; import {Count} from 'sentry/components/count'; @@ -25,6 +26,7 @@ import type { } from 'sentry/views/explore/conversations/hooks/useConversations'; import {LLMCosts} from 'sentry/views/insights/pages/agents/components/llmCosts'; +import {getConversationHref} from './conversation/conversationLink'; import { combineAgentQuery, ConversationsQueryLink, @@ -73,37 +75,64 @@ function getUserLabel(user: ConversationUser | null): string | null { return fields.find(value => value && value.toLowerCase() !== 'none') ?? null; } +/** A row carries epoch milliseconds; the detail URL wants ISO timestamps. */ +function toIsoTimestamp(timestamp: number | null | undefined): string | undefined { + return timestamp ? new Date(timestamp).toISOString() : undefined; +} + function getConversationIdLabel(conversationId: string): string { // UUIDs are long and opaque, so show a short prefix; other id formats // (e.g. `resp_...`, `slack:1234`) are already short enough. return isUUID(conversationId) ? conversationId.slice(0, 8) : conversationId; } +/** + * Opens in a new tab so following a row cannot replace the page the embed is + * rendered into -- the answer above it would be lost. `getConversationHref` + * builds the same detail URL the `conversation` embed links to. + */ +function ConversationCell({row}: {row: ConversationApiRow}) { + const organization = useOrganization(); + const label = getConversationLabel(row); + const user = getUserLabel(row.user); + + return ( + + + + {label ?? t('Untitled conversation')} + + + + + {getConversationIdLabel(row.conversationId)} + + {user ? ( + + {user} + + ) : null} + + + ); +} + const COLUMNS: Array> = [ { key: 'conversation', label: t('Conversation'), - render: row => { - const label = getConversationLabel(row); - const user = getUserLabel(row.user); - return ( - - - {label ?? {t('Untitled conversation')}} - - - - {getConversationIdLabel(row.conversationId)} - - {user ? ( - - {user} - - ) : null} - - - ); - }, + render: row => , }, { key: 'duration', diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryLink.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx similarity index 100% rename from static/app/components/seer/markdown/embeds/components/conversation/conversationsQueryLink.tsx rename to static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx diff --git a/static/app/components/seer/markdown/embeds/index.ts b/static/app/components/seer/markdown/embeds/index.ts index 68bf4c86f568..f54556af2dd1 100644 --- a/static/app/components/seer/markdown/embeds/index.ts +++ b/static/app/components/seer/markdown/embeds/index.ts @@ -3,7 +3,7 @@ import {Alert} from './components/alert/alert'; import {Autofix, AutofixRef} from './components/autofix'; import {Chart} from './components/chart'; import {Conversation} from './components/conversation/conversation'; -import {ConversationsQuery} from './components/conversation/conversationsQuery'; +import {ConversationsQuery} from './components/conversationsQuery'; import {Dashboard} from './components/dashboard'; import {Docs} from './components/docs'; import {Dsn} from './components/dsn'; From 21b91f928eb813c9e470770d684e8d3a7e1a1a1f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:39:15 -0400 Subject: [PATCH 09/10] ref(seer): stop exporting the conversations query href builder knip flagged it as an unused export: only `ConversationsQueryLink`, in the same file, calls it. `getConversationHref` stays exported -- the query block imports it for its row links. Claude-Session: https://claude.ai/code/session_01DwM6SuBXUTSXciHNCM59iR --- .../seer/markdown/embeds/components/conversationsQueryLink.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx index 3c6cb1dfe2aa..210a96be6a65 100644 --- a/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx @@ -29,7 +29,7 @@ export function combineAgentQuery(query: string, agents?: string[]): string { return `(${agentQuery}) and (${query})`; } -export function getConversationsQueryHref( +function getConversationsQueryHref( data: ConversationsQueryData, organization: Organization ): string { From d7215629870e09ccd3b769a9144a51bc34881285 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 12:41:47 -0400 Subject: [PATCH 10/10] fix(seer): scope the conversation embed's fetch to its own projects `useConversation` reads its project filter from `usePageFilters()`, which is right for the conversations route and wrong for an embed: the block passed the tag's `projects` to the link but not to the fetch, so a conversation outside the host page's selected projects came back with no spans and rendered the empty state. Add an optional `projects` to the hook's options, overriding the page filters when given. Every existing caller omits it and keeps the previous behaviour. Claude-Session: https://claude.ai/code/session_01DwM6SuBXUTSXciHNCM59iR --- .../conversation/conversation.spec.tsx | 26 +++++++++++++++++++ .../conversation/conversationBlock.tsx | 15 +++++++++++ .../conversations/hooks/useConversation.tsx | 11 ++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx index f2764a5c99a2..bbd226047bb1 100644 --- a/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx @@ -139,6 +139,32 @@ describe('conversation embed', () => { }); }); + it("scopes the fetch to the tag's projects, not the host page's", async () => { + const request = MockApiClient.addMockResponse({ + url: DETAIL_URL, + body: {conversationId: CONVERSATION_ID, title: 'Scoped', spans: []}, + }); + + // The page the embed is rendered into is filtered to a different project. + act(() => { + PageFiltersStore.updateProjects([99], null); + }); + + renderEmbed({ + name: 'conversation', + data: {id: CONVERSATION_ID, projects: ['7']}, + }); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + DETAIL_URL, + expect.objectContaining({ + query: expect.objectContaining({project: [7]}), + }) + ); + }); + }); + it('shows an error when the conversation cannot be loaded', async () => { MockApiClient.addMockResponse({url: DETAIL_URL, statusCode: 500, body: {}}); diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx index 671127736960..2ba9c29648a6 100644 --- a/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx @@ -15,6 +15,20 @@ function toTimestampMs(isoTimestamp: string | undefined): number | undefined { return Number.isNaN(parsed) ? undefined : parsed; } +/** + * Scopes the fetch to the conversation's own projects rather than the host + * page's filters, which have nothing to do with the conversation the tag names. + * The ids come from the model, so anything non-numeric is dropped instead of + * being sent as `NaN`. + */ +function toProjectIds(projects: ConversationData['projects']): number[] | undefined { + if (!projects?.length) { + return undefined; + } + const ids = projects.map(Number).filter(id => Number.isFinite(id)); + return ids.length > 0 ? ids : undefined; +} + /** * Deliberately shows the conversation's totals and not its transcript: the * embed is itself rendered inside an agent conversation, so a nested transcript @@ -24,6 +38,7 @@ function toTimestampMs(isoTimestamp: string | undefined): number | undefined { export default function ConversationBlock({data}: {data: ConversationData}) { const {nodes, isLoading, error, title} = useConversation({ conversationId: data.id, + projects: toProjectIds(data.projects), startTimestamp: toTimestampMs(data.start), endTimestamp: toTimestampMs(data.end), }); diff --git a/static/app/views/explore/conversations/hooks/useConversation.tsx b/static/app/views/explore/conversations/hooks/useConversation.tsx index 9459f263d2df..a565adf0d13e 100644 --- a/static/app/views/explore/conversations/hooks/useConversation.tsx +++ b/static/app/views/explore/conversations/hooks/useConversation.tsx @@ -19,6 +19,13 @@ import type {TraceTree} from 'sentry/views/performance/newTraceDetails/traceMode export interface UseConversationsOptions { conversationId: string; endTimestamp?: number; + /** + * Projects to scope the span query to, overriding the page filters. A caller + * that is not the conversations route -- an embed rendered into some other + * page -- knows the conversation's own project and must not inherit whatever + * the host page happens to have selected. + */ + projects?: number[]; startTimestamp?: number; } @@ -314,8 +321,8 @@ export function useConversation( ? normalizeDateTimeParams(selection.datetime) : {}; - const project = - selection.projects.length > 0 ? selection.projects : [ALL_ACCESS_PROJECTS]; + const selectedProjects = conversation.projects ?? selection.projects; + const project = selectedProjects.length > 0 ? selectedProjects : [ALL_ACCESS_PROJECTS]; const queryParams = { project,