Fix ChunkBrowserQA layout: proper height constraints, remove bottom nav duplication

- Root container uses calc(100vh - 220px) for fixed viewport height
- All flex children use min-h-0 to enable proper overflow scrolling
- Removed duplicate bottom nav buttons (Zurueck/Weiter) that appeared
  in the middle of the chunk text — navigation is only in the header now
- Chunk text panel scrolls internally with fixed header
- Added prominent article/section badges in header and panel header
- Added chunk length quality indicator (warns on very short/long chunks)
- Structural metadata keys (article, section, pages) sorted first
- Sidebar shows regulation name instead of code for better readability
- PDF viewer uses pages metadata from payload when available

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-28 20:24:50 +01:00
parent d481e0087b
commit b48cd8bb46

View File

@@ -137,7 +137,6 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
let offset: string | null = null
try {
// Paginated scroll, 100 at a time
let safety = 0
do {
const params = new URLSearchParams({
@@ -157,7 +156,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
allChunks.push(...chunks)
offset = data.next_offset || null
safety++
} while (offset && safety < 200) // safety limit ~20k chunks
} while (offset && safety < 200)
// Sort by chunk_index
allChunks.sort((a, b) => {
@@ -186,14 +185,22 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
const prevChunk = docChunkIndex > 0 ? docChunks[docChunkIndex - 1] : null
const nextChunk = docChunkIndex < docChunks.length - 1 ? docChunks[docChunkIndex + 1] : null
// PDF page estimation
const estimatePdfPage = (chunkIndex: number): number => {
// PDF page estimation — use pages metadata if available
const estimatePdfPage = (chunk: Record<string, unknown> | null, chunkIdx: number): number => {
if (chunk) {
// Try pages array from payload (e.g. [7] or [7,8])
const pages = chunk.pages as number[] | undefined
if (Array.isArray(pages) && pages.length > 0) return pages[0]
// Try page field
const page = chunk.page as number | undefined
if (typeof page === 'number' && page > 0) return page
}
const mapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
const cpp = mapping?.chunksPerPage || chunksPerPage
return Math.floor(chunkIndex / cpp) + 1
return Math.floor(chunkIdx / cpp) + 1
}
const pdfPage = currentChunk ? estimatePdfPage(docChunkIndex) : 1
const pdfPage = estimatePdfPage(currentChunk, docChunkIndex)
const pdfMapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
const pdfUrl = pdfMapping ? `/rag-originals/${pdfMapping.filename}#page=${pdfPage}` : null
@@ -249,7 +256,27 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
// Get text content from a chunk
const getChunkText = (chunk: Record<string, unknown> | null): string => {
if (!chunk) return ''
return String(chunk.text || chunk.content || chunk.chunk_text || '')
return String(chunk.chunk_text || chunk.text || chunk.content || '')
}
// Extract structural metadata for prominent display
const getStructuralInfo = (chunk: Record<string, unknown> | null): { article?: string; section?: string; pages?: string } => {
if (!chunk) return {}
const result: { article?: string; section?: string; pages?: string } = {}
// Article / paragraph
const article = chunk.article || chunk.artikel || chunk.paragraph || chunk.section_title
if (article) result.article = String(article)
// Section
const section = chunk.section || chunk.chapter || chunk.abschnitt || chunk.kapitel
if (section) result.section = String(section)
// Pages
const pages = chunk.pages as number[] | undefined
if (Array.isArray(pages) && pages.length > 0) {
result.pages = pages.length === 1 ? `S. ${pages[0]}` : `S. ${pages[0]}-${pages[pages.length - 1]}`
} else if (chunk.page) {
result.pages = `S. ${chunk.page}`
}
return result
}
// Overlap extraction
@@ -287,10 +314,21 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
return reg?.name || code
}
// Important metadata keys to show prominently
const STRUCTURAL_KEYS = new Set([
'article', 'artikel', 'paragraph', 'section_title', 'section', 'chapter',
'abschnitt', 'kapitel', 'pages', 'page',
])
const HIDDEN_KEYS = new Set([
'text', 'content', 'chunk_text', 'id', 'embedding',
])
const structInfo = getStructuralInfo(currentChunk)
return (
<div className="space-y-4">
{/* Header bar */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)' }}>
{/* Header bar — fixed height */}
<div className="flex-shrink-0 bg-white rounded-xl border border-slate-200 p-3 mb-3">
<div className="flex flex-wrap items-center gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Collection</label>
@@ -309,27 +347,49 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
<>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
QA-Modus: {selectedRegulation} {getRegName(selectedRegulation)}
{selectedRegulation} {getRegName(selectedRegulation)}
</span>
{structInfo.article && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 text-xs font-medium rounded">
{structInfo.article}
</span>
)}
{structInfo.pages && (
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">
{structInfo.pages}
</span>
)}
</div>
<div className="flex items-center gap-2 ml-auto">
<span className="text-sm text-slate-600">
Chunk {docChunkIndex + 1} / {docTotalChunks}
</span>
<button
onClick={handlePrev}
disabled={docChunkIndex === 0}
className="px-3 py-1 text-sm border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30"
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
>
&#9664; Prev
&#9664; Zurueck
</button>
<span className="text-sm font-mono text-slate-600 min-w-[80px] text-center">
{docChunkIndex + 1} / {docTotalChunks}
</span>
<button
onClick={handleNext}
disabled={docChunkIndex >= docChunks.length - 1}
className="px-3 py-1 text-sm border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30"
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
>
Next &#9654;
Weiter &#9654;
</button>
<input
type="number"
min={1}
max={docTotalChunks}
value={docChunkIndex + 1}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!isNaN(v) && v >= 1 && v <= docTotalChunks) setDocChunkIndex(v - 1)
}}
className="w-16 px-2 py-1 border rounded text-xs text-center"
title="Springe zu Chunk Nr."
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500">Chunks/Seite:</label>
@@ -356,11 +416,11 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
</div>
</div>
{/* Main content: Sidebar + Content */}
<div className="flex gap-4" style={{ minHeight: '70vh' }}>
{/* Sidebar */}
<div className="w-64 flex-shrink-0 bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
<div className="p-3 border-b border-slate-100">
{/* Main content: Sidebar + Content — fills remaining height */}
<div className="flex gap-3 flex-1 min-h-0">
{/* Sidebar — scrollable */}
<div className="w-56 flex-shrink-0 bg-white rounded-xl border border-slate-200 flex flex-col min-h-0">
<div className="flex-shrink-0 p-3 border-b border-slate-100">
<input
type="text"
value={filterSearch}
@@ -369,10 +429,10 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
className="w-full px-2 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
/>
{countsLoading && (
<div className="text-xs text-slate-400 mt-1">Counts werden geladen...</div>
<div className="text-xs text-slate-400 mt-1 animate-pulse">Counts laden...</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto min-h-0">
{GROUP_ORDER.map(group => {
const items = filteredRegulations[group]
if (items.length === 0) return null
@@ -381,13 +441,13 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
<div key={group}>
<button
onClick={() => toggleGroup(group)}
className="w-full px-3 py-1.5 text-left text-xs font-semibold text-slate-500 bg-slate-50 hover:bg-slate-100 flex items-center justify-between"
className="w-full px-3 py-1.5 text-left text-xs font-semibold text-slate-500 bg-slate-50 hover:bg-slate-100 flex items-center justify-between sticky top-0 z-10"
>
<span>{GROUP_LABELS[group]}</span>
<span className="text-slate-400">{isCollapsed ? '+' : '-'}</span>
</button>
{!isCollapsed && items.map(reg => {
const count = regulationCounts[reg.code] ?? REGULATIONS_IN_RAG[reg.code]?.chunks ?? 0
const count = regulationCounts[reg.code] ?? 0
const isSelected = selectedRegulation === reg.code
return (
<button
@@ -397,8 +457,8 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
isSelected ? 'bg-teal-100 text-teal-900 font-medium' : 'text-slate-700'
}`}
>
<span className="truncate">{reg.code}</span>
<span className={`text-xs tabular-nums ${count > 0 ? 'text-slate-500' : 'text-slate-300'}`}>
<span className="truncate text-xs">{reg.name || reg.code}</span>
<span className={`text-xs tabular-nums flex-shrink-0 ml-1 ${count > 0 ? 'text-slate-500' : 'text-slate-300'}`}>
{count > 0 ? count.toLocaleString() : '—'}
</span>
</button>
@@ -410,13 +470,13 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
</div>
</div>
{/* Content area */}
{/* Content area — fills remaining width and height */}
{!selectedRegulation ? (
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
<div className="text-center text-slate-400 space-y-2">
<div className="text-4xl">&#128269;</div>
<p className="text-sm">Waehle ein Dokument in der Sidebar, um die QA-Ansicht zu starten.</p>
<p className="text-xs text-slate-300">Pfeiltasten navigieren zwischen Chunks.</p>
<p className="text-sm">Dokument in der Sidebar auswaehlen, um QA zu starten.</p>
<p className="text-xs text-slate-300">Pfeiltasten: Chunk vor/zurueck</p>
</div>
</div>
) : docLoading ? (
@@ -425,40 +485,57 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
<div className="animate-spin text-3xl">&#9881;</div>
<p className="text-sm">Chunks werden geladen...</p>
<p className="text-xs text-slate-400">
{selectedRegulation}: {REGULATIONS_IN_RAG[selectedRegulation]?.chunks || '?'} Chunks erwartet
{selectedRegulation}: {REGULATIONS_IN_RAG[selectedRegulation]?.chunks.toLocaleString() || '?'} Chunks erwartet
</p>
</div>
</div>
) : (
<div className={`flex-1 grid gap-4 ${splitViewActive ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Chunk-Text Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
<div className="px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
<div className={`flex-1 grid gap-3 min-h-0 ${splitViewActive ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Chunk-Text Panel — fixed height, internal scroll */}
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">Chunk-Text</span>
<span className="text-xs text-slate-400">
Index: {docChunkIndex} / {docTotalChunks - 1}
</span>
<div className="flex items-center gap-2">
{structInfo.article && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs font-medium rounded border border-blue-200">
{structInfo.article}
</span>
)}
{structInfo.section && (
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 text-xs rounded border border-purple-200">
{structInfo.section}
</span>
)}
<span className="text-xs text-slate-400 tabular-nums">
#{docChunkIndex} / {docTotalChunks - 1}
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto min-h-0 p-4 space-y-3">
{/* Overlap from previous chunk */}
{prevChunk && (
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
<div className="font-medium text-amber-600 mb-1">&#8593; Overlap (vorheriger Chunk #{docChunkIndex - 1})</div>
<div className="font-medium text-amber-600 mb-1">&#8593; Ende vorheriger Chunk #{docChunkIndex - 1}</div>
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapPrev()}</p>
</div>
)}
{/* Current chunk text */}
{currentChunk && (
{currentChunk ? (
<div className="text-sm text-slate-800 whitespace-pre-wrap break-words leading-relaxed border-l-2 border-teal-400 pl-3">
{getChunkText(currentChunk)}
</div>
) : (
<div className="text-sm text-slate-400 italic">Kein Chunk-Text vorhanden.</div>
)}
{/* Overlap from next chunk */}
{nextChunk && (
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
<div className="font-medium text-amber-600 mb-1">&#8595; Overlap (naechster Chunk #{docChunkIndex + 1})</div>
<div className="font-medium text-amber-600 mb-1">&#8595; Anfang naechster Chunk #{docChunkIndex + 1}</div>
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapNext()}</p>
</div>
)}
@@ -469,62 +546,62 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
<div className="text-xs font-medium text-slate-500 mb-2">Metadaten</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{Object.entries(currentChunk)
.filter(([k]) => !['text', 'content', 'chunk_text', 'id'].includes(k))
.filter(([k]) => !HIDDEN_KEYS.has(k))
.sort(([a], [b]) => {
// Structural keys first
const aStruct = STRUCTURAL_KEYS.has(a) ? 0 : 1
const bStruct = STRUCTURAL_KEYS.has(b) ? 0 : 1
return aStruct - bStruct || a.localeCompare(b)
})
.map(([k, v]) => (
<div key={k} className="flex gap-1">
<span className="font-medium text-slate-500">{k}:</span>
<span className="text-slate-700 truncate">{String(v)}</span>
<div key={k} className={`flex gap-1 ${STRUCTURAL_KEYS.has(k) ? 'col-span-2 font-medium' : ''}`}>
<span className="font-medium text-slate-500 flex-shrink-0">{k}:</span>
<span className="text-slate-700 break-all">
{Array.isArray(v) ? v.join(', ') : String(v)}
</span>
</div>
))}
</div>
{/* Chunk quality indicator */}
<div className="mt-3 pt-2 border-t border-slate-50">
<div className="text-xs text-slate-400">
Chunk-Laenge: {getChunkText(currentChunk).length} Zeichen
{getChunkText(currentChunk).length < 50 && (
<span className="ml-2 text-orange-500 font-medium">&#9888; Sehr kurz</span>
)}
{getChunkText(currentChunk).length > 2000 && (
<span className="ml-2 text-orange-500 font-medium">&#9888; Sehr lang</span>
)}
</div>
</div>
</div>
)}
</div>
{/* Bottom nav */}
<div className="px-4 py-2 border-t border-slate-100 bg-slate-50 flex items-center justify-between">
<button
onClick={handlePrev}
disabled={docChunkIndex === 0}
className="px-3 py-1 text-xs border rounded bg-white hover:bg-slate-50 disabled:opacity-30"
>
&#9664; Zurueck
</button>
<div className="flex items-center gap-3">
<input
type="number"
min={0}
max={docTotalChunks - 1}
value={docChunkIndex}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!isNaN(v) && v >= 0 && v < docTotalChunks) setDocChunkIndex(v)
}}
className="w-20 px-2 py-1 border rounded text-xs text-center"
/>
<span className="text-xs text-slate-400">/ {docTotalChunks - 1}</span>
</div>
<button
onClick={handleNext}
disabled={docChunkIndex >= docChunks.length - 1}
className="px-3 py-1 text-xs border rounded bg-white hover:bg-slate-50 disabled:opacity-30"
>
Weiter &#9654;
</button>
</div>
</div>
{/* PDF-Viewer Panel */}
{splitViewActive && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
<div className="px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">Original-PDF</span>
<span className="text-xs text-slate-400">
Seite ~{pdfPage}
{pdfMapping?.totalPages ? ` / ${pdfMapping.totalPages}` : ''}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400">
Seite ~{pdfPage}
{pdfMapping?.totalPages ? ` / ${pdfMapping.totalPages}` : ''}
</span>
{pdfUrl && (
<a
href={pdfUrl.split('#')[0]}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-teal-600 hover:text-teal-800 underline"
>
Oeffnen &#8599;
</a>
)}
</div>
</div>
<div className="flex-1 relative">
<div className="flex-1 min-h-0 relative">
{pdfUrl ? (
<iframe
key={`${selectedRegulation}-${pdfPage}`}
@@ -533,30 +610,15 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
title="Original PDF"
/>
) : (
<div className="flex items-center justify-center h-full text-slate-400 text-sm">
<div className="flex items-center justify-center h-full text-slate-400 text-sm p-4">
<div className="text-center space-y-2">
<div className="text-3xl">&#128196;</div>
<p>Kein PDF-Mapping fuer {selectedRegulation}.</p>
<p className="text-xs">Bitte rag-pdf-mapping.ts ergaenzen und PDF in ~/rag-originals/ ablegen.</p>
<p className="text-xs">rag-pdf-mapping.ts ergaenzen und PDF in ~/rag-originals/ ablegen.</p>
</div>
</div>
)}
</div>
{pdfUrl && (
<div className="px-4 py-2 border-t border-slate-100 bg-slate-50 flex items-center justify-between">
<span className="text-xs text-slate-500">
{pdfMapping?.filename}
</span>
<a
href={pdfUrl.split('#')[0]}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-teal-600 hover:text-teal-800"
>
PDF oeffnen &#8599;
</a>
</div>
)}
</div>
)}
</div>