feat(frontend): implement (thought for x second) thinking duration indicator (#3627)

This commit is contained in:
Chetan Sharma 2026-06-19 21:14:10 +05:30 committed by GitHub
parent e97d93503d
commit df5d90fbb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 123 additions and 19 deletions

View File

@ -19,6 +19,7 @@ type ReasoningContextValue = {
isOpen: boolean; isOpen: boolean;
setIsOpen: (open: boolean) => void; setIsOpen: (open: boolean) => void;
duration: number | undefined; duration: number | undefined;
startTime: number | null;
}; };
const ReasoningContext = createContext<ReasoningContextValue | null>(null); const ReasoningContext = createContext<ReasoningContextValue | null>(null);
@ -37,6 +38,7 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
defaultOpen?: boolean; defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
duration?: number; duration?: number;
startTimeProp?: number | null;
}; };
const AUTO_CLOSE_DELAY = 1000; const AUTO_CLOSE_DELAY = 1000;
@ -50,6 +52,7 @@ export const Reasoning = memo(
defaultOpen = true, defaultOpen = true,
onOpenChange, onOpenChange,
duration: durationProp, duration: durationProp,
startTimeProp,
children, children,
...props ...props
}: ReasoningProps) => { }: ReasoningProps) => {
@ -64,19 +67,24 @@ export const Reasoning = memo(
}); });
const [hasAutoClosed, setHasAutoClosed] = useState(false); const [hasAutoClosed, setHasAutoClosed] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null); const [startTime, setStartTime] = useState<number | null>(
() => startTimeProp ?? (isStreaming ? Date.now() : null),
);
// Track duration when streaming starts and ends // Track duration when streaming starts and ends
useEffect(() => { useEffect(() => {
if (isStreaming) { if (isStreaming) {
if (startTime === null) { // Force sync the start time with the Turn start time if provided
if (startTimeProp != null && startTime !== startTimeProp) {
setStartTime(startTimeProp);
} else if (startTimeProp == null && startTime === null) {
setStartTime(Date.now()); setStartTime(Date.now());
} }
} else if (startTime !== null) { } else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S)); setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
setStartTime(null); setStartTime(null);
} }
}, [isStreaming, startTime, setDuration]); }, [isStreaming, startTimeProp, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only) // Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => { useEffect(() => {
@ -97,7 +105,7 @@ export const Reasoning = memo(
return ( return (
<ReasoningContext.Provider <ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }} value={{ isStreaming, isOpen, setIsOpen, duration, startTime }}
> >
<Collapsible <Collapsible
className={cn("not-prose mb-4", className)} className={cn("not-prose mb-4", className)}
@ -115,10 +123,44 @@ export const Reasoning = memo(
export type ReasoningTriggerProps = ComponentProps< export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger typeof CollapsibleTrigger
> & { > & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode; getThinkingMessage?: (
isStreaming: boolean,
duration?: number,
startTime?: number | null,
) => ReactNode;
hasContent?: boolean;
}; };
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => { const LiveTimer = ({ startTime }: { startTime: number }) => {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const calculateElapsed = () => Math.ceil((Date.now() - startTime) / 1000);
setElapsed(calculateElapsed());
const interval = setInterval(() => {
setElapsed(calculateElapsed());
}, 1000);
return () => clearInterval(interval);
}, [startTime]);
return (
<span className="flex items-center gap-2">
<Shimmer duration={1}>Thinking...</Shimmer>
<span className="text-muted-foreground/80">({elapsed}s)</span>
</span>
);
};
const defaultGetThinkingMessage = (
isStreaming: boolean,
duration?: number,
startTime?: number | null,
) => {
if (isStreaming && startTime != null && startTime !== undefined) {
return <LiveTimer startTime={startTime} />;
}
if (isStreaming || duration === 0) { if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>; return <Shimmer duration={1}>Thinking...</Shimmer>;
} }
@ -133,14 +175,16 @@ export const ReasoningTrigger = memo(
className, className,
children, children,
getThinkingMessage = defaultGetThinkingMessage, getThinkingMessage = defaultGetThinkingMessage,
hasContent = true,
...props ...props
}: ReasoningTriggerProps) => { }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning(); const { isStreaming, isOpen, duration, startTime } = useReasoning();
return ( return (
<CollapsibleTrigger <CollapsibleTrigger
className={cn( className={cn(
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors", "text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
!hasContent && "cursor-default",
className, className,
)} )}
{...props} {...props}
@ -148,13 +192,15 @@ export const ReasoningTrigger = memo(
{children ?? ( {children ?? (
<> <>
<BrainIcon className="size-4" /> <BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)} {getThinkingMessage(isStreaming, duration, startTime)}
<ChevronDownIcon {hasContent && (
className={cn( <ChevronDownIcon
"size-4 transition-transform", className={cn(
isOpen ? "rotate-180" : "rotate-0", "size-4 transition-transform",
)} isOpen ? "rotate-180" : "rotate-0",
/> )}
/>
)}
</> </>
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>

View File

@ -10,6 +10,7 @@ import {
useCallback, useCallback,
useMemo, useMemo,
useState, useState,
useEffect,
type AnchorHTMLAttributes, type AnchorHTMLAttributes,
type ImgHTMLAttributes, type ImgHTMLAttributes,
} from "react"; } from "react";
@ -124,6 +125,7 @@ export function MessageListItem({
runId, runId,
threadId, threadId,
showCopyButton = true, showCopyButton = true,
turnStartTime,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
@ -132,6 +134,7 @@ export function MessageListItem({
feedback?: FeedbackData | null; feedback?: FeedbackData | null;
runId?: string; runId?: string;
showCopyButton?: boolean; showCopyButton?: boolean;
turnStartTime?: number | null;
}) { }) {
const isHuman = message.type === "human"; const isHuman = message.type === "human";
return ( return (
@ -144,6 +147,7 @@ export function MessageListItem({
message={message} message={message}
isLoading={isLoading} isLoading={isLoading}
threadId={threadId} threadId={threadId}
turnStartTime={turnStartTime}
/> />
{!isLoading && showCopyButton && ( {!isLoading && showCopyButton && (
<MessageToolbar <MessageToolbar
@ -211,14 +215,20 @@ function MessageContent_({
message, message,
isLoading = false, isLoading = false,
threadId, threadId,
turnStartTime,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
isLoading?: boolean; isLoading?: boolean;
threadId: string; threadId: string;
turnStartTime?: number | null;
}) { }) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const [wasLoading, setWasLoading] = useState(isLoading);
useEffect(() => {
if (isLoading) setWasLoading(true);
}, [isLoading]);
const components = useMemo( const components = useMemo(
() => ({ () => ({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => ( img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
@ -324,6 +334,14 @@ function MessageContent_({
return ( return (
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
{filesList} {filesList}
{!isHuman && (!!reasoningContent || wasLoading) && (
<Reasoning isStreaming={isLoading} startTimeProp={turnStartTime}>
<ReasoningTrigger hasContent={!!reasoningContent} />
{reasoningContent && (
<ReasoningContent>{reasoningContent}</ReasoningContent>
)}
</Reasoning>
)}
<MarkdownContent <MarkdownContent
content={contentToDisplay} content={contentToDisplay}
isLoading={isLoading} isLoading={isLoading}

View File

@ -1,12 +1,16 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { ChevronUpIcon, Loader2Icon } from "lucide-react"; import { ChevronUpIcon, Loader2Icon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import {
Reasoning,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@ -179,10 +183,32 @@ export function MessageList({
isHistoryLoading?: boolean; isHistoryLoading?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const [turnStartTime, setTurnStartTime] = useState<number | null>(null);
const updateSubtask = useUpdateSubtask(); const prevIsLoading = useRef(thread.isLoading);
useEffect(() => {
if (thread.isLoading && !prevIsLoading.current) {
setTurnStartTime(Date.now());
}
prevIsLoading.current = thread.isLoading;
}, [thread.isLoading]);
const messages = thread.messages; const messages = thread.messages;
const groupedMessages = getMessageGroups(messages); const groupedMessages = getMessageGroups(messages);
const hasActiveAssistantText = useMemo(() => {
let lastHumanIndex = -1;
for (let i = groupedMessages.length - 1; i >= 0; i--) {
if (groupedMessages[i]?.type === "human") {
lastHumanIndex = i;
break;
}
}
if (lastHumanIndex === -1) return false;
return groupedMessages
.slice(lastHumanIndex)
.some((g) => g.type === "assistant");
}, [groupedMessages]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const lastGroupIndex = groupedMessages.length - 1; const lastGroupIndex = groupedMessages.length - 1;
const turnUsageMessagesByGroupIndex = const turnUsageMessagesByGroupIndex =
getAssistantTurnUsageMessages(groupedMessages); getAssistantTurnUsageMessages(groupedMessages);
@ -296,9 +322,17 @@ export function MessageList({
<MessageListItem <MessageListItem
key={`${group.id}/${msg.id}`} key={`${group.id}/${msg.id}`}
message={msg} message={msg}
isLoading={thread.isLoading} isLoading={
thread.isLoading &&
groupIndex === groupedMessages.length - 1
}
threadId={threadId} threadId={threadId}
showCopyButton={group.type !== "assistant"} showCopyButton={group.type !== "assistant"}
turnStartTime={
groupIndex === groupedMessages.length - 1
? turnStartTime
: null
}
/> />
); );
})} })}
@ -479,7 +513,13 @@ export function MessageList({
</div> </div>
); );
})} })}
{thread.isLoading && <StreamingIndicator className="my-4" />} {thread.isLoading && !hasActiveAssistantText && (
<div className="w-full">
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
<ReasoningTrigger hasContent={false} />
</Reasoning>
</div>
)}
<div style={{ height: `${paddingBottom}px` }} /> <div style={{ height: `${paddingBottom}px` }} />
</ConversationContent> </ConversationContent>
</Conversation> </Conversation>