From 794a063553d2e10e2ada103d3499fdb3719b3637 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, 15 Jun 2026 19:03:06 +0800 Subject: [PATCH] fix(desktop): wrap pending tool JSON input (#712) Tested: - cd desktop && bun run test src/components/chat/CodeViewer.test.tsx - cd desktop && bun run test src/components/chat/chatBlocks.test.tsx - cd desktop && bun run lint - bun run check:desktop Confidence: high Scope-risk: narrow --- .../src/components/chat/CodeViewer.test.tsx | 11 +++ desktop/src/components/chat/CodeViewer.tsx | 36 ++++++-- desktop/src/components/chat/ToolCallBlock.tsx | 82 ++++++++++++++++++- .../src/components/chat/chatBlocks.test.tsx | 26 ++++++ 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/desktop/src/components/chat/CodeViewer.test.tsx b/desktop/src/components/chat/CodeViewer.test.tsx index 41353035..f3414d0d 100644 --- a/desktop/src/components/chat/CodeViewer.test.tsx +++ b/desktop/src/components/chat/CodeViewer.test.tsx @@ -22,4 +22,15 @@ describe('CodeViewer', () => { expect(container.querySelector('[data-line-number="1"]')).toBeTruthy() expect(container.querySelector('[data-line-number="2"]')).toBeTruthy() }) + + it('can wrap long highlighted code content when requested', () => { + const { container } = render( + /tmp/index.html"}'} language="json" wrapLongLines />, + ) + + const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null + expect(contentWrapper).toBeTruthy() + expect(contentWrapper?.style.whiteSpace).toBe('pre-wrap') + expect(contentWrapper?.style.wordBreak).toBe('break-word') + }) }) diff --git a/desktop/src/components/chat/CodeViewer.tsx b/desktop/src/components/chat/CodeViewer.tsx index 8ede06e3..8b965637 100644 --- a/desktop/src/components/chat/CodeViewer.tsx +++ b/desktop/src/components/chat/CodeViewer.tsx @@ -7,6 +7,7 @@ type Props = { language?: string maxLines?: number showLineNumbers?: boolean + wrapLongLines?: boolean } const warmPrismTheme: PrismTheme = { @@ -124,7 +125,17 @@ function loadShikiRuntime(): Promise { return shikiRuntimePromise } -function PrismCodeContent({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) { +function PrismCodeContent({ + code, + language, + showLineNumbers, + wrapLongLines, +}: { + code: string + language?: string + showLineNumbers: boolean + wrapLongLines: boolean +}) { return ( @@ -168,7 +179,17 @@ function PrismCodeContent({ code, language, showLineNumbers }: { code: string; l ) } -function CodeArea({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) { +function CodeArea({ + code, + language, + showLineNumbers, + wrapLongLines, +}: { + code: string + language?: string + showLineNumbers: boolean + wrapLongLines: boolean +}) { const containerRef = useRef(null) const [runtime, setRuntime] = useState(null) const [loaded, setLoaded] = useState(false) @@ -217,6 +238,7 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language? code={code} language={language} showLineNumbers={showLineNumbers} + wrapLongLines={wrapLongLines} /> )} {ShikiHighlighter && ( @@ -247,7 +269,8 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language? fontFamily: 'var(--font-mono)', fontSize: '12px', lineHeight: String(CODE_LINE_HEIGHT), - whiteSpace: 'pre', + whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre', + wordBreak: wrapLongLines ? 'break-word' : 'normal', }} > {code} @@ -258,7 +281,7 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language? ) } -export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false }: Props) { +export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false, wrapLongLines = false }: Props) { const [expanded, setExpanded] = useState(false) const allLines = code.split('\n') @@ -289,6 +312,7 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa code={visibleCode} language={language} showLineNumbers={effectiveShowLineNumbers} + wrapLongLines={wrapLongLines} /> {/* Expand/collapse toggle */} diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 30461aea..4a320c8a 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -577,16 +577,96 @@ function renderPartialInput( partialInput: string, t?: (key: TranslationKey, params?: Record) => string, ) { + const formattedInput = formatPartialJsonInput(partialInput) + return (
{t?.('tool.partialInput') ?? 'Partial input'}
- +
) } +function formatPartialJsonInput(source: string): string { + const trimmed = source.trim() + if (!trimmed) return source + + try { + return JSON.stringify(JSON.parse(trimmed), null, 2) + } catch { + return formatJsonLikeInput(trimmed) + } +} + +function formatJsonLikeInput(source: string): string { + let output = '' + let indent = 0 + let inString = false + let escaping = false + let skipWhitespace = false + + const newline = () => { + output = output.trimEnd() + output += `\n${' '.repeat(indent)}` + skipWhitespace = true + } + + for (const char of source) { + if (inString) { + output += char + if (escaping) { + escaping = false + } else if (char === '\\') { + escaping = true + } else if (char === '"') { + inString = false + } + continue + } + + if (skipWhitespace && /\s/.test(char)) continue + skipWhitespace = false + + if (char === '"') { + inString = true + output += char + continue + } + + if (char === '{' || char === '[') { + output += char + indent += 1 + newline() + continue + } + + if (char === '}' || char === ']') { + indent = Math.max(0, indent - 1) + if (!output.endsWith('\n')) newline() + output += char + continue + } + + if (char === ',') { + output += char + newline() + continue + } + + if (char === ':') { + output += ': ' + skipWhitespace = true + continue + } + + output += char + } + + return output.trimEnd() +} + function getPendingSummary( toolName: string, t?: (key: TranslationKey, params?: Record) => string, diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 2a88f542..5e30b53b 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -143,6 +143,32 @@ describe('chat blocks', () => { expect(container.textContent).not.toContain('"content"') }) + it('formats and wraps pending Bash partial JSON input when expanded', () => { + const partialInput = [ + '{"command":"cat << \'HTMLEOF\' > /tmp/index.html\\n\\n",', + '"description":"Create HTML shell command"}', + ].join('') + const { container } = render( + /tmp/index.html', description: 'Create HTML shell command' }} + isPending + partialInput={partialInput} + />, + ) + + fireEvent.click(screen.getByRole('button')) + + expect(container.textContent).toContain('Partial input') + expect(container.textContent).toContain('json') + expect(container.textContent).toContain('4 lines') + expect(container.textContent).not.toContain('1 line') + + const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null + expect(contentWrapper?.style.whiteSpace).toBe('pre-wrap') + expect(contentWrapper?.style.wordBreak).toBe('break-word') + }) + it('shows non-windowed Writer preview stats before the 120-line limit', () => { const { container } = render(