# AI chat widget

The AI chat widget is a chat bubble at the bottom right of an app that opens the user's conversation with an AI assistant. It works on top of an app that already has a chat, such as the base app, and on its own, in an app that only wants the assistant.

>
> **Note:** **What you'll build**
>
> - A round button that opens the assistant's window
> - A small floating window with the conversation
> - Status text and replies that stream in
>

---

**Preview**

![The base app with the Design review channel open, and the assistant window floating at the bottom right above a round blue close button: a header reading Assistant with a close button, then the user asking for something light and the assistant suggesting a warm, funny story, and a composer at the bottom](https://getstream.io/docs-assets/images/ebf0b42e41fd.png)

---

**Approach:** Compose. [Choosing where to start](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#choosing-where-to-start) explains the approaches.

**Starts from:** [the base app](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#the-base-app). The page shows the widget, then how to add it to the base app and to an app without a chat of its own.

>
> **Note:** The widget only shows the conversation. The assistant's replies come from an AI agent on your backend, which watches the channel, sends each reply as the assistant user, and reports what it's doing with `ai_indicator.*` events. [AI integrations overview](https://getstream.io/chat/docs/sdk/react/features/ai/ai-integrations-overview/) explains how the backend drives the reply. The cookbook's demo app is read-only and has no agent, so nothing answers a message sent there.
>

## Best practices

- Give each user one channel with the assistant, created by your backend, and pass the widget its id. The widget watches the channel the first time the window opens, so a user who never opens it costs no request.
- Keep the window non-modal. It's a side conversation the user may read while using the rest of the page, so it has no backdrop and doesn't trap focus: Tab moves out of it as through any other part of the page. It's a `dialog` without `aria-modal`, named by its heading.
- Give the launcher `aria-expanded`, and `aria-controls` pointing to the window while it's open.
- Move focus into the window when it opens and back to the launcher when it closes. Let Escape close it, but only after a menu, picker or suggestion list inside it has closed.
- Mark the assistant's replies on the backend, and tell `Chat` how to recognise them with `isMessageAIGenerated`, so the message list types them out as they stream in.
- In an app with a channel list, keep the assistant's channel out of that list, so the conversation has one place. Keep it out of the app's channel search too, with the same filter in `ChannelSearchSource.filters`.

## Build the widget

`AssistantWidget` renders the launcher, and the window while it's open. The window renders `Channel` with the SDK's `MessageList`, `AIStateIndicator` and `MessageComposer`, so messages, reactions and uploads work as they do in the base app's channel.

- `useAssistantChannel()` gets the channel with `client.channel()` and loads it with `Channel.watch()` the first time the window opens. `Channel` doesn't query the channel it's given, so the window shows a `LoadingIndicator` until the watch resolves, and a short note if it fails. The channel stays loaded when the window closes, so opening it again is instant, and the hook watches another channel when `channelId` changes.
- `AIStateIndicator` from `stream-chat-react` follows the channel's AI state: it shows "Thinking..." and "Generating..." between the list and the composer while the backend reports those states, and nothing otherwise. [Streamed answers with a stop button](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/ai/streamed-answers-with-stop-button/) shows the whole streaming flow, with an indicator for every state.
- `MessageComposer` gets `focus`, so the text input takes focus when the window opens. Focus lands inside the `dialog`, so a screen reader announces the window's name too. `close()` moves focus back to the launcher, since the element that had it is gone.
- `closeOnEscape` closes the window on Escape, unless something inside it used the key first. `isEscapeForWindow()` checks that: the key was pressed in the window or on the launcher, not in a modal the window opened, such as the location picker (SDK modals render in a portal, but React still bubbles their keydown to the widget, and that Escape closes only the modal); the composer marks the event handled when Escape leaves a command; and `hasOpenPopup()` finds an open menu, picker or suggestion list by its toggle's `aria-expanded` or the text input's `aria-controls`. While the window is open, the handler stops every Escape inside the widget from reaching the page, so a page-level shortcut, such as the channel list's search, which focuses its input on any Escape, doesn't take focus away.
- The launcher is the SDK's `Button`, with the `IconMessageBubble` and `IconXmark` icons from `useComponentContextIcons()`, so an app that replaces the icon set replaces them here too.
- `.cookbook-assistant-widget` pins the launcher and the window to the bottom right, with the window above the launcher. The window is the widget's own markup, so the CSS positions it freely; it never sets layout on the SDK's classes. 96px from the bottom clears the composer of the base app's channel; on a page without one, set it lower.

```tsx label="React" title="src/AssistantWidget.tsx"
import { useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import type { Channel as StreamChannel } from "stream-chat";
import {
  AIStateIndicator,
  Button,
  Channel,
  LoadingIndicator,
  MessageComposer,
  MessageList,
  useChatContext,
  useComponentContextIcons,
} from "stream-chat-react";
import "./AssistantWidget.css";

/**
 * Watches the user's channel with the assistant the first time the window opens, and keeps it
 * afterwards, until `channelId` names another channel. A user who never opens the window costs no
 * request.
 */
const useAssistantChannel = (channelId: string, isOpen: boolean) => {
  const { client } = useChatContext();
  const [channel, setChannel] = useState<StreamChannel>();
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    if (!isOpen || channel?.id === channelId) return;
    let cancelled = false;
    setFailed(false);
    client
      .channel("messaging", channelId)
      .watch()
      .then((watched) => !cancelled && setChannel(watched))
      .catch(() => !cancelled && setFailed(true));
    return () => {
      cancelled = true;
    };
  }, [channel, channelId, client, isOpen]);

  return { channel, failed };
};

/**
 * A menu, picker or suggestion list inside the window is open, so Escape closes that first. Their
 * toggles carry `aria-expanded="true"`, and the composer's textarea carries `aria-controls` while
 * its suggestions show.
 */
const hasOpenPopup = (container: HTMLElement | null) =>
  !!container?.querySelector('[aria-expanded="true"], textarea[aria-controls]');

/**
 * The Escape was pressed in the window or on the launcher, and nothing inside the window used it,
 * as the composer does to leave a command. A modal opened from the window renders in a portal, but
 * React still bubbles its keydown here, and that Escape closes only the modal.
 */
const isEscapeForWindow = (
  event: KeyboardEvent,
  container: HTMLElement | null,
  launcher: HTMLElement | null,
) => {
  const target = event.target as Node;
  return (
    (!!container?.contains(target) || !!launcher?.contains(target)) &&
    !event.defaultPrevented &&
    !hasOpenPopup(container)
  );
};

type AssistantWidgetProps = {
  /** The id of the user's `messaging` channel with the assistant. */
  channelId: string;
  /** The window's heading, which also names it for assistive technology. */
  title?: string;
};

/**
 * A launcher button at the bottom right of the page that opens a small, non-modal chat window with
 * the assistant. Escape or the close button closes it and gives focus back to the launcher.
 */
export const AssistantWidget = ({
  channelId,
  title = "Assistant",
}: AssistantWidgetProps) => {
  const { IconMessageBubble, IconXmark } = useComponentContextIcons();
  const [isOpen, setIsOpen] = useState(false);
  const { channel, failed } = useAssistantChannel(channelId, isOpen);
  const launcherRef = useRef<HTMLButtonElement>(null);
  const windowRef = useRef<HTMLElement>(null);
  const windowId = useId();
  const titleId = useId();

  const close = () => {
    setIsOpen(false);
    launcherRef.current?.focus();
  };

  const closeOnEscape = (event: KeyboardEvent) => {
    if (event.key !== "Escape" || !isOpen) return;
    // Escape inside the open widget is the widget's, so handlers on the page don't act on it too,
    // such as the channel list's search, which focuses its input on any Escape.
    event.stopPropagation();
    if (!isEscapeForWindow(event, windowRef.current, launcherRef.current))
      return;
    event.preventDefault();
    close();
  };

  return (
    <div className="cookbook-assistant-widget" onKeyDown={closeOnEscape}>
      <Button
        appearance="solid"
        aria-controls={isOpen ? windowId : undefined}
        aria-expanded={isOpen}
        aria-label={`Chat with ${title}`}
        circular
        className="cookbook-assistant-launcher"
        onClick={() => (isOpen ? close() : setIsOpen(true))}
        ref={launcherRef}
        size="lg"
        variant="primary"
      >
        {isOpen ? <IconXmark /> : <IconMessageBubble />}
      </Button>
      {isOpen && (
        <section
          aria-labelledby={titleId}
          className="cookbook-assistant-window"
          id={windowId}
          ref={windowRef}
          role="dialog"
        >
          <header className="cookbook-assistant-window__header">
            <h2 className="cookbook-assistant-window__title" id={titleId}>
              {title}
            </h2>
            <Button
              appearance="ghost"
              aria-label={`Close ${title}`}
              circular
              onClick={close}
              size="md"
              variant="secondary"
            >
              <IconXmark />
            </Button>
          </header>
          <div className="cookbook-assistant-window__body">
            {channel ? (
              <Channel channel={channel}>
                <MessageList />
                <AIStateIndicator />
                {/* Focus moves to the text input when the window opens. */}
                <MessageComposer focus />
              </Channel>
            ) : failed ? (
              <p className="cookbook-assistant-window__note" role="alert">
                Couldn’t open the conversation. Close the window and try again.
              </p>
            ) : (
              <LoadingIndicator />
            )}
          </div>
        </section>
      )}
    </div>
  );
};
```

```css label="CSS" title="src/AssistantWidget.css"
@layer stream-overrides {
  /*
   * Pinned to the bottom right of the page, the window stacked above the launcher. 96px clears a
   * composer along the bottom of the page, such as the base app's.
   */
  .cookbook-assistant-widget {
    position: fixed;
    z-index: 10;
    inset-block-end: 96px;
    inset-inline-end: var(--str-chat__spacing-lg);
    display: flex;
    flex-direction: column-reverse;
    align-items: flex-end;
    gap: var(--str-chat__spacing-sm);
  }

  .cookbook-assistant-launcher {
    box-shadow: var(--str-chat__box-shadow-3);
  }

  /* A compact card, never wider or taller than the page leaves room for. */
  .cookbook-assistant-window {
    display: flex;
    flex-direction: column;
    width: min(380px, calc(100vw - 2 * var(--str-chat__spacing-lg)));
    height: min(520px, calc(100dvh - 200px));
    overflow: hidden;
    border: 1px solid var(--str-chat__border-core-default);
    border-radius: var(--str-chat__radius-xl);
    background: var(--str-chat__background-core-elevation-1);
    box-shadow: var(--str-chat__box-shadow-3);
    color: var(--str-chat__text-primary);
  }

  .cookbook-assistant-window__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--str-chat__spacing-sm);
    padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-sm)
      var(--str-chat__spacing-sm) var(--str-chat__spacing-md);
    border-block-end: 1px solid var(--str-chat__border-core-default);
  }

  .cookbook-assistant-window__title {
    margin: 0;
    font: var(--str-chat__font-heading-sm);
  }

  /* The channel fills the space under the header. */
  .cookbook-assistant-window__body {
    display: flex;
    flex: 1;
    flex-direction: column;
    min-height: 0;
  }

  .cookbook-assistant-window__note {
    margin: 0;
    padding: var(--str-chat__spacing-md);
    color: var(--str-chat__text-secondary);
    font: var(--str-chat__font-body-default);
  }
}
```

Closed, the widget is only the launcher, above the channel's send button:

---

**The closed widget's launcher**

![The bottom right of the base app with the round blue launcher showing a speech bubble icon, above the channel composer's send button](https://getstream.io/docs-assets/images/44f7a3403d10.png)

---

## Recognise the assistant's replies

The backend marks each reply of the assistant with the custom field `ai_generated`. `isMessageAIGenerated()` reads it. Passed to `Chat`, it makes `MessageUI` render the text of those messages with `StreamedMessageText`, which types out whatever the text grows by, and leaves out the "Edited" label every streamed update would add. A reply already stored shows in full. [SDK integration](https://getstream.io/chat/docs/sdk/react/features/ai/sdk-integration/#streamedmessagetext) explains `StreamedMessageText`, and [Mark assistant messages with `ai_generated`](https://getstream.io/chat/docs/sdk/react/features/ai/ai-integrations-overview/#mark-assistant-messages-with-ai_generated) how the backend sets the field.

```ts title="src/isMessageAIGenerated.ts"
import type { LocalMessage } from "stream-chat";

declare module "stream-chat" {
  // The field the backend sets on the assistant's replies. Optional, because every message in the
  // app shares this interface.
  interface CustomMessageData {
    ai_generated?: boolean;
  }
}

/** The assistant wrote the message: the backend marks its replies with `ai_generated`. */
export const isMessageAIGenerated = (message: LocalMessage) =>
  !!message.custom?.ai_generated;
```

## Add it to the base app

Render the widget as a child of `ChatView`, and pass `isMessageAIGenerated` to `Chat`. The rest of the [base app](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#the-base-app) stays as it is:

```tsx title="src/App.tsx"
// The base app's App, with these changes.
import { AssistantWidget } from "./AssistantWidget";
import { isMessageAIGenerated } from "./isMessageAIGenerated";

// The user's channel with the assistant, which your backend creates.
const assistantChannelId = "YOUR_ASSISTANT_CHANNEL_ID";

// In App:
return (
  <Chat client={client} isMessageAIGenerated={isMessageAIGenerated}>
    <SidePanelProvider>
      <ChatView layouts={layouts} views={{ channels: <ChannelsView /> }}>
        <AssistantWidget channelId={assistantChannelId} />
      </ChatView>
    </SidePanelProvider>
  </Chat>
);
```

`ChatView`'s root carries the `str-chat` class, so the widget gets the SDK's design tokens from it. The widget doesn't bind its channel into a layout slot, so it stays open while the user switches channels and views.

The base app's channel list shows every `messaging` channel the user is a member of, so it would list the assistant's channel too. Give the assistant's channel a channel type of its own, created in the Dashboard, and the channel paginator's `type: "messaging"` filter leaves it out; the widget then calls `client.channel()` with that type. The cookbook's demo app has only the `messaging` type, so its channel list leaves the channel out by id instead.

## Use it on its own

An app without a chat of its own needs only `Chat` around the widget. `Chat` renders no element, and the SDK's design tokens are set on elements with the `str-chat` class, such as the roots of `ChatView` and `Channel`. Without `ChatView`, wrap the widget in an element with that class, so the launcher and the window's header get the tokens too. [Theming](https://getstream.io/chat/docs/sdk/react/features/app-experience/theming/#design-tokens) explains where the tokens are set.

```tsx title="src/App.tsx"
import { Chat, useCreateChatClient } from "stream-chat-react";
import { AssistantWidget } from "./AssistantWidget";
import { isMessageAIGenerated } from "./isMessageAIGenerated";

const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";
// The user's channel with the assistant, which your backend creates.
const assistantChannelId = "YOUR_ASSISTANT_CHANNEL_ID";

/**
 * An app that has no chat of its own, only the widget. Load the SDK's stylesheet in a CSS layer, as
 * the base app's `chat.css` does, so the widget's CSS wins over it.
 */
export const App = () => {
  const client = useCreateChatClient({
    apiKey,
    tokenOrProvider: userToken,
    userData: { id: userId },
  });

  return (
    <>
      <main>{/* Your app's own pages. */}</main>
      {client && (
        <Chat client={client} isMessageAIGenerated={isMessageAIGenerated}>
          {/* The SDK's tokens are set on `.str-chat`; without ChatView, the widget needs one. */}
          <div className="str-chat">
            <AssistantWidget channelId={assistantChannelId} />
          </div>
        </Chat>
      )}
    </>
  );
};
```

Load the SDK's stylesheet in a CSS layer before the widget's, as the base app's `chat.css` does, so the widget's rules win over the SDK's.

## How it works

>
> **Info:** **Example**
>
> The user has a question for the assistant while reading the Design review channel:
>
> 1. The user clicks the launcher. `AssistantWidget` sets `isOpen`, sets `aria-expanded` to `true` and points `aria-controls` at the window, and renders the window with a `LoadingIndicator`.
> 2. `useAssistantChannel()` calls `Channel.watch()` on the assistant's channel. When it resolves, the window renders `Channel` with the message list and the composer, and the text input takes focus.
> 3. The user sends a question. The backend's agent posts an empty reply marked `ai_generated`, then sends `ai_indicator.update` with `AI_STATE_THINKING`. The client sets `channel.state.aiState`, and `AIStateIndicator` shows "Thinking...".
> 4. The agent writes the answer into the reply a few words at a time. Each update reaches the client as `message.updated`, and `StreamedMessageText` types out the new part.
> 5. The agent stores the final text and sends `ai_indicator.clear`. The indicator goes away.
> 6. The user presses Escape. Nothing inside the window is open, so `closeOnEscape` closes the window, and focus goes back to the launcher.
>

- The window renders a second `Channel` beside the base app's. `Channel.activate()` claims are counted, so closing the window doesn't deactivate a channel the layout also shows.
- The message actions inside the window are the same as in the rest of the app. Reply in Thread opens the thread in the layout's `thread` slot, beside the channel, not in the window; without `ChatView`, it does nothing. Turn off replies for the assistant's channel type if your assistant doesn't answer in threads.
- `AI_STATE_EXTERNAL_SOURCES` shows no indicator, since `AIStateIndicator` from `stream-chat-react` covers thinking and generating only. [Streamed answers with a stop button](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/ai/streamed-answers-with-stop-button/) shows every state.

## Related

- [Streamed answers with a stop button](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/ai/streamed-answers-with-stop-button/) shows a reply as it's generated, with the AI state indicator and Stop.
- [AI integrations overview](https://getstream.io/chat/docs/sdk/react/features/ai/ai-integrations-overview/) explains the AI states, the `ai_indicator.*` events and how the backend streams a reply.
- [SDK integration](https://getstream.io/chat/docs/sdk/react/features/ai/sdk-integration/) explains `StreamedMessageText`, `AIStateIndicator` and the stop button.
- [Theming](https://getstream.io/chat/docs/sdk/react/features/app-experience/theming/) explains the design tokens and CSS layers the widget's CSS uses.
- [Workspace with ChatView](https://getstream.io/chat/docs/sdk/react/ui/layout/workspace-with-chatview/) explains the layout the widget floats over.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/ui-cookbooks/ai/ai-chat-widget/](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/ai/ai-chat-widget/).