diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
index 97fc3091299..087a147e3f9 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
@@ -153,6 +153,8 @@ export const MessageRow = memo(function MessageRow({
return (
({
default: ({ content }: { content: string }) => {content}
}))
-afterEach(cleanup)
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+})
describe('reasoning disclosure', () => {
- it('starts collapsed with the first meaningful line and does not mount markdown', () => {
+ it('shows the unobserved-history fallback collapsed without mounting markdown', () => {
render()
expect(
- screen.getByRole('button', { name: 'Reasoning: Inspecting the request' })
+ screen.getByRole('button', { name: 'Reasoning: Thought for a few seconds' })
).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByTestId('markdown')).not.toBeInTheDocument()
- expect(screen.queryByText(/Thought for/)).not.toBeInTheDocument()
+ expect(screen.getByRole('button')).not.toHaveTextContent(/\d/)
})
it('expands through a native button and preserves disclosure state through revisions', () => {
@@ -44,10 +47,46 @@ describe('reasoning disclosure', () => {
expect(container).toBeEmptyDOMElement()
})
- it('bounds a growing summary without changing the collapsed layout', () => {
- render()
- expect(screen.getByRole('button')).toHaveTextContent(`${'a'.repeat(120)}…`)
+ it('shows Thinking while streaming and freezes the locally observed duration on settlement', () => {
+ const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
+ const { rerender } = render(
+
+ )
+ expect(screen.getByRole('button')).toHaveTextContent('Thinking...')
+ expect(screen.getByText('Thinking...')).toHaveClass('animate-pulse')
+ now.mockReturnValue(105_000)
+ rerender()
+ now.mockReturnValue(112_000)
+ rerender()
+ expect(screen.getByRole('button')).toHaveTextContent('Thought for 12s')
+ now.mockReturnValue(180_000)
+ rerender()
+ expect(screen.getByRole('button')).toHaveTextContent('Thought for 12s')
expect(screen.queryByTestId('markdown')).not.toBeInTheDocument()
+ rerender()
+ expect(screen.getByRole('button')).toHaveTextContent('Thought for a few seconds')
+ expect(screen.getByRole('button')).not.toHaveTextContent(/\d/)
+ rerender()
+ now.mockReturnValue(245_000)
+ rerender()
+ expect(screen.getByRole('button')).toHaveTextContent('Thought for 1m 5s')
+ })
+
+ it('resets the clock when block identity changes without a streaming-state transition', () => {
+ const now = vi.spyOn(Date, 'now').mockReturnValue(10_000)
+ const { rerender } = render()
+ now.mockReturnValue(30_000)
+ rerender()
+ now.mockReturnValue(33_000)
+ rerender()
+ expect(screen.getByRole('button')).toHaveTextContent('Thought for 3s')
+ })
+
+ it('never displays zero seconds for a briefly observed stream', () => {
+ vi.spyOn(Date, 'now').mockReturnValue(100_000)
+ const { rerender } = render()
+ rerender()
+ expect(screen.getByRole('button')).toHaveTextContent('Thought for 1s')
})
it('uses the existing message prose pipeline for reasoning-role messages', () => {
@@ -58,7 +97,24 @@ describe('reasoning disclosure', () => {
timestamp: 1,
blocks: [{ type: 'text', text: 'Inspecting the request\nFull reasoning' }]
}
- render()
+ const { rerender } = render(
+
+ )
+ expect(screen.getByRole('button')).toHaveTextContent('Thinking...')
+ rerender(
+
+ )
+ expect(screen.getByRole('button')).toHaveTextContent('Thought for a few seconds')
fireEvent.click(screen.getByRole('button'))
expect(screen.getByTestId('markdown')).toHaveTextContent('Full reasoning')
})
diff --git a/src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx b/src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx
index 1778305c1af..1ed9f02120e 100644
--- a/src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx
@@ -1,32 +1,81 @@
+import { useEffect, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { translate } from '@/i18n/i18n'
+import { formatNativeChatDuration } from '../../../../shared/native-chat-turn-status'
export function NativeChatReasoningRow({
markdown,
+ isStreaming = false,
+ blockId = 'default',
onLinkClick,
allowFileUriLinks
}: {
markdown: string
+ isStreaming?: boolean
+ blockId?: string
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
}): React.JSX.Element | null {
- const firstLine = markdown.trim().split('\n', 1)[0]?.trim()
- if (!firstLine) {
+ const [timing, setTiming] = useState(() => ({
+ blockId,
+ streaming: isStreaming,
+ startedAt: isStreaming ? Date.now() : null,
+ elapsedMs: null as number | null
+ }))
+ useEffect(() => {
+ const now = Date.now()
+ setTiming((previous) => {
+ if (previous.blockId !== blockId) {
+ return {
+ blockId,
+ streaming: isStreaming,
+ startedAt: isStreaming ? now : null,
+ elapsedMs: null
+ }
+ }
+ if (previous.streaming === isStreaming) {
+ return previous
+ }
+ // Only an observed stream supplies a duration; journal history has no local start.
+ return {
+ ...previous,
+ streaming: isStreaming,
+ startedAt: isStreaming ? now : previous.startedAt,
+ elapsedMs: isStreaming || previous.startedAt === null ? null : now - previous.startedAt
+ }
+ })
+ }, [blockId, isStreaming])
+ if (!markdown.trim()) {
return null
}
- const summary = firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine
const label = translate('components.native-chat.reasoning', 'Reasoning')
+ const duration = timing.blockId === blockId ? timing.elapsedMs : null
+ const headline = isStreaming
+ ? translate('components.native-chat.thinking', 'Thinking...')
+ : duration === null
+ ? translate('components.native-chat.thoughtForFewSeconds', 'Thought for a few seconds')
+ : translate('components.native-chat.thoughtForDuration', 'Thought for {{duration}}', {
+ duration: formatNativeChatDuration(Math.max(1, duration / 1000))
+ })
return (
-
+
{label}:
- {summary}
-
+
+ {headline}
+
+