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:
parent
864b684d32
commit
f6618fd57e
|
|
@ -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,6 +94,19 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
|||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<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) => {
|
||||
|
|
@ -74,11 +122,24 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
|||
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">
|
||||
<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}
|
||||
|
|
@ -91,6 +152,7 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue