fix: prevent activity reasoning flash

This commit is contained in:
Bohdan Triapitsyn
2026-05-25 02:07:25 +03:00
parent bb3a51db39
commit f6c76239c5
3 changed files with 142 additions and 97 deletions
@@ -1038,6 +1038,7 @@ const AssistantMessageBody = React.memo(({
const collapsedPreviewCount = 7; const collapsedPreviewCount = 7;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop'; const hasStopFinish = messageFinish === 'stop';
const effectiveStreamPhase: StreamPhase = hasStopFinish ? 'completed' : streamPhase;
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const currentProjectRef = React.useMemo(() => { const currentProjectRef = React.useMemo(() => {
@@ -1506,7 +1507,7 @@ const AssistantMessageBody = React.memo(({
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange} onContentChange={onContentChange}
streamPhase={streamPhase} streamPhase={effectiveStreamPhase}
showHeader={true} showHeader={true}
animateRows={animateActivityRows} animateRows={animateActivityRows}
animatedToolIds={animatedToolIdsLookup} animatedToolIds={animatedToolIdsLookup}
@@ -1551,7 +1552,7 @@ const AssistantMessageBody = React.memo(({
part={part} part={part}
sessionId={sessionId} sessionId={sessionId}
messageId={messageId} messageId={messageId}
streamPhase={streamPhase} streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
@@ -1585,7 +1586,7 @@ const AssistantMessageBody = React.memo(({
part={part} part={part}
sessionId={sessionId} sessionId={sessionId}
messageId={messageId} messageId={messageId}
streamPhase={streamPhase} streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
@@ -1599,7 +1600,7 @@ const AssistantMessageBody = React.memo(({
key={`reasoning-merged-${messageId}`} key={`reasoning-merged-${messageId}`}
parts={flatReasoningParts} parts={flatReasoningParts}
messageId={messageId} messageId={messageId}
streamPhase={streamPhase} streamPhase={effectiveStreamPhase}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
); );
@@ -1611,7 +1612,7 @@ const AssistantMessageBody = React.memo(({
key={`reasoning-${messageId}-${i}`} key={`reasoning-${messageId}-${i}`}
part={part} part={part}
messageId={messageId} messageId={messageId}
streamPhase={streamPhase} streamPhase={effectiveStreamPhase}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
); );
@@ -1719,7 +1720,7 @@ const AssistantMessageBody = React.memo(({
shouldRenderActivityGroup, shouldRenderActivityGroup,
shouldShowStandaloneMessageActions, shouldShowStandaloneMessageActions,
shouldShowTool, shouldShowTool,
streamPhase, effectiveStreamPhase,
showReasoningTraces, showReasoningTraces,
shouldDeferSortedInlineText, shouldDeferSortedInlineText,
syntaxTheme, syntaxTheme,
@@ -39,7 +39,8 @@ describe('ReasoningTimelineBlock', () => {
// Summary preview (beginning of text) is visible in the header // Summary preview (beginning of text) is visible in the header
expect(markup).toContain('First thought'); expect(markup).toContain('First thought');
// Expanded body (MarkdownRenderer) is NOT rendered while collapsed // Historical collapsed blocks do not mount the expanded body, avoiding a
// first-frame flash when Activity reveals previously hidden rows.
expect(markup).not.toContain('data-message-text-export-source'); expect(markup).not.toContain('data-message-text-export-source');
}); });
@@ -34,7 +34,7 @@ const cleanReasoningText = (text: string): string => {
}; };
const SUMMARY_MAX_CHARS = 80; const SUMMARY_MAX_CHARS = 80;
const INLINE_THRESHOLD = 120; const EXPANDED_CONTENT_UNMOUNT_DELAY_MS = 350;
const EXPANDED_CONTENT_SPRING = { type: 'spring' as const, visualDuration: 0.35, bounce: 0 }; const EXPANDED_CONTENT_SPRING = { type: 'spring' as const, visualDuration: 0.35, bounce: 0 };
/** Strip common markdown syntax so the header preview reads as plain text. */ /** Strip common markdown syntax so the header preview reads as plain text. */
@@ -89,6 +89,11 @@ type ReasoningTimelineBlockProps = {
defaultExpanded?: boolean; defaultExpanded?: boolean;
}; };
type ExpansionState = {
expanded: boolean;
source: 'auto' | 'user';
};
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
text, text,
variant, variant,
@@ -101,15 +106,22 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const hasEnded = typeof time?.end === 'number'; const hasEnded = typeof time?.end === 'number';
const [isExpanded, setIsExpanded] = React.useState(hasEnded ? false : (defaultExpanded ?? isStreaming)); const canAutoExpand = isStreaming && !hasEnded;
const [expansion, setExpansion] = React.useState<ExpansionState>(() => {
if (defaultExpanded === true) {
return { expanded: true, source: 'user' };
}
return { expanded: canAutoExpand, source: 'auto' };
});
const isExpanded = expansion.source === 'auto'
? canAutoExpand && expansion.expanded
: expansion.expanded;
const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = React.useState(defaultExpanded === true || canAutoExpand);
const contentId = React.useId(); const contentId = React.useId();
const scrollRef = React.useRef<HTMLElement>(null); const scrollRef = React.useRef<HTMLElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null); const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const contentMountedRef = React.useRef(false); const contentMountedRef = React.useRef(false);
// Track previous isStreaming so the effect only collapses on true→false
// transitions and does NOT override defaultExpanded on initial mount.
const prevIsStreamingRef = React.useRef(isStreaming);
const summary = React.useMemo(() => getReasoningSummary(text), [text]); const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const toggleAriaLabel = isExpanded const toggleAriaLabel = isExpanded
@@ -117,9 +129,10 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
: t('chat.reasoningTrace.expandAria'); : t('chat.reasoningTrace.expandAria');
const handleToggle = React.useCallback(() => { const handleToggle = React.useCallback(() => {
setIsExpanded((prev) => !prev); setShouldRenderExpandedContent(true);
setExpansion({ expanded: !isExpanded, source: 'user' });
onContentChange?.('structural'); onContentChange?.('structural');
}, [onContentChange]); }, [isExpanded, onContentChange]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
@@ -128,15 +141,17 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
} }
}, [handleToggle]); }, [handleToggle]);
React.useEffect(() => { React.useLayoutEffect(() => {
const wasStreaming = prevIsStreamingRef.current; setExpansion((prev) => {
prevIsStreamingRef.current = isStreaming; if (prev.source === 'user') {
// Auto-collapse when live streaming ends or when an end timestamp arrives. return prev;
// Completed blocks initialize collapsed, so historical loads do not animate closed. }
if (hasEnded || (wasStreaming && !isStreaming)) { if (prev.expanded === canAutoExpand) {
setIsExpanded(false); return prev;
} }
}, [hasEnded, isStreaming]); return { expanded: canAutoExpand, source: 'auto' };
});
}, [canAutoExpand]);
React.useEffect(() => { React.useEffect(() => {
if (text.trim().length === 0) { if (text.trim().length === 0) {
@@ -151,6 +166,30 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
} }
}, [text, isStreaming, isExpanded]); }, [text, isStreaming, isExpanded]);
React.useEffect(() => {
if (isExpanded || isStreaming) {
setShouldRenderExpandedContent(true);
return;
}
if (!shouldRenderExpandedContent) {
return;
}
if (typeof window === 'undefined') {
setShouldRenderExpandedContent(false);
return;
}
const timer = window.setTimeout(() => {
setShouldRenderExpandedContent(false);
}, EXPANDED_CONTENT_UNMOUNT_DELAY_MS);
return () => {
window.clearTimeout(timer);
};
}, [isExpanded, isStreaming, shouldRenderExpandedContent]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const element = contentRef.current; const element = contentRef.current;
if (!element) { if (!element) {
@@ -161,10 +200,39 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
if (!contentMountedRef.current) { if (!contentMountedRef.current) {
contentMountedRef.current = true; contentMountedRef.current = true;
element.style.height = isExpanded ? 'auto' : '0px'; if (!isExpanded) {
element.style.opacity = isExpanded ? '1' : '0'; element.style.height = '0px';
element.style.overflow = isExpanded ? 'visible' : 'hidden'; element.style.opacity = '0';
return; element.style.overflow = 'hidden';
return;
}
element.style.height = '0px';
element.style.opacity = '0';
element.style.overflow = 'hidden';
const animation = animate(
element,
{ height: 'auto', opacity: 1 },
EXPANDED_CONTENT_SPRING,
);
contentAnimationRef.current = animation;
void animation.finished.then(() => {
if (contentAnimationRef.current !== animation) {
return;
}
contentAnimationRef.current = null;
element.style.overflow = 'visible';
element.style.height = 'auto';
}).catch(() => undefined);
return () => {
animation.stop();
if (contentAnimationRef.current === animation) {
contentAnimationRef.current = null;
}
};
} }
element.style.overflow = 'hidden'; element.style.overflow = 'hidden';
@@ -216,32 +284,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
return null; return null;
} }
const isShort = !isStreaming && text.trim().length < INLINE_THRESHOLD;
// Short blocks: render content directly without a collapsible toggle.
if (isShort) {
return (
<div data-reasoning-block-id={blockId} data-message-text-export-root="true">
<div data-message-text-export-source="true">
<MarkdownRenderer
content={text}
messageId={blockId}
isAnimated={false}
isStreaming={false}
variant="reasoning"
/>
</div>
{actions ? (
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
{actions}
</div>
</div>
) : null}
</div>
);
}
return ( return (
<div data-reasoning-block-id={blockId} data-message-text-export-root="true"> <div data-reasoning-block-id={blockId} data-message-text-export-root="true">
<div <div
@@ -317,52 +359,53 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
</div> </div>
</div> </div>
{/* Expanded content — keep mounted so auto-collapse can animate smoothly. */} {shouldRenderExpandedContent ? (
<div <div
ref={contentRef} ref={contentRef}
id={contentId} id={contentId}
aria-hidden={!isExpanded} aria-hidden={!isExpanded}
style={{ style={{
height: isExpanded ? 'auto' : '0px', height: isExpanded ? 'auto' : '0px',
opacity: isExpanded ? 1 : 0, opacity: isExpanded ? 1 : 0,
overflow: isExpanded ? 'visible' : 'hidden', overflow: isExpanded ? 'visible' : 'hidden',
overflowAnchor: 'none', overflowAnchor: 'none',
}} }}
> >
<div className="relative ml-2 pl-3 pb-1 pt-0.5"> <div className="relative ml-2 pl-3 pb-1 pt-0.5">
<span <span
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px" className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
style={{ backgroundColor: 'var(--tools-border)' }} style={{ backgroundColor: 'var(--tools-border)' }}
/> />
<ScrollableOverlay <ScrollableOverlay
ref={scrollRef} ref={scrollRef}
as="div" as="div"
outerClassName="max-h-80" outerClassName="max-h-80"
className="p-0" className="p-0"
useScrollShadow useScrollShadow
scrollShadowSize={36} scrollShadowSize={36}
userIntentOnly userIntentOnly
> >
<div data-message-text-export-source="true"> <div data-message-text-export-source="true">
<MarkdownRenderer <MarkdownRenderer
content={text} content={text}
messageId={blockId} messageId={blockId}
isAnimated={false} isAnimated={false}
isStreaming={isStreaming} isStreaming={isStreaming}
variant="reasoning" variant="reasoning"
/> />
</div>
{actions ? (
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
{actions}
</div>
</div> </div>
) : null} {actions ? (
</ScrollableOverlay> <div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
{actions}
</div>
</div>
) : null}
</ScrollableOverlay>
</div>
</div> </div>
</div> ) : null}
</div> </div>
); );
}; };