A React Native app for the ultimate thinking partner.

refactor: unified message labels with dynamic tool actions and inline reasoning

Implements intelligent, context-aware message headers with single unified labels
and inline reasoning toggles.

New Architecture:
- src/utils/messageLabels.ts - Pure label computation with tool action mapping
- src/components/InlineReasoningButton.tsx - Compact chevron-only reasoning toggle
- Updated MessageGroupBubble.tsx - Unified header (label + inline button)
- Updated ToolCallItem.tsx - Removed reasoning prop (handled in parent)
- Deleted ReasoningToggle.tsx - Replaced by inline button

Label Logic:
- Tool calls: "(co searched the web)" / "(co is searching the web)"
- Assistant messages: "(co said)" / "(co is saying)"
- Reasoning-only: "(co thought)" / "(co is thinking)"
- Dynamic transitions during streaming (thinking → saying, thinking → searching)

Tool Mappings:
- web_search → "searched the web" / "is searching the web"
- memory → "recalled" / "is recalling"
- conversation_search → "searched the conversation" / "is searching the conversation"
- grep_files, semantic_search_files → "searched files" / "is searching files"
- memory_replace → "updated memory" / "is updating memory"
- memory_insert → "added to memory" / "is adding to memory"
- fetch_webpage → "fetched a webpage" / "is fetching a webpage"
- open_files → "opened files" / "is opening files"

Benefits:
- No more duplicate labels like "(co thought)" + "(co said)"
- Cleaner visual hierarchy (single header per message)
- Streaming state awareness (labels update as content arrives)
- Easier to add new tool actions (centralized mapping)
- Better UX: reasoning toggle is now subtle inline button, not separate row

+228 -130
+49
src/components/InlineReasoningButton.tsx
··· 1 + /** 2 + * InlineReasoningButton Component 3 + * 4 + * Small chevron button that toggles reasoning visibility. 5 + * Designed to appear inline next to message labels (e.g., "(co said) [>]"). 6 + * 7 + * Replaces the standalone ReasoningToggle component with a more compact, 8 + * integrated design. 9 + */ 10 + 11 + import React from 'react'; 12 + import { TouchableOpacity, StyleSheet } from 'react-native'; 13 + import { Ionicons } from '@expo/vector-icons'; 14 + 15 + interface InlineReasoningButtonProps { 16 + isExpanded: boolean; 17 + onToggle: () => void; 18 + isDark: boolean; 19 + } 20 + 21 + export const InlineReasoningButton: React.FC<InlineReasoningButtonProps> = ({ 22 + isExpanded, 23 + onToggle, 24 + isDark, 25 + }) => { 26 + return ( 27 + <TouchableOpacity 28 + onPress={onToggle} 29 + style={styles.button} 30 + activeOpacity={0.6} 31 + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} 32 + > 33 + <Ionicons 34 + name={isExpanded ? 'chevron-down' : 'chevron-forward'} 35 + size={18} 36 + color={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'} 37 + /> 38 + </TouchableOpacity> 39 + ); 40 + }; 41 + 42 + const styles = StyleSheet.create({ 43 + button: { 44 + marginLeft: 6, 45 + padding: 2, 46 + }, 47 + }); 48 + 49 + export default InlineReasoningButton;
+91 -29
src/components/MessageGroupBubble.tsx
··· 11 11 * - tool_return_orphaned: Orphaned tool return (defensive case) 12 12 * - compaction: Memory compaction alerts 13 13 * 14 - * This component reuses existing sub-components (ReasoningToggle, ToolCallItem, 15 - * MessageContent, etc.) but provides a clean, unified rendering path. 14 + * Features unified message headers with dynamic labels: 15 + * - "(co said)" / "(co is saying)" for assistant messages 16 + * - "(co thought)" / "(co is thinking)" for reasoning-only messages 17 + * - "(co searched the web)" / "(co is searching the web)" for tool calls 18 + * - Inline reasoning toggle button (chevron) instead of separate row 16 19 */ 17 20 18 21 import React from 'react'; ··· 22 25 import type { Theme } from '../theme'; 23 26 24 27 // Import sub-components (reused from existing architecture) 25 - import ReasoningToggle from './ReasoningToggle'; 26 28 import ToolCallItem from './ToolCallItem'; 27 29 import ExpandableMessageContent from './ExpandableMessageContent'; 28 30 import CompactionBar from './CompactionBar'; 29 31 import OrphanedToolReturn from './OrphanedToolReturn'; 32 + import InlineReasoningButton from './InlineReasoningButton'; 33 + import { getMessageLabel } from '../utils/messageLabels'; 30 34 31 35 interface MessageGroupBubbleProps { 32 36 group: MessageGroup; ··· 114 118 // ======================================== 115 119 if (group.type === 'tool_call') { 116 120 const isReasoningExpanded = expandedReasoning.has(group.id); 121 + const label = getMessageLabel(group); 117 122 118 123 return ( 119 124 <View style={styles.messageContainer}> 120 - {/* Optional reasoning toggle */} 121 - {group.reasoning && ( 122 - <ReasoningToggle 123 - reasoning={group.reasoning} 124 - messageId={group.id} 125 - isExpanded={isReasoningExpanded} 126 - onToggle={() => toggleReasoning(group.id)} 127 - isDark={isDark} 128 - /> 125 + {/* Unified header: label + optional reasoning button */} 126 + <View style={styles.messageHeader}> 127 + <Text style={[styles.messageLabel, { color: theme.colors.text.primary }]}> 128 + {label} 129 + </Text> 130 + {group.reasoning && ( 131 + <InlineReasoningButton 132 + isExpanded={isReasoningExpanded} 133 + onToggle={() => toggleReasoning(group.id)} 134 + isDark={isDark} 135 + /> 136 + )} 137 + </View> 138 + 139 + {/* Expanded reasoning content */} 140 + {group.reasoning && isReasoningExpanded && ( 141 + <View 142 + style={[ 143 + styles.reasoningExpandedContainer, 144 + { 145 + backgroundColor: isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)', 146 + borderLeftColor: theme.colors.border.primary, 147 + }, 148 + ]} 149 + > 150 + <Text style={[styles.reasoningExpandedText, { color: theme.colors.text.primary }]}> 151 + {group.reasoning} 152 + </Text> 153 + </View> 129 154 )} 130 155 131 156 {/* Tool call + return */} 132 157 <ToolCallItem 133 158 callText={group.toolCall?.args || group.content} 134 159 resultText={group.toolReturn} 135 - reasoning={undefined} // Already shown above if present 160 + reasoning={undefined} // Already shown above 136 161 hasResult={!!group.toolReturn} 137 162 isDark={isDark} 138 163 /> ··· 195 220 // ======================================== 196 221 if (group.type === 'assistant') { 197 222 const isReasoningExpanded = expandedReasoning.has(group.id); 223 + const label = getMessageLabel(group); 198 224 199 225 // Determine if this is the last message (for spacing) 200 226 const shouldHaveMinHeight = lastMessageNeedsSpace; ··· 207 233 group.isStreaming && styles.streamingPulse, 208 234 ]} 209 235 > 210 - {/* Reasoning toggle */} 211 - {group.reasoning && ( 212 - <ReasoningToggle 213 - reasoning={group.reasoning} 214 - messageId={group.id} 215 - isExpanded={isReasoningExpanded} 216 - onToggle={() => toggleReasoning(group.id)} 217 - isDark={isDark} 218 - /> 236 + {/* Unified header: label + optional reasoning button */} 237 + <View style={styles.messageHeader}> 238 + <Text style={[styles.messageLabel, { color: theme.colors.text.primary }]}> 239 + {label} 240 + </Text> 241 + {group.reasoning && ( 242 + <InlineReasoningButton 243 + isExpanded={isReasoningExpanded} 244 + onToggle={() => toggleReasoning(group.id)} 245 + isDark={isDark} 246 + /> 247 + )} 248 + </View> 249 + 250 + {/* Expanded reasoning content */} 251 + {group.reasoning && isReasoningExpanded && ( 252 + <View 253 + style={[ 254 + styles.reasoningExpandedContainer, 255 + { 256 + backgroundColor: isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)', 257 + borderLeftColor: theme.colors.border.primary, 258 + }, 259 + ]} 260 + > 261 + <Text style={[styles.reasoningExpandedText, { color: theme.colors.text.primary }]}> 262 + {group.reasoning} 263 + </Text> 264 + </View> 219 265 )} 220 - 221 - {/* "(co said)" label */} 222 - <Text style={[styles.assistantLabel, { color: theme.colors.text.primary }]}> 223 - (co said) 224 - </Text> 225 266 226 267 {/* Message content with copy button */} 227 268 <View style={{ position: 'relative' }}> ··· 297 338 paddingVertical: 16, 298 339 width: '100%', 299 340 }, 300 - assistantLabel: { 341 + // Unified message header (label + reasoning button) 342 + messageHeader: { 343 + flexDirection: 'row', 344 + alignItems: 'center', 345 + marginBottom: 8, 346 + }, 347 + messageLabel: { 301 348 fontSize: 16, 302 349 fontFamily: 'Lexend_500Medium', 303 - marginBottom: 8, 350 + }, 351 + // Reasoning expanded content (from ReasoningToggle) 352 + reasoningExpandedContainer: { 353 + paddingVertical: 12, 354 + paddingHorizontal: 16, 355 + paddingLeft: 20, 356 + marginBottom: 12, 357 + borderRadius: 8, 358 + borderLeftWidth: 4, 359 + overflow: 'hidden', 360 + }, 361 + reasoningExpandedText: { 362 + fontSize: 14, 363 + fontFamily: 'Lexend_400Regular', 364 + lineHeight: 22, 365 + fontStyle: 'normal', 304 366 }, 305 367 copyButtonContainer: { 306 368 position: 'absolute',
-97
src/components/ReasoningToggle.tsx
··· 1 - import React, { useState } from 'react'; 2 - import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; 3 - import { Ionicons } from '@expo/vector-icons'; 4 - import { darkTheme, lightTheme } from '../theme'; 5 - 6 - interface ReasoningToggleProps { 7 - reasoning: string; 8 - messageId?: string; 9 - isExpanded?: boolean; 10 - onToggle?: () => void; 11 - customToggleContent?: React.ReactNode; 12 - hideChevron?: boolean; 13 - isDark?: boolean; 14 - } 15 - 16 - const ReasoningToggle: React.FC<ReasoningToggleProps> = ({ 17 - reasoning, 18 - messageId, 19 - isExpanded: externalExpanded, 20 - onToggle: externalOnToggle, 21 - customToggleContent, 22 - hideChevron = false, 23 - isDark = true 24 - }) => { 25 - const theme = isDark ? darkTheme : lightTheme; 26 - const [internalExpanded, setInternalExpanded] = useState(false); 27 - 28 - // Use external state if provided, otherwise use internal 29 - const isExpanded = externalExpanded !== undefined ? externalExpanded : internalExpanded; 30 - const handleToggle = externalOnToggle || (() => setInternalExpanded(!internalExpanded)); 31 - 32 - return ( 33 - <> 34 - <TouchableOpacity 35 - onPress={handleToggle} 36 - style={styles.reasoningToggle} 37 - > 38 - {customToggleContent ? ( 39 - customToggleContent 40 - ) : ( 41 - <> 42 - <Text style={[styles.reasoningToggleText, { color: theme.colors.text.primary }]}>(co thought)</Text> 43 - {!hideChevron && ( 44 - <Ionicons 45 - name={isExpanded ? "chevron-up" : "chevron-down"} 46 - size={18} 47 - color={theme.colors.text.tertiary} 48 - style={{ marginLeft: 4 }} 49 - /> 50 - )} 51 - </> 52 - )} 53 - </TouchableOpacity> 54 - {isExpanded && ( 55 - <View style={[ 56 - styles.reasoningExpandedContainer, 57 - { 58 - backgroundColor: isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)', 59 - borderLeftColor: theme.colors.border.primary, 60 - } 61 - ]}> 62 - <Text style={[styles.reasoningExpandedText, { color: theme.colors.text.primary }]}>{reasoning}</Text> 63 - </View> 64 - )} 65 - </> 66 - ); 67 - }; 68 - 69 - const styles = StyleSheet.create({ 70 - reasoningToggle: { 71 - flexDirection: 'row', 72 - alignItems: 'center', 73 - paddingVertical: 4, 74 - marginBottom: 8, 75 - }, 76 - reasoningToggleText: { 77 - fontSize: 16, 78 - fontFamily: 'Lexend_500Medium', 79 - }, 80 - reasoningExpandedContainer: { 81 - paddingVertical: 12, 82 - paddingHorizontal: 16, 83 - paddingLeft: 20, 84 - marginBottom: 12, 85 - borderRadius: 8, 86 - borderLeftWidth: 4, 87 - overflow: 'hidden', 88 - }, 89 - reasoningExpandedText: { 90 - fontSize: 14, 91 - fontFamily: 'Lexend_400Regular', 92 - lineHeight: 22, 93 - fontStyle: 'normal', 94 - }, 95 - }); 96 - 97 - export default React.memo(ReasoningToggle);
+1 -4
src/components/ToolCallItem.tsx
··· 2 2 import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native'; 3 3 import { Ionicons } from '@expo/vector-icons'; 4 4 import { darkTheme, lightTheme } from '../theme'; 5 - import ReasoningToggle from './ReasoningToggle'; 6 5 7 6 interface ToolCallItemProps { 8 7 callText: string; 9 8 resultText?: string; 10 - reasoning?: string; 11 9 hasResult?: boolean; 12 10 isDark?: boolean; 13 11 } ··· 63 61 return displayNames[toolName] || { present: toolName, past: toolName }; 64 62 }; 65 63 66 - const ToolCallItem: React.FC<ToolCallItemProps> = ({ callText, resultText, reasoning, hasResult = false, isDark = true }) => { 64 + const ToolCallItem: React.FC<ToolCallItemProps> = ({ callText, resultText, hasResult = false, isDark = true }) => { 67 65 const theme = isDark ? darkTheme : lightTheme; 68 66 const [expanded, setExpanded] = useState(false); 69 67 const [resultExpanded, setResultExpanded] = useState(false); ··· 136 134 137 135 return ( 138 136 <View style={styles.container}> 139 - {reasoning && <ReasoningToggle reasoning={reasoning} isDark={isDark} />} 140 137 <TouchableOpacity 141 138 style={styles.header} 142 139 onPress={() => setExpanded((e) => !e)}
+87
src/utils/messageLabels.ts
··· 1 + /** 2 + * Message Label Utilities 3 + * 4 + * Computes human-readable labels for message groups based on: 5 + * - Message type (assistant, tool_call, etc.) 6 + * - Streaming state 7 + * - Tool name (for tool calls) 8 + * - Content presence (reasoning-only vs full message) 9 + * 10 + * Used by MessageGroupBubble to show unified "(co <action>)" labels. 11 + */ 12 + 13 + import type { MessageGroup } from '../hooks/useMessageGroups'; 14 + 15 + /** 16 + * Tool name to human-readable action mapping 17 + */ 18 + const TOOL_ACTIONS: Record<string, { past: string; present: string }> = { 19 + web_search: { past: 'searched the web', present: 'is searching the web' }, 20 + open_files: { past: 'opened files', present: 'is opening files' }, 21 + memory: { past: 'recalled', present: 'is recalling' }, 22 + conversation_search: { past: 'searched the conversation', present: 'is searching the conversation' }, 23 + grep_files: { past: 'searched files', present: 'is searching files' }, 24 + memory_replace: { past: 'updated memory', present: 'is updating memory' }, 25 + memory_insert: { past: 'added to memory', present: 'is adding to memory' }, 26 + fetch_webpage: { past: 'fetched a webpage', present: 'is fetching a webpage' }, 27 + semantic_search_files: { past: 'searched files', present: 'is searching files' }, 28 + }; 29 + 30 + /** 31 + * Get human-readable label for a message group 32 + * 33 + * Examples: 34 + * - "(co said)" - assistant message with content 35 + * - "(co thought)" - assistant message with only reasoning 36 + * - "(co searched the web)" - completed web_search tool call 37 + * - "(co is thinking)" - streaming with only reasoning 38 + * - "(co is searching the web)" - streaming web_search tool call 39 + */ 40 + export function getMessageLabel(group: MessageGroup): string { 41 + const isStreaming = group.isStreaming === true; 42 + 43 + // TOOL CALL MESSAGE 44 + if (group.type === 'tool_call') { 45 + const toolName = group.toolCall?.name || 'unknown_tool'; 46 + const action = TOOL_ACTIONS[toolName]; 47 + 48 + if (action) { 49 + return isStreaming ? `(co ${action.present})` : `(co ${action.past})`; 50 + } 51 + 52 + // Fallback for unknown tools 53 + return isStreaming ? `(co is using ${toolName})` : `(co used ${toolName})`; 54 + } 55 + 56 + // ASSISTANT MESSAGE 57 + if (group.type === 'assistant') { 58 + const hasContent = group.content && group.content.trim().length > 0; 59 + const hasReasoningOnly = group.reasoning && !hasContent; 60 + 61 + if (hasReasoningOnly) { 62 + // Only reasoning, no assistant message content 63 + return isStreaming ? '(co is thinking)' : '(co thought)'; 64 + } 65 + 66 + // Has assistant message content (with or without reasoning) 67 + return isStreaming ? '(co is saying)' : '(co said)'; 68 + } 69 + 70 + // USER MESSAGE (shouldn't need label, but handle gracefully) 71 + if (group.type === 'user') { 72 + return ''; // User messages don't show labels 73 + } 74 + 75 + // COMPACTION (shouldn't need label) 76 + if (group.type === 'compaction') { 77 + return ''; // Compaction bars have their own styling 78 + } 79 + 80 + // ORPHANED TOOL RETURN (defensive) 81 + if (group.type === 'tool_return_orphaned') { 82 + return '(tool result)'; 83 + } 84 + 85 + // Fallback 86 + return '(co)'; 87 + }