diff --git a/.plans/package3_enhancement_plan.md b/.plans/package3_enhancement_plan.md
index 58d948d..943a102 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 in progress)
+**Status**: 🔧 In Progress (3.1 ✅, 3.2 ✅, 3.3 ✅, next: 3.4)
---
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index bce1186..2675053 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import { NavBar } from './components/NavBar'
import { LTTPage } from './pages/LTTPage'
import { RAGDatabasePage } from './pages/RAGDatabasePage'
+import { SystemPromptsPage } from './pages/SystemPromptsPage'
import { PdfViewerPage } from './pages/PdfViewerPage'
export default function App(): JSX.Element {
@@ -21,6 +22,7 @@ export default function App(): JSX.Element {
} />
} />
+ } />
diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx
index acabc5e..84977ae 100644
--- a/frontend/src/components/NavBar.tsx
+++ b/frontend/src/components/NavBar.tsx
@@ -29,6 +29,18 @@ export const NavBar: React.FC = () => {
>
RAG Database
+
+ `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'
+ }`
+ }
+ >
+ System Prompts
+
)
diff --git a/frontend/src/components/PlaceholderDocs.tsx b/frontend/src/components/PlaceholderDocs.tsx
new file mode 100644
index 0000000..e51b28f
--- /dev/null
+++ b/frontend/src/components/PlaceholderDocs.tsx
@@ -0,0 +1,53 @@
+import React, { useState } from 'react'
+import { Info, ChevronDown, ChevronUp } from 'lucide-react'
+
+interface PlaceholderRow {
+ placeholder: string
+ description: string
+}
+
+const PLACEHOLDERS: PlaceholderRow[] = [
+ { placeholder: '{question}', description: 'The user\'s input question' },
+ { placeholder: '{chunks}', description: 'Retrieved document chunks (filter step only)' },
+ { placeholder: '{context}', description: 'Formatted chunks with citations (generate step only)' },
+]
+
+export const PlaceholderDocs: React.FC = () => {
+ const [isOpen, setIsOpen] = useState(true)
+
+ return (
+
+
+
+ {isOpen && (
+
+
+ Use these placeholders in your prompt templates. They will be replaced with actual values at runtime.
+
+
+ {PLACEHOLDERS.map((row) => (
+
+
+ {row.placeholder}
+
+ {row.description}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ProfileList.tsx b/frontend/src/components/ProfileList.tsx
new file mode 100644
index 0000000..ffd0ade
--- /dev/null
+++ b/frontend/src/components/ProfileList.tsx
@@ -0,0 +1,63 @@
+import React from 'react'
+import { Pencil } from 'lucide-react'
+import type { PromptProfile } from '../types'
+
+export interface ProfileListProps {
+ profiles: PromptProfile[]
+ selectedProfile: string | null
+ onSelect: (name: string) => void
+}
+
+export const ProfileList: React.FC = ({ profiles, selectedProfile, onSelect }) => {
+ return (
+
+ {profiles.map((profile) => {
+ const isActive = profile.is_active
+ const isSelected = selectedProfile === profile.name
+
+ return (
+
+
+
+
+ Profile {profile.name}
+
+ {isActive && (
+
+ Active
+
+ )}
+
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/PromptEditor.tsx b/frontend/src/components/PromptEditor.tsx
new file mode 100644
index 0000000..0dfc702
--- /dev/null
+++ b/frontend/src/components/PromptEditor.tsx
@@ -0,0 +1,165 @@
+import React, { useState, useEffect, useCallback } from 'react'
+import { RotateCcw, AlertTriangle } from 'lucide-react'
+
+export interface PromptEditorProps {
+ profileName: string
+ prompts: Record
+ isSaving: boolean
+ onUpdate: (step: string, template: string) => void
+ onSave: () => void
+ onResetStep: (step: string) => void
+ onResetAll: () => void
+ onCancel: () => void
+}
+
+const STEPS = [
+ { key: 'decompose', label: 'Step 1: Query Decomposition', placeholders: ['question'] },
+ { key: 'filter', label: 'Step 2: Relevance Filtering', placeholders: ['question', 'chunks'] },
+ { key: 'generate', label: 'Step 3: Response Generation', placeholders: ['question', 'context'] },
+] as const
+
+const VALID_PLACEHOLDERS: Record = {
+ decompose: ['{question}'],
+ filter: ['{question}', '{chunks}'],
+ generate: ['{question}', '{context}'],
+}
+
+const findUnknownPlaceholders = (template: string, stepKey: string): string[] => {
+ const valid = VALID_PLACEHOLDERS[stepKey] ?? []
+ const found = template.match(/\{[a-zA-Z_]+\}/g) ?? []
+ return found.filter((p) => !valid.includes(p))
+}
+
+export const PromptEditor: React.FC = ({
+ profileName,
+ prompts,
+ isSaving,
+ onUpdate,
+ onSave,
+ onResetStep,
+ onResetAll,
+ onCancel,
+}) => {
+ const [localPrompts, setLocalPrompts] = useState>({})
+
+ useEffect(() => {
+ setLocalPrompts({ ...prompts })
+ }, [prompts])
+
+ const handleChange = (stepKey: string, value: string) => {
+ setLocalPrompts((prev) => ({ ...prev, [stepKey]: value }))
+ onUpdate(stepKey, value)
+ }
+
+ const handleResetAll = () => {
+ if (window.confirm('Reset all prompts to defaults? This cannot be undone.')) {
+ onResetAll()
+ }
+ }
+
+ const handleResetStep = (stepKey: string) => {
+ if (window.confirm(`Reset "${STEPS.find((s) => s.key === stepKey)?.label}" to default?`)) {
+ onResetStep(stepKey)
+ }
+ }
+
+ const isDirty = useCallback(() => {
+ return STEPS.some((step) => localPrompts[step.key] !== prompts[step.key])
+ }, [localPrompts, prompts])
+
+ return (
+
+
+ Editing Profile
+ {profileName}
+
+
+ {STEPS.map((step) => {
+ const value = localPrompts[step.key] ?? ''
+ const unknownPlaceholders = findUnknownPlaceholders(value, step.key)
+ const placeholderBadges = step.placeholders.map((p) => `{${p}}`).join(', ')
+
+ return (
+
+
+
+
+
+
+
+
+
+ Placeholders:
+ {step.placeholders.map((p) => (
+
+ {'{'}{p}{'}'}
+
+ ))}
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 554241c..af77fcc 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 } from '../types'
+import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse } from '../types'
const BASE_URL: string = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api/v1'
@@ -88,3 +88,33 @@ export const getPdfViewerUrl = (filePath: string, page?: number, title?: string)
if (title) params.set('title', title)
return `/pdf-viewer?${params.toString()}`
}
+
+export const listPromptProfiles = async (): Promise => {
+ const resp = await apiClient.get('/prompts/profiles')
+ return resp.data
+}
+
+export const getPromptProfile = async (name: string): Promise => {
+ const resp = await apiClient.get(`/prompts/profiles/${name}`)
+ return resp.data
+}
+
+export const activatePromptProfile = async (name: string): Promise => {
+ const resp = await apiClient.put(`/prompts/profiles/${name}/activate`)
+ return resp.data
+}
+
+export const updatePrompt = async (name: string, step: string, request: PromptUpdateRequest): Promise => {
+ const resp = await apiClient.put(`/prompts/profiles/${name}/${step}`, request)
+ return resp.data
+}
+
+export const updateAllPrompts = async (name: string, request: PromptBatchUpdateRequest): Promise => {
+ const resp = await apiClient.put(`/prompts/profiles/${name}/all`, request)
+ return resp.data
+}
+
+export const resetPrompts = async (name: string, step?: string): Promise => {
+ const resp = await apiClient.put(`/prompts/profiles/${name}/reset`, step ? { step } : {})
+ return resp.data
+}
diff --git a/frontend/src/lib/queries.tsx b/frontend/src/lib/queries.tsx
index 0165279..0ddedcb 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 } from './api'
-import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse } from '../types'
+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 { useState, useCallback, useRef } from 'react'
export const queryClient = new QueryClient()
@@ -145,6 +145,61 @@ export const useDeleteChunk = () => {
})
}
+export const usePromptProfiles = () => {
+ return useQuery({
+ queryKey: ['prompts', 'profiles'],
+ queryFn: listPromptProfiles,
+ })
+}
+
+export const usePromptProfile = (name: string | null) => {
+ return useQuery({
+ queryKey: ['prompts', 'profiles', name],
+ queryFn: () => getPromptProfile(name!),
+ enabled: name !== null,
+ })
+}
+
+export const useActivateProfile = () => {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: activatePromptProfile,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['prompts'] })
+ },
+ })
+}
+
+export const useUpdatePrompt = () => {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ name, step, request }) => updatePrompt(name, step, request),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['prompts'] })
+ },
+ })
+}
+
+export const useUpdateAllPrompts = () => {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ name, request }) => updateAllPrompts(name, request),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['prompts'] })
+ },
+ })
+}
+
+export const useResetPrompts = () => {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ name, step }) => resetPrompts(name, step),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['prompts'] })
+ },
+ })
+}
+
export const AppQueryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return {children}
}
diff --git a/frontend/src/pages/SystemPromptsPage.tsx b/frontend/src/pages/SystemPromptsPage.tsx
new file mode 100644
index 0000000..5381b96
--- /dev/null
+++ b/frontend/src/pages/SystemPromptsPage.tsx
@@ -0,0 +1,237 @@
+import React, { useState } from 'react'
+import { AlertCircle, RotateCcw } from 'lucide-react'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ usePromptProfiles,
+ usePromptProfile,
+ useActivateProfile,
+ useUpdateAllPrompts,
+ useResetPrompts,
+} from '../lib/queries'
+import { ProfileList } from '../components/ProfileList'
+import { PromptEditor } from '../components/PromptEditor'
+import { PlaceholderDocs } from '../components/PlaceholderDocs'
+import type { PromptProfile } from '../types'
+
+export const SystemPromptsPage: React.FC = () => {
+ const [selectedProfile, setSelectedProfile] = useState(null)
+ const [activeDropdown, setActiveDropdown] = useState('')
+ const [editPrompts, setEditPrompts] = useState>({})
+ const [hasChanges, setHasChanges] = useState(false)
+
+ const queryClient = useQueryClient()
+
+ const {
+ data: profilesData,
+ isLoading: isLoadingProfiles,
+ error: profilesError,
+ } = usePromptProfiles()
+
+ const {
+ data: profileData,
+ isLoading: isLoadingProfile,
+ } = usePromptProfile(selectedProfile)
+
+ const activateMutation = useActivateProfile()
+ const updateAllMutation = useUpdateAllPrompts()
+ const resetMutation = useResetPrompts()
+
+ const profiles: PromptProfile[] = profilesData?.profiles ?? []
+ const activeProfile = profiles.find((p) => p.is_active)?.name ?? ''
+
+ React.useEffect(() => {
+ if (activeProfile && !activeDropdown) {
+ setActiveDropdown(activeProfile)
+ }
+ }, [activeProfile, activeDropdown])
+
+ React.useEffect(() => {
+ if (profileData?.prompts) {
+ setEditPrompts(profileData.prompts)
+ setHasChanges(false)
+ }
+ }, [profileData])
+
+ const handleSelectProfile = (name: string) => {
+ if (selectedProfile === name) {
+ setSelectedProfile(null)
+ setEditPrompts({})
+ setHasChanges(false)
+ } else {
+ setSelectedProfile(name)
+ }
+ }
+
+ const handleSetActive = () => {
+ if (!activeDropdown || activeDropdown === activeProfile) return
+ activateMutation.mutate(activeDropdown, {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['prompts'] })
+ },
+ })
+ }
+
+ const handleUpdate = (step: string, template: string) => {
+ setEditPrompts((prev) => ({ ...prev, [step]: template }))
+ setHasChanges(true)
+ }
+
+ const handleSave = () => {
+ if (!selectedProfile || !hasChanges) return
+ updateAllMutation.mutate(
+ { name: selectedProfile, request: { prompts: editPrompts } },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['prompts'] })
+ setHasChanges(false)
+ },
+ }
+ )
+ }
+
+ const handleResetStep = (step: string) => {
+ if (!selectedProfile) return
+ resetMutation.mutate(
+ { name: selectedProfile, step },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['prompts', 'profiles', selectedProfile] })
+ },
+ }
+ )
+ }
+
+ const handleResetAll = () => {
+ if (!selectedProfile) return
+ resetMutation.mutate(
+ { name: selectedProfile },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['prompts', 'profiles', selectedProfile] })
+ },
+ }
+ )
+ }
+
+ const handleCancel = () => {
+ setSelectedProfile(null)
+ setEditPrompts({})
+ setHasChanges(false)
+ }
+
+ const isSaving = updateAllMutation.isPending || resetMutation.isPending || activateMutation.isPending
+
+ if (isLoadingProfiles) {
+ return (
+
+ )
+ }
+
+ if (profilesError) {
+ return (
+
+
+
System Prompts
+
+
+
+
+
+
Failed to load profiles: {profilesError.message}
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
System Prompts
+
+
+
+
+
+
+
+
+
+
+
+ {selectedProfile && (
+
+ {isLoadingProfile ? (
+
+ ) : profileData?.prompts ? (
+ <>
+
+
+ >
+ ) : (
+
+
No prompts found for this profile.
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/test/components/PlaceholderDocs.test.tsx b/frontend/src/test/components/PlaceholderDocs.test.tsx
new file mode 100644
index 0000000..8b68ed5
--- /dev/null
+++ b/frontend/src/test/components/PlaceholderDocs.test.tsx
@@ -0,0 +1,54 @@
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { PlaceholderDocs } from '../../components/PlaceholderDocs'
+
+describe('PlaceholderDocs', () => {
+ it('renders the "Available Placeholders" title', () => {
+ render()
+ expect(screen.getByText('Available Placeholders')).toBeInTheDocument()
+ })
+
+ it('renders all 3 placeholders: {question}, {chunks}, {context}', () => {
+ render()
+ expect(screen.getByText('{question}')).toBeInTheDocument()
+ expect(screen.getByText('{chunks}')).toBeInTheDocument()
+ expect(screen.getByText('{context}')).toBeInTheDocument()
+ })
+
+ it('renders each placeholder in a monospace element', () => {
+ render()
+ const placeholders = ['{question}', '{chunks}', '{context}']
+ placeholders.forEach((ph) => {
+ const el = screen.getByText(ph)
+ expect(el.tagName.toLowerCase()).toBe('code')
+ })
+ })
+
+ it('renders descriptions for each placeholder', () => {
+ render()
+ expect(screen.getByText("The user's input question")).toBeInTheDocument()
+ expect(screen.getByText('Retrieved document chunks (filter step only)')).toBeInTheDocument()
+ expect(screen.getByText('Formatted chunks with citations (generate step only)')).toBeInTheDocument()
+ })
+
+ it('hides placeholder details when toggle button is clicked', () => {
+ render()
+ const toggleBtn = screen.getByRole('button', { name: /available placeholders/i })
+ fireEvent.click(toggleBtn)
+
+ expect(screen.queryByText('{question}')).not.toBeInTheDocument()
+ expect(screen.queryByText('{chunks}')).not.toBeInTheDocument()
+ expect(screen.queryByText('{context}')).not.toBeInTheDocument()
+ })
+
+ it('shows placeholder details again when toggle is clicked twice', () => {
+ render()
+ const toggleBtn = screen.getByRole('button', { name: /available placeholders/i })
+
+ fireEvent.click(toggleBtn)
+ expect(screen.queryByText('{question}')).not.toBeInTheDocument()
+
+ fireEvent.click(toggleBtn)
+ expect(screen.getByText('{question}')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/components/ProfileList.test.tsx b/frontend/src/test/components/ProfileList.test.tsx
new file mode 100644
index 0000000..d14440e
--- /dev/null
+++ b/frontend/src/test/components/ProfileList.test.tsx
@@ -0,0 +1,79 @@
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { ProfileList } from '../../components/ProfileList'
+import type { PromptProfile } from '../../types'
+
+const mockProfiles: PromptProfile[] = [
+ { name: 'A', is_active: true },
+ { name: 'B', is_active: false },
+ { name: 'C', is_active: false },
+]
+
+describe('ProfileList', () => {
+ const mockOnSelect = vi.fn()
+
+ beforeEach(() => {
+ mockOnSelect.mockClear()
+ })
+
+ it('renders 3 profile cards', () => {
+ render()
+ expect(screen.getByText('Profile A')).toBeInTheDocument()
+ expect(screen.getByText('Profile B')).toBeInTheDocument()
+ expect(screen.getByText('Profile C')).toBeInTheDocument()
+ })
+
+ it('shows active indicator for the active profile', () => {
+ render()
+ const activeDot = screen.getByTitle('Active')
+ expect(activeDot).toBeInTheDocument()
+ expect(activeDot).toHaveClass('bg-green-500')
+ })
+
+ it('shows "Active" badge for the active profile', () => {
+ render()
+ // The "Active" text appears both as a title attribute and as badge text
+ const badges = screen.getAllByText('Active')
+ // At least one visible badge should exist for the active profile
+ expect(badges.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('shows inactive state for inactive profiles', () => {
+ render()
+ const inactiveDots = screen.getAllByTitle('Inactive')
+ expect(inactiveDots).toHaveLength(2)
+ inactiveDots.forEach((dot) => {
+ expect(dot).toHaveClass('bg-gray-300')
+ })
+ })
+
+ it('calls onSelect with profile name when Edit button is clicked', () => {
+ render()
+
+ const editButtons = screen.getAllByRole('button', { name: /edit/i })
+ fireEvent.click(editButtons[0])
+
+ expect(mockOnSelect).toHaveBeenCalledTimes(1)
+ expect(mockOnSelect).toHaveBeenCalledWith('A')
+ })
+
+ it('highlights the selected profile with distinct styling', () => {
+ render()
+
+ // Selected profile should show "Editing" instead of "Edit"
+ expect(screen.getByRole('button', { name: /editing/i })).toBeInTheDocument()
+
+ // Other profiles should still show "Edit" (exact match to exclude "Editing")
+ const editButtons = screen.getAllByRole('button').filter(
+ (btn) => btn.textContent?.trim() === 'Edit'
+ )
+ expect(editButtons).toHaveLength(2)
+ })
+
+ it('renders nothing when profiles array is empty', () => {
+ const { container } = render()
+ // The grid renders but with no children
+ expect(container.querySelector('.grid')).toBeInTheDocument()
+ expect(container.querySelectorAll('.grid > div')).toHaveLength(0)
+ })
+})
diff --git a/frontend/src/test/components/PromptEditor.test.tsx b/frontend/src/test/components/PromptEditor.test.tsx
new file mode 100644
index 0000000..3ae0bab
--- /dev/null
+++ b/frontend/src/test/components/PromptEditor.test.tsx
@@ -0,0 +1,180 @@
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { PromptEditor } from '../../components/PromptEditor'
+
+const mockPrompts: Record = {
+ decompose: 'Decompose template with {question}',
+ filter: 'Filter template with {question} and {chunks}',
+ generate: 'Generate template with {question} and {context}',
+}
+
+const defaultProps = {
+ profileName: 'A',
+ prompts: mockPrompts,
+ isSaving: false,
+ onUpdate: vi.fn(),
+ onSave: vi.fn(),
+ onResetStep: vi.fn(),
+ onResetAll: vi.fn(),
+ onCancel: vi.fn(),
+}
+
+describe('PromptEditor', () => {
+ beforeEach(() => {
+ defaultProps.onUpdate.mockClear()
+ defaultProps.onSave.mockClear()
+ defaultProps.onResetStep.mockClear()
+ defaultProps.onResetAll.mockClear()
+ defaultProps.onCancel.mockClear()
+ })
+
+ it('renders 3 textareas for decompose, filter, and generate steps', () => {
+ render()
+ const textareas = screen.getAllByRole('textbox')
+ expect(textareas).toHaveLength(3)
+ })
+
+ it('renders labels for each step', () => {
+ render()
+ expect(screen.getByText('Step 1: Query Decomposition')).toBeInTheDocument()
+ expect(screen.getByText('Step 2: Relevance Filtering')).toBeInTheDocument()
+ expect(screen.getByText('Step 3: Response Generation')).toBeInTheDocument()
+ })
+
+ it('shows valid placeholders in the label area for each step', () => {
+ render()
+ // {question} appears in all 3 steps (decompose, filter, generate)
+ const questionPlaceholders = screen.getAllByText('{question}')
+ expect(questionPlaceholders).toHaveLength(3)
+ // {chunks} appears only in filter step
+ expect(screen.getByText('{chunks}')).toBeInTheDocument()
+ // {context} appears only in generate step
+ expect(screen.getByText('{context}')).toBeInTheDocument()
+ })
+
+ it('renders per-step reset buttons that call onResetStep when clicked', () => {
+ // Mock window.confirm to return true
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+ render()
+ const resetButtons = screen.getAllByTitle(/reset.*to default/i)
+ expect(resetButtons).toHaveLength(3)
+
+ fireEvent.click(resetButtons[0])
+ expect(defaultProps.onResetStep).toHaveBeenCalledWith('decompose')
+
+ fireEvent.click(resetButtons[1])
+ expect(defaultProps.onResetStep).toHaveBeenCalledWith('filter')
+
+ fireEvent.click(resetButtons[2])
+ expect(defaultProps.onResetStep).toHaveBeenCalledWith('generate')
+
+ confirmSpy.mockRestore()
+ })
+
+ it('does not call onResetStep when confirm is cancelled', () => {
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
+
+ render()
+ const resetButtons = screen.getAllByTitle(/reset.*to default/i)
+
+ fireEvent.click(resetButtons[0])
+ expect(defaultProps.onResetStep).not.toHaveBeenCalled()
+
+ confirmSpy.mockRestore()
+ })
+
+ it('calls onSave when "Save Changes" button is clicked', () => {
+ render()
+
+ // The Save button is disabled when not dirty, so we need to change something first
+ const textareas = screen.getAllByRole('textbox')
+ fireEvent.change(textareas[0], { target: { value: 'Modified decompose template' } })
+
+ const saveButton = screen.getByRole('button', { name: /save changes/i })
+ expect(saveButton).not.toBeDisabled()
+ fireEvent.click(saveButton)
+
+ expect(defaultProps.onSave).toHaveBeenCalledTimes(1)
+ })
+
+ it('calls onResetAll when "Reset All to Defaults" is clicked and confirmed', () => {
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+ render()
+ const resetAllBtn = screen.getByRole('button', { name: /reset all to defaults/i })
+ fireEvent.click(resetAllBtn)
+
+ expect(confirmSpy).toHaveBeenCalledWith('Reset all prompts to defaults? This cannot be undone.')
+ expect(defaultProps.onResetAll).toHaveBeenCalledTimes(1)
+
+ confirmSpy.mockRestore()
+ })
+
+ it('does not call onResetAll when confirmation is dismissed', () => {
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
+
+ render()
+ const resetAllBtn = screen.getByRole('button', { name: /reset all to defaults/i })
+ fireEvent.click(resetAllBtn)
+
+ expect(defaultProps.onResetAll).not.toHaveBeenCalled()
+
+ confirmSpy.mockRestore()
+ })
+
+ it('calls onCancel when "Cancel" button is clicked', () => {
+ render()
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
+ fireEvent.click(cancelButton)
+
+ expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('pre-fills textareas with provided prompt values', () => {
+ render()
+ const textareas = screen.getAllByRole('textbox')
+
+ expect(textareas[0]).toHaveValue('Decompose template with {question}')
+ expect(textareas[1]).toHaveValue('Filter template with {question} and {chunks}')
+ expect(textareas[2]).toHaveValue('Generate template with {question} and {context}')
+ })
+
+ it('calls onUpdate when typing in a textarea', () => {
+ render()
+ const textareas = screen.getAllByRole('textbox')
+
+ fireEvent.change(textareas[0], { target: { value: 'New decompose content' } })
+
+ expect(defaultProps.onUpdate).toHaveBeenCalledWith('decompose', 'New decompose content')
+ })
+
+ it('displays character count for each textarea', () => {
+ render()
+
+ const decomposeLength = mockPrompts.decompose.length
+ const filterLength = mockPrompts.filter.length
+ const generateLength = mockPrompts.generate.length
+
+ expect(screen.getByText(`${decomposeLength} characters`)).toBeInTheDocument()
+ expect(screen.getByText(`${filterLength} characters`)).toBeInTheDocument()
+ expect(screen.getByText(`${generateLength} characters`)).toBeInTheDocument()
+ })
+
+ it('shows the profile name being edited', () => {
+ render()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ })
+
+ it('disables buttons when isSaving is true', () => {
+ render()
+
+ const textareas = screen.getAllByRole('textbox')
+ textareas.forEach((ta) => expect(ta).toBeDisabled())
+
+ // "Reset All to Defaults" and "Cancel" should be disabled when saving
+ expect(screen.getByRole('button', { name: /saving\.\.\./i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /reset all to defaults/i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled()
+ })
+})
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index da02073..8b429c8 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -56,3 +56,37 @@ export interface DeleteResponse {
deleted: boolean
message: string
}
+
+export interface PromptProfile {
+ name: string
+ is_active: boolean
+}
+
+export interface PromptProfileListResponse {
+ profiles: PromptProfile[]
+}
+
+export interface PromptSetResponse {
+ profile_name: string
+ prompts: Record
+}
+
+export interface PromptUpdateRequest {
+ template: string
+}
+
+export interface PromptBatchUpdateRequest {
+ prompts: Record
+}
+
+export interface PromptActivateResponse {
+ status: string
+ active_profile: string
+}
+
+export interface PromptStatusResponse {
+ status: string
+ profile: string
+ step?: string
+ reset_step?: string
+}