diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 53b0df58d4..42feb37dde 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -1,4 +1,12 @@ -import React, { useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import React, { + useState, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useDeferredValue, + useMemo, +} from "react"; import { cn } from "@/common/lib/utils"; import { MessageRenderer } from "./Messages/MessageRenderer"; import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; @@ -156,6 +164,13 @@ const AIViewInner: React.FC = ({ // Extract state from workspace state const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState; + // Merge consecutive identical stream errors. + // Use useDeferredValue to allow React to defer the heavy message list rendering + // during rapid updates (streaming), keeping the UI responsive. + // Must be defined before any early returns to satisfy React Hooks rules. + const mergedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]); + const deferredMessages = useDeferredValue(mergedMessages); + // Get active stream message ID for token counting const activeStreamMessageId = aggregator?.getActiveStreamMessageId(); @@ -418,9 +433,6 @@ const AIViewInner: React.FC = ({ // If user pressed the interrupt key, autoRetry stays false until they manually retry. // This makes state transitions explicit and predictable. - // Merge consecutive identical stream errors - const mergedMessages = mergeConsecutiveStreamErrors(messages); - // When editing, find the cutoff point const editCutoffHistoryId = editingMessage ? mergedMessages.find( @@ -502,9 +514,9 @@ const AIViewInner: React.FC = ({ >
- {mergedMessages.length === 0 ? ( + {deferredMessages.length === 0 ? (

No Messages Yet

Send a message below to begin

@@ -520,7 +532,7 @@ const AIViewInner: React.FC = ({
) : ( <> - {mergedMessages.map((msg) => { + {deferredMessages.map((msg) => { const isAtCutoff = editCutoffHistoryId !== undefined && msg.type !== "history-hidden" && diff --git a/src/browser/components/Messages/ReasoningMessage.tsx b/src/browser/components/Messages/ReasoningMessage.tsx index d8d2a63771..b33b66c759 100644 --- a/src/browser/components/Messages/ReasoningMessage.tsx +++ b/src/browser/components/Messages/ReasoningMessage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef, useLayoutEffect } from "react"; import type { DisplayedMessage } from "@/common/types/message"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { TypewriterMarkdown } from "./TypewriterMarkdown"; @@ -15,6 +15,9 @@ const REASONING_FONT_CLASSES = "font-primary text-[12px] leading-[18px]"; export const ReasoningMessage: React.FC = ({ message, className }) => { const [isExpanded, setIsExpanded] = useState(true); + // Track the height when expanded to reserve space during collapse transitions + const [expandedHeight, setExpandedHeight] = useState(null); + const contentRef = useRef(null); const content = message.content; const isStreaming = message.isStreaming; @@ -26,6 +29,14 @@ export const ReasoningMessage: React.FC = ({ message, cla const isSingleLineTrace = !isStreaming && hasContent && !hasAdditionalLines; const isCollapsible = !isStreaming && hasContent && hasAdditionalLines; const showEllipsis = isCollapsible && !isExpanded; + const showExpandedContent = isExpanded && !isSingleLineTrace; + + // Capture expanded height before collapsing to enable smooth transitions + useLayoutEffect(() => { + if (contentRef.current && isExpanded && !isSingleLineTrace) { + setExpandedHeight(contentRef.current.scrollHeight); + } + }, [isExpanded, isSingleLineTrace, content]); // Auto-collapse when streaming ends useEffect(() => { @@ -118,16 +129,23 @@ export const ReasoningMessage: React.FC = ({ message, cla )}
- {isExpanded && !isSingleLineTrace && ( -
- {renderContent()} -
- )} + {/* Always render the content container to prevent layout shifts. + Use CSS transitions for smooth height changes instead of conditional rendering. */} +
+ {renderContent()} +
); };