feat(frontend): polish styling, spacing, and add e2e integration tests for Phase 1.3

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:53 +08:00
parent f6618fd57e
commit e927e5fc60
6 changed files with 146 additions and 11 deletions

View File

@ -32,11 +32,11 @@ const AppContent: React.FC = () => {
} }
return ( return (
<div className="h-screen grid grid-rows-[1fr_auto] grid-cols-2"> <div className="h-screen grid grid-rows-[1fr_auto] grid-cols-2 bg-gray-50">
<div className="border-r border-b border-gray-200 p-4"> <div className="border-r border-b border-gray-200 p-4 min-h-0">
<VideoPlaceholder /> <VideoPlaceholder />
</div> </div>
<div className="border-b border-gray-200 p-4 flex flex-col gap-4 overflow-y-auto"> <div className="border-b border-gray-200 p-6 flex flex-col gap-4 overflow-y-auto min-h-0">
<QueryInput onSubmit={handleQuerySubmit} isLoading={queryMutation.isPending} /> <QueryInput onSubmit={handleQuerySubmit} isLoading={queryMutation.isPending} />
<KeywordsDisplay keywords={queryMutation.data?.keywords} isLoading={queryMutation.isPending} /> <KeywordsDisplay keywords={queryMutation.data?.keywords} isLoading={queryMutation.isPending} />
<IngestPanel <IngestPanel
@ -46,7 +46,7 @@ const AppContent: React.FC = () => {
error={ingestMutation.isError ? (ingestMutation.error instanceof Error ? ingestMutation.error.message : 'Upload failed') : null} error={ingestMutation.isError ? (ingestMutation.error instanceof Error ? ingestMutation.error.message : 'Upload failed') : null}
/> />
</div> </div>
<div className="col-span-2 p-4 border-t border-gray-200 overflow-y-auto"> <div className="col-span-2 p-6 border-t border-gray-200 overflow-y-auto min-h-0">
<ResponsePanel <ResponsePanel
answer={queryMutation.data?.answer ?? null} answer={queryMutation.data?.answer ?? null}
sources={queryMutation.data?.sources ?? []} sources={queryMutation.data?.sources ?? []}

View File

@ -28,7 +28,7 @@ export const KeywordsDisplay: React.FC<KeywordsDisplayProps> = ({ keywords, isLo
} }
return ( return (
<div className="space-y-2"> <div data-testid="keywords-section" className="space-y-2 transition-all duration-300 ease-in-out">
<span className="text-xs text-gray-500 uppercase tracking-wide">Extracted Keywords:</span> <span className="text-xs text-gray-500 uppercase tracking-wide">Extracted Keywords:</span>
<div data-testid="keywords-container" className="flex flex-wrap"> <div data-testid="keywords-container" className="flex flex-wrap">
{keywords?.map((keyword, index) => ( {keywords?.map((keyword, index) => (

View File

@ -40,9 +40,9 @@ export const QueryInput: React.FC<QueryInputProps> = ({ onSubmit, isLoading }) =
<button <button
type="submit" type="submit"
disabled={isDisabled} disabled={isDisabled}
className="px-4 py-2 bg-blue-600 text-white font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600" className="px-4 py-2 bg-blue-600 text-white font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 transition-all duration-200"
> >
{isLoading ? 'Loading...' : 'Submit'} {isLoading ? 'Processing...' : 'Submit'}
</button> </button>
</form> </form>
) )

View File

@ -55,8 +55,19 @@ describe('KeywordsDisplay', () => {
const keywords = ['keyword1', 'keyword2', 'keyword3'] const keywords = ['keyword1', 'keyword2', 'keyword3']
render(<KeywordsDisplay keywords={keywords} isLoading={false} />) render(<KeywordsDisplay keywords={keywords} isLoading={false} />)
const container = screen.getByTestId('keywords-container') const container = screen.getByTestId('keywords-section')
expect(container).toHaveClass('flex', 'flex-wrap') expect(container).toBeInTheDocument()
const innerContainer = screen.getByTestId('keywords-container')
expect(innerContainer).toHaveClass('flex', 'flex-wrap')
})
it('has smooth transition classes on keywords section', () => {
const keywords = ['test']
render(<KeywordsDisplay keywords={keywords} isLoading={false} />)
const section = screen.getByTestId('keywords-section')
expect(section).toHaveClass('transition-all', 'duration-300', 'ease-in-out')
}) })
it('renders each keyword chip with mr-2 mb-2 spacing', () => { it('renders each keyword chip with mr-2 mb-2 spacing', () => {

View File

@ -39,7 +39,7 @@ describe('QueryInput', () => {
it('button is disabled when isLoading is true', () => { it('button is disabled when isLoading is true', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={true} />) render(<QueryInput onSubmit={mockOnSubmit} isLoading={true} />)
const button = screen.getByRole('button', { name: /loading/i }) const button = screen.getByRole('button', { name: /processing/i })
expect(button).toBeDisabled() expect(button).toBeDisabled()
}) })
@ -52,7 +52,7 @@ describe('QueryInput', () => {
it('button is disabled when isLoading is true even with text', () => { it('button is disabled when isLoading is true even with text', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={true} />) render(<QueryInput onSubmit={mockOnSubmit} isLoading={true} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...') const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
const button = screen.getByRole('button', { name: /loading/i }) const button = screen.getByRole('button', { name: /processing/i })
fireEvent.change(textarea, { target: { value: 'Some question' } }) fireEvent.change(textarea, { target: { value: 'Some question' } })

View File

@ -0,0 +1,124 @@
import React from 'react'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { queryClient } from '../../lib/queries'
import App from '../../App'
import type { QueryResponse, IngestResponse } from '../../types'
vi.mock('../../lib/api', () => ({
queryDocument: vi.fn(),
ingestDocument: vi.fn(),
}))
import { queryDocument, ingestDocument } from '../../lib/api'
const mockQueryDocument = vi.mocked(queryDocument)
const mockIngestDocument = vi.mocked(ingestDocument)
const mockQueryResponse: QueryResponse = {
keywords: ['legislative', 'council', 'meeting'],
answer: '- The meeting was held on January 15\n- Key topics were discussed',
sources: [
{
filename: 'minutes.pdf',
upload_date: '2024-01-15',
content_summary: 'Meeting minutes',
chunk_index: 0,
},
],
}
const mockIngestResponse: IngestResponse = {
document_id: 'doc-abc123',
chunk_count: 5,
filename: 'test-document.pdf',
}
describe('Query flow integration (App-level)', () => {
beforeEach(() => {
queryClient.clear()
vi.restoreAllMocks()
mockQueryDocument.mockResolvedValue(mockQueryResponse)
mockIngestDocument.mockResolvedValue(mockIngestResponse)
})
it('shows empty state initially', () => {
render(<App />)
expect(screen.getByText(/Video upload coming in Phase 2/i)).toBeInTheDocument()
expect(
screen.getByText(/Ask a question to see the answer here/i),
).toBeInTheDocument()
const submitButton = screen.getByRole('button', { name: /submit/i })
expect(submitButton).toBeDisabled()
})
it('full query flow: type question, submit, see keywords and answer', async () => {
mockQueryDocument.mockResolvedValue(mockQueryResponse)
render(<App />)
const textarea = screen.getByPlaceholderText(
'Ask a question about your documents...',
)
fireEvent.change(textarea, { target: { value: 'What happened at the meeting?' } })
const submitButton = screen.getByRole('button', { name: /submit/i })
await act(async () => {
fireEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('legislative')).toBeInTheDocument()
})
expect(screen.getByText('council')).toBeInTheDocument()
expect(screen.getByText('meeting')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('The meeting was held on January 15')).toBeInTheDocument()
})
expect(screen.getByText('Key topics were discussed')).toBeInTheDocument()
expect(screen.getByText('minutes.pdf')).toBeInTheDocument()
expect(screen.getByText('Meeting minutes')).toBeInTheDocument()
})
it('handles API error gracefully', async () => {
mockQueryDocument.mockRejectedValue(new Error('Server error: 500'))
render(<App />)
const textarea = screen.getByPlaceholderText(
'Ask a question about your documents...',
)
fireEvent.change(textarea, { target: { value: 'Will this fail?' } })
const submitButton = screen.getByRole('button', { name: /submit/i })
await act(async () => {
fireEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText(/Server error: 500/i)).toBeInTheDocument()
})
})
it('handles ingest flow: upload document successfully', async () => {
mockIngestDocument.mockResolvedValue(mockIngestResponse)
render(<App />)
const fileInput = screen.getByTestId('file-input')
const file = new File(['dummy content'], 'test-document.pdf', {
type: 'application/pdf',
})
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } })
})
await waitFor(() => {
expect(screen.getByText(/Uploaded test-document\.pdf/i)).toBeInTheDocument()
})
})
})