Repository navigation
feat: add link previews to message composer - #3376
Merged
Merged
Conversation
oliverlaz
approved these changes
Jan 30, 2026
# Conflicts: # package/src/components/MessageInput/MessageInput.tsx
Contributor
SDK Size
|
This was referenced Nov 3, 2026
isekovanic
added a commit
that referenced
this pull request
Nov 6, 2026
…#3920) Ticket: https://linear.app/stream/issue/RN-485 ## 🎯 Goal First of a stack of PRs that bring agent conversations to `ai-components`: reasoning, tool calls, approvals, sign-in and the conversation view, as in StreamChatAI, ai-compose and stream-chat-react#3376. This one adds only the data model. No UI. ## 🛠 Implementation details An agent writes each step of its reply as an attachment (`ai_reasoning`, `ai_tool_call`). `parseMessageParts(message.attachments)` turns those into typed parts and skips everything else, such as images and files. Ported from React #3376 with the same names and behaviour: - Lenient decoding: missing fields get defaults and wrong types are ignored. - Unknown kinds and newer versions (`v > 1`) keep their payload but have no typed view. - `approval` and `authorization` are parsed on tool calls. - Helpers for the views to come: `isAwaitingApproval`, `isAwaitingAuthorization`, `isAwaitingClient`, `isDeclined`, `isToolCallFinished`, `isAuthorizationExpired`, `isSupportedPart`. One deviation from React: the sign-in URL check doesn't use `URL`. On RN < 0.80 its `protocol` and `host` getters throw "not implemented", which would silently drop every sign-in on 0.76–0.79. It's a regex instead. Exported from the `ai-components` entry only. The main entry is unaffected. ## 🎨 UI Changes None. ## 🧪 Testing - React's `messageParts` suite ported to Jest. - A fixture taken from a real reply written by a local Vision-Agents router (reasoning → `search` tool call → reasoning). - A regression test that fakes the pre-0.80 `URL` and checks a sign-in still parses. - `yarn lint`, `yarn test:typecheck`, `yarn build` and `yarn test:unit` pass. ## ☑️ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
isekovanic
added a commit
that referenced
this pull request
Nov 6, 2026
…als) (#3924) Ticket: https://linear.app/stream/issue/RN-485 Stacked on #3921. ## 🎯 Goal Show an agent reply's steps: its rounds of reasoning, its tool calls, and the questions a tool asks before it runs. The views are ported from React's AI components (#3376). Effects follow the native SDKs instead. ## 🛠 Implementation details **Views** (all props-only, none of them reads `<Channel>`) - `AIReasoning` shows a header that counts up while the agent thinks ("Thinking... 7s") and then reads "Thought for 12s" with the step's summary. - The panel opens while the agent thinks and follows the reasoning as it streams in, then folds once the step is done. Tapping the header opens or folds it. - The first step takes over the turn's thinking clock, so the time carries on from `AIThinking`. - `AIThinking` is the same header for a turn that hasn't shown any reasoning yet. - `AIToolCall` shows the call's title, a detail line, a status icon and its duration. The detail line covers waiting for approval, a sign-in or the device, and declined, didn't complete or cancelled. - `AIToolApproval` and `AIToolApprovalCard` ask the person a tool's question, such as "Share your location?", with the agent's reason and Allow / Don't Allow. - The question is only asked of the targeted user on the targeted install. - The buttons wait while the answer is sent, and a failed send lets the person answer again. - `AIMessageParts` / `AIMessagePart` render a reply's steps in order, with a neutral line for a kind this version can't show. - `AIToolApproverProvider` / `useAIToolApprover` say who answers the questions on this device. **Shared bits** - In-progress states use our `NativeShimmerView` through a small internal `Shimmer`, which `AITypingIndicatorView` now uses too. Running calls use the platform `ActivityIndicator`. This matches Swift and Android, not React's CSS. - Durations come out as "0.4s" and "1m 5s" on both platforms. Hermes on iOS ignores narrow units in `Intl`, so we fall back the same way React does. - There's a new, optional `messageParts` section in the AI theme. - New strings are added to all 13 locales. The thinking header reuses the existing `Thinking...` key. The `ar` and `he` translations are new and could use a native speaker's look. **ExpoChatGPTSample** - A reply renders its steps above its text. - Live reasoning comes from calypso's `LiveReasoning`, fed by `message.updated`, and "thinking" comes from the session's state. - Approvals go through `session.decide`. - The drawer lists the router's sessions. - The steps take the row's full width. The stand-in `MessageList` sizes bubbles to their content, and `AIConversation` replaces it later anyway. ## 🎨 UI Changes Reasoning steps (folded, open, streaming), tool calls (running, waiting, done, failed, declined) and the approval card. ## 🧪 Testing - Unit tests are ported from React's `AIMessagePartViews` suite, plus duration formatting, including the iOS fallback. - On an Android emulator against a local Vision-Agents router: - thinking, the reasoning steps folding and opening, and a web search with its spinner, check and duration; - the approval card: Share runs the call, Not now declines it, and no answer shows the call as failed after the router's 30s; - reasoning streaming into an open panel. Our router's models send their thinking in one piece, so this was driven with router-shaped events. - On the iOS simulator: a reply with a web search step and its duration. - To get an approval at all, the sample needs a phone-side tool, and the question has to carry the install id (`/respond` with `client_id`). calypso 0.2.3's `responses.create` doesn't send one. Both come with the client tool runner later in this stack. - Not tested: RTL and screen readers, on either platform. - `yarn lint`, `yarn typecheck`, `yarn build` and `yarn test:unit` pass. ## ☑️ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch (stacked on #3921) - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [x] Expo iOS and Android
isekovanic
added a commit
that referenced
this pull request
Nov 6, 2026
…3928) Ticket: https://linear.app/stream/issue/RN-485 Stacked on #3926. ## 🎯 Goal Show the person's messages and the agent's replies the way the native AI SDKs do, with the actions under a finished reply. The APIs come from React's AI components (#3376). The look follows StreamChatAI and the Android AI SDK. ## 🛠 Implementation details **Views** (props only, under `message/`) - `AIUserMessage`: the person's attachments, then their text in a bubble on the trailing side. With `onEdit` it shows an edit button. - `AIAssistantMessage`: a reply's steps, then the answer (typed out while it streams), then what the agent attached. - `AIMessageAttachment`: - An image is a 180 × 180 thumbnail that doesn't open on tap, as on both native SDKs. - A file is a card with its type's icon (PDF, image, audio, video, archive or file), name, type and size. One at a web URL opens in the browser. - `AIReplyActions`: copy (shows a check for a moment), share through the system sheet, regenerate with `onRegenerate`, and a thumbs up/down rating with `rating` / `onRate`. Copy is hidden when the app has no clipboard module. **Customising** - The AI theme has a new optional `message` section: styles, and colours under StreamChatAI's names (`userMessageBackground`, `attachmentIconBackground`, `messageAction`, …). The defaults come from the design tokens. - The theme also takes `icons`, which override any of the agent replies' 24 icons. This covers the step views from earlier PRs as well as the new ones. `defaultAIIcons` and `useAIIcons` are exported. `mergeThemes` carries icons through as they are, since the JSON copy it makes would drop them. - `AIUserMessage` and `AIAssistantMessage` take an `Attachment` component, like the attachment view factories on Swift and Android. **Also** - Fixed: copying crashed with current `expo-clipboard`, which no longer has `setString`. The AI clipboard service now uses `setStringAsync` when it's there, as the core Expo package already does. This affected copying code blocks too. - New strings are in all 13 locales. As before, `ar` and `he` could use a native speaker's look. **Left for later** - The native SDKs offer Copy and Edit on the person's message through a long-press menu (Swift's context menu, Android's dropdown). Until then, `onEdit` shows a button, as in React. If we want Swift's lifted preview, the menu needs a portal host of its own, which `AIConversation` can own in a later PR. **ExpoChatGPTSample** - Renders the person's messages with `AIUserMessage`, and replies with `AIAssistantMessage` plus `AIReplyActions` once the router clears `generating`. - Ratings are kept for the session only, as in Juno: the router doesn't take ratings yet. Like Juno, the sample doesn't wire edit or regenerate. ## 🎨 UI Changes The person's bubble, replies with their steps and action row, and image and file attachments. ## 🧪 Testing - Unit tests cover: - the four views; - file size and details formatting; - the file type icons; - icon overrides, and icons surviving `mergeThemes`; - the `Attachment` slot. - On an Android emulator and the iOS simulator, against a local Vision-Agents router: - a web-search reply with its steps; - the actions appearing once the reply finished; - rating; - copy (pasting confirmed the text); - the share sheet; - image and PDF attachments; - the PDF opening in Chrome; - an image tap doing nothing. - The attachments were posted straight to the channel. Asking with a photo fails at the router ("personal conversations require a command ID") because of how the sample sends photos with calypso 0.2.3, not because of these views. - Not tested: edit and regenerate on a device (the sample doesn't use them), RTL and screen readers. - `yarn lint`, `yarn typecheck`, `yarn build` and `yarn test:unit` pass. ## ☑️ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch (stacked on #3926) - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [x] Expo iOS and Android
isekovanic
added a commit
that referenced
this pull request
Nov 6, 2026
Ticket: https://linear.app/stream/issue/RN-485 Stacked on #3928. ## 🎯 Goal A conversation view for AI agents, laid out by turn like StreamChatAI's `AIConversationView` and React's `AIConversation` (#3376): each question starts a turn, the agent's reply writes out below it, and a new question goes to the top of the screen without moving anything else. ## 🛠 Implementation details **`AIConversation`** (`conversation/`) - Takes props only and never reads `<Channel>`, as in React: `messages`, `pendingMessage`, `isThinking`, `loadOlderMessages`, `footer`, `emptyContent`, `bottomInset`, `approver`, and the `UserMessage` / `AssistantMessage` / `ReplyActions` slots plus `replyActions`. - Split in three, like Swift's `AIConversationView` and its scroll view: - `useConversationRows`: the data. Turns, a pending message until its copy arrives, the thinking row, and a row cache so only changed messages render again. - `list/`: how the list behaves, as hooks that render nothing (`usePinnedAnchor`, `useEndInView`, `useLoadOlder`, put together by `useChatListController`), and an internal `AIChatList` that draws it. Moving to another LegendList, such as keyboard-controller's, only touches `AIChatList`. - `AIConversation` itself: the props, the slots and the row views. - The list is Reanimated LegendList (`@legendapp/list` 3.7.1, JS only, a regular dependency of the AI entry point; the isolation check keeps it out of the main one), with recycling on. - A reply's steps are keyed by message (new optional `id` on `AIAssistantMessage`), since step ids like `r1` repeat across replies. Reply actions are keyed too. - The answer's typing starts afresh for a replaced text, so a reused row shows its own text at once. - The latest question is pinned with `anchoredEndSpace`, plus a content floor (the question's position plus the list's tallest height at this width). Without the floor, the list shrinking under the question (a reasoning step folding, the thinking going, the keyboard closing) pulled the question down before LegendList caught up. Thinking and the footer are a row of their own for the same reason. - Older messages load once the spinner has been in view for 300 ms, and only when the list is at rest. On iOS, a page that lands while a finger holds the list, or while it glides or bounces, pushes the view. - Scroll-to-latest button (Swift's). It shows when the end of the latest reply's content is out of view, not counting the space the pin leaves under a short reply. **Design tokens** - Text in the AI components is sized from the chat SDK's tokens (Md/Sm/Xs, 17/15/13 on iOS and 16/14/12 on Android), mapped to Swift's font roles. Answers were RN's default 14 before. Headings scale from the body as Swift's markdown does. - The conversation's spacing comes from the tokens too. - The isolation check now allows `theme/generated/light/StreamTokens*`, which is plain data. **Also** - `fromStreamMessage` (React's) and `createFromStreamMessage`, which returns the same `AIMessage` for the same Stream message object. - `ArrowDown` icon, a `conversation` theme section (Swift's colours), and two strings in all 13 locales. `ar` and `he` could use a native speaker's look. - Jest maps `@legendapp/list/{react-native,reanimated}` to their native builds, since the exports map only has a `react-native` condition. **ExpoChatGPTSample** - Drops `<Channel>` and `<MessageList>` for `AIConversation`, as Juno does. An `AgentChat` store watches the channel, follows message events, pages older messages with `channel.query({ id_lt })` and keeps the live reasoning. - The question shows at once as the pending message, and "thinking" comes from the session's state. Voice mode reads the session's state instead of the AI indicator. ## 🎨 UI Changes The conversation: turns, a new question pinned to the top with the reply growing below, the spinner while older messages load, and the scroll-to-latest button. Text is larger throughout (answers 17 on iOS, 16 on Android). ## 🧪 Testing - Unit tests port React's `AIConversation` tests, and cover the rows hook on its own, only changed rows re-rendering, the pending row keeping its mount, loading older messages (delay, retry after a failure, waiting for rest), repeated step ids across replies, a reused answer not typing again, and `createFromStreamMessage`'s cache. - On an Android emulator and the iOS simulator against a local Vision-Agents router, and briefly on an iPhone: - first and later questions, including asked with the keyboard up and the keyboard then closed; - reasoning streaming and folding, and tool calls (web search); - the Linear sign-in card ("Not now"), stopping a reply, rating; - long replies and the scroll-to-latest button; - switching conversations; - loading older messages; - recycling in a 34-turn conversation (rows reused 56 times): each row showing its own message, no step state carried across replies, no answer typing again. - Not tested: TalkBack/VoiceOver and RTL; Connect on the sign-in card (unchanged since #3926); approval cards and attachments in reused rows. - Left for a polish pass: Swift's animations, the long-press Copy/Edit menu, and trying react-native-keyboard-controller. - `yarn lint`, `yarn typecheck`, `yarn build` and the unit tests pass. ## ☑️ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch (stacked on #3928) - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [x] Expo iOS and Android
isekovanic
added a commit
that referenced
this pull request
Nov 6, 2026
… fixes (#3931) Ticket: https://linear.app/stream/issue/RN-485 Stacked on #3929. ## 🎯 Goal The composer extras of StreamChatAI's `AIComposerView` and React's `AIMessageComposer` (#3376): chat options such as "Think harder", the chip of the one picked, and starter suggestions for an empty conversation. ## 🛠 Implementation details **Chat options** (`message-composer/ChatOptions.tsx`) - `AIChatOption` is React's type: `id`, `title`, and optional `description`, `icon`, `shortTitle` and `customData`. - `ComposerView` takes `chatOptions` and lists them in its sheet under Camera and Photos, as Swift's picker does. Picking one closes the sheet; picking the picked one again removes it. - The picked option shows as a chip above the input (`SelectedChatOptionChip`, Swift's look), and is sent with each message as `chatOption` in `onSendMessage`. Like Swift and Juno, it stays picked after sending, until the person removes it. - It lives in the composer's `state` store as `chatOption`, so an app that passes its own store can read it. `useComposerChatOption` reads it inside the composer. - `ChatOptionsList` and `SelectedChatOptionChip` are exported for custom composers. **`AISuggestions`** (`suggestions/`) - React's API: `suggestions` and `onSelect`. A row of chips scrolling sideways, two lines each at most 160 wide, as Swift's `SuggestionsView`. Where it goes is up to the app. **Attachment preview** - The remove button has a label and a bigger hit area. - An attachment that isn't an image shows as a file card rather than an empty `Image`, as React's preview does. The built-in pickers only pick photos, so this shows only with a custom `mediaPickerService`. **Also** - `chatOptions` and `suggestions` theme sections, with Swift's colours from the shared tokens. Sizes and spacing come from the tokens. - Two strings in all 13 locales. `ar` and `he` could use a native speaker's look. **ExpoChatGPTSample** - The placeholder sheet options (which only showed an alert) are gone. In their place is "Think harder", which asks the router with `reasoningEffort: 'high'`, as Juno does. - Starters show above the composer on a new conversation, until its first question. A starter asks with the option picked in the composer, which Juno doesn't do. ## 🎨 UI Changes The sheet with "Think harder" under Camera and Photos, the "Think" chip above the input, and the starters above the composer on a new conversation. ## 🧪 Testing - Unit tests: the option is sent and stays picked, removing it from the chip and by picking it again, a non-image attachment shown as a file and removed, and the suggestions. - On the iOS simulator and an Android emulator against a local Vision-Agents router: picking the option, the chip, sending typed questions and starters with it picked and removed, and removing it with the keyboard up. A temporary log confirmed the sample sent `thinkHarder: true` with the chip and `false` without. - Not tested: TalkBack/VoiceOver and RTL, and the file card on device (no picker gives one). - `yarn lint`, `yarn typecheck`, `yarn build` and the unit tests pass. ## ☑️ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch (stacked on #3929) - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [x] Expo iOS and Android
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🎯 Goal
This PR adds the missing link previews feature that we've been missing on the React Native SDK.
It's essentially a way to prepopulate the message composer with a preview of the first link within the message we are composing, which is then going to be admitted as an attachment to the
messagewe send.As this is an optional feature, it first needs to be enabled by doing:
when configuring the composer state.
🛠 Implementation details
🎨 UI Changes
iOS
Android
🧪 Testing
☑️ Checklist
developbranch