diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 088e5f4d..e475e5c2 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -62,4 +62,7 @@ module.exports = { ArrowLeft01Icon: createIconMock('ArrowLeft01Icon'), // Proof page InformationCircleIcon: createIconMock('InformationCircleIcon'), + // FileTreePanel / DiffViewer + FileAddIcon: createIconMock('FileAddIcon'), + FileRemoveIcon: createIconMock('FileRemoveIcon'), }; diff --git a/web-ui/__tests__/components/review/DiffViewer.test.tsx b/web-ui/__tests__/components/review/DiffViewer.test.tsx new file mode 100644 index 00000000..22d19498 --- /dev/null +++ b/web-ui/__tests__/components/review/DiffViewer.test.tsx @@ -0,0 +1,168 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DiffViewer } from '@/components/review/DiffViewer'; +import type { FileChange, Task } from '@/types'; +import type { DiffFile } from '@/lib/diffParser'; + +// jsdom doesn't provide ResizeObserver (needed by radix ScrollArea) +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +// Mock scrollIntoView (not available in jsdom) +Element.prototype.scrollIntoView = jest.fn(); + +// ─── Fixtures ─────────────────────────────────────────────────────── + +const mockDiffFiles: DiffFile[] = [ + { + oldPath: 'src/foo.ts', + newPath: 'src/foo.ts', + hunks: [ + { + header: '@@ -1,3 +1,4 @@', + oldStart: 1, + oldCount: 3, + newStart: 1, + newCount: 4, + lines: [ + { type: 'context', content: 'const a = 1;', oldLineNumber: 1, newLineNumber: 1 }, + { type: 'addition', content: 'const b = 2;', oldLineNumber: null, newLineNumber: 2 }, + ], + }, + ], + insertions: 1, + deletions: 0, + isNew: false, + isDeleted: false, + isRenamed: false, + }, +]; + +const mockTasks: Task[] = [ + { + id: 'task-1', + title: 'Add login', + description: '', + status: 'IN_PROGRESS', + priority: 1, + depends_on: [], + requirement_ids: ['REQ-42'], + }, +]; + +const mockTaskNoReqs: Task[] = [ + { + id: 'task-1', + title: 'Add login', + description: '', + status: 'IN_PROGRESS', + priority: 1, + depends_on: [], + }, +]; + +// getFilePath returns newPath for non-deleted/non-renamed files +const mockChangedFiles: FileChange[] = [ + { path: 'src/foo.ts', change_type: 'modified', insertions: 1, deletions: 0, task_id: 'task-1' }, +]; + +// ─── Tests ────────────────────────────────────────────────────────── + +describe('DiffViewer', () => { + it('renders "No changes to display" when diffFiles is empty', () => { + render(); + expect(screen.getByText('No changes to display')).toBeInTheDocument(); + }); + + it('does not render task chip when no tasks or contextTask', () => { + render(); + expect(screen.queryByText('Add login')).not.toBeInTheDocument(); + expect(screen.queryByText(/View Task/)).not.toBeInTheDocument(); + }); + + it('does not render task chip when tasks is empty and no contextTask', () => { + render(); + expect(screen.queryByText(/View Task/)).not.toBeInTheDocument(); + }); + + it('renders task chip via contextTask fallback when no per-file mapping', () => { + render( + + ); + expect(screen.getByText('Add login')).toBeInTheDocument(); + }); + + it('renders task chip via per-file changedFiles mapping', () => { + render( + + ); + expect(screen.getByText('Add login')).toBeInTheDocument(); + }); + + it('renders requirement ID when task has requirement_ids', () => { + render( + + ); + expect(screen.getByText('REQ: REQ-42')).toBeInTheDocument(); + }); + + it('does not render requirement ID when task has no requirement_ids', () => { + render( + + ); + expect(screen.queryByText(/REQ:/)).not.toBeInTheDocument(); + }); + + it('renders "View Task" link pointing to /tasks', () => { + render( + + ); + const link = screen.getByText(/View Task/); + expect(link.closest('a')).toHaveAttribute('href', '/tasks'); + }); + + it('clicking "View Task" link does not collapse the file', async () => { + const user = userEvent.setup(); + render( + + ); + + expect(screen.getByText('const a = 1;')).toBeInTheDocument(); + const link = screen.getByText(/View Task/); + await user.click(link); + expect(screen.getByText('const a = 1;')).toBeInTheDocument(); + }); +}); diff --git a/web-ui/__tests__/components/review/FileTreePanel.test.tsx b/web-ui/__tests__/components/review/FileTreePanel.test.tsx new file mode 100644 index 00000000..9f6270e5 --- /dev/null +++ b/web-ui/__tests__/components/review/FileTreePanel.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FileTreePanel } from '@/components/review/FileTreePanel'; +import type { FileChange, Task } from '@/types'; + +// jsdom doesn't provide ResizeObserver (needed by radix ScrollArea) +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +// ─── Fixtures ─────────────────────────────────────────────────────── + +const mockFiles: FileChange[] = [ + { path: 'src/foo.ts', change_type: 'modified', insertions: 5, deletions: 2, task_id: 'task-1', task_title: 'Add login' }, + { path: 'src/bar.ts', change_type: 'added', insertions: 10, deletions: 0 }, + { path: 'lib/utils.ts', change_type: 'modified', insertions: 3, deletions: 1, task_id: 'task-2', task_title: 'Fix utils' }, +]; + +const mockTasks: Task[] = [ + { id: 'task-1', title: 'Add login', description: '', status: 'IN_PROGRESS', priority: 1, depends_on: [] }, + { id: 'task-2', title: 'Fix utils', description: '', status: 'READY', priority: 2, depends_on: [] }, +]; + +const defaultProps = { + files: mockFiles, + selectedFile: null, + onFileSelect: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── Tests ────────────────────────────────────────────────────────── + +describe('FileTreePanel', () => { + it('renders file list grouped by directory', () => { + render(); + expect(screen.getByText('src')).toBeInTheDocument(); + expect(screen.getByText('lib')).toBeInTheDocument(); + expect(screen.getByText('foo.ts')).toBeInTheDocument(); + expect(screen.getByText('bar.ts')).toBeInTheDocument(); + expect(screen.getByText('utils.ts')).toBeInTheDocument(); + }); + + it('renders a grouping toggle button when tasks prop has items', () => { + render(); + expect(screen.getByRole('button', { name: /task/i })).toBeInTheDocument(); + }); + + it('does not render a grouping toggle when tasks is empty', () => { + render(); + expect(screen.queryByRole('button', { name: /task/i })).not.toBeInTheDocument(); + }); + + it('does not render a grouping toggle when tasks is undefined', () => { + render(); + expect(screen.queryByRole('button', { name: /task/i })).not.toBeInTheDocument(); + }); + + it('groups files under task headers when toggled to task mode', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /task/i })); + + // Task group headers should appear + expect(screen.getByText('Add login')).toBeInTheDocument(); + expect(screen.getByText('Fix utils')).toBeInTheDocument(); + expect(screen.getByText('Unassigned')).toBeInTheDocument(); + }); + + it('shows task title badge next to filename in dir mode when file has task_title', () => { + render(); + // In dir mode (default), files with task_title should show a badge + const badges = screen.getAllByText('Add login'); + expect(badges.length).toBeGreaterThanOrEqual(1); + }); + + it('groups untagged files under contextTask when contextTask is provided', async () => { + const user = userEvent.setup(); + const contextTask = mockTasks[0]; // 'Add login' + const untaggedFiles: FileChange[] = [ + { path: 'src/untagged.ts', change_type: 'modified', insertions: 1, deletions: 0 }, + ]; + render( + + ); + + await user.click(screen.getByRole('button', { name: /group by task/i })); + + // Untagged file should appear under the contextTask group, not 'Unassigned' + expect(screen.getByText('Add login')).toBeInTheDocument(); + expect(screen.queryByText('Unassigned')).not.toBeInTheDocument(); + }); + + it('shows contextTask title as badge in dir mode for files without task_title', () => { + const contextTask = mockTasks[0]; // 'Add login' + const untaggedFiles: FileChange[] = [ + { path: 'src/untagged.ts', change_type: 'modified', insertions: 1, deletions: 0 }, + ]; + render( + + ); + + expect(screen.getByText('Add login')).toBeInTheDocument(); + }); + + it('task groups are collapsible', async () => { + const user = userEvent.setup(); + render(); + + // Switch to task mode + await user.click(screen.getByRole('button', { name: /task/i })); + + // Files should be visible + expect(screen.getByText('foo.ts')).toBeInTheDocument(); + + // Click on task group header to collapse + const addLoginHeader = screen.getByRole('button', { name: /collapse add login/i }); + await user.click(addLoginHeader); + + // foo.ts should no longer be visible + expect(screen.queryByText('foo.ts')).not.toBeInTheDocument(); + }); +}); diff --git a/web-ui/src/app/review/page.tsx b/web-ui/src/app/review/page.tsx index c2c67237..341e3aef 100644 --- a/web-ui/src/app/review/page.tsx +++ b/web-ui/src/app/review/page.tsx @@ -4,10 +4,12 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import Link from 'next/link'; import useSWR from 'swr'; import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; -import { reviewApi, gatesApi, gitApi, prApi } from '@/lib/api'; +import { reviewApi, gatesApi, gitApi, prApi, tasksApi } from '@/lib/api'; import { parseDiff, getFilePath } from '@/lib/diffParser'; import type { DiffStatsResponse, + TaskListResponse, + Task, GateResult, ApiError, } from '@/types'; @@ -62,6 +64,20 @@ export default function ReviewPage() { () => reviewApi.getDiff(workspacePath!) ); + // Fetch tasks for context (optional — errors degrade gracefully, no banner) + const { data: tasksData } = useSWR( + workspacePath ? `/api/v2/tasks?workspace_path=${workspacePath}` : null, + () => tasksApi.getAll(workspacePath!), + { onError: () => {} } + ); + + // Auto-select single IN_PROGRESS task as context + const inProgressTasks = useMemo( + () => (tasksData?.tasks ?? []).filter((t: Task) => t.status === 'IN_PROGRESS'), + [tasksData?.tasks] + ); + const contextTask = inProgressTasks.length === 1 ? inProgressTasks[0] : null; + // Parse diff into structured files const diffFiles = useMemo( () => (diffData?.diff ? parseDiff(diffData.diff) : []), @@ -280,12 +296,17 @@ export default function ReviewPage() { files={diffData?.changed_files ?? []} selectedFile={selectedFile} onFileSelect={handleFileSelect} + tasks={tasksData?.tasks ?? []} + contextTask={contextTask} /> {/* Diff viewer (center) */} {/* Commit panel (right sidebar) */} diff --git a/web-ui/src/components/review/DiffViewer.tsx b/web-ui/src/components/review/DiffViewer.tsx index 570bb42f..fbaf9e79 100644 --- a/web-ui/src/components/review/DiffViewer.tsx +++ b/web-ui/src/components/review/DiffViewer.tsx @@ -6,10 +6,15 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import type { DiffFile, DiffHunkLine } from '@/lib/diffParser'; import { getFilePath } from '@/lib/diffParser'; +import Link from 'next/link'; +import type { FileChange, Task } from '@/types'; interface DiffViewerProps { diffFiles: DiffFile[]; selectedFile: string | null; + tasks?: Task[]; + contextTask?: Task | null; + changedFiles?: FileChange[]; } function lineClassName(type: DiffHunkLine['type']): string { @@ -63,7 +68,7 @@ function linePrefix(type: DiffHunkLine['type']): string { * hunk separators, and dual-column line numbers. When a file is * selected via FileTreePanel, the viewer scrolls to that section. */ -export function DiffViewer({ diffFiles, selectedFile }: DiffViewerProps) { +export function DiffViewer({ diffFiles, selectedFile, tasks, contextTask, changedFiles }: DiffViewerProps) { const fileRefs = useRef>(new Map()); const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); @@ -72,6 +77,20 @@ export function DiffViewer({ diffFiles, selectedFile }: DiffViewerProps) { [diffFiles] ); + // Build a lookup map from file path to task context for per-file resolution + const taskMap = useMemo(() => { + if (!tasks?.length) return new Map(); + const byId = new Map(tasks.map((t) => [t.id, t])); + const result = new Map(); + for (const f of changedFiles ?? []) { + if (f.task_id) { + const task = byId.get(f.task_id); + if (task) result.set(f.path, task); + } + } + return result; + }, [tasks, changedFiles]); + // Scroll to selected file useEffect(() => { if (!selectedFile) return; @@ -120,6 +139,8 @@ export function DiffViewer({ diffFiles, selectedFile }: DiffViewerProps) { const isHighlighted = selectedFile !== null && (key.includes(selectedFile) || selectedFile.includes(key)); + // Resolve task context per file: explicit mapping → contextTask fallback + const fileTask = taskMap.get(key) ?? contextTask ?? null; return (
- {/* File header */} - + + {key} + + + {file.insertions > 0 && ( + + +{file.insertions} + + )} + {file.deletions > 0 && ( + + -{file.deletions} + + )} + + + {/* Task context - outside the toggle button, resolved per file */} + {fileTask && ( +
+ {fileTask.requirement_ids?.[0] && ( + REQ: {fileTask.requirement_ids[0]} + )} + {fileTask.title} + e.stopPropagation()} + > + View Task → + +
+ )} +
{/* File diff content */} {!isCollapsed && ( diff --git a/web-ui/src/components/review/FileTreePanel.tsx b/web-ui/src/components/review/FileTreePanel.tsx index 7bded9de..2be4fa59 100644 --- a/web-ui/src/components/review/FileTreePanel.tsx +++ b/web-ui/src/components/review/FileTreePanel.tsx @@ -5,12 +5,14 @@ import { FileAddIcon, FileRemoveIcon, FileEditIcon, ArrowDown01Icon, ArrowRight0 import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import { getDirectory, getFilename } from '@/lib/diffParser'; -import type { FileChange } from '@/types'; +import type { FileChange, Task } from '@/types'; interface FileTreePanelProps { files: FileChange[]; selectedFile: string | null; onFileSelect: (filePath: string) => void; + tasks?: Task[]; + contextTask?: Task | null; } const changeTypeIcon: Record> = { @@ -28,13 +30,15 @@ const changeTypeColor: Record = { }; /** - * Left sidebar showing changed files grouped by directory. + * Left sidebar showing changed files grouped by directory or task. * * Files are organized into collapsible directory groups with * per-file insertion/deletion counts and change type icons. + * When tasks are provided, a toggle allows grouping by task. */ -export function FileTreePanel({ files, selectedFile, onFileSelect }: FileTreePanelProps) { +export function FileTreePanel({ files, selectedFile, onFileSelect, tasks, contextTask }: FileTreePanelProps) { const [collapsedDirs, setCollapsedDirs] = useState>(new Set()); + const [groupBy, setGroupBy] = useState<'dir' | 'task'>('dir'); const grouped = useMemo(() => { const groups = new Map(); @@ -50,6 +54,26 @@ export function FileTreePanel({ files, selectedFile, onFileSelect }: FileTreePan return groups; }, [files]); + const groupedByTask = useMemo(() => { + const taskTitleById = new Map(tasks?.map((t) => [t.id, t.title]) ?? []); + const groups = new Map(); + for (const file of files) { + const taskId = file.task_id ?? contextTask?.id ?? 'unassigned'; + const taskTitle = + file.task_title ?? + taskTitleById.get(taskId) ?? + contextTask?.title ?? + 'Unassigned'; + const existing = groups.get(taskId); + if (existing) { + existing.files.push(file); + } else { + groups.set(taskId, { title: taskTitle, files: [file] }); + } + } + return groups; + }, [files, tasks, contextTask]); + function toggleDir(dir: string) { setCollapsedDirs((prev) => { const next = new Set(prev); @@ -62,80 +86,183 @@ export function FileTreePanel({ files, selectedFile, onFileSelect }: FileTreePan }); } + const hasTasks = tasks && tasks.length > 0; + return (
-
+
Files Changed ({files.length}) + {hasTasks && ( +
+ + +
+ )}
- {Array.from(grouped.entries()).map(([dir, dirFiles]) => { - const isCollapsed = collapsedDirs.has(dir); - - return ( -
- - - {!isCollapsed && ( -
- {dirFiles.map((file) => { - const Icon = changeTypeIcon[file.change_type]; - const iconColor = changeTypeColor[file.change_type]; - const isSelected = selectedFile === file.path; - - return ( - - ); - })} + {groupBy === 'dir' + ? Array.from(grouped.entries()).map(([dir, dirFiles]) => { + const isCollapsed = collapsedDirs.has(dir); + + return ( +
+ + + {!isCollapsed && ( +
+ {dirFiles.map((file) => { + const Icon = changeTypeIcon[file.change_type]; + const iconColor = changeTypeColor[file.change_type]; + const isSelected = selectedFile === file.path; + + return ( + + ); + })} +
+ )} +
+ ); + }) + : Array.from(groupedByTask.entries()).map(([taskId, { title: taskTitle, files: taskFiles }]) => { + const isCollapsed = collapsedDirs.has(`task:${taskId}`); + + return ( +
+ + + {!isCollapsed && ( +
+ {taskFiles.map((file) => { + const Icon = changeTypeIcon[file.change_type]; + const iconColor = changeTypeColor[file.change_type]; + const isSelected = selectedFile === file.path; + + return ( + + ); + })} +
+ )}
- )} -
- ); - })} + ); + })}
diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index 8fb630ac..d1d3cf99 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -195,6 +195,8 @@ export interface FileChange { change_type: FileChangeType; insertions: number; deletions: number; + task_id?: string; // Which task modified this file (optional, for future backend enrichment) + task_title?: string; // Task title for display (optional) } export interface DiffStatsResponse {