diff --git a/webview-ui/src/components/chat/ReasoningBlock.tsx b/webview-ui/src/components/chat/ReasoningBlock.tsx index 11166f5ae1..cfc52e00a6 100644 --- a/webview-ui/src/components/chat/ReasoningBlock.tsx +++ b/webview-ui/src/components/chat/ReasoningBlock.tsx @@ -14,14 +14,36 @@ interface ReasoningBlockProps { metadata?: any } -export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => { +/** + * Module-level cache that persists elapsed reasoning durations across + * React component remounts. When Virtuoso recycles a ReasoningBlock + * (e.g., during expand/collapse or scroll), the cache provides the + * final elapsed value from the previous mount cycle instead of + * recomputing from Date.now() - ts, which would be wrong if minutes + * have passed since the message was created. + */ +const elapsedCache = new Map() + +export const ReasoningBlock = ({ content, isStreaming, isLast, ts }: ReasoningBlockProps) => { const { t } = useTranslation() const { reasoningBlockCollapsed } = useExtensionState() const [isCollapsed, setIsCollapsed] = useState(reasoningBlockCollapsed) - const startTimeRef = useRef(Date.now()) - const [elapsed, setElapsed] = useState(0) + // Anchor the elapsed timer to the message creation timestamp (ts) + // rather than component mount time. When Virtuoso recycles or + // remounts this component (e.g. during expand/collapse in a + // virtualized list), the timer survives because ts is a stable + // prop from the message data rather than a fresh Date.now(). + const startTimeRef = useRef(ts) + // On init, prefer cached elapsed (survives remounts). If not cached, + // use Date.now() - ts for a reasonable initial estimate. This handles + // the first mount where no cached value exists yet. + const [elapsed, setElapsed] = useState(() => { + const cached = elapsedCache.get(ts) + if (cached !== undefined) return cached + return isStreaming ? 0 : Math.max(0, Date.now() - ts) + }) const contentRef = useRef(null) useEffect(() => { @@ -30,12 +52,25 @@ export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockP useEffect(() => { if (isLast && isStreaming) { - const tick = () => setElapsed(Date.now() - startTimeRef.current) + const tick = () => { + const current = Date.now() - startTimeRef.current + setElapsed(current) + elapsedCache.set(ts, current) + } tick() const id = setInterval(tick, 1000) return () => clearInterval(id) } - }, [isLast, isStreaming]) + }, [isLast, isStreaming, ts]) + + // Cache the final elapsed value when streaming stops so it survives + // future remounts even if the component unmounts before the timer + // effect cleanup runs. + useEffect(() => { + if (!isStreaming && elapsed > 0) { + elapsedCache.set(ts, elapsed) + } + }, [isStreaming, ts, elapsed]) const seconds = Math.floor(elapsed / 1000) const secondsLabel = t("chat:reasoning.seconds", { count: seconds }) diff --git a/webview-ui/src/components/chat/__tests__/ReasoningBlock.spec.tsx b/webview-ui/src/components/chat/__tests__/ReasoningBlock.spec.tsx new file mode 100644 index 0000000000..4e119f8127 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ReasoningBlock.spec.tsx @@ -0,0 +1,152 @@ +// npx vitest src/components/chat/__tests__/ReasoningBlock.spec.tsx + +import React from "react" +import { render, screen, act } from "@/utils/test-utils" + +import { ReasoningBlock } from "../ReasoningBlock" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: any) => { + if (key === "chat:reasoning.seconds") { + return `${options?.count ?? 0}s` + } + return key + }, + }), + initReactI18next: { + type: "3rdParty", + init: vi.fn(), + }, +})) + +// Mock ExtensionStateContext +const mockExtensionState = { + reasoningBlockCollapsed: false, +} + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => mockExtensionState, +})) + +const defaultProps = { + content: "Let me think about this...", + ts: Date.now() - 30_000, // 30 seconds ago + isStreaming: false, + isLast: false, +} as const + +describe("ReasoningBlock", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(Date.now()) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("renders the thinking label", () => { + render() + expect(screen.getByText("chat:reasoning.thinking")).toBeInTheDocument() + }) + + it("shows elapsed time for non-streaming blocks anchored to ts", () => { + const thirtySecAgo = Date.now() - 30_000 + render() + + // Should show ~30s since the timestamp + expect(screen.getByText("30s")).toBeInTheDocument() + }) + + it("starts timer at 0 when streaming and is last message", () => { + const ts = Date.now() + render() + + // Initially 0 while streaming (just started) — no label shown when elapsed is 0 + expect(screen.queryByText(/^\d+s$/)).not.toBeInTheDocument() + + // Advance time by 5 seconds + act(() => { + vi.advanceTimersByTime(5000) + }) + + expect(screen.getByText("5s")).toBeInTheDocument() + }) + + it("preserves elapsed time on remount with same ts (Virtuoso recycle)", () => { + // ts is 1 second in the past so elapsed starts at 1s (not 0, since 0 hides the label) + const ts = Date.now() - 1000 + + const { unmount } = render() + + // Initially 1s since ts is 1s ago + expect(screen.getByText("1s")).toBeInTheDocument() + + // Advance time by 10 seconds + act(() => { + vi.advanceTimersByTime(10_000) + }) + + // Unmount (simulating Virtuoso recycling the component) + unmount() + + // Remount with the same ts + render() + + // Should still show 1s — the elapsed value was cached at module level + // and survived the remount, instead of recomputing from Date.now() - ts + // (which would give 11s and inflate the timer after a scroll away) + expect(screen.getByText("1s")).toBeInTheDocument() + }) + + it("stops timer when streaming ends", () => { + const ts = Date.now() - 5000 + + const { rerender } = render( + , + ) + + // Streaming — timer is active + expect(screen.getByText("5s")).toBeInTheDocument() + + // Advance time while streaming + act(() => { + vi.advanceTimersByTime(3000) + }) + + expect(screen.getByText("8s")).toBeInTheDocument() + + // Streaming stops — rerender with isStreaming=false + rerender() + + // Timer should stop at current value + act(() => { + vi.advanceTimersByTime(5000) + }) + + // Should NOT have advanced + expect(screen.getByText("8s")).toBeInTheDocument() + }) + + it("collapses when reasoningBlockCollapsed is true", () => { + mockExtensionState.reasoningBlockCollapsed = true + + render() + + // Content and markdown should be hidden + expect(screen.queryByText(defaultProps.content)).not.toBeInTheDocument() + + // Reset for other tests + mockExtensionState.reasoningBlockCollapsed = false + }) + + it("hides elapsed label when elapsed is 0", () => { + const ts = Date.now() + render() + + // No seconds label because elapsed is 0 + expect(screen.queryByText(/^\d+s$/)).not.toBeInTheDocument() + }) +})