Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -156,6 +164,13 @@ const AIViewInner: React.FC<AIViewProps> = ({
// 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();

Expand Down Expand Up @@ -418,9 +433,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
// 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(
Expand Down Expand Up @@ -502,9 +514,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
>
<div
ref={innerRef}
className={cn("max-w-4xl mx-auto", mergedMessages.length === 0 && "h-full")}
className={cn("max-w-4xl mx-auto", deferredMessages.length === 0 && "h-full")}
>
{mergedMessages.length === 0 ? (
{deferredMessages.length === 0 ? (
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
<h3>No Messages Yet</h3>
<p>Send a message below to begin</p>
Expand All @@ -520,7 +532,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
</div>
) : (
<>
{mergedMessages.map((msg) => {
{deferredMessages.map((msg) => {
const isAtCutoff =
editCutoffHistoryId !== undefined &&
msg.type !== "history-hidden" &&
Expand Down
40 changes: 29 additions & 11 deletions src/browser/components/Messages/ReasoningMessage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +15,9 @@ const REASONING_FONT_CLASSES = "font-primary text-[12px] leading-[18px]";

export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, className }) => {
const [isExpanded, setIsExpanded] = useState(true);
// Track the height when expanded to reserve space during collapse transitions
const [expandedHeight, setExpandedHeight] = useState<number | null>(null);
const contentRef = useRef<HTMLDivElement>(null);

const content = message.content;
const isStreaming = message.isStreaming;
Expand All @@ -26,6 +29,14 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ 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(() => {
Expand Down Expand Up @@ -118,16 +129,23 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
)}
</div>

{isExpanded && !isSingleLineTrace && (
<div
className={cn(
REASONING_FONT_CLASSES,
"italic opacity-85 [&_p]:mt-0 [&_p]:mb-1 [&_p:last-child]:mb-0"
)}
>
{renderContent()}
</div>
)}
{/* Always render the content container to prevent layout shifts.
Use CSS transitions for smooth height changes instead of conditional rendering. */}
<div
ref={contentRef}
className={cn(
REASONING_FONT_CLASSES,
"italic opacity-85 [&_p]:mt-0 [&_p]:mb-1 [&_p:last-child]:mb-0",
"overflow-hidden transition-[height,opacity] duration-200 ease-in-out"
)}
style={{
height: showExpandedContent ? (expandedHeight ?? "auto") : 0,
opacity: showExpandedContent ? 1 : 0,
}}
aria-hidden={!showExpandedContent}
>
{renderContent()}
</div>
</div>
);
};