feat(frontend): Phase 3.6 — History page with timing bars, expandable cards, and pagination

This commit is contained in:
Woody 2026-04-26 13:19:52 +08:00
parent 475306f2b1
commit d7cf785452
13 changed files with 1392 additions and 4 deletions

View File

@ -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 ✅)
---

View File

@ -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 {
<Route path="/" element={<LTTPage />} />
<Route path="/rag-database" element={<RAGDatabasePage />} />
<Route path="/system-prompts" element={<SystemPromptsPage />} />
<Route path="/history" element={<HistoryPage />} />
</Routes>
</div>
</div>

View File

@ -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<CollapsibleSectionProps> = ({ title, children }) => {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="border border-gray-200 rounded-lg bg-white">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
{title}
</button>
{isOpen && (
<div className="px-3 pb-3">
<div className="mt-2 overflow-x-auto bg-gray-50 rounded p-3">
{children}
</div>
</div>
)}
</div>
)
}
export const HistoryCard: React.FC<HistoryCardProps> = ({
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 (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<div
className={`flex items-center gap-3 ${isExpanded ? 'p-4' : 'px-4 py-3'}`}
>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex-shrink-0 text-gray-500 hover:text-gray-700 transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{inputText}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-gray-500">{formatDate(item.created_at)}</span>
<span className="text-xs text-gray-400"></span>
<span className="text-xs text-gray-500">{formatTime(item.total_time_ms)}</span>
<span className="text-xs text-gray-400"></span>
<span className="text-xs text-gray-500">
{item.chunks_retrieved_count} {item.chunks_filtered_count} filtered
</span>
{item.profile_used && (
<>
<span className="text-xs text-gray-400"></span>
<span className="inline-flex bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
Profile {item.profile_used}
</span>
</>
)}
</div>
</div>
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
className="flex-shrink-0 flex items-center gap-1 px-2 py-1 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{isExpanded && (
<div className="px-4 pb-4 space-y-4">
{isLoadingDetail ? (
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded animate-pulse w-1/3" />
<div className="h-3 bg-gray-200 rounded animate-pulse w-full" />
<div className="h-3 bg-gray-200 rounded animate-pulse w-4/5" />
<div className="h-3 bg-gray-200 rounded animate-pulse w-3/4" />
<div className="h-3 bg-gray-200 rounded animate-pulse w-full" />
</div>
) : detail ? (
<>
<div className="space-y-1">
<TimingBar
decomposerTimeMs={detail.decomposer_time_ms}
retrieverTimeMs={detail.retriever_time_ms}
filterTimeMs={detail.filter_time_ms}
generatorTimeMs={detail.generator_time_ms}
totalTimeMs={detail.total_time_ms}
/>
</div>
{extractedQuestions.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Extracted Questions</h4>
<ol className="list-decimal list-inside space-y-1">
{extractedQuestions.map((q, idx) => (
<li key={idx} className="text-sm text-gray-700">{q}</li>
))}
</ol>
</div>
)}
{detail.decompose_prompt && (
<CollapsibleSection title="Decompose Prompt">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.decompose_prompt}</pre>
</CollapsibleSection>
)}
{detail.chunks_retrieved && (
<CollapsibleSection title="Retrieved Chunks">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.chunks_retrieved}</pre>
</CollapsibleSection>
)}
{detail.filter_prompt && (
<CollapsibleSection title="Filter Prompt">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.filter_prompt}</pre>
</CollapsibleSection>
)}
{detail.chunks_filtered && (
<CollapsibleSection title="Filtered Chunks">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.chunks_filtered}</pre>
</CollapsibleSection>
)}
{detail.generate_prompt && (
<CollapsibleSection title="Generate Prompt">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-700">{detail.generate_prompt}</pre>
</CollapsibleSection>
)}
{detail.final_answer && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Final Answer</h4>
<div className="text-sm text-gray-700 whitespace-pre-wrap">{detail.final_answer}</div>
</div>
)}
{sources.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Sources</h4>
<ul className="space-y-1">
{sources.map((source, idx) => (
<li key={idx} className="text-sm">
{source.chunk_file_path ? (
<a
href={getPdfViewerUrl(
source.chunk_file_path,
source.page_number ?? undefined,
source.filename
)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{source.filename} (chunk {source.chunk_index}
{source.page_number !== null && source.page_number !== undefined
? `, page ${source.page_number}`
: ''}
)
</a>
) : (
<span className="text-gray-700">
{source.filename} (chunk {source.chunk_index}
{source.page_number !== null && source.page_number !== undefined
? `, page ${source.page_number}`
: ''}
)
</span>
)}
</li>
))}
</ul>
</div>
)}
{detail.profile_used && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Profile used:</span>
<span className="inline-flex bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
{detail.profile_used}
</span>
</div>
)}
</>
) : (
<div className="text-sm text-gray-500 text-center py-4">
Failed to load details
</div>
)}
</div>
)}
</div>
)
}

View File

@ -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<HistoryListProps> = ({
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 (
<div className="space-y-4">
{items.length > 0 && (
<div className="flex justify-end">
<button
type="button"
onClick={handleClearAll}
disabled={isClearing}
className="px-3 py-1.5 text-sm font-medium text-red-600 border border-red-300 rounded hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{isClearing ? 'Clearing...' : 'Clear All'}
</button>
</div>
)}
<div className="space-y-3">
{items.map((item) => (
<HistoryCard
key={item.id}
item={item}
onDelete={onDelete}
isDeleting={isDeleting}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center pt-4">
<button
type="button"
onClick={onLoadMore}
disabled={isLoadingMore}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{isLoadingMore && <Loader2 className="w-4 h-4 animate-spin" />}
{isLoadingMore ? 'Loading...' : 'Load More'}
</button>
</div>
)}
</div>
)
}

View File

@ -41,6 +41,30 @@ export const NavBar: React.FC = () => {
>
System Prompts
</NavLink>
<NavLink
to="/history"
className={({ isActive }) =>
`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
</NavLink>
<NavLink
to="/history"
className={({ isActive }) =>
`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
</NavLink>
</div>
</nav>
)

View File

@ -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<TimingBarProps> = ({
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 (
<div className="space-y-1">
{segments.map((segment) => {
const widthPercent = maxTime > 0 ? (segment.timeMs / maxTime) * 100 : 0
return (
<div key={segment.label}>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">{segment.label}</span>
<span className="text-xs text-gray-500">{formatTime(segment.timeMs)}</span>
</div>
<div className="h-3 rounded-sm bg-gray-100 overflow-hidden">
<div
data-testid="timing-bar-fill"
className={`h-full rounded-sm ${segment.colorClass} transition-all duration-300`}
style={{ width: `${widthPercent}%` }}
/>
</div>
</div>
)
})}
<div className="flex items-center justify-between pt-1">
<span className="text-xs font-medium text-gray-600">Total</span>
<span className="text-xs font-medium text-gray-600">{formatTime(totalTimeMs)}</span>
</div>
</div>
)
}

View File

@ -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
const resp = await apiClient.put<PromptStatusResponse>(`/prompts/profiles/${name}/reset`, step ? { step } : {})
return resp.data
}
export const listQueryHistory = async (limit = 50, offset = 0): Promise<QueryHistoryList> => {
const resp = await apiClient.get<QueryHistoryList>(`/history?limit=${limit}&offset=${offset}`)
return resp.data
}
export const getQueryHistoryDetail = async (id: number): Promise<QueryHistoryDetail> => {
const resp = await apiClient.get<QueryHistoryDetail>(`/history/${id}`)
return resp.data
}
export const deleteQueryHistory = async (id: number): Promise<HistoryDeleteResponse> => {
const resp = await apiClient.delete<HistoryDeleteResponse>(`/history/${id}`)
return resp.data
}
export const clearQueryHistory = async (): Promise<HistoryDeleteResponse> => {
const resp = await apiClient.delete<HistoryDeleteResponse>('/history')
return resp.data
}
export const getHistoryStats = async (): Promise<HistoryStats> => {
const resp = await apiClient.get<HistoryStats>('/history/stats')
return resp.data
}

View File

@ -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<QueryHistoryList, Error>({
queryKey: ['history', { limit, offset }],
queryFn: () => listQueryHistory(limit, offset),
})
}
export const useQueryHistoryDetail = (id: number | null) => {
return useQuery<QueryHistoryDetail, Error>({
queryKey: ['history', id],
queryFn: () => getQueryHistoryDetail(id!),
enabled: id !== null,
})
}
export const useDeleteQueryHistory = () => {
const queryClient = useQueryClient()
return useMutation<HistoryDeleteResponse, Error, number>({
mutationFn: deleteQueryHistory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['history'] })
},
})
}
export const useClearQueryHistory = () => {
const queryClient = useQueryClient()
return useMutation<HistoryDeleteResponse, Error, void>({
mutationFn: clearQueryHistory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['history'] })
},
})
}
export const useHistoryStats = () => {
return useQuery<HistoryStats, Error>({
queryKey: ['history', 'stats'],
queryFn: getHistoryStats,
})
}
export const AppQueryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

View File

@ -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<QueryHistorySummary[]>([])
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 (
<div className="h-full flex flex-col bg-gray-50">
<div className="border-b border-gray-200 bg-white px-6 py-4">
<h1 className="text-2xl font-bold text-gray-900">History</h1>
</div>
<div className="flex-1 overflow-auto p-6">
<div data-testid="history-loading-skeleton" className="space-y-3">
<div className="h-20 bg-gray-200 rounded animate-pulse" />
<div className="h-20 bg-gray-200 rounded animate-pulse" />
<div className="h-20 bg-gray-200 rounded animate-pulse" />
<div className="h-20 bg-gray-200 rounded animate-pulse" />
<div className="h-20 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
)
}
if (historyError) {
return (
<div className="h-full flex flex-col bg-gray-50">
<div className="border-b border-gray-200 bg-white px-6 py-4">
<h1 className="text-2xl font-bold text-gray-900">History</h1>
</div>
<div className="flex-1 overflow-auto p-6">
<div className="bg-red-50 border border-red-200 text-red-700 rounded p-4">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
<span>Failed to load history: {historyError.message}</span>
</div>
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: ['history'] })}
className="mt-3 flex items-center gap-1.5 text-sm font-medium text-red-700 hover:text-red-800"
>
<RotateCcw className="w-3.5 h-3.5" />
Retry
</button>
</div>
</div>
</div>
)
}
const hasItems = allItems.length > 0
return (
<div className="h-full flex flex-col bg-gray-50">
<div className="border-b border-gray-200 bg-white px-6 py-4">
<h1 className="text-2xl font-bold text-gray-900">History</h1>
{statsData && hasItems && (
<div className="flex items-center gap-4 mt-2 text-sm text-gray-600">
<span>Total queries: {statsData.total_queries}</span>
<span className="text-gray-300">|</span>
<span>Avg time: {formatTime(statsData.avg_time_ms)}</span>
<span className="text-gray-300">|</span>
<span>Avg chunks: {statsData.avg_chunks_retrieved.toFixed(1)} {statsData.avg_chunks_filtered.toFixed(1)} filtered</span>
<span className="text-gray-300">|</span>
<span>Top profile: {statsData.most_used_profile ?? 'None'}</span>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-6">
{!hasItems ? (
<div className="h-full flex items-center justify-center bg-white/50 rounded-lg border border-gray-200">
<div className="text-center space-y-2">
<Clock className="mx-auto w-12 h-12 text-gray-600" />
<div className="text-gray-700 font-semibold">No queries yet</div>
<div className="text-sm text-gray-500">Submit a query from the LTT page to see it here.</div>
</div>
</div>
) : (
<HistoryList
items={allItems}
hasMore={hasMore}
onLoadMore={handleLoadMore}
isLoadingMore={isLoadingHistory && offset > 0}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
onClearAll={handleClearAll}
isClearing={clearMutation.isPending}
/>
)}
</div>
</div>
)
}

View File

@ -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<string, unknown>),
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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
}
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: '<chunk index="0">...</chunk>',
chunks_retrieved_count: 8,
filter_prompt: 'Filter relevant chunks...',
filter_time_ms: 150,
chunks_filtered: '<chunk index="0">...</chunk><chunk index="1">...</chunk>',
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={longItem} onDelete={mockOnDelete} isDeleting={false} />)
const truncated = screen.getByText(/A{100}\.\.\./)
expect(truncated.textContent!.length).toBeLessThanOrEqual(103)
})
it('shows created_at formatted date', () => {
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
expect(screen.getByText(/2024/)).toBeInTheDocument()
})
it('shows total_time_ms', () => {
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
expect(screen.getByText('3.5s')).toBeInTheDocument()
})
it('shows profile badge when profile_used is set', () => {
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
expect(screen.getByText(/profile_a/)).toBeInTheDocument()
})
it('does not show profile badge when profile_used is null', () => {
const noProfileItem = { ...mockItem, profile_used: null }
renderWithClient(<HistoryCard item={noProfileItem} onDelete={mockOnDelete} isDeleting={false} />)
expect(screen.queryByText(/Profile/)).not.toBeInTheDocument()
})
it('shows chunk count summary', () => {
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
expect(screen.getByText(/8 → 4 filtered/)).toBeInTheDocument()
})
it('shows expand button in collapsed state', () => {
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
const expandBtn = screen.getByLabelText('Expand')
expect(expandBtn).toBeInTheDocument()
})
it('clicking expand fetches detail via useQueryHistoryDetail', async () => {
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
fireEvent.click(screen.getByLabelText('Delete'))
expect(confirmSpy).toHaveBeenCalled()
expect(mockOnDelete).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('shows isDeleting state with disabled button', () => {
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={true} />)
const deleteBtn = screen.getByLabelText('Delete')
expect(deleteBtn).toBeDisabled()
})
it('has collapsible sections for prompts in expanded view', async () => {
mockGetQueryHistoryDetail.mockResolvedValueOnce(mockDetail)
renderWithClient(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={mockItem} onDelete={mockOnDelete} isDeleting={false} />)
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(<HistoryCard item={profileBItem} onDelete={mockOnDelete} isDeleting={false} />)
expect(screen.getByText(/profile_b/)).toBeInTheDocument()
})
it('shows correct profile text for profile_c', () => {
const profileCItem = { ...mockItem, profile_used: 'profile_c' }
renderWithClient(<HistoryCard item={profileCItem} onDelete={mockOnDelete} isDeleting={false} />)
expect(screen.getByText(/profile_c/)).toBeInTheDocument()
})
})

View File

@ -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<string, unknown>),
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(
<QueryClientProvider client={qc}>
<HistoryPage />
</QueryClientProvider>
)
}
const makeHistoryItem = (id: number, overrides?: Partial<QueryHistorySummary>): 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()
})
})
})

View File

@ -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(<TimingBar {...defaultProps} />)
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(<TimingBar {...defaultProps} />)
expect(screen.getByText('1.0s')).toBeInTheDocument()
})
it('renders bars with proportional widths based on time contribution', () => {
const { container } = render(<TimingBar {...defaultProps} />)
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(
<TimingBar
{...defaultProps}
decomposerTimeMs={0}
retrieverTimeMs={0}
filterTimeMs={0}
generatorTimeMs={0}
totalTimeMs={0}
/>
)
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(
<TimingBar
decomposerTimeMs={500}
retrieverTimeMs={200}
filterTimeMs={150}
generatorTimeMs={300}
totalTimeMs={1500}
/>
)
expect(screen.getByText('1.5s')).toBeInTheDocument()
})
it('formats times < 1000ms as milliseconds', () => {
render(
<TimingBar
decomposerTimeMs={100}
retrieverTimeMs={200}
filterTimeMs={150}
generatorTimeMs={300}
totalTimeMs={750}
/>
)
expect(screen.getByText('750ms')).toBeInTheDocument()
})
it('handles zero time for a single stage with bar width 0%', () => {
const { container } = render(
<TimingBar
decomposerTimeMs={0}
retrieverTimeMs={200}
filterTimeMs={150}
generatorTimeMs={300}
totalTimeMs={650}
/>
)
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(<TimingBar {...defaultProps} />)
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(<TimingBar {...defaultProps} />)
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(<TimingBar {...defaultProps} />)
expect(screen.getByText(/total/i)).toBeInTheDocument()
})
})

View File

@ -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
}