From df5d90fbb1473fecce9718fc26d1f50a8a9900db Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 19 Jun 2026 21:14:10 +0530 Subject: [PATCH] feat(frontend): implement (thought for x second) thinking duration indicator (#3627) --- .../src/components/ai-elements/reasoning.tsx | 74 +++++++++++++++---- .../workspace/messages/message-list-item.tsx | 18 +++++ .../workspace/messages/message-list.tsx | 50 +++++++++++-- 3 files changed, 123 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/ai-elements/reasoning.tsx b/frontend/src/components/ai-elements/reasoning.tsx index 94d4e7a5c..d23b213e8 100644 --- a/frontend/src/components/ai-elements/reasoning.tsx +++ b/frontend/src/components/ai-elements/reasoning.tsx @@ -19,6 +19,7 @@ type ReasoningContextValue = { isOpen: boolean; setIsOpen: (open: boolean) => void; duration: number | undefined; + startTime: number | null; }; const ReasoningContext = createContext(null); @@ -37,6 +38,7 @@ export type ReasoningProps = ComponentProps & { defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; duration?: number; + startTimeProp?: number | null; }; const AUTO_CLOSE_DELAY = 1000; @@ -50,6 +52,7 @@ export const Reasoning = memo( defaultOpen = true, onOpenChange, duration: durationProp, + startTimeProp, children, ...props }: ReasoningProps) => { @@ -64,19 +67,24 @@ export const Reasoning = memo( }); const [hasAutoClosed, setHasAutoClosed] = useState(false); - const [startTime, setStartTime] = useState(null); + const [startTime, setStartTime] = useState( + () => startTimeProp ?? (isStreaming ? Date.now() : null), + ); // Track duration when streaming starts and ends useEffect(() => { 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()); } } else if (startTime !== null) { setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S)); setStartTime(null); } - }, [isStreaming, startTime, setDuration]); + }, [isStreaming, startTimeProp, startTime, setDuration]); // Auto-open when streaming starts, auto-close when streaming ends (once only) useEffect(() => { @@ -97,7 +105,7 @@ export const Reasoning = memo( return ( & { - 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 ( + + Thinking... + ({elapsed}s) + + ); +}; + +const defaultGetThinkingMessage = ( + isStreaming: boolean, + duration?: number, + startTime?: number | null, +) => { + if (isStreaming && startTime != null && startTime !== undefined) { + return ; + } if (isStreaming || duration === 0) { return Thinking...; } @@ -133,14 +175,16 @@ export const ReasoningTrigger = memo( className, children, getThinkingMessage = defaultGetThinkingMessage, + hasContent = true, ...props }: ReasoningTriggerProps) => { - const { isStreaming, isOpen, duration } = useReasoning(); + const { isStreaming, isOpen, duration, startTime } = useReasoning(); return ( - {getThinkingMessage(isStreaming, duration)} - + {getThinkingMessage(isStreaming, duration, startTime)} + {hasContent && ( + + )} )} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index c3c33a90e..425998af8 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -10,6 +10,7 @@ import { useCallback, useMemo, useState, + useEffect, type AnchorHTMLAttributes, type ImgHTMLAttributes, } from "react"; @@ -124,6 +125,7 @@ export function MessageListItem({ runId, threadId, showCopyButton = true, + turnStartTime, }: { className?: string; message: Message; @@ -132,6 +134,7 @@ export function MessageListItem({ feedback?: FeedbackData | null; runId?: string; showCopyButton?: boolean; + turnStartTime?: number | null; }) { const isHuman = message.type === "human"; return ( @@ -144,6 +147,7 @@ export function MessageListItem({ message={message} isLoading={isLoading} threadId={threadId} + turnStartTime={turnStartTime} /> {!isLoading && showCopyButton && ( { + if (isLoading) setWasLoading(true); + }, [isLoading]); const components = useMemo( () => ({ img: (props: ImgHTMLAttributes) => ( @@ -324,6 +334,14 @@ function MessageContent_({ return ( {filesList} + {!isHuman && (!!reasoningContent || wasLoading) && ( + + + {reasoningContent && ( + {reasoningContent} + )} + + )} (null); + 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 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 turnUsageMessagesByGroupIndex = getAssistantTurnUsageMessages(groupedMessages); @@ -296,9 +322,17 @@ export function MessageList({ ); })} @@ -479,7 +513,13 @@ export function MessageList({ ); })} - {thread.isLoading && } + {thread.isLoading && !hasActiveAssistantText && ( +
+ + + +
+ )}