diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 080fbbcd94..5597180e1c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2865,6 +2865,35 @@ export const webviewMessageHandler = async ( } break } + case "updateMessageReasoningMeta": { + // Persist reasoning timer metadata on a specific message (by ts) + try { + const currentCline = provider.getCurrentTask() + if (!currentCline || !message.messageTs) { + break + } + const { messageIndex } = findMessageIndices(message.messageTs, currentCline) + if (messageIndex === -1) { + break + } + const msg = currentCline.clineMessages[messageIndex] as any + const existingMeta = (msg.metadata as any) || {} + const existingReasoning = existingMeta.reasoning || {} + msg.metadata = { + ...existingMeta, + reasoning: { ...existingReasoning, ...(message.reasoningMeta || {}) }, + } + + await saveTaskMessages({ + messages: currentCline.clineMessages, + taskId: currentCline.taskId, + globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, + }) + } catch (error) { + console.error("[updateMessageReasoningMeta] Failed to persist reasoning metadata:", error) + } + break + } case "showMdmAuthRequiredNotification": { // Show notification that organization requires authentication vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth")) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 565712bfbf..55d1ada638 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -221,6 +221,7 @@ export interface WebviewMessage { | "queueMessage" | "removeQueuedMessage" | "editQueuedMessage" + | "updateMessageReasoningMeta" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -256,6 +257,10 @@ export interface WebviewMessage { terminalOperation?: "continue" | "abort" messageTs?: number restoreCheckpoint?: boolean + reasoningMeta?: { + startedAt?: number + elapsedMs?: number + } historyPreviewCollapsed?: boolean filters?: { type?: string; search?: string; tags?: string[] } settings?: any diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 0e56c96d1d..67df0c2dbb 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1083,7 +1083,15 @@ export const ChatRowContent = ({ ) case "reasoning": - return + return ( + + ) case "api_req_started": return ( <> diff --git a/webview-ui/src/components/chat/ReasoningBlock.tsx b/webview-ui/src/components/chat/ReasoningBlock.tsx index 3c2a7c839e..6e34091fcb 100644 --- a/webview-ui/src/components/chat/ReasoningBlock.tsx +++ b/webview-ui/src/components/chat/ReasoningBlock.tsx @@ -1,16 +1,89 @@ +import React, { useEffect, useMemo, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + import MarkdownBlock from "../common/MarkdownBlock" +import { vscode } from "@src/utils/vscode" interface ReasoningBlockProps { content: string + ts: number + isStreaming: boolean + isLast: boolean + metadata?: Record +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, "0")}` } /** - * Render reasoning as simple italic text, matching how content is shown. - * No borders, boxes, headers, timers, or collapsible behavior. + * Render reasoning with a heading and a persistent timer. + * - Heading uses i18n key chat:reasoning.thinking + * - Timer persists via message.metadata.reasoning { startedAt, elapsedMs } */ -export const ReasoningBlock = ({ content }: ReasoningBlockProps) => { +export const ReasoningBlock = ({ content, ts, isStreaming, isLast, metadata }: ReasoningBlockProps) => { + const { t } = useTranslation() + + const persisted = (metadata?.reasoning as { startedAt?: number; elapsedMs?: number } | undefined) || {} + const startedAtRef = useRef(persisted.startedAt ?? Date.now()) + const [elapsed, setElapsed] = useState(persisted.elapsedMs ?? 0) + + // Initialize startedAt on first mount if missing (persist to task) + useEffect(() => { + if (!persisted.startedAt && isLast) { + vscode.postMessage({ + type: "updateMessageReasoningMeta", + messageTs: ts, + reasoningMeta: { startedAt: startedAtRef.current }, + } as any) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts]) + + // Tick while active (last row and streaming) + useEffect(() => { + const active = isLast && isStreaming + if (!active) return + + const tick = () => setElapsed(Date.now() - startedAtRef.current) + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [isLast, isStreaming]) + + // Persist final elapsed when streaming stops + const wasActiveRef = useRef(false) + useEffect(() => { + const active = isLast && isStreaming + if (wasActiveRef.current && !active) { + const finalMs = Date.now() - startedAtRef.current + setElapsed(finalMs) + vscode.postMessage({ + type: "updateMessageReasoningMeta", + messageTs: ts, + reasoningMeta: { startedAt: startedAtRef.current, elapsedMs: finalMs }, + } as any) + } + wasActiveRef.current = active + }, [isLast, isStreaming, ts]) + + const displayMs = useMemo(() => { + if (isLast && isStreaming) return elapsed + return persisted.elapsedMs ?? elapsed + }, [elapsed, isLast, isStreaming, persisted.elapsedMs]) + return (
+
+
+ + {t("chat:reasoning.thinking")} +
+ {formatDuration(displayMs)} +