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:
parent
b35d2d4fe7
commit
f597da6e33
@ -342,7 +342,10 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
}
|
||||
}, [displayParts.length]);
|
||||
|
||||
const firstThinkingIdx = displayParts.findIndex((p) => p.type === "thinking");
|
||||
const activeThinkingIdx =
|
||||
!displayDone && displayParts[displayParts.length - 1]?.type === "thinking"
|
||||
? displayParts.length - 1
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<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)" }}>
|
||||
{displayParts.map((part, i) => {
|
||||
if (part.type === "thinking") {
|
||||
const isActivelyThinking = !displayDone && i === firstThinkingIdx;
|
||||
return (
|
||||
<Reasoning key={i} isStreaming={isActivelyThinking} defaultOpen={isActivelyThinking}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.content}</ReasoningContent>
|
||||
</Reasoning>
|
||||
<StreamingReasoningBlock
|
||||
key={i}
|
||||
content={part.content}
|
||||
isActivelyThinking={i === activeThinkingIdx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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() {
|
||||
return (
|
||||
<span
|
||||
|
||||
@ -51,6 +51,7 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
autoLifecycle?: boolean;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
@ -64,6 +65,7 @@ export const Reasoning = memo(
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
autoLifecycle = true,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
@ -100,15 +102,16 @@ export const Reasoning = memo(
|
||||
|
||||
// Auto-open when streaming starts (unless explicitly closed)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
if (autoLifecycle && isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||
}, [autoLifecycle, isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||
|
||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasEverStreamedRef.current &&
|
||||
autoLifecycle &&
|
||||
!isStreaming &&
|
||||
isOpen &&
|
||||
!hasAutoClosed
|
||||
@ -120,7 +123,7 @@ export const Reasoning = memo(
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||
}, [autoLifecycle, isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user