feat(frontend): add ResponsePanel component with bullet-point rendering and tests
Displays bullet-point answer with source metadata cards. Handles empty, loading, error, and success states. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
fa94b7c9a3
commit
3d76b894cb
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react'
|
||||
import { MessageSquare, AlertCircle } from 'lucide-react'
|
||||
import type { SourceMetadata } from '../types'
|
||||
|
||||
interface ResponsePanelProps {
|
||||
answer: string | null
|
||||
sources: SourceMetadata[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
||||
answer,
|
||||
sources,
|
||||
isLoading,
|
||||
error,
|
||||
}) => {
|
||||
if (answer === null && !isLoading && !error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-white/50">
|
||||
<div className="text-center space-y-2">
|
||||
<MessageSquare className="mx-auto w-12 h-12 text-gray-600" />
|
||||
<div className="text-gray-700 font-semibold">Ask a question to see the answer here.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
<div
|
||||
data-testid="skeleton-line"
|
||||
className="h-4 bg-gray-200 rounded animate-pulse w-full"
|
||||
/>
|
||||
<div
|
||||
data-testid="skeleton-line"
|
||||
className="h-4 bg-gray-200 rounded animate-pulse w-4/5"
|
||||
/>
|
||||
<div
|
||||
data-testid="skeleton-line"
|
||||
className="h-4 bg-gray-200 rounded animate-pulse w-3/5"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded p-4 m-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
{answer
|
||||
?.split('\n')
|
||||
.map((line, index) => {
|
||||
const trimmedLine = line.trim()
|
||||
if (trimmedLine.startsWith('-') || trimmedLine.startsWith('•')) {
|
||||
const content = trimmedLine.replace(/^[-•]\s*/, '')
|
||||
return (
|
||||
<li key={index} className="ml-4">
|
||||
{content}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return <p key={index}>{trimmedLine}</p>
|
||||
})}
|
||||
</div>
|
||||
|
||||
{sources.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xs uppercase text-gray-500 tracking-wide mb-3">Sources</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sources.map((source, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded p-3 bg-gray-50"
|
||||
>
|
||||
<div className="font-medium text-sm">{source.filename}</div>
|
||||
<div className="text-sm text-gray-500">{source.upload_date}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">{source.content_summary}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Chunk {source.chunk_index}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ResponsePanel } from '../../components/ResponsePanel'
|
||||
import type { SourceMetadata } from '../../types'
|
||||
|
||||
describe('ResponsePanel', () => {
|
||||
const mockSources: SourceMetadata[] = [
|
||||
{
|
||||
filename: 'document1.pdf',
|
||||
upload_date: '2024-01-15',
|
||||
content_summary: 'Introduction to RAG systems',
|
||||
chunk_index: 0,
|
||||
},
|
||||
{
|
||||
filename: 'document2.txt',
|
||||
upload_date: '2024-01-16',
|
||||
content_summary: 'Advanced retrieval techniques',
|
||||
chunk_index: 1,
|
||||
},
|
||||
]
|
||||
|
||||
it('shows empty state message when no answer and not loading', () => {
|
||||
render(<ResponsePanel answer={null} sources={[]} isLoading={false} error={null} />)
|
||||
expect(screen.getByText(/Ask a question to see the answer here/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading skeletons when isLoading is true', () => {
|
||||
render(<ResponsePanel answer={null} sources={[]} isLoading={true} error={null} />)
|
||||
const skeletonElements = screen.getAllByTestId('skeleton-line')
|
||||
expect(skeletonElements).toHaveLength(3)
|
||||
expect(skeletonElements[0]).toHaveClass('w-full')
|
||||
expect(skeletonElements[1]).toHaveClass('w-4/5')
|
||||
expect(skeletonElements[2]).toHaveClass('w-3/5')
|
||||
})
|
||||
|
||||
it('shows error message when error prop is set', () => {
|
||||
render(
|
||||
<ResponsePanel
|
||||
answer={null}
|
||||
sources={[]}
|
||||
isLoading={false}
|
||||
error="Failed to fetch answer"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Failed to fetch answer/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders answer text as bullet points', () => {
|
||||
const answer = `- First point\n- Second point\n• Third point\nPlain text line`
|
||||
render(<ResponsePanel answer={answer} sources={[]} isLoading={false} error={null} />)
|
||||
expect(screen.getByText('First point')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second point')).toBeInTheDocument()
|
||||
expect(screen.getByText('Third point')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plain text line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders source metadata cards', () => {
|
||||
render(
|
||||
<ResponsePanel
|
||||
answer="Test answer"
|
||||
sources={mockSources}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('document1.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('document2.txt')).toBeInTheDocument()
|
||||
expect(screen.getByText('2024-01-15')).toBeInTheDocument()
|
||||
expect(screen.getByText('Introduction to RAG systems')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced retrieval techniques')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show sources section when sources array is empty', () => {
|
||||
render(<ResponsePanel answer="Test answer" sources={[]} isLoading={false} error={null} />)
|
||||
expect(screen.queryByText(/sources/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue