From c793eefee9bcbda77d2ba17840bd738ea288f253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 1 Jun 2026 17:06:49 +0800 Subject: [PATCH] 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 --- desktop/src/components/chat/ThinkingBlock.tsx | 82 +++++++++++++++---- .../src/components/chat/chatBlocks.test.tsx | 20 +++++ 2 files changed, 84 insertions(+), 18 deletions(-) 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(