fix(ui): preserve reasoning collapse animation
This commit is contained in:
@@ -1595,6 +1595,7 @@ const AssistantMessageBody = React.memo(({
|
|||||||
key={`reasoning-merged-${messageId}`}
|
key={`reasoning-merged-${messageId}`}
|
||||||
parts={flatReasoningParts}
|
parts={flatReasoningParts}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
|
streamPhase={streamPhase}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1606,6 +1607,7 @@ const AssistantMessageBody = React.memo(({
|
|||||||
key={`reasoning-${messageId}-${i}`}
|
key={`reasoning-${messageId}-${i}`}
|
||||||
part={part}
|
part={part}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
|
streamPhase={streamPhase}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -803,14 +803,16 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
|
|||||||
/**
|
/**
|
||||||
* Inline reasoning text block — rendered as dimmed italic markdown.
|
* Inline reasoning text block — rendered as dimmed italic markdown.
|
||||||
*/
|
*/
|
||||||
const InlineReasoningBlock = React.memo(({ activity, onContentChange }: {
|
const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: {
|
||||||
activity: TurnActivityPart;
|
activity: TurnActivityPart;
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
|
streamPhase: StreamPhase;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ReasoningPart
|
<ReasoningPart
|
||||||
part={activity.part}
|
part={activity.part}
|
||||||
messageId={activity.messageId}
|
messageId={activity.messageId}
|
||||||
|
streamPhase={streamPhase}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -845,13 +847,12 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
onToggleTool,
|
onToggleTool,
|
||||||
onShowPopup,
|
onShowPopup,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
streamPhase: _streamPhase,
|
streamPhase,
|
||||||
showHeader,
|
showHeader,
|
||||||
animateRows = true,
|
animateRows = true,
|
||||||
animatedToolIds,
|
animatedToolIds,
|
||||||
renderJustificationActions,
|
renderJustificationActions,
|
||||||
}) => {
|
}) => {
|
||||||
void _streamPhase;
|
|
||||||
const previewCount = showHeader && !isExpanded
|
const previewCount = showHeader && !isExpanded
|
||||||
? Math.max(0, Math.floor(collapsedPreviewCount))
|
? Math.max(0, Math.floor(collapsedPreviewCount))
|
||||||
: 0;
|
: 0;
|
||||||
@@ -905,6 +906,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
<>
|
<>
|
||||||
<InlineReasoningBlock
|
<InlineReasoningBlock
|
||||||
activity={row.activity}
|
activity={row.activity}
|
||||||
|
streamPhase={streamPhase}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||||
|
import type { StreamPhase } from '../types';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
@@ -97,8 +98,6 @@ 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 [isExpanded, setIsExpanded] = React.useState(hasEnded ? false : (defaultExpanded ?? isStreaming));
|
||||||
const userToggledRef = React.useRef(false);
|
|
||||||
const effectiveIsExpanded = hasEnded && !userToggledRef.current ? false : isExpanded;
|
|
||||||
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);
|
||||||
@@ -109,12 +108,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
const prevIsStreamingRef = React.useRef(isStreaming);
|
const prevIsStreamingRef = React.useRef(isStreaming);
|
||||||
|
|
||||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||||
const toggleAriaLabel = effectiveIsExpanded
|
const toggleAriaLabel = isExpanded
|
||||||
? t('chat.reasoningTrace.collapseAria')
|
? t('chat.reasoningTrace.collapseAria')
|
||||||
: t('chat.reasoningTrace.expandAria');
|
: t('chat.reasoningTrace.expandAria');
|
||||||
|
|
||||||
const handleToggle = React.useCallback(() => {
|
const handleToggle = React.useCallback(() => {
|
||||||
userToggledRef.current = true;
|
|
||||||
setIsExpanded((prev) => !prev);
|
setIsExpanded((prev) => !prev);
|
||||||
onContentChange?.('structural');
|
onContentChange?.('structural');
|
||||||
}, [onContentChange]);
|
}, [onContentChange]);
|
||||||
@@ -129,8 +127,8 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const wasStreaming = prevIsStreamingRef.current;
|
const wasStreaming = prevIsStreamingRef.current;
|
||||||
prevIsStreamingRef.current = isStreaming;
|
prevIsStreamingRef.current = isStreaming;
|
||||||
// Auto-collapse only when streaming ends (true → false).
|
// Auto-collapse when live streaming ends or when an end timestamp arrives.
|
||||||
// Do not fire on mount so that defaultExpanded is respected.
|
// Completed blocks initialize collapsed, so historical loads do not animate closed.
|
||||||
if (hasEnded || (wasStreaming && !isStreaming)) {
|
if (hasEnded || (wasStreaming && !isStreaming)) {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
}
|
}
|
||||||
@@ -144,10 +142,10 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
}, [onContentChange, text]);
|
}, [onContentChange, text]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isStreaming && effectiveIsExpanded && scrollRef.current) {
|
if (isStreaming && isExpanded && scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [text, isStreaming, effectiveIsExpanded]);
|
}, [text, isStreaming, isExpanded]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const element = contentRef.current;
|
const element = contentRef.current;
|
||||||
@@ -157,25 +155,17 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
|
|
||||||
contentAnimationRef.current?.stop();
|
contentAnimationRef.current?.stop();
|
||||||
|
|
||||||
if (hasEnded && !userToggledRef.current) {
|
|
||||||
element.style.height = '0px';
|
|
||||||
element.style.opacity = '0';
|
|
||||||
element.style.overflow = 'hidden';
|
|
||||||
contentMountedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contentMountedRef.current) {
|
if (!contentMountedRef.current) {
|
||||||
contentMountedRef.current = true;
|
contentMountedRef.current = true;
|
||||||
element.style.height = effectiveIsExpanded ? 'auto' : '0px';
|
element.style.height = isExpanded ? 'auto' : '0px';
|
||||||
element.style.opacity = effectiveIsExpanded ? '1' : '0';
|
element.style.opacity = isExpanded ? '1' : '0';
|
||||||
element.style.overflow = effectiveIsExpanded ? 'visible' : 'hidden';
|
element.style.overflow = isExpanded ? 'visible' : 'hidden';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
element.style.overflow = 'hidden';
|
element.style.overflow = 'hidden';
|
||||||
|
|
||||||
if (effectiveIsExpanded) {
|
if (isExpanded) {
|
||||||
element.style.height = '0px';
|
element.style.height = '0px';
|
||||||
element.style.opacity = '0';
|
element.style.opacity = '0';
|
||||||
} else {
|
} else {
|
||||||
@@ -185,7 +175,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
|
|
||||||
const animation = animate(
|
const animation = animate(
|
||||||
element,
|
element,
|
||||||
{ height: effectiveIsExpanded ? 'auto' : '0px', opacity: effectiveIsExpanded ? 1 : 0 },
|
{ height: isExpanded ? 'auto' : '0px', opacity: isExpanded ? 1 : 0 },
|
||||||
EXPANDED_CONTENT_SPRING,
|
EXPANDED_CONTENT_SPRING,
|
||||||
);
|
);
|
||||||
contentAnimationRef.current = animation;
|
contentAnimationRef.current = animation;
|
||||||
@@ -195,7 +185,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
contentAnimationRef.current = null;
|
contentAnimationRef.current = null;
|
||||||
if (effectiveIsExpanded) {
|
if (isExpanded) {
|
||||||
element.style.overflow = 'visible';
|
element.style.overflow = 'visible';
|
||||||
element.style.height = 'auto';
|
element.style.height = 'auto';
|
||||||
} else {
|
} else {
|
||||||
@@ -209,7 +199,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
contentAnimationRef.current = null;
|
contentAnimationRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [effectiveIsExpanded, hasEnded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -253,7 +243,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-expanded={effectiveIsExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-controls={contentId}
|
aria-controls={contentId}
|
||||||
aria-label={toggleAriaLabel}
|
aria-label={toggleAriaLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -267,8 +257,8 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity',
|
'absolute inset-0 transition-opacity',
|
||||||
effectiveIsExpanded && 'opacity-0',
|
isExpanded && 'opacity-0',
|
||||||
!effectiveIsExpanded && 'group-hover/tool:opacity-0',
|
!isExpanded && 'group-hover/tool:opacity-0',
|
||||||
)}
|
)}
|
||||||
style={{ color: 'var(--tools-icon)' }}
|
style={{ color: 'var(--tools-icon)' }}
|
||||||
>
|
>
|
||||||
@@ -277,12 +267,12 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||||
effectiveIsExpanded && 'opacity-100',
|
isExpanded && 'opacity-100',
|
||||||
!effectiveIsExpanded && 'opacity-0 group-hover/tool:opacity-100',
|
!isExpanded && 'opacity-0 group-hover/tool:opacity-100',
|
||||||
)}
|
)}
|
||||||
style={{ color: 'var(--tools-icon)' }}
|
style={{ color: 'var(--tools-icon)' }}
|
||||||
>
|
>
|
||||||
{effectiveIsExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -291,7 +281,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
<span>{t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')}</span>
|
<span>{t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')}</span>
|
||||||
<BusyDots />
|
<BusyDots />
|
||||||
</span>
|
</span>
|
||||||
) : effectiveIsExpanded ? (
|
) : isExpanded ? (
|
||||||
<span
|
<span
|
||||||
className="typography-meta font-medium"
|
className="typography-meta font-medium"
|
||||||
style={{ color: 'var(--tools-title)' }}
|
style={{ color: 'var(--tools-title)' }}
|
||||||
@@ -309,7 +299,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
|
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
|
||||||
{!isStreaming && !effectiveIsExpanded && summary ? (
|
{!isStreaming && !isExpanded && summary ? (
|
||||||
<span
|
<span
|
||||||
className="min-w-0 truncate typography-meta"
|
className="min-w-0 truncate typography-meta"
|
||||||
style={{ color: 'var(--tools-description)', opacity: 0.8 }}
|
style={{ color: 'var(--tools-description)', opacity: 0.8 }}
|
||||||
@@ -327,11 +317,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
id={contentId}
|
id={contentId}
|
||||||
aria-hidden={!effectiveIsExpanded}
|
aria-hidden={!isExpanded}
|
||||||
style={{
|
style={{
|
||||||
height: effectiveIsExpanded ? 'auto' : '0px',
|
height: isExpanded ? 'auto' : '0px',
|
||||||
opacity: effectiveIsExpanded ? 1 : 0,
|
opacity: isExpanded ? 1 : 0,
|
||||||
overflow: effectiveIsExpanded ? 'visible' : 'hidden',
|
overflow: isExpanded ? 'visible' : 'hidden',
|
||||||
overflowAnchor: 'none',
|
overflowAnchor: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -377,19 +367,22 @@ type ReasoningPartProps = {
|
|||||||
part: Part;
|
part: Part;
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
streamPhase?: StreamPhase;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ReasoningPart = React.memo(({
|
const ReasoningPart = React.memo(({
|
||||||
part,
|
part,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
messageId,
|
messageId,
|
||||||
|
streamPhase,
|
||||||
}: ReasoningPartProps) => {
|
}: ReasoningPartProps) => {
|
||||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
const rawText = partWithText.text || partWithText.content || '';
|
const rawText = partWithText.text || partWithText.content || '';
|
||||||
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
||||||
const time = partWithText.time;
|
const time = partWithText.time;
|
||||||
const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number';
|
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||||
|
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
|
||||||
const throttledText = useStreamingTextThrottle({
|
const throttledText = useStreamingTextThrottle({
|
||||||
text: textContent,
|
text: textContent,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@@ -418,6 +411,7 @@ type MergedReasoningPartProps = {
|
|||||||
parts: Part[];
|
parts: Part[];
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
streamPhase?: StreamPhase;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,6 +423,7 @@ export const MergedReasoningPart = React.memo(({
|
|||||||
parts,
|
parts,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
messageId,
|
messageId,
|
||||||
|
streamPhase,
|
||||||
}: MergedReasoningPartProps) => {
|
}: MergedReasoningPartProps) => {
|
||||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||||
|
|
||||||
@@ -463,7 +458,8 @@ export const MergedReasoningPart = React.memo(({
|
|||||||
return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined;
|
return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined;
|
||||||
}, [parts]);
|
}, [parts]);
|
||||||
|
|
||||||
const isStreaming = chatRenderMode === 'live' && parts.some(
|
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||||
|
const isStreaming = chatRenderMode === 'live' && canBeStreaming && parts.some(
|
||||||
(part) => typeof (part as PartWithText).time?.end !== 'number',
|
(part) => typeof (part as PartWithText).time?.end !== 'number',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user