diff --git a/app/src/screens/ChatScreen/MessageBubble.tsx b/app/src/screens/ChatScreen/MessageBubble.tsx index 24bf2e9..445e2f5 100644 --- a/app/src/screens/ChatScreen/MessageBubble.tsx +++ b/app/src/screens/ChatScreen/MessageBubble.tsx @@ -6,6 +6,8 @@ import { StyleSheet, Pressable, TouchableOpacity, + ActivityIndicator, + ScrollView, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import type { Message } from '../../store/chatSlice'; @@ -29,8 +31,13 @@ export default function MessageBubble({ message }: MessageBubbleProps) { ? (message.metadata?.agentName as string | undefined) : undefined; const modelName = !isUser ? (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 [showThinking, setShowThinking] = useState(false); const { colors } = useTheme(); const themeStyles = createStyles(colors); @@ -39,24 +46,57 @@ export default function MessageBubble({ message }: MessageBubbleProps) { {agentName && ( 🤖 {agentName} )} - - navigation.navigate('MessageDetails', { messageId: message.id }) - } - delayLongPress={300} - style={({ pressed }) => [ - themeStyles.bubble, - isUser ? themeStyles.userBubble : themeStyles.assistantBubble, - pressed && themeStyles.pressedBubble, - ]} - > - - {message.content || '...'} - - - {time} - - + + {/* ── Thinking indicator ────────────────────────────────────────── */} + {!isUser && isThinking && ( + + + Thinking... + + )} + {!isUser && !isThinking && reasoningContent ? ( + + setShowThinking(v => !v)} + activeOpacity={0.7} + > + + 🧠 Thinking {showThinking ? '▾' : '▸'} + + + {showThinking && ( + + + {reasoningContent} + + + )} + + ) : null} + + {/* ── Main message bubble (hidden while thinking, always shown after) ── */} + {!isThinking && ( + + navigation.navigate('MessageDetails', { messageId: message.id }) + } + delayLongPress={300} + style={({ pressed }) => [ + themeStyles.bubble, + isUser ? themeStyles.userBubble : themeStyles.assistantBubble, + pressed && themeStyles.pressedBubble, + ]} + > + + {message.content || '...'} + + + {time} + + + )} + {liveRate !== undefined && ( {liveRate.toFixed(1)} tok/s @@ -144,4 +184,54 @@ const createStyles = (colors: Record) => marginTop: 2, 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', + }, }); + diff --git a/app/src/store/chatThunks.ts b/app/src/store/chatThunks.ts index e1bde1b..7698931 100644 --- a/app/src/store/chatThunks.ts +++ b/app/src/store/chatThunks.ts @@ -111,6 +111,9 @@ export const generateResponse = createAsyncThunk( let liveTokenCount = 0; const generationStart = Date.now(); let lastRateDispatch = 0; + // Track think-block state for live UI feedback + let lastCleanLength = 0; + let isThinkingActive = false; const result: CompletionResult = await llamaContext.completion( 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 - // we do NOT use it here; the final clean version is applied via - // finalizeAssistantMessage after completion. + } + // Accumulate reasoning tokens and signal thinking state + if (data?.reasoning_content) { + 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 (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; dispatch({ type: 'chat/setMessageMeta',