From 465b95a7a13c127404a3b3adcf7637e2d855252e Mon Sep 17 00:00:00 2001 From: shine1i Date: Sun, 1 Feb 2026 08:31:22 +0100 Subject: [PATCH] refactor: improve reasoning UI with animations and dynamic behavior, minor CSS and layout tweaks --- .../src/components/assistant-ui/reasoning.tsx | 133 ++++++++++++++---- .../src/components/ui/animated-shiny-text.tsx | 38 +++++ .../src/features/chat/chat-settings-sheet.tsx | 6 +- frontend/src/index.css | 13 +- 4 files changed, 157 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/ui/animated-shiny-text.tsx diff --git a/frontend/src/components/assistant-ui/reasoning.tsx b/frontend/src/components/assistant-ui/reasoning.tsx index 7274e311bc..3dd2585d2e 100644 --- a/frontend/src/components/assistant-ui/reasoning.tsx +++ b/frontend/src/components/assistant-ui/reasoning.tsx @@ -2,6 +2,7 @@ /* eslint-disable react-refresh/only-export-components */ +import { AnimatedShinyText } from "@/components/ui/animated-shiny-text"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { Collapsible, @@ -24,6 +25,7 @@ import { type ComponentProps, memo, useCallback, + useEffect, useRef, useState, } from "react"; @@ -110,15 +112,21 @@ function ReasoningFade({ className, ...props }: ComponentProps<"div">) { data-slot="reasoning-fade" className={cn( "aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8", - "bg-[linear-gradient(to_top,var(--color-background),transparent)]", - "group-data-[variant=muted]/reasoning-root:bg-[linear-gradient(to_top,hsl(var(--muted)/0.5),transparent)]", - "fade-in-0 animate-in", - "group-data-[state=open]/collapsible-content:animate-out", - "group-data-[state=open]/collapsible-content:fade-out-0", - "group-data-[state=open]/collapsible-content:delay-[calc(var(--animation-duration)*0.75)]", - "group-data-[state=open]/collapsible-content:fill-mode-forwards", - "duration-(--animation-duration)", - "group-data-[state=open]/collapsible-content:duration-(--animation-duration)", + "bg-gradient-to-t from-background to-transparent", + className, + )} + {...props} + /> + ); +} + +function ReasoningFadeTop({ className, ...props }: ComponentProps<"div">) { + return ( +
- Reasoning{durationText} {active ? ( - - Reasoning{durationText} - - ) : null} + + Thinking... + + ) : ( + Thought for {duration ?? 0}s + )} ) { +}: ComponentProps & { streaming?: boolean }) { return ( + {streaming && } {children} ); } -function ReasoningText({ className, ...props }: ComponentProps<"div">) { +function ReasoningText({ + className, + streaming, + children, + ...props +}: ComponentProps<"div"> & { streaming?: boolean }) { + const scrollRef = useRef(null); + + useEffect(() => { + if (!streaming || !scrollRef.current) return; + const el = scrollRef.current; + const observer = new MutationObserver(() => { + el.scrollTop = el.scrollHeight; + }); + observer.observe(el, { childList: true, subtree: true, characterData: true }); + el.scrollTop = el.scrollHeight; + return () => observer.disconnect(); + }, [streaming]); + return (
) { className, )} {...props} - /> + > + {children} +
); } @@ -249,11 +276,58 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ return lastIndex >= startIndex && lastIndex <= endIndex; }); + const [manualOpen, setManualOpen] = useState(false); + const [duration, setDuration] = useState(0); + const startTimeRef = useRef(null); + + useEffect(() => { + if (isReasoningStreaming) { + if (startTimeRef.current === null) { + startTimeRef.current = Date.now(); + } + } else if (startTimeRef.current !== null) { + const elapsed = Math.round( + (Date.now() - startTimeRef.current) / 1000, + ); + setDuration(elapsed); + startTimeRef.current = null; + } + }, [isReasoningStreaming]); + + const isOpen = isReasoningStreaming || manualOpen; + + const variant = isReasoningStreaming + ? "outline" + : manualOpen + ? "outline" + : "ghost"; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!isReasoningStreaming) { + setManualOpen(open); + } + }, + [isReasoningStreaming], + ); + return ( - - - - {children} + + + + + {children} + ); @@ -267,6 +341,7 @@ const Reasoning = memo( Content: typeof ReasoningContent; Text: typeof ReasoningText; Fade: typeof ReasoningFade; + FadeTop: typeof ReasoningFadeTop; }; Reasoning.displayName = "Reasoning"; @@ -275,6 +350,7 @@ Reasoning.Trigger = ReasoningTrigger; Reasoning.Content = ReasoningContent; Reasoning.Text = ReasoningText; Reasoning.Fade = ReasoningFade; +Reasoning.FadeTop = ReasoningFadeTop; const ReasoningGroup = memo(ReasoningGroupImpl); ReasoningGroup.displayName = "ReasoningGroup"; @@ -287,4 +363,5 @@ export { ReasoningContent, ReasoningText, ReasoningFade, + ReasoningFadeTop, }; diff --git a/frontend/src/components/ui/animated-shiny-text.tsx b/frontend/src/components/ui/animated-shiny-text.tsx new file mode 100644 index 0000000000..03bd6e178b --- /dev/null +++ b/frontend/src/components/ui/animated-shiny-text.tsx @@ -0,0 +1,38 @@ +import type { ComponentPropsWithoutRef, CSSProperties, FC } from "react" + +import { cn } from "@/lib/utils" + +export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> { + shimmerWidth?: number +} + +export const AnimatedShinyText: FC = ({ + children, + className, + shimmerWidth = 100, + ...props +}) => { + return ( + + {children} + + ) +} diff --git a/frontend/src/features/chat/chat-settings-sheet.tsx b/frontend/src/features/chat/chat-settings-sheet.tsx index 0b006c4e78..2d1b2142b9 100644 --- a/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/frontend/src/features/chat/chat-settings-sheet.tsx @@ -135,7 +135,7 @@ function CollapsibleSection({