mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
eb5ba2cbdf
commit
c793eefee9
@ -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;
|
||||
}
|
||||
`
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user