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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 19:03:06 +08:00
parent b5161a4709
commit 794a063553
4 changed files with 148 additions and 7 deletions

View File

@ -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(
<CodeViewer code={'{"command":"cat << EOF > /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')
})
})

View File

@ -7,6 +7,7 @@ type Props = {
language?: string
maxLines?: number
showLineNumbers?: boolean
wrapLongLines?: boolean
}
const warmPrismTheme: PrismTheme = {
@ -124,7 +125,17 @@ function loadShikiRuntime(): Promise<ShikiRuntime | null> {
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 (
<Highlight
theme={warmPrismTheme}
@ -141,8 +152,8 @@ function PrismCodeContent({ code, language, showLineNumbers }: { code: string; l
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: 'pre',
wordBreak: 'normal',
whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre',
wordBreak: wrapLongLines ? 'break-word' : 'normal',
color: 'var(--color-code-fg)',
}}
>
@ -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<HTMLDivElement>(null)
const [runtime, setRuntime] = useState<ShikiRuntime | null>(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 */}

View File

@ -577,16 +577,96 @@ function renderPartialInput(
partialInput: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
) {
const formattedInput = formatPartialJsonInput(partialInput)
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
{t?.('tool.partialInput') ?? 'Partial input'}
</div>
<CodeViewer code={partialInput} language="json" maxLines={8} />
<CodeViewer code={formattedInput} language="json" maxLines={8} wrapLongLines />
</div>
)
}
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, string | number>) => string,

View File

@ -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<!DOCTYPE html>\\n<html lang=\\"zh-CN\\">",',
'"description":"Create HTML shell command"}',
].join('')
const { container } = render(
<ToolCallBlock
toolName="Bash"
input={{ command: 'cat << \'HTMLEOF\' > /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(
<ToolCallBlock