adding ai elements thinking component

This commit is contained in:
Eva Ho
2025-11-05 18:06:35 -05:00
parent a534d4e9e1
commit 33454567d0
7 changed files with 483 additions and 172 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<HTMLDivElement>(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 (
<div
className={`flex mb-8 flex-col transition-opacity duration-300 space-y-4 ${isFaded ? "opacity-50" : "opacity-100"}`}
>
<div className="flex-1 flex flex-col justify-start relative group max-w-none text-wrap break-words">
{/* Thinking area */}
{/* Reasoning area */}
{message.thinking && (
<Thinking
thinking={message.thinking}
startTime={message.thinkingTimeStart}
endTime={message.thinkingTimeEnd}
/>
<Reasoning
isStreaming={!!activelyThinking}
duration={duration}
defaultOpen={false}
className={`flex mb-4 flex-col w-full ${
activelyThinking
? "text-neutral-800 dark:text-neutral-200"
: "text-neutral-600 dark:text-neutral-400"
} hover:text-neutral-800 dark:hover:text-neutral-200 transition-colors`}
>
<CollapsibleTrigger className="flex items-center cursor-pointer group/thinking self-start relative select-text outline-none">
<span className="relative w-4 h-4 flex-shrink-0">
{/* Light bulb */}
<svg
className="w-3 absolute left-0 top-1/2 -translate-y-1/2 transition-opacity opacity-100 group-hover/thinking:opacity-0 group-data-[state=open]:opacity-0 fill-current will-change-opacity"
viewBox="0 0 14 24"
fill="none"
>
<path d="M0 6.01562C0 9.76562 2.24609 10.6934 2.87109 17.207C2.91016 17.5586 3.10547 17.7832 3.47656 17.7832H9.58984C9.9707 17.7832 10.166 17.5586 10.2051 17.207C10.8301 10.6934 13.0664 9.76562 13.0664 6.01562C13.0664 2.64648 10.1855 0 6.5332 0C2.88086 0 0 2.64648 0 6.01562ZM1.47461 6.01562C1.47461 3.37891 3.78906 1.47461 6.5332 1.47461C9.27734 1.47461 11.5918 3.37891 11.5918 6.01562C11.5918 8.81836 9.73633 9.48242 8.85742 16.3086H4.21875C3.33008 9.48242 1.47461 8.81836 1.47461 6.01562ZM3.44727 19.8926H9.62891C9.95117 19.8926 10.1953 19.6387 10.1953 19.3164C10.1953 19.0039 9.95117 18.75 9.62891 18.75H3.44727C3.125 18.75 2.87109 19.0039 2.87109 19.3164C2.87109 19.6387 3.125 19.8926 3.44727 19.8926ZM6.5332 22.7246C8.04688 22.7246 9.30664 21.9824 9.4043 20.8594H3.67188C3.74023 21.9824 5.00977 22.7246 6.5332 22.7246Z" />
</svg>
{/* Arrow */}
<svg
className="h-4 w-4 absolute left-0 top-1/2 -translate-y-1/2 transition-all opacity-0 -rotate-90 group-hover/thinking:opacity-100 group-hover/thinking:rotate-0 group-data-[state=open]:opacity-100 group-data-[state=open]:rotate-0 will-change-[opacity,transform]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</span>
<h3 className="ml-2 select-text text-base">
{getThinkingMessage(
!!activelyThinking,
finishedThinking ? duration : undefined,
)}
</h3>
</CollapsibleTrigger>
<CollapsibleContent
forceMount
className="text-xs text-neutral-500 dark:text-neutral-500 rounded-md transition-[max-height,opacity] duration-500 ease-in-out relative ml-6 mt-3 outline-none data-[state=closed]:overflow-hidden data-[state=open]:overflow-y-auto data-[state=open]:max-h-28 data-[state=closed]:max-h-0 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
>
<StreamingMarkdownContent
content={message.thinking}
isStreaming={!!activelyThinking}
size="sm"
/>
</CollapsibleContent>
</Reasoning>
)}
{/* Only render content div if there's actual content to show */}

View File

@@ -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<number>(0);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(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 (
<div
className={`flex mb-4 flex-col w-full ${activelyThinking || !isCollapsed ? "text-neutral-800 dark:text-neutral-200" : "text-neutral-600 dark:text-neutral-400"}
hover:text-neutral-800
dark:hover:text-neutral-200 transition-colors`}
>
<div
className="flex items-center cursor-pointer group/thinking self-start relative select-text"
onClick={handleToggle}
>
{/* Light bulb */}
<svg
className={`w-3 absolute left-0 top-1/2 -translate-y-1/2 transition-opacity ${
isCollapsed ? "opacity-100" : "opacity-0"
} group-hover/thinking:opacity-0 fill-current will-change-opacity`}
viewBox="0 0 14 24"
fill="none"
>
<path d="M0 6.01562C0 9.76562 2.24609 10.6934 2.87109 17.207C2.91016 17.5586 3.10547 17.7832 3.47656 17.7832H9.58984C9.9707 17.7832 10.166 17.5586 10.2051 17.207C10.8301 10.6934 13.0664 9.76562 13.0664 6.01562C13.0664 2.64648 10.1855 0 6.5332 0C2.88086 0 0 2.64648 0 6.01562ZM1.47461 6.01562C1.47461 3.37891 3.78906 1.47461 6.5332 1.47461C9.27734 1.47461 11.5918 3.37891 11.5918 6.01562C11.5918 8.81836 9.73633 9.48242 8.85742 16.3086H4.21875C3.33008 9.48242 1.47461 8.81836 1.47461 6.01562ZM3.44727 19.8926H9.62891C9.95117 19.8926 10.1953 19.6387 10.1953 19.3164C10.1953 19.0039 9.95117 18.75 9.62891 18.75H3.44727C3.125 18.75 2.87109 19.0039 2.87109 19.3164C2.87109 19.6387 3.125 19.8926 3.44727 19.8926ZM6.5332 22.7246C8.04688 22.7246 9.30664 21.9824 9.4043 20.8594H3.67188C3.74023 21.9824 5.00977 22.7246 6.5332 22.7246Z" />
</svg>
{/* Arrow */}
<svg
className={`h-4 w-4 absolute transition-all ${
isCollapsed
? "-rotate-90 opacity-0 group-hover/thinking:opacity-100"
: "rotate-0 opacity-100"
} will-change-[opacity,transform]`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 className="ml-6 select-text">
{activelyThinking
? "Thinking..."
: finishedThinking
? (() => {
const thinkingTime =
(endTime.getTime() - startTime.getTime()) / 1000;
return thinkingTime < 2
? "Thought for a moment"
: `Thought for ${thinkingTime.toFixed(1)} seconds`;
})()
: "Thinking..."}
</h3>
</div>
<div
ref={wrapperRef}
className={`text-xs text-neutral-500 dark:text-neutral-500 rounded-md
transition-[max-height,opacity] duration-300 ease-in-out relative ml-6 mt-2
${isCollapsed ? "overflow-hidden" : "overflow-y-auto max-h-28"}`}
style={{
maxHeight: isCollapsed ? getMaxHeight() : undefined,
opacity: isCollapsed && finishedThinking ? 0 : 1,
}}
>
<div
ref={contentRef}
className="transition-transform duration-300 opacity-75 select-text"
>
<StreamingMarkdownContent
content={thinking}
isStreaming={activelyThinking}
size="sm"
/>
</div>
{/* Gradient overlay for fade effect when collapsed and scrolled */}
{isCollapsed && hasOverflow && (
<div className="absolute inset-x-0 -top-1 h-8 pointer-events-none bg-gradient-to-b from-white dark:from-neutral-900 to-transparent" />
)}
</div>
</div>
);
}

View File

@@ -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<ReasoningContextValue | null>(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<typeof Collapsible> & {
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<number | null>(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 (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={`not-prose mb-4 ${className || ""}`}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
},
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <span>Thought for a few seconds</span>;
}
if (duration < 2) {
return <span>Thought for a moment</span>;
}
return <span>Thought for {duration} seconds</span>;
};
export const ReasoningTrigger = memo(
({ className, children, ...props }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={`flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground cursor-pointer ${className || ""}`}
{...props}
>
{children ?? (
<>
{/* Light bulb icon */}
<svg className="w-3 fill-current" viewBox="0 0 14 24" fill="none">
<path d="M0 6.01562C0 9.76562 2.24609 10.6934 2.87109 17.207C2.91016 17.5586 3.10547 17.7832 3.47656 17.7832H9.58984C9.9707 17.7832 10.166 17.5586 10.2051 17.207C10.8301 10.6934 13.0664 9.76562 13.0664 6.01562C13.0664 2.64648 10.1855 0 6.5332 0C2.88086 0 0 2.64648 0 6.01562ZM1.47461 6.01562C1.47461 3.37891 3.78906 1.47461 6.5332 1.47461C9.27734 1.47461 11.5918 3.37891 11.5918 6.01562C11.5918 8.81836 9.73633 9.48242 8.85742 16.3086H4.21875C3.33008 9.48242 1.47461 8.81836 1.47461 6.01562ZM3.44727 19.8926H9.62891C9.95117 19.8926 10.1953 19.6387 10.1953 19.3164C10.1953 19.0039 9.95117 18.75 9.62891 18.75H3.44727C3.125 18.75 2.87109 19.0039 2.87109 19.3164C2.87109 19.6387 3.125 19.8926 3.44727 19.8926ZM6.5332 22.7246C8.04688 22.7246 9.30664 21.9824 9.4043 20.8594H3.67188C3.74023 21.9824 5.00977 22.7246 6.5332 22.7246Z" />
</svg>
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={`size-4 transition-transform duration-300 ${
isOpen ? "rotate-180" : "rotate-0"
}`}
/>
</>
)}
</CollapsibleTrigger>
);
},
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";

View File

@@ -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) => (
<div className={className} {...props}>
{children}
</div>
),
);
Response.displayName = "Response";

View File

@@ -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 <span>{children}</span>;
return (
<span
className={`inline-block animate-pulse ${className || ""}`}
{...props}
>
{children}
</span>
);
},
);
Shimmer.displayName = "Shimmer";