From 8517bc8af5c8617416c5b1a8e564b49ef5f7ab80 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 11 Nov 2026 16:46:40 +0100 Subject: [PATCH] docs(ai-components): document a StrictMode-safe controller lifecycle and custom.uiAction Create AIConversationController in the effect that disposes it, so React StrictMode's cleanup-then-rerun doesn't leave the views with a disposed controller. Document that a generated-UI action travels on AIConversationRequest.custom.uiAction, and add a Conversation state section to the ai-components README. The Expo agent examples turn off edit and regenerate: the agents router keeps the conversation in Stream Chat and can't rewind it. --- .../ExpoChatGPTSample/contexts/AppContext.tsx | 3 ++ .../ExpoCrewAssistant/contexts/AppContext.tsx | 3 ++ package/src/ai-components/README.md | 45 +++++++++++++++++++ .../controller/AIConversationController.ts | 21 ++++++++- .../conversation-state/controller/types.ts | 7 ++- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/examples/ExpoChatGPTSample/contexts/AppContext.tsx b/examples/ExpoChatGPTSample/contexts/AppContext.tsx index 92fa1094f7..ff6b7b347b 100644 --- a/examples/ExpoChatGPTSample/contexts/AppContext.tsx +++ b/examples/ExpoChatGPTSample/contexts/AppContext.tsx @@ -164,6 +164,9 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St const show = (next: AgentChat) => { const previous = current.current; const conversation = new AIConversationController({ + // The agents router keeps the conversation in Stream Chat and can't rewind it, so a reply + // can't be written again or a message edited: either would only be asked again as new. + actions: { edit: false, regenerate: false }, approver: approverFor(next), transport: createAgentTransport(next, { onCreated: () => setConversationsVersion((version) => version + 1), diff --git a/examples/ExpoCrewAssistant/contexts/AppContext.tsx b/examples/ExpoCrewAssistant/contexts/AppContext.tsx index 80013f64ce..465daa881d 100644 --- a/examples/ExpoCrewAssistant/contexts/AppContext.tsx +++ b/examples/ExpoCrewAssistant/contexts/AppContext.tsx @@ -211,6 +211,9 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St const show = (next: AgentChat) => { const previous = current.current; const conversation = new AIConversationController({ + // The agents router keeps the conversation in Stream Chat and can't rewind it, so a reply + // can't be written again or a message edited: either would only be asked again as new. + actions: { edit: false, regenerate: false }, approver: approverFor(next), transport: createAgentTransport(next, { onCreated: () => setConversationsVersion((version) => version + 1), diff --git a/package/src/ai-components/README.md b/package/src/ai-components/README.md index 4db0c07116..88fc5fe548 100644 --- a/package/src/ai-components/README.md +++ b/package/src/ai-components/README.md @@ -301,6 +301,51 @@ The motion runs entirely on the UI thread with Reanimated and Skia, so the visua **Rendering.** The glow's shader runs through Skia on every Android version, so there is no fallback rendering, and it does not switch to a cheaper look in battery saver. A visualization keeps animating while it is mounted, even when scrolled off screen; it pauses only while the app is in the background. +### Conversation state + +`AIConversationController` holds a conversation's messages and where the latest exchange with the agent is, and sends what the person asks through an `AIConversationTransport` you write for your agent. Give it to `AIConversation` and `ComposerView` as `conversation`. + +Create it in the effect that disposes it, not in `useState`: React's StrictMode runs an effect's cleanup and then the effect again, which would leave the views with a disposed controller. + +```tsx +import { AIConversationController, ComposerView } from 'stream-chat-react-native/ai-components'; +import { AIConversation } from 'stream-chat-react-native/ai-components/conversation'; + +const Conversation = ({ transport }) => { + const [conversation, setConversation] = useState(); + useEffect(() => { + const created = new AIConversationController({ transport }); + setConversation(created); + return () => { + created.dispose(); + setConversation(undefined); + }; + }, [transport]); + + if (!conversation) return null; + + return ( + <> + + + + ); +}; +``` + +**Transport.** `connect(feed)` tells the controller the conversation's messages as they change, `send(request)` sends what the person asks, and `stop()` stops the reply being written. A request's `kind` is `message`, `regenerate` or `edit`, and its `custom` carries what the app sent besides the text. What the person did in a reply's [generated UI](#generated-ui) travels there as `custom.uiAction`, the action's JSON from `aiUIActionJSON`, for the transport to hand to the agent: + +```ts +// Sending the action +conversation.send({ custom: { uiAction: aiUIActionJSON(action) }, text: action.label }); + +// In the transport +send: request => + session.responses.create(request.text, { uiAction: request.custom?.uiAction }), +``` + +**Actions.** The views offer editing the person's latest message, and regenerating and rating replies. Turn off what your agent can't do with `actions`: an agent that keeps the conversation on its side and can't rewind it, such as one behind Stream's agents router, takes `actions: { edit: false, regenerate: false }`. + ### Generated UI An agent can answer with UI instead of text: a form, a list of options, a card with buttons. The backend splits the model's UI block out of the reply into the message's `ai_ui` field, and the app draws it with a renderer for the format the agent writes. Today that is [A2UI](https://a2ui.org) v1.0, from its own entry point, so apps that don't use it don't bundle it: diff --git a/package/src/ai-components/conversation-state/controller/AIConversationController.ts b/package/src/ai-components/conversation-state/controller/AIConversationController.ts index 0100067fe9..aec5fac5eb 100644 --- a/package/src/ai-components/conversation-state/controller/AIConversationController.ts +++ b/package/src/ai-components/conversation-state/controller/AIConversationController.ts @@ -74,6 +74,10 @@ export type AIConversationSendData = { attachments?: MediaAsset[]; /** The option the person picked in the composer. */ chatOption?: AIChatOption; + /** + * Goes to the transport as the request's `custom`, such as a generated-UI action as + * `{ uiAction: aiUIActionJSON(action) }`. + */ custom?: Record; }; @@ -118,9 +122,22 @@ type RatingChange = { * `AIConversation` and `ComposerView` as `conversation`, which show it and act on it: sending, * stopping, regenerating and rating replies, and editing the person's latest message. * + * Create it in the effect that disposes it, not in `useState`: React's StrictMode runs an + * effect's cleanup and then the effect again, which would leave the views with a disposed + * controller. + * * ```tsx - * const [conversation] = useState(() => new AIConversationController({ transport })); - * useEffect(() => () => conversation.dispose(), [conversation]); + * const [conversation, setConversation] = useState(); + * useEffect(() => { + * const created = new AIConversationController({ transport }); + * setConversation(created); + * return () => { + * created.dispose(); + * setConversation(undefined); + * }; + * }, [transport]); + * + * if (!conversation) return null; * * * diff --git a/package/src/ai-components/conversation-state/controller/types.ts b/package/src/ai-components/conversation-state/controller/types.ts index 02cd95f7e9..a019fe2e45 100644 --- a/package/src/ai-components/conversation-state/controller/types.ts +++ b/package/src/ai-components/conversation-state/controller/types.ts @@ -45,7 +45,12 @@ export type AIConversationRequest = { attachments: MediaAsset[]; /** The option the person picked in the composer, such as thinking harder. */ chatOption?: AIChatOption; - /** What the composer held besides, as `ComposerState.custom`. */ + /** + * What the composer held besides, as `ComposerState.custom`, or what `send` was given as + * `custom`. What the person did in a reply's generated UI travels here as `uiAction`, the + * action's JSON from `aiUIActionJSON`, and the transport hands it to the agent, such as + * `session.responses.create(request.text, { uiAction: request.custom?.uiAction })`. + */ custom?: Record; /** The message it is about: the reply to write again, or the person's message they edited. */ message?: Message;