diff --git a/desktop/src/components/chat/ThinkingBlock.tsx b/desktop/src/components/chat/ThinkingBlock.tsx index 3975525d..d43889b4 100644 --- a/desktop/src/components/chat/ThinkingBlock.tsx +++ b/desktop/src/components/chat/ThinkingBlock.tsx @@ -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(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 (
- {expanded && ( + {hasDisplayContent && (
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 && } + + {!expanded && isPreviewTruncated &&
)}
@@ -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; +} ` diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 71331e54..22985b67 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -28,6 +28,26 @@ describe('chat blocks', () => { expect(container.querySelector('.thinking-inline-cursor')).toBeNull() }) + it('renders collapsed thinking content as markdown', () => { + const { container } = render() + + 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() + + 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(