feat(frontend): polish ResponsePanel with collapsible sources, copy button, and enhanced skeletons

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:46:40 +08:00
parent 864b684d32
commit f6618fd57e
2 changed files with 181 additions and 34 deletions

View File

@ -1,5 +1,5 @@
import React from 'react'
import { MessageSquare, AlertCircle } from 'lucide-react'
import React, { useState } from 'react'
import { MessageSquare, AlertCircle, Copy, ChevronDown, ChevronRight } from 'lucide-react'
import type { SourceMetadata } from '../types'
interface ResponsePanelProps {
@ -15,6 +15,17 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
isLoading,
error,
}) => {
const [sourcesExpanded, setSourcesExpanded] = useState<boolean>(true)
const [copied, setCopied] = useState<boolean>(false)
const handleCopyAnswer = async (): Promise<void> => {
if (answer) {
await navigator.clipboard.writeText(answer)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (answer === null && !isLoading && !error) {
return (
<div className="h-full flex items-center justify-center bg-white/50">
@ -33,6 +44,10 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
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-11/12"
/>
<div
data-testid="skeleton-line"
className="h-4 bg-gray-200 rounded animate-pulse w-4/5"
@ -41,6 +56,26 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
data-testid="skeleton-line"
className="h-4 bg-gray-200 rounded animate-pulse w-3/5"
/>
<div
data-testid="skeleton-line"
className="h-4 bg-gray-200 rounded animate-pulse w-2/5"
/>
<div className="mt-4 space-y-2">
<div
data-testid="source-skeleton"
className="border border-gray-200 rounded p-3 bg-gray-50"
>
<div className="h-3 bg-gray-200 rounded w-3/4 mb-2 animate-pulse" />
<div className="h-2 bg-gray-200 rounded w-1/2 animate-pulse" />
</div>
<div
data-testid="source-skeleton"
className="border border-gray-200 rounded p-3 bg-gray-50"
>
<div className="h-3 bg-gray-200 rounded w-2/3 mb-2 animate-pulse" />
<div className="h-2 bg-gray-200 rounded w-1/3 animate-pulse" />
</div>
</div>
</div>
)
}
@ -59,38 +94,65 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
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 className="flex items-start justify-between">
<div className="flex-1" />
<button
data-testid="copy-answer-btn"
onClick={handleCopyAnswer}
className="flex items-center space-x-1 text-gray-500 hover:text-gray-700 transition-colors duration-200"
title="Copy answer to clipboard"
>
<Copy className="w-4 h-4" />
<span className="text-sm">{copied ? 'Copied!' : 'Copy'}</span>
</button>
</div>
<div className="space-y-2 transition-all duration-300">
{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>
</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>
<button
data-testid="sources-toggle"
onClick={() => setSourcesExpanded(!sourcesExpanded)}
className="flex items-center space-x-2 text-xs uppercase text-gray-500 tracking-wide mb-3 hover:text-gray-700 transition-colors duration-200"
>
{sourcesExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<span>Sources ({sources.length})</span>
</button>
{sourcesExpanded && (
<div data-testid="sources-container" 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

@ -1,5 +1,5 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ResponsePanel } from '../../components/ResponsePanel'
import type { SourceMetadata } from '../../types'
@ -27,10 +27,18 @@ describe('ResponsePanel', () => {
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).toHaveLength(5)
expect(skeletonElements[0]).toHaveClass('w-full')
expect(skeletonElements[1]).toHaveClass('w-4/5')
expect(skeletonElements[2]).toHaveClass('w-3/5')
expect(skeletonElements[1]).toHaveClass('w-11/12')
expect(skeletonElements[2]).toHaveClass('w-4/5')
expect(skeletonElements[3]).toHaveClass('w-3/5')
expect(skeletonElements[4]).toHaveClass('w-2/5')
})
it('shows source skeleton cards during loading', () => {
render(<ResponsePanel answer={null} sources={[]} isLoading={true} error={null} />)
const sourceSkeletons = screen.getAllByTestId('source-skeleton')
expect(sourceSkeletons).toHaveLength(2)
})
it('shows error message when error prop is set', () => {
@ -74,4 +82,81 @@ describe('ResponsePanel', () => {
render(<ResponsePanel answer="Test answer" sources={[]} isLoading={false} error={null} />)
expect(screen.queryByText(/sources/i)).not.toBeInTheDocument()
})
it('has a toggle button for sources section', () => {
render(
<ResponsePanel
answer="Test answer"
sources={mockSources}
isLoading={false}
error={null}
/>
)
const toggleButton = screen.getByTestId('sources-toggle')
expect(toggleButton).toBeInTheDocument()
expect(toggleButton).toHaveTextContent('Sources (2)')
})
it('toggles sources visibility when clicking toggle button', () => {
render(
<ResponsePanel
answer="Test answer"
sources={mockSources}
isLoading={false}
error={null}
/>
)
const toggleButton = screen.getByTestId('sources-toggle')
const sourcesContainer = screen.getByTestId('sources-container')
expect(sourcesContainer).toBeInTheDocument()
expect(screen.getByText('document1.pdf')).toBeInTheDocument()
fireEvent.click(toggleButton)
expect(screen.queryByTestId('sources-container')).not.toBeInTheDocument()
expect(toggleButton).toHaveTextContent('Sources (2)')
fireEvent.click(toggleButton)
expect(screen.getByTestId('sources-container')).toBeInTheDocument()
expect(screen.getByText('document1.pdf')).toBeInTheDocument()
})
it('has a copy button when answer is shown', () => {
render(
<ResponsePanel
answer="Test answer"
sources={[]}
isLoading={false}
error={null}
/>
)
const copyButton = screen.getByTestId('copy-answer-btn')
expect(copyButton).toBeInTheDocument()
})
it('copies answer to clipboard when copy button is clicked', async () => {
const mockClipboardWrite = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, {
clipboard: {
writeText: mockClipboardWrite,
},
})
render(
<ResponsePanel
answer="Test answer to copy"
sources={[]}
isLoading={false}
error={null}
/>
)
const copyButton = screen.getByTestId('copy-answer-btn')
fireEvent.click(copyButton)
expect(mockClipboardWrite).toHaveBeenCalledWith('Test answer to copy')
await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
})