Skip to content

Add streaming to AI Insights#247644

Merged
yuliia-fryshko merged 46 commits intoelastic:mainfrom
yuliia-fryshko:add-streamimg-to-ai-insight
Jan 23, 2026
Merged

Add streaming to AI Insights#247644
yuliia-fryshko merged 46 commits intoelastic:mainfrom
yuliia-fryshko:add-streamimg-to-ai-insight

Conversation

@yuliia-fryshko
Copy link
Copy Markdown
Contributor

@yuliia-fryshko yuliia-fryshko commented Dec 30, 2025

Close #440

This PR adds streaming support to all AI insights (log, error, and alert insights) in the Observability Agent Builder.

Changes in the PR:

  1. Updated insight generation functions to return Observables instead of Promises: getLogAiInsights(),generateErrorAiInsight(), getAlertAiInsight()
  2. Added Stop generating button during streaming
  3. Added "Regenerate" button after stopping

Examples:

  1. Alert AI Insights:
Screen.Recording.2026-01-08.at.12.39.52.mov
  1. Error Ai Insights:
Screen.Recording.2026-01-08.at.12.43.17.mov
  1. Log Ai Insights:
Screen.Recording.2026-01-08.at.12.44.35.mov

@yuliia-fryshko yuliia-fryshko requested a review from a team as a code owner December 30, 2025 17:23
@yuliia-fryshko yuliia-fryshko self-assigned this Dec 30, 2025
@yuliia-fryshko yuliia-fryshko added release_note:skip Skip the PR/issue when compiling release notes backport:skip This PR does not require backporting labels Dec 30, 2025
DefaultClientOptions
>({ http });
const fetchInsight = async (signal?: AbortSignal) => {
const response = await http.fetch('/internal/observability_agent_builder/ai_insights/alert', {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why was the use of the API client reverted. We want to keep using the typed API client from server-route-repository, ideally.

</EuiButtonEmpty>
</ButtonSection>
) : wasStopped ? (
<ButtonSection>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • The regenerate button next to the "Start Conversation" (similar to contextual insights) might look a bit better I feel.
  • Also can you add a "loading animation/cursor" similar to contextual insights
Screen.Recording.2025-12-30.at.12.55.57.PM.mov

</EuiFlexGroup>
</>
{isLoading ? (
<ButtonSection>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When the buttons are outside of the EuiPanel it looks a bit odd, can we move them inside the panel similar to contextual insights?

@yuliia-fryshko yuliia-fryshko force-pushed the add-streamimg-to-ai-insight branch from fcb4247 to be99599 Compare January 2, 2026 09:05
const fetchInsight = async () => {
const response = await apiClient.fetch(
const fetchInsight = async (signal?: AbortSignal) => {
const response = (await apiClient.fetch(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you make apiClient a hook while we're in here as we're repeating some code in the insight components. Something like:

export function useApiClient() {
  const {
    services: { http },
  } = useKibana();

  return createRepositoryClient<
    ObservabilityAgentBuilderServerRouteRepository,
    DefaultClientOptions
  >({ http });
}

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.

Thank you, @neptunian ! It's a good suggestion, I created the hook use_api_client

Copy link
Copy Markdown
Contributor

@viduni94 viduni94 left a comment

Choose a reason for hiding this comment

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

Thanks for the changes @yuliia-fryshko
Added some comments.

Can you also add tests for the observability_agent_builder/public/components/ai_insight/ai_insight.tsx component and use_streaming_ai_insight hook?

import { AbortError } from '@kbn/kibana-utils-plugin/common';
import { httpResponseIntoObservable } from '@kbn/sse-utils-client';

interface InsightState {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should this be

Suggested change
interface InsightState {
interface InsightResponse {

},
{ summary: '', context: '' }
),
takeUntil(abort$)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of having setIsLoading(false); in line 36, you probably could do:

Suggested change
takeUntil(abort$)
takeUntil(abort$),
finalize(() => setIsLoading(false))

This guarantees that setIsLoading(false) runs regardless of how the stream ends (complete, error, or unsubscribe)

takeUntil(abort$)
);

const subscription = observable$.subscribe({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
const subscription = observable$.subscribe({
subscriptionRef.current = observable$.subscribe({

},
});

subscriptionRef.current = subscription;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
subscriptionRef.current = subscription;

Comment on lines +72 to +75
asResponse: true,
rawResponse: true,
}
);
return response;
)) as unknown as { response: Response };
Copy link
Copy Markdown
Contributor

@viduni94 viduni94 Jan 5, 2026

Choose a reason for hiding this comment

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

Can we avoid manually adding asResponse and rawResponse to each endpoint and also can we avoid as unknown as?
Instead, you can use the inbuilt stream method from the API client:

const createStream = useCallback(
    (signal: AbortSignal) =>
      apiClient.stream('POST /internal/observability_agent_builder/ai_insights/log', {
        signal,
        params: {
          body: {
            alertId,
          },
        },
      }),
    [apiClient, alertId]
  );

Note: This may require some changes in the use_streaming_ai_insight hook

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.

Thank you, Viduni! For pointing to it, I wanted refactor it as well a bit .

Replaced manual asResponse/rawResponse flags and type casting with apiClient.stream(), updated useStreamingAiInsight to accept Observable<StreamEvent> instead of Promise<Response>

Comment on lines +60 to +63
asResponse: true,
rawResponse: true,
}
);
return {
summary: response.summary ?? '',
context: response.context ?? '',
};
)) as unknown as { response: Response };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Comment on lines +47 to +50
asResponse: true,
rawResponse: true,
}
);
return {
summary: response.summary,
context: response.context,
};
)) as unknown as { response: Response };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

onClick={regenerate}
>
{i18n.translate('xpack.observabilityAgentBuilder.aiInsight.regenerateButton', {
defaultMessage: 'Regenerate',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you move the regenerate button to the right side?
Having it in the left side looks odd.

Image

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.

Done

</EuiFlexItem>
</EuiFlexGroup>
</>
{isLoading ? (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you add a loading cursor animation when the response is loading? Something similar to what we had in contextual insights?

Also I feel like moving "Stop generating" and other buttons inside the EuiPanel similar to contextual insights looks better. WDYT?

Image Image

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.

Good suggestion. @viduni94 ! Changed it

try {
const observable$ = createStream(abortController.signal).pipe(
filter(
(event: InsightStreamEvent): event is InsightStreamEvent =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This type guard seems redundant as the input is already typed as InsightStreamEvent

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.

Thank you, Viduni! I removed alsp the condition checks for 'context' | 'chatCompletionChunk' | 'chatCompletionMessage', which are the only possible values of InsightStreamEvent.

Comment on lines +93 to +123
const observable$ = createStream(abortController.signal).pipe(
filter(
(event: InsightStreamEvent): event is InsightStreamEvent =>
event.type === 'context' ||
event.type === 'chatCompletionChunk' ||
event.type === 'chatCompletionMessage'
),
map((event: InsightStreamEvent): ParsedEvent => {
if (event.type === 'context') {
return { type: 'context', context: event.context };
}
if (event.type === 'chatCompletionChunk') {
return { type: 'chunk', content: event.content };
}
return { type: 'message', content: event.content };
}),
scan<ParsedEvent, InsightResponse>(
(acc, event) => {
if (event.type === 'context') {
return { ...acc, context: event.context || '' };
}
if (event.type === 'chunk') {
return { ...acc, summary: acc.summary + (event.content || '') };
}
if (event.type === 'message') {
return { ...acc, summary: event.content || '' };
}
return acc;
},
{ summary: '', context: '' }
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need these intermediate mapping steps?

Can this be simplified as:

Suggested change
const observable$ = createStream(abortController.signal).pipe(
filter(
(event: InsightStreamEvent): event is InsightStreamEvent =>
event.type === 'context' ||
event.type === 'chatCompletionChunk' ||
event.type === 'chatCompletionMessage'
),
map((event: InsightStreamEvent): ParsedEvent => {
if (event.type === 'context') {
return { type: 'context', context: event.context };
}
if (event.type === 'chatCompletionChunk') {
return { type: 'chunk', content: event.content };
}
return { type: 'message', content: event.content };
}),
scan<ParsedEvent, InsightResponse>(
(acc, event) => {
if (event.type === 'context') {
return { ...acc, context: event.context || '' };
}
if (event.type === 'chunk') {
return { ...acc, summary: acc.summary + (event.content || '') };
}
if (event.type === 'message') {
return { ...acc, summary: event.content || '' };
}
return acc;
},
{ summary: '', context: '' }
),
const observable$ = createStream(abortController.signal).pipe(
scan<InsightStreamEvent, InsightResponse>(
(acc, event) => {
if (event.type === 'context') {
return { ...acc, context: event.context };
}
if (event.type === 'chatCompletionChunk') {
return { ...acc, summary: acc.summary + event.content };
}
if (event.type === 'chatCompletionMessage') {
return { ...acc, summary: event.content };
}
return acc;
},
{ summary: '', context: '' }
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Btw, I didn't test the above suggestion. Feel free to make any adjustments after testing.

expect(result.current.error).toBe('Boom');
});
unmount();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you add a test that tests calling stop() aborts the stream?

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.

Good suggestion , I checked the hook and also added a test that verifies the regenerate button appears after stopping the stream

Comment on lines +12 to +16
interface ParsedEvent {
type: 'context' | 'chunk' | 'message';
context?: string;
content?: string;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

See comment - https://github.com/elastic/kibana/pull/247644/changes#r2705477560

Suggested change
interface ParsedEvent {
type: 'context' | 'chunk' | 'message';
context?: string;
content?: string;
}

Comment on lines +86 to +89
abortController.signal.addEventListener('abort', () => {
subscriber.next();
subscriber.complete();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This event listener is added and not removed and can cause a memory leak.

Suggested change
abortController.signal.addEventListener('abort', () => {
subscriber.next();
subscriber.complete();
});
const handler = () => {
subscriber.next();
subscriber.complete();
};
abortController.signal.addEventListener('abort', handler);
return () => abortController.signal.removeEventListener('abort', handler);

Comment on lines +47 to +49
if (typeof index !== 'string' || typeof id !== 'string') {
throw new Error('Index and id must be strings');
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we need to throw an error here?
There is a check for this already in line 63 and there is no way createStream can be called if the conditions of this check are satisfied yeah?

Instead you can do a type assertion for the params in line 54 and 55

index: index as string,
id: id as string,

unmount();
});

it('shows error banner and retries on click', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
it('shows error banner and retries on click', () => {
it('displays an error banner with a retry button when an error occurs that refetches insights when clicked', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

OR you could consider splitting this into 2 tests and wrap in a describe block.

Example:

describe('when an error occurs', () => {
  it('displays an error banner', () => { ... });
  it('refetches insights when retry button is clicked', () => { ... });
});

unmount();
});

it('renders regenerate and start conversation actions when summary exists', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test is testing too many things in a single test. Consider splitting is as follows:

Example:

describe('when a summary has been generated', () => {
  it('displays regenerate and start conversation buttons', () => {
    // Just check buttons exist
  });

  it('calls regenerate when regenerate button is clicked', () => {
    // Test regenerate click
  });

  it('opens the conversation flyout with correct attachments when start conversation is clicked', () => {
    // Test start conversation click
  });
});

Comment on lines +210 to +215
<EuiButtonEmpty
data-test-subj="observabilityAgentBuilderRegenerateButton"
size="s"
iconType="sparkles"
onClick={regenerate}
>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We decided not to show the Regenerate button if there is no error yeah?
(Because we can't edit the prompt, there is no point in regenerating as the response will most likely be the same. Ideally it should only be shown if the stream was stopped mid response - and not show when the stream completes)

Did that decision change? (Maybe I missed something)

Image

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.

I’m fine with removing it. my understanding was that we wouldn’t want to add it as a separate ticket, because it would make sense to handle it within the prompt edit. But since we’re implementing the stop functionality, it wouldn’t hurt to keep it there anyway - we know that the LLM can give slightly different answers each time we call it.

Sorry if this was my misunderstanding. I will remove it, and thank you for pointing it out.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we know that the LLM can give slightly different answers each time we call it

I don't think a slight difference adds any value without being able to edit the prompt. If we decide to add the prompt editing functionality, we can introduce the Regenerate button at that point. For now, I think it's sufficient to show only when the stream is stopped mid-generation.

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.

Yes, I removed the Regenerate btn in case we have received a full response

@viduni94
Copy link
Copy Markdown
Contributor

Merging main to pull in this fix - #249799

expect(buildAttachments).toHaveBeenCalledWith('Hello world', 'context');
expect(mockOpenConversationFlyout).toHaveBeenCalledWith({
newConversation: true,
attachments: [{ type: 'test', data: {} }],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you check for the agentId for OBSERVABILITY_AGENT_ID in here too?

height: 16px;
margin-left: 2px;
vertical-align: middle;
background-color: ${euiTheme.colors.darkShade};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks a little too dark. Is there a slightly lighter version?

Image

});

describe('when an error occurs', () => {
it('displays an error banner', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The test says "displays an error banner" but doesn't actually verify the error banner and message. We should verify that as well.

<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<StartConversationButton onClick={handleStartConversation} />
Copy link
Copy Markdown
Contributor

@viduni94 viduni94 Jan 21, 2026

Choose a reason for hiding this comment

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

I can start a conversation when the summary is emtpy

  • Click on the insight
  • Immediately click on Stop Generation --> The content is empty
  • "Start conversation" button is shown --> Click that and continue the conversation

Notice how an empty string is passed as the summary

image
Screen.Recording.2026-01-21.at.5.22.07.PM.mov

We shouldn't allow starting a conversation when the summary is empty.

Copy link
Copy Markdown
Contributor Author

@yuliia-fryshko yuliia-fryshko Jan 21, 2026

Choose a reason for hiding this comment

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

Good catch, @viduni94 ! So, when the user clicked "stop conversation" we should show only "Regenerate " btn (without "Start conversation"), right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should definitely not show "Start conversation" when the summary is empty.

When there's a partial result:

  • Old contextual results show the "Start conversation" button
  • However, I'm not sure whether there's any value in starting a conversation with a partial summary - maybe there is.
image

Maybe worth checking with @isaclfreire

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.

Hmm, I definitely think that if the summary is empty, it doesn’t make sense to start the conversation. But when the summary is partial, I think it depends on which parts are available. I don’t have a strong opinion on this.

I’d propose removing the “Start conversation” button when the summary is empty, and keeping it when the summary is partial. That said, I’m flexible and happy to change this now or later.

@viduni94

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.

Screen.Recording.2026-01-22.at.20.02.37.mov

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sounds good! Let's leave it this way and update later if it comes up.

logger,
signal: getRequestAbortedSignal(request),
}),
}) as IKibanaResponse;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

as IKibanaResponse is only included in 2 out of the 3 routes.
Can we remove it in all?

logger,
signal: getRequestAbortedSignal(request),
}),
}) as IKibanaResponse;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

as IKibanaResponse is only included in 2 out of the 3 routes.
Can we remove it in all?

@viduni94
Copy link
Copy Markdown
Contributor

Thanks for the changes @yuliia-fryshko
The PR mostly looks good! The only blocker is this one - #247644 (comment)

Copy link
Copy Markdown
Contributor

@viduni94 viduni94 left a comment

Choose a reason for hiding this comment

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

LGTM
Left a few comments about readability, cursor in dark mode and API client memoizing.

Thanks for all the changes @yuliia-fryshko

Comment on lines +154 to +209
{isLoading ? (
<>
<EuiSpacer size="m" />
<EuiHorizontalRule margin="none" />
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexStart" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="observabilityAgentBuilderStopGeneratingButton"
color="text"
iconType="stop"
size="s"
onClick={stop}
>
{i18n.translate(
'xpack.observabilityAgentBuilder.aiInsight.stopGeneratingButton',
{
defaultMessage: 'Stop generating',
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</>
) : wasStopped ? (
<>
<EuiSpacer size="m" />
<EuiHorizontalRule margin="none" />
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="observabilityAgentBuilderRegenerateButton"
size="s"
iconType="sparkles"
onClick={regenerate}
>
{i18n.translate('xpack.observabilityAgentBuilder.aiInsight.regenerateButton', {
defaultMessage: 'Regenerate',
})}
</EuiButtonEmpty>
</EuiFlexItem>
{Boolean(summary && summary.trim()) && (
<EuiFlexItem grow={false}>
<StartConversationButton onClick={handleStartConversation} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
) : Boolean(summary && summary.trim()) ? (
<>
<EuiSpacer size="m" />
<EuiHorizontalRule margin="none" />
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All the conditions here makes it very hard to read this component.
Can we improve it with something like:

const hasSummary = Boolean(summary?.trim());

const renderFooter = () => {
    if (isLoading) {
      return (
        <EuiFlexGroup justifyContent="flexStart" gutterSize="s" responsive={false}>
          <EuiFlexItem grow={false}>
            <EuiButtonEmpty
              data-test-subj="observabilityAgentBuilderStopGeneratingButton"
              color="text"
              iconType="stop"
              size="s"
              onClick={stop}
            >
              {i18n.translate('xpack.observabilityAgentBuilder.aiInsight.stopGeneratingButton', {
                defaultMessage: 'Stop generating',
              })}
            </EuiButtonEmpty>
          </EuiFlexItem>
        </EuiFlexGroup>
      );
    }

    if (wasStopped) {
      return (
        <EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}>
          <EuiFlexItem grow={false}>
            <EuiButtonEmpty
              data-test-subj="observabilityAgentBuilderRegenerateButton"
              size="s"
              iconType="sparkles"
              onClick={regenerate}
            >
              {i18n.translate('xpack.observabilityAgentBuilder.aiInsight.regenerateButton', {
                defaultMessage: 'Regenerate',
              })}
            </EuiButtonEmpty>
          </EuiFlexItem>
          {hasSummary && (
            <EuiFlexItem grow={false}>
              <StartConversationButton onClick={handleStartConversation} />
            </EuiFlexItem>
          )}
        </EuiFlexGroup>
      );
    }

    if (hasSummary) {
      return (
        <EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}>
          <EuiFlexItem grow={false}>
            <StartConversationButton onClick={handleStartConversation} />
          </EuiFlexItem>
        </EuiFlexGroup>
      );
    }

    return null;
  };

// already exists
if (
    !hasConnectors ||
    !agentBuilder ||
    !isAgentChatExperienceEnabled ||
    !hasAgentBuilderAccess ||
    !hasEnterpriseLicense
  ) {
    return null;
  }

const footer = renderFooter();

Then in the JSX:

<EuiPanel color="subdued">
          {error ? (
            <AiInsightErrorBanner error={error} onRetry={fetch} />
          ) : (
            <EuiText size="s">
              <EuiMarkdownFormat textSize="s">{summary}</EuiMarkdownFormat>
              {isLoading && <LoadingCursor />}
            </EuiText>
          )}

          {footer && (
            <>
              <EuiSpacer size="m" />
              <EuiHorizontalRule margin="none" />
              <EuiSpacer size="s" />
              {footer}
            </>
          )}
        </EuiPanel>

WDYT?

height: 16px;
margin-left: 2px;
vertical-align: middle;
background: rgba(0, 0, 0, 0.25);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This won't work in dark mode

Screenshot 2026-01-22 at 2 44 04 PM

Maybe let's stick with euiTheme.colors.textSubdued

ObservabilityAgentBuilderServerRouteRepository,
DefaultClientOptions
>({ http });
const apiClient = useApiClient();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This re-creates the API client object on every render. Consider memoizing it in use_api_client.

import { useMemo } from 'react';

export function useApiClient(): RouteRepositoryClient<...> {
  const {
    services: { http },
  } = useKibana();

  return useMemo(
    () => createRepositoryClient<
      ObservabilityAgentBuilderServerRouteRepository,
      DefaultClientOptions
    >({ http }),
    [http]
  );
}

@elasticmachine
Copy link
Copy Markdown
Contributor

💚 Build Succeeded

Metrics [docs]

Module Count

Fewer modules leads to a faster build time

id before after diff
observabilityAgentBuilder 58 61 +3

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
observabilityAgentBuilder 15.8KB 18.2KB +2.4KB

Public APIs missing exports

Total count of every type that is part of your API that should be exported but is not. This will cause broken links in the API documentation system. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats exports for more detailed information.

id before after diff
observabilityAgentBuilder 2 1 -1

Page load bundle

Size of the bundles that are downloaded on every page load. Target size is below 100kb

id before after diff
observabilityAgentBuilder 6.4KB 6.6KB +139.0B

History

cc @yuliia-fryshko

@yuliia-fryshko yuliia-fryshko enabled auto-merge (squash) January 23, 2026 16:06
@yuliia-fryshko yuliia-fryshko merged commit 577ad1e into elastic:main Jan 23, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:skip This PR does not require backporting release_note:skip Skip the PR/issue when compiling release notes v9.4.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants