import React, { useEffect, useRef } from 'react'; import { themedColor } from '../../theme'; import { Animated, Easing, Linking, Pressable, Text, View } from 'react-native'; import { Check, ChevronLeft, Copy, MoreHorizontal, Reply, Share2 } from 'lucide-react-native'; import { AgentSphereIcon } from '../branding/AgentSphereIcon'; import { getToolTypeIcon, type ThinkingPhase } from './agentPhase'; import { ConnectorBrandIcon, hasConnectorBrandIcon } from '../connectors/connectorBrandIcons'; import { ShimmerText } from '../../components/ShimmerText'; import { conversationStyles } from './conversationStyles'; import { MarkdownText } from './MarkdownText'; import { useAgentBi } from '../../analytics/mixpanelContext'; import { useClipboard } from '../../runtime/useClipboard'; import { getFileName, getMessageFiles, getMessageToolCalls, getReplyTo, getRequestedConnectors, getTextDirection, messageHasReplyableContent } from './messageUtils'; import { fileTypeFromUrl, type FileType } from '../attachments/mediaUtils'; import { isSafeUrl } from './markdownTextUtils'; import { FileAttachment } from '../attachments/FileAttachment'; import { ImageGallery, type GalleryImage } from '../../components/ImageGallery'; import { ToolCallSummary } from '../tools/ToolCallSummary'; import type { SuperagentAgent, SuperagentConnector, SuperagentConnectorActionInput, SuperagentConversation, SuperagentEditorTab, SuperagentMarkdownRenderer, SuperagentMessage, SuperagentToolRenderers, } from '../../types'; export function ConversationHeader({ agent, onBack, onOpenMenu, onOpenShare, onRenameRequest, }: { activeTab: SuperagentEditorTab; agent: SuperagentAgent; onBack: () => void; onOpenMenu: () => void; onOpenShare?: () => void; onRenameRequest?: () => void; }) { return ( {agent.name || 'Superagent'} {onOpenShare ? ( ) : null} ); } export function MessageBubble({ agent, availableConnectors, conversationId, currentUserAvatarUrl, isLastAssistantMessage, message, onConnectConnector, onReplyMessage, renderMarkdown, showAvatar, showDebugPayloads, showTimestamp, submitToolCallInput, toolRenderers, }: { agent: SuperagentAgent; availableConnectors?: SuperagentConnector[]; conversationId: string | null; currentUserAvatarUrl?: string | null; isLastAssistantMessage: boolean; message: SuperagentMessage; onConnectConnector?: (input: SuperagentConnectorActionInput) => Promise | boolean | string | void; onReplyMessage?: (message: SuperagentMessage) => void; renderMarkdown?: SuperagentMarkdownRenderer; showAvatar: boolean; showDebugPayloads?: boolean; showTimestamp: boolean; submitToolCallInput?: ( toolCallId: string, approve: boolean, extraUserInput?: unknown, originRequestId?: string, ) => Promise; toolRenderers?: SuperagentToolRenderers; }) { const bi = useAgentBi(); const isUser = message.role === 'user'; const files = getMessageFiles(message); const replyTo = getReplyTo(message); const toolCalls = getMessageToolCalls(message); const requestedConnectors = isUser ? getRequestedConnectors(message).filter(hasConnectorBrandIcon) : []; const senderName = isUser ? 'You' : agent.name || 'Superagent'; const usesScrollableMarkdown = hasScrollableMarkdown(message.content); // Tool-step content and scrollable markdown need full width: the shrink-to-fit // bubble otherwise collapses around its shrinkable rows and crushes them. const breaksOutOfBubble = usesScrollableMarkdown || toolCalls.length > 0; const bubbleStyle = [ conversationStyles.messageBubble, isUser ? conversationStyles.userBubble : conversationStyles.assistantBubble, !showTimestamp && (isUser ? conversationStyles.userBubbleStacked : conversationStyles.assistantBubbleStacked), ]; const { canCopy: clipboardReady, copied, copy } = useClipboard(); // Reply and copy are offered as visible icons under the agent's latest message // only (user messages don't get the affordance; earlier ones would just repeat it). const hasFooterActionContent = !isUser && isLastAssistantMessage && messageHasReplyableContent(message); const canReply = Boolean(onReplyMessage) && hasFooterActionContent; const canCopy = clipboardReady && hasFooterActionContent; const handleReply = () => { void bi.trackEditor('Message Reply'); onReplyMessage?.(message); }; const handleCopy = () => { void bi.trackEditor('Message Copy'); void copy(message.content ?? ''); }; // Only the text/reply/file content rides inside the bubble. Tool-call widgets // (automation cards, artifacts, …) and the reply/timestamp footer render as // siblings instead (see toolCallContent / footerContent below). const textContent = ( <> {requestedConnectors.length > 0 ? : null} {replyTo ? : null} {files.length > 0 ? : null} {message.content ? ( ) : null} ); const toolCallContent = toolCalls.length > 0 ? ( ) : null; // Reply icon and timestamp share one row, split by a thin separator (either can // be absent — stacked messages show no timestamp, user messages get no reply). const timeText = showTimestamp && message.createdAt ? ( {formatTime(message.createdAt)} ) : null; const hasActions = canReply || canCopy; const footerContent = (hasActions || timeText) ? ( {canReply ? ( ) : null} {canCopy ? ( {copied ? ( ) : ( )} ) : null} {hasActions && timeText ? : null} {timeText} ) : null; return ( {showAvatar ? ( {senderName} ) : null} {/* Tool-call widgets and the reply/timestamp footer render as siblings outside the bubble (see toolCallContent / footerContent). The full-bleed width breakout is applied on the wrap above via breaksOutOfBubble. */} {textContent} {toolCallContent} {footerContent} ); } function hasScrollableMarkdown(content?: string) { if (!content) return false; // Backtick and tilde fences both render as a horizontal ScrollView, so the // bubble breaks out to full width for either (see breaksOutOfBubble). if (/```|~~~/.test(content)) return true; const lines = content.replace(/\r\n/g, '\n').split('\n'); return lines.some((line, index) => ( line.includes('|') && Boolean(lines[index + 1]?.match(/^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/)) )); } function ReplyQuote({ content }: { content: string }) { // Align the quoted text by its own language so it matches the reply body below // it (see MarkdownText), rather than flipping against a Hebrew/Arabic reply. const direction = getTextDirection(content); return ( {content} ); } function ConnectorBadges({ connectorIds }: { connectorIds: string[] }) { return ( {connectorIds.map((id) => ( ))} ); } function FileChips({ files }: { files: string[] }) { const bi = useAgentBi(); // Images render through the shared ImageGallery (inline / grid + lightbox); // every other file type stays a FileAttachment chip. const images: GalleryImage[] = []; const others: { url: string; fileType: FileType }[] = []; for (const file of files) { const fileType = fileTypeFromUrl(file); // Only safe http(s) images go to the gallery, which fetches them via ; // anything else falls through to a non-opening chip (its onPress is guarded too). if (fileType === 'image' && isSafeUrl(file)) images.push({ key: file, url: file, fileName: getFileName(file) }); else others.push({ url: file, fileType }); } return ( <> {images.length > 0 ? ( void bi.trackEditor('Chat File Preview Open', { file_type: 'image', source: 'inline_image' })} /> ) : null} {others.length > 0 ? ( {others.map((file) => ( { if (!isSafeUrl(file.url)) return; void bi.trackEditor('Chat File Preview Open', { file_type: file.fileType, source: 'attachment' }); void Linking.openURL(file.url).catch(() => {}); }} /> ))} ) : null} ); } export function TypingIndicator({ agent, phase }: { agent?: SuperagentAgent; phase?: ThinkingPhase | null }) { // Gentle "breathing" on the icon — the native analog of the web // `AgentLiveAvatar` thinking orb (round, living icon) next to shimmer text. const breathe = useRef(new Animated.Value(0)).current; useEffect(() => { const animation = Animated.loop( Animated.sequence([ Animated.timing(breathe, { duration: 900, easing: Easing.inOut(Easing.quad), toValue: 1, useNativeDriver: true, }), Animated.timing(breathe, { duration: 900, easing: Easing.inOut(Easing.quad), toValue: 0, useNativeDriver: true, }), ]), ); animation.start(); return () => animation.stop(); }, [breathe]); const scale = breathe.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1.06], }); const opacity = breathe.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1], }); // Tool-driven phase → show the tool's group label + icon (e.g. "Editing your // code..." with a code glyph). No active tool → fall back to the agent orb + // "Thinking...". const PhaseIcon = phase ? getToolTypeIcon(phase.toolName) : null; const label = phase?.label ?? 'Thinking...'; return ( {PhaseIcon ? ( ) : ( )} ); } function formatTime(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }); }