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} + +