fix: prevent activity reasoning flash
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user