Skip to content

Commit 2af0cdf

Browse files
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.
1 parent 62099ad commit 2af0cdf

File tree

5 files changed

+229
-15
lines changed

5 files changed

+229
-15
lines changed

static/app/views/dashboards/dashboardChatPanel.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,6 @@ export function DashboardChatPanel({
135135
/>
136136
)}
137137
<InputGroup>
138-
{!hasHistory && <IconSeer size="md" />}
139138
<Container padding="md">
140139
<InputGroup.TextArea
141140
ref={textAreaRef}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {useCallback, useRef} from 'react';
2+
3+
import {useOrganization} from 'sentry/utils/useOrganization';
4+
5+
import {DashboardChatPanel, type WidgetError} from './dashboardChatPanel';
6+
import type {DashboardDetails, Widget} from './types';
7+
import {useSeerDashboardSession} from './useSeerDashboardSession';
8+
9+
interface DashboardEditSeerChatProps {
10+
dashboard: DashboardDetails;
11+
onDashboardUpdate: (dashboard: Pick<DashboardDetails, 'title' | 'widgets'>) => void;
12+
}
13+
14+
export function DashboardEditSeerChat({
15+
dashboard,
16+
onDashboardUpdate,
17+
}: DashboardEditSeerChatProps) {
18+
const organization = useOrganization();
19+
const widgetErrorsMap = useRef(new Map<string, WidgetError>());
20+
21+
const hasFeature =
22+
organization.features.includes('dashboards-edit') &&
23+
organization.features.includes('dashboards-ai-generate');
24+
25+
const handleDashboardUpdate = useCallback(
26+
(data: {title: string; widgets: Widget[]}) => {
27+
widgetErrorsMap.current.clear();
28+
onDashboardUpdate({title: data.title, widgets: data.widgets});
29+
},
30+
[onDashboardUpdate]
31+
);
32+
33+
const {session, isUpdating, isError, sendFollowUpMessage} = useSeerDashboardSession({
34+
dashboard: {title: dashboard.title, widgets: dashboard.widgets},
35+
onDashboardUpdate: handleDashboardUpdate,
36+
enabled: hasFeature,
37+
});
38+
39+
if (!hasFeature) {
40+
return null;
41+
}
42+
43+
const widgetErrors: WidgetError[] = dashboard.widgets.flatMap(widget => {
44+
if (widget.tempId === undefined) {
45+
return [];
46+
}
47+
const error = widgetErrorsMap.current.get(widget.tempId);
48+
return error ? [error] : [];
49+
});
50+
51+
return (
52+
<DashboardChatPanel
53+
blocks={session?.blocks ?? []}
54+
pendingUserInput={session?.pending_user_input}
55+
onSend={sendFollowUpMessage}
56+
isUpdating={isUpdating}
57+
isError={isError}
58+
widgetErrors={widgetErrors}
59+
/>
60+
);
61+
}

static/app/views/dashboards/detail.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import {DiscoverQueryPageSource} from 'sentry/views/performance/utils';
8787
import {PrebuiltDashboardOnboardingGate} from './components/prebuiltDashboardOnboardingGate';
8888
import {Controls} from './controls';
8989
import {Dashboard} from './dashboard';
90+
import {DashboardEditSeerChat} from './dashboardEditSeerChat';
9091
import {DEFAULT_STATS_PERIOD} from './data';
9192
import {FiltersBar} from './filtersBar';
9293
import {
@@ -960,6 +961,20 @@ class DashboardDetail extends Component<Props, State> {
960961
});
961962
};
962963

964+
handleSeerDashboardUpdate = ({title, widgets}: Pick<DashboardDetails, 'title' | 'widgets'>) => {
965+
this.setState(state => {
966+
const dashboard = cloneDashboard(state.modifiedDashboard ?? this.props.dashboard);
967+
return {
968+
widgetLimitReached: widgets.length >= MAX_WIDGETS,
969+
modifiedDashboard: {
970+
...dashboard,
971+
widgets,
972+
...(title === undefined ? {} : {title}),
973+
},
974+
};
975+
});
976+
};
977+
963978
handleUpdateEditStateWidgets = (widgets: Widget[]) => {
964979
this.setState(state => {
965980
const modifiedDashboard = {
@@ -1311,6 +1326,15 @@ class DashboardDetail extends Component<Props, State> {
13111326
dashboard={modifiedDashboard ?? dashboard}
13121327
onSave={this.handleSaveWidget}
13131328
/>
1329+
{dashboardState === DashboardState.EDIT &&
1330+
organization.features.includes(
1331+
'dashboards-ai-generate'
1332+
) && (
1333+
<DashboardEditSeerChat
1334+
dashboard={modifiedDashboard ?? dashboard}
1335+
onDashboardUpdate={this.handleSeerDashboardUpdate}
1336+
/>
1337+
)}
13141338
</Fragment>
13151339
</MEPSettingProvider>
13161340
)}

static/app/views/dashboards/useSeerDashboardSession.spec.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {OrganizationFixture} from 'sentry-fixture/organization';
22

33
import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';
4+
import { DisplayType } from 'sentry/views/dashboards/types';
45

56
import {useSeerDashboardSession} from 'sentry/views/dashboards/useSeerDashboardSession';
67

@@ -119,4 +120,89 @@ describe('useSeerDashboardSession', () => {
119120
})
120121
);
121122
});
123+
124+
it('starts a new session via the generate endpoint when dashboard is provided without seerRunId', async () => {
125+
const dashboard = {
126+
title: 'My Dashboard',
127+
widgets: [
128+
{
129+
title: 'Count',
130+
displayType: DisplayType.LINE,
131+
interval: '1h',
132+
queries: [
133+
{
134+
name: '',
135+
conditions: '',
136+
fields: ['count()'],
137+
columns: [],
138+
aggregates: ['count()'],
139+
orderby: '',
140+
},
141+
],
142+
},
143+
],
144+
};
145+
146+
const generateMock = MockApiClient.addMockResponse({
147+
url: `/organizations/${organization.slug}/dashboards/generate/`,
148+
method: 'POST',
149+
body: {run_id: '789'},
150+
});
151+
152+
MockApiClient.addMockResponse({
153+
url: makeSeerApiUrl(organization.slug, 789),
154+
body: {session: {run_id: 789, status: 'processing', updated_at: '2026-01-01T00:00:00Z', blocks: []}},
155+
});
156+
157+
const onDashboardUpdate = jest.fn();
158+
159+
const {result} = renderHookWithProviders(
160+
() =>
161+
useSeerDashboardSession({
162+
dashboard,
163+
onDashboardUpdate,
164+
}),
165+
{organization}
166+
);
167+
168+
await act(async () => {
169+
await result.current.sendFollowUpMessage('Add me another widget');
170+
});
171+
172+
expect(generateMock).toHaveBeenCalledWith(
173+
`/organizations/${organization.slug}/dashboards/generate/`,
174+
expect.objectContaining({
175+
method: 'POST',
176+
data: {
177+
prompt: 'Add a latency widget',
178+
current_dashboard: {
179+
title: 'My Dashboard',
180+
widgets: dashboard.widgets,
181+
},
182+
},
183+
})
184+
);
185+
186+
await waitFor(() => {
187+
expect(result.current.session).toBeDefined();
188+
});
189+
});
190+
191+
it('does nothing when sendFollowUpMessage is called without seerRunId or dashboard', async () => {
192+
const onDashboardUpdate = jest.fn();
193+
194+
const {result} = renderHookWithProviders(
195+
() =>
196+
useSeerDashboardSession({
197+
onDashboardUpdate,
198+
}),
199+
{organization}
200+
);
201+
202+
await act(async () => {
203+
await result.current.sendFollowUpMessage('Add me another widget');
204+
});
205+
206+
expect(result.current.isUpdating).toBe(false);
207+
});
122208
});

static/app/views/dashboards/useSeerDashboardSession.tsx

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,46 @@ import {useCallback, useEffect, useRef, useState} from 'react';
33
import {addErrorMessage} from 'sentry/actionCreators/indicator';
44
import {t} from 'sentry/locale';
55
import {parseQueryKey} from 'sentry/utils/api/apiQueryKey';
6+
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
67
import {fetchMutation, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
78
import {useOrganization} from 'sentry/utils/useOrganization';
89
import type {SeerExplorerResponse} from 'sentry/views/seerExplorer/hooks/useSeerExplorer';
910
import {makeSeerExplorerQueryKey} from 'sentry/views/seerExplorer/utils';
1011

1112
import {extractDashboardFromSession, statusIsTerminal} from './createFromSeerUtils';
12-
import type {Widget} from './types';
13+
import type {DashboardDetails, Widget} from './types';
1314

1415
const POLL_INTERVAL_MS = 500;
1516
const POST_COMPLETE_POLL_MS = 5000;
1617

18+
async function startDashboardEditSession(
19+
orgSlug: string,
20+
message: string,
21+
dashboard: Pick<DashboardDetails, 'title' | 'widgets'>
22+
): Promise<number> {
23+
const url = getApiUrl('/organizations/$organizationIdOrSlug/dashboards/generate/', {
24+
path: {organizationIdOrSlug: orgSlug},
25+
});
26+
const response = await fetchMutation<{run_id: string}>({
27+
url,
28+
method: 'POST',
29+
data: {
30+
prompt: message,
31+
current_dashboard: {
32+
title: dashboard.title,
33+
widgets: dashboard.widgets,
34+
},
35+
},
36+
});
37+
return Number(response.run_id);
38+
}
39+
1740
interface UseSeerDashboardSessionOptions {
1841
onDashboardUpdate: (data: {title: string; widgets: Widget[]}) => void;
19-
seerRunId: number | null;
42+
dashboard?: Pick<DashboardDetails, 'title' | 'widgets'>;
2043
enabled?: boolean;
2144
onPostCompletePollEnd?: () => void;
45+
seerRunId?: number | null;
2246
}
2347

2448
interface UseSeerDashboardSessionResult {
@@ -34,14 +58,18 @@ interface UseSeerDashboardSessionResult {
3458
* detecting terminal-state transitions, and sending follow-up messages.
3559
*/
3660
export function useSeerDashboardSession({
37-
seerRunId,
61+
seerRunId: externalSeerRunId,
62+
dashboard,
3863
onDashboardUpdate,
3964
enabled = true,
4065
onPostCompletePollEnd,
4166
}: UseSeerDashboardSessionOptions): UseSeerDashboardSessionResult {
4267
const organization = useOrganization();
4368
const queryClient = useQueryClient();
4469

70+
const [internalRunId, setInternalRunId] = useState<number | null>(null);
71+
const seerRunId = externalSeerRunId ?? internalRunId;
72+
4573
const [isUpdating, setIsUpdating] = useState(false);
4674

4775
const prevSessionStatusRef = useRef<{
@@ -106,26 +134,42 @@ export function useSeerDashboardSession({
106134

107135
const sendFollowUpMessage = useCallback(
108136
async (message: string) => {
109-
if (!seerRunId) {
137+
if (!seerRunId && !dashboard) {
110138
return;
111139
}
112140
setIsUpdating(true);
113141
completedAtRef.current = null;
142+
const errorMessage = t('Failed to send message');
114143
try {
115-
const queryKey = makeSeerExplorerQueryKey(organization.slug, seerRunId);
116-
const {url} = parseQueryKey(queryKey);
117-
await fetchMutation({
118-
url,
119-
method: 'POST',
120-
data: {query: message},
121-
});
122-
queryClient.invalidateQueries({queryKey});
144+
if (!seerRunId && dashboard) {
145+
// No session exists yet and an initial dashboard is provided, start a new Seer session
146+
const runId = await startDashboardEditSession(
147+
organization.slug,
148+
message,
149+
dashboard
150+
);
151+
if (!runId) {
152+
throw new Error('Failed to start dashboard editing session');
153+
}
154+
setInternalRunId(runId);
155+
} else {
156+
// A session exists, send the message to the existing session
157+
const queryKey = makeSeerExplorerQueryKey(organization.slug, seerRunId);
158+
const {url} = parseQueryKey(queryKey);
159+
await fetchMutation({
160+
url,
161+
method: 'POST',
162+
data: {query: message},
163+
});
164+
queryClient.invalidateQueries({queryKey});
165+
}
123166
} catch {
124167
setIsUpdating(false);
125-
addErrorMessage(t('Failed to send message'));
168+
addErrorMessage(errorMessage);
126169
}
170+
127171
},
128-
[organization.slug, queryClient, seerRunId]
172+
[organization.slug, queryClient, seerRunId, dashboard]
129173
);
130174

131175
return {

0 commit comments

Comments
 (0)