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',
});
}