De-emphasize reasoning headlines with observed timing labels

This commit is contained in:
Merge Sim
2026-09-08 16:54:03 -07:00
parent d09f3b8a72
commit 9bcb747bc2
4 changed files with 125 additions and 15 deletions
@@ -153,6 +153,8 @@ export const MessageRow = memo(function MessageRow({
return (
<div ref={rowRef}>
<NativeChatReasoningRow
blockId={message.id}
isStreaming={activeTurnIsWorking}
markdown={markdown}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
@@ -10,16 +10,19 @@ vi.mock('@/components/sidebar/CommentMarkdown', () => ({
default: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>
}))
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(<NativeChatReasoningRow markdown={'\n\nInspecting the request\nFull reasoning'} />)
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(<NativeChatReasoningRow markdown={'a'.repeat(200)} />)
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(
<NativeChatReasoningRow blockId="a" markdown="Inspecting" isStreaming />
)
expect(screen.getByRole('button')).toHaveTextContent('Thinking...')
expect(screen.getByText('Thinking...')).toHaveClass('animate-pulse')
now.mockReturnValue(105_000)
rerender(<NativeChatReasoningRow blockId="a" markdown="Inspecting more" isStreaming />)
now.mockReturnValue(112_000)
rerender(<NativeChatReasoningRow blockId="a" markdown="Done" isStreaming={false} />)
expect(screen.getByRole('button')).toHaveTextContent('Thought for 12s')
now.mockReturnValue(180_000)
rerender(<NativeChatReasoningRow blockId="a" markdown="Done again" />)
expect(screen.getByRole('button')).toHaveTextContent('Thought for 12s')
expect(screen.queryByTestId('markdown')).not.toBeInTheDocument()
rerender(<NativeChatReasoningRow blockId="history" markdown="Replayed" />)
expect(screen.getByRole('button')).toHaveTextContent('Thought for a few seconds')
expect(screen.getByRole('button')).not.toHaveTextContent(/\d/)
rerender(<NativeChatReasoningRow blockId="b" markdown="New block" isStreaming />)
now.mockReturnValue(245_000)
rerender(<NativeChatReasoningRow blockId="b" markdown="New block done" />)
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(<NativeChatReasoningRow blockId="a" markdown="First" isStreaming />)
now.mockReturnValue(30_000)
rerender(<NativeChatReasoningRow blockId="b" markdown="Second" isStreaming />)
now.mockReturnValue(33_000)
rerender(<NativeChatReasoningRow blockId="b" markdown="Second done" />)
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(<NativeChatReasoningRow markdown="Brief" isStreaming />)
rerender(<NativeChatReasoningRow markdown="Brief" />)
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(<MessageRow message={message} expandSignal={false} onScrollMessageToTop={vi.fn()} />)
const { rerender } = render(
<MessageRow
message={message}
activeTurnIsWorking
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
)
expect(screen.getByRole('button')).toHaveTextContent('Thinking...')
rerender(
<MessageRow
message={{ ...message, id: 'history', timestamp: 1 }}
activeTurnIsWorking={false}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
)
expect(screen.getByRole('button')).toHaveTextContent('Thought for a few seconds')
fireEvent.click(screen.getByRole('button'))
expect(screen.getByTestId('markdown')).toHaveTextContent('Full reasoning')
})
@@ -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 (
<Collapsible className="min-w-0 text-sm text-muted-foreground">
<CollapsibleTrigger className="group flex w-full min-w-0 items-center gap-1.5 rounded-sm py-1 text-left hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<CollapsibleTrigger className="group flex w-full min-w-0 items-center gap-1.5 rounded-sm py-1 text-left text-[13px] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="sr-only">{label}: </span>
<span className="min-w-0 flex-1 truncate">{summary}</span>
<ChevronRight className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-90 motion-reduce:transition-none" />
<span
className={
isStreaming
? 'min-w-0 truncate animate-pulse motion-reduce:animate-none'
: 'min-w-0 truncate'
}
>
{headline}
</span>
<ChevronRight className="ml-auto size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-90 motion-reduce:transition-none" />
</CollapsibleTrigger>
<CollapsibleContent className="pt-1 pl-4 italic">
<CommentMarkdown
+4 -1
View File
@@ -17192,7 +17192,10 @@
"conversationCommand": {
"pendingWork": "Wait for pending work and messages to finish before using this command.",
"unconfirmed": "Conversation operation was not confirmed."
}
},
"thinking": "Thinking...",
"thoughtForFewSeconds": "Thought for a few seconds",
"thoughtForDuration": "Thought for {{duration}}"
},
"tab": {
"bar": {