Skip to content

Commit ec1ddd1

Browse files
feat(seer): Add LLM context to widget builder page
Register a 'widget-builder' node in the LLM context tree so the Seer Explorer agent understands what widget the user is configuring. Pushes builder state (dataset, displayType, visualize, fields, query, sort, thresholds, legendAlias) with readable condition transforms. Also adds the widget builder routes to STRUCTURED_CONTEXT_ROUTES so structured context is sent instead of ASCII when the builder is open. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8eb4dae commit ec1ddd1

File tree

4 files changed

+277
-2
lines changed

4 files changed

+277
-2
lines changed

static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
1818
import {t} from 'sentry/locale';
1919
import {CustomMeasurementsProvider} from 'sentry/utils/customMeasurements/customMeasurementsProvider';
2020
import {EventView} from 'sentry/utils/discover/eventView';
21+
import {generateFieldAsString} from 'sentry/utils/discover/fields';
2122
import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
2223
import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
2324
import {useDimensions} from 'sentry/utils/useDimensions';
@@ -48,9 +49,46 @@ import {
4849
useWidgetBuilderContext,
4950
WidgetBuilderProvider,
5051
} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
52+
import {useIsEditingWidget} from 'sentry/views/dashboards/widgetBuilder/hooks/useIsEditingWidget';
5153
import type {OnDataFetchedParams} from 'sentry/views/dashboards/widgetCard';
5254
import {DashboardsMEPProvider} from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext';
55+
import {readableConditions} from 'sentry/views/dashboards/widgetCard/widgetLLMContext';
5356
import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
57+
import {useLLMContext} from 'sentry/views/seerExplorer/contexts/llmContext';
58+
import {registerLLMContext} from 'sentry/views/seerExplorer/contexts/registerLLMContext';
59+
60+
/**
61+
* Renderless component that registers a 'widget-builder' node in the LLM
62+
* context tree and pushes the current builder state into it. Must be rendered
63+
* inside WidgetBuilderProvider so useWidgetBuilderContext() is available.
64+
*/
65+
function WidgetBuilderLLMContextInner() {
66+
const {state} = useWidgetBuilderContext();
67+
const isEditing = useIsEditingWidget();
68+
69+
useLLMContext({
70+
contextHint:
71+
'Sentry widget builder. The user is configuring a dashboard widget. visualize is the y-axis metrics (timeseries) or the aggregate (big number/table). fields are group-by columns (timeseries) or visible columns (table). query filters the data and sort controls ordering.',
72+
mode: isEditing ? 'editing' : 'creating',
73+
title: state.title,
74+
description: state.description,
75+
dataset: state.dataset,
76+
displayType: state.displayType,
77+
visualize: state.yAxis?.map(generateFieldAsString),
78+
fields: state.fields?.map(generateFieldAsString),
79+
query: state.query?.map(readableConditions),
80+
sort: state.sort?.map(s => (s.kind === 'desc' ? `-${s.field}` : s.field)),
81+
thresholds: state.thresholds,
82+
legendAlias: state.legendAlias,
83+
});
84+
85+
return null;
86+
}
87+
88+
const WidgetBuilderLLMContext = registerLLMContext(
89+
'widget-builder',
90+
WidgetBuilderLLMContextInner
91+
);
5492

5593
export interface ThresholdMetaState {
5694
dataType?: string;
@@ -185,6 +223,7 @@ export function WidgetBuilderV2({
185223
/>
186224
<Backdrop style={{opacity: 0.5, pointerEvents: 'auto'}} />
187225
<WidgetBuilderProvider>
226+
<WidgetBuilderLLMContext />
188227
<CustomMeasurementsProvider organization={organization} selection={selection}>
189228
<ContainerWithoutSidebar
190229
style={
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import {render, waitFor} from 'sentry-test/reactTestingLibrary';
2+
3+
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
4+
import {
5+
LLMContextProvider,
6+
useLLMContext,
7+
} from 'sentry/views/seerExplorer/contexts/llmContext';
8+
import type {LLMContextSnapshot} from 'sentry/views/seerExplorer/contexts/llmContextTypes';
9+
10+
import {WidgetBuilderV2} from './newWidgetBuilder';
11+
12+
// ---------------------------------------------------------------------------
13+
// Helpers
14+
// ---------------------------------------------------------------------------
15+
16+
function makeContextCapture() {
17+
const ref: {current: (() => LLMContextSnapshot) | null} = {current: null};
18+
function ContextCapture() {
19+
const {getLLMContext} = useLLMContext();
20+
ref.current = getLLMContext;
21+
return null;
22+
}
23+
return {
24+
ContextCapture,
25+
getSnapshot: () => {
26+
if (!ref.current) throw new Error('ContextCapture not mounted');
27+
return ref.current();
28+
},
29+
};
30+
}
31+
32+
const DASHBOARD = {
33+
id: '1',
34+
title: 'Test',
35+
widgets: [],
36+
filters: {},
37+
dateCreated: '',
38+
projects: undefined,
39+
};
40+
41+
const NEW_PATH = '/organizations/org-slug/dashboard/1/widget-builder/widget/new/';
42+
const EDIT_PATH = '/organizations/org-slug/dashboard/1/widget-builder/widget/0/edit/';
43+
const EDIT_ROUTE =
44+
'/organizations/:orgId/dashboard/:dashboardId/widget-builder/widget/:widgetIndex/edit/';
45+
46+
/** Render the builder with the given URL query params, return the node data. */
47+
async function renderAndGetData(
48+
query: Record<string, string | number | string[]>,
49+
opts?: {isOpen?: boolean; path?: string; route?: string}
50+
) {
51+
const {ContextCapture, getSnapshot} = makeContextCapture();
52+
const isOpen = opts?.isOpen ?? true;
53+
54+
render(
55+
<LLMContextProvider>
56+
<ContextCapture />
57+
<WidgetBuilderV2
58+
isOpen={isOpen}
59+
onClose={jest.fn()}
60+
onSave={jest.fn()}
61+
dashboard={DASHBOARD}
62+
dashboardFilters={{}}
63+
openWidgetTemplates={false}
64+
setOpenWidgetTemplates={jest.fn()}
65+
/>
66+
</LLMContextProvider>,
67+
{
68+
initialRouterConfig: {
69+
location: {pathname: opts?.path ?? NEW_PATH, query},
70+
...(opts?.route ? {route: opts.route} : {}),
71+
},
72+
}
73+
);
74+
75+
if (!isOpen) return undefined;
76+
77+
let data: Record<string, unknown> | undefined;
78+
await waitFor(() => {
79+
const node = getSnapshot().nodes.find(n => n.nodeType === 'widget-builder');
80+
expect(node).toBeDefined();
81+
data = node!.data as Record<string, unknown>;
82+
});
83+
return data!;
84+
}
85+
86+
// ---------------------------------------------------------------------------
87+
// Mocks
88+
// ---------------------------------------------------------------------------
89+
90+
const MOCK_URLS = [
91+
'/organizations/org-slug/releases/',
92+
'/organizations/org-slug/tags/',
93+
'/organizations/org-slug/dashboard/1/',
94+
'/organizations/org-slug/issues/',
95+
'/organizations/org-slug/events/',
96+
'/organizations/org-slug/releases/stats/',
97+
'/organizations/org-slug/trace-items/attributes/',
98+
'/organizations/org-slug/measurements-meta/',
99+
'/organizations/org-slug/recent-searches/',
100+
];
101+
102+
beforeEach(() => {
103+
for (const url of MOCK_URLS) {
104+
MockApiClient.addMockResponse({url, body: []});
105+
}
106+
MockApiClient.addMockResponse({
107+
url: '/organizations/org-slug/events-stats/',
108+
body: {data: [], start: 0, end: 0},
109+
});
110+
});
111+
112+
// ---------------------------------------------------------------------------
113+
// Tests
114+
// ---------------------------------------------------------------------------
115+
116+
describe('WidgetBuilder LLM Context', () => {
117+
it('registers node with full builder state', async () => {
118+
const data = await renderAndGetData({
119+
displayType: DisplayType.LINE,
120+
dataset: WidgetType.ERRORS,
121+
title: 'My Widget',
122+
yAxis: 'count()',
123+
field: 'browser.name',
124+
query: 'browser.name:Firefox',
125+
});
126+
127+
expect(data!.mode).toBe('creating');
128+
expect(data!.title).toBe('My Widget');
129+
expect(data!.dataset).toBe(WidgetType.ERRORS);
130+
expect(data!.displayType).toBe(DisplayType.LINE);
131+
expect(data!.visualize).toEqual(['count()']);
132+
expect(data!.fields).toEqual(['browser.name']);
133+
expect(data!.query).toEqual(['browser.name:Firefox']);
134+
expect(data!.contextHint).toContain('widget builder');
135+
});
136+
137+
it('sets mode to editing when widgetIndex param is present', async () => {
138+
const data = await renderAndGetData(
139+
{displayType: DisplayType.TABLE, dataset: WidgetType.SPANS},
140+
{path: EDIT_PATH, route: EDIT_ROUTE}
141+
);
142+
expect(data!.mode).toBe('editing');
143+
});
144+
145+
it('does not register a node when the builder is closed', async () => {
146+
const data = await renderAndGetData({}, {isOpen: false});
147+
expect(data).toBeUndefined();
148+
});
149+
150+
it('applies readableConditions to query filters', async () => {
151+
const data = await renderAndGetData({
152+
displayType: DisplayType.TABLE,
153+
dataset: WidgetType.ERRORS,
154+
query: 'span.name:\uf00dContains\uf00dfoo',
155+
});
156+
expect(data!.query).toEqual(['span.name: contains foo']);
157+
});
158+
159+
it('formats sort with desc prefix', async () => {
160+
const data = await renderAndGetData({
161+
displayType: DisplayType.TABLE,
162+
dataset: WidgetType.ERRORS,
163+
field: 'count()',
164+
sort: '-count()',
165+
});
166+
expect(data!.sort).toEqual(['-count()']);
167+
});
168+
169+
it('formats sort without prefix for asc', async () => {
170+
const data = await renderAndGetData({
171+
displayType: DisplayType.TABLE,
172+
dataset: WidgetType.ERRORS,
173+
field: 'count()',
174+
sort: 'count()',
175+
});
176+
expect(data!.sort).toEqual(['count()']);
177+
});
178+
179+
it('passes through thresholds config', async () => {
180+
const thresholds = JSON.stringify({
181+
max_values: {max1: 100, max2: 200},
182+
unit: 'millisecond',
183+
});
184+
const data = await renderAndGetData({
185+
displayType: DisplayType.BIG_NUMBER,
186+
dataset: WidgetType.ERRORS,
187+
thresholds,
188+
});
189+
expect(data!.thresholds).toEqual(expect.objectContaining({unit: 'millisecond'}));
190+
});
191+
192+
it('passes through legendAlias', async () => {
193+
const data = await renderAndGetData({
194+
displayType: DisplayType.LINE,
195+
dataset: WidgetType.ERRORS,
196+
yAxis: 'count()',
197+
legendAlias: 'Error count',
198+
});
199+
expect(data!.legendAlias).toEqual(['Error count']);
200+
});
201+
202+
it('passes through description', async () => {
203+
const data = await renderAndGetData({
204+
displayType: DisplayType.LINE,
205+
dataset: WidgetType.ERRORS,
206+
description: 'Tracks error rate',
207+
});
208+
expect(data!.description).toBe('Tracks error rate');
209+
});
210+
211+
it('handles multiple yAxis and fields', async () => {
212+
const data = await renderAndGetData({
213+
displayType: DisplayType.LINE,
214+
dataset: WidgetType.ERRORS,
215+
yAxis: ['count()', 'count_unique(user)'],
216+
field: ['browser.name', 'os.name'],
217+
});
218+
expect(data!.visualize).toEqual(['count()', 'count_unique(user)']);
219+
expect(data!.fields).toEqual(['browser.name', 'os.name']);
220+
});
221+
222+
it('exposes fields as table columns for table display type', async () => {
223+
const data = await renderAndGetData({
224+
displayType: DisplayType.TABLE,
225+
dataset: WidgetType.ERRORS,
226+
field: ['title', 'count()', 'last_seen()'],
227+
});
228+
// For tables, fields holds all visible columns; yAxis defaults to empty
229+
expect(data!.fields).toEqual(['title', 'count()', 'last_seen()']);
230+
expect(data!.visualize).toEqual([]);
231+
});
232+
});

static/app/views/seerExplorer/contexts/llmContextTypes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* Known node types for the LLM context tree.
1818
* Add new types here as new context-aware components are registered.
1919
*/
20-
export type LLMContextNodeType = 'chart' | 'dashboard' | 'widget';
20+
export type LLMContextNodeType = 'chart' | 'dashboard' | 'widget' | 'widget-builder';
2121

2222
/**
2323
* A single node in the flat registry.

static/app/views/seerExplorer/hooks/useSeerExplorer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ type SeerExplorerChatResponse = {
5252
const POLL_INTERVAL = 500; // Poll every 500ms
5353

5454
/** Routes where the LLMContext tree provides structured page context. */
55-
const STRUCTURED_CONTEXT_ROUTES = new Set(['/dashboard/:dashboardId/']);
55+
const STRUCTURED_CONTEXT_ROUTES = new Set([
56+
'/dashboard/:dashboardId/',
57+
'/dashboard/:dashboardId/widget-builder/widget/new/',
58+
'/dashboard/:dashboardId/widget-builder/widget/:widgetIndex/edit/',
59+
]);
5660

5761
const OPTIMISTIC_ASSISTANT_TEXTS = [
5862
'Looking around...',

0 commit comments

Comments
 (0)