Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,51 @@
* 2.0.
*/

import type { ReactNode } from 'react';
import type { IconType } from '@elastic/eui';
import type { UnknownAttachment, AttachmentVersion } from '@kbn/agent-builder-common/attachments';

export enum ActionButtonType {
PRIMARY = 'primary',
SECONDARY = 'secondary',
OVERFLOW = 'overflow',
}
/**
* Props passed to custom attachment content renderers.
*/
export interface AttachmentRenderProps<TAttachment extends UnknownAttachment = UnknownAttachment> {
/** The attachment to render */
attachment: TAttachment;
/** Whether the attachment is being rendered in a sidebar context */
isSidebar: boolean;
}

/**
* Parameters passed when requesting action buttons for an inline-rendered attachment.
*/
export interface GetActionButtonsParams<TAttachment extends UnknownAttachment = UnknownAttachment> {
/** The attachment for which to provide action buttons */
attachment: TAttachment;
/** Whether the attachment is being rendered in a sidebar context */
isSidebar: boolean;
/** Function to update the attachment's origin reference */
updateOrigin: (originId: string) => Promise<void>;
}

/**
* Action button definition for inline-rendered attachments.
*/
export interface ActionButton {
/** Button label text */
label: string;
/** Optional icon to display in the button (EUI icon name or custom React element) */
icon?: IconType;
/** Whether this is the primary action button */
type: ActionButtonType;
/** Handler function called when the button is clicked */
handler: () => void | Promise<void>;
}

/**
* UI definition for rendering attachments of a specific type.
*/
Expand All @@ -25,6 +67,17 @@ export interface AttachmentUIDefinition<TAttachment extends UnknownAttachment =
* When provided, pills will invoke this instead of the default behavior.
*/
onClick?: (args: { attachment: TAttachment; version?: AttachmentVersion }) => void;
/**
* Optional custom content renderer for inline attachment display.
* When provided, attachments can be rendered inline in the conversation
* using the <render_attachment> tag.
*/
renderContent?: (props: AttachmentRenderProps<TAttachment>) => ReactNode;
/**
* Optional function to provide action buttons for inline-rendered attachments.
* Buttons will appear alongside or below the rendered content.
*/
getActionButtons?: (params: GetActionButtonsParams<TAttachment>) => ActionButton[];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,11 @@
* 2.0.
*/

export type { AttachmentUIDefinition, AttachmentServiceStartContract } from './contract';
export type {
AttachmentUIDefinition,
AttachmentServiceStartContract,
AttachmentRenderProps,
GetActionButtonsParams,
ActionButton,
} from './contract';
export { ActionButtonType } from './contract';
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,16 @@ export const dashboardElement = {
toolResultId: 'tool-result-id',
},
};

export interface RenderAttachmentElementAttributes {
attachmentId?: string;
version?: number | string;
}

export const renderAttachmentElement = {
tagName: 'render_attachment',
attributes: {
attachmentId: 'id',
version: 'version',
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const ConversationRounds: React.FC<ConversationRoundsProps> = ({
scrollContainerHeight={scrollContainerHeight}
isCurrentRound={isCurrentRound}
rawRound={round}
conversationId={conversation?.id}
conversationAttachments={conversation?.attachments}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface RoundLayoutProps {
scrollContainerHeight: number;
rawRound: ConversationRound;
conversationAttachments?: VersionedAttachment[];
conversationId?: string;
}

const labels = {
Expand All @@ -40,6 +41,7 @@ export const RoundLayout: React.FC<RoundLayoutProps> = ({
scrollContainerHeight,
rawRound,
conversationAttachments,
conversationId,
}) => {
const [roundContainerMinHeight, setRoundContainerMinHeight] = useState(0);
const [hasBeenLoading, setHasBeenLoading] = useState(false);
Expand Down Expand Up @@ -153,6 +155,9 @@ export const RoundLayout: React.FC<RoundLayoutProps> = ({
steps={steps}
isLoading={isLoadingCurrentRound}
isLastRound={isCurrentRound}
conversationAttachments={conversationAttachments}
attachmentRefs={input.attachment_refs}
conversationId={conversationId}
/>
</EuiFlexItem>
<EuiSpacer />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import type { UnknownAttachment } from '@kbn/agent-builder-common/attachments';
import { EuiButton } from '@elastic/eui';
import { AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids';
import type { AttachmentsService } from '../../../../../services/attachments/attachements_service';
import { useKibana } from '../../../../hooks/use_kibana';

interface AttachmentWithActionsProps {
attachment: UnknownAttachment;
attachmentsService: AttachmentsService;
isSidebar: boolean;
conversationId: string;
}

/**
* Component that renders an attachment with its action buttons.
*/
export const AttachmentWithActions: React.FC<AttachmentWithActionsProps> = ({
attachment,
attachmentsService,
isSidebar,
conversationId,
}) => {
const {
services: { settings },
} = useKibana();
const isExperimentalFeaturesEnabled = settings?.client.get<boolean>(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not blocking but we should consider pushing this up into agentBuilderServices hook

Copy link
Copy Markdown
Contributor Author

@chrisbmar chrisbmar Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we technically don't even need it considering the agent doesn't have any prompt to emit the custom XML tag... so I could remove it for now? This was basically just a guard in case the agent emitted the custom XML tag for attachment_renderer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeh we can remove for now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waiting forever for CI to pass, so will merge this PR as is once CI is done and in the canvas-mode PR I'll remove this check 🙏🏽

AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID,
false
);

if (isExperimentalFeaturesEnabled === false) {
return null;
}

const uiDefinition = attachmentsService.getAttachmentUiDefinition(attachment.type);

if (!uiDefinition) {
return null;
}

const actionButtons = uiDefinition.getActionButtons?.({
attachment,
isSidebar,
updateOrigin: async (originId: string) => {
// TODO: Implement updateOrigin
// attachmentsService.updateOrigin(conversationId, attachment.id, originId);
},
});

return (
<>
{actionButtons?.map((button) => (
<EuiButton key={button.label} onClick={button.handler}>
{button.label}
</EuiButton>
))}
{uiDefinition?.renderContent?.({ attachment, isSidebar })}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,46 @@ import {
} from '@elastic/eui';
import { type PluggableList } from 'unified';
import type { ConversationRoundStep } from '@kbn/agent-builder-common';
import { visualizationElement } from '@kbn/agent-builder-common/tools/custom_rendering';
import type {
VersionedAttachment,
AttachmentVersionRef,
} from '@kbn/agent-builder-common/attachments';
import {
visualizationElement,
renderAttachmentElement,
} from '@kbn/agent-builder-common/tools/custom_rendering';
import { useAgentBuilderServices } from '../../../../hooks/use_agent_builder_service';
import {
Cursor,
esqlLanguagePlugin,
createVisualizationRenderer,
loadingCursorPlugin,
visualizationTagParser,
renderAttachmentTagParser,
createRenderAttachmentRenderer,
} from './markdown_plugins';
import { useStepsFromPrevRounds } from '../../../../hooks/use_conversation';
import { useConversationContext } from '../../../../context/conversation/conversation_context';

interface Props {
content: string;
steps: ConversationRoundStep[];
conversationAttachments?: VersionedAttachment[];
attachmentRefs?: AttachmentVersionRef[];
conversationId?: string;
}

/**
* Component handling markdown support to the assistant's responses.
* Also handles "loading" state by appending the blinking cursor.
*/
export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props) {
export function ChatMessageText({
content,
steps: stepsFromCurrentRound,
conversationAttachments,
attachmentRefs,
conversationId,
}: Props) {
const { euiTheme } = useEuiTheme();

const containerClassName = css`
Expand All @@ -58,8 +77,9 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props
}
`;

const { startDependencies } = useAgentBuilderServices();
const { attachmentsService, startDependencies } = useAgentBuilderServices();
const stepsFromPrevRounds = useStepsFromPrevRounds();
const { isEmbeddedContext: isSidebar } = useConversationContext();

const { parsingPluginList, processingPluginList } = useMemo(() => {
const parsingPlugins = getDefaultEuiMarkdownParsingPlugins();
Expand Down Expand Up @@ -125,18 +145,35 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props
stepsFromCurrentRound,
stepsFromPrevRounds,
}),
[renderAttachmentElement.tagName]: createRenderAttachmentRenderer({
conversationAttachments,
attachmentRefs,
conversationId,
isSidebar,
attachmentsService,
}),
};

return {
parsingPluginList: [
loadingCursorPlugin,
esqlLanguagePlugin,
visualizationTagParser,
renderAttachmentTagParser,
...parsingPlugins,
],
processingPluginList: processingPlugins,
};
}, [startDependencies, stepsFromCurrentRound, stepsFromPrevRounds]);
}, [
startDependencies,
stepsFromCurrentRound,
stepsFromPrevRounds,
conversationAttachments,
attachmentRefs,
conversationId,
isSidebar,
attachmentsService,
]);

return (
<EuiText size="m" className={containerClassName}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@

export { findToolResult, type MutableNode } from './utils';
export { visualizationTagParser, createVisualizationRenderer } from './visualization_plugin';
export {
renderAttachmentTagParser,
createRenderAttachmentRenderer,
} from './render_attachment_plugin';
export { loadingCursorPlugin, Cursor } from './cursor_plugin';
export { esqlLanguagePlugin } from './esql_plugin';
Loading
Loading