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',