mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
4589ef67f3
commit
335082582e
@ -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;
|
||||
}
|
||||
`
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user