diff --git a/.veritas-kanban/worktrees/task_20260126_z6KAvr b/.veritas-kanban/worktrees/task_20260126_z6KAvr new file mode 160000 index 00000000..88bf96a7 --- /dev/null +++ b/.veritas-kanban/worktrees/task_20260126_z6KAvr @@ -0,0 +1 @@ +Subproject commit 88bf96a77f887086d7d88a535ceb44deb512a469 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49b35280..8590ac54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-toast': specifier: ^1.2.4 version: 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -970,6 +973,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toast@1.2.15': resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} peerDependencies: @@ -3272,6 +3288,22 @@ snapshots: optionalDependencies: '@types/react': 19.2.9 + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 diff --git a/server/src/index.ts b/server/src/index.ts index f95648c1..19a3ffab 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import { createServer } from 'http'; import { taskRoutes } from './routes/tasks.js'; import { configRoutes } from './routes/config.js'; import { agentRoutes, agentService } from './routes/agents.js'; +import { diffRoutes } from './routes/diff.js'; import type { AgentOutput } from './services/agent-service.js'; const app = express(); @@ -23,6 +24,7 @@ app.get('/health', (_req, res) => { app.use('/api/tasks', taskRoutes); app.use('/api/config', configRoutes); app.use('/api/agents', agentRoutes); +app.use('/api/diff', diffRoutes); // Create HTTP server const server = createServer(app); diff --git a/server/src/routes/diff.ts b/server/src/routes/diff.ts new file mode 100644 index 00000000..8aeceed6 --- /dev/null +++ b/server/src/routes/diff.ts @@ -0,0 +1,44 @@ +import { Router, type Router as RouterType } from 'express'; +import { DiffService } from '../services/diff-service.js'; + +const router: RouterType = Router(); +const diffService = new DiffService(); + +// GET /api/diff/:taskId - Get diff summary for task +router.get('/:taskId', async (req, res) => { + try { + const summary = await diffService.getDiffSummary(req.params.taskId); + res.json(summary); + } catch (error: any) { + console.error('Error getting diff summary:', error); + res.status(400).json({ error: error.message || 'Failed to get diff summary' }); + } +}); + +// GET /api/diff/:taskId/file - Get diff for specific file +router.get('/:taskId/file', async (req, res) => { + try { + const { path } = req.query; + if (!path || typeof path !== 'string') { + return res.status(400).json({ error: 'File path is required' }); + } + const diff = await diffService.getFileDiff(req.params.taskId, path); + res.json(diff); + } catch (error: any) { + console.error('Error getting file diff:', error); + res.status(400).json({ error: error.message || 'Failed to get file diff' }); + } +}); + +// GET /api/diff/:taskId/full - Get full diff for all files +router.get('/:taskId/full', async (req, res) => { + try { + const diffs = await diffService.getFullDiff(req.params.taskId); + res.json(diffs); + } catch (error: any) { + console.error('Error getting full diff:', error); + res.status(400).json({ error: error.message || 'Failed to get full diff' }); + } +}); + +export { router as diffRoutes }; diff --git a/server/src/services/diff-service.ts b/server/src/services/diff-service.ts new file mode 100644 index 00000000..39490ba0 --- /dev/null +++ b/server/src/services/diff-service.ts @@ -0,0 +1,258 @@ +import { simpleGit } from 'simple-git'; +import { TaskService } from './task-service.js'; +import type { Task } from '@veritas-kanban/shared'; + +export interface FileChange { + path: string; + status: 'added' | 'modified' | 'deleted' | 'renamed'; + additions: number; + deletions: number; + oldPath?: string; // For renames +} + +export interface FileDiff { + path: string; + status: 'added' | 'modified' | 'deleted' | 'renamed'; + hunks: DiffHunk[]; + language: string; + additions: number; + deletions: number; +} + +export interface DiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: DiffLine[]; +} + +export interface DiffLine { + type: 'context' | 'add' | 'delete'; + content: string; + oldNumber?: number; + newNumber?: number; +} + +export interface DiffSummary { + files: FileChange[]; + totalAdditions: number; + totalDeletions: number; + totalFiles: number; +} + +export class DiffService { + private taskService: TaskService; + + constructor() { + this.taskService = new TaskService(); + } + + private expandPath(p: string): string { + return p.replace(/^~/, process.env.HOME || ''); + } + + private getLanguageFromPath(filePath: string): string { + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + const langMap: Record = { + 'ts': 'typescript', + 'tsx': 'tsx', + 'js': 'javascript', + 'jsx': 'jsx', + 'json': 'json', + 'md': 'markdown', + 'css': 'css', + 'scss': 'scss', + 'html': 'html', + 'py': 'python', + 'rs': 'rust', + 'go': 'go', + 'rb': 'ruby', + 'java': 'java', + 'sh': 'bash', + 'yaml': 'yaml', + 'yml': 'yaml', + 'toml': 'toml', + 'sql': 'sql', + }; + return langMap[ext] || 'plaintext'; + } + + private parseStatusCode(code: string): FileChange['status'] { + switch (code[0]) { + case 'A': return 'added'; + case 'M': return 'modified'; + case 'D': return 'deleted'; + case 'R': return 'renamed'; + default: return 'modified'; + } + } + + async getDiffSummary(taskId: string): Promise { + const task = await this.taskService.getTask(taskId); + if (!task?.git?.worktreePath) { + throw new Error('Task does not have an active worktree'); + } + + const worktreePath = this.expandPath(task.git.worktreePath); + const git = simpleGit(worktreePath); + + // Get diff against base branch + const baseBranch = task.git.baseBranch || 'main'; + + // Get list of changed files with stats + const diffStat = await git.diffSummary([baseBranch]); + + const files: FileChange[] = diffStat.files.map(file => { + const additions = 'insertions' in file ? file.insertions : 0; + const deletions = 'deletions' in file ? file.deletions : 0; + const isBinary = 'binary' in file && file.binary; + + return { + path: file.file, + status: isBinary ? 'modified' as const : ( + additions > 0 && deletions === 0 ? 'added' as const : + deletions > 0 && additions === 0 ? 'deleted' as const : 'modified' as const + ), + additions, + deletions, + }; + }); + + return { + files, + totalAdditions: diffStat.insertions, + totalDeletions: diffStat.deletions, + totalFiles: files.length, + }; + } + + async getFileDiff(taskId: string, filePath: string): Promise { + const task = await this.taskService.getTask(taskId); + if (!task?.git?.worktreePath) { + throw new Error('Task does not have an active worktree'); + } + + const worktreePath = this.expandPath(task.git.worktreePath); + const git = simpleGit(worktreePath); + const baseBranch = task.git.baseBranch || 'main'; + + // Get unified diff for the file + const diffOutput = await git.diff([baseBranch, '--', filePath]); + + // Get file stats + const diffStat = await git.diffSummary([baseBranch, '--', filePath]); + const fileStat = diffStat.files[0]; + + // Parse the unified diff + const hunks = this.parseUnifiedDiff(diffOutput); + + // Determine status and get stats + let status: FileChange['status'] = 'modified'; + let additions = 0; + let deletions = 0; + + if (fileStat && 'insertions' in fileStat) { + additions = fileStat.insertions; + deletions = fileStat.deletions; + if (additions > 0 && deletions === 0) status = 'added'; + else if (deletions > 0 && additions === 0) status = 'deleted'; + } + + return { + path: filePath, + status, + hunks, + language: this.getLanguageFromPath(filePath), + additions, + deletions, + }; + } + + private parseUnifiedDiff(diffOutput: string): DiffHunk[] { + const hunks: DiffHunk[] = []; + const lines = diffOutput.split('\n'); + + let currentHunk: DiffHunk | null = null; + let oldLineNum = 0; + let newLineNum = 0; + + for (const line of lines) { + // Match hunk header: @@ -start,count +start,count @@ + const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + + if (hunkMatch) { + if (currentHunk) { + hunks.push(currentHunk); + } + + oldLineNum = parseInt(hunkMatch[1], 10); + newLineNum = parseInt(hunkMatch[3], 10); + + currentHunk = { + oldStart: oldLineNum, + oldLines: parseInt(hunkMatch[2] || '1', 10), + newStart: newLineNum, + newLines: parseInt(hunkMatch[4] || '1', 10), + lines: [], + }; + continue; + } + + if (!currentHunk) continue; + + // Skip diff metadata lines + if (line.startsWith('diff --git') || + line.startsWith('index ') || + line.startsWith('---') || + line.startsWith('+++') || + line.startsWith('\\')) { + continue; + } + + if (line.startsWith('+')) { + currentHunk.lines.push({ + type: 'add', + content: line.slice(1), + newNumber: newLineNum++, + }); + } else if (line.startsWith('-')) { + currentHunk.lines.push({ + type: 'delete', + content: line.slice(1), + oldNumber: oldLineNum++, + }); + } else if (line.startsWith(' ') || line === '') { + currentHunk.lines.push({ + type: 'context', + content: line.slice(1) || '', + oldNumber: oldLineNum++, + newNumber: newLineNum++, + }); + } + } + + if (currentHunk && currentHunk.lines.length > 0) { + hunks.push(currentHunk); + } + + return hunks; + } + + async getFullDiff(taskId: string): Promise { + const summary = await this.getDiffSummary(taskId); + const diffs: FileDiff[] = []; + + for (const file of summary.files) { + try { + const diff = await this.getFileDiff(taskId, file.path); + diffs.push(diff); + } catch (e) { + // Skip files that can't be diffed (binary, etc.) + console.warn(`Could not get diff for ${file.path}:`, e); + } + } + + return diffs; + } +} diff --git a/sprint-4.md b/sprint-4.md new file mode 100644 index 00000000..4ea06a5a --- /dev/null +++ b/sprint-4.md @@ -0,0 +1,25 @@ +# Sprint 4: Code Review & Diff Viewing + +**Goal:** Review agent changes before merging. + +**Started:** 2026-01-26 +**Status:** In Progress + +--- + +## Stories + +| ID | Title | Status | Notes | +|----|-------|--------|-------| +| US-401 | Diff viewer component | ⏳ Todo | File tree + unified diff | +| US-402 | Line-level comments | ⏳ Todo | Inline comment UI | +| US-403 | Send review to agent | ⏳ Todo | Restart agent with feedback | +| US-404 | Approve and merge | ⏳ Todo | Merge flow | + +--- + +## Progress Log + +### 2026-01-26 + +(Starting Sprint 4) diff --git a/web/package.json b/web/package.json index 5e2b7156..2b3224a7 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.62.0", diff --git a/web/src/components/task/DiffViewer.tsx b/web/src/components/task/DiffViewer.tsx new file mode 100644 index 00000000..0ca6c2de --- /dev/null +++ b/web/src/components/task/DiffViewer.tsx @@ -0,0 +1,296 @@ +import { useState } from 'react'; +import { useDiffSummary, useFileDiff } from '@/hooks/useDiff'; +import { + FileCode, + FilePlus, + FileMinus, + FileEdit, + ChevronRight, + Loader2, + AlertCircle, + Plus, +} from 'lucide-react'; +import type { Task } from '@veritas-kanban/shared'; +import type { FileChange, DiffLine, DiffHunk } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +interface DiffViewerProps { + task: Task; + onAddComment?: (filePath: string, lineNumber: number) => void; +} + +const statusIcons: Record = { + added: , + modified: , + deleted: , + renamed: , +}; + +function FileTree({ + files, + selectedFile, + onSelectFile, +}: { + files: FileChange[]; + selectedFile: string | null; + onSelectFile: (path: string) => void; +}) { + return ( +
+ {files.map((file) => ( + + ))} +
+ ); +} + +function DiffHunkView({ + hunk, + filePath, + onAddComment, +}: { + hunk: DiffHunk; + filePath: string; + onAddComment?: (filePath: string, lineNumber: number) => void; +}) { + return ( +
+ {/* Hunk header */} +
+ @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@ +
+ + {/* Lines */} +
+ {hunk.lines.map((line, idx) => ( + + ))} +
+
+ ); +} + +function DiffLineView({ + line, + filePath, + onAddComment, +}: { + line: DiffLine; + filePath: string; + onAddComment?: (filePath: string, lineNumber: number) => void; +}) { + const lineNumber = line.newNumber || line.oldNumber; + + return ( +
+ {/* Line numbers */} +
+ + {line.oldNumber || ''} + + + {line.newNumber || ''} + +
+ + {/* Add comment button */} + {onAddComment && lineNumber && ( + + )} + + {/* Change indicator */} +
+ {line.type === 'add' && +} + {line.type === 'delete' && -} +
+ + {/* Content */} +
+        {line.content || ' '}
+      
+
+ ); +} + +function FileDiffView({ + taskId, + filePath, + onAddComment, +}: { + taskId: string; + filePath: string; + onAddComment?: (filePath: string, lineNumber: number) => void; +}) { + const { data: diff, isLoading, error } = useFileDiff(taskId, filePath); + + if (isLoading) { + return ( +
+ + Loading diff... +
+ ); + } + + if (error || !diff) { + return ( +
+ + {(error as Error)?.message || 'Failed to load diff'} +
+ ); + } + + if (diff.hunks.length === 0) { + return ( +
+ No changes in this file +
+ ); + } + + return ( +
+ {/* File header */} +
+
+ {statusIcons[diff.status]} + {diff.path} +
+
+ +{diff.additions} + -{diff.deletions} +
+
+ + {/* Hunks */} +
+ {diff.hunks.map((hunk, idx) => ( + + ))} +
+
+ ); +} + +export function DiffViewer({ task, onAddComment }: DiffViewerProps) { + const hasWorktree = !!task.git?.worktreePath; + const { data: summary, isLoading, error } = useDiffSummary(task.id, hasWorktree); + const [selectedFile, setSelectedFile] = useState(null); + + if (!hasWorktree) { + return ( +
+ + No worktree active +
+ ); + } + + if (isLoading) { + return ( +
+ + Loading changes... +
+ ); + } + + if (error) { + return ( +
+ + {(error as Error)?.message || 'Failed to load changes'} +
+ ); + } + + if (!summary || summary.files.length === 0) { + return ( +
+ No changes detected +
+ ); + } + + return ( +
+ {/* File tree */} +
+
+
+ Changed Files ({summary.totalFiles}) +
+
+ +{summary.totalAdditions} + {' / '} + -{summary.totalDeletions} +
+
+
+ +
+
+ + {/* Diff view */} +
+ {selectedFile ? ( + + ) : ( +
+ + Select a file to view changes +
+ )} +
+
+ ); +} diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx index 0ed87243..36f6eca0 100644 --- a/web/src/components/task/TaskDetailPanel.tsx +++ b/web/src/components/task/TaskDetailPanel.tsx @@ -5,6 +5,12 @@ import { SheetHeader, SheetTitle, } from '@/components/ui/sheet'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs'; import { AlertDialog, AlertDialogAction, @@ -28,10 +34,11 @@ import { SelectValue, } from '@/components/ui/select'; import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks'; -import { Trash2, Code, Search, FileText, Zap, Calendar, Clock } from 'lucide-react'; +import { Trash2, Code, Search, FileText, Zap, Calendar, Clock, GitBranch, Bot, FileDiff } from 'lucide-react'; import type { Task, TaskType, TaskStatus, TaskPriority } from '@veritas-kanban/shared'; import { GitSection } from './GitSection'; import { AgentPanel } from './AgentPanel'; +import { DiffViewer } from './DiffViewer'; interface TaskDetailPanelProps { task: Task | null; @@ -70,13 +77,11 @@ function useDebouncedSave(task: Task | null, updateTask: ReturnType(task); const [isDirty, setIsDirty] = useState(false); - // Sync with external task when it changes useEffect(() => { setLocalTask(task); setIsDirty(false); }, [task]); - // Debounced save useEffect(() => { if (!isDirty || !localTask) return; @@ -112,15 +117,14 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro const updateTask = useUpdateTask(); const deleteTask = useDeleteTask(); const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask); + const [activeTab, setActiveTab] = useState('details'); - // Handle Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && open) { onOpenChange(false); } }; - document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [open, onOpenChange]); @@ -143,10 +147,13 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro }); }; + const isCodeTask = localTask.type === 'code'; + const hasWorktree = !!localTask.git?.worktreePath; + return ( - - + +
{typeIcons[localTask.type]} {typeLabels[localTask.type]} Task @@ -164,165 +171,186 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro -
- {/* Description */} -
- -