Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions web-ui/__mocks__/@hugeicons/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,7 @@ module.exports = {
ArrowLeft01Icon: createIconMock('ArrowLeft01Icon'),
// Proof page
InformationCircleIcon: createIconMock('InformationCircleIcon'),
// FileTreePanel / DiffViewer
FileAddIcon: createIconMock('FileAddIcon'),
FileRemoveIcon: createIconMock('FileRemoveIcon'),
};
168 changes: 168 additions & 0 deletions web-ui/__tests__/components/review/DiffViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DiffViewer diffFiles={[]} selectedFile={null} />);
expect(screen.getByText('No changes to display')).toBeInTheDocument();
});

it('does not render task chip when no tasks or contextTask', () => {
render(<DiffViewer diffFiles={mockDiffFiles} selectedFile={null} />);
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(<DiffViewer diffFiles={mockDiffFiles} selectedFile={null} tasks={[]} />);
expect(screen.queryByText(/View Task/)).not.toBeInTheDocument();
});

it('renders task chip via contextTask fallback when no per-file mapping', () => {
render(
<DiffViewer
diffFiles={mockDiffFiles}
selectedFile={null}
tasks={mockTasks}
contextTask={mockTasks[0]}
/>
);
expect(screen.getByText('Add login')).toBeInTheDocument();
});

it('renders task chip via per-file changedFiles mapping', () => {
render(
<DiffViewer
diffFiles={mockDiffFiles}
selectedFile={null}
tasks={mockTasks}
changedFiles={mockChangedFiles}
/>
);
expect(screen.getByText('Add login')).toBeInTheDocument();
});

it('renders requirement ID when task has requirement_ids', () => {
render(
<DiffViewer
diffFiles={mockDiffFiles}
selectedFile={null}
tasks={mockTasks}
contextTask={mockTasks[0]}
/>
);
expect(screen.getByText('REQ: REQ-42')).toBeInTheDocument();
});

it('does not render requirement ID when task has no requirement_ids', () => {
render(
<DiffViewer
diffFiles={mockDiffFiles}
selectedFile={null}
tasks={mockTaskNoReqs}
contextTask={mockTaskNoReqs[0]}
/>
);
expect(screen.queryByText(/REQ:/)).not.toBeInTheDocument();
});

it('renders "View Task" link pointing to /tasks', () => {
render(
<DiffViewer
diffFiles={mockDiffFiles}
selectedFile={null}
tasks={mockTasks}
contextTask={mockTasks[0]}
/>
);
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(
<DiffViewer
diffFiles={mockDiffFiles}
selectedFile={null}
tasks={mockTasks}
contextTask={mockTasks[0]}
/>
);

expect(screen.getByText('const a = 1;')).toBeInTheDocument();
const link = screen.getByText(/View Task/);
await user.click(link);
expect(screen.getByText('const a = 1;')).toBeInTheDocument();
});
});
140 changes: 140 additions & 0 deletions web-ui/__tests__/components/review/FileTreePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<FileTreePanel {...defaultProps} />);
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(<FileTreePanel {...defaultProps} tasks={mockTasks} />);
expect(screen.getByRole('button', { name: /task/i })).toBeInTheDocument();
});

it('does not render a grouping toggle when tasks is empty', () => {
render(<FileTreePanel {...defaultProps} tasks={[]} />);
expect(screen.queryByRole('button', { name: /task/i })).not.toBeInTheDocument();
});

it('does not render a grouping toggle when tasks is undefined', () => {
render(<FileTreePanel {...defaultProps} />);
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(<FileTreePanel {...defaultProps} tasks={mockTasks} />);

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(<FileTreePanel {...defaultProps} tasks={mockTasks} />);
// 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(
<FileTreePanel
files={untaggedFiles}
selectedFile={null}
onFileSelect={jest.fn()}
tasks={mockTasks}
contextTask={contextTask}
/>
);

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(
<FileTreePanel
files={untaggedFiles}
selectedFile={null}
onFileSelect={jest.fn()}
tasks={mockTasks}
contextTask={contextTask}
/>
);

expect(screen.getByText('Add login')).toBeInTheDocument();
});

it('task groups are collapsible', async () => {
const user = userEvent.setup();
render(<FileTreePanel {...defaultProps} tasks={mockTasks} />);

// 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();
});
});
23 changes: 22 additions & 1 deletion web-ui/src/app/review/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<TaskListResponse>(
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) : []),
Expand Down Expand Up @@ -280,12 +296,17 @@ export default function ReviewPage() {
files={diffData?.changed_files ?? []}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
tasks={tasksData?.tasks ?? []}
contextTask={contextTask}
/>

{/* Diff viewer (center) */}
<DiffViewer
diffFiles={diffFiles}
selectedFile={selectedFile}
tasks={tasksData?.tasks ?? []}
contextTask={contextTask}
changedFiles={diffData?.changed_files ?? []}
/>

{/* Commit panel (right sidebar) */}
Expand Down
Loading
Loading