diff --git a/frontend/src/components/ResponsePanel.tsx b/frontend/src/components/ResponsePanel.tsx index 224743e..665b388 100644 --- a/frontend/src/components/ResponsePanel.tsx +++ b/frontend/src/components/ResponsePanel.tsx @@ -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 = ({ isLoading, error, }) => { + const [sourcesExpanded, setSourcesExpanded] = useState(true) + const [copied, setCopied] = useState(false) + + const handleCopyAnswer = async (): Promise => { + if (answer) { + await navigator.clipboard.writeText(answer) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + if (answer === null && !isLoading && !error) { return (
@@ -33,6 +44,10 @@ export const ResponsePanel: React.FC = ({ data-testid="skeleton-line" className="h-4 bg-gray-200 rounded animate-pulse w-full" /> +
= ({ data-testid="skeleton-line" className="h-4 bg-gray-200 rounded animate-pulse w-3/5" /> +
+
+
+
+
+
+
+
+
+
+
) } @@ -59,38 +94,65 @@ export const ResponsePanel: React.FC = ({ return (
- {answer - ?.split('\n') - .map((line, index) => { - const trimmedLine = line.trim() - if (trimmedLine.startsWith('-') || trimmedLine.startsWith('•')) { - const content = trimmedLine.replace(/^[-•]\s*/, '') - return ( -
  • - {content} -
  • - ) - } - return

    {trimmedLine}

    - })} +
    +
    + +
    +
    + {answer + ?.split('\n') + .map((line, index) => { + const trimmedLine = line.trim() + if (trimmedLine.startsWith('-') || trimmedLine.startsWith('•')) { + const content = trimmedLine.replace(/^[-•]\s*/, '') + return ( +
  • + {content} +
  • + ) + } + return

    {trimmedLine}

    + })} +
    {sources.length > 0 && (
    -

    Sources

    -
    - {sources.map((source, index) => ( -
    -
    {source.filename}
    -
    {source.upload_date}
    -
    {source.content_summary}
    -
    Chunk {source.chunk_index}
    -
    - ))} -
    + + {sourcesExpanded && ( +
    + {sources.map((source, index) => ( +
    +
    {source.filename}
    +
    {source.upload_date}
    +
    {source.content_summary}
    +
    Chunk {source.chunk_index}
    +
    + ))} +
    + )}
    )}
    diff --git a/frontend/src/test/components/ResponsePanel.test.tsx b/frontend/src/test/components/ResponsePanel.test.tsx index 4fa167b..8f4fda2 100644 --- a/frontend/src/test/components/ResponsePanel.test.tsx +++ b/frontend/src/test/components/ResponsePanel.test.tsx @@ -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() 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() + 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() expect(screen.queryByText(/sources/i)).not.toBeInTheDocument() }) + + it('has a toggle button for sources section', () => { + render( + + ) + const toggleButton = screen.getByTestId('sources-toggle') + expect(toggleButton).toBeInTheDocument() + expect(toggleButton).toHaveTextContent('Sources (2)') + }) + + it('toggles sources visibility when clicking toggle button', () => { + render( + + ) + 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( + + ) + 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( + + ) + + const copyButton = screen.getByTestId('copy-answer-btn') + fireEvent.click(copyButton) + + expect(mockClipboardWrite).toHaveBeenCalledWith('Test answer to copy') + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument() + }) + }) })