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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion static/app/views/dashboards/dashboardChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ export function DashboardChatPanel({
/>
)}
<InputGroup>
{!hasHistory && <IconSeer size="md" />}
<Container padding="md">
<InputGroup.TextArea
ref={textAreaRef}
Expand Down
50 changes: 50 additions & 0 deletions static/app/views/dashboards/dashboardEditSeerChat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {useCallback} from 'react';

import {useOrganization} from 'sentry/utils/useOrganization';

import {DashboardChatPanel} from './dashboardChatPanel';
import type {DashboardDetails, Widget} from './types';
import {useSeerDashboardSession} from './useSeerDashboardSession';

interface DashboardEditSeerChatProps {
dashboard: DashboardDetails;
onDashboardUpdate: (dashboard: Pick<DashboardDetails, 'title' | 'widgets'>) => 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 (
<DashboardChatPanel
blocks={session?.blocks ?? []}
pendingUserInput={session?.pending_user_input}
onSend={sendFollowUpMessage}
isUpdating={isUpdating}
isError={isError}
/>
);
}
30 changes: 30 additions & 0 deletions static/app/views/dashboards/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -960,6 +961,23 @@ class DashboardDetail extends Component<Props, State> {
});
};

handleSeerDashboardUpdate = ({
title,
widgets,
}: Pick<DashboardDetails, 'title' | 'widgets'>) => {
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 = {
Expand Down Expand Up @@ -1311,6 +1329,18 @@ class DashboardDetail extends Component<Props, State> {
dashboard={modifiedDashboard ?? dashboard}
onSave={this.handleSaveWidget}
/>
{dashboardState === DashboardState.EDIT &&
organization.features.includes(
'dashboards-ai-generate-edit'
) &&
organization.features.includes(
'dashboards-ai-generate'
) && (
<DashboardEditSeerChat
dashboard={modifiedDashboard ?? dashboard}
onDashboardUpdate={this.handleSeerDashboardUpdate}
/>
)}
</Fragment>
</MEPSettingProvider>
)}
Expand Down
93 changes: 93 additions & 0 deletions static/app/views/dashboards/useSeerDashboardSession.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
71 changes: 57 additions & 14 deletions static/app/views/dashboards/useSeerDashboardSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DashboardDetails, 'title' | 'widgets'>
): Promise<number> {
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<DashboardDetails, 'title' | 'widgets'>;
enabled?: boolean;
onPostCompletePollEnd?: () => void;
seerRunId?: number | null;
}

interface UseSeerDashboardSessionResult {
Expand All @@ -34,14 +58,18 @@ interface UseSeerDashboardSessionResult {
* detecting terminal-state transitions, and sending follow-up messages.
*/
export function useSeerDashboardSession({
seerRunId,
seerRunId: externalSeerRunId,
dashboard,
onDashboardUpdate,
enabled = true,
onPostCompletePollEnd,
}: UseSeerDashboardSessionOptions): UseSeerDashboardSessionResult {
const organization = useOrganization();
const queryClient = useQueryClient();

const [internalRunId, setInternalRunId] = useState<number | null>(null);
const seerRunId = externalSeerRunId ?? internalRunId;

const [isUpdating, setIsUpdating] = useState(false);

const prevSessionStatusRef = useRef<{
Expand Down Expand Up @@ -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 {
Expand Down
Loading