feat(ui): improve streaming reasoning block lifecycle control

- Add autoLifecycle prop to Reasoning component for fine-grained control
- Extract StreamingReasoningBlock with manual/auto collapse state management
- Fix active thinking index logic for streaming messages
This commit is contained in:
ZhenYi 2026-05-15 12:39:10 +08:00
parent b35d2d4fe7
commit f597da6e33
2 changed files with 68 additions and 9 deletions

View File

@ -342,7 +342,10 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
} }
}, [displayParts.length]); }, [displayParts.length]);
const firstThinkingIdx = displayParts.findIndex((p) => p.type === "thinking"); const activeThinkingIdx =
!displayDone && displayParts[displayParts.length - 1]?.type === "thinking"
? displayParts.length - 1
: -1;
return ( return (
<div ref={contentRef} className="flex gap-4 px-4 py-3 max-w-3xl mx-auto w-full"> <div ref={contentRef} className="flex gap-4 px-4 py-3 max-w-3xl mx-auto w-full">
@ -364,12 +367,12 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
<div className="text-sm" style={{ color: "var(--text-primary)" }}> <div className="text-sm" style={{ color: "var(--text-primary)" }}>
{displayParts.map((part, i) => { {displayParts.map((part, i) => {
if (part.type === "thinking") { if (part.type === "thinking") {
const isActivelyThinking = !displayDone && i === firstThinkingIdx;
return ( return (
<Reasoning key={i} isStreaming={isActivelyThinking} defaultOpen={isActivelyThinking}> <StreamingReasoningBlock
<ReasoningTrigger /> key={i}
<ReasoningContent>{part.content}</ReasoningContent> content={part.content}
</Reasoning> isActivelyThinking={i === activeThinkingIdx}
/>
); );
} }
if (part.type === "tool_call") { if (part.type === "tool_call") {
@ -407,6 +410,59 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
); );
} }
function StreamingReasoningBlock({
content,
isActivelyThinking,
}: {
content: string;
isActivelyThinking: boolean;
}) {
const [manualOpen, setManualOpen] = useState(false);
const [autoCollapsed, setAutoCollapsed] = useState(false);
const wasActivelyThinkingRef = useRef(isActivelyThinking);
useEffect(() => {
const wasActivelyThinking = wasActivelyThinkingRef.current;
if (isActivelyThinking && !wasActivelyThinking) {
setAutoCollapsed(false);
setManualOpen(false);
}
if (!isActivelyThinking && wasActivelyThinking) {
setAutoCollapsed(false);
setManualOpen(false);
}
wasActivelyThinkingRef.current = isActivelyThinking;
}, [isActivelyThinking]);
const isOpen = isActivelyThinking ? !autoCollapsed : manualOpen;
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (isActivelyThinking) {
setAutoCollapsed(!nextOpen);
return;
}
setManualOpen(nextOpen);
},
[isActivelyThinking]
);
return (
<Reasoning
autoLifecycle={false}
isStreaming={isActivelyThinking}
onOpenChange={handleOpenChange}
open={isOpen}
>
<ReasoningTrigger />
<ReasoningContent>{content}</ReasoningContent>
</Reasoning>
);
}
function StreamingCursor() { function StreamingCursor() {
return ( return (
<span <span

View File

@ -51,6 +51,7 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
defaultOpen?: boolean; defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
duration?: number; duration?: number;
autoLifecycle?: boolean;
}; };
const AUTO_CLOSE_DELAY = 1000; const AUTO_CLOSE_DELAY = 1000;
@ -64,6 +65,7 @@ export const Reasoning = memo(
defaultOpen, defaultOpen,
onOpenChange, onOpenChange,
duration: durationProp, duration: durationProp,
autoLifecycle = true,
children, children,
...props ...props
}: ReasoningProps) => { }: ReasoningProps) => {
@ -100,15 +102,16 @@ export const Reasoning = memo(
// Auto-open when streaming starts (unless explicitly closed) // Auto-open when streaming starts (unless explicitly closed)
useEffect(() => { useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) { if (autoLifecycle && isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true); setIsOpen(true);
} }
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]); }, [autoLifecycle, isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed) // Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => { useEffect(() => {
if ( if (
hasEverStreamedRef.current && hasEverStreamedRef.current &&
autoLifecycle &&
!isStreaming && !isStreaming &&
isOpen && isOpen &&
!hasAutoClosed !hasAutoClosed
@ -120,7 +123,7 @@ export const Reasoning = memo(
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]); }, [autoLifecycle, isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(newOpen: boolean) => { (newOpen: boolean) => {