diff --git a/web-app/src/containers/ThinkingBlock.tsx b/web-app/src/containers/ThinkingBlock.tsx
index 808d238d7..b6319c7a3 100644
--- a/web-app/src/containers/ThinkingBlock.tsx
+++ b/web-app/src/containers/ThinkingBlock.tsx
@@ -1,10 +1,9 @@
import { ChevronDown, ChevronUp, Loader, Check } from 'lucide-react'
import { create } from 'zustand'
import { RenderMarkdown } from './RenderMarkdown'
-import { useAppState } from '@/hooks/useAppState'
+// import { useAppState } from '@/hooks/useAppState'
import { useTranslation } from '@/i18n/react-i18next-compat'
-// import { extractThinkingContent } from '@/lib/utils'
-import { useMemo, useState, useEffect } from 'react'
+import { useMemo } from 'react'
import { cn } from '@/lib/utils'
// Define ThoughtStep type
@@ -50,26 +49,20 @@ const formatDuration = (ms: number) => {
const ThinkingBlock = ({
id,
- text,
+ // text, // Unused internally
steps = [],
loading: propLoading,
duration,
}: Props) => {
const thinkingState = useThinkingStore((state) => state.thinkingState)
const setThinkingState = useThinkingStore((state) => state.setThinkingState)
- const isStreamingApp = useAppState((state) => !!state.streamingContent)
const { t } = useTranslation()
- // Determine actual loading state
- const hasThinkTag = text.includes('') && !text.includes('')
- const hasAnalysisChannel =
- text.includes('<|channel|>analysis<|message|>') &&
- !text.includes('<|start|>assistant<|channel|>final<|message|>')
-
- const loading =
- propLoading ?? ((hasThinkTag || hasAnalysisChannel) && isStreamingApp)
+ // Actual loading state comes from prop, determined by whether final text started streaming (Req 2)
+ const loading = propLoading
// Set default expansion state: expanded if loading, collapsed if done.
+ // If loading transitions to false (textSegment starts), this defaults to collapsed if state is absent.
const isExpanded = thinkingState[id] ?? (loading ? true : false)
// Filter out the 'done' step for streaming display
@@ -78,38 +71,21 @@ const ThinkingBlock = ({
[steps]
)
- // Get the current step being streamed (last step that's not 'done')
- const currentStreamingStepIndex = stepsWithoutDone.length - 1
- const currentStep =
- currentStreamingStepIndex >= 0
- ? stepsWithoutDone[currentStreamingStepIndex]
- : null
+ const N = stepsWithoutDone.length
- // Track which step index we're displaying
- const [displayedStepIndex, setDisplayedStepIndex] = useState(
- currentStreamingStepIndex
- )
- const [transitioning, setTransitioning] = useState(false)
-
- // When a new step arrives during streaming, animate the transition
- useEffect(() => {
- if (loading && currentStreamingStepIndex > displayedStepIndex) {
- setTransitioning(true)
-
- const timeout = setTimeout(() => {
- setDisplayedStepIndex(currentStreamingStepIndex)
- setTransitioning(false)
- }, 150)
-
- return () => clearTimeout(timeout)
- } else if (!loading) {
- // When streaming stops, ensure we show the final state
- setDisplayedStepIndex(currentStreamingStepIndex)
+ // Determine the step to display in the condensed streaming view (Req 3)
+ // Show step N-2 when N >= 2 (i.e., when step N-1 is streaming, show the previously finished step)
+ const stepToRenderWhenStreaming = useMemo(() => {
+ if (!loading) return null // Only apply this logic when actively loading
+ if (N >= 2) {
+ // Show the penultimate step (index N-2)
+ return stepsWithoutDone[N - 2]
}
- }, [currentStreamingStepIndex, loading, displayedStepIndex])
+ return null
+ }, [loading, N, stepsWithoutDone])
// Determine if the block is truly empty (streaming started but no content/steps yet)
- const isStreamingEmpty = loading && stepsWithoutDone.length === 0
+ const isStreamingEmpty = loading && N === 0
// If loading started but no content or steps have arrived yet, display the non-expandable 'Thinking...' block
if (isStreamingEmpty) {
@@ -236,20 +212,19 @@ const ThinkingBlock = ({
- {/* Streaming/Condensed View - shows current step one at a time */}
- {loading && currentStep && displayedStepIndex >= 0 && (
+ {/* Streaming/Condensed View - shows previous finished step */}
+ {loading && stepToRenderWhenStreaming && (
{/* Bullet point */}
- {/* Current step content */}
- {renderStepContent(currentStep, displayedStepIndex)}
+ {/* Previous completed step content */}
+ {renderStepContent(stepToRenderWhenStreaming, N - 2)}
diff --git a/web-app/src/containers/ThreadContent.tsx b/web-app/src/containers/ThreadContent.tsx
index f7c8102f2..6888dfdc7 100644
--- a/web-app/src/containers/ThreadContent.tsx
+++ b/web-app/src/containers/ThreadContent.tsx
@@ -7,7 +7,6 @@ import { useAppState } from '@/hooks/useAppState'
import { cn } from '@/lib/utils'
import { useMessages } from '@/hooks/useMessages'
import ThinkingBlock from '@/containers/ThinkingBlock'
-// import ToolCallBlock from '@/containers/ToolCallBlock'
import { useChat } from '@/hooks/useChat'
import {
EditMessageDialog,
@@ -252,27 +251,36 @@ export const ThreadContent = memo(
| { avatar?: React.ReactNode; name?: React.ReactNode }
| undefined
- // START: Constructing allSteps for ThinkingBlock (Req 5)
+ // Constructing allSteps for ThinkingBlock
const allSteps: ThoughtStep[] = useMemo(() => {
const steps: ThoughtStep[] = []
- // 1. Extract thought paragraphs
+ // Extract thought paragraphs from reasoningSegment. We assume these are ordered
+ // relative to tool calls.
const thoughtText = extractThinkingContent(reasoningSegment || '')
const thoughtParagraphs = thoughtText
? thoughtText
.split(/\n\s*\n/)
.filter((s) => s.trim().length > 0)
- .map((content) => ({
- type: 'thought' as const,
- content: content.trim(),
- }))
+ .map((content) => content.trim())
: []
- steps.push(...thoughtParagraphs)
- // 2. Extract tool steps
+ let thoughtIndex = 0
+
+ // 2. Interleave tool steps and thought steps
if (isToolCalls && item.metadata?.tool_calls) {
const toolCalls = item.metadata.tool_calls as ToolCall[]
+
for (const call of toolCalls) {
+ // Check for thought chunk preceding this tool call
+ if (thoughtIndex < thoughtParagraphs.length) {
+ steps.push({
+ type: 'thought',
+ content: thoughtParagraphs[thoughtIndex],
+ })
+ thoughtIndex++
+ }
+
// Tool Call Step
steps.push({
type: 'tool_call',
@@ -295,9 +303,23 @@ export const ThreadContent = memo(
}
}
- // 3. Add Done step if not streaming
+ // Add remaining thoughts (e.g., final answer formulation thought)
+ while (thoughtIndex < thoughtParagraphs.length) {
+ steps.push({
+ type: 'thought',
+ content: thoughtParagraphs[thoughtIndex],
+ })
+ thoughtIndex++
+ }
+
+ // Add Done step if not streaming AND the reasoning/tooling process is concluded
const totalTime = item.metadata?.totalThinkingTime as number | undefined
- if (!isStreamingThisThread && (hasReasoning || isToolCalls)) {
+
+ // If the thread is not streaming, and we had steps or final text output, we add 'done'.
+ if (
+ !isStreamingThisThread &&
+ (hasReasoning || isToolCalls || textSegment)
+ ) {
steps.push({
type: 'done',
content: 'Done',
@@ -312,11 +334,18 @@ export const ThreadContent = memo(
item.metadata,
isStreamingThisThread,
hasReasoning,
+ textSegment,
])
// END: Constructing allSteps
- // Determine if we should show the thinking block (has reasoning OR tool calls)
- const shouldShowThinkingBlock = hasReasoning || isToolCalls
+ // Determine if reasoning phase is actively loading (Req 2)
+ // Loading is true only if streaming is happening AND we haven't started outputting final text yet.
+ const isReasoningActiveLoading =
+ isStreamingThisThread && textSegment.length === 0
+
+ // Determine if we should show the thinking block (has reasoning OR tool calls OR currently loading reasoning)
+ const shouldShowThinkingBlock =
+ hasReasoning || isToolCalls || isReasoningActiveLoading
return (
@@ -462,7 +491,7 @@ export const ThreadContent = memo(
}
text={reasoningSegment || ''}
steps={allSteps}
- loading={isStreamingThisThread}
+ loading={isReasoningActiveLoading} // Req 2: False if textSegment is starting
duration={
item.metadata?.totalThinkingTime as number | undefined
}