diff --git a/__tests__/MessageBubble.test.tsx b/__tests__/MessageBubble.test.tsx
new file mode 100644
index 0000000..5e7f68a
--- /dev/null
+++ b/__tests__/MessageBubble.test.tsx
@@ -0,0 +1,154 @@
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import * as Clipboard from 'expo-clipboard';
+import React from 'react';
+import { MessageBubble } from '../components/chat/MessageBubble';
+import { ChatMessage } from '../store/useAppStore';
+
+jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
+
+let actionsProps: any;
+jest.mock('../components/chat/MessageActions', () => ({
+ MessageActions: (props: any) => {
+ actionsProps = props;
+ const { Text } = require('react-native');
+ return actions;
+ },
+}));
+
+const message = (overrides: Partial = {}): ChatMessage => ({
+ id: '1',
+ role: 'assistant',
+ content: 'hello **world**',
+ timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
+ ...overrides,
+});
+
+beforeEach(() => {
+ actionsProps = undefined;
+ (Clipboard.setStringAsync as jest.Mock).mockClear();
+});
+
+describe('MessageBubble user message', () => {
+ it('shows the plain text and keeps the actions hidden until touched', () => {
+ render();
+ expect(screen.getByText('hi there')).toBeTruthy();
+ expect(screen.queryByText('actions')).toBeNull();
+ });
+
+ it('reveals edit and delete (but not regenerate) after a touch', () => {
+ const onEdit = jest.fn();
+ const onDelete = jest.fn();
+ const onRegenerate = jest.fn();
+ render(
+ ,
+ );
+ fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
+
+ expect(screen.getByText('actions')).toBeTruthy();
+ expect(actionsProps.role).toBe('user');
+ expect(actionsProps.onEdit).toBe(onEdit);
+ expect(actionsProps.onDelete).toBe(onDelete);
+ expect(actionsProps.onRegenerate).toBeUndefined();
+ });
+
+ it('never renders actions for an empty message', () => {
+ render();
+ fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
+ expect(screen.queryByText('actions')).toBeNull();
+ });
+});
+
+describe('MessageBubble assistant message', () => {
+ it('renders markdown and always shows actions', () => {
+ render();
+ expect(screen.getByText('world')).toBeTruthy();
+ expect(screen.getByText('actions')).toBeTruthy();
+ });
+
+ it('offers regenerate only on the last assistant message and never edit', () => {
+ const onEdit = jest.fn();
+ const onRegenerate = jest.fn();
+ const { rerender } = render();
+ expect(actionsProps.onRegenerate).toBeUndefined();
+ expect(actionsProps.onEdit).toBeUndefined();
+
+ rerender();
+ expect(actionsProps.onRegenerate).toBe(onRegenerate);
+ expect(actionsProps.onEdit).toBeUndefined();
+ });
+
+ it('flags actions as streaming only for the last assistant message', () => {
+ const { rerender } = render();
+ expect(actionsProps.isStreaming).toBeFalsy();
+
+ rerender();
+ expect(actionsProps.isStreaming).toBe(true);
+ });
+
+ it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
+ render();
+ expect(screen.queryByText('actions')).toBeNull();
+ });
+
+ it('renders an empty answer as markdown when not streaming', () => {
+ render();
+ expect(screen.queryByText('actions')).toBeNull();
+ });
+});
+
+describe('MessageBubble metadata', () => {
+ it('shows the time and the edited badge only for edited messages', () => {
+ const { rerender } = render();
+ expect(screen.queryByText(/edited/)).toBeNull();
+
+ rerender();
+ expect(screen.getByText(/· edited/)).toBeTruthy();
+ });
+});
+
+describe('MessageBubble code blocks', () => {
+ const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
+
+ it('shows the language, the code and copies it to the clipboard', async () => {
+ jest.useFakeTimers();
+ try {
+ render();
+ expect(screen.getByText('ts')).toBeTruthy();
+ expect(screen.getByText(/const a = 1;/)).toBeTruthy();
+ expect(screen.getByText('Copy')).toBeTruthy();
+
+ await act(async () => {
+ fireEvent.press(screen.getByText('Copy'));
+ });
+ expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
+ expect(screen.getByText('Copied!')).toBeTruthy();
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+ expect(screen.getByText('Copy')).toBeTruthy();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('labels a fence without a language as "code"', () => {
+ render();
+ expect(screen.getByText('code')).toBeTruthy();
+ });
+
+ it('renders indented code blocks with a "code" header and copy button', async () => {
+ render();
+ expect(screen.getByText('code')).toBeTruthy();
+ await act(async () => {
+ fireEvent.press(screen.getByText('Copy'));
+ });
+ expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
+ });
+});
diff --git a/components/chat/CodeBlock.tsx b/components/chat/CodeBlock.tsx
new file mode 100644
index 0000000..e03bbca
--- /dev/null
+++ b/components/chat/CodeBlock.tsx
@@ -0,0 +1,73 @@
+import { Check, Copy } from 'lucide-react-native';
+import React from 'react';
+import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+interface CodeBlockProps {
+ language: string;
+ content: string;
+ copied: boolean;
+ onCopy: (text: string) => void;
+}
+
+export function CodeBlock({ language, content, copied, onCopy }: CodeBlockProps) {
+ const CopyIcon = copied ? Check : Copy;
+
+ return (
+
+
+ {language}
+ onCopy(content)} style={styles.copyButton}>
+
+ {copied ? 'Copied!' : 'Copy'}
+
+
+
+ {content}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ codeBlockWrapper: {
+ backgroundColor: '#1E1E1E',
+ borderRadius: 8,
+ marginVertical: 8,
+ overflow: 'hidden',
+ width: '100%',
+ },
+ codeBlockHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ backgroundColor: '#2D2D2D',
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: '#404040',
+ },
+ codeBlockLanguage: {
+ color: '#A0A0A0',
+ fontSize: 12,
+ fontFamily: 'monospace',
+ textTransform: 'lowercase',
+ },
+ copyButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ padding: 4,
+ },
+ copyText: {
+ color: '#A0A0A0',
+ fontSize: 12,
+ },
+ codeScrollView: {
+ padding: 12,
+ },
+ codeText: {
+ color: '#D4D4D4',
+ fontSize: 13,
+ fontFamily: 'monospace',
+ },
+});
diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx
index 24c1fb1..df5f086 100644
--- a/components/chat/MessageBubble.tsx
+++ b/components/chat/MessageBubble.tsx
@@ -1,11 +1,10 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore';
-import Markdown from '@ronradtke/react-native-markdown-display';
-import * as Clipboard from 'expo-clipboard';
-import { Bot, Check, Copy, User } from 'lucide-react-native';
-import React, { useMemo, useState } from 'react';
-import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
+import { Bot, User } from 'lucide-react-native';
+import React, { useState } from 'react';
+import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions';
+import { MessageContent } from './MessageContent';
interface MessageBubbleProps {
message: ChatMessage;
@@ -16,6 +15,58 @@ interface MessageBubbleProps {
isLastAssistant?: boolean;
}
+function Avatar({ isUser }: { isUser: boolean }) {
+ const colors = useThemeColor();
+ const Icon = isUser ? User : Bot;
+
+ return (
+
+
+
+ );
+}
+
+function BubbleRow({ message, showingStreaming }: { message: ChatMessage; showingStreaming: boolean }) {
+ const { width } = useWindowDimensions();
+ const colors = useThemeColor();
+ const isUser = message.role === 'user';
+ const maxWidth = width > 768 ? '70%' : '85%';
+
+ return (
+
+ {!isUser && }
+
+
+
+
+
+ {isUser && }
+
+ );
+}
+
+/** Timestamp + edited badge. */
+function BubbleMeta({ message }: { message: ChatMessage }) {
+ const colors = useThemeColor();
+ const isUser = message.role === 'user';
+
+ return (
+
+ {!isUser && }
+
+ {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+ {message.isEdited ? ' · edited' : ''}
+
+
+ );
+}
+
export function MessageBubble({
message,
onEdit,
@@ -24,217 +75,21 @@ export function MessageBubble({
isStreaming,
isLastAssistant,
}: MessageBubbleProps) {
- const { width } = useWindowDimensions();
- const colors = useThemeColor();
- const isLargeScreen = width > 768;
const isUser = message.role === 'user';
const [showActions, setShowActions] = useState(false);
- const [copiedText, setCopiedText] = useState(null);
- const handleCopy = async (text: string) => {
- await Clipboard.setStringAsync(text);
- setCopiedText(text);
- setTimeout(() => setCopiedText(null), 2000);
- };
-
- const showingStreaming = isStreaming && isLastAssistant;
- const shouldShowActions = !isUser || showActions;
-
- // UseMemo for dynamic colors
- const markdownStyles = useMemo(() => ({
- body: {
- fontSize: 15,
- lineHeight: 22,
- color: colors.messageAssistantText,
- },
- strong: {
- fontWeight: '700',
- color: colors.text,
- },
- em: {
- fontStyle: 'italic',
- },
- code_inline: {
- backgroundColor: colors.backgroundColor,
- color: colors.text,
- paddingHorizontal: 5,
- paddingVertical: 1,
- borderRadius: 4,
- fontSize: 13,
- fontFamily: 'monospace',
- },
- fence: {
- backgroundColor: colors.inputBg,
- borderColor: colors.border,
- borderWidth: 1,
- color: colors.text,
- padding: 12,
- borderRadius: 8,
- fontSize: 13,
- fontFamily: 'monospace',
- marginVertical: 8,
- overflow: 'hidden',
- },
- code_block: {
- backgroundColor: colors.inputBg,
- color: colors.text,
- padding: 12,
- borderRadius: 8,
- fontSize: 13,
- fontFamily: 'monospace',
- marginVertical: 8,
- },
- blockquote: {
- borderLeftWidth: 3,
- borderLeftColor: colors.border,
- paddingLeft: 12,
- marginVertical: 4,
- color: colors.textMuted,
- },
- bullet_list: {
- marginVertical: 4,
- },
- ordered_list: {
- marginVertical: 4,
- },
- list_item: {
- marginVertical: 2,
- color: colors.messageAssistantText,
- },
- heading1: {
- fontSize: 22,
- fontWeight: '700',
- marginVertical: 8,
- color: colors.text,
- },
- heading2: {
- fontSize: 19,
- fontWeight: '700',
- marginVertical: 6,
- color: colors.text,
- },
- heading3: {
- fontSize: 17,
- fontWeight: '600',
- marginVertical: 4,
- color: colors.text,
- },
- link: {
- color: '#3B82F6',
- textDecorationLine: 'underline',
- },
- hr: {
- backgroundColor: colors.border,
- height: 1,
- marginVertical: 12,
- },
- paragraph: {
- marginVertical: 2,
- },
- } as any), [colors]); // using 'as any' to satisfy internal Markdown types if needed
-
- const markdownRules = useMemo(() => ({
- fence: (node: any, children: any, parent: any, parentStyles: any) => {
- const content = node.content || '';
- const language = node.sourceInfo || 'code';
- return (
-
-
- {language}
- handleCopy(content)} style={styles.copyButton}>
- {copiedText === content ? : }
- {copiedText === content ? 'Copied!' : 'Copy'}
-
-
-
- {content}
-
-
- );
- },
- code_block: (node: any, children: any, parent: any, parentStyles: any) => {
- const content = node.content || '';
- return (
-
-
- code
- handleCopy(content)} style={styles.copyButton}>
- {copiedText === content ? : }
- {copiedText === content ? 'Copied!' : 'Copy'}
-
-
-
- {content}
-
-
- );
- }
- }), [copiedText]);
-
- const renderContent = () => {
- if (isUser) {
- return (
-
- {message.content}
-
- );
- }
-
- if (!message.content && showingStreaming) {
- return null; // TypingIndicator handled externally
- }
-
- return (
-
- {message.content}
-
- );
- };
+ const showingStreaming = !!isStreaming && !!isLastAssistant;
+ const actionsVisible = (!isUser || showActions) && message.content.length > 0;
return (
isUser && setShowActions(true)}
style={styles.outerWrap}
>
-
- {/* Avatar */}
- {!isUser && (
-
-
-
- )}
+
+
-
- {renderContent()}
-
-
- {isUser && (
-
-
-
- )}
-
-
- {/* Timestamp + edited badge */}
-
- {!isUser && }
-
- {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
- {message.isEdited ? ' · edited' : ''}
-
-
-
- {/* Action buttons */}
- {(shouldShowActions || !isUser) && message.content.length > 0 && (
+ {actionsVisible && (
(null);
+
+ const handleCopy = async (text: string) => {
+ await Clipboard.setStringAsync(text);
+ setCopiedText(text);
+ setTimeout(() => setCopiedText(null), 2000);
+ };
+
+ const markdownStyles = useMemo(() => createMarkdownStyles(colors), [colors]);
+
+ const markdownRules = useMemo(() => ({
+ fence: (node: any) => (
+
+ ),
+ code_block: (node: any) => (
+
+ ),
+ }), [copiedText]);
+
+ if (message.role === 'user') {
+ return (
+
+ {message.content}
+
+ );
+ }
+
+ if (!message.content && showingStreaming) {
+ return null; // TypingIndicator handled externally
+ }
+
+ return (
+
+ {message.content}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ text: {
+ fontSize: 15,
+ lineHeight: 22,
+ },
+});
diff --git a/components/chat/markdownStyles.ts b/components/chat/markdownStyles.ts
new file mode 100644
index 0000000..e86f1d0
--- /dev/null
+++ b/components/chat/markdownStyles.ts
@@ -0,0 +1,99 @@
+import { useThemeColor } from '@/hooks/useThemeColor';
+
+type ThemeColors = ReturnType;
+
+/** Style map for the Markdown renderer, derived from the active theme colours. */
+export function createMarkdownStyles(colors: ThemeColors) {
+ return {
+ body: {
+ fontSize: 15,
+ lineHeight: 22,
+ color: colors.messageAssistantText,
+ },
+ strong: {
+ fontWeight: '700',
+ color: colors.text,
+ },
+ em: {
+ fontStyle: 'italic',
+ },
+ code_inline: {
+ backgroundColor: colors.backgroundColor,
+ color: colors.text,
+ paddingHorizontal: 5,
+ paddingVertical: 1,
+ borderRadius: 4,
+ fontSize: 13,
+ fontFamily: 'monospace',
+ },
+ fence: {
+ backgroundColor: colors.inputBg,
+ borderColor: colors.border,
+ borderWidth: 1,
+ color: colors.text,
+ padding: 12,
+ borderRadius: 8,
+ fontSize: 13,
+ fontFamily: 'monospace',
+ marginVertical: 8,
+ overflow: 'hidden',
+ },
+ code_block: {
+ backgroundColor: colors.inputBg,
+ color: colors.text,
+ padding: 12,
+ borderRadius: 8,
+ fontSize: 13,
+ fontFamily: 'monospace',
+ marginVertical: 8,
+ },
+ blockquote: {
+ borderLeftWidth: 3,
+ borderLeftColor: colors.border,
+ paddingLeft: 12,
+ marginVertical: 4,
+ color: colors.textMuted,
+ },
+ bullet_list: {
+ marginVertical: 4,
+ },
+ ordered_list: {
+ marginVertical: 4,
+ },
+ list_item: {
+ marginVertical: 2,
+ color: colors.messageAssistantText,
+ },
+ heading1: {
+ fontSize: 22,
+ fontWeight: '700',
+ marginVertical: 8,
+ color: colors.text,
+ },
+ heading2: {
+ fontSize: 19,
+ fontWeight: '700',
+ marginVertical: 6,
+ color: colors.text,
+ },
+ heading3: {
+ fontSize: 17,
+ fontWeight: '600',
+ marginVertical: 4,
+ color: colors.text,
+ },
+ link: {
+ color: '#3B82F6',
+ textDecorationLine: 'underline',
+ },
+ hr: {
+ backgroundColor: colors.border,
+ height: 1,
+ marginVertical: 12,
+ },
+ paragraph: {
+ marginVertical: 2,
+ },
+ // The Markdown renderer's style types do not accept every RN style key literal.
+ } as any;
+}
diff --git a/jest.config.js b/jest.config.js
index 6da70c7..8b1e660 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -4,6 +4,10 @@ module.exports = {
moduleNameMapper: {
'^@/(.*)$': '/$1',
},
+ // jest-expo's default pattern plus the ESM-only markdown renderer.
+ transformIgnorePatterns: [
+ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@ronradtke/.*)',
+ ],
testMatch: ['/__tests__/**/*.test.{ts,tsx}'],
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
};