From 2af0cdf8996a8255afdfcdbf1e1e189722e8f467 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 8 Apr 2026 13:33:13 -0400 Subject: [PATCH 1/5] feat(dashboards): Support dashboard editing via Seer chat session Update useSeerDashboardSession to accept an optional dashboard prop. When provided without a seerRunId, the first sendFollowUpMessage call starts a new Seer session via the dashboard generate endpoint with the current dashboard as editing context. Add DashboardEditSeerChat component and wire it into the dashboard detail edit view. --- .../views/dashboards/dashboardChatPanel.tsx | 1 - .../dashboards/dashboardEditSeerChat.tsx | 61 +++++++++++++ static/app/views/dashboards/detail.tsx | 24 ++++++ .../useSeerDashboardSession.spec.tsx | 86 +++++++++++++++++++ .../dashboards/useSeerDashboardSession.tsx | 72 +++++++++++++--- 5 files changed, 229 insertions(+), 15 deletions(-) create mode 100644 static/app/views/dashboards/dashboardEditSeerChat.tsx diff --git a/static/app/views/dashboards/dashboardChatPanel.tsx b/static/app/views/dashboards/dashboardChatPanel.tsx index 6de1b06e2ad36e..74dd88cce94632 100644 --- a/static/app/views/dashboards/dashboardChatPanel.tsx +++ b/static/app/views/dashboards/dashboardChatPanel.tsx @@ -135,7 +135,6 @@ export function DashboardChatPanel({ /> )} - {!hasHistory && } ) => void; +} + +export function DashboardEditSeerChat({ + dashboard, + onDashboardUpdate, +}: DashboardEditSeerChatProps) { + const organization = useOrganization(); + const widgetErrorsMap = useRef(new Map()); + + const hasFeature = + organization.features.includes('dashboards-edit') && + organization.features.includes('dashboards-ai-generate'); + + const handleDashboardUpdate = useCallback( + (data: {title: string; widgets: Widget[]}) => { + widgetErrorsMap.current.clear(); + onDashboardUpdate({title: data.title, widgets: data.widgets}); + }, + [onDashboardUpdate] + ); + + const {session, isUpdating, isError, sendFollowUpMessage} = useSeerDashboardSession({ + dashboard: {title: dashboard.title, widgets: dashboard.widgets}, + onDashboardUpdate: handleDashboardUpdate, + enabled: hasFeature, + }); + + if (!hasFeature) { + return null; + } + + const widgetErrors: WidgetError[] = dashboard.widgets.flatMap(widget => { + if (widget.tempId === undefined) { + return []; + } + const error = widgetErrorsMap.current.get(widget.tempId); + return error ? [error] : []; + }); + + return ( + + ); +} diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index b89a2e53a758e4..af449adafa5215 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -87,6 +87,7 @@ import {DiscoverQueryPageSource} from 'sentry/views/performance/utils'; import {PrebuiltDashboardOnboardingGate} from './components/prebuiltDashboardOnboardingGate'; import {Controls} from './controls'; import {Dashboard} from './dashboard'; +import {DashboardEditSeerChat} from './dashboardEditSeerChat'; import {DEFAULT_STATS_PERIOD} from './data'; import {FiltersBar} from './filtersBar'; import { @@ -960,6 +961,20 @@ class DashboardDetail extends Component { }); }; + handleSeerDashboardUpdate = ({title, widgets}: Pick) => { + this.setState(state => { + const dashboard = cloneDashboard(state.modifiedDashboard ?? this.props.dashboard); + return { + widgetLimitReached: widgets.length >= MAX_WIDGETS, + modifiedDashboard: { + ...dashboard, + widgets, + ...(title === undefined ? {} : {title}), + }, + }; + }); + }; + handleUpdateEditStateWidgets = (widgets: Widget[]) => { this.setState(state => { const modifiedDashboard = { @@ -1311,6 +1326,15 @@ class DashboardDetail extends Component { dashboard={modifiedDashboard ?? dashboard} onSave={this.handleSaveWidget} /> + {dashboardState === DashboardState.EDIT && + organization.features.includes( + 'dashboards-ai-generate' + ) && ( + + )} )} diff --git a/static/app/views/dashboards/useSeerDashboardSession.spec.tsx b/static/app/views/dashboards/useSeerDashboardSession.spec.tsx index ca65fa166012f8..1e1a22180af5d0 100644 --- a/static/app/views/dashboards/useSeerDashboardSession.spec.tsx +++ b/static/app/views/dashboards/useSeerDashboardSession.spec.tsx @@ -1,6 +1,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; +import { DisplayType } from 'sentry/views/dashboards/types'; import {useSeerDashboardSession} from 'sentry/views/dashboards/useSeerDashboardSession'; @@ -119,4 +120,89 @@ describe('useSeerDashboardSession', () => { }) ); }); + + it('starts a new session via the generate endpoint when dashboard is provided without seerRunId', async () => { + const dashboard = { + title: 'My Dashboard', + widgets: [ + { + title: 'Count', + displayType: DisplayType.LINE, + interval: '1h', + queries: [ + { + name: '', + conditions: '', + fields: ['count()'], + columns: [], + aggregates: ['count()'], + orderby: '', + }, + ], + }, + ], + }; + + const generateMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/dashboards/generate/`, + method: 'POST', + body: {run_id: '789'}, + }); + + MockApiClient.addMockResponse({ + url: makeSeerApiUrl(organization.slug, 789), + body: {session: {run_id: 789, status: 'processing', updated_at: '2026-01-01T00:00:00Z', blocks: []}}, + }); + + const onDashboardUpdate = jest.fn(); + + const {result} = renderHookWithProviders( + () => + useSeerDashboardSession({ + dashboard, + onDashboardUpdate, + }), + {organization} + ); + + await act(async () => { + await result.current.sendFollowUpMessage('Add me another widget'); + }); + + expect(generateMock).toHaveBeenCalledWith( + `/organizations/${organization.slug}/dashboards/generate/`, + expect.objectContaining({ + method: 'POST', + data: { + prompt: 'Add a latency widget', + current_dashboard: { + title: 'My Dashboard', + widgets: dashboard.widgets, + }, + }, + }) + ); + + await waitFor(() => { + expect(result.current.session).toBeDefined(); + }); + }); + + it('does nothing when sendFollowUpMessage is called without seerRunId or dashboard', async () => { + const onDashboardUpdate = jest.fn(); + + const {result} = renderHookWithProviders( + () => + useSeerDashboardSession({ + onDashboardUpdate, + }), + {organization} + ); + + await act(async () => { + await result.current.sendFollowUpMessage('Add me another widget'); + }); + + expect(result.current.isUpdating).toBe(false); + }); }); diff --git a/static/app/views/dashboards/useSeerDashboardSession.tsx b/static/app/views/dashboards/useSeerDashboardSession.tsx index 0e34d7c2b14d62..d10cb55b47f686 100644 --- a/static/app/views/dashboards/useSeerDashboardSession.tsx +++ b/static/app/views/dashboards/useSeerDashboardSession.tsx @@ -3,22 +3,46 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {t} from 'sentry/locale'; import {parseQueryKey} from 'sentry/utils/api/apiQueryKey'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {fetchMutation, useApiQuery, useQueryClient} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import type {SeerExplorerResponse} from 'sentry/views/seerExplorer/hooks/useSeerExplorer'; import {makeSeerExplorerQueryKey} from 'sentry/views/seerExplorer/utils'; import {extractDashboardFromSession, statusIsTerminal} from './createFromSeerUtils'; -import type {Widget} from './types'; +import type {DashboardDetails, Widget} from './types'; const POLL_INTERVAL_MS = 500; const POST_COMPLETE_POLL_MS = 5000; +async function startDashboardEditSession( + orgSlug: string, + message: string, + dashboard: Pick +): Promise { + const url = getApiUrl('/organizations/$organizationIdOrSlug/dashboards/generate/', { + path: {organizationIdOrSlug: orgSlug}, + }); + const response = await fetchMutation<{run_id: string}>({ + url, + method: 'POST', + data: { + prompt: message, + current_dashboard: { + title: dashboard.title, + widgets: dashboard.widgets, + }, + }, + }); + return Number(response.run_id); +} + interface UseSeerDashboardSessionOptions { onDashboardUpdate: (data: {title: string; widgets: Widget[]}) => void; - seerRunId: number | null; + dashboard?: Pick; enabled?: boolean; onPostCompletePollEnd?: () => void; + seerRunId?: number | null; } interface UseSeerDashboardSessionResult { @@ -34,7 +58,8 @@ interface UseSeerDashboardSessionResult { * detecting terminal-state transitions, and sending follow-up messages. */ export function useSeerDashboardSession({ - seerRunId, + seerRunId: externalSeerRunId, + dashboard, onDashboardUpdate, enabled = true, onPostCompletePollEnd, @@ -42,6 +67,9 @@ export function useSeerDashboardSession({ const organization = useOrganization(); const queryClient = useQueryClient(); + const [internalRunId, setInternalRunId] = useState(null); + const seerRunId = externalSeerRunId ?? internalRunId; + const [isUpdating, setIsUpdating] = useState(false); const prevSessionStatusRef = useRef<{ @@ -106,26 +134,42 @@ export function useSeerDashboardSession({ const sendFollowUpMessage = useCallback( async (message: string) => { - if (!seerRunId) { + if (!seerRunId && !dashboard) { return; } setIsUpdating(true); completedAtRef.current = null; + const errorMessage = t('Failed to send message'); try { - const queryKey = makeSeerExplorerQueryKey(organization.slug, seerRunId); - const {url} = parseQueryKey(queryKey); - await fetchMutation({ - url, - method: 'POST', - data: {query: message}, - }); - queryClient.invalidateQueries({queryKey}); + if (!seerRunId && dashboard) { + // No session exists yet and an initial dashboard is provided, start a new Seer session + const runId = await startDashboardEditSession( + organization.slug, + message, + dashboard + ); + if (!runId) { + throw new Error('Failed to start dashboard editing session'); + } + setInternalRunId(runId); + } else { + // A session exists, send the message to the existing session + const queryKey = makeSeerExplorerQueryKey(organization.slug, seerRunId); + const {url} = parseQueryKey(queryKey); + await fetchMutation({ + url, + method: 'POST', + data: {query: message}, + }); + queryClient.invalidateQueries({queryKey}); + } } catch { setIsUpdating(false); - addErrorMessage(t('Failed to send message')); + addErrorMessage(errorMessage); } + }, - [organization.slug, queryClient, seerRunId] + [organization.slug, queryClient, seerRunId, dashboard] ); return { From 9f253d3374e4c2b0eec62f40178e9db8afbe025c Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 8 Apr 2026 13:41:36 -0400 Subject: [PATCH 2/5] formatting --- static/app/views/dashboards/detail.tsx | 5 ++++- .../views/dashboards/useSeerDashboardSession.spec.tsx | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index af449adafa5215..8d443a00d42eb8 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -961,7 +961,10 @@ class DashboardDetail extends Component { }); }; - handleSeerDashboardUpdate = ({title, widgets}: Pick) => { + handleSeerDashboardUpdate = ({ + title, + widgets, + }: Pick) => { this.setState(state => { const dashboard = cloneDashboard(state.modifiedDashboard ?? this.props.dashboard); return { diff --git a/static/app/views/dashboards/useSeerDashboardSession.spec.tsx b/static/app/views/dashboards/useSeerDashboardSession.spec.tsx index 1e1a22180af5d0..51a099f19f7dc8 100644 --- a/static/app/views/dashboards/useSeerDashboardSession.spec.tsx +++ b/static/app/views/dashboards/useSeerDashboardSession.spec.tsx @@ -1,8 +1,8 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; -import { DisplayType } from 'sentry/views/dashboards/types'; +import {DisplayType} from 'sentry/views/dashboards/types'; import {useSeerDashboardSession} from 'sentry/views/dashboards/useSeerDashboardSession'; const SEER_RUN_ID = 456; @@ -151,7 +151,14 @@ describe('useSeerDashboardSession', () => { MockApiClient.addMockResponse({ url: makeSeerApiUrl(organization.slug, 789), - body: {session: {run_id: 789, status: 'processing', updated_at: '2026-01-01T00:00:00Z', blocks: []}}, + body: { + session: { + run_id: 789, + status: 'processing', + updated_at: '2026-01-01T00:00:00Z', + blocks: [], + }, + }, }); const onDashboardUpdate = jest.fn(); From cd4b740d49428f63f4716b12d6ddf76403eae182 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 8 Apr 2026 13:42:34 -0400 Subject: [PATCH 3/5] flag check --- static/app/views/dashboards/detail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index 8d443a00d42eb8..6678bedcb28c29 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -1331,7 +1331,7 @@ class DashboardDetail extends Component { /> {dashboardState === DashboardState.EDIT && organization.features.includes( - 'dashboards-ai-generate' + 'dashboards-ai-generate-edit' ) && ( Date: Wed, 8 Apr 2026 13:54:34 -0400 Subject: [PATCH 4/5] style(dashboards): Fix formatting in useSeerDashboardSession --- static/app/views/dashboards/useSeerDashboardSession.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/static/app/views/dashboards/useSeerDashboardSession.tsx b/static/app/views/dashboards/useSeerDashboardSession.tsx index d10cb55b47f686..067d16905ce0ae 100644 --- a/static/app/views/dashboards/useSeerDashboardSession.tsx +++ b/static/app/views/dashboards/useSeerDashboardSession.tsx @@ -142,7 +142,7 @@ export function useSeerDashboardSession({ const errorMessage = t('Failed to send message'); try { if (!seerRunId && dashboard) { - // No session exists yet and an initial dashboard is provided, start a new Seer session + // No session exists yet and an initial dashboard is provided, start a new Seer session const runId = await startDashboardEditSession( organization.slug, message, @@ -167,7 +167,6 @@ export function useSeerDashboardSession({ setIsUpdating(false); addErrorMessage(errorMessage); } - }, [organization.slug, queryClient, seerRunId, dashboard] ); From 5b414ea45336512828d39290fcade30cf7512c55 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 8 Apr 2026 14:04:21 -0400 Subject: [PATCH 5/5] fix --- .../views/dashboards/dashboardEditSeerChat.tsx | 15 ++------------- static/app/views/dashboards/detail.tsx | 3 +++ .../dashboards/useSeerDashboardSession.spec.tsx | 2 +- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/static/app/views/dashboards/dashboardEditSeerChat.tsx b/static/app/views/dashboards/dashboardEditSeerChat.tsx index 270454ad62a55c..deec57208d2d92 100644 --- a/static/app/views/dashboards/dashboardEditSeerChat.tsx +++ b/static/app/views/dashboards/dashboardEditSeerChat.tsx @@ -1,8 +1,8 @@ -import {useCallback, useRef} from 'react'; +import {useCallback} from 'react'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {DashboardChatPanel, type WidgetError} from './dashboardChatPanel'; +import {DashboardChatPanel} from './dashboardChatPanel'; import type {DashboardDetails, Widget} from './types'; import {useSeerDashboardSession} from './useSeerDashboardSession'; @@ -16,7 +16,6 @@ export function DashboardEditSeerChat({ onDashboardUpdate, }: DashboardEditSeerChatProps) { const organization = useOrganization(); - const widgetErrorsMap = useRef(new Map()); const hasFeature = organization.features.includes('dashboards-edit') && @@ -24,7 +23,6 @@ export function DashboardEditSeerChat({ const handleDashboardUpdate = useCallback( (data: {title: string; widgets: Widget[]}) => { - widgetErrorsMap.current.clear(); onDashboardUpdate({title: data.title, widgets: data.widgets}); }, [onDashboardUpdate] @@ -40,14 +38,6 @@ export function DashboardEditSeerChat({ return null; } - const widgetErrors: WidgetError[] = dashboard.widgets.flatMap(widget => { - if (widget.tempId === undefined) { - return []; - } - const error = widgetErrorsMap.current.get(widget.tempId); - return error ? [error] : []; - }); - return ( ); } diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index 6678bedcb28c29..47aed9a222d246 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -1332,6 +1332,9 @@ class DashboardDetail extends Component { {dashboardState === DashboardState.EDIT && organization.features.includes( 'dashboards-ai-generate-edit' + ) && + organization.features.includes( + 'dashboards-ai-generate' ) && ( { expect.objectContaining({ method: 'POST', data: { - prompt: 'Add a latency widget', + prompt: 'Add me another widget', current_dashboard: { title: 'My Dashboard', widgets: dashboard.widgets,