From d7cf78545292b400aa1355b4c2b893b74246aec7 Mon Sep 17 00:00:00 2001 From: Woody Date: Sun, 26 Apr 2026 13:19:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=203.6=20=E2=80=94=20His?= =?UTF-8?q?tory=20page=20with=20timing=20bars,=20expandable=20cards,=20and?= =?UTF-8?q?=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .plans/package3_enhancement_plan.md | 2 +- frontend/src/App.tsx | 2 + frontend/src/components/HistoryCard.tsx | 279 ++++++++++++++++++ frontend/src/components/HistoryList.tsx | 74 +++++ frontend/src/components/NavBar.tsx | 24 ++ frontend/src/components/TimingBar.tsx | 66 +++++ frontend/src/lib/api.ts | 27 +- frontend/src/lib/queries.tsx | 46 ++- frontend/src/pages/HistoryPage.tsx | 165 +++++++++++ .../src/test/components/HistoryCard.test.tsx | 269 +++++++++++++++++ .../src/test/components/HistoryPage.test.tsx | 266 +++++++++++++++++ .../src/test/components/TimingBar.test.tsx | 123 ++++++++ frontend/src/types/index.ts | 53 ++++ 13 files changed, 1392 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/HistoryCard.tsx create mode 100644 frontend/src/components/HistoryList.tsx create mode 100644 frontend/src/components/TimingBar.tsx create mode 100644 frontend/src/pages/HistoryPage.tsx create mode 100644 frontend/src/test/components/HistoryCard.test.tsx create mode 100644 frontend/src/test/components/HistoryPage.test.tsx create mode 100644 frontend/src/test/components/TimingBar.test.tsx diff --git a/.plans/package3_enhancement_plan.md b/.plans/package3_enhancement_plan.md index 8217112..6d74365 100644 --- a/.plans/package3_enhancement_plan.md +++ b/.plans/package3_enhancement_plan.md @@ -2,7 +2,7 @@ **Source**: User request (2026-04-25) **Scope**: System Prompt Configuration Page + Query History Page -**Status**: 🔧 In Progress (3.1 ✅, 3.2 ✅, 3.3 ✅, 3.4 ✅, 3.5 ✅, next: 3.6) +**Status**: ✅ Package 3 Complete (3.1 ✅, 3.2 ✅, 3.3 ✅, 3.4 ✅, 3.5 ✅, 3.6 ✅) --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2675053..d5da38b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { NavBar } from './components/NavBar' import { LTTPage } from './pages/LTTPage' import { RAGDatabasePage } from './pages/RAGDatabasePage' import { SystemPromptsPage } from './pages/SystemPromptsPage' +import { HistoryPage } from './pages/HistoryPage' import { PdfViewerPage } from './pages/PdfViewerPage' export default function App(): JSX.Element { @@ -23,6 +24,7 @@ export default function App(): JSX.Element { } /> } /> } /> + } /> diff --git a/frontend/src/components/HistoryCard.tsx b/frontend/src/components/HistoryCard.tsx new file mode 100644 index 0000000..512f2f7 --- /dev/null +++ b/frontend/src/components/HistoryCard.tsx @@ -0,0 +1,279 @@ +import React, { useState } from 'react' +import { ChevronDown, ChevronUp, Trash2, ChevronRight } from 'lucide-react' +import { useQueryHistoryDetail } from '../lib/queries' +import { getPdfViewerUrl } from '../lib/api' +import { TimingBar } from './TimingBar' +import type { QueryHistorySummary, SourceMetadata } from '../types' + +interface HistoryCardProps { + item: QueryHistorySummary + onDelete: (id: number) => void + isDeleting: boolean +} + +const formatDate = (dateStr: string): string => { + try { + const date = new Date(dateStr) + return date.toLocaleString() + } catch { + return dateStr + } +} + +const formatTime = (ms: number): string => { + if (ms >= 1000) { + return `${(ms / 1000).toFixed(1)}s` + } + return `${Math.round(ms)}ms` +} + +interface CollapsibleSectionProps { + title: string + children: React.ReactNode +} + +const CollapsibleSection: React.FC = ({ title, children }) => { + const [isOpen, setIsOpen] = useState(false) + + return ( +
+ + {isOpen && ( +
+
+ {children} +
+
+ )} +
+ ) +} + +export const HistoryCard: React.FC = ({ + item, + onDelete, + isDeleting, +}) => { + const [isExpanded, setIsExpanded] = useState(false) + const { data: detail, isLoading: isLoadingDetail } = useQueryHistoryDetail( + isExpanded ? item.id : null + ) + + const handleDelete = () => { + if (window.confirm('Delete this query history entry?')) { + onDelete(item.id) + } + } + + const inputText = item.input_text.length > 100 + ? `${item.input_text.slice(0, 100)}...` + : item.input_text + + let extractedQuestions: string[] = [] + if (detail?.extracted_questions) { + try { + const parsed = JSON.parse(detail.extracted_questions) + if (Array.isArray(parsed)) { + extractedQuestions = parsed + } + } catch {} + } + + let sources: SourceMetadata[] = [] + if (detail?.sources) { + try { + const parsed = JSON.parse(detail.sources) + if (Array.isArray(parsed)) { + sources = parsed + } + } catch {} + } + + return ( +
+
+ + +
+
{inputText}
+
+ {formatDate(item.created_at)} + • + {formatTime(item.total_time_ms)} + • + + {item.chunks_retrieved_count} → {item.chunks_filtered_count} filtered + + {item.profile_used && ( + <> + • + + Profile {item.profile_used} + + + )} +
+
+ + +
+ + {isExpanded && ( +
+ {isLoadingDetail ? ( +
+
+
+
+
+
+
+ ) : detail ? ( + <> +
+ +
+ + {extractedQuestions.length > 0 && ( +
+

Extracted Questions

+
    + {extractedQuestions.map((q, idx) => ( +
  1. {q}
  2. + ))} +
+
+ )} + + {detail.decompose_prompt && ( + +
{detail.decompose_prompt}
+
+ )} + + {detail.chunks_retrieved && ( + +
{detail.chunks_retrieved}
+
+ )} + + {detail.filter_prompt && ( + +
{detail.filter_prompt}
+
+ )} + + {detail.chunks_filtered && ( + +
{detail.chunks_filtered}
+
+ )} + + {detail.generate_prompt && ( + +
{detail.generate_prompt}
+
+ )} + + {detail.final_answer && ( +
+

Final Answer

+
{detail.final_answer}
+
+ )} + + {sources.length > 0 && ( +
+

Sources

+ +
+ )} + + {detail.profile_used && ( +
+ Profile used: + + {detail.profile_used} + +
+ )} + + ) : ( +
+ Failed to load details +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/HistoryList.tsx b/frontend/src/components/HistoryList.tsx new file mode 100644 index 0000000..54c3dd3 --- /dev/null +++ b/frontend/src/components/HistoryList.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { Loader2 } from 'lucide-react' +import { HistoryCard } from './HistoryCard' +import type { QueryHistorySummary } from '../types' + +interface HistoryListProps { + items: QueryHistorySummary[] + hasMore: boolean + onLoadMore: () => void + isLoadingMore: boolean + onDelete: (id: number) => void + isDeleting: boolean + onClearAll: () => void + isClearing: boolean +} + +export const HistoryList: React.FC = ({ + items, + hasMore, + onLoadMore, + isLoadingMore, + onDelete, + isDeleting, + onClearAll, + isClearing, +}) => { + const handleClearAll = () => { + if (window.confirm('Are you sure you want to clear all query history? This cannot be undone.')) { + onClearAll() + } + } + + return ( +
+ {items.length > 0 && ( +
+ +
+ )} + +
+ {items.map((item) => ( + + ))} +
+ + {hasMore && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 84977ae..da07efb 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -41,6 +41,30 @@ export const NavBar: React.FC = () => { > System Prompts + + `text-sm font-medium transition-colors ${ + isActive + ? 'text-gray-900 border-b-2 border-gray-900' + : 'text-gray-500 hover:text-gray-700 border-b-2 border-transparent' + }` + } + > + History + + + `text-sm font-medium transition-colors ${ + isActive + ? 'text-gray-900 border-b-2 border-gray-900' + : 'text-gray-500 hover:text-gray-700 border-b-2 border-transparent' + }` + } + > + History +
) diff --git a/frontend/src/components/TimingBar.tsx b/frontend/src/components/TimingBar.tsx new file mode 100644 index 0000000..e416f84 --- /dev/null +++ b/frontend/src/components/TimingBar.tsx @@ -0,0 +1,66 @@ +import React from 'react' + +interface TimingBarProps { + decomposerTimeMs: number + retrieverTimeMs: number + filterTimeMs: number + generatorTimeMs: number + totalTimeMs: number +} + +const formatTime = (ms: number): string => { + if (ms >= 1000) { + return `${(ms / 1000).toFixed(1)}s` + } + return `${Math.round(ms)}ms` +} + +interface BarSegment { + label: string + timeMs: number + colorClass: string +} + +export const TimingBar: React.FC = ({ + decomposerTimeMs, + retrieverTimeMs, + filterTimeMs, + generatorTimeMs, + totalTimeMs, +}) => { + const segments: BarSegment[] = [ + { label: 'Decompose', timeMs: decomposerTimeMs, colorClass: 'bg-blue-400' }, + { label: 'Retrieve', timeMs: retrieverTimeMs, colorClass: 'bg-green-400' }, + { label: 'Filter', timeMs: filterTimeMs, colorClass: 'bg-amber-400' }, + { label: 'Generate', timeMs: generatorTimeMs, colorClass: 'bg-purple-400' }, + ] + + const maxTime = totalTimeMs > 0 ? totalTimeMs : 1 + + return ( +
+ {segments.map((segment) => { + const widthPercent = maxTime > 0 ? (segment.timeMs / maxTime) * 100 : 0 + return ( +
+
+ {segment.label} + {formatTime(segment.timeMs)} +
+
+
+
+
+ ) + })} +
+ Total + {formatTime(totalTimeMs)} +
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index af77fcc..79b9e96 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse } from '../types' +import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types' const BASE_URL: string = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api/v1' @@ -118,3 +118,28 @@ export const resetPrompts = async (name: string, step?: string): Promise(`/prompts/profiles/${name}/reset`, step ? { step } : {}) return resp.data } + +export const listQueryHistory = async (limit = 50, offset = 0): Promise => { + const resp = await apiClient.get(`/history?limit=${limit}&offset=${offset}`) + return resp.data +} + +export const getQueryHistoryDetail = async (id: number): Promise => { + const resp = await apiClient.get(`/history/${id}`) + return resp.data +} + +export const deleteQueryHistory = async (id: number): Promise => { + const resp = await apiClient.delete(`/history/${id}`) + return resp.data +} + +export const clearQueryHistory = async (): Promise => { + const resp = await apiClient.delete('/history') + return resp.data +} + +export const getHistoryStats = async (): Promise => { + const resp = await apiClient.get('/history/stats') + return resp.data +} diff --git a/frontend/src/lib/queries.tsx b/frontend/src/lib/queries.tsx index 0ddedcb..eaed72b 100644 --- a/frontend/src/lib/queries.tsx +++ b/frontend/src/lib/queries.tsx @@ -1,7 +1,7 @@ import React from 'react' import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts } from './api' -import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse } from '../types' +import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts, listQueryHistory, getQueryHistoryDetail, deleteQueryHistory, clearQueryHistory, getHistoryStats } from './api' +import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types' import { useState, useCallback, useRef } from 'react' export const queryClient = new QueryClient() @@ -200,6 +200,48 @@ export const useResetPrompts = () => { }) } +export const useQueryHistoryList = (limit = 50, offset = 0) => { + return useQuery({ + queryKey: ['history', { limit, offset }], + queryFn: () => listQueryHistory(limit, offset), + }) +} + +export const useQueryHistoryDetail = (id: number | null) => { + return useQuery({ + queryKey: ['history', id], + queryFn: () => getQueryHistoryDetail(id!), + enabled: id !== null, + }) +} + +export const useDeleteQueryHistory = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: deleteQueryHistory, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['history'] }) + }, + }) +} + +export const useClearQueryHistory = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: clearQueryHistory, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['history'] }) + }, + }) +} + +export const useHistoryStats = () => { + return useQuery({ + queryKey: ['history', 'stats'], + queryFn: getHistoryStats, + }) +} + export const AppQueryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { return {children} } diff --git a/frontend/src/pages/HistoryPage.tsx b/frontend/src/pages/HistoryPage.tsx new file mode 100644 index 0000000..7a140f7 --- /dev/null +++ b/frontend/src/pages/HistoryPage.tsx @@ -0,0 +1,165 @@ +import React, { useState, useEffect } from 'react' +import { AlertCircle, RotateCcw, Clock } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' +import { + useQueryHistoryList, + useHistoryStats, + useDeleteQueryHistory, + useClearQueryHistory, +} from '../lib/queries' +import { HistoryList } from '../components/HistoryList' +import type { QueryHistorySummary } from '../types' + +const formatTime = (ms: number): string => { + if (ms >= 1000) { + return `${(ms / 1000).toFixed(1)}s` + } + return `${Math.round(ms)}ms` +} + +export const HistoryPage: React.FC = () => { + const [offset, setOffset] = useState(0) + const [allItems, setAllItems] = useState([]) + const limit = 20 + + const queryClient = useQueryClient() + + const { + data: historyData, + isLoading: isLoadingHistory, + error: historyError, + } = useQueryHistoryList(limit, offset) + + const { data: statsData } = useHistoryStats() + + const deleteMutation = useDeleteQueryHistory() + const clearMutation = useClearQueryHistory() + + useEffect(() => { + if (historyData?.queries) { + if (offset === 0) { + setAllItems(historyData.queries) + } else { + setAllItems((prev) => { + const existingIds = new Set(prev.map((item) => item.id)) + const newItems = historyData.queries.filter((item) => !existingIds.has(item.id)) + return [...prev, ...newItems] + }) + } + } + }, [historyData, offset]) + + const handleLoadMore = () => { + setOffset((prev) => prev + limit) + } + + const handleDelete = (id: number) => { + deleteMutation.mutate(id, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['history'] }) + }, + }) + } + + const handleClearAll = () => { + clearMutation.mutate(undefined, { + onSuccess: () => { + setOffset(0) + queryClient.invalidateQueries({ queryKey: ['history'] }) + }, + }) + } + + const hasMore = historyData + ? historyData.offset + historyData.limit < historyData.total + : false + + if (isLoadingHistory && offset === 0) { + return ( +
+
+

History

+
+
+
+
+
+
+
+
+
+
+
+ ) + } + + if (historyError) { + return ( +
+
+

History

+
+
+
+
+ + Failed to load history: {historyError.message} +
+ +
+
+
+ ) + } + + const hasItems = allItems.length > 0 + + return ( +
+
+

History

+ {statsData && hasItems && ( +
+ Total queries: {statsData.total_queries} + | + Avg time: {formatTime(statsData.avg_time_ms)} + | + Avg chunks: {statsData.avg_chunks_retrieved.toFixed(1)} → {statsData.avg_chunks_filtered.toFixed(1)} filtered + | + Top profile: {statsData.most_used_profile ?? 'None'} +
+ )} +
+ +
+ {!hasItems ? ( +
+
+ +
No queries yet
+
Submit a query from the LTT page to see it here.
+
+
+ ) : ( + 0} + onDelete={handleDelete} + isDeleting={deleteMutation.isPending} + onClearAll={handleClearAll} + isClearing={clearMutation.isPending} + /> + )} +
+
+ ) +} diff --git a/frontend/src/test/components/HistoryCard.test.tsx b/frontend/src/test/components/HistoryCard.test.tsx new file mode 100644 index 0000000..fd9a0cb --- /dev/null +++ b/frontend/src/test/components/HistoryCard.test.tsx @@ -0,0 +1,269 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { HistoryCard } from '../../components/HistoryCard' +import type { QueryHistorySummary, QueryHistoryDetail } from '../../types' + +vi.mock('../../lib/api', async () => { + const actual = await vi.importActual('../../lib/api') + return { + ...(actual as Record), + getQueryHistoryDetail: vi.fn(), + } +}) + +import { getQueryHistoryDetail } from '../../lib/api' + +const mockGetQueryHistoryDetail = vi.mocked(getQueryHistoryDetail) + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) +} + +function renderWithClient(ui: React.ReactElement) { + const qc = createQueryClient() + return render({ui}) +} + +describe('HistoryCard', () => { + const mockItem: QueryHistorySummary = { + id: 42, + input_text: 'What is the NEC4 contract about?', + total_time_ms: 3500, + chunks_retrieved_count: 8, + chunks_filtered_count: 4, + profile_used: 'profile_a', + created_at: '2024-03-15T10:30:00Z', + } + + const mockDetail: QueryHistoryDetail = { + id: 42, + input_text: 'What is the NEC4 contract about?', + extracted_questions: '["What is NEC4?","What are the contract terms?"]', + decompose_prompt: 'Break down the following question...', + decomposer_time_ms: 500, + retriever_time_ms: 200, + chunks_retrieved: '...', + chunks_retrieved_count: 8, + filter_prompt: 'Filter relevant chunks...', + filter_time_ms: 150, + chunks_filtered: '......', + chunks_filtered_count: 4, + generate_prompt: 'Answer based on context...', + generator_time_ms: 300, + total_time_ms: 1150, + final_answer: '- NEC4 is a suite of contracts\n- Key terms include...', + sources: '[{"filename":"nec4.pdf","upload_date":"2024-01-01","content_summary":"NEC4 overview","chunk_index":0,"page_number":5,"chunk_file_path":"chunk_0.pdf"}]', + profile_used: 'profile_a', + created_at: '2024-03-15T10:30:00Z', + } + + const mockOnDelete = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders collapsed card with input_text', () => { + renderWithClient() + expect(screen.getByText('What is the NEC4 contract about?')).toBeInTheDocument() + }) + + it('truncates long input_text to approximately 100 characters', () => { + const longText = 'A'.repeat(200) + const longItem = { ...mockItem, input_text: longText } + renderWithClient() + const truncated = screen.getByText(/A{100}\.\.\./) + expect(truncated.textContent!.length).toBeLessThanOrEqual(103) + }) + + it('shows created_at formatted date', () => { + renderWithClient() + expect(screen.getByText(/2024/)).toBeInTheDocument() + }) + + it('shows total_time_ms', () => { + renderWithClient() + expect(screen.getByText('3.5s')).toBeInTheDocument() + }) + + it('shows profile badge when profile_used is set', () => { + renderWithClient() + expect(screen.getByText(/profile_a/)).toBeInTheDocument() + }) + + it('does not show profile badge when profile_used is null', () => { + const noProfileItem = { ...mockItem, profile_used: null } + renderWithClient() + expect(screen.queryByText(/Profile/)).not.toBeInTheDocument() + }) + + it('shows chunk count summary', () => { + renderWithClient() + expect(screen.getByText(/8 → 4 filtered/)).toBeInTheDocument() + }) + + it('shows expand button in collapsed state', () => { + renderWithClient() + const expandBtn = screen.getByLabelText('Expand') + expect(expandBtn).toBeInTheDocument() + }) + + it('clicking expand fetches detail via useQueryHistoryDetail', async () => { + mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail) + + renderWithClient() + await act(async () => { + fireEvent.click(screen.getByLabelText('Expand')) + }) + + await waitFor(() => { + expect(mockGetQueryHistoryDetail).toHaveBeenCalledWith(42) + }) + }) + + it('expanded view shows collapse toggle button', async () => { + mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail) + + renderWithClient() + await act(async () => { + fireEvent.click(screen.getByLabelText('Expand')) + }) + + await waitFor(() => { + expect(screen.getByLabelText('Collapse')).toBeInTheDocument() + }) + }) + + it('expanded view shows TimingBar with timing data', async () => { + mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail) + + renderWithClient() + await act(async () => { + fireEvent.click(screen.getByLabelText('Expand')) + }) + + await waitFor(() => { + expect(screen.getByText('Decompose')).toBeInTheDocument() + expect(screen.getByText('Retrieve')).toBeInTheDocument() + expect(screen.getByText('Filter')).toBeInTheDocument() + expect(screen.getByText('Generate')).toBeInTheDocument() + }) + }) + + it('expanded view shows extracted questions parsed from JSON', async () => { + mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail) + + renderWithClient() + await act(async () => { + fireEvent.click(screen.getByLabelText('Expand')) + }) + + await waitFor(() => { + expect(screen.getByText('What is NEC4?')).toBeInTheDocument() + expect(screen.getByText('What are the contract terms?')).toBeInTheDocument() + }) + }) + + it('expanded view shows final answer', async () => { + mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail) + + renderWithClient() + await act(async () => { + fireEvent.click(screen.getByLabelText('Expand')) + }) + + await waitFor(() => { + expect(screen.getByText(/NEC4 is a suite of contracts/)).toBeInTheDocument() + }) + }) + + it('expanded view shows source links that are clickable', async () => { + mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail) + + renderWithClient() + await act(async () => { + fireEvent.click(screen.getByLabelText('Expand')) + }) + + await waitFor(() => { + const sourceLink = screen.getByRole('link', { name: /nec4\.pdf/i }) + expect(sourceLink).toBeInTheDocument() + expect(sourceLink).toHaveAttribute('href', expect.stringContaining('/pdf-viewer')) + expect(sourceLink).toHaveAttribute('target', '_blank') + }) + }) + + it('delete button calls onDelete with correct id after confirmation', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + renderWithClient() + + fireEvent.click(screen.getByLabelText('Delete')) + + expect(confirmSpy).toHaveBeenCalled() + expect(mockOnDelete).toHaveBeenCalledWith(42) + confirmSpy.mockRestore() + }) + + it('delete button does not call onDelete when confirmation is cancelled', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + renderWithClient() + + fireEvent.click(screen.getByLabelText('Delete')) + + expect(confirmSpy).toHaveBeenCalled() + expect(mockOnDelete).not.toHaveBeenCalled() + confirmSpy.mockRestore() + }) + + it('shows isDeleting state with disabled button', () => { + renderWithClient() + const deleteBtn = screen.getByLabelText('Delete') + expect(deleteBtn).toBeDisabled() + }) + + it('has collapsible sections for prompts in expanded view', async () => { + mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail) + + renderWithClient() + await act(async () => { + fireEvent.click(screen.getByLabelText('Expand')) + }) + + await waitFor(() => { + expect(screen.getByText('Decompose Prompt')).toBeInTheDocument() + expect(screen.getByText('Filter Prompt')).toBeInTheDocument() + expect(screen.getByText('Generate Prompt')).toBeInTheDocument() + }) + }) + + it('has collapsible sections for chunk XML in expanded view', async () => { + mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail) + + renderWithClient() + await act(async () => { + fireEvent.click(screen.getByLabelText('Expand')) + }) + + await waitFor(() => { + expect(screen.getByText('Retrieved Chunks')).toBeInTheDocument() + expect(screen.getByText('Filtered Chunks')).toBeInTheDocument() + }) + }) + + it('shows correct profile text for profile_b', () => { + const profileBItem = { ...mockItem, profile_used: 'profile_b' } + renderWithClient() + expect(screen.getByText(/profile_b/)).toBeInTheDocument() + }) + + it('shows correct profile text for profile_c', () => { + const profileCItem = { ...mockItem, profile_used: 'profile_c' } + renderWithClient() + expect(screen.getByText(/profile_c/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/components/HistoryPage.test.tsx b/frontend/src/test/components/HistoryPage.test.tsx new file mode 100644 index 0000000..16911c2 --- /dev/null +++ b/frontend/src/test/components/HistoryPage.test.tsx @@ -0,0 +1,266 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { HistoryPage } from '../../pages/HistoryPage' +import type { QueryHistorySummary, HistoryStats } from '../../types' + +vi.mock('../../lib/api', async () => { + const actual = await vi.importActual('../../lib/api') + return { + ...(actual as Record), + listQueryHistory: vi.fn(), + getHistoryStats: vi.fn(), + deleteQueryHistory: vi.fn(), + clearQueryHistory: vi.fn(), + } +}) + +import { listQueryHistory, getHistoryStats, clearQueryHistory } from '../../lib/api' + +const mockListQueryHistory = vi.mocked(listQueryHistory) +const mockGetHistoryStats = vi.mocked(getHistoryStats) +const mockClearQueryHistory = vi.mocked(clearQueryHistory) + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) +} + +function renderHistoryPage() { + const qc = createQueryClient() + return render( + + + + ) +} + +const makeHistoryItem = (id: number, overrides?: Partial): QueryHistorySummary => ({ + id, + input_text: `Query question ${id}`, + total_time_ms: 1000 + id * 100, + chunks_retrieved_count: 10, + chunks_filtered_count: 5, + profile_used: 'profile_a', + created_at: `2024-03-${String(id).padStart(2, '0')}T10:00:00Z`, + ...overrides, +}) + +describe('HistoryPage', () => { + const defaultStats: HistoryStats = { + total_queries: 2, + avg_time_ms: 1500, + avg_chunks_retrieved: 10, + avg_chunks_filtered: 5, + most_used_profile: 'profile_a', + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetHistoryStats.mockResolvedValue(defaultStats) + }) + + it('shows loading skeleton while data is loading', () => { + mockListQueryHistory.mockReturnValue(new Promise(() => {})) + mockGetHistoryStats.mockReturnValue(new Promise(() => {})) + + renderHistoryPage() + + expect(screen.getByRole('heading', { name: 'History' })).toBeInTheDocument() + }) + + it('shows error banner with retry button on API error', async () => { + mockListQueryHistory.mockRejectedValueOnce(new Error('Network error')) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByText(/Failed to load history/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument() + }) + }) + + it('retry button refetches data', async () => { + mockListQueryHistory.mockRejectedValueOnce(new Error('Network error')) + mockListQueryHistory.mockResolvedValueOnce({ + queries: [makeHistoryItem(1)], + total: 1, + limit: 20, + offset: 0, + }) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /retry/i })) + }) + + await waitFor(() => { + expect(screen.getByText(/Query question 1/)).toBeInTheDocument() + }) + }) + + it('shows empty state when list is empty', async () => { + mockListQueryHistory.mockResolvedValueOnce({ + queries: [], + total: 0, + limit: 20, + offset: 0, + }) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByText(/no queries yet/i)).toBeInTheDocument() + }) + }) + + it('shows history items when data is loaded', async () => { + const items = [makeHistoryItem(1), makeHistoryItem(2), makeHistoryItem(3)] + mockListQueryHistory.mockResolvedValueOnce({ + queries: items, + total: 3, + limit: 20, + offset: 0, + }) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByText(/Query question 1/)).toBeInTheDocument() + }) + expect(screen.getByText(/Query question 2/)).toBeInTheDocument() + expect(screen.getByText(/Query question 3/)).toBeInTheDocument() + }) + + it('shows stats bar when stats are loaded', async () => { + mockListQueryHistory.mockResolvedValueOnce({ + queries: [makeHistoryItem(1)], + total: 1, + limit: 20, + offset: 0, + }) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByText(/Total queries: 2/)).toBeInTheDocument() + expect(screen.getByText(/Avg time: 1\.5s/)).toBeInTheDocument() + expect(screen.getByText(/Top profile: profile_a/)).toBeInTheDocument() + }) + }) + + it('shows Load More button when more items are available', async () => { + const items = Array.from({ length: 5 }, (_, i) => makeHistoryItem(i + 1)) + mockListQueryHistory.mockResolvedValueOnce({ + queries: items, + total: 30, + limit: 20, + offset: 0, + }) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument() + }) + }) + + it('clicking Load More fetches additional items', async () => { + const firstBatch = Array.from({ length: 5 }, (_, i) => makeHistoryItem(i + 1)) + const secondBatch = Array.from({ length: 3 }, (_, i) => makeHistoryItem(i + 6)) + + mockListQueryHistory.mockResolvedValueOnce({ + queries: firstBatch, + total: 30, + limit: 20, + offset: 0, + }) + mockListQueryHistory.mockResolvedValueOnce({ + queries: secondBatch, + total: 30, + limit: 20, + offset: 20, + }) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /load more/i })) + }) + + await waitFor(() => { + expect(screen.getByText(/Query question 6/)).toBeInTheDocument() + }) + }) + + it('Clear All button shows confirmation and calls API', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + mockClearQueryHistory.mockResolvedValueOnce({ status: 'ok', deleted_count: 5 }) + mockListQueryHistory.mockResolvedValueOnce({ + queries: [makeHistoryItem(1)], + total: 1, + limit: 20, + offset: 0, + }) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /clear all/i })) + }) + + expect(confirmSpy).toHaveBeenCalled() + expect(mockClearQueryHistory).toHaveBeenCalled() + confirmSpy.mockRestore() + }) + + it('Clear All does not proceed when confirmation is cancelled', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + mockListQueryHistory.mockResolvedValueOnce({ + queries: [makeHistoryItem(1)], + total: 1, + limit: 20, + offset: 0, + }) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /clear all/i })) + }) + + expect(confirmSpy).toHaveBeenCalled() + expect(mockClearQueryHistory).not.toHaveBeenCalled() + confirmSpy.mockRestore() + }) + + it('page title History is always visible even in error state', async () => { + mockListQueryHistory.mockRejectedValueOnce(new Error('Network error')) + + renderHistoryPage() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /history/i })).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/test/components/TimingBar.test.tsx b/frontend/src/test/components/TimingBar.test.tsx new file mode 100644 index 0000000..3800b13 --- /dev/null +++ b/frontend/src/test/components/TimingBar.test.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { TimingBar } from '../../components/TimingBar' + +describe('TimingBar', () => { + const defaultProps = { + decomposerTimeMs: 500, + retrieverTimeMs: 200, + filterTimeMs: 150, + generatorTimeMs: 300, + totalTimeMs: 1000, + } + + it('renders all 4 stage labels', () => { + render() + expect(screen.getByText('Decompose')).toBeInTheDocument() + expect(screen.getByText('Retrieve')).toBeInTheDocument() + expect(screen.getByText('Filter')).toBeInTheDocument() + expect(screen.getByText('Generate')).toBeInTheDocument() + }) + + it('renders total time', () => { + render() + expect(screen.getByText('1.0s')).toBeInTheDocument() + }) + + it('renders bars with proportional widths based on time contribution', () => { + const { container } = render() + const bars = container.querySelectorAll('[data-testid="timing-bar-fill"]') + expect(bars).toHaveLength(4) + + // decomposer=500/1000=50%, retriever=200/1000=20%, filter=150/1000=15%, generator=300/1000=30% + expect(bars[0]).toHaveStyle({ width: '50%' }) + expect(bars[1]).toHaveStyle({ width: '20%' }) + expect(bars[2]).toHaveStyle({ width: '15%' }) + expect(bars[3]).toHaveStyle({ width: '30%' }) + }) + + it('handles zero total time without division by zero', () => { + const { container } = render( + + ) + const bars = container.querySelectorAll('[data-testid="timing-bar-fill"]') + expect(bars).toHaveLength(4) + bars.forEach((bar) => { + expect(bar).toHaveStyle({ width: '0%' }) + }) + }) + + it('formats times >= 1000ms as seconds with 1 decimal', () => { + render( + + ) + expect(screen.getByText('1.5s')).toBeInTheDocument() + }) + + it('formats times < 1000ms as milliseconds', () => { + render( + + ) + expect(screen.getByText('750ms')).toBeInTheDocument() + }) + + it('handles zero time for a single stage with bar width 0%', () => { + const { container } = render( + + ) + const bars = container.querySelectorAll('[data-testid="timing-bar-fill"]') + expect(bars[0]).toHaveStyle({ width: '0%' }) + }) + + it('uses correct Tailwind color classes for each stage', () => { + const { container } = render() + const bars = container.querySelectorAll('[data-testid="timing-bar-fill"]') + + // Decompose = blue-400 + expect(bars[0]).toHaveClass('bg-blue-400') + // Retrieve = green-400 + expect(bars[1]).toHaveClass('bg-green-400') + // Filter = amber-400 + expect(bars[2]).toHaveClass('bg-amber-400') + // Generate = purple-400 + expect(bars[3]).toHaveClass('bg-purple-400') + }) + + it('renders individual stage times alongside labels', () => { + render() + expect(screen.getByText('500ms')).toBeInTheDocument() + expect(screen.getByText('200ms')).toBeInTheDocument() + expect(screen.getByText('150ms')).toBeInTheDocument() + expect(screen.getByText('300ms')).toBeInTheDocument() + }) + + it('renders a "Total" label alongside the total time', () => { + render() + expect(screen.getByText(/total/i)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8b429c8..c0005e0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -90,3 +90,56 @@ export interface PromptStatusResponse { step?: string reset_step?: string } + +export interface QueryHistorySummary { + id: number + input_text: string + total_time_ms: number + chunks_retrieved_count: number + chunks_filtered_count: number + profile_used: string | null + created_at: string +} + +export interface QueryHistoryList { + queries: QueryHistorySummary[] + total: number + limit: number + offset: number +} + +export interface QueryHistoryDetail { + id: number + input_text: string + extracted_questions: string | null + decompose_prompt: string | null + decomposer_time_ms: number + retriever_time_ms: number + chunks_retrieved: string | null + chunks_retrieved_count: number + filter_prompt: string | null + filter_time_ms: number + chunks_filtered: string | null + chunks_filtered_count: number + generate_prompt: string | null + generator_time_ms: number + total_time_ms: number + final_answer: string | null + sources: string | null + profile_used: string | null + created_at: string +} + +export interface HistoryStats { + total_queries: number + avg_time_ms: number + avg_chunks_retrieved: number + avg_chunks_filtered: number + most_used_profile: string | null +} + +export interface HistoryDeleteResponse { + status: string + deleted_id?: number + deleted_count?: number +}