@crafter/rn-ai-elements 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/chatbot/AIImage.js +126 -0
- package/lib/commonjs/chatbot/AIImage.js.map +1 -0
- package/lib/commonjs/chatbot/Attachments.js +317 -0
- package/lib/commonjs/chatbot/Attachments.js.map +1 -0
- package/lib/commonjs/chatbot/ChatErrorBoundary.js +201 -0
- package/lib/commonjs/chatbot/ChatErrorBoundary.js.map +1 -0
- package/lib/commonjs/chatbot/ChatMessageItem.js +169 -0
- package/lib/commonjs/chatbot/ChatMessageItem.js.map +1 -0
- package/lib/commonjs/chatbot/Conversation.js +415 -0
- package/lib/commonjs/chatbot/Conversation.js.map +1 -0
- package/lib/commonjs/chatbot/ConversationScrollButton.js +131 -0
- package/lib/commonjs/chatbot/ConversationScrollButton.js.map +1 -0
- package/lib/commonjs/chatbot/Message.js +203 -0
- package/lib/commonjs/chatbot/Message.js.map +1 -0
- package/lib/commonjs/chatbot/PromptInput.js +352 -0
- package/lib/commonjs/chatbot/PromptInput.js.map +1 -0
- package/lib/commonjs/chatbot/Reasoning.js +184 -0
- package/lib/commonjs/chatbot/Reasoning.js.map +1 -0
- package/lib/commonjs/chatbot/Shimmer.js +116 -0
- package/lib/commonjs/chatbot/Shimmer.js.map +1 -0
- package/lib/commonjs/chatbot/Sources.js +212 -0
- package/lib/commonjs/chatbot/Sources.js.map +1 -0
- package/lib/commonjs/chatbot/Suggestion.js +99 -0
- package/lib/commonjs/chatbot/Suggestion.js.map +1 -0
- package/lib/commonjs/chatbot/Tool.js +307 -0
- package/lib/commonjs/chatbot/Tool.js.map +1 -0
- package/lib/commonjs/chatbot/adapters/uiMessageAdapter.js +141 -0
- package/lib/commonjs/chatbot/adapters/uiMessageAdapter.js.map +1 -0
- package/lib/commonjs/chatbot/index.js +140 -0
- package/lib/commonjs/chatbot/index.js.map +1 -0
- package/lib/commonjs/chatbot/types.js +6 -0
- package/lib/commonjs/chatbot/types.js.map +1 -0
- package/lib/commonjs/hooks/index.js +34 -0
- package/lib/commonjs/hooks/index.js.map +1 -0
- package/lib/commonjs/hooks/useAutoScroll.js +39 -0
- package/lib/commonjs/hooks/useAutoScroll.js.map +1 -0
- package/lib/commonjs/hooks/useClipboard.js +44 -0
- package/lib/commonjs/hooks/useClipboard.js.map +1 -0
- package/lib/commonjs/hooks/useCollapsible.js +35 -0
- package/lib/commonjs/hooks/useCollapsible.js.map +1 -0
- package/lib/commonjs/hooks/useStickToBottom.js +68 -0
- package/lib/commonjs/hooks/useStickToBottom.js.map +1 -0
- package/lib/commonjs/index.js +257 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/primitives/Badge.js +119 -0
- package/lib/commonjs/primitives/Badge.js.map +1 -0
- package/lib/commonjs/primitives/Button.js +185 -0
- package/lib/commonjs/primitives/Button.js.map +1 -0
- package/lib/commonjs/primitives/Card.js +166 -0
- package/lib/commonjs/primitives/Card.js.map +1 -0
- package/lib/commonjs/primitives/Collapsible.js +137 -0
- package/lib/commonjs/primitives/Collapsible.js.map +1 -0
- package/lib/commonjs/primitives/ScrollArea.js +40 -0
- package/lib/commonjs/primitives/ScrollArea.js.map +1 -0
- package/lib/commonjs/primitives/index.js +83 -0
- package/lib/commonjs/primitives/index.js.map +1 -0
- package/lib/commonjs/streaming/StreamingMarkdown.js +252 -0
- package/lib/commonjs/streaming/StreamingMarkdown.js.map +1 -0
- package/lib/commonjs/streaming/index.js +13 -0
- package/lib/commonjs/streaming/index.js.map +1 -0
- package/lib/commonjs/streaming/parser.js +482 -0
- package/lib/commonjs/streaming/parser.js.map +1 -0
- package/lib/commonjs/streaming/renderers/BlockquoteRenderer.js +35 -0
- package/lib/commonjs/streaming/renderers/BlockquoteRenderer.js.map +1 -0
- package/lib/commonjs/streaming/renderers/CodeRenderer.js +128 -0
- package/lib/commonjs/streaming/renderers/CodeRenderer.js.map +1 -0
- package/lib/commonjs/streaming/renderers/HeadingRenderer.js +61 -0
- package/lib/commonjs/streaming/renderers/HeadingRenderer.js.map +1 -0
- package/lib/commonjs/streaming/renderers/ImageRenderer.js +53 -0
- package/lib/commonjs/streaming/renderers/ImageRenderer.js.map +1 -0
- package/lib/commonjs/streaming/renderers/LinkRenderer.js +49 -0
- package/lib/commonjs/streaming/renderers/LinkRenderer.js.map +1 -0
- package/lib/commonjs/streaming/renderers/ListRenderer.js +63 -0
- package/lib/commonjs/streaming/renderers/ListRenderer.js.map +1 -0
- package/lib/commonjs/streaming/renderers/TableRenderer.js +77 -0
- package/lib/commonjs/streaming/renderers/TableRenderer.js.map +1 -0
- package/lib/commonjs/streaming/renderers/TextRenderer.js +41 -0
- package/lib/commonjs/streaming/renderers/TextRenderer.js.map +1 -0
- package/lib/commonjs/streaming/renderers/index.js +76 -0
- package/lib/commonjs/streaming/renderers/index.js.map +1 -0
- package/lib/commonjs/streaming/renderers/renderInlineChildren.js +112 -0
- package/lib/commonjs/streaming/renderers/renderInlineChildren.js.map +1 -0
- package/lib/commonjs/streaming/renderers/renderNode.js +81 -0
- package/lib/commonjs/streaming/renderers/renderNode.js.map +1 -0
- package/lib/commonjs/theme/ThemeProvider.js +68 -0
- package/lib/commonjs/theme/ThemeProvider.js.map +1 -0
- package/lib/commonjs/theme/defaultTheme.js +96 -0
- package/lib/commonjs/theme/defaultTheme.js.map +1 -0
- package/lib/commonjs/theme/index.js +32 -0
- package/lib/commonjs/theme/index.js.map +1 -0
- package/lib/commonjs/theme/tokens.js +2 -0
- package/lib/commonjs/theme/tokens.js.map +1 -0
- package/lib/commonjs/types.d.js +2 -0
- package/lib/commonjs/types.d.js.map +1 -0
- package/lib/commonjs/voice/index.js +13 -0
- package/lib/commonjs/voice/index.js.map +1 -0
- package/lib/commonjs/voice/useSpeechRecognition.js +172 -0
- package/lib/commonjs/voice/useSpeechRecognition.js.map +1 -0
- package/lib/module/chatbot/AIImage.js +121 -0
- package/lib/module/chatbot/AIImage.js.map +1 -0
- package/lib/module/chatbot/Attachments.js +312 -0
- package/lib/module/chatbot/Attachments.js.map +1 -0
- package/lib/module/chatbot/ChatErrorBoundary.js +196 -0
- package/lib/module/chatbot/ChatErrorBoundary.js.map +1 -0
- package/lib/module/chatbot/ChatMessageItem.js +164 -0
- package/lib/module/chatbot/ChatMessageItem.js.map +1 -0
- package/lib/module/chatbot/Conversation.js +412 -0
- package/lib/module/chatbot/Conversation.js.map +1 -0
- package/lib/module/chatbot/ConversationScrollButton.js +126 -0
- package/lib/module/chatbot/ConversationScrollButton.js.map +1 -0
- package/lib/module/chatbot/Message.js +198 -0
- package/lib/module/chatbot/Message.js.map +1 -0
- package/lib/module/chatbot/PromptInput.js +347 -0
- package/lib/module/chatbot/PromptInput.js.map +1 -0
- package/lib/module/chatbot/Reasoning.js +179 -0
- package/lib/module/chatbot/Reasoning.js.map +1 -0
- package/lib/module/chatbot/Shimmer.js +111 -0
- package/lib/module/chatbot/Shimmer.js.map +1 -0
- package/lib/module/chatbot/Sources.js +207 -0
- package/lib/module/chatbot/Sources.js.map +1 -0
- package/lib/module/chatbot/Suggestion.js +94 -0
- package/lib/module/chatbot/Suggestion.js.map +1 -0
- package/lib/module/chatbot/Tool.js +303 -0
- package/lib/module/chatbot/Tool.js.map +1 -0
- package/lib/module/chatbot/adapters/uiMessageAdapter.js +137 -0
- package/lib/module/chatbot/adapters/uiMessageAdapter.js.map +1 -0
- package/lib/module/chatbot/index.js +39 -0
- package/lib/module/chatbot/index.js.map +1 -0
- package/lib/module/chatbot/types.js +4 -0
- package/lib/module/chatbot/types.js.map +1 -0
- package/lib/module/hooks/index.js +7 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/useAutoScroll.js +35 -0
- package/lib/module/hooks/useAutoScroll.js.map +1 -0
- package/lib/module/hooks/useClipboard.js +40 -0
- package/lib/module/hooks/useClipboard.js.map +1 -0
- package/lib/module/hooks/useCollapsible.js +31 -0
- package/lib/module/hooks/useCollapsible.js.map +1 -0
- package/lib/module/hooks/useStickToBottom.js +64 -0
- package/lib/module/hooks/useStickToBottom.js.map +1 -0
- package/lib/module/index.js +19 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/primitives/Badge.js +114 -0
- package/lib/module/primitives/Badge.js.map +1 -0
- package/lib/module/primitives/Button.js +180 -0
- package/lib/module/primitives/Button.js.map +1 -0
- package/lib/module/primitives/Card.js +156 -0
- package/lib/module/primitives/Card.js.map +1 -0
- package/lib/module/primitives/Collapsible.js +130 -0
- package/lib/module/primitives/Collapsible.js.map +1 -0
- package/lib/module/primitives/ScrollArea.js +35 -0
- package/lib/module/primitives/ScrollArea.js.map +1 -0
- package/lib/module/primitives/index.js +8 -0
- package/lib/module/primitives/index.js.map +1 -0
- package/lib/module/streaming/StreamingMarkdown.js +246 -0
- package/lib/module/streaming/StreamingMarkdown.js.map +1 -0
- package/lib/module/streaming/index.js +4 -0
- package/lib/module/streaming/index.js.map +1 -0
- package/lib/module/streaming/parser.js +477 -0
- package/lib/module/streaming/parser.js.map +1 -0
- package/lib/module/streaming/renderers/BlockquoteRenderer.js +30 -0
- package/lib/module/streaming/renderers/BlockquoteRenderer.js.map +1 -0
- package/lib/module/streaming/renderers/CodeRenderer.js +123 -0
- package/lib/module/streaming/renderers/CodeRenderer.js.map +1 -0
- package/lib/module/streaming/renderers/HeadingRenderer.js +56 -0
- package/lib/module/streaming/renderers/HeadingRenderer.js.map +1 -0
- package/lib/module/streaming/renderers/ImageRenderer.js +48 -0
- package/lib/module/streaming/renderers/ImageRenderer.js.map +1 -0
- package/lib/module/streaming/renderers/LinkRenderer.js +44 -0
- package/lib/module/streaming/renderers/LinkRenderer.js.map +1 -0
- package/lib/module/streaming/renderers/ListRenderer.js +58 -0
- package/lib/module/streaming/renderers/ListRenderer.js.map +1 -0
- package/lib/module/streaming/renderers/TableRenderer.js +72 -0
- package/lib/module/streaming/renderers/TableRenderer.js.map +1 -0
- package/lib/module/streaming/renderers/TextRenderer.js +36 -0
- package/lib/module/streaming/renderers/TextRenderer.js.map +1 -0
- package/lib/module/streaming/renderers/index.js +13 -0
- package/lib/module/streaming/renderers/index.js.map +1 -0
- package/lib/module/streaming/renderers/renderInlineChildren.js +107 -0
- package/lib/module/streaming/renderers/renderInlineChildren.js.map +1 -0
- package/lib/module/streaming/renderers/renderNode.js +78 -0
- package/lib/module/streaming/renderers/renderNode.js.map +1 -0
- package/lib/module/theme/ThemeProvider.js +62 -0
- package/lib/module/theme/ThemeProvider.js.map +1 -0
- package/lib/module/theme/defaultTheme.js +92 -0
- package/lib/module/theme/defaultTheme.js.map +1 -0
- package/lib/module/theme/index.js +5 -0
- package/lib/module/theme/index.js.map +1 -0
- package/lib/module/theme/tokens.js +2 -0
- package/lib/module/theme/tokens.js.map +1 -0
- package/lib/module/types.d.js +2 -0
- package/lib/module/types.d.js.map +1 -0
- package/lib/module/voice/index.js +14 -0
- package/lib/module/voice/index.js.map +1 -0
- package/lib/module/voice/useSpeechRecognition.js +169 -0
- package/lib/module/voice/useSpeechRecognition.js.map +1 -0
- package/lib/typescript/src/chatbot/AIImage.d.ts +24 -0
- package/lib/typescript/src/chatbot/AIImage.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/Attachments.d.ts +20 -0
- package/lib/typescript/src/chatbot/Attachments.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/ChatErrorBoundary.d.ts +57 -0
- package/lib/typescript/src/chatbot/ChatErrorBoundary.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/ChatMessageItem.d.ts +45 -0
- package/lib/typescript/src/chatbot/ChatMessageItem.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/Conversation.d.ts +94 -0
- package/lib/typescript/src/chatbot/Conversation.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/ConversationScrollButton.d.ts +62 -0
- package/lib/typescript/src/chatbot/ConversationScrollButton.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/Message.d.ts +39 -0
- package/lib/typescript/src/chatbot/Message.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/PromptInput.d.ts +93 -0
- package/lib/typescript/src/chatbot/PromptInput.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/Reasoning.d.ts +14 -0
- package/lib/typescript/src/chatbot/Reasoning.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/Shimmer.d.ts +13 -0
- package/lib/typescript/src/chatbot/Shimmer.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/Sources.d.ts +17 -0
- package/lib/typescript/src/chatbot/Sources.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/Suggestion.d.ts +15 -0
- package/lib/typescript/src/chatbot/Suggestion.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/Tool.d.ts +30 -0
- package/lib/typescript/src/chatbot/Tool.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/adapters/uiMessageAdapter.d.ts +24 -0
- package/lib/typescript/src/chatbot/adapters/uiMessageAdapter.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/index.d.ts +29 -0
- package/lib/typescript/src/chatbot/index.d.ts.map +1 -0
- package/lib/typescript/src/chatbot/types.d.ts +49 -0
- package/lib/typescript/src/chatbot/types.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +9 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useAutoScroll.d.ts +23 -0
- package/lib/typescript/src/hooks/useAutoScroll.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useClipboard.d.ts +22 -0
- package/lib/typescript/src/hooks/useClipboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useCollapsible.d.ts +28 -0
- package/lib/typescript/src/hooks/useCollapsible.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useStickToBottom.d.ts +39 -0
- package/lib/typescript/src/hooks/useStickToBottom.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +11 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/primitives/Badge.d.ts +10 -0
- package/lib/typescript/src/primitives/Badge.d.ts.map +1 -0
- package/lib/typescript/src/primitives/Button.d.ts +16 -0
- package/lib/typescript/src/primitives/Button.d.ts.map +1 -0
- package/lib/typescript/src/primitives/Card.d.ts +33 -0
- package/lib/typescript/src/primitives/Card.d.ts.map +1 -0
- package/lib/typescript/src/primitives/Collapsible.d.ts +20 -0
- package/lib/typescript/src/primitives/Collapsible.d.ts.map +1 -0
- package/lib/typescript/src/primitives/ScrollArea.d.ts +10 -0
- package/lib/typescript/src/primitives/ScrollArea.d.ts.map +1 -0
- package/lib/typescript/src/primitives/index.d.ts +11 -0
- package/lib/typescript/src/primitives/index.d.ts.map +1 -0
- package/lib/typescript/src/streaming/StreamingMarkdown.d.ts +47 -0
- package/lib/typescript/src/streaming/StreamingMarkdown.d.ts.map +1 -0
- package/lib/typescript/src/streaming/index.d.ts +3 -0
- package/lib/typescript/src/streaming/index.d.ts.map +1 -0
- package/lib/typescript/src/streaming/parser.d.ts +41 -0
- package/lib/typescript/src/streaming/parser.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/BlockquoteRenderer.d.ts +7 -0
- package/lib/typescript/src/streaming/renderers/BlockquoteRenderer.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/CodeRenderer.d.ts +7 -0
- package/lib/typescript/src/streaming/renderers/CodeRenderer.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/HeadingRenderer.d.ts +7 -0
- package/lib/typescript/src/streaming/renderers/HeadingRenderer.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/ImageRenderer.d.ts +7 -0
- package/lib/typescript/src/streaming/renderers/ImageRenderer.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/LinkRenderer.d.ts +7 -0
- package/lib/typescript/src/streaming/renderers/LinkRenderer.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/ListRenderer.d.ts +7 -0
- package/lib/typescript/src/streaming/renderers/ListRenderer.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/TableRenderer.d.ts +7 -0
- package/lib/typescript/src/streaming/renderers/TableRenderer.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/TextRenderer.d.ts +7 -0
- package/lib/typescript/src/streaming/renderers/TextRenderer.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/index.d.ts +19 -0
- package/lib/typescript/src/streaming/renderers/index.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/renderInlineChildren.d.ts +12 -0
- package/lib/typescript/src/streaming/renderers/renderInlineChildren.d.ts.map +1 -0
- package/lib/typescript/src/streaming/renderers/renderNode.d.ts +8 -0
- package/lib/typescript/src/streaming/renderers/renderNode.d.ts.map +1 -0
- package/lib/typescript/src/theme/ThemeProvider.d.ts +14 -0
- package/lib/typescript/src/theme/ThemeProvider.d.ts.map +1 -0
- package/lib/typescript/src/theme/defaultTheme.d.ts +4 -0
- package/lib/typescript/src/theme/defaultTheme.d.ts.map +1 -0
- package/lib/typescript/src/theme/index.d.ts +5 -0
- package/lib/typescript/src/theme/index.d.ts.map +1 -0
- package/lib/typescript/src/theme/tokens.d.ts +66 -0
- package/lib/typescript/src/theme/tokens.d.ts.map +1 -0
- package/lib/typescript/src/voice/index.d.ts +3 -0
- package/lib/typescript/src/voice/index.d.ts.map +1 -0
- package/lib/typescript/src/voice/useSpeechRecognition.d.ts +77 -0
- package/lib/typescript/src/voice/useSpeechRecognition.d.ts.map +1 -0
- package/package.json +132 -0
- package/src/chatbot/AIImage.tsx +166 -0
- package/src/chatbot/Attachments.tsx +382 -0
- package/src/chatbot/ChatErrorBoundary.tsx +230 -0
- package/src/chatbot/ChatMessageItem.tsx +195 -0
- package/src/chatbot/Conversation.tsx +537 -0
- package/src/chatbot/ConversationScrollButton.tsx +149 -0
- package/src/chatbot/Message.tsx +266 -0
- package/src/chatbot/PromptInput.tsx +532 -0
- package/src/chatbot/Reasoning.tsx +198 -0
- package/src/chatbot/Shimmer.tsx +146 -0
- package/src/chatbot/Sources.tsx +263 -0
- package/src/chatbot/Suggestion.tsx +123 -0
- package/src/chatbot/Tool.tsx +340 -0
- package/src/chatbot/adapters/uiMessageAdapter.ts +177 -0
- package/src/chatbot/index.ts +97 -0
- package/src/chatbot/types.ts +66 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/useAutoScroll.ts +43 -0
- package/src/hooks/useClipboard.ts +46 -0
- package/src/hooks/useCollapsible.ts +42 -0
- package/src/hooks/useStickToBottom.ts +82 -0
- package/src/index.ts +139 -0
- package/src/primitives/Badge.tsx +119 -0
- package/src/primitives/Button.tsx +213 -0
- package/src/primitives/Card.tsx +221 -0
- package/src/primitives/Collapsible.tsx +168 -0
- package/src/primitives/ScrollArea.tsx +53 -0
- package/src/primitives/index.ts +36 -0
- package/src/streaming/StreamingMarkdown.tsx +282 -0
- package/src/streaming/index.ts +2 -0
- package/src/streaming/parser.ts +506 -0
- package/src/streaming/renderers/BlockquoteRenderer.tsx +42 -0
- package/src/streaming/renderers/CodeRenderer.tsx +158 -0
- package/src/streaming/renderers/HeadingRenderer.tsx +64 -0
- package/src/streaming/renderers/ImageRenderer.tsx +62 -0
- package/src/streaming/renderers/LinkRenderer.tsx +53 -0
- package/src/streaming/renderers/ListRenderer.tsx +65 -0
- package/src/streaming/renderers/TableRenderer.tsx +103 -0
- package/src/streaming/renderers/TextRenderer.tsx +39 -0
- package/src/streaming/renderers/index.ts +26 -0
- package/src/streaming/renderers/renderInlineChildren.tsx +115 -0
- package/src/streaming/renderers/renderNode.tsx +72 -0
- package/src/theme/ThemeProvider.tsx +77 -0
- package/src/theme/defaultTheme.ts +93 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/tokens.ts +69 -0
- package/src/types.d.ts +71 -0
- package/src/voice/index.ts +15 -0
- package/src/voice/useSpeechRecognition.ts +230 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
memo,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
type Ref,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import {
|
|
14
|
+
FlatList,
|
|
15
|
+
StyleSheet,
|
|
16
|
+
View,
|
|
17
|
+
type LayoutChangeEvent,
|
|
18
|
+
type ListRenderItemInfo,
|
|
19
|
+
type NativeScrollEvent,
|
|
20
|
+
type NativeSyntheticEvent,
|
|
21
|
+
type ViewProps,
|
|
22
|
+
} from 'react-native';
|
|
23
|
+
import { useAIElementsTheme } from '../theme';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Constants
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Distance in px from the bottom of the content that still counts as "at the
|
|
31
|
+
* bottom" for the purposes of auto-following content growth (e.g. expanding
|
|
32
|
+
* a Sources collapsible).
|
|
33
|
+
*/
|
|
34
|
+
const STICK_TO_BOTTOM_THRESHOLD = 80;
|
|
35
|
+
|
|
36
|
+
/** Delay before retrying a failed `scrollToIndex` once layout settles. */
|
|
37
|
+
const SCROLL_RETRY_DELAY_MS = 120;
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Types
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Minimum shape Conversation requires from a message. Most chat libraries
|
|
45
|
+
* (the Vercel `ai` SDK, OpenAI's `ChatMessage`, Anthropic's `Message`) all
|
|
46
|
+
* satisfy this contract via `id` + `role`.
|
|
47
|
+
*/
|
|
48
|
+
export interface ConversationMessage {
|
|
49
|
+
id: string;
|
|
50
|
+
/** Optional — used by the default `isUserMessage` predicate. */
|
|
51
|
+
role?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Order in which `messages` is provided. */
|
|
55
|
+
export type ConversationMessageOrder = 'oldest-first' | 'newest-first';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How `Conversation` reacts to new messages and content growth.
|
|
59
|
+
*
|
|
60
|
+
* - `'anchor-user-message'` *(default)* — When a new user message arrives,
|
|
61
|
+
* scroll it to the top of the viewport and use dynamic bottom padding so
|
|
62
|
+
* the assistant response streams downward beneath it. Auto-scroll on
|
|
63
|
+
* collapsible expansion (e.g. opening a Sources block) when the user is
|
|
64
|
+
* already near the bottom and not currently streaming. This mimics how
|
|
65
|
+
* ChatGPT, Claude, and Vercel ai-elements anchor each new turn.
|
|
66
|
+
* - `'stick-to-bottom'` — Classic chat behavior: always follow the bottom
|
|
67
|
+
* on any content size change, even mid-stream. The list "sticks" to the
|
|
68
|
+
* newest content. Matches what `use-stick-to-bottom` does on the web.
|
|
69
|
+
* - `'none'` — Disable all automatic scrolling and dynamic padding. The
|
|
70
|
+
* consumer is fully responsible for managing scroll position via the
|
|
71
|
+
* imperative `ConversationRef`.
|
|
72
|
+
*/
|
|
73
|
+
export type ConversationScrollBehavior =
|
|
74
|
+
| 'anchor-user-message'
|
|
75
|
+
| 'stick-to-bottom'
|
|
76
|
+
| 'none';
|
|
77
|
+
|
|
78
|
+
/** Imperative handle exposed via ref. */
|
|
79
|
+
export interface ConversationRef {
|
|
80
|
+
/** Scroll to a given display index, optionally animated. */
|
|
81
|
+
scrollToIndex: (
|
|
82
|
+
index: number,
|
|
83
|
+
opts?: { animated?: boolean; viewPosition?: number },
|
|
84
|
+
) => void;
|
|
85
|
+
/** Scroll to the top of the list (oldest message). */
|
|
86
|
+
scrollToTop: (opts?: { animated?: boolean }) => void;
|
|
87
|
+
/** Scroll to the bottom of the list (newest message). */
|
|
88
|
+
scrollToBottom: (opts?: { animated?: boolean }) => void;
|
|
89
|
+
/** Whether the user is currently scrolled near the bottom of content. */
|
|
90
|
+
isAtBottom: () => boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ConversationProps<T extends ConversationMessage>
|
|
94
|
+
extends Omit<ViewProps, 'children'> {
|
|
95
|
+
/** Imperative handle. Use `useRef<ConversationRef>(null)` and pass it here. */
|
|
96
|
+
ref?: Ref<ConversationRef>;
|
|
97
|
+
/** The conversation messages. Order is controlled by the `order` prop. */
|
|
98
|
+
messages: T[];
|
|
99
|
+
/** Render a single message. Called for each item in display order. */
|
|
100
|
+
renderMessage: (item: T) => ReactNode;
|
|
101
|
+
/** Rendered when `messages` is empty. Filled to the chat area (`flex: 1`). */
|
|
102
|
+
emptyState?: ReactNode;
|
|
103
|
+
/**
|
|
104
|
+
* Whether the assistant is currently generating. While true, content
|
|
105
|
+
* growth from streaming is **not** auto-scrolled — the user-message
|
|
106
|
+
* anchor takes precedence. Once it flips back to false, content growth
|
|
107
|
+
* (e.g. expanding a Sources / Reasoning collapsible) auto-scrolls if
|
|
108
|
+
* the user was already near the bottom.
|
|
109
|
+
*/
|
|
110
|
+
isStreaming?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Order of items in the `messages` array. Defaults to `'oldest-first'`,
|
|
113
|
+
* matching what `@ai-sdk/react`'s `useChat()` and most LLM SDKs return.
|
|
114
|
+
* Use `'newest-first'` if your state is built with `[newMsg, ...prev]`.
|
|
115
|
+
*/
|
|
116
|
+
order?: ConversationMessageOrder;
|
|
117
|
+
/**
|
|
118
|
+
* Predicate for identifying user messages — used to anchor the most
|
|
119
|
+
* recent user message to the top of the viewport when it appears.
|
|
120
|
+
* Defaults to `(m) => m.role === 'user'`.
|
|
121
|
+
*/
|
|
122
|
+
isUserMessage?: (item: T) => boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Push-based notification when the "near the bottom" state changes.
|
|
125
|
+
* Fires once on each transition (true → false or false → true), driven
|
|
126
|
+
* by either user scroll events or content-size growth from streaming.
|
|
127
|
+
* Use this to drive a floating scroll-to-bottom button — see
|
|
128
|
+
* `ConversationScrollButton`.
|
|
129
|
+
*/
|
|
130
|
+
onIsAtBottomChange?: (isAtBottom: boolean) => void;
|
|
131
|
+
/**
|
|
132
|
+
* How to react to new messages and content growth. Defaults to
|
|
133
|
+
* `'anchor-user-message'`. See {@link ConversationScrollBehavior}.
|
|
134
|
+
*/
|
|
135
|
+
scrollBehavior?: ConversationScrollBehavior;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Helpers
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const defaultIsUserMessage = (m: ConversationMessage): boolean =>
|
|
143
|
+
m.role === 'user';
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Find the most-recently-added user message regardless of array order.
|
|
147
|
+
* - For `'newest-first'`: the newest user msg is the *first* match in the
|
|
148
|
+
* forward direction.
|
|
149
|
+
* - For `'oldest-first'`: the newest user msg is the *last* match, so we
|
|
150
|
+
* walk backward.
|
|
151
|
+
*/
|
|
152
|
+
function findNewestUserMessage<T extends ConversationMessage>(
|
|
153
|
+
messages: T[],
|
|
154
|
+
isUserMessage: (m: T) => boolean,
|
|
155
|
+
order: ConversationMessageOrder,
|
|
156
|
+
): T | undefined {
|
|
157
|
+
if (order === 'newest-first') {
|
|
158
|
+
return messages.find(isUserMessage);
|
|
159
|
+
}
|
|
160
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
161
|
+
if (isUserMessage(messages[i])) return messages[i];
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Component
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Chat list, top-to-bottom, with three signature behaviors:
|
|
172
|
+
*
|
|
173
|
+
* 1. **Anchor on send.** When a new user message arrives, the list scrolls
|
|
174
|
+
* that message to the top of the viewport. The assistant response then
|
|
175
|
+
* streams downward beneath it.
|
|
176
|
+
* 2. **Dynamic bottom padding.** When the conversation is shorter than the
|
|
177
|
+
* viewport, padding fills the gap so the user message can still be
|
|
178
|
+
* anchored to the top. Once natural content fills the viewport, the
|
|
179
|
+
* padding collapses to zero so the user can't scroll into empty space.
|
|
180
|
+
* 3. **Stick-to-bottom on growth.** When the user is already near the
|
|
181
|
+
* bottom and content grows (e.g. they tap "Sources" to expand), the
|
|
182
|
+
* list animates to show the new content. Disabled while
|
|
183
|
+
* `isStreaming === true` to avoid fighting the anchor.
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```tsx
|
|
187
|
+
* const ref = useRef<ConversationRef>(null);
|
|
188
|
+
*
|
|
189
|
+
* <Conversation
|
|
190
|
+
* ref={ref}
|
|
191
|
+
* messages={messages} // any T extends { id, role? }
|
|
192
|
+
* renderMessage={(m) => <Bubble msg={m} />}
|
|
193
|
+
* isStreaming={isLoading}
|
|
194
|
+
* order="oldest-first" // or "newest-first"
|
|
195
|
+
* emptyState={<EmptyChat />}
|
|
196
|
+
* />
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
function ConversationInner<T extends ConversationMessage>({
|
|
200
|
+
ref,
|
|
201
|
+
messages,
|
|
202
|
+
renderMessage,
|
|
203
|
+
emptyState,
|
|
204
|
+
isStreaming = false,
|
|
205
|
+
order = 'oldest-first',
|
|
206
|
+
isUserMessage = defaultIsUserMessage as (item: T) => boolean,
|
|
207
|
+
onIsAtBottomChange,
|
|
208
|
+
scrollBehavior = 'anchor-user-message',
|
|
209
|
+
style,
|
|
210
|
+
...viewProps
|
|
211
|
+
}: ConversationProps<T>) {
|
|
212
|
+
const theme = useAIElementsTheme();
|
|
213
|
+
const listRef = useRef<FlatList<T>>(null);
|
|
214
|
+
|
|
215
|
+
// --- Refs (frequently updated, no re-render) ----------------------------
|
|
216
|
+
|
|
217
|
+
/** Latest scroll position (true when within threshold of the bottom). */
|
|
218
|
+
const isAtBottomRef = useRef(true);
|
|
219
|
+
/** Latest "natural" content height (i.e. without dynamic padding). */
|
|
220
|
+
const previousNaturalRef = useRef(0);
|
|
221
|
+
/** Newest natural content height — readable from the imperative API. */
|
|
222
|
+
const naturalContentHeightRef = useRef(0);
|
|
223
|
+
/** Mirror of `isStreaming` so callbacks can read the freshest value. */
|
|
224
|
+
const isStreamingRef = useRef(isStreaming);
|
|
225
|
+
isStreamingRef.current = isStreaming;
|
|
226
|
+
/** Mirror of `scrollBehavior` so callbacks read the freshest value. */
|
|
227
|
+
const scrollBehaviorRef = useRef(scrollBehavior);
|
|
228
|
+
scrollBehaviorRef.current = scrollBehavior;
|
|
229
|
+
/** Last user-message id we anchored to — guards against re-anchoring. */
|
|
230
|
+
const lastUserMsgIdRef = useRef<string | null>(null);
|
|
231
|
+
/**
|
|
232
|
+
* The natural content height as it stood **before** the most recent user
|
|
233
|
+
* message was added. Used as the baseline for the dynamic-padding
|
|
234
|
+
* formula so the new user message can always be scrolled to viewport
|
|
235
|
+
* top, regardless of how much history sits above it. `null` until the
|
|
236
|
+
* first user message has been anchored.
|
|
237
|
+
*/
|
|
238
|
+
const naturalAtAnchorRef = useRef<number | null>(null);
|
|
239
|
+
|
|
240
|
+
// --- onIsAtBottomChange plumbing ----------------------------------------
|
|
241
|
+
/** Mirror the callback so call sites don't bust useCallback memos. */
|
|
242
|
+
const onIsAtBottomChangeRef = useRef(onIsAtBottomChange);
|
|
243
|
+
onIsAtBottomChangeRef.current = onIsAtBottomChange;
|
|
244
|
+
/** Last value emitted to the consumer — used to dedupe transitions. */
|
|
245
|
+
const lastEmittedIsAtBottomRef = useRef(true);
|
|
246
|
+
/** Latest scroll metrics — needed so contentSize changes can recompute. */
|
|
247
|
+
const scrollMetricsRef = useRef({ contentOffset: 0, layoutHeight: 0 });
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Compute isAtBottom from the latest scroll metrics + a known content
|
|
251
|
+
* height. Updates `isAtBottomRef` and fires the change callback if the
|
|
252
|
+
* value transitioned.
|
|
253
|
+
*
|
|
254
|
+
* Skips when `layoutHeight === 0` — that's the "no scroll event has
|
|
255
|
+
* fired yet" sentinel. Without this guard, the first
|
|
256
|
+
* `handleListContentSizeChange` after mount would compute
|
|
257
|
+
* `distanceFromBottom = contentHeight - 0 - 0 = contentHeight`, which
|
|
258
|
+
* is always huge, and emit a false `isAtBottom: false` before the user
|
|
259
|
+
* has even scrolled.
|
|
260
|
+
*/
|
|
261
|
+
const updateIsAtBottom = useCallback((contentHeight: number) => {
|
|
262
|
+
const { contentOffset, layoutHeight } = scrollMetricsRef.current;
|
|
263
|
+
if (layoutHeight === 0) return;
|
|
264
|
+
const distanceFromBottom = contentHeight - (contentOffset + layoutHeight);
|
|
265
|
+
const isAtBottom = distanceFromBottom < STICK_TO_BOTTOM_THRESHOLD;
|
|
266
|
+
isAtBottomRef.current = isAtBottom;
|
|
267
|
+
if (isAtBottom !== lastEmittedIsAtBottomRef.current) {
|
|
268
|
+
lastEmittedIsAtBottomRef.current = isAtBottom;
|
|
269
|
+
onIsAtBottomChangeRef.current?.(isAtBottom);
|
|
270
|
+
}
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
// --- Display order ------------------------------------------------------
|
|
274
|
+
// We always render top-to-bottom = oldest-first. If the caller passes
|
|
275
|
+
// newest-first, reverse internally.
|
|
276
|
+
const orderedMessages = useMemo(
|
|
277
|
+
() => (order === 'newest-first' ? [...messages].reverse() : messages),
|
|
278
|
+
[messages, order],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// --- Dynamic bottom padding ---------------------------------------------
|
|
282
|
+
const [chatAreaHeight, setChatAreaHeight] = useState(0);
|
|
283
|
+
const [listPaddingBottom, setListPaddingBottom] = useState(0);
|
|
284
|
+
|
|
285
|
+
const handleAreaLayout = useCallback((e: LayoutChangeEvent) => {
|
|
286
|
+
setChatAreaHeight(e.nativeEvent.layout.height);
|
|
287
|
+
}, []);
|
|
288
|
+
|
|
289
|
+
const handleListContentSizeChange = useCallback(
|
|
290
|
+
(_w: number, h: number) => {
|
|
291
|
+
// `h` is the *total* content height, which already includes whatever
|
|
292
|
+
// bottom padding we previously set. Subtract it back out to get the
|
|
293
|
+
// natural (real) content height.
|
|
294
|
+
const natural = Math.max(0, h - listPaddingBottom);
|
|
295
|
+
const grew = natural > previousNaturalRef.current;
|
|
296
|
+
previousNaturalRef.current = natural;
|
|
297
|
+
naturalContentHeightRef.current = natural;
|
|
298
|
+
|
|
299
|
+
const behavior = scrollBehaviorRef.current;
|
|
300
|
+
|
|
301
|
+
// --- Dynamic bottom padding (anchor-user-message mode only) ---------
|
|
302
|
+
// Reserve enough room so the most recent user message can be
|
|
303
|
+
// scrolled to the top of the viewport. We measure this as "content
|
|
304
|
+
// added since the user message arrived" and pad the rest of the
|
|
305
|
+
// viewport. As the assistant streams below the user message,
|
|
306
|
+
// contentBelowAnchor grows and the padding shrinks proportionally —
|
|
307
|
+
// total content height stays constant until the assistant has
|
|
308
|
+
// produced enough text to fill the viewport on its own, at which
|
|
309
|
+
// point padding naturally hits zero.
|
|
310
|
+
//
|
|
311
|
+
// The other modes don't need padding manipulation, so they fall
|
|
312
|
+
// back to zero.
|
|
313
|
+
let desired = 0;
|
|
314
|
+
if (behavior === 'anchor-user-message') {
|
|
315
|
+
const contentBelowAnchor =
|
|
316
|
+
naturalAtAnchorRef.current !== null
|
|
317
|
+
? Math.max(0, natural - naturalAtAnchorRef.current)
|
|
318
|
+
: natural;
|
|
319
|
+
desired = Math.max(0, chatAreaHeight - contentBelowAnchor);
|
|
320
|
+
}
|
|
321
|
+
if (desired !== listPaddingBottom) {
|
|
322
|
+
setListPaddingBottom(desired);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Re-evaluate isAtBottom against the new content height. The user
|
|
326
|
+
// may have been "at the bottom" before this growth and now no longer
|
|
327
|
+
// be — common during streaming when the assistant pushes content
|
|
328
|
+
// past the viewport while the user passively watches.
|
|
329
|
+
updateIsAtBottom(h);
|
|
330
|
+
|
|
331
|
+
// --- Auto-scroll on growth ------------------------------------------
|
|
332
|
+
// Behavior depends on the mode:
|
|
333
|
+
//
|
|
334
|
+
// anchor-user-message: only follow the bottom when the user is
|
|
335
|
+
// already near it AND not streaming. Streaming uses the
|
|
336
|
+
// user-message anchor instead, and auto-scrolling would fight
|
|
337
|
+
// it. Post-streaming this catches collapsible expansions.
|
|
338
|
+
// stick-to-bottom: always follow the bottom on growth, even
|
|
339
|
+
// mid-stream — classic chat behavior.
|
|
340
|
+
// none: never auto-scroll. Consumer drives the list via the ref.
|
|
341
|
+
if (!grew || behavior === 'none') return;
|
|
342
|
+
|
|
343
|
+
const shouldFollow =
|
|
344
|
+
behavior === 'stick-to-bottom'
|
|
345
|
+
? true
|
|
346
|
+
: !isStreamingRef.current && isAtBottomRef.current;
|
|
347
|
+
|
|
348
|
+
if (shouldFollow) {
|
|
349
|
+
requestAnimationFrame(() => {
|
|
350
|
+
listRef.current?.scrollToEnd({ animated: true });
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
[listPaddingBottom, chatAreaHeight, updateIsAtBottom],
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const handleScroll = useCallback(
|
|
358
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
359
|
+
const { contentOffset, layoutMeasurement, contentSize } = e.nativeEvent;
|
|
360
|
+
scrollMetricsRef.current = {
|
|
361
|
+
contentOffset: contentOffset.y,
|
|
362
|
+
layoutHeight: layoutMeasurement.height,
|
|
363
|
+
};
|
|
364
|
+
updateIsAtBottom(contentSize.height);
|
|
365
|
+
},
|
|
366
|
+
[updateIsAtBottom],
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// Recompute the padding if the viewport itself changes (rotation, keyboard).
|
|
370
|
+
// Mirrors the formula in handleListContentSizeChange — same anchor-aware
|
|
371
|
+
// contentBelowAnchor calculation so the user message stays anchored
|
|
372
|
+
// through orientation changes. Skipped entirely outside the
|
|
373
|
+
// anchor-user-message mode since other modes don't manage padding.
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (chatAreaHeight === 0) return;
|
|
376
|
+
if (scrollBehavior !== 'anchor-user-message') {
|
|
377
|
+
if (listPaddingBottom !== 0) setListPaddingBottom(0);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const contentBelowAnchor =
|
|
381
|
+
naturalAtAnchorRef.current !== null
|
|
382
|
+
? Math.max(
|
|
383
|
+
0,
|
|
384
|
+
naturalContentHeightRef.current - naturalAtAnchorRef.current,
|
|
385
|
+
)
|
|
386
|
+
: naturalContentHeightRef.current;
|
|
387
|
+
const desired = Math.max(0, chatAreaHeight - contentBelowAnchor);
|
|
388
|
+
setListPaddingBottom(desired);
|
|
389
|
+
// listPaddingBottom intentionally omitted to avoid feedback loops —
|
|
390
|
+
// the same set of conditions runs again whenever it actually matters.
|
|
391
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
392
|
+
}, [chatAreaHeight, scrollBehavior]);
|
|
393
|
+
|
|
394
|
+
// --- Auto-anchor new user message to the top of the viewport -----------
|
|
395
|
+
// Only the `'anchor-user-message'` mode performs this scroll. The other
|
|
396
|
+
// modes leave scroll position to the consumer (or to stick-to-bottom).
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
if (scrollBehavior !== 'anchor-user-message') return;
|
|
399
|
+
|
|
400
|
+
const newestUserMsg = findNewestUserMessage(messages, isUserMessage, order);
|
|
401
|
+
if (!newestUserMsg || newestUserMsg.id === lastUserMsgIdRef.current) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
lastUserMsgIdRef.current = newestUserMsg.id;
|
|
405
|
+
|
|
406
|
+
// Capture the natural content height as it stood **before** this new
|
|
407
|
+
// user message arrived. handleListContentSizeChange may or may not
|
|
408
|
+
// have already processed the new content, but `previousNaturalRef`
|
|
409
|
+
// always holds the value from the most recent step *before* the
|
|
410
|
+
// latest update — which for a freshly-arrived message is the height
|
|
411
|
+
// without it. This becomes the baseline for the dynamic-padding
|
|
412
|
+
// formula so the new message can be scrolled all the way to the top
|
|
413
|
+
// even when there's history above it.
|
|
414
|
+
naturalAtAnchorRef.current = previousNaturalRef.current;
|
|
415
|
+
|
|
416
|
+
const displayIndex = orderedMessages.findIndex(
|
|
417
|
+
(m) => m.id === newestUserMsg.id,
|
|
418
|
+
);
|
|
419
|
+
if (displayIndex < 0) return;
|
|
420
|
+
|
|
421
|
+
// Wait for the new item to be laid out before scrolling.
|
|
422
|
+
requestAnimationFrame(() => {
|
|
423
|
+
listRef.current?.scrollToIndex({
|
|
424
|
+
index: displayIndex,
|
|
425
|
+
viewPosition: 0,
|
|
426
|
+
animated: true,
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}, [messages, orderedMessages, order, isUserMessage, scrollBehavior]);
|
|
430
|
+
|
|
431
|
+
// --- Imperative handle --------------------------------------------------
|
|
432
|
+
useImperativeHandle(
|
|
433
|
+
ref,
|
|
434
|
+
() => ({
|
|
435
|
+
scrollToIndex: (index, opts) => {
|
|
436
|
+
listRef.current?.scrollToIndex({
|
|
437
|
+
index,
|
|
438
|
+
viewPosition: opts?.viewPosition ?? 0,
|
|
439
|
+
animated: opts?.animated ?? true,
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
scrollToTop: (opts) => {
|
|
443
|
+
listRef.current?.scrollToOffset({
|
|
444
|
+
offset: 0,
|
|
445
|
+
animated: opts?.animated ?? true,
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
scrollToBottom: (opts) => {
|
|
449
|
+
listRef.current?.scrollToEnd({ animated: opts?.animated ?? true });
|
|
450
|
+
},
|
|
451
|
+
isAtBottom: () => isAtBottomRef.current,
|
|
452
|
+
}),
|
|
453
|
+
[],
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// --- Row renderer -------------------------------------------------------
|
|
457
|
+
const renderItem = useCallback(
|
|
458
|
+
({ item }: ListRenderItemInfo<T>) => <>{renderMessage(item)}</>,
|
|
459
|
+
[renderMessage],
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const keyExtractor = useCallback((item: T) => item.id, []);
|
|
463
|
+
|
|
464
|
+
const handleScrollToIndexFailed = useCallback(
|
|
465
|
+
(info: {
|
|
466
|
+
index: number;
|
|
467
|
+
highestMeasuredFrameIndex: number;
|
|
468
|
+
averageItemLength: number;
|
|
469
|
+
}) => {
|
|
470
|
+
// Fallback for items not yet measured: approximate offset, then
|
|
471
|
+
// retry once layout settles.
|
|
472
|
+
const offset = info.averageItemLength * info.index;
|
|
473
|
+
listRef.current?.scrollToOffset({ offset, animated: true });
|
|
474
|
+
setTimeout(() => {
|
|
475
|
+
listRef.current?.scrollToIndex({
|
|
476
|
+
index: info.index,
|
|
477
|
+
viewPosition: 0,
|
|
478
|
+
animated: true,
|
|
479
|
+
});
|
|
480
|
+
}, SCROLL_RETRY_DELAY_MS);
|
|
481
|
+
},
|
|
482
|
+
[],
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
return (
|
|
486
|
+
<View
|
|
487
|
+
{...viewProps}
|
|
488
|
+
style={[styles.root, style]}
|
|
489
|
+
onLayout={handleAreaLayout}
|
|
490
|
+
>
|
|
491
|
+
{messages.length === 0 ? (
|
|
492
|
+
<View style={styles.emptyFill}>{emptyState}</View>
|
|
493
|
+
) : (
|
|
494
|
+
<FlatList
|
|
495
|
+
ref={listRef}
|
|
496
|
+
data={orderedMessages}
|
|
497
|
+
renderItem={renderItem}
|
|
498
|
+
keyExtractor={keyExtractor}
|
|
499
|
+
scrollEventThrottle={16}
|
|
500
|
+
showsVerticalScrollIndicator={false}
|
|
501
|
+
keyboardDismissMode="interactive"
|
|
502
|
+
onScroll={handleScroll}
|
|
503
|
+
onContentSizeChange={handleListContentSizeChange}
|
|
504
|
+
contentContainerStyle={{
|
|
505
|
+
paddingTop: theme.spacing.lg,
|
|
506
|
+
paddingBottom: listPaddingBottom,
|
|
507
|
+
}}
|
|
508
|
+
onScrollToIndexFailed={handleScrollToIndexFailed}
|
|
509
|
+
/>
|
|
510
|
+
)}
|
|
511
|
+
</View>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
ConversationInner.displayName = 'Conversation';
|
|
516
|
+
|
|
517
|
+
// `memo` strips the generic from the function signature, so we cast it back
|
|
518
|
+
// to a generic-friendly call signature for the public export. The runtime
|
|
519
|
+
// behavior is unchanged — only the type is preserved.
|
|
520
|
+
export const Conversation = memo(ConversationInner) as <
|
|
521
|
+
T extends ConversationMessage,
|
|
522
|
+
>(
|
|
523
|
+
props: ConversationProps<T>,
|
|
524
|
+
) => ReactElement;
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// Styles
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
const styles = StyleSheet.create({
|
|
531
|
+
root: {
|
|
532
|
+
flex: 1,
|
|
533
|
+
},
|
|
534
|
+
emptyFill: {
|
|
535
|
+
flex: 1,
|
|
536
|
+
},
|
|
537
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Platform,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
type StyleProp,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import { ArrowDown } from 'lucide-react-native';
|
|
10
|
+
import { GlassView } from 'expo-glass-effect';
|
|
11
|
+
import { useAIElementsTheme } from '../theme';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface ConversationScrollButtonProps {
|
|
18
|
+
/**
|
|
19
|
+
* Whether the button is currently visible. When false, the component
|
|
20
|
+
* returns `null` (mount/unmount instead of fade) so the underlying
|
|
21
|
+
* `GlassView` native view is never wrapped in something Reanimated
|
|
22
|
+
* touches — see the comment block below for why that matters.
|
|
23
|
+
*/
|
|
24
|
+
visible: boolean;
|
|
25
|
+
/** Press handler — typically calls `conversationRef.current.scrollToBottom()`. */
|
|
26
|
+
onPress: () => void;
|
|
27
|
+
/** Outer style for the floating wrapper (use for absolute positioning). */
|
|
28
|
+
style?: StyleProp<ViewStyle>;
|
|
29
|
+
/** Optional accessibility label override. */
|
|
30
|
+
accessibilityLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Constants
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
// Match the PromptInput's CircleButton dimensions exactly so the scroll
|
|
38
|
+
// button visually stacks above the Plus button as a continuation of the
|
|
39
|
+
// same control column.
|
|
40
|
+
const CIRCLE_BUTTON_SIZE = 36;
|
|
41
|
+
const ICON_SIZE = 18;
|
|
42
|
+
const isIOS = Platform.OS === 'ios';
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Component
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A floating circle button that appears when the user has scrolled away
|
|
50
|
+
* from the bottom of a conversation, and disappears when they return.
|
|
51
|
+
*
|
|
52
|
+
* Designed to sit in the chat area's bottom-left corner so it visually
|
|
53
|
+
* stacks above the PromptInput's Plus button — same size, same glass
|
|
54
|
+
* treatment, same press feedback. Pairs with
|
|
55
|
+
* `<Conversation onIsAtBottomChange={...} />`: the parent screen tracks
|
|
56
|
+
* the boolean and passes it as `visible`.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```tsx
|
|
60
|
+
* const [isAtBottom, setIsAtBottom] = useState(true);
|
|
61
|
+
* const ref = useRef<ConversationRef>(null);
|
|
62
|
+
*
|
|
63
|
+
* <View style={{ flex: 1 }}>
|
|
64
|
+
* <Conversation
|
|
65
|
+
* ref={ref}
|
|
66
|
+
* onIsAtBottomChange={setIsAtBottom}
|
|
67
|
+
* ...
|
|
68
|
+
* />
|
|
69
|
+
* <ConversationScrollButton
|
|
70
|
+
* visible={!isAtBottom}
|
|
71
|
+
* onPress={() => ref.current?.scrollToBottom()}
|
|
72
|
+
* style={{ position: 'absolute', left: 12, bottom: 12 }}
|
|
73
|
+
* />
|
|
74
|
+
* </View>
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* **Why no fade animation?**
|
|
78
|
+
* Earlier iterations wrapped this in a Reanimated `Animated.View` for a
|
|
79
|
+
* smooth fade-in/out. On the iOS simulator (and likely device) the
|
|
80
|
+
* `expo-glass-effect` `GlassView` would render correctly on first mount
|
|
81
|
+
* but lose its glass material after a Fast Refresh or reload. Reanimated
|
|
82
|
+
* drives styles on the UI thread via a code path that conflicts with
|
|
83
|
+
* `expo-glass-effect`'s custom native view manager — the prop diff that
|
|
84
|
+
* re-applies the glass layer never reaches the native view. Dropping the
|
|
85
|
+
* animation wrapper and using a plain conditional render keeps the
|
|
86
|
+
* GlassView in exactly the same context as the working `Plus` button
|
|
87
|
+
* inside `PromptInput.CircleButton`.
|
|
88
|
+
*/
|
|
89
|
+
export function ConversationScrollButton({
|
|
90
|
+
visible,
|
|
91
|
+
onPress,
|
|
92
|
+
style,
|
|
93
|
+
accessibilityLabel = 'Scroll to latest message',
|
|
94
|
+
}: ConversationScrollButtonProps) {
|
|
95
|
+
const theme = useAIElementsTheme();
|
|
96
|
+
|
|
97
|
+
if (!visible) return null;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<GlassView
|
|
101
|
+
glassEffectStyle={isIOS ? 'regular' : 'none'}
|
|
102
|
+
isInteractive
|
|
103
|
+
colorScheme={theme.dark ? 'dark' : 'light'}
|
|
104
|
+
style={[
|
|
105
|
+
styles.circleButton,
|
|
106
|
+
// On non-iOS GlassView is just a plain View — paint the bg
|
|
107
|
+
// ourselves to match the PromptInput's Plus fallback.
|
|
108
|
+
!isIOS && { backgroundColor: theme.colors.secondary },
|
|
109
|
+
style,
|
|
110
|
+
]}
|
|
111
|
+
>
|
|
112
|
+
<Pressable
|
|
113
|
+
onPress={onPress}
|
|
114
|
+
accessibilityRole="button"
|
|
115
|
+
accessibilityLabel={accessibilityLabel}
|
|
116
|
+
style={({ pressed }) => [
|
|
117
|
+
styles.circleButtonInner,
|
|
118
|
+
{ opacity: pressed ? 0.6 : 1 },
|
|
119
|
+
]}
|
|
120
|
+
>
|
|
121
|
+
<ArrowDown size={ICON_SIZE} color={theme.colors.foreground} />
|
|
122
|
+
</Pressable>
|
|
123
|
+
</GlassView>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ConversationScrollButton.displayName = 'ConversationScrollButton';
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Styles — mirror the PromptInput CircleButton's dimensions exactly
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
const styles = StyleSheet.create({
|
|
134
|
+
circleButton: {
|
|
135
|
+
width: CIRCLE_BUTTON_SIZE,
|
|
136
|
+
height: CIRCLE_BUTTON_SIZE,
|
|
137
|
+
borderRadius: CIRCLE_BUTTON_SIZE / 2,
|
|
138
|
+
// Clip the GlassView blur (and the non-iOS bg) to the rounded shape.
|
|
139
|
+
overflow: 'hidden',
|
|
140
|
+
alignItems: 'center',
|
|
141
|
+
justifyContent: 'center',
|
|
142
|
+
},
|
|
143
|
+
circleButtonInner: {
|
|
144
|
+
width: '100%',
|
|
145
|
+
height: '100%',
|
|
146
|
+
alignItems: 'center',
|
|
147
|
+
justifyContent: 'center',
|
|
148
|
+
},
|
|
149
|
+
});
|