From 82c9494219b95a5fe619fd15d469d7d5e2c4ad56 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 ++++++- .../src/components/ai-elements/reasoning.tsx | 137 +++++++++++ .../src/components/ai-elements/response.tsx | 18 ++ .../src/components/ai-elements/shimmer.tsx | 37 +++ 6 files changed, 483 insertions(+), 11 deletions(-) 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 0dcd91a20..e9db75bf3 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 5532e70f0..93e9d1398 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 1996a0d4e..c894b52ff 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/ai-elements/reasoning.tsx b/app/ui/app/src/components/ai-elements/reasoning.tsx new file mode 100644 index 000000000..fca223995 --- /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 000000000..b0b72f949 --- /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 000000000..42ec06d27 --- /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";