fix(desktop): keep thinking details hidden by default

Thinking markdown should remain available only after the user opens the disclosure. The prior preview mounted markdown content while collapsed, which made internal thinking occupy visible transcript space by default.

Constraint: Thinking content must stay behind the existing disclosure while preserving markdown rendering after expansion
Rejected: Ten-line collapsed markdown preview | still exposes thinking content in the normal chat flow
Confidence: high
Scope-risk: narrow
Directive: Do not render ThinkingBlock content while aria-expanded=false; keep MarkdownRenderer inside the expanded branch
Tested: cd desktop && bun run test -- chatBlocks.test.tsx
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: Live desktop visual smoke with a real provider transcript
Related: #660
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 22:08:18 +08:00
parent 4589ef67f3
commit 335082582e
2 changed files with 21 additions and 57 deletions

View File

@ -2,20 +2,12 @@ import { useState, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
const PREVIEW_LINE_LIMIT = 10
export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
const displayContent = useMemo(() => content.replace(/\r\n?/g, '\n').trimEnd(), [content])
const hasDisplayContent = displayContent.trim().length > 0
const displayLines = useMemo(() => displayContent.split('\n'), [displayContent])
const previewContent = useMemo(
() => displayLines.slice(0, PREVIEW_LINE_LIMIT).join('\n'),
[displayLines],
)
const isPreviewTruncated = displayLines.length > PREVIEW_LINE_LIMIT
useEffect(() => {
if (expanded && isActive && contentRef.current) {
@ -40,29 +32,20 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
{isActive && <span className="thinking-dots" />}
</span>
</button>
{hasDisplayContent && (
{expanded && hasDisplayContent && (
<div
ref={expanded ? contentRef : undefined}
data-thinking-content={expanded ? 'expanded' : 'collapsed'}
data-thinking-truncated={!expanded && isPreviewTruncated ? 'true' : undefined}
onClick={!expanded ? () => setExpanded(true) : undefined}
className={`relative mt-1 rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-lowest)] p-2.5 text-[11px] text-[var(--color-text-secondary)] ${
expanded
? 'max-h-[300px] overflow-y-auto'
: 'thinking-preview-clamp cursor-pointer'
}`}
ref={contentRef}
data-thinking-content="expanded"
className="relative mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-lowest)] p-2.5 text-[11px] text-[var(--color-text-secondary)]"
>
<MarkdownRenderer
content={expanded ? displayContent : previewContent}
content={displayContent}
variant="compact"
cache={!isActive}
streaming={isActive}
className="thinking-markdown text-[var(--color-text-secondary)]"
/>
{!expanded && isPreviewTruncated && <span className="thinking-preview-fade" aria-hidden="true" />}
{isActive && (
<span className={expanded ? 'thinking-cursor' : 'thinking-inline-cursor thinking-preview-active-cursor'} />
)}
{isActive && <span className="thinking-cursor" />}
</div>
)}
</div>
@ -89,15 +72,6 @@ const thinkingStyles = `
margin-left: 1px;
animation: thinking-cursor-blink 1s step-end infinite;
}
.thinking-inline-cursor {
display: inline-block;
width: 1px;
height: 0.95em;
margin-left: 3px;
vertical-align: text-bottom;
background: var(--color-text-tertiary);
animation: thinking-cursor-blink 1s step-end infinite;
}
.thinking-dots::after {
content: '';
animation: thinking-dots 1.4s steps(1, end) infinite;
@ -110,24 +84,4 @@ const thinkingStyles = `
.thinking-markdown > :last-child > :last-child {
margin-bottom: 0;
}
.thinking-preview-clamp {
max-height: calc(${PREVIEW_LINE_LIMIT} * 1.25rem + 1.25rem);
overflow: hidden;
}
.thinking-preview-fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1.75rem;
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
pointer-events: none;
background: linear-gradient(to bottom, transparent, var(--color-surface-container-lowest));
}
.thinking-preview-active-cursor {
position: absolute;
right: 0.6rem;
bottom: 0.45rem;
}
`

View File

@ -18,32 +18,42 @@ describe('chat blocks', () => {
const { container } = render(<ThinkingBlock content="this is a long internal reasoning trace" isActive />)
expect(screen.getByText(/Thinking/)).toBeTruthy()
expect(container.textContent).toContain('this is a long internal reasoning trace')
expect(container.textContent).not.toContain('this is a long internal reasoning trace')
expect(container.querySelector('.thinking-cursor')).toBeNull()
})
it('does not animate inactive historical thinking blocks', () => {
const { container } = render(<ThinkingBlock content="old reasoning" isActive={false} />)
expect(container.querySelector('.thinking-inline-cursor')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
expect(container.textContent).toContain('old reasoning')
expect(container.querySelector('.thinking-cursor')).toBeNull()
})
it('renders collapsed thinking content as markdown', () => {
it('renders thinking content as markdown only after expanding', () => {
const { container } = render(<ThinkingBlock content={'**important**\n\n- item one'} />)
expect(container.textContent).not.toContain('important')
expect(container.querySelector('strong')).toBeNull()
expect(container.querySelector('li')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
expect(container.querySelector('strong')?.textContent).toBe('important')
expect(container.querySelector('li')?.textContent).toBe('item one')
})
it('limits collapsed thinking content to ten lines until expanded', () => {
it('hides full thinking content until expanded', () => {
const content = Array.from({ length: 12 }, (_, index) => `line-${index + 1}`).join('\n')
const { container } = render(<ThinkingBlock content={content} />)
expect(container.textContent).toContain('line-10')
expect(container.textContent).not.toContain('line-1')
expect(container.textContent).not.toContain('line-11')
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
expect(container.textContent).toContain('line-1')
expect(container.textContent).toContain('line-11')
expect(container.textContent).toContain('line-12')
})