From 33454567d07e1444fb9355e1143b824ec95e0706 Mon Sep 17 00:00:00 2001 From: Eva Ho Date: Wed, 5 Nov 2025 18:06:35 -0500 Subject: [PATCH] adding ai elements thinking component --- app/ui/app/package-lock.json | 221 +++++++++++++++++- app/ui/app/package.json | 3 + app/ui/app/src/components/Message.tsx | 78 ++++++- app/ui/app/src/components/Thinking.tsx | 161 ------------- .../src/components/ai-elements/reasoning.tsx | 137 +++++++++++ .../src/components/ai-elements/response.tsx | 18 ++ .../src/components/ai-elements/shimmer.tsx | 37 +++ 7 files changed, 483 insertions(+), 172 deletions(-) delete mode 100644 app/ui/app/src/components/Thinking.tsx create mode 100644 app/ui/app/src/components/ai-elements/reasoning.tsx create mode 100644 app/ui/app/src/components/ai-elements/response.tsx create mode 100644 app/ui/app/src/components/ai-elements/shimmer.tsx diff --git a/app/ui/app/package-lock.json b/app/ui/app/package-lock.json index 0dcd91a202..e9db75bf3b 100644 --- a/app/ui/app/package-lock.json +++ b/app/ui/app/package-lock.json @@ -10,12 +10,15 @@ "dependencies": { "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@tanstack/react-query": "^5.80.7", "@tanstack/react-router": "^1.120.20", "@tanstack/react-router-devtools": "^1.120.20", "clsx": "^2.1.1", "framer-motion": "^12.17.0", "katex": "^0.16.22", + "lucide-react": "^0.552.0", "micromark-extension-llm-math": "^3.1.0", "ollama": "^0.6.0", "react": "^19.1.0", @@ -2669,6 +2672,207 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@react-aria/focus": { "version": "3.20.5", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.5.tgz", @@ -4587,7 +4791,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -8464,9 +8668,9 @@ } }, "node_modules/lucide-react": { - "version": "0.542.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", - "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "version": "0.552.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.552.0.tgz", + "integrity": "sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -11884,6 +12088,15 @@ "react": "^18.0.0 || ^19.0.0" } }, + "node_modules/streamdown/node_modules/lucide-react": { + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/app/ui/app/package.json b/app/ui/app/package.json index 5532e70f05..93e9d13987 100644 --- a/app/ui/app/package.json +++ b/app/ui/app/package.json @@ -19,12 +19,15 @@ "dependencies": { "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@tanstack/react-query": "^5.80.7", "@tanstack/react-router": "^1.120.20", "@tanstack/react-router-devtools": "^1.120.20", "clsx": "^2.1.1", "framer-motion": "^12.17.0", "katex": "^0.16.22", + "lucide-react": "^0.552.0", "micromark-extension-llm-math": "^3.1.0", "ollama": "^0.6.0", "react": "^19.1.0", diff --git a/app/ui/app/src/components/Message.tsx b/app/ui/app/src/components/Message.tsx index 1996a0d4e5..c894b52ff7 100644 --- a/app/ui/app/src/components/Message.tsx +++ b/app/ui/app/src/components/Message.tsx @@ -1,10 +1,17 @@ import { Message as MessageType, ToolCall, File } from "@/gotypes"; -import Thinking from "./Thinking"; import StreamingMarkdownContent from "./StreamingMarkdownContent"; import { ImageThumbnail } from "./ImageThumbnail"; import { isImageFile } from "@/utils/imageUtils"; import CopyButton from "./CopyButton"; import React, { useState, useMemo, useRef } from "react"; +import { + Reasoning, + getThinkingMessage, +} from "@/components/ai-elements/reasoning"; +import { + CollapsibleContent, + CollapsibleTrigger, +} from "@radix-ui/react-collapsible"; const Message = React.memo( ({ @@ -891,18 +898,75 @@ function OtherRoleMessage({ }) { const messageRef = useRef(null); + const startTime = message.thinkingTimeStart; + const endTime = message.thinkingTimeEnd; + + const activelyThinking = startTime && !endTime; + const finishedThinking = startTime && endTime; + + // Calculate duration in seconds + const duration = finishedThinking + ? Math.ceil((endTime.getTime() - startTime.getTime()) / 1000) + : 0; + return (
- {/* Thinking area */} + {/* Reasoning area */} {message.thinking && ( - + + + + {/* Light bulb */} + + + + {/* Arrow */} + + + + +

+ {getThinkingMessage( + !!activelyThinking, + finishedThinking ? duration : undefined, + )} +

+
+ + + +
)} {/* Only render content div if there's actual content to show */} diff --git a/app/ui/app/src/components/Thinking.tsx b/app/ui/app/src/components/Thinking.tsx deleted file mode 100644 index a725ce9c22..0000000000 --- a/app/ui/app/src/components/Thinking.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useEffect, useState, useRef } from "react"; -import StreamingMarkdownContent from "./StreamingMarkdownContent"; - -export default function Thinking({ - thinking, - startTime, - endTime, -}: { - thinking: string; - startTime?: Date; - endTime?: Date; -}) { - const [isCollapsed, setIsCollapsed] = useState(true); - const [hasUserInteracted, setHasUserInteracted] = useState(false); - const [contentHeight, setContentHeight] = useState(0); - const [hasOverflow, setHasOverflow] = useState(false); - const contentRef = useRef(null); - const wrapperRef = useRef(null); - - const activelyThinking = startTime && !endTime; - const finishedThinking = startTime && endTime; - - // Auto-collapse when thinking is done (only if user hasn't manually interacted) - useEffect(() => { - if (endTime && !hasUserInteracted) { - setIsCollapsed(true); - } - }, [endTime, hasUserInteracted]); - - // Reset user interaction flag when a new thinking session starts - useEffect(() => { - if (activelyThinking) { - setHasUserInteracted(false); - } - }, [activelyThinking]); - - // Measure content height for animations - useEffect(() => { - if (contentRef.current) { - const resizeObserver = new ResizeObserver(() => { - if (contentRef.current) { - setContentHeight(contentRef.current.scrollHeight); - } - }); - resizeObserver.observe(contentRef.current); - return () => resizeObserver.disconnect(); - } - }, [thinking]); - - // Position content to show bottom when collapsed - useEffect(() => { - if (isCollapsed && contentRef.current && wrapperRef.current) { - const contentHeight = contentRef.current.scrollHeight; - const wrapperHeight = wrapperRef.current.clientHeight; - if (contentHeight > wrapperHeight) { - const translateY = -(contentHeight - wrapperHeight); - contentRef.current.style.transform = `translateY(${translateY}px)`; - setHasOverflow(true); - } else { - setHasOverflow(false); - } - } else if (contentRef.current) { - contentRef.current.style.transform = "translateY(0)"; - setHasOverflow(false); - } - }, [thinking, isCollapsed]); - - const handleToggle = () => { - setIsCollapsed(!isCollapsed); - setHasUserInteracted(true); - }; - - // Calculate max height for smooth animations - const getMaxHeight = () => { - if (isCollapsed) { - return finishedThinking ? "0px" : "12rem"; - } - // When expanded, use the content height or grow naturally - return contentHeight ? `${contentHeight}px` : "none"; - }; - - return ( -
-
- {/* Light bulb */} - - - - {/* Arrow */} - - - - -

- {activelyThinking - ? "Thinking..." - : finishedThinking - ? (() => { - const thinkingTime = - (endTime.getTime() - startTime.getTime()) / 1000; - return thinkingTime < 2 - ? "Thought for a moment" - : `Thought for ${thinkingTime.toFixed(1)} seconds`; - })() - : "Thinking..."} -

-
-
-
- -
- - {/* Gradient overlay for fade effect when collapsed and scrolled */} - {isCollapsed && hasOverflow && ( -
- )} -
-
- ); -} diff --git a/app/ui/app/src/components/ai-elements/reasoning.tsx b/app/ui/app/src/components/ai-elements/reasoning.tsx new file mode 100644 index 0000000000..fca2239957 --- /dev/null +++ b/app/ui/app/src/components/ai-elements/reasoning.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Collapsible, CollapsibleTrigger } from "@radix-ui/react-collapsible"; +import { ChevronDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { createContext, memo, useContext, useEffect, useState } from "react"; +import { Shimmer } from "./shimmer"; + +type ReasoningContextValue = { + isStreaming: boolean; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + duration: number; +}; + +const ReasoningContext = createContext(null); + +const useReasoning = () => { + const context = useContext(ReasoningContext); + if (!context) { + throw new Error("Reasoning components must be used within Reasoning"); + } + return context; +}; + +export type ReasoningProps = ComponentProps & { + isStreaming?: boolean; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + duration?: number; +}; + +const MS_IN_S = 1000; + +export const Reasoning = memo( + ({ + className, + isStreaming = false, + open, + defaultOpen = false, + onOpenChange, + duration: durationProp, + children, + ...props + }: ReasoningProps) => { + const [isOpen, setIsOpen] = useControllableState({ + prop: open, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + const [duration, setDuration] = useControllableState({ + prop: durationProp, + defaultProp: 0, + }); + + const [startTime, setStartTime] = useState(null); + + // Track duration when streaming starts and ends + useEffect(() => { + if (isStreaming) { + if (startTime === null) { + setStartTime(Date.now()); + } + } else if (startTime !== null) { + setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S)); + setStartTime(null); + } + }, [isStreaming, startTime, setDuration]); + + const handleOpenChange = (newOpen: boolean) => { + setIsOpen(newOpen); + }; + + return ( + + + {children} + + + ); + }, +); + +export type ReasoningTriggerProps = ComponentProps; + +export const getThinkingMessage = (isStreaming: boolean, duration?: number) => { + if (isStreaming || duration === 0) { + return Thinking...; + } + if (duration === undefined) { + return Thought for a few seconds; + } + if (duration < 2) { + return Thought for a moment; + } + return Thought for {duration} seconds; +}; + +export const ReasoningTrigger = memo( + ({ className, children, ...props }: ReasoningTriggerProps) => { + const { isStreaming, isOpen, duration } = useReasoning(); + + return ( + + {children ?? ( + <> + {/* Light bulb icon */} + + + + {getThinkingMessage(isStreaming, duration)} + + + )} + + ); + }, +); + +Reasoning.displayName = "Reasoning"; +ReasoningTrigger.displayName = "ReasoningTrigger"; diff --git a/app/ui/app/src/components/ai-elements/response.tsx b/app/ui/app/src/components/ai-elements/response.tsx new file mode 100644 index 0000000000..b0b72f9495 --- /dev/null +++ b/app/ui/app/src/components/ai-elements/response.tsx @@ -0,0 +1,18 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { memo } from "react"; + +export type ResponseProps = ComponentProps<"div"> & { + children: React.ReactNode; +}; + +export const Response = memo( + ({ className, children, ...props }: ResponseProps) => ( +
+ {children} +
+ ), +); + +Response.displayName = "Response"; diff --git a/app/ui/app/src/components/ai-elements/shimmer.tsx b/app/ui/app/src/components/ai-elements/shimmer.tsx new file mode 100644 index 0000000000..42ec06d275 --- /dev/null +++ b/app/ui/app/src/components/ai-elements/shimmer.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { memo, useEffect, useState } from "react"; + +export type ShimmerProps = ComponentProps<"span"> & { + duration?: number; +}; + +export const Shimmer = memo( + ({ className, duration, children, ...props }: ShimmerProps) => { + const [isShimmering, setIsShimmering] = useState(true); + + useEffect(() => { + if (!duration) return; + + const timer = setTimeout(() => { + setIsShimmering(false); + }, duration * 1000); + + return () => clearTimeout(timer); + }, [duration]); + + if (!isShimmering && duration) return {children}; + + return ( + + {children} + + ); + }, +); + +Shimmer.displayName = "Shimmer";