feat: enhance MessageBubble with thinking indicator and reasoning content display

This commit is contained in:
Jonathan Atta
2026-03-04 11:40:49 +01:00
parent d77bdcf80e
commit 7494107491
2 changed files with 146 additions and 23 deletions

View File

@@ -6,6 +6,8 @@ import {
StyleSheet, StyleSheet,
Pressable, Pressable,
TouchableOpacity, TouchableOpacity,
ActivityIndicator,
ScrollView,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import type { Message } from '../../store/chatSlice'; import type { Message } from '../../store/chatSlice';
@@ -29,8 +31,13 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
? (message.metadata?.agentName as string | undefined) : undefined; ? (message.metadata?.agentName as string | undefined) : undefined;
const modelName = !isUser const modelName = !isUser
? (message.metadata?.modelName as string | undefined) : undefined; ? (message.metadata?.modelName as string | undefined) : undefined;
const isThinking = !isUser
? (message.metadata?.isThinking as boolean | undefined) : undefined;
const reasoningContent = !isUser
? (message.metadata?.reasoningContent as string | undefined) : undefined;
const [showModel, setShowModel] = useState(false); const [showModel, setShowModel] = useState(false);
const [showThinking, setShowThinking] = useState(false);
const { colors } = useTheme(); const { colors } = useTheme();
const themeStyles = createStyles(colors); const themeStyles = createStyles(colors);
@@ -39,24 +46,57 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
{agentName && ( {agentName && (
<Text style={themeStyles.agentLabel}>🤖 {agentName}</Text> <Text style={themeStyles.agentLabel}>🤖 {agentName}</Text>
)} )}
<Pressable
onLongPress={() => {/* ── Thinking indicator ────────────────────────────────────────── */}
navigation.navigate('MessageDetails', { messageId: message.id }) {!isUser && isThinking && (
} <View style={themeStyles.thinkingSpinnerRow}>
delayLongPress={300} <ActivityIndicator size="small" color={colors.textTertiary as string} />
style={({ pressed }) => [ <Text style={themeStyles.thinkingSpinnerText}>Thinking...</Text>
themeStyles.bubble, </View>
isUser ? themeStyles.userBubble : themeStyles.assistantBubble, )}
pressed && themeStyles.pressedBubble, {!isUser && !isThinking && reasoningContent ? (
]} <View style={themeStyles.thinkingWrapper}>
> <TouchableOpacity
<Text style={[themeStyles.text, isUser && themeStyles.userText]}> style={themeStyles.thinkingBadge}
{message.content || '...'} onPress={() => setShowThinking(v => !v)}
</Text> activeOpacity={0.7}
<Text style={[themeStyles.time, isUser && themeStyles.userTime]}> >
{time} <Text style={themeStyles.thinkingBadgeText}>
</Text> 🧠 Thinking {showThinking ? '▾' : '▸'}
</Pressable> </Text>
</TouchableOpacity>
{showThinking && (
<View style={themeStyles.thinkingCard}>
<ScrollView style={themeStyles.thinkingScroll} nestedScrollEnabled>
<Text style={themeStyles.thinkingCardText}>{reasoningContent}</Text>
</ScrollView>
</View>
)}
</View>
) : null}
{/* ── Main message bubble (hidden while thinking, always shown after) ── */}
{!isThinking && (
<Pressable
onLongPress={() =>
navigation.navigate('MessageDetails', { messageId: message.id })
}
delayLongPress={300}
style={({ pressed }) => [
themeStyles.bubble,
isUser ? themeStyles.userBubble : themeStyles.assistantBubble,
pressed && themeStyles.pressedBubble,
]}
>
<Text style={[themeStyles.text, isUser && themeStyles.userText]}>
{message.content || '...'}
</Text>
<Text style={[themeStyles.time, isUser && themeStyles.userTime]}>
{time}
</Text>
</Pressable>
)}
<View style={themeStyles.metaRow}> <View style={themeStyles.metaRow}>
{liveRate !== undefined && ( {liveRate !== undefined && (
<Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text> <Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text>
@@ -144,4 +184,54 @@ const createStyles = (colors: Record<string, unknown>) =>
marginTop: 2, marginTop: 2,
paddingHorizontal: spacing.xs, paddingHorizontal: spacing.xs,
}, },
// ── Thinking styles ──────────────────────────────────────────────────
thinkingSpinnerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
},
thinkingSpinnerText: {
fontSize: typography.sizes.sm,
color: colors.textTertiary,
fontStyle: 'italic',
},
thinkingWrapper: {
maxWidth: '80%',
marginBottom: spacing.xs,
},
thinkingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surfaceTertiary ?? colors.surfaceSecondary,
borderRadius: borderRadius.sm,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
alignSelf: 'flex-start',
},
thinkingBadgeText: {
fontSize: typography.sizes.xs,
color: colors.textSecondary,
fontWeight: typography.weights.semibold,
},
thinkingCard: {
marginTop: spacing.xs,
backgroundColor: colors.surfaceTertiary ?? colors.surfaceSecondary,
borderRadius: borderRadius.md,
borderLeftWidth: 2,
borderLeftColor: colors.textTertiary as string,
padding: spacing.sm,
maxHeight: 220,
},
thinkingScroll: {
flexGrow: 0,
},
thinkingCardText: {
fontSize: typography.sizes.xs,
color: colors.textSecondary,
lineHeight: 16,
fontFamily: 'monospace',
},
}); });

View File

@@ -111,6 +111,9 @@ export const generateResponse = createAsyncThunk(
let liveTokenCount = 0; let liveTokenCount = 0;
const generationStart = Date.now(); const generationStart = Date.now();
let lastRateDispatch = 0; let lastRateDispatch = 0;
// Track think-block state for live UI feedback
let lastCleanLength = 0;
let isThinkingActive = false;
const result: CompletionResult = await llamaContext.completion( const result: CompletionResult = await llamaContext.completion(
samplingParams, samplingParams,
@@ -134,16 +137,46 @@ export const generateResponse = createAsyncThunk(
}); });
} }
} }
// data.token is always the incremental raw piece — safe to append. }
// data.content is cumulative parsed text (think-tags stripped) so // Accumulate reasoning tokens and signal thinking state
// we do NOT use it here; the final clean version is applied via if (data?.reasoning_content) {
// finalizeAssistantMessage after completion. rawStreamReasoning += data.reasoning_content;
if (!isThinkingActive) {
isThinkingActive = true;
dispatch({
type: 'chat/setMessageMeta',
payload: { id: params.assistantMessageId, meta: { isThinking: true } },
});
}
}
// Forward only the clean response via delta of cumulative data.content
// (think-tags already stripped by llama.rn with reasoning_format:'auto')
if (data?.content !== undefined && data.content.length > lastCleanLength) {
const delta = data.content.slice(lastCleanLength);
lastCleanLength = data.content.length;
if (isThinkingActive) {
isThinkingActive = false;
dispatch({
type: 'chat/setMessageMeta',
payload: { id: params.assistantMessageId, meta: { isThinking: false } },
});
}
if (params.onToken) { params.onToken(delta); }
} else if (token && !data?.reasoning_content && data?.content === undefined) {
// Fallback: model without thinking support — forward raw token directly
if (params.onToken) { params.onToken(token); } if (params.onToken) { params.onToken(token); }
} }
if (data?.reasoning_content) { rawStreamReasoning += data.reasoning_content; }
}, },
); );
// Ensure isThinking is cleared if model ended inside a think block
if (isThinkingActive) {
dispatch({
type: 'chat/setMessageMeta',
payload: { id: params.assistantMessageId, meta: { isThinking: false } },
});
}
const elapsed = (Date.now() - generationStart) / 1000; const elapsed = (Date.now() - generationStart) / 1000;
dispatch({ dispatch({
type: 'chat/setMessageMeta', type: 'chat/setMessageMeta',