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