Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ export const ChatRowContent = ({

const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration } = useExtensionState()
const { info: model } = useSelectedModel(apiConfiguration)
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state and are no longer used after the simplification. Should we remove this unused import and state declaration?

const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false)
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [isEditing, setIsEditing] = useState(false)
Expand Down Expand Up @@ -1087,9 +1086,10 @@ export const ChatRowContent = ({
return (
<ReasoningBlock
content={message.text || ""}
elapsed={isLast && isStreaming ? Date.now() - message.ts : undefined}
isCollapsed={reasoningCollapsed}
onToggleCollapse={() => setReasoningCollapsed(!reasoningCollapsed)}
ts={message.ts}
isStreaming={isStreaming}
isLast={isLast}
metadata={message.metadata as any}
/>
)
case "api_req_started":
Expand Down
113 changes: 37 additions & 76 deletions webview-ui/src/components/chat/ReasoningBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,57 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { CaretDownIcon, CaretUpIcon, CounterClockwiseClockIcon } from "@radix-ui/react-icons"
import React, { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"

import MarkdownBlock from "../common/MarkdownBlock"
import { useMount } from "react-use"
import { Clock, Lightbulb } from "lucide-react"

interface ReasoningBlockProps {
content: string
elapsed?: number
isCollapsed?: boolean
onToggleCollapse?: () => void
ts: number
isStreaming: boolean
isLast: boolean
metadata?: any
}

export const ReasoningBlock = ({ content, elapsed, isCollapsed = false, onToggleCollapse }: ReasoningBlockProps) => {
const contentRef = useRef<HTMLDivElement>(null)
const elapsedRef = useRef<number>(0)
const { t } = useTranslation("chat")
const [thought, setThought] = useState<string>()
const [prevThought, setPrevThought] = useState<string>(t("chat:reasoning.thinking"))
const [isTransitioning, setIsTransitioning] = useState<boolean>(false)
const cursorRef = useRef<number>(0)
const queueRef = useRef<string[]>([])
/**
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice documentation! Could we expand it slightly to explain the design rationale? For example:

* Render reasoning with a heading and a simple timer.
* - Heading uses i18n key chat:reasoning.thinking
* - Timer runs while reasoning is active (no persistence)
*/
export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => {
const { t } = useTranslation()

useEffect(() => {
if (contentRef.current && !isCollapsed) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [content, isCollapsed])

useEffect(() => {
if (elapsed) {
elapsedRef.current = elapsed
}
}, [elapsed])

// Process the transition queue.
const processNextTransition = useCallback(() => {
const nextThought = queueRef.current.pop()
queueRef.current = []

if (nextThought) {
setIsTransitioning(true)
}

setTimeout(() => {
if (nextThought) {
setPrevThought(nextThought)
setIsTransitioning(false)
}

setTimeout(() => processNextTransition(), 500)
}, 200)
}, [])

useMount(() => {
processNextTransition()
})
const startTimeRef = useRef<number>(Date.now())
const [elapsed, setElapsed] = useState<number>(0)

// Simple timer that runs while streaming
useEffect(() => {
if (content.length - cursorRef.current > 160) {
setThought("... " + content.slice(cursorRef.current))
cursorRef.current = content.length
if (isLast && isStreaming) {
const tick = () => setElapsed(Date.now() - startTimeRef.current)
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}
}, [content])
}, [isLast, isStreaming])

useEffect(() => {
if (thought && thought !== prevThought) {
queueRef.current.push(thought)
}
}, [thought, prevThought])
const seconds = Math.floor(elapsed / 1000)
const secondsLabel = t("chat:reasoning.seconds", { count: seconds })

return (
<div className="bg-vscode-editor-background border border-vscode-border rounded-xs overflow-hidden">
<div
className="flex items-center justify-between gap-1 px-3 py-2 cursor-pointer text-muted-foreground"
onClick={onToggleCollapse}>
<div
className={`truncate flex-1 transition-opacity duration-200 ${isTransitioning ? "opacity-0" : "opacity-100"}`}>
{prevThought}
</div>
<div className="flex flex-row items-center gap-1">
{elapsedRef.current > 1000 && (
<>
<CounterClockwiseClockIcon className="scale-80" />
<div>{t("reasoning.seconds", { count: Math.round(elapsedRef.current / 1000) })}</div>
</>
)}
{isCollapsed ? <CaretDownIcon /> : <CaretUpIcon />}
<div className="py-1">
<div className="flex items-center justify-between mb-2.5">
<div className="flex items-center gap-2">
<Lightbulb className="w-4" />
<span className="font-bold text-vscode-foreground">{t("chat:reasoning.thinking")}</span>
</div>
{elapsed > 0 && (
<span className="text-vscode-foreground tabular-nums flex items-center gap-1">
<Clock className="w-4" />
{secondsLabel}
</span>
)}
</div>
{!isCollapsed && (
<div ref={contentRef} className="px-3 max-h-[160px] overflow-y-auto">
{(content?.trim()?.length ?? 0) > 0 && (
<div className="px-3 italic text-vscode-descriptionForeground">
<MarkdownBlock markdown={content} />
</div>
)}
Expand Down
Loading