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 hasFeature = + organization.features.includes('dashboards-edit') && + organization.features.includes('dashboards-ai-generate'); + + const handleDashboardUpdate = useCallback( + (data: {title: string; widgets: Widget[]}) => { + 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; + } + + return ( + + ); +} diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index b89a2e53a758e4..47aed9a222d246 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,23 @@ 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 +1329,18 @@ class DashboardDetail extends Component { dashboard={modifiedDashboard ?? dashboard} onSave={this.handleSaveWidget} /> + {dashboardState === DashboardState.EDIT && + organization.features.includes( + 'dashboards-ai-generate-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..8e722ed46c0dca 100644 --- a/static/app/views/dashboards/useSeerDashboardSession.spec.tsx +++ b/static/app/views/dashboards/useSeerDashboardSession.spec.tsx @@ -2,6 +2,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'; const SEER_RUN_ID = 456; @@ -119,4 +120,96 @@ 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 me another 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..067d16905ce0ae 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,41 @@ 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 {