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({