Skip to content

Commit c557a2f

Browse files
committed
feat(chat): add Reasoning heading and persistent timer; persist timing in message.metadata; render timer in UI
1 parent ab958bf commit c557a2f

File tree

4 files changed

+119
-4
lines changed

4 files changed

+119
-4
lines changed

src/core/webview/webviewMessageHandler.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2865,6 +2865,35 @@ export const webviewMessageHandler = async (
28652865
}
28662866
break
28672867
}
2868+
case "updateMessageReasoningMeta": {
2869+
// Persist reasoning timer metadata on a specific message (by ts)
2870+
try {
2871+
const currentCline = provider.getCurrentTask()
2872+
if (!currentCline || !message.messageTs) {
2873+
break
2874+
}
2875+
const { messageIndex } = findMessageIndices(message.messageTs, currentCline)
2876+
if (messageIndex === -1) {
2877+
break
2878+
}
2879+
const msg = currentCline.clineMessages[messageIndex] as any
2880+
const existingMeta = (msg.metadata as any) || {}
2881+
const existingReasoning = existingMeta.reasoning || {}
2882+
msg.metadata = {
2883+
...existingMeta,
2884+
reasoning: { ...existingReasoning, ...(message.reasoningMeta || {}) },
2885+
}
2886+
2887+
await saveTaskMessages({
2888+
messages: currentCline.clineMessages,
2889+
taskId: currentCline.taskId,
2890+
globalStoragePath: provider.contextProxy.globalStorageUri.fsPath,
2891+
})
2892+
} catch (error) {
2893+
console.error("[updateMessageReasoningMeta] Failed to persist reasoning metadata:", error)
2894+
}
2895+
break
2896+
}
28682897
case "showMdmAuthRequiredNotification": {
28692898
// Show notification that organization requires authentication
28702899
vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth"))

src/shared/WebviewMessage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export interface WebviewMessage {
221221
| "queueMessage"
222222
| "removeQueuedMessage"
223223
| "editQueuedMessage"
224+
| "updateMessageReasoningMeta"
224225
text?: string
225226
editedMessageContent?: string
226227
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@@ -256,6 +257,10 @@ export interface WebviewMessage {
256257
terminalOperation?: "continue" | "abort"
257258
messageTs?: number
258259
restoreCheckpoint?: boolean
260+
reasoningMeta?: {
261+
startedAt?: number
262+
elapsedMs?: number
263+
}
259264
historyPreviewCollapsed?: boolean
260265
filters?: { type?: string; search?: string; tags?: string[] }
261266
settings?: any

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1083,7 +1083,15 @@ export const ChatRowContent = ({
10831083
</div>
10841084
)
10851085
case "reasoning":
1086-
return <ReasoningBlock content={message.text || ""} />
1086+
return (
1087+
<ReasoningBlock
1088+
content={message.text || ""}
1089+
ts={message.ts}
1090+
isStreaming={isStreaming}
1091+
isLast={isLast}
1092+
metadata={message.metadata as any}
1093+
/>
1094+
)
10871095
case "api_req_started":
10881096
return (
10891097
<>

webview-ui/src/components/chat/ReasoningBlock.tsx

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,89 @@
1+
import React, { useEffect, useMemo, useRef, useState } from "react"
2+
import { useTranslation } from "react-i18next"
3+
14
import MarkdownBlock from "../common/MarkdownBlock"
5+
import { vscode } from "@src/utils/vscode"
26

37
interface ReasoningBlockProps {
48
content: string
9+
ts: number
10+
isStreaming: boolean
11+
isLast: boolean
12+
metadata?: Record<string, any>
13+
}
14+
15+
function formatDuration(ms: number): string {
16+
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
17+
const minutes = Math.floor(totalSeconds / 60)
18+
const seconds = totalSeconds % 60
19+
return `${minutes}:${seconds.toString().padStart(2, "0")}`
520
}
621

722
/**
8-
* Render reasoning as simple italic text, matching how <thinking> content is shown.
9-
* No borders, boxes, headers, timers, or collapsible behavior.
23+
* Render reasoning with a heading and a persistent timer.
24+
* - Heading uses i18n key chat:reasoning.thinking
25+
* - Timer persists via message.metadata.reasoning { startedAt, elapsedMs }
1026
*/
11-
export const ReasoningBlock = ({ content }: ReasoningBlockProps) => {
27+
export const ReasoningBlock = ({ content, ts, isStreaming, isLast, metadata }: ReasoningBlockProps) => {
28+
const { t } = useTranslation()
29+
30+
const persisted = (metadata?.reasoning as { startedAt?: number; elapsedMs?: number } | undefined) || {}
31+
const startedAtRef = useRef<number>(persisted.startedAt ?? Date.now())
32+
const [elapsed, setElapsed] = useState<number>(persisted.elapsedMs ?? 0)
33+
34+
// Initialize startedAt on first mount if missing (persist to task)
35+
useEffect(() => {
36+
if (!persisted.startedAt && isLast) {
37+
vscode.postMessage({
38+
type: "updateMessageReasoningMeta",
39+
messageTs: ts,
40+
reasoningMeta: { startedAt: startedAtRef.current },
41+
} as any)
42+
}
43+
// eslint-disable-next-line react-hooks/exhaustive-deps
44+
}, [ts])
45+
46+
// Tick while active (last row and streaming)
47+
useEffect(() => {
48+
const active = isLast && isStreaming
49+
if (!active) return
50+
51+
const tick = () => setElapsed(Date.now() - startedAtRef.current)
52+
tick()
53+
const id = setInterval(tick, 1000)
54+
return () => clearInterval(id)
55+
}, [isLast, isStreaming])
56+
57+
// Persist final elapsed when streaming stops
58+
const wasActiveRef = useRef<boolean>(false)
59+
useEffect(() => {
60+
const active = isLast && isStreaming
61+
if (wasActiveRef.current && !active) {
62+
const finalMs = Date.now() - startedAtRef.current
63+
setElapsed(finalMs)
64+
vscode.postMessage({
65+
type: "updateMessageReasoningMeta",
66+
messageTs: ts,
67+
reasoningMeta: { startedAt: startedAtRef.current, elapsedMs: finalMs },
68+
} as any)
69+
}
70+
wasActiveRef.current = active
71+
}, [isLast, isStreaming, ts])
72+
73+
const displayMs = useMemo(() => {
74+
if (isLast && isStreaming) return elapsed
75+
return persisted.elapsedMs ?? elapsed
76+
}, [elapsed, isLast, isStreaming, persisted.elapsedMs])
77+
1278
return (
1379
<div className="px-3 py-1">
80+
<div className="flex items-center justify-between mb-1">
81+
<div className="flex items-center gap-2">
82+
<span className="codicon codicon-light-bulb text-muted-foreground" />
83+
<span className="font-medium text-vscode-foreground">{t("chat:reasoning.thinking")}</span>
84+
</div>
85+
<span className="text-xs text-muted-foreground tabular-nums">{formatDuration(displayMs)}</span>
86+
</div>
1487
<div className="italic text-muted-foreground">
1588
<MarkdownBlock markdown={content} />
1689
</div>

0 commit comments

Comments
 (0)