feat: Add expandable collapsible component for improved content display
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced `ExpandableCollapsible` component to manage the visibility of lengthy content, allowing users to toggle between expanded and collapsed views.
- Updated `renderTestOutput` to utilize the new component for displaying test results, JSON outputs, and object representations, enhancing user experience by reducing clutter.
- Enhanced `Markdown` component to support fenced code blocks, improving the rendering of code snippets with language labels and better styling.

These changes improve the readability and organization of content within the application, providing users with a more interactive and manageable interface.
This commit is contained in:
Lars 2026-04-12 11:10:39 +02:00
parent 4b6e1bed11
commit 08c9cccdcc
2 changed files with 165 additions and 52 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useLayoutEffect } from 'react'
import { api } from '../utils/api'
import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react'
import PlaceholderPicker from './PlaceholderPicker'
@ -66,6 +66,53 @@ function sanitizeDebugForPanel(dbg, resultType) {
return o
}
/** Langer Inhalt: zunächst auf maxRem eingeklappt, „Mehr/Weniger anzeigen“ */
function ExpandableCollapsible({ children, contentKey, maxRem = 28 }) {
const [expanded, setExpanded] = useState(false)
const wrapRef = useRef(null)
const [showToggle, setShowToggle] = useState(false)
useEffect(() => {
setExpanded(false)
}, [contentKey])
useLayoutEffect(() => {
const el = wrapRef.current
if (!el) return
if (expanded) {
setShowToggle(true)
return
}
setShowToggle(el.scrollHeight > el.clientHeight + 2)
}, [contentKey, expanded])
return (
<div>
<div
ref={wrapRef}
style={{
maxHeight: expanded ? 'none' : `${maxRem}rem`,
overflow: expanded ? 'visible' : 'hidden'
}}
>
{children}
</div>
{showToggle && (
<div style={{ marginTop: 10, textAlign: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => setExpanded((e) => !e)}
>
{expanded ? 'Weniger anzeigen' : 'Mehr anzeigen'}
</button>
</div>
)}
</div>
)
}
function renderTestOutput(testResult) {
if (!testResult) return null
if (testResult.error) {
@ -89,7 +136,7 @@ function renderTestOutput(testResult) {
return <span style={{ color: 'var(--text3)' }}>Keine Ausgabe</span>
}
const proseBox = (content) => (
const proseBox = (content, contentKey) => (
<div
style={{
padding: 16,
@ -102,7 +149,11 @@ function renderTestOutput(testResult) {
textAlign: 'left'
}}
>
{content}
{contentKey != null ? (
<ExpandableCollapsible contentKey={contentKey}>{content}</ExpandableCollapsible>
) : (
content
)}
</div>
)
@ -123,23 +174,27 @@ function renderTestOutput(testResult) {
{key}
</div>
{typeof val === 'string' ? (
proseBox(<Markdown text={val} />)
proseBox(
<Markdown text={val} />,
`pipe-${key}-${val.length}-${val.slice(0, 120)}`
)
) : (
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
maxHeight: 360,
textAlign: 'left'
}}
>
{JSON.stringify(val, null, 2)}
</pre>
<ExpandableCollapsible contentKey={`pipe-json-${key}-${JSON.stringify(val).slice(0, 200)}`} maxRem={22}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{JSON.stringify(val, null, 2)}
</pre>
</ExpandableCollapsible>
)}
</div>
))}
@ -151,47 +206,54 @@ function renderTestOutput(testResult) {
if (fmt === 'json') {
try {
const parsed = JSON.parse(out)
const jsonStr = JSON.stringify(parsed, null, 2)
return (
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
maxHeight: 480,
textAlign: 'left'
}}
>
{JSON.stringify(parsed, null, 2)}
</pre>
<ExpandableCollapsible contentKey={`json-out-${jsonStr.length}-${jsonStr.slice(0, 80)}`} maxRem={26}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{jsonStr}
</pre>
</ExpandableCollapsible>
)
} catch {
return proseBox(<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{out}</pre>)
return proseBox(
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{out}</pre>,
`json-fail-${out.length}-${out.slice(0, 80)}`
)
}
}
return proseBox(<Markdown text={out} />)
return proseBox(<Markdown text={out} />, `text-out-${out.length}-${out.slice(0, 120)}`)
}
if (typeof out === 'object') {
const jsonStr = JSON.stringify(out, null, 2)
return (
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
maxHeight: 480,
textAlign: 'left'
}}
>
{JSON.stringify(out, null, 2)}
</pre>
<ExpandableCollapsible contentKey={`obj-out-${jsonStr.length}-${jsonStr.slice(0, 120)}`} maxRem={26}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{jsonStr}
</pre>
</ExpandableCollapsible>
)
}

View File

@ -1,5 +1,5 @@
// Lightweight Markdown renderer handles the subset used by the AI:
// ## Headings, **bold**, bullet lists, numbered lists, line breaks
// ## Headings, **bold**, bullet lists, numbered lists, fenced ``` code ```, line breaks
export default function Markdown({ text }) {
if (!text) return null
@ -7,6 +7,19 @@ export default function Markdown({ text }) {
const lines = text.split('\n')
const elements = []
let i = 0
let blockId = 0
const codeBlockStyle = {
margin: '10px 0',
padding: 12,
background: 'var(--surface2)',
borderRadius: 8,
border: '1px solid var(--border)',
overflow: 'auto',
fontSize: 12,
lineHeight: 1.5,
fontFamily: 'ui-monospace, Consolas, monospace'
}
const parseLine = (line) => {
// Parse inline **bold** and *italic*
@ -39,6 +52,44 @@ export default function Markdown({ text }) {
while (i < lines.length) {
const line = lines[i]
// Fenced code block: ``` or ```lang
const trimmedStart = line.trimStart()
if (trimmedStart.startsWith('```')) {
const lang = trimmedStart.slice(3).trim() || null
i++
const codeLines = []
while (i < lines.length) {
if (lines[i].trim().startsWith('```')) {
i++
break
}
codeLines.push(lines[i])
i++
}
const code = codeLines.join('\n')
elements.push(
<div key={`code-${blockId++}`} style={{ margin: '10px 0' }}>
{lang && (
<div
style={{
fontSize: 10,
color: 'var(--text3)',
marginBottom: 6,
fontFamily: 'ui-monospace, monospace',
letterSpacing: 0.02
}}
>
{lang}
</div>
)}
<pre style={codeBlockStyle}>
<code style={{ color: 'var(--text1)', whiteSpace: 'pre', display: 'block' }}>{code}</code>
</pre>
</div>
)
continue
}
// Skip empty lines (add spacing)
if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 8 }} />)