feat(desktop): make thinking summaries readable as markdown

Support #660 by reusing the existing desktop markdown renderer for thinking content while keeping collapsed previews bounded to the first ten source lines and a ten-line visual cap.

Constraint: Issue #660 requests markdown-rendered thinking summaries with a ten-line collapsed preview and click-to-expand full content
Rejected: Keep the old single-line plaintext preview | it loses markdown structure and hides useful context
Confidence: high
Scope-risk: narrow
Directive: Keep ThinkingBlock on MarkdownRenderer instead of duplicating markdown parsing behavior
Tested: cd desktop && bun run test -- chatBlocks.test.tsx
Tested: bun run check:desktop
Not-tested: Live desktop visual smoke with a real provider transcript
Related: #660
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 17:06:49 +08:00
parent eb5ba2cbdf
commit c793eefee9
2 changed files with 84 additions and 18 deletions

View File

@ -1,27 +1,35 @@
import { useState, useEffect, useRef } from 'react'
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) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [content, expanded, isActive])
// Preview: take first meaningful line, not first 140 chars
const lines = content.split('\n').filter((l) => l.trim())
const firstLine = lines[0]?.replace(/\s+/g, ' ').trim() || ''
const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine
}, [displayContent, expanded, isActive])
return (
<div className="mb-1">
<style>{thinkingStyles}</style>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center gap-1.5 rounded-md px-1 py-0.5 text-left text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-secondary)]"
>
<span className="text-[10px] text-[var(--color-outline)]">
@ -31,20 +39,30 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
{t('thinking.label')}
{isActive && <span className="thinking-dots" />}
</span>
{!expanded && preview && (
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{preview}
{isActive && <span className="thinking-inline-cursor" />}
</span>
)}
</button>
{expanded && (
{hasDisplayContent && (
<div
ref={contentRef}
className="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 font-[var(--font-mono)] text-[11px] leading-[1.35] text-[var(--color-text-secondary)] whitespace-pre-wrap break-words"
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'
}`}
>
{content}
{isActive && expanded && <span className="thinking-cursor" />}
<MarkdownRenderer
content={expanded ? displayContent : previewContent}
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'} />
)}
</div>
)}
</div>
@ -84,4 +102,32 @@ const thinkingStyles = `
content: '';
animation: thinking-dots 1.4s steps(1, end) infinite;
}
.thinking-markdown > :first-child,
.thinking-markdown > :first-child > :first-child {
margin-top: 0;
}
.thinking-markdown > :last-child,
.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

@ -28,6 +28,26 @@ describe('chat blocks', () => {
expect(container.querySelector('.thinking-inline-cursor')).toBeNull()
})
it('renders collapsed thinking content as markdown', () => {
const { container } = render(<ThinkingBlock content={'**important**\n\n- item one'} />)
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', () => {
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-11')
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
expect(container.textContent).toContain('line-11')
expect(container.textContent).toContain('line-12')
})
it('shows tool previews only after expanding the tool block', () => {
const { container } = render(
<ToolCallBlock