apps/frontend/REASONING_TOGGLE_CHANGES.md
This document catalogs all frontend changes made in the new_reasoning_design branch for reference when implementing similar changes in mobile.
File: apps/frontend/src/components/thread/content/ReasoningSection.tsx
border-l-2 border-muted-foreground/20)text-muted-foreground stylingy: -10 to just opacity fadeinterface ReasoningSectionProps {
content: string;
className?: string;
isStreaming?: boolean;
/** Whether reasoning is actively being generated (for shimmer effect) */
isReasoningActive?: boolean;
/** Whether reasoning generation is complete */
isReasoningComplete?: boolean;
/** Whether this is persisted content (from server) vs streaming content */
isPersistedContent?: boolean;
/** Controlled mode: external expanded state */
isExpanded?: boolean;
/** Controlled mode: callback when expanded state changes */
onExpandedChange?: (expanded: boolean) => void;
}
Supports both controlled mode (parent provides isExpanded/onExpandedChange) and uncontrolled mode (internal state):
// Support both controlled and uncontrolled modes - start collapsed by default
const [internalExpanded, setInternalExpanded] = useState(false);
// Use controlled mode if external state is provided
const isControlled = controlledExpanded !== undefined;
const isExpanded = isControlled ? controlledExpanded : internalExpanded;
const setIsExpanded = (expanded: boolean) => {
if (isControlled && onExpandedChange) {
onExpandedChange(expanded);
} else {
setInternalExpanded(expanded);
}
};
Uses refs to preserve content and prevent re-animation when user collapses/expands:
const committedContentRef = useRef<string>("");
const lastContentLengthRef = useRef<number>(0);
useEffect(() => {
if (content && content.length > lastContentLengthRef.current) {
committedContentRef.current = content;
lastContentLengthRef.current = content.length;
}
// Reset refs when content is cleared (new stream starting)
if (!content || content.length === 0) {
committedContentRef.current = "";
lastContentLengthRef.current = 0;
}
}, [content]);
// Use committed content for display
const displayContent = committedContentRef.current || content;
const shouldShimmer = (isReasoningActive || isStreaming) && !isReasoningComplete;
Uses Streamdown component directly with isAnimating prop instead of useSmoothText.
File: apps/frontend/src/components/thread/content/ThreadContent.tsx
Refs cache last known content to prevent flash during streaming-to-persisted transitions:
// Refs for frozen content (prevents flash during transitions)
const lastTextContentRef = useRef<string>("");
const lastReasoningContentRef = useRef<string>("");
const lastAskCompleteTextRef = useRef<string>("");
// Always keep refs updated with latest content
useEffect(() => {
if (streamingTextContent) {
lastTextContentRef.current = streamingTextContent;
}
}, [streamingTextContent]);
// Reset refs when agent starts a new turn
const prevAgentActiveRef = useRef(isAgentActive);
useEffect(() => {
const wasActive = prevAgentActiveRef.current;
const isNowActive = isAgentActive;
prevAgentActiveRef.current = isNowActive;
// Agent just started - clear refs for fresh content
if (!wasActive && isNowActive && isLastGroup) {
lastTextContentRef.current = "";
lastReasoningContentRef.current = "";
lastAskCompleteTextRef.current = "";
}
}, [isAgentActive, isLastGroup]);
Uses controlled mode to persist reasoning expanded state across streaming/persisted transitions:
// In AssistantGroupRow (parent passes state):
const [internalReasoningExpanded, setInternalReasoningExpanded] = useState(false);
// Passed to ReasoningSection:
<ReasoningSection
content={...}
isExpanded={reasoningExpanded}
onExpandedChange={setReasoningExpanded}
isReasoningActive={isReasoningActive}
isReasoningComplete={isReasoningComplete}
/>
Early check prevents showing streaming content when persisted ask/complete exists:
// In streamingContent useMemo:
if (!isStreaming && !isAgentRunning) {
const hasPersistedAskComplete = group.messages.some(m => {
if (m.message_id === "streamingTextContent" || m.message_id === "playbackStreamingText") return false;
if (m.type === "tool") {
const toolContent = safeJsonParse<{ name?: string }>(m.content, {});
return toolContent.name === "ask" || toolContent.name === "complete";
}
if (m.type === "assistant") {
const meta = safeJsonParse<ParsedMetadata>(m.metadata, {});
const toolCalls = meta.tool_calls || [];
return toolCalls.some(tc => {
const toolName = tc.function_name?.replace(/_/g, '-').toLowerCase();
return toolName === "ask" || toolName === "complete";
});
}
return false;
});
if (hasPersistedAskComplete) {
return null; // Let persisted message handle rendering
}
}
The askCompleteText useMemo now caches extracted text in refs to prevent flash:
const askCompleteText = useMemo(() => {
if (!streamingToolCall) {
// No tool call - return cached value to prevent flash during transitions
return lastAskCompleteTextRef.current;
}
// ... extraction logic ...
// Cache the extracted text for smooth transitions
if (extractedText) {
lastAskCompleteTextRef.current = extractedText;
}
return extractedText || lastAskCompleteTextRef.current;
}, [streamingToolCall]);
The loader layout now matches ReasoningSection header layout for smooth visual transition:
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 py-1">
<AgentLoader />
</div>
</div>
Passes actual streaming/activity state instead of hardcoded values:
<ReasoningSection
content={streamingReasoningContent}
isStreaming={streamHookStatus === 'streaming' || streamHookStatus === 'connecting'}
isReasoningActive={agentStatus === 'running' || agentStatus === 'connecting'}
isReasoningComplete={isReasoningComplete}
// ...
/>
CRITICAL FIX: Server-confirmed user messages are NEVER deduplicated based on content:
// For user messages, perform content-based deduplication ONLY for temp messages
// Server-confirmed messages (with real UUIDs) are NEVER deduplicated - they represent
// intentional user actions and should always be displayed
if (messageType === 'user') {
const isTemp = message.message_id?.startsWith('temp-');
// Only deduplicate temp messages - server-confirmed messages are always kept
if (isTemp) {
const contentKey = extractUserMessageText(message.content).trim().toLowerCase();
if (contentKey) {
const tempCreatedAt = message.created_at ? new Date(message.created_at).getTime() : Date.now();
// Skip temp message if server already confirmed a message with same content
// Uses timestamp-aware deduplication: only skip if server message was created within 30 seconds
const hasMatchingServerVersion = displayMessages.some((existing) => {
if (existing.type !== 'user') return false;
if (existing.message_id?.startsWith('temp-')) return false;
if (extractUserMessageText(existing.content).trim().toLowerCase() !== contentKey) return false;
const serverCreatedAt = existing.created_at ? new Date(existing.created_at).getTime() : 0;
return Math.abs(serverCreatedAt - tempCreatedAt) < 30000;
});
if (hasMatchingServerVersion) return;
// Also skip if we already have another temp message with same content (race condition)
if (processedTempUserContents.has(contentKey)) return;
processedTempUserContents.add(contentKey);
}
}
}
File: apps/frontend/src/components/thread/utils.ts
Extracts actual text from user message content (handles JSON wrapper):
/**
* Extract the actual text content from a user message.
* User message content can be:
* 1. A JSON string like '{"content": "Hello"}'
* 2. A plain string like "Hello"
* 3. An object (if already parsed) like {content: "Hello"}
*/
export const extractUserMessageText = (content: unknown): string => {
if (!content) return '';
// If it's already a string
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
if (parsed && typeof parsed === 'object') {
const text = parsed.content;
if (typeof text === 'string') return text;
if (text && typeof text === 'object') return JSON.stringify(text);
return String(text || '');
}
return String(parsed);
} catch {
return content;
}
}
// If it's an object (already parsed)
if (typeof content === 'object' && content !== null) {
const obj = content as Record<string, unknown>;
if ('content' in obj) {
const text = obj.content;
if (typeof text === 'string') return text;
if (text && typeof text === 'object') return JSON.stringify(text);
return String(text || '');
}
return JSON.stringify(content);
}
return String(content);
};
File: apps/frontend/src/hooks/threads/page/use-thread-data.ts
extractUserMessageText from utils.toLowerCase() for content comparison// For user messages: only deduplicate temp messages
if (msg.type === 'user') {
const isTemp = msgId?.startsWith('temp-');
const contentKey = extractUserMessageText(msg.content).trim().toLowerCase();
if (isTemp && contentKey) {
const tempCreatedAt = msg.created_at ? new Date(msg.created_at).getTime() : Date.now();
// Find if there's a matching server message created at similar time
const hasMatchingServerVersion = mergedMessages.some((existing) => {
if (existing.type !== 'user') return false;
if (existing.message_id?.startsWith('temp-')) return false;
if (extractUserMessageText(existing.content).trim().toLowerCase() !== contentKey) return false;
const serverCreatedAt = existing.created_at ? new Date(existing.created_at).getTime() : 0;
return Math.abs(serverCreatedAt - tempCreatedAt) < 30000;
});
if (hasMatchingServerVersion) return;
}
// For temp messages, also check if we already added a temp with same content
if (isTemp && contentKey) {
const alreadyHasTempWithContent = dedupedMessages.some(
(m) => m.type === 'user' &&
m.message_id?.startsWith('temp-') &&
extractUserMessageText(m.content).trim().toLowerCase() === contentKey
);
if (alreadyHasTempWithContent) return;
}
}
Same timestamp-aware logic applied in the merge effect for consistency.
File: apps/frontend/src/components/thread/ThreadComponent.tsx
if (message.type === 'user') {
const contentKey = extractUserMessageText(message.content).trim().toLowerCase();
// First try to find a temp message with same content to replace
const tempIndex = prev.findIndex(
(m) =>
m.type === 'user' &&
m.message_id?.startsWith('temp-') &&
extractUserMessageText(m.content).trim().toLowerCase() === contentKey,
);
if (tempIndex !== -1) {
return prev.map((m, index) => index === tempIndex ? message : m);
}
// Only deduplicate temp messages - allow multiple server-confirmed messages with same content
const tempDuplicateIndex = contentKey ? prev.findIndex(
(m) => m.type === 'user' &&
m.message_id?.startsWith('temp-') &&
extractUserMessageText(m.content).trim().toLowerCase() === contentKey,
) : -1;
if (tempDuplicateIndex !== -1) {
return prev.map((m, index) => index === tempDuplicateIndex ? message : m);
}
}
Aggressive deduplication at render level with timestamp-aware logic:
const deduplicateMessages = (msgs: UnifiedMessage[]): UnifiedMessage[] => {
const seenIds = new Set<string>();
const seenUserContent = new Set<string>();
const result: UnifiedMessage[] = [];
for (const msg of msgs) {
// Skip if we've seen this exact message ID (except temp IDs which can be replaced)
if (msg.message_id && !msg.message_id.startsWith('temp-') && seenIds.has(msg.message_id)) {
continue;
}
// For USER messages: only deduplicate temp messages when server version exists
if (msg.type === 'user') {
const contentKey = extractUserMessageText(msg.content).trim().toLowerCase();
const isTemp = msg.message_id?.startsWith('temp-');
if (isTemp && contentKey) {
const tempCreatedAt = msg.created_at ? new Date(msg.created_at).getTime() : Date.now();
// Only skip if server message with same content exists AND was created within 30 seconds
const hasMatchingServerVersion = result.some((existing) => {
if (existing.type !== 'user') return false;
if (existing.message_id?.startsWith('temp-')) return false;
if (extractUserMessageText(existing.content).trim().toLowerCase() !== contentKey) return false;
const serverCreatedAt = existing.created_at ? new Date(existing.created_at).getTime() : 0;
const timeDiff = Math.abs(serverCreatedAt - tempCreatedAt);
return timeDiff < 30000; // 30 seconds window
});
if (hasMatchingServerVersion) continue;
}
// Track content for temp message deduplication only
if (isTemp && contentKey) {
if (seenUserContent.has(contentKey)) continue;
seenUserContent.add(contentKey);
}
}
// For assistant/tool messages: use looser fingerprint
if ((msg.type === 'assistant' || msg.type === 'tool') && msg.content) {
const fingerprint = `${msg.type}:${String(msg.content).substring(0, 200)}`;
const isDuplicate = result.some(existing => {
if (existing.type !== msg.type) return false;
const existingFingerprint = `${existing.type}:${String(existing.content || '').substring(0, 200)}`;
return existingFingerprint === fingerprint;
});
if (isDuplicate) continue;
}
result.push(msg);
if (msg.message_id) seenIds.add(msg.message_id);
}
return result;
};
File: apps/frontend/src/lib/streaming/types.ts
Added isReasoningComplete to track reasoning state:
export interface UseAgentStreamResult {
status: AgentStatus;
textContent: string;
reasoningContent: string;
isReasoningComplete: boolean; // NEW
toolCall: UnifiedMessage | null;
error: string | null;
agentRunId: string | null;
startStreaming: (runId: string) => Promise<void>;
stopStreaming: () => Promise<void>;
}
File: apps/frontend/src/app/globals.css
@keyframes text-shimmer {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.animate-text-shimmer {
animation: text-shimmer 1.2s ease-in-out infinite;
}
Problem: When user sends the same message again in a later turn, it was incorrectly filtered.
Solution: Timestamp-aware deduplication - temp messages only filtered if server message with same content was created within 30 seconds.
Problem: Both streaming content and persisted ask/complete tool would show same text.
Solution: Early check in streamingContent useMemo to return null when persisted ask/complete exists.
Problem: Content would flash/disappear during streaming-to-persisted transitions.
Solution: Use refs to cache content (lastTextContentRef, lastReasoningContentRef, lastAskCompleteTextRef).
Problem: Expanding/collapsing reasoning would re-animate the text.
Solution: Content freezing pattern with committedContentRef in ReasoningSection.
| File | Type | Key Changes |
|---|---|---|
components/thread/content/ReasoningSection.tsx | Modified | New design, controlled mode, shimmer, content freezing, removed italic |
components/thread/content/ThreadContent.tsx | Modified | Frozen refs, ask/complete fix, loader layout match, deduplication fix |
components/thread/utils.ts | Modified | Added extractUserMessageText helper |
components/thread/ThreadComponent.tsx | Modified | Deduplication in handlers with extractUserMessageText |
hooks/threads/page/use-thread-data.ts | Modified | Timestamp-aware deduplication, extractUserMessageText usage |
lib/streaming/types.ts | Modified | Added isReasoningComplete to UseAgentStreamResult |
lib/streaming/message-processor.ts | Unchanged | Already had reasoning content extraction |
When porting to mobile (apps/mobile/):
Animated API or react-native-reanimated (pulse when active)react-native-shimmer-placeholder or custom Animated)borderLeftWidth: 2, borderLeftColor: colors.mutedForeground/20)Use refs to cache content and prevent flash:
const lastTextContentRef = useRef<string>("");
const lastReasoningContentRef = useRef<string>("");
const lastAskCompleteTextRef = useRef<string>("");
Reset refs when agent starts new turn.
Implement extractUserMessageText helper and use it in:
.toLowerCase()Check for persisted ask/complete before showing streaming content.
Match loader layout with reasoning section header for visual continuity.
Maintain reasoning expanded state across streaming/persisted transitions using controlled mode.
Reasoning Toggle
User Message Deduplication
Ask/Complete Tools
Content Transitions