feat(chat): add Reasoning heading and persistent timer; persist timing in message.metadata; render timer in UI

This commit is contained in:
Hannes Rudolph 2025-09-08 10:47:46 -06:00
parent ab958bf80b
commit c557a2faa3
4 changed files with 119 additions and 4 deletions

View file

@ -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"))

View file

@ -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

View file

@ -1083,7 +1083,15 @@ export const ChatRowContent = ({
</div>
)
case "reasoning":
return <ReasoningBlock content={message.text || ""} />
return (
<ReasoningBlock
content={message.text || ""}
ts={message.ts}
isStreaming={isStreaming}
isLast={isLast}
metadata={message.metadata as any}
/>
)
case "api_req_started":
return (
<>

View file

@ -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<string, any>
}
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 <thinking> 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<number>(persisted.startedAt ?? Date.now())
const [elapsed, setElapsed] = useState<number>(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<boolean>(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 (
<div className="px-3 py-1">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="codicon codicon-light-bulb text-muted-foreground" />
<span className="font-medium text-vscode-foreground">{t("chat:reasoning.thinking")}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{formatDuration(displayMs)}</span>
</div>
<div className="italic text-muted-foreground">
<MarkdownBlock markdown={content} />
</div>