mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
b5161a4709
commit
794a063553
@ -22,4 +22,15 @@ describe('CodeViewer', () => {
|
|||||||
expect(container.querySelector('[data-line-number="1"]')).toBeTruthy()
|
expect(container.querySelector('[data-line-number="1"]')).toBeTruthy()
|
||||||
expect(container.querySelector('[data-line-number="2"]')).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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,7 @@ type Props = {
|
|||||||
language?: string
|
language?: string
|
||||||
maxLines?: number
|
maxLines?: number
|
||||||
showLineNumbers?: boolean
|
showLineNumbers?: boolean
|
||||||
|
wrapLongLines?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const warmPrismTheme: PrismTheme = {
|
const warmPrismTheme: PrismTheme = {
|
||||||
@ -124,7 +125,17 @@ function loadShikiRuntime(): Promise<ShikiRuntime | null> {
|
|||||||
return shikiRuntimePromise
|
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 (
|
return (
|
||||||
<Highlight
|
<Highlight
|
||||||
theme={warmPrismTheme}
|
theme={warmPrismTheme}
|
||||||
@ -141,8 +152,8 @@ function PrismCodeContent({ code, language, showLineNumbers }: { code: string; l
|
|||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
lineHeight: String(CODE_LINE_HEIGHT),
|
lineHeight: String(CODE_LINE_HEIGHT),
|
||||||
whiteSpace: 'pre',
|
whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre',
|
||||||
wordBreak: 'normal',
|
wordBreak: wrapLongLines ? 'break-word' : 'normal',
|
||||||
color: 'var(--color-code-fg)',
|
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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [runtime, setRuntime] = useState<ShikiRuntime | null>(null)
|
const [runtime, setRuntime] = useState<ShikiRuntime | null>(null)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
@ -217,6 +238,7 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
|
|||||||
code={code}
|
code={code}
|
||||||
language={language}
|
language={language}
|
||||||
showLineNumbers={showLineNumbers}
|
showLineNumbers={showLineNumbers}
|
||||||
|
wrapLongLines={wrapLongLines}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{ShikiHighlighter && (
|
{ShikiHighlighter && (
|
||||||
@ -247,7 +269,8 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
|
|||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
lineHeight: String(CODE_LINE_HEIGHT),
|
lineHeight: String(CODE_LINE_HEIGHT),
|
||||||
whiteSpace: 'pre',
|
whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre',
|
||||||
|
wordBreak: wrapLongLines ? 'break-word' : 'normal',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{code}
|
{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 [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
const allLines = code.split('\n')
|
const allLines = code.split('\n')
|
||||||
@ -289,6 +312,7 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa
|
|||||||
code={visibleCode}
|
code={visibleCode}
|
||||||
language={language}
|
language={language}
|
||||||
showLineNumbers={effectiveShowLineNumbers}
|
showLineNumbers={effectiveShowLineNumbers}
|
||||||
|
wrapLongLines={wrapLongLines}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Expand/collapse toggle */}
|
{/* Expand/collapse toggle */}
|
||||||
|
|||||||
@ -577,16 +577,96 @@ function renderPartialInput(
|
|||||||
partialInput: string,
|
partialInput: string,
|
||||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||||
) {
|
) {
|
||||||
|
const formattedInput = formatPartialJsonInput(partialInput)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
|
<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)]">
|
<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'}
|
{t?.('tool.partialInput') ?? 'Partial input'}
|
||||||
</div>
|
</div>
|
||||||
<CodeViewer code={partialInput} language="json" maxLines={8} />
|
<CodeViewer code={formattedInput} language="json" maxLines={8} wrapLongLines />
|
||||||
</div>
|
</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(
|
function getPendingSummary(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||||
|
|||||||
@ -143,6 +143,32 @@ describe('chat blocks', () => {
|
|||||||
expect(container.textContent).not.toContain('"content"')
|
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', () => {
|
it('shows non-windowed Writer preview stats before the 120-line limit', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ToolCallBlock
|
<ToolCallBlock
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user