# Streamed answers with a stop button

An assistant's answer can take a while to write. This UI cookbook shows the answer as it is written, says what the assistant is doing in a line under the message list, and puts a Stop button of your own in the composer where the send button was. The SDK types the answer out and swaps the send button for Stop by itself; how the AI state, the stop button and the typed-out text work is on [SDK integration](https://getstream.io/chat/docs/sdk/react-native/features/ai/sdk-integration/).

The answers come from an AI agent on your backend, which watches the channel and writes as the assistant user. For each answer it:

1. Posts an empty answer marked `ai_generated`.
2. Sends `ai_indicator.update` with `ai_state` set to `AI_STATE_THINKING`, for example with `Channel.updateAIState()`. While a tool call runs, it sends `AI_STATE_EXTERNAL_SOURCES`, and with the first words, `AI_STATE_GENERATING`.
3. Writes the answer into the message a few words at a time. Each update reaches the app as `message.updated`.
4. Stores the final text and sends `ai_indicator.clear`, for example with `Channel.clearAIIndicator()`. If the answer fails, it sends `AI_STATE_ERROR` instead.
5. Stops writing when the app sends `ai_indicator.stop`, stores the text it has, and sends `ai_indicator.clear`.

[Drive the AI indicator from events](https://getstream.io/chat/docs/sdk/react-native/features/ai/ai-integrations-overview/#drive-the-ai-indicator-from-events) and [Let users stop the response](https://getstream.io/chat/docs/sdk/react-native/features/ai/ai-integrations-overview/#let-users-stop-the-response) explain each step.

>
> **Note:** **What you'll build**
>
> - a check that recognizes the assistant's replies
> - a status line for what the assistant is doing
> - a Stop button in the composer
>

---

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

## Best practices

- Let the SDK type out the answer. The message bubble renders an AI-generated message with `StreamingMessageView`, which types out whatever the text grows by, and shows a stored answer in full.
- Say what the assistant is doing, in words, where the answer appears. A spinner alone doesn't tell thinking from checking sources.
- Keep Stop where the send button was, so the user finds it without looking, and give it an accessible label, since it shows only an icon.
- Don't make the growing text a live region, because a screen reader would read every update. Announce the state changes instead, and that the answer is finished.
- Show Stop only while it can stop something. The default composer shows it for thinking and generating, and the send button comes back when the answer ends.

## Recognize the assistant's replies

The backend marks each of the assistant's replies with the custom field `ai_generated`, which the client puts under `message.custom`. `isMessageAIGenerated` reads it. Passed to `Chat`, it makes every message list render the text of those messages with `StreamingMessageView`, and leaves out the "Edited" label that every streamed update would add. [Mark assistant messages with `ai_generated`](https://getstream.io/chat/docs/sdk/react-native/features/ai/ai-integrations-overview/#mark-assistant-messages-with-ai_generated) shows how the backend sets the field, and [SDK integration](https://getstream.io/chat/docs/sdk/react-native/features/ai/sdk-integration/#streamingmessageview) explains how the message bubble uses it.

```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;
```

The function is declared at module scope, so `Chat` gets the same one on every render.

## Show what the assistant is doing

`AITypingIndicatorView` shows only "Thinking..." and "Generating...". `AssistantStatus` reads the channel's AI state with `useAIState()` and shows a line for each state the assistant works in, including checking sources and a failed answer. While the assistant is idle, or the user stopped the answer, it renders nothing.

No SDK component renders the line, so it is a child of `Channel`, between the message list and the composer. The line sits just above where the user types, and stays in view while the user scrolls the messages.

`useAnswerAnnouncements()` tells a screen reader how the answer goes without reading every streamed word: that the assistant started answering, and that the answer is finished, stopped or failed. It uses `useAccessibilityAnnouncer()`, which does nothing unless SDK accessibility is on, as [Accessibility](https://getstream.io/chat/docs/sdk/react-native/features/app-experience/accessibility/) describes. The message list itself isn't a live region, so the user reads the finished answer like any other message.

```ts title="src/useAnswerAnnouncements.ts"
import { useEffect, useRef } from "react";
import { AIStates } from "stream-chat";
import type { AIState } from "stream-chat";
import { useAccessibilityAnnouncer } from "stream-chat-react-native";

/** The assistant is working on an answer: thinking, checking sources or writing it. */
const isAnswering = (aiState: AIState) =>
  aiState === AIStates.Thinking ||
  aiState === AIStates.ExternalSources ||
  aiState === AIStates.Generating;

/** What to say when an answer ends in each state that isn't answering. */
const endings: Partial<Record<AIState, string>> = {
  [AIStates.Error]: "The assistant couldn't finish the answer",
  [AIStates.Idle]: "The assistant finished answering",
  [AIStates.Stop]: "Answer stopped",
};

/**
 * Announces that an answer started, and how it ended. Each change is announced once, and an
 * ending only after an answer, so a channel that opens on an earlier error stays quiet.
 */
export const useAnswerAnnouncements = (aiState: AIState) => {
  const announce = useAccessibilityAnnouncer();
  // Starts idle, so opening the channel in the middle of an answer announces it too.
  const previousState = useRef<AIState>(AIStates.Idle);

  useEffect(() => {
    if (previousState.current === aiState) return;
    const wasAnswering = isAnswering(previousState.current);
    previousState.current = aiState;

    if (isAnswering(aiState)) {
      if (!wasAnswering) announce("The assistant is answering");
    } else if (wasAnswering) {
      const ending = endings[aiState];
      if (ending) announce(ending);
    }
  }, [aiState, announce]);
};
```

The line takes its colors from the theme, so it follows the light and dark schemes and a custom theme:

```tsx title="src/AssistantStatus.tsx"
import { useMemo } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { AIStates } from "stream-chat";
import type { AIState } from "stream-chat";
import { useAIState, useChannel, useTheme } from "stream-chat-react-native";
import { useAnswerAnnouncements } from "./useAnswerAnnouncements";

/** What the status line says in each state that shows it. */
const labels: Partial<Record<AIState, string>> = {
  [AIStates.Error]: "Couldn't finish the answer",
  [AIStates.ExternalSources]: "Checking sources...",
  [AIStates.Generating]: "Writing...",
  [AIStates.Thinking]: "Thinking...",
};

/**
 * The assistant's state under the message list: a spinner and a line of text while it works, the
 * text alone after an error, and nothing otherwise. Render it inside `Channel`.
 */
export const AssistantStatus = () => {
  const channel = useChannel();
  const { aiState } = useAIState(channel);
  const {
    theme: { semantics },
  } = useTheme();
  useAnswerAnnouncements(aiState);

  const styles = useMemo(
    () =>
      StyleSheet.create({
        container: {
          alignItems: "center",
          flexDirection: "row",
          gap: 8,
          paddingHorizontal: 16,
          paddingVertical: 8,
        },
        error: { color: semantics.accentError },
        text: { color: semantics.textSecondary, fontSize: 14 },
      }),
    [semantics],
  );

  const label = labels[aiState];
  if (!label) return null;
  const isError = aiState === AIStates.Error;

  return (
    <View style={styles.container}>
      {!isError && (
        <ActivityIndicator color={semantics.accentPrimary} size="small" />
      )}
      <Text style={[styles.text, isError && styles.error]}>{label}</Text>
    </View>
  );
};
```

<table>
  <tr>
  <td align='center' width='50%'>

![The status line reading Thinking above the composer with a Stop button, next to an empty answer, on iOS](https://getstream.io/docs-assets/images/f0a5f8b17b84.png)

  </td>
  <td align='center' width='50%'>

![The status line reading Thinking above the composer with a Stop button, next to an empty answer, on Android](https://getstream.io/docs-assets/images/6a361ede2481.png)

  </td>
  </tr>
</table>

<table>
  <tr>
  <td align='center' width='50%'>

![The status line reading Checking sources with the send button still in place on iOS](https://getstream.io/docs-assets/images/a1b21b87c0b6.png)

  </td>
  <td align='center' width='50%'>

![The status line reading Checking sources with the send button still in place on Android](https://getstream.io/docs-assets/images/04a2bb511aed.png)

  </td>
  </tr>
</table>

The line isn't a live region, so a screen reader doesn't read it out on every change. The user hears "The assistant is answering" once when the answer starts, and the ending when it finishes.

## Replace the send button with Stop

While `channel.state.aiState` is `AI_STATE_THINKING` or `AI_STATE_GENERATING`, the default composer renders its `StopMessageStreamingButton` in place of the send button, and passes it an `onPress` that calls `Channel.stopAIResponse()`. That sends `ai_indicator.stop` to the channel, and the client sets the state to `AI_STATE_STOP`. The backend stops writing when it gets the event.

The default button draws the `Stop` icon in an outline, and has no accessible label. `StopButton` fills the key with the SDK's `Button`, the size of the send button, with the `Stop` icon from the components context and the label "Stop the answer". Because it is the same button the send button uses, the composer keeps its layout when the two swap.

```tsx title="src/StopButton.tsx"
import { Button, useComponentsContext } from "stream-chat-react-native";
import type { StopMessageStreamingButtonProps } from "stream-chat-react-native";

/**
 * Fills the composer's `StopMessageStreamingButton` key, which shows in place of the send button
 * while the assistant is thinking or writing. Its `onPress` stops the answer.
 */
export const StopButton = ({ onPress }: StopMessageStreamingButtonProps) => {
  const { icons } = useComponentsContext();

  return (
    <Button
      accessibilityLabel="Stop the answer"
      iconOnly
      LeadingIcon={icons.Stop}
      onPress={onPress}
      size="sm"
      type="solid"
      variant="primary"
    />
  );
};
```

<table>
  <tr>
  <td align='center' width='50%'>

![The status line reading Writing above the composer, whose send button became a Stop button, on iOS](https://getstream.io/docs-assets/images/0501e142047a.png)

  </td>
  <td align='center' width='50%'>

![The status line reading Writing above the composer, whose send button became a Stop button, on Android](https://getstream.io/docs-assets/images/12b96541b53a.png)

  </td>
  </tr>
</table>

The composer shows the button only for thinking and generating. While the state is `AI_STATE_EXTERNAL_SOURCES`, the send button stays, and the status line reads "Checking sources..." without a Stop button. [StopMessageStreamingButton](https://getstream.io/chat/docs/sdk/react-native/features/ai/sdk-integration/#stopmessagestreamingbutton) lists the other cases in which it doesn't show.

## Add it to your app

`WithStreamedAnswers` puts the button in its key with `WithComponents`. The overrides are read once, when `WithComponents` mounts, so they are declared at module scope:

```tsx title="src/WithStreamedAnswers.tsx"
import type { PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react-native";
import { StopButton } from "./StopButton";

// Declared once, so WithComponents gets the same object on every render.
const overrides = { StopMessageStreamingButton: StopButton };

/** A Stop button of your own in place of the send button while the assistant answers. */
export const WithStreamedAnswers = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);
```

The channel screen renders `AssistantStatus` between the list and the composer. `Channel` watches the channel when it mounts, so the screen needs no request of its own:

```tsx title="src/AssistantChannelScreen.tsx"
import { useMemo } from "react";
import {
  Channel,
  MessageComposer,
  MessageList,
  useChatContext,
} from "stream-chat-react-native";
import { AssistantStatus } from "./AssistantStatus";
import { WithStreamedAnswers } from "./WithStreamedAnswers";

/** The conversation with the assistant, which your backend creates for the user. */
export const AssistantChannelScreen = ({
  channelId,
}: {
  channelId: string;
}) => {
  const { client } = useChatContext();
  const channel = useMemo(
    () => client.channel("messaging", channelId),
    [channelId, client],
  );

  return (
    <WithStreamedAnswers>
      <Channel channel={channel}>
        <MessageList />
        <AssistantStatus />
        <MessageComposer />
      </Channel>
    </WithStreamedAnswers>
  );
};
```

Pass `isMessageAIGenerated` to `Chat`, where the rest of your app stays as it is:

```tsx title="src/App.tsx"
import type { PropsWithChildren } from "react";
import type { StreamChat } from "stream-chat";
import { Chat, OverlayProvider } from "stream-chat-react-native";
import { isMessageAIGenerated } from "./isMessageAIGenerated";

export const App = ({
  children,
  client,
}: PropsWithChildren<{ client: StreamChat }>) => (
  <OverlayProvider>
    <Chat client={client} isMessageAIGenerated={isMessageAIGenerated}>
      {children}
    </Chat>
  </OverlayProvider>
);
```

## How it works

>
> **Info:** **Example**
>
> The user asks the assistant which vegetables are easy for a first season:
>
> 1. The backend posts an empty answer, marked `ai_generated`, and the message list renders it with `StreamingMessageView`.
> 2. The backend sends `ai_indicator.update` with `AI_STATE_THINKING`. The client sets `channel.state.aiState`, `AssistantStatus` shows a spinner and "Thinking...", `useAnswerAnnouncements()` announces "The assistant is answering", and the composer shows `StopButton` in place of the send button. With the first words, the backend sends `AI_STATE_GENERATING`, and the line reads "Writing...".
> 3. Each `message.updated` grows the answer's text, and `StreamingMessageView` types out the new part.
> 4. The backend stores the final text and sends `ai_indicator.clear`. `channel.state.aiState` goes back to `AI_STATE_IDLE`, the status line goes away, the send button comes back, and the user hears "The assistant finished answering".
> 5. Had the user pressed Stop in step 3, `Channel.stopAIResponse()` would have sent `ai_indicator.stop`. The state becomes `AI_STATE_STOP`, the send button comes back, and the user hears "Answer stopped". The backend stops writing, stores what it has, and sends `ai_indicator.clear`.
>

- `ai_indicator.*` events are only delivered live. A user who opens the channel in the middle of an answer sees the status line from the next event on, and the answer's text from the next `message.updated`.
- The client sets the state back to idle when the connection closes or drops, so a missed `ai_indicator.clear` doesn't leave the line on screen.
- `AI_STATE_ERROR` stays until the next answer starts, so "Couldn't finish the answer" stays under the list until then.
- `AI_STATE_STOP` lasts until the backend's `ai_indicator.clear`, and shows no status line.

## Read more

- [AI integrations overview](https://getstream.io/chat/docs/sdk/react-native/features/ai/ai-integrations-overview/): the AI states, the `ai_indicator.*` events and how the backend streams an answer.
- [SDK integration](https://getstream.io/chat/docs/sdk/react-native/features/ai/sdk-integration/): `StreamingMessageView`, `AITypingIndicatorView` and the stop button, and the AI composer as a replacement for the default one.
- [AI hooks](https://getstream.io/chat/docs/sdk/react-native/reference/hooks/ai-hooks/): `useAIState()` and `useStreamingMessage()`.
- [Overriding components](https://getstream.io/chat/docs/sdk/react-native/ui/components/overriding-components/): the `WithComponents` keys.
- [AI chat widget](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/ai/ai-chat-widget/): the conversation with the assistant in a modal opened from any screen.

---

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