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.",
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/embeds/components/conversation/conversation.spec.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx
new file mode 100644
index 000000000000..bbd226047bb1
--- /dev/null
+++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx
@@ -0,0 +1,175 @@
+import {act, screen, waitFor} 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 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,
+ };
+}
+
+function searchParams(href: string) {
+ return new URLSearchParams(href.split('?')[1] ?? '');
+}
+
+describe('conversation embed', () => {
+ beforeAll(async () => {
+ // 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');
+ }, 60_000);
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ act(() => {
+ PageFiltersStore.reset();
+ PageFiltersStore.init();
+ });
+ });
+
+ 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 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,
+ }),
+ ],
+ },
+ });
+
+ renderEmbed({
+ name: 'conversation',
+ data: {
+ id: CONVERSATION_ID,
+ title: 'Stale title',
+ start: '2026-08-25T16:37:12Z',
+ end: '2026-08-25T16:39:02Z',
+ },
+ });
+
+ // 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',
+ }),
+ })
+ );
+ });
+ });
+
+ 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: {}});
+
+ renderEmbed({name: 'conversation', data: {id: CONVERSATION_ID}});
+
+ 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
new file mode 100644
index 000000000000..569e64aa6499
--- /dev/null
+++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx
@@ -0,0 +1,18 @@
+import {lazy} from 'react';
+
+import {LazyLoad} from 'sentry/components/lazyLoad';
+import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils';
+
+import {ConversationLink} from './conversationLink';
+
+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/conversationBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx
new file mode 100644
index 000000000000..2ba9c29648a6
--- /dev/null
+++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx
@@ -0,0 +1,64 @@
+import {Text} from '@sentry/scraps/text';
+
+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 {useConversation} from 'sentry/views/explore/conversations/hooks/useConversation';
+
+import {ConversationLink, type ConversationData} from './conversationLink';
+
+function toTimestampMs(isoTimestamp: string | undefined): number | undefined {
+ if (!isoTimestamp) {
+ return undefined;
+ }
+ const parsed = Date.parse(isoTimestamp);
+ 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
+ * 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}) {
+ const {nodes, isLoading, error, title} = useConversation({
+ conversationId: data.id,
+ projects: toProjectIds(data.projects),
+ startTimestamp: toTimestampMs(data.start),
+ endTimestamp: toTimestampMs(data.end),
+ });
+
+ return (
+ }
+ testId="seer-conversation-embed"
+ >
+ {error ? (
+ {t('Unable to load conversation.')}
+ ) : !isLoading && nodes.length === 0 ? (
+ {t('No messages in this conversation')}
+ ) : (
+
+ )}
+
+ );
+}
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..3e5445bd2d1c
--- /dev/null
+++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx
@@ -0,0 +1,73 @@
+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,
+ referrer = 'seer-conversation-embed'
+): 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', referrer);
+
+ 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/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/conversationsQuery.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQuery.tsx
new file mode 100644
index 000000000000..45583d2d403b
--- /dev/null
+++ b/static/app/components/seer/markdown/embeds/components/conversationsQuery.tsx
@@ -0,0 +1,18 @@
+import {lazy} from 'react';
+
+import {LazyLoad} from 'sentry/components/lazyLoad';
+import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils';
+
+import {ConversationsQueryLink} from './conversationsQueryLink';
+
+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/conversationsQueryBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQueryBlock.tsx
new file mode 100644
index 000000000000..d76401e26775
--- /dev/null
+++ b/static/app/components/seer/markdown/embeds/components/conversationsQueryBlock.tsx
@@ -0,0 +1,222 @@
+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';
+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 {getConversationHref} from './conversation/conversationLink';
+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;
+}
+
+/** 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 => ,
+ },
+ {
+ 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}
+ />
+
+ );
+}
diff --git a/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx
new file mode 100644
index 000000000000..210a96be6a65
--- /dev/null
+++ b/static/app/components/seer/markdown/embeds/components/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})`;
+}
+
+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 (
+
+ );
+}
diff --git a/static/app/components/seer/markdown/embeds/index.ts b/static/app/components/seer/markdown/embeds/index.ts
index ca31b55e1875..f54556af2dd1 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/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/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. ' +
diff --git a/static/app/components/seer/markdown/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx
index 76226ee2ccf8..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';
@@ -137,6 +138,14 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a
+### conversation
+
+
+
+### conversationsQuery
+
+
+
## Embed Architecture
The embed system has four parts:
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,