A React Native app for the ultimate thinking partner.

fix(messages): prevent duplicate labels for tool calls with reasoning

Tool calls with reasoning were rendering two separate labels - one from
MessageGroupBubble and one from ToolCallItem, creating visual duplicates
like "(co updated memory)" appearing twice.

Changes:
- Add hideHeader prop to ToolCallItem for embedded rendering mode
- When hideHeader=true, ToolCallItem shows only function signature with
inline chevron instead of full label header
- MessageGroupBubble now passes hideHeader=true for unified label display
- Remove all debug console.log statements from investigation
- Document unified label architecture in MessageGroupBubble comments

Result: Tool calls now display as single unified message:
(co updated memory) > <- ONE label
[reasoning content] <- Expandable
> memory_replace({...}) <- Tool call (no duplicate label)
> Result <- Expandable

+93 -65
+8 -1
src/components/MessageGroupBubble.tsx
··· 116 116 // ======================================== 117 117 // TOOL CALL MESSAGE 118 118 // ======================================== 119 + // Unified label architecture: 120 + // - ONE label at top: "(co updated memory)" 121 + // - Optional reasoning section (expandable) 122 + // - Tool call details with inline chevron (ToolCallItem with hideHeader={true}) 123 + // - Result section (expandable) 124 + // 125 + // This prevents duplicate labels when both reasoning and tool call are present. 119 126 if (group.type === 'tool_call') { 120 127 const isReasoningExpanded = expandedReasoning.has(group.id); 121 128 const label = getMessageLabel(group); ··· 157 164 <ToolCallItem 158 165 callText={group.toolCall?.args || group.content} 159 166 resultText={group.toolReturn} 160 - reasoning={undefined} // Already shown above 161 167 hasResult={!!group.toolReturn} 162 168 isDark={isDark} 169 + hideHeader={true} // Label already shown in unified header above 163 170 /> 164 171 </View> 165 172 );
+46 -16
src/components/ToolCallItem.tsx
··· 8 8 resultText?: string; 9 9 hasResult?: boolean; 10 10 isDark?: boolean; 11 + hideHeader?: boolean; // When true, skip rendering the label header (shown by parent) 11 12 } 12 13 13 14 // Extract parameters for contextual display ··· 61 62 return displayNames[toolName] || { present: toolName, past: toolName }; 62 63 }; 63 64 64 - const ToolCallItem: React.FC<ToolCallItemProps> = ({ callText, resultText, hasResult = false, isDark = true }) => { 65 + const ToolCallItem: React.FC<ToolCallItemProps> = ({ callText, resultText, hasResult = false, isDark = true, hideHeader = false }) => { 65 66 const theme = isDark ? darkTheme : lightTheme; 66 67 const [expanded, setExpanded] = useState(false); 67 68 const [resultExpanded, setResultExpanded] = useState(false); ··· 134 135 135 136 return ( 136 137 <View style={styles.container}> 137 - <TouchableOpacity 138 - style={styles.header} 139 - onPress={() => setExpanded((e) => !e)} 140 - activeOpacity={0.7} 141 - > 142 - <Text style={[expanded ? styles.callText : styles.displayName, { color: expanded ? theme.colors.text.primary : theme.colors.text.primary }]} numberOfLines={expanded ? 0 : 1}> 143 - {expanded ? prettyCallText : `(${displayText})`} 144 - </Text> 145 - <Ionicons 146 - name={expanded ? 'chevron-up' : 'chevron-down'} 147 - size={18} 148 - color={theme.colors.text.tertiary} 149 - style={styles.chevron} 150 - /> 151 - </TouchableOpacity> 138 + {!hideHeader && ( 139 + <TouchableOpacity 140 + style={styles.header} 141 + onPress={() => setExpanded((e) => !e)} 142 + activeOpacity={0.7} 143 + > 144 + <Text style={[expanded ? styles.callText : styles.displayName, { color: expanded ? theme.colors.text.primary : theme.colors.text.primary }]} numberOfLines={expanded ? 0 : 1}> 145 + {expanded ? prettyCallText : `(${displayText})`} 146 + </Text> 147 + <Ionicons 148 + name={expanded ? 'chevron-up' : 'chevron-down'} 149 + size={18} 150 + color={theme.colors.text.tertiary} 151 + style={styles.chevron} 152 + /> 153 + </TouchableOpacity> 154 + )} 155 + {hideHeader && ( 156 + <TouchableOpacity 157 + style={styles.embeddedHeader} 158 + onPress={() => setExpanded((e) => !e)} 159 + activeOpacity={0.7} 160 + > 161 + <Ionicons 162 + name={expanded ? 'chevron-down' : 'chevron-forward'} 163 + size={14} 164 + color={theme.colors.text.tertiary} 165 + style={styles.embeddedChevron} 166 + /> 167 + <Text style={[styles.callText, { color: theme.colors.text.primary }]} numberOfLines={expanded ? 0 : 1}> 168 + {prettyCallText} 169 + </Text> 170 + </TouchableOpacity> 171 + )} 152 172 {!!resultText && ( 153 173 <TouchableOpacity 154 174 style={[styles.resultHeader, resultExpanded && styles.resultHeaderExpanded, { backgroundColor: theme.colors.background.secondary, borderColor: theme.colors.border.primary }]} ··· 185 205 }, 186 206 chevron: { 187 207 marginLeft: 4, 208 + }, 209 + embeddedHeader: { 210 + flexDirection: 'row', 211 + alignItems: 'flex-start', 212 + paddingVertical: 4, 213 + marginBottom: 8, 214 + }, 215 + embeddedChevron: { 216 + marginRight: 6, 217 + marginTop: 2, 188 218 }, 189 219 displayName: { 190 220 fontSize: 16,
+39 -38
src/hooks/useMessageGroups.ts
··· 136 136 groupedById.get(msg.id)!.push(msg); 137 137 } 138 138 139 - // DEBUG: Log tool call/return linking 140 - console.log('[useMessageGroups] Analyzing tool call/return linking:'); 141 - for (const msg of sorted) { 142 - if (msg.message_type === 'tool_call_message') { 143 - console.log(` TOOL CALL ${msg.id}:`, { 144 - content: typeof msg.content === 'string' ? msg.content.substring(0, 50) : msg.content, 145 - tool_call_id: (msg as any).tool_call_id, 146 - tool_call: msg.tool_call, 147 - }); 148 - } 149 - if (msg.message_type === 'tool_return_message') { 150 - console.log(` TOOL RETURN ${msg.id}:`, { 151 - content: typeof msg.content === 'string' ? msg.content.substring(0, 50) : msg.content, 152 - tool_call_id: (msg as any).tool_call_id, 153 - status: (msg as any).status, 154 - }); 155 - } 156 - } 157 - 158 139 // Step 4: Convert each ID group to MessageGroup 159 140 const groups: MessageGroup[] = []; 160 141 ··· 165 146 } 166 147 } 167 148 168 - // Step 4.5: Pair orphaned tool returns with their tool calls 149 + // Step 4.5: Remove assistant groups that have a tool call in the same step 150 + // When reasoning → assistant → tool call happen in the same step, we only want to show the tool call 151 + const stepIdToGroups = new Map<string, MessageGroup[]>(); 152 + for (const group of groups) { 153 + const msg = sorted.find(m => m.id === group.id); 154 + const stepId = extractStepId(msg); 155 + if (stepId) { 156 + if (!stepIdToGroups.has(stepId)) { 157 + stepIdToGroups.set(stepId, []); 158 + } 159 + stepIdToGroups.get(stepId)!.push(group); 160 + } 161 + } 162 + 163 + // Remove assistant groups if there's a tool_call group in the same step 164 + const groupsToRemove = new Set<string>(); 165 + for (const [stepId, stepGroups] of stepIdToGroups.entries()) { 166 + const hasToolCall = stepGroups.some(g => g.type === 'tool_call'); 167 + if (hasToolCall) { 168 + // Remove any assistant groups in this step (tool call supersedes) 169 + for (const group of stepGroups) { 170 + if (group.type === 'assistant') { 171 + groupsToRemove.add(group.id); 172 + } 173 + } 174 + } 175 + } 176 + 177 + // Filter out the groups marked for removal 178 + const filteredGroups = groups.filter(g => !groupsToRemove.has(g.id)); 179 + 180 + // Step 4.6: Pair orphaned tool returns with their tool calls 169 181 // Letta uses different IDs for tool_call_message and tool_return_message, 170 182 // but they share the same step_id - that's how we link them 171 183 const toolCallGroups = new Map<string, MessageGroup>(); 172 184 const orphanedReturns = new Map<string, MessageGroup>(); 173 185 174 186 // First pass: index tool calls and orphaned returns by step_id 175 - for (const group of groups) { 187 + for (const group of filteredGroups) { 176 188 if (group.type === 'tool_call') { 177 189 const msg = sorted.find(m => m.id === group.id); 178 190 const stepId = extractStepId(msg); 179 191 if (stepId) { 180 192 toolCallGroups.set(stepId, group); 181 - console.log(`[useMessageGroups] Indexed tool call ${group.id} with step_id=${stepId}`); 182 - } else { 183 - console.log(`[useMessageGroups] WARNING: Tool call ${group.id} has no step_id`); 184 193 } 185 194 } else if (group.type === 'tool_return_orphaned') { 186 195 const msg = sorted.find(m => m.id === group.id); 187 196 const stepId = extractStepId(msg); 188 197 if (stepId) { 189 198 orphanedReturns.set(stepId, group); 190 - console.log(`[useMessageGroups] Indexed orphaned return ${group.id} with step_id=${stepId}`); 191 - } else { 192 - console.log(`[useMessageGroups] WARNING: Orphaned return ${group.id} has no step_id`); 193 199 } 194 200 } 195 201 } ··· 201 207 // Merge the return into the call group 202 208 callGroup.toolReturn = returnGroup.content; 203 209 204 - // Remove the orphaned return from groups array 205 - const returnIndex = groups.findIndex(g => g.id === returnGroup.id); 210 + // Remove the orphaned return from filtered groups array 211 + const returnIndex = filteredGroups.findIndex(g => g.id === returnGroup.id); 206 212 if (returnIndex !== -1) { 207 - groups.splice(returnIndex, 1); 213 + filteredGroups.splice(returnIndex, 1); 208 214 } 209 - 210 - console.log(`[useMessageGroups] Paired tool call ${callGroup.id} with return ${returnGroup.id} via step_id=${stepId}`); 211 215 } 212 216 } 213 217 214 218 // Step 5: Sort groups by created_at 215 - groups.sort((a, b) => { 219 + filteredGroups.sort((a, b) => { 216 220 const timeA = new Date(a.created_at || 0).getTime(); 217 221 const timeB = new Date(b.created_at || 0).getTime(); 218 222 return timeA - timeB; 219 223 }); 220 224 221 225 // Step 6: Append streaming group if active 222 - console.log(`[useMessageGroups] isStreaming: ${isStreaming}, streamingState:`, streamingState); 223 226 if (isStreaming && streamingState) { 224 227 const streamingGroup = createStreamingGroup(streamingState); 225 228 if (streamingGroup) { 226 - console.log('[useMessageGroups] Adding streaming group:', streamingGroup.groupKey); 227 - groups.push(streamingGroup); 229 + filteredGroups.push(streamingGroup); 228 230 } 229 231 } 230 232 231 - console.log(`[useMessageGroups] Final groups: ${groups.length} total`); 232 - return groups; 233 + return filteredGroups; 233 234 }, [messages, isStreaming, streamingState]); 234 235 } 235 236
-10
src/hooks/useMessages.ts
··· 41 41 use_assistant_message: true, 42 42 }); 43 43 44 - console.log('[LOAD MESSAGES] Received', loadedMessages.length, 'messages from server'); 45 - 46 - // DEBUG: Log raw tool call/return messages 47 - const toolMessages = loadedMessages.filter(m => 48 - m.message_type === 'tool_call_message' || m.message_type === 'tool_return_message' 49 - ); 50 - if (toolMessages.length > 0) { 51 - console.log('[LOAD MESSAGES] Raw tool messages:', JSON.stringify(toolMessages.slice(0, 4), null, 2)); 52 - } 53 - 54 44 if (loadedMessages.length > 0) { 55 45 if (before) { 56 46 // Loading older messages - prepend them