Skip to content

Commit 27bd83b

Browse files
mbondyrakibanamachinemacroscopeapp[bot]
authored
[Dashboards in Chat] Sidebar dashboard app integration - enable automatic dashboard attachments in new conversations (elastic#263987)
## Summary This PR removes `onAttachmentMount` and replaces it with conversation-level APIs that are a better fit for dashboard/chat integration. `onAttachmentMount` worked for persisted attachments that were already rendered in a conversation, but it turned out to be too narrow for the dashboard app use case. In particular, dashboard app sync also needs to work for draft attachments. If a user opens a new dashboard with a new conversation, the dashboard can be attached as a draft attachment before it exists as a persisted conversation attachment. In that flow, manual dashboard changes still need to update the attachment, but `onAttachmentMount` never fires because there is nothing persisted yet (attachment has not been mounted). The old model also pushed dashboard integration toward per-attachment subscriptions, while the actual behavior we need is conversation-level. Dashboard sync needs awareness of the full set of conversation attachments so it can decide which dashboard attachment should own sync, rather than wiring each attachment independently. It also needs to preserve some state across conversation switches, such as the current draft attachment id so we don't create a new attachment every time we switch to another conversation (because we cannot remove the old one). To support that, this PR introduces `subscribeToConversationChanges`, which exposes the active conversation id and attachments whenever the bound conversation changes, and `chatOpen$`, which lets dashboard integration activate only when chat is actually open. Together, these APIs let dashboard integration subscribe once for the active dashboard/chat session instead of once per mounted attachment. ## What Changed - removed `onAttachmentMount` from the attachment UI contract - added `subscribeToConversationChanges` to the `agent_builder` public start contract - added `chatOpen$` to the `agent_builder` public start contract - added internal conversation change notifications for both embedded/sidebar chat (we don't use full-page chat here, but I think we will soon) and routed/full-page chat - updated dashboard integration to activate only when both the dashboard app and chat are running - refactored dashboard integration to use conversation-level attachment state instead of per-attachment mount lifecycle - added draft attachment id management so draft dashboard attachments keep a stable id until they are persisted - extracted shared dashboard state defaults into helpers - updated and expanded tests around plugin APIs, conversation change notifications, and dashboard integration flows ## Dashboard Integration Improvements This change makes dashboard/chat synchronization work for both draft and persisted dashboard attachments. It also allows dashboard integration to: - add and update dashboard attachments for new conversations (!New attachments can only be created for new conversations!) - keep manual dashboard edits synced even before the attachment is persisted - preserve the current draft attachment id across conversation switches (so we don't create a new attachment, since we have no ability to remove the old one) ## Why This Is Better A conversation-level subscription is a better abstraction for dashboard integration than an attachment-mount lifecycle hook. It gives integration code visibility into all attachments for the active conversation, lets it maintain state across conversation changes, and covers the important case where dashboard sync must begin before any persisted attachment exists. It also keeps integration inactive unless both chat and dashboard are actually present, which reduces unnecessary subscriptions and avoids running sync logic outside the intended flow. ## Test Plan - [ ] Open a new dashboard with a new conversation and verify manual dashboard edits keep updating the draft dashboard attachment - [ ] Switch between conversations and verify the current draft attachment id is preserved until the draft is persisted - [ ] Open a dashboard with an existing conversation and verify manual dashboard edits keep updating the draft dashboard attachment. When submitting, new attachment correctly is added to the conversation - [ ] Complete a round that creates the draft attachment and verify subsequent new drafts use a new id - [ ] Save and save as from the dashboard app and verify attachment origin is updated correctly - [ ] Verify dashboard integration only becomes active when both the dashboard app and chat are running demo: https://github.com/user-attachments/assets/3c8563db-ca3e-46a8-8f7d-6ff14b5a7c53 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
1 parent b0bd61a commit 27bd83b

28 files changed

Lines changed: 1206 additions & 995 deletions

packages/kbn-optimizer/limits.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pageLoadAssetSize:
3131
crossClusterReplication: 12662
3232
customIntegrations: 11715
3333
dashboard: 20000
34-
dashboardAgent: 5135
34+
dashboardAgent: 8000
3535
dashboardMarkdown: 6151
3636
data: 496646
3737
dataQuality: 11469

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,6 @@ export interface AttachmentUIDefinition<TAttachment extends UnknownAttachment =
154154
* Buttons will appear alongside or below the rendered content.
155155
*/
156156
getActionButtons?: (params: GetActionButtonsParams<TAttachment>) => ActionButton[];
157-
/**
158-
* Optional lifecycle hook called when an attachment is first rendered in the conversation.
159-
* Called once per attachment (not per version). Use for setting up subscriptions or
160-
* other side effects that should persist across version renders.
161-
*
162-
* @returns Optional cleanup function called when the attachment is removed from the conversation.
163-
*/
164-
onAttachmentMount?: (params: AttachmentLifecycleParams<TAttachment>) => void | (() => void);
165157
}
166158

167159
/**

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,32 @@
66
*/
77

88
import type { Observable } from 'rxjs';
9+
import type { Conversation } from '@kbn/agent-builder-common';
910
import type { BrowserChatEvent } from './events';
1011

12+
export interface ActiveConversation {
13+
/**
14+
* Active conversation id, if one already exists.
15+
* Undefined means we're currently in a new conversation.
16+
*/
17+
id?: string;
18+
/**
19+
* The currently bound conversation, when it has been successfully fetched.
20+
* Undefined while the conversation is new, still loading, or failed to load.
21+
*/
22+
conversation?: Conversation;
23+
}
24+
25+
export interface ChatUiEventsContract {
26+
/**
27+
* Emits the currently active conversation binding for both the embeddable sidebar and
28+
* the full-page routed chat. Emits `null` when no chat surface is currently bound.
29+
*
30+
* Backed by a `BehaviorSubject`: new subscribers receive the current value immediately.
31+
*/
32+
activeConversation$: Observable<ActiveConversation | null>;
33+
}
34+
1135
/**
1236
* Public-facing contract for AgentBuilder's events service.
1337
*/
@@ -16,4 +40,8 @@ export interface EventsServiceStartContract {
1640
* (hot) observable of all chat events.
1741
*/
1842
chat$: Observable<BrowserChatEvent>;
43+
/**
44+
* Chat UI-shell state observables.
45+
*/
46+
ui: ChatUiEventsContract;
1947
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@
66
*/
77

88
export type { BrowserChatEvent } from './events';
9-
export type { EventsServiceStartContract } from './contract';
9+
export type {
10+
EventsServiceStartContract,
11+
ChatUiEventsContract,
12+
ActiveConversation,
13+
} from './contract';

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ export type {
1616
} from './tools';
1717
export type { AgentsServiceStartContract } from './agents';
1818
export type { AttachmentUIDefinition, AttachmentServiceStartContract } from './attachments';
19-
export type { EventsServiceStartContract, BrowserChatEvent } from './events';
19+
export type {
20+
EventsServiceStartContract,
21+
ChatUiEventsContract,
22+
BrowserChatEvent,
23+
ActiveConversation,
24+
} from './events';
2025
export { WorkflowComboBox } from './workflow_combo_box';
2126
export type { WorkflowComboBoxProps, WorkflowComboBoxOption } from './workflow_combo_box';
2227
export { AgentBuilderAnnouncementModal } from './announcement_modal/agent_builder_announcement_modal';

x-pack/platform/plugins/shared/agent_builder/CONTRIBUTOR_GUIDE.md

Lines changed: 113 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -775,62 +775,135 @@ const myAttachmentType: AttachmentTypeDefinition<'my_type', MyContent> = {
775775

776776
Refer to [`AttachmentStaleCheckResult`](https://github.com/elastic/kibana/blob/main/x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/stale_check.ts) for the result types returned by the stale check API.
777777

778-
#### Attachment lifecycle hook: onAttachmentMount
778+
## Chat integration and pending attachments
779779

780-
The `onAttachmentMount` lifecycle hook allows you to run side effects when an attachment is mounted to a conversation, and clean them up when the attachment is removed.
780+
Plugins can integrate with the active chat surface (the embeddable sidebar and the full-page routed chat) through the `agentBuilder` start contract.
781781

782-
**When to use `onAttachmentMount`:**
782+
This is useful when the surrounding application wants to attach page context only under specific conditions, and react when the active chat binds to a new or existing conversation.
783783

784-
- Setting up subscriptions that should live for the duration of the attachment's presence in the conversation
785-
- Syncing attachment state with external systems
786-
- Any side effect that needs cleanup when the attachment is removed
784+
A **pending attachment** is a client-only attachment attached to the active conversation that has not yet been persisted to a round; it lives in the chat UI until the user submits the next message, at which point it is sent with that round and persisted.
787785

788-
**Important:** This hook is called once per attachment (not per version). The framework tracks attachment presence at the conversation level, so you don't need to handle deduplication.
786+
### `setChatConfig(...)`
789787

790-
**Parameters:**
788+
> **Scope:** sidebar only.
789+
790+
`setChatConfig(...)` configures the next sidebar open, or updates the active sidebar if it is already open.
791+
792+
It supports the regular embeddable conversation props, including:
793+
794+
- `newConversation` - force the sidebar to start a fresh conversation instead of restoring the persisted one
795+
- `attachments` - pre-populate the pending attachment list for the active sidebar conversation
796+
797+
Use `clearChatConfig()` to remove that runtime configuration.
798+
799+
#### `newConversation`
800+
801+
Set `newConversation: true` when the sidebar must always bind to a fresh conversation:
791802

792803
```ts
793-
interface AttachmentLifecycleParams<TAttachment> {
794-
/** The attachment instance */
795-
attachment: TAttachment;
796-
/** The conversation ID containing this attachment */
797-
conversationId: string;
798-
/** Whether the attachment is rendered in the sidebar context */
799-
isSidebar: boolean;
800-
}
804+
agentBuilder.setChatConfig({
805+
newConversation: true,
806+
});
801807
```
802808

803-
**Example: Syncing attachment origin when a dashboard is saved**
809+
#### `attachments`
804810

805-
```tsx
806-
export const myAttachmentDefinition: AttachmentUIDefinition<MyAttachment> = {
807-
getLabel: () => 'My attachment',
808-
getIcon: () => 'document',
811+
Set `attachments` when you want the sidebar to open with one or more pending attachments already present:
809812

810-
onAttachmentMount: ({ attachment, conversationId }) => {
811-
// Set up a subscription when the attachment is added
812-
const subscription = someObservable$.subscribe((newValue) => {
813-
if (newValue !== attachment.origin) {
814-
// Update the attachment's origin using the plugin API
815-
agentBuilder.updateAttachmentOrigin(conversationId, attachment.id, newValue);
816-
}
817-
});
813+
```ts
814+
agentBuilder.setChatConfig({
815+
attachments: [
816+
{
817+
id: 'my-context',
818+
type: 'my_type',
819+
data: { ... },
820+
},
821+
],
822+
});
823+
```
818824

819-
// Return cleanup function - called when the attachment is removed from the conversation
820-
return () => {
821-
subscription.unsubscribe();
822-
};
823-
},
825+
### `addAttachment(...)`
824826

825-
// ... other definition properties
826-
};
827+
> **Scope:** sidebar only. If no sidebar is open, the call is silently ignored.
828+
829+
`addAttachment(...)` adds or updates a pending attachment in the active sidebar conversation.
830+
831+
```ts
832+
agentBuilder.addAttachment({
833+
id: 'my-pending-context',
834+
type: 'my_type',
835+
data: { ... },
836+
origin: 'saved-object-id',
837+
});
838+
```
839+
840+
Pending attachments added through `agentBuilder.addAttachment(...)` can include an `origin` string, just like other attachment inputs sent to the Agent Builder APIs. Use this when your pending attachment already corresponds to a persistent resource (for example, a saved object-backed dashboard or visualization), and your attachment type expects `origin` to be present.
841+
842+
## Events
843+
844+
The `agentBuilder` start contract exposes observables on the `events.ui` namespace that let plugins react to the chat surface lifecycle (currently the active conversation binding).
845+
846+
### Observing sidebar open state
847+
848+
If you need to know whether the Agent Builder sidebar is currently open, subscribe to the core chrome sidebar primitive and match on the `agentBuilder` app id:
849+
850+
```ts
851+
useEffect(() => {
852+
const sub = chrome.sidebar.getCurrentAppId$().subscribe((appId) => {
853+
const isOpen = appId === 'agentBuilder';
854+
// react to the {isOpen} value
855+
});
856+
857+
return () => sub.unsubscribe();
858+
}, [chrome.sidebar]);
827859
```
828860

829-
**Cleanup behavior:**
861+
### `events.ui.activeConversation$`
862+
863+
Use `events.ui.activeConversation$` when you need to react to the conversation currently bound to the active chat surface.
864+
865+
The non-null payload is:
866+
867+
- `id?: string` - the currently bound conversation id, or `undefined` when the chat is currently bound to a new conversation
868+
- `conversation?: Conversation` - the fully loaded conversation when it has been successfully fetched (undefined for new conversations, while loading, or on fetch errors)
869+
870+
```ts
871+
class MyPlugin {
872+
private conversationSubscription?: Subscription;
873+
874+
start(core: CoreStart, { agentBuilder }: { agentBuilder: AgentBuilderPluginStart }) {
875+
this.conversationSubscription = agentBuilder.events.ui.activeConversation$.subscribe((change) => {
876+
if (!change) {
877+
// No chat surface currently bound — tear down local state.
878+
return;
879+
}
880+
881+
const { id, conversation } = change;
882+
883+
if (!id) {
884+
agentBuilder.addAttachment({
885+
id: 'my-pending-context',
886+
type: 'my_type',
887+
data: { ... },
888+
});
889+
return;
890+
}
891+
892+
const hasMyAttachment = conversation?.attachments?.some(
893+
(attachment) => attachment.id === 'my-pending-context'
894+
);
830895

831-
- The cleanup function is called when the attachment is removed from the conversation
832-
- It's also called when the conversation component unmounts (e.g., navigating away)
833-
- If `onAttachmentMount` returns `undefined` or `void`, no cleanup is performed
896+
if (!hasMyAttachment) {
897+
// Handle the switch away from the pending attachment in your plugin state.
898+
}
899+
});
900+
}
901+
902+
stop() {
903+
this.conversationSubscription?.unsubscribe();
904+
}
905+
}
906+
```
834907

835908
## Registering skills
836909

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

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ import { EuiFlexGroup } from '@elastic/eui';
99
import { i18n } from '@kbn/i18n';
1010
import React from 'react';
1111
import { useConversation, useConversationRounds } from '../../../hooks/use_conversation';
12-
import { useAgentBuilderServices } from '../../../hooks/use_agent_builder_service';
13-
import { useAttachmentLifecycle } from '../../../hooks/use_attachment_lifecycle';
14-
import { useConversationContext } from '../../../context/conversation/conversation_context';
1512
import { RoundLayout } from './round_layout';
1613

1714
const CONVERSATION_ROUNDS_ID = 'agentBuilderConversationRoundsContainer';
@@ -25,15 +22,6 @@ export const ConversationRounds: React.FC<ConversationRoundsProps> = ({
2522
}) => {
2623
const { conversation } = useConversation();
2724
const conversationRounds = useConversationRounds();
28-
const { attachmentsService } = useAgentBuilderServices();
29-
const { conversationActions } = useConversationContext();
30-
31-
useAttachmentLifecycle({
32-
attachments: conversation?.attachments,
33-
conversationId: conversation?.id,
34-
attachmentsService,
35-
invalidateConversation: conversationActions.invalidateConversation,
36-
});
3725

3826
return (
3927
<EuiFlexGroup
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 { useEffect } from 'react';
9+
import { useAgentBuilderServices } from '../../hooks/use_agent_builder_service';
10+
import { useConversation } from '../../hooks/use_conversation';
11+
import { useConversationId } from './use_conversation_id';
12+
13+
/**
14+
* Publishes the active conversation to the shared `EventsService` whenever the
15+
* conversation id or fetch state changes, and resets it to `null` when the
16+
* subtree unmounts (e.g. the user navigates away from the full-page chat or
17+
* closes the sidebar).
18+
*/
19+
export const ConversationChangeNotifier = (): null => {
20+
const { eventsService } = useAgentBuilderServices();
21+
const conversationId = useConversationId();
22+
const { conversation, isError, isFetched } = useConversation();
23+
24+
useEffect(() => {
25+
if (!conversationId) {
26+
eventsService.setActiveConversation({ id: undefined });
27+
return;
28+
}
29+
30+
if (isError) {
31+
eventsService.setActiveConversation({ id: conversationId });
32+
return;
33+
}
34+
35+
if (isFetched && conversation) {
36+
eventsService.setActiveConversation({ id: conversationId, conversation });
37+
}
38+
}, [conversationId, conversation, isError, isFetched, eventsService]);
39+
40+
useEffect(() => {
41+
return () => {
42+
eventsService.clearActiveConversation();
43+
};
44+
}, [eventsService]);
45+
46+
return null;
47+
};

x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/embeddable_conversations_provider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { upsertAttachmentsIntoList } from './upsert_attachments_into_list';
2020
import { AgentBuilderServicesContext } from '../agent_builder_services_context';
2121
import { SendMessageProvider } from '../send_message/send_message_context';
2222
import { useConversationActions } from './use_conversation_actions';
23+
import { ConversationChangeNotifier } from './conversation_change_notifier';
2324
import { usePersistedConversationId } from '../../hooks/use_persisted_conversation_id';
2425
import { AppLeaveContext } from '../app_leave_context';
2526

@@ -229,6 +230,7 @@ export const EmbeddableConversationsProvider: React.FC<EmbeddableConversationsPr
229230
<AgentBuilderServicesContext.Provider value={services}>
230231
<AppLeaveContext.Provider value={noopOnAppLeave}>
231232
<ConversationContext.Provider value={conversationContextValue}>
233+
<ConversationChangeNotifier />
232234
<SendMessageProvider>{children}</SendMessageProvider>
233235
</ConversationContext.Provider>
234236
</AppLeaveContext.Provider>

x-pack/platform/plugins/shared/agent_builder/public/application/context/conversation/routed_conversations_provider.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useAgentBuilderServices } from '../../hooks/use_agent_builder_service';
1818
import { useConversationActions } from './use_conversation_actions';
1919
import { queryKeys } from '../../query_keys';
2020
import { upsertAttachmentsIntoList } from './upsert_attachments_into_list';
21+
import { ConversationChangeNotifier } from './conversation_change_notifier';
2122

2223
interface RoutedConversationsProviderProps {
2324
children: React.ReactNode;
@@ -147,6 +148,9 @@ export const RoutedConversationsProvider: React.FC<RoutedConversationsProviderPr
147148
);
148149

149150
return (
150-
<ConversationContext.Provider value={contextValue}>{children}</ConversationContext.Provider>
151+
<ConversationContext.Provider value={contextValue}>
152+
<ConversationChangeNotifier />
153+
{children}
154+
</ConversationContext.Provider>
151155
);
152156
};

0 commit comments

Comments
 (0)