Skip to content

Commit d80ae86

Browse files
authored
[Agent Builder] move visualization attachment to a new attachments system (#266600)
## Summary Fixes #261488 Migrates visualization results to the Agent Builder attachment framework so visualization attachments can render inline with the same header and action-button system as other attachments. - Registers a `visualization` attachment UI definition that reuses `VisualizeLens` for inline rendering. - Adds inline renderer callbacks so attachment content can register dynamic action buttons in the attachment header - `registerActionButtons` - this is aligned with how we do the same for canvas for dashboards. - Moves Lens "View configuration" and "Save to dashboard" actions into `BaseVisualization`, including dashboard permission handling. - Keeps local fallback actions for ES|QL `<visualization />` output that is rendered outside the attachment flow. ## Screenshots Visualization attachment: <img width="688" height="646" alt="Screenshot 2026-04-30 at 11 27 27" src="https://github.com/user-attachments/assets/9da652db-43c7-4193-9651-46136d889e50" /> ES|QL `<visualization />` tag: <img width="652" height="815" alt="Screenshot 2026-04-30 at 11 54 33" src="https://github.com/user-attachments/assets/54ccd80b-695c-47c9-89d0-cc70d9fd1d2a" />
1 parent d6d9e42 commit d80ae86

15 files changed

Lines changed: 371 additions & 244 deletions

File tree

x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ export interface CanvasRenderCallbacks {
5252
setPreviewState?: (previewState: AttachmentPreviewState) => void;
5353
}
5454

55+
/**
56+
* Callbacks available to inline content renderers.
57+
*/
58+
export interface InlineRenderCallbacks {
59+
/** Register action buttons to display in the inline attachment header */
60+
registerActionButtons: (buttons: ActionButton[]) => void;
61+
}
62+
5563
/**
5664
* Parameters passed when requesting action buttons for an inline-rendered attachment.
5765
*/
@@ -126,8 +134,14 @@ export interface AttachmentUIDefinition<TAttachment extends UnknownAttachment =
126134
* Optional custom content renderer for inline attachment display.
127135
* When provided, attachments can be rendered inline in the conversation
128136
* using the <render_attachment> tag.
137+
*
138+
* The `callbacks` object provides:
139+
* - `registerActionButtons`: dynamically register action buttons in the inline header
129140
*/
130-
renderInlineContent?: (props: AttachmentRenderProps<TAttachment>) => ReactNode;
141+
renderInlineContent?: (
142+
props: AttachmentRenderProps<TAttachment>,
143+
callbacks?: InlineRenderCallbacks
144+
) => ReactNode;
131145
/**
132146
* Optional preferred width for the canvas flyout when opened in full-screen context.
133147
* Accepts any valid CSS width value (e.g. `'600px'`, `'40vw'`).

x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type {
1010
AttachmentServiceStartContract,
1111
AttachmentRenderProps,
1212
CanvasRenderCallbacks,
13+
InlineRenderCallbacks,
1314
GetActionButtonsParams,
1415
ActionButton,
1516
AttachmentPreviewState,

x-pack/platform/plugins/private/translations/translations/fr-FR.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11004,7 +11004,6 @@
1100411004
"xpack.agentBuilder.conversation.toolResponseFlyout.title": "Examiner la réponse de l'outil",
1100511005
"xpack.agentBuilder.conversation.traceFlyout.title": "Trace",
1100611006
"xpack.agentBuilder.conversation.viewTraceButton": "Afficher la trace",
11007-
"xpack.agentBuilder.conversation.visualization.edit": "Modifier la visualisation",
1100811007
"xpack.agentBuilder.conversation.visualization.saveToDashboard": "Enregistrer dans le tableau de bord",
1100911008
"xpack.agentBuilder.conversationActions.actions": "Plus",
1101011009
"xpack.agentBuilder.conversationActions.actionsAriaLabel": "Plus",

x-pack/platform/plugins/private/translations/translations/ja-JP.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11049,7 +11049,6 @@
1104911049
"xpack.agentBuilder.conversation.toolResponseFlyout.title": "ツールの応答を検査",
1105011050
"xpack.agentBuilder.conversation.traceFlyout.title": "トレース",
1105111051
"xpack.agentBuilder.conversation.viewTraceButton": "トレースを表示",
11052-
"xpack.agentBuilder.conversation.visualization.edit": "可視化を編集",
1105311052
"xpack.agentBuilder.conversation.visualization.saveToDashboard": "ダッシュボードに保存",
1105411053
"xpack.agentBuilder.conversationActions.actions": "詳細",
1105511054
"xpack.agentBuilder.conversationActions.actionsAriaLabel": "詳細",

x-pack/platform/plugins/private/translations/translations/zh-CN.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11047,7 +11047,6 @@
1104711047
"xpack.agentBuilder.conversation.toolResponseFlyout.title": "检查工具响应",
1104811048
"xpack.agentBuilder.conversation.traceFlyout.title": "追踪",
1104911049
"xpack.agentBuilder.conversation.viewTraceButton": "查看追踪",
11050-
"xpack.agentBuilder.conversation.visualization.edit": "编辑可视化",
1105111050
"xpack.agentBuilder.conversation.visualization.saveToDashboard": "保存到仪表板",
1105211051
"xpack.agentBuilder.conversationActions.actions": "更多",
1105311052
"xpack.agentBuilder.conversationActions.actionsAriaLabel": "更多",

x-pack/platform/plugins/shared/agent_builder/public/application/components/attachments/visualization_attachment.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import React, { Suspense } from 'react';
99
import { EuiLoadingSpinner } from '@elastic/eui';
1010
import { i18n } from '@kbn/i18n';
1111
import type { VisualizationAttachment } from '@kbn/agent-builder-common/attachments';
12-
import type { AttachmentUIDefinition } from '@kbn/agent-builder-browser/attachments';
12+
import { type AttachmentUIDefinition } from '@kbn/agent-builder-browser/attachments';
1313
import type { AgentBuilderStartDependencies } from '../../../types';
1414

1515
const LazyVisualizeLens = React.lazy(() =>
@@ -26,13 +26,16 @@ export const createVisualizationAttachmentDefinition = ({
2626
startDependencies: AgentBuilderStartDependencies;
2727
}): AttachmentUIDefinition<VisualizationAttachment> => {
2828
return {
29-
getLabel: (attachment) =>
30-
attachment.data.query ??
31-
i18n.translate('xpack.agentBuilder.attachments.visualization.label', {
32-
defaultMessage: 'Visualization',
33-
}),
29+
getLabel: (attachment: VisualizationAttachment): string => {
30+
const { title } = attachment.data.visualization;
31+
return typeof title === 'string'
32+
? title
33+
: i18n.translate('xpack.agentBuilder.attachments.visualization.label', {
34+
defaultMessage: 'Visualization',
35+
});
36+
},
3437
getIcon: () => 'lensApp',
35-
renderInlineContent: ({ attachment, screenContext }) => {
38+
renderInlineContent: ({ attachment, screenContext }, callbacks) => {
3639
const timeRange = attachment.data.time_range ?? screenContext?.time_range;
3740

3841
return (
@@ -43,6 +46,7 @@ export const createVisualizationAttachmentDefinition = ({
4346
lens={startDependencies.lens}
4447
uiActions={startDependencies.uiActions}
4548
timeRange={timeRange}
49+
registerActionButtons={callbacks?.registerActionButtons}
4650
/>
4751
</Suspense>
4852
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { useEffect } from 'react';
9+
import { render, screen } from '@testing-library/react';
10+
import {
11+
ActionButtonType,
12+
type AttachmentRenderProps,
13+
type InlineRenderCallbacks,
14+
} from '@kbn/agent-builder-browser/attachments';
15+
import type { UnknownAttachment } from '@kbn/agent-builder-common/attachments';
16+
import type { AttachmentsService } from '../../../../../../services/attachments/attachements_service';
17+
import { InlineAttachmentWithActions } from './inline_attachment_with_actions';
18+
19+
const mockOpenCanvas = jest.fn();
20+
const mockSetPreviewedAttachmentKey = jest.fn();
21+
const mockInvalidateConversation = jest.fn();
22+
const mockOpenSidebarConversation = jest.fn();
23+
const mockUpdatePersistedConversationId = jest.fn();
24+
25+
jest.mock('./canvas_context', () => ({
26+
getAttachmentPreviewKey: (attachmentId: string, version?: number) =>
27+
`${attachmentId}:${version ?? 'latest'}`,
28+
useCanvasContext: () => ({
29+
openCanvas: mockOpenCanvas,
30+
previewedAttachmentKey: null,
31+
setPreviewedAttachmentKey: mockSetPreviewedAttachmentKey,
32+
}),
33+
}));
34+
35+
jest.mock('../../../../../context/conversation/conversation_context', () => ({
36+
useConversationContext: () => ({
37+
conversationActions: { invalidateConversation: mockInvalidateConversation },
38+
}),
39+
}));
40+
41+
jest.mock('../../../../../hooks/use_agent_builder_service', () => ({
42+
useAgentBuilderServices: () => ({
43+
openSidebarConversation: mockOpenSidebarConversation,
44+
}),
45+
}));
46+
47+
jest.mock('../../../../../hooks/use_persisted_conversation_id', () => ({
48+
usePersistedConversationId: () => ({
49+
updatePersistedConversationId: mockUpdatePersistedConversationId,
50+
}),
51+
}));
52+
53+
const dynamicActionHandler = jest.fn();
54+
55+
const DynamicInlineContent = ({ callbacks }: { callbacks?: InlineRenderCallbacks }) => {
56+
const { registerActionButtons } = callbacks ?? {};
57+
58+
useEffect(() => {
59+
registerActionButtons?.([
60+
{
61+
label: 'Dynamic action',
62+
type: ActionButtonType.PRIMARY,
63+
handler: dynamicActionHandler,
64+
},
65+
]);
66+
}, [registerActionButtons]);
67+
68+
return <div>Inline content</div>;
69+
};
70+
71+
describe('InlineAttachmentWithActions', () => {
72+
beforeEach(() => {
73+
jest.clearAllMocks();
74+
});
75+
76+
it('renders action buttons registered by inline content', async () => {
77+
const attachment: UnknownAttachment = { id: 'attachment-1', type: 'test', data: {} };
78+
const attachmentsService = {
79+
getAttachmentUiDefinition: jest.fn().mockReturnValue({
80+
getLabel: () => 'Test attachment',
81+
renderInlineContent: (
82+
_props: AttachmentRenderProps<UnknownAttachment>,
83+
callbacks?: InlineRenderCallbacks
84+
) => <DynamicInlineContent callbacks={callbacks} />,
85+
getActionButtons: () => [
86+
{
87+
label: 'Static action',
88+
type: ActionButtonType.SECONDARY,
89+
handler: jest.fn(),
90+
},
91+
],
92+
}),
93+
updateOrigin: jest.fn(),
94+
};
95+
96+
render(
97+
<InlineAttachmentWithActions
98+
attachment={attachment}
99+
attachmentsService={attachmentsService as unknown as AttachmentsService}
100+
conversationId="conversation-1"
101+
isSidebar={false}
102+
/>
103+
);
104+
105+
expect(screen.getByText('Inline content')).not.toBeNull();
106+
expect(screen.getByRole('button', { name: 'Static action' })).not.toBeNull();
107+
expect(await screen.findByRole('button', { name: 'Dynamic action' })).not.toBeNull();
108+
});
109+
});

x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
* 2.0.
66
*/
77

8-
import React, { useCallback, useMemo } from 'react';
8+
import React, { useCallback, useMemo, useState } from 'react';
99
import type {
1010
UnknownAttachment,
1111
ScreenContextAttachmentData,
1212
} from '@kbn/agent-builder-common/attachments';
13-
import type { AttachmentPreviewState } from '@kbn/agent-builder-browser/attachments';
13+
import type { ActionButton, AttachmentPreviewState } from '@kbn/agent-builder-browser/attachments';
1414
import { EuiSplitPanel } from '@elastic/eui';
1515
import { css } from '@emotion/react';
1616
import type { AttachmentsService } from '../../../../../../services/attachments/attachements_service';
@@ -77,8 +77,19 @@ export const InlineAttachmentWithActions: React.FC<InlineAttachmentWithActionsPr
7777

7878
const uiDefinition = attachmentsService.getAttachmentUiDefinition(attachment.type);
7979
const attachmentPreviewKey = getAttachmentPreviewKey(attachment.id, version);
80+
const [dynamicButtonsState, setDynamicButtonsState] = useState<{
81+
key: string;
82+
buttons: ActionButton[];
83+
}>({ key: attachmentPreviewKey, buttons: [] });
8084

81-
const inlineActionButtons = useMemo(
85+
const registerActionButtons = useCallback(
86+
(buttons: ActionButton[]) => {
87+
setDynamicButtonsState({ key: attachmentPreviewKey, buttons });
88+
},
89+
[attachmentPreviewKey]
90+
);
91+
92+
const staticActionButtons = useMemo(
8293
() =>
8394
uiDefinition?.getActionButtons?.({
8495
attachment,
@@ -92,7 +103,7 @@ export const InlineAttachmentWithActions: React.FC<InlineAttachmentWithActionsPr
92103
nextPreviewState === 'previewing' ? attachmentPreviewKey : null
93104
);
94105
},
95-
}),
106+
}) ?? [],
96107
[
97108
uiDefinition,
98109
attachment,
@@ -105,6 +116,14 @@ export const InlineAttachmentWithActions: React.FC<InlineAttachmentWithActionsPr
105116
]
106117
);
107118

119+
const inlineActionButtons = useMemo(
120+
() => [
121+
...staticActionButtons,
122+
...(dynamicButtonsState.key === attachmentPreviewKey ? dynamicButtonsState.buttons : []),
123+
],
124+
[staticActionButtons, attachmentPreviewKey, dynamicButtonsState]
125+
);
126+
108127
const isPreviewingAttachment = previewedAttachmentKey === attachmentPreviewKey;
109128

110129
const resolvedPreviewBadgeState: AttachmentPreviewState =
@@ -131,12 +150,17 @@ export const InlineAttachmentWithActions: React.FC<InlineAttachmentWithActionsPr
131150
previewBadgeState={resolvedPreviewBadgeState}
132151
/>
133152
<EuiSplitPanel.Inner grow={false} paddingSize="none">
134-
{uiDefinition?.renderInlineContent?.({
135-
attachment,
136-
isSidebar,
137-
screenContext,
138-
openSidebarConversation: isSidebar ? undefined : openSidebarConversation,
139-
})}
153+
{uiDefinition?.renderInlineContent?.(
154+
{
155+
attachment,
156+
isSidebar,
157+
screenContext,
158+
openSidebarConversation: isSidebar ? undefined : openSidebarConversation,
159+
},
160+
{
161+
registerActionButtons,
162+
}
163+
)}
140164
</EuiSplitPanel.Inner>
141165
</EuiSplitPanel.Outer>
142166
);

0 commit comments

Comments
 (0)