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:
Woody 2026-04-23 11:23:54 +08:00
parent fa94b7c9a3
commit 3d76b894cb
2 changed files with 175 additions and 0 deletions

View File

@ -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>
)
}

View File

@ -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()
})
})