diff --git a/web-ui/src/__tests__/components/prd/PRDVersionHistoryModal.test.tsx b/web-ui/src/__tests__/components/prd/PRDVersionHistoryModal.test.tsx
new file mode 100644
index 00000000..80f2584e
--- /dev/null
+++ b/web-ui/src/__tests__/components/prd/PRDVersionHistoryModal.test.tsx
@@ -0,0 +1,295 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import useSWR from 'swr';
+import { PRDVersionHistoryModal } from '@/components/prd/PRDVersionHistoryModal';
+import { prdApi } from '@/lib/api';
+import type { PrdResponse, PrdDiffResponse } from '@/types';
+
+// ResizeObserver is not available in jsdom
+global.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+}));
+
+jest.mock('swr');
+jest.mock('sonner', () => ({
+ toast: { success: jest.fn(), error: jest.fn() },
+}));
+// Radix ScrollArea Viewport hides children in jsdom — render children directly
+jest.mock('@/components/ui/scroll-area', () => ({
+ ScrollArea: ({ children }: { children: React.ReactNode }) =>
{children}
,
+ ScrollBar: () => null,
+}));
+
+jest.mock('@/lib/api', () => ({
+ prdApi: {
+ getVersions: jest.fn(),
+ diff: jest.fn(),
+ createVersion: jest.fn(),
+ },
+}));
+
+const mockUseSWR = useSWR as jest.MockedFunction;
+const mockDiff = prdApi.diff as jest.MockedFunction;
+const mockCreateVersion = prdApi.createVersion as jest.MockedFunction;
+
+const WORKSPACE = '/home/user/project';
+
+const makeVersion = (v: number, summary: string | null = null): PrdResponse => ({
+ id: `prd-${v}`,
+ workspace_id: 'ws-1',
+ title: 'My PRD',
+ content: `# Version ${v} content`,
+ metadata: {},
+ created_at: `2026-01-0${v}T00:00:00Z`,
+ version: v,
+ parent_id: v > 1 ? `prd-${v - 1}` : null,
+ change_summary: summary,
+ chain_id: 'chain-1',
+});
+
+const fakeVersions: PrdResponse[] = [
+ makeVersion(3, 'Added section 3'),
+ makeVersion(2, 'Updated intro'),
+ makeVersion(1, null),
+];
+
+const currentPrd = fakeVersions[0];
+
+const defaultProps = {
+ open: true,
+ onOpenChange: jest.fn(),
+ prd: currentPrd,
+ workspacePath: WORKSPACE,
+ onVersionRestored: jest.fn(),
+};
+
+function setupSWR(versions = fakeVersions) {
+ mockUseSWR.mockReturnValue({
+ data: versions,
+ error: undefined,
+ isLoading: false,
+ mutate: jest.fn(),
+ } as unknown as ReturnType);
+}
+
+describe('PRDVersionHistoryModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setupSWR();
+ });
+
+ it('renders the dialog with version list', () => {
+ render();
+ expect(screen.getByText('Version History')).toBeInTheDocument();
+ expect(screen.getByText('Version 3')).toBeInTheDocument();
+ expect(screen.getByText('Version 2')).toBeInTheDocument();
+ expect(screen.getByText('Version 1')).toBeInTheDocument();
+ });
+
+ it('shows change_summary when present', () => {
+ render();
+ expect(screen.getByText('Added section 3')).toBeInTheDocument();
+ expect(screen.getByText('Updated intro')).toBeInTheDocument();
+ });
+
+ it('shows "No summary" for versions with null change_summary', () => {
+ render();
+ expect(screen.getByText('No summary')).toBeInTheDocument();
+ });
+
+ it('highlights the current version with a "Current" badge', () => {
+ render();
+ expect(screen.getByText('Current')).toBeInTheDocument();
+ });
+
+ it('shows loading state while fetching', () => {
+ mockUseSWR.mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: true,
+ mutate: jest.fn(),
+ } as unknown as ReturnType);
+
+ render();
+ expect(screen.getByText(/loading/i)).toBeInTheDocument();
+ });
+
+ it('shows error state on fetch failure', () => {
+ mockUseSWR.mockReturnValue({
+ data: undefined,
+ error: new Error('Network error'),
+ isLoading: false,
+ mutate: jest.fn(),
+ } as unknown as ReturnType);
+
+ render();
+ expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
+ });
+
+ describe('View version', () => {
+ it('shows content preview when View button is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ await user.click(viewButtons[0]); // click View on version 2
+
+ expect(screen.getByText(/Version 2 content/)).toBeInTheDocument();
+ });
+
+ it('shows "Back to list" button in preview mode', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ await user.click(viewButtons[0]);
+
+ expect(screen.getByRole('button', { name: /back to list/i })).toBeInTheDocument();
+ });
+
+ it('returns to version list when Back is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ await user.click(viewButtons[0]);
+ await user.click(screen.getByRole('button', { name: /back to list/i }));
+
+ expect(screen.getByText('Version History')).toBeInTheDocument();
+ expect(screen.queryByText('# Version 2 content')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Compare with current', () => {
+ it('calls prdApi.diff and shows diff output', async () => {
+ const fakeDiff: PrdDiffResponse = {
+ version1: 2,
+ version2: 3,
+ diff: '@@ -1 +1 @@\n-# Version 2 content\n+# Version 3 content',
+ };
+ mockDiff.mockResolvedValueOnce(fakeDiff);
+
+ const user = userEvent.setup();
+ render();
+
+ // versions ordered newest-first; version 3 is current (no View btn), so index 0 = version 2
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ await user.click(viewButtons[0]); // version 2
+
+ const compareBtn = screen.getByRole('button', { name: /compare with current/i });
+ await user.click(compareBtn);
+
+ await waitFor(() => {
+ expect(mockDiff).toHaveBeenCalledWith(
+ currentPrd.id,
+ WORKSPACE,
+ 2,
+ 3
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/@@ -1 \+1 @@/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows error message and re-enables Compare button on diff failure', async () => {
+ mockDiff.mockRejectedValueOnce(new Error('Network error'));
+
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ await user.click(viewButtons[0]); // version 2
+
+ const compareBtn = screen.getByRole('button', { name: /compare with current/i });
+ await user.click(compareBtn);
+
+ await waitFor(() => {
+ expect(mockDiff).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/failed to load diff/i)).toBeInTheDocument();
+ });
+
+ // Compare button should be re-enabled so the user can retry
+ expect(screen.getByRole('button', { name: /compare with current/i })).not.toBeDisabled();
+ });
+ });
+
+ describe('Restore version', () => {
+ it('shows confirmation UI when Restore is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ await user.click(viewButtons[0]); // version 2
+
+ const restoreBtn = screen.getByRole('button', { name: /restore this version/i });
+ await user.click(restoreBtn);
+
+ expect(screen.getByText(/restore version 2/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /confirm restore/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ it('calls createVersion with restored content on confirm', async () => {
+ const restoredPrd = makeVersion(4, 'Restored from version 2');
+ mockCreateVersion.mockResolvedValueOnce(restoredPrd);
+
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ await user.click(viewButtons[0]); // version 2
+ await user.click(screen.getByRole('button', { name: /restore this version/i }));
+ await user.click(screen.getByRole('button', { name: /confirm restore/i }));
+
+ await waitFor(() => {
+ expect(mockCreateVersion).toHaveBeenCalledWith(
+ currentPrd.id,
+ WORKSPACE,
+ '# Version 2 content',
+ 'Restored from version 2'
+ );
+ });
+
+ await waitFor(() => {
+ expect(defaultProps.onVersionRestored).toHaveBeenCalledWith(restoredPrd);
+ });
+ });
+
+ it('cancels restore without calling API', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ await user.click(viewButtons[0]);
+ await user.click(screen.getByRole('button', { name: /restore this version/i }));
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+ expect(mockCreateVersion).not.toHaveBeenCalled();
+ // Should return to preview without confirmation UI
+ expect(screen.queryByRole('button', { name: /confirm restore/i })).not.toBeInTheDocument();
+ });
+
+ it('does not show Restore button for the current version', () => {
+ render();
+
+ // Version 3 is current — its View button should not be visible (or Restore should be absent)
+ // The current version row should not have a "View" button at all
+ const viewButtons = screen.getAllByRole('button', { name: /^view$/i });
+ // Only versions 1 and 2 should have View buttons (not version 3 which is current)
+ expect(viewButtons).toHaveLength(2);
+ });
+ });
+
+ it('does not render version list when closed', () => {
+ render();
+ expect(screen.queryByText('Version History')).not.toBeInTheDocument();
+ });
+});
diff --git a/web-ui/src/app/prd/page.tsx b/web-ui/src/app/prd/page.tsx
index e162cf4c..9c8a7798 100644
--- a/web-ui/src/app/prd/page.tsx
+++ b/web-ui/src/app/prd/page.tsx
@@ -6,6 +6,7 @@ import { toast } from 'sonner';
import useSWR from 'swr';
import { PRDView } from '@/components/prd';
import { UploadPRDModal } from '@/components/prd/UploadPRDModal';
+import { PRDVersionHistoryModal } from '@/components/prd/PRDVersionHistoryModal';
import { prdApi, tasksApi, discoveryApi } from '@/lib/api';
import { getSelectedWorkspacePath } from '@/lib/workspace-storage';
import type {
@@ -21,6 +22,7 @@ export default function PrdPage() {
const [discoveryOpen, setDiscoveryOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isGeneratingTasks, setIsGeneratingTasks] = useState(false);
+ const [versionHistoryOpen, setVersionHistoryOpen] = useState(false);
useEffect(() => {
setWorkspacePath(getSelectedWorkspacePath());
@@ -127,6 +129,11 @@ export default function PrdPage() {
setDiscoveryOpen(false);
};
+ const handleVersionRestored = (newPrd: PrdResponse) => {
+ mutatePrd(newPrd, false);
+ setVersionHistoryOpen(false);
+ };
+
const handleGenerateTasks = async () => {
if (!workspacePath || !prd) return;
setIsGeneratingTasks(true);
@@ -167,6 +174,7 @@ export default function PrdPage() {
onGenerateTasks={handleGenerateTasks}
onSavePrd={handleSavePrd}
onPrdGenerated={handlePrdGenerated}
+ onViewHistory={() => setVersionHistoryOpen(true)}
/>
+
+ {hasPrd && prd && (
+
+ )}
);
diff --git a/web-ui/src/components/prd/PRDHeader.tsx b/web-ui/src/components/prd/PRDHeader.tsx
index 4d4fb13f..16530fdf 100644
--- a/web-ui/src/components/prd/PRDHeader.tsx
+++ b/web-ui/src/components/prd/PRDHeader.tsx
@@ -6,6 +6,7 @@ import {
MessageSearch01Icon,
TaskEdit01Icon,
Loading03Icon,
+ Time01Icon,
} from '@hugeicons/react';
import { Button } from '@/components/ui/button';
import type { PrdResponse } from '@/types';
@@ -16,6 +17,7 @@ interface PRDHeaderProps {
onUploadPrd: () => void;
onStartDiscovery: () => void;
onGenerateTasks: () => void;
+ onViewHistory?: () => void;
}
export function PRDHeader({
@@ -24,6 +26,7 @@ export function PRDHeader({
onUploadPrd,
onStartDiscovery,
onGenerateTasks,
+ onViewHistory,
}: PRDHeaderProps) {
return (