Composer

ChatComposer is where the user writes to your agent. It has a text field that grows with the text, photos from the system photo picker, and a microphone that dictates into the field. While the agent answers, its send button turns into a stop button. Put it under the conversation:

Column(modifier = Modifier.fillMaxSize()) {
    AIConversation(
        messages = messages,
        modifier = Modifier.weight(1f),
    )
    ChatComposer(
        onSendClick = { data: MessageData -> send(data) },
        onStopClick = { stop() },
        isGenerating = isAnswering,
    )
}

The composer pads itself above the keyboard and the navigation bar. To let the conversation scroll under it, float it over the list.

Permissions

The attachment button opens the system photo picker, which needs no permission. The user picks up to three images at a time.

Dictation records with the microphone, so declare the permission in your app's AndroidManifest.xml:

<uses-permission android:name="android.permission.RECORD_AUDIO" />

The composer asks for it the first time the user taps the microphone. If they decline, it shows a message with a button that opens your app's settings. Apps that use Stream Video already declare the permission.

Send a message

onSendClick receives a MessageData, with the text and the attachments the user picked, as content:// URIs:

ChatComposer(
    onSendClick = { data: MessageData ->
        viewModel.send(text = data.text, attachments = data.attachments.toList())
    },
    onStopClick = viewModel::stop,
    isGenerating = isAnswering,
)

The composer clears its text and attachments once a message is sent. Stream Chat uploads an attachment from a File, so copy each picked file first, as shown in Show a message while it's sent. With Stream Agents, ask the session instead of sending to the channel, as the Quickstart does.

Control it from your code

By default, the composer keeps the message being written itself. To fill in text from elsewhere, such as a suggestion or a restored draft, or to keep what was written when sending fails, own the message with the overload that takes onMessageDataChange:

var message by rememberSaveable(stateSaver = MessageData.Saver) { mutableStateOf(MessageData()) }
val focus = remember { FocusRequester() }

ChatComposer(
    messageData = message,
    onMessageDataChange = { message = it },
    onSendClick = { sent -> if (!viewModel.trySend(sent)) message = sent },
    onStopClick = viewModel::stop,
    isGenerating = isAnswering,
    focusRequester = focus,
)

The composer reports an empty message through onMessageDataChange just before it calls onSendClick, so setting the message back there keeps it. MessageData.Saver keeps the message across configuration changes and process death. Call focus.requestFocus() to put the cursor in the text field, for example after filling it in.

Stop the reply

While isGenerating is true, the composer shows a stop button in place of the send button, and calls onStopClick when the user taps it. It also disables its text field and attachment button, so set isGenerating for the whole reply, and make sure it always resets: while it's stuck at true, the user can't type.

With Stream Agents, stop the reply with session.interrupt(). With a backend of your own and a Stream Chat channel, send the channel an ai_indicator.stop event with the id of the reply:

fun stop(cid: String, replyId: String) {
    ChatClient.instance().channel(cid)
        .sendEvent(
            eventType = EventType.AI_TYPING_INDICATOR_STOP,
            extraData = mapOf("message_id" to replyId),
        )
        .enqueue()
}

What your backend sends covers how the backend stops the reply.

Dictation

SpeechToTextButton turns speech into text with Android's SpeechRecognizer, in the device's language. The composer shows one inside its text field while the agent isn't answering, which dictates into it after the text already there. To use the button in an input of your own, remember its state with the callbacks that receive the transcript:

var text by remember { mutableStateOf("") }

val state = rememberSpeechToTextButtonState(
    // Called with what's recognized so far, while the user speaks.
    onPartialResult = { partial -> text = partial },
    // Called with the whole transcript once recording stops.
    onFinalResult = { final -> text = final },
)

SpeechToTextButton(
    state = state,
    onPermissionDenied = { showMicrophoneRationale() },
)

When tapped, it asks for microphone access and records. Recording stops after 3 seconds of silence, or when the user taps the button again. state.isRecording() tells whether it's recording. The device needs a speech recognition service, such as Google's, which most phones have.

To let the user talk to your agent instead of dictating, see the Voice agent use case.

Customize the composer

The composer draws its parts through ChatAiComponentFactory, which you provide with LocalChatAiComponentFactory. Each slot has a default, so you override only the ones you want to change:

Slot Shows Default
ComposerLeadingContent Left of the text field A button that opens the photo picker
ComposerInputContent The text field, with the dictation and send or stop buttons The built-in field
ComposerInputTrailingContent Inside the text field, while the agent isn't answering SpeechToTextButton
ComposerTrailingContent Right of the text field Nothing
SpeechToTextButtonIdleContent The dictation button while it isn't recording A microphone button
SpeechToTextButtonRecordingContent The dictation button while it records, with the audio level Bars that follow the voice

For example, this replaces the attachment button with a paperclip and leaves dictation out, on top of the factory already provided:

CompoundChatAiComponentFactory(
    factory = { current ->
        object : ChatAiComponentFactory by current {
            @Composable
            override fun RowScope.ComposerLeadingContent(params: ComposerLeadingContentParams) {
                IconButton(onClick = params.onAttachmentsClick, enabled = !params.isGenerating) {
                    Icon(Icons.Default.AttachFile, contentDescription = "Attach photos")
                }
            }

            @Composable
            override fun RowScope.ComposerInputTrailingContent(params: ComposerInputTrailingContentParams) {
                // Nothing: no dictation.
            }
        }
    },
) {
    ChatComposer(
        onSendClick = { data -> send(data) },
        onStopClick = { stop() },
        isGenerating = isAnswering,
    )
}

Override ComposerTrailingContent to add a mode toggle, a slash-command trigger, or another control next to the field. The params carry what each slot needs, such as onAttachmentsClick, which opens the photo picker, and the speechToTextState of the field's dictation.

Appearance

The composer takes its colors and text styles from the current MaterialTheme. Its texts, such as the "Ask Assistant" placeholder and the descriptions of its buttons, are string resources whose names start with stream_ai_compose_composer_ and stream_ai_compose_speech_to_text_, which you can override in your app's strings.xml.