Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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