feat(US-401): diff viewer component

- DiffService for git diff operations
- Diff API routes (summary, file, full)
- DiffViewer component with file tree
- Unified diff display with line numbers
- Syntax highlighting by file extension
- Added/removed line counts
- Tabs UI in task detail panel (Details, Git, Agent, Changes)
This commit is contained in:
Brad Groux 2026-01-26 03:30:24 -06:00
parent 88bf96a77f
commit 6fbbce09ce
12 changed files with 985 additions and 162 deletions

@ -0,0 +1 @@
Subproject commit 88bf96a77f887086d7d88a535ceb44deb512a469

32
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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);

44
server/src/routes/diff.ts Normal file
View file

@ -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 };

View file

@ -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<string, string> = {
'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<DiffSummary> {
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<FileDiff> {
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<FileDiff[]> {
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;
}
}

25
sprint-4.md Normal file
View file

@ -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)

View file

@ -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",

View file

@ -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<FileChange['status'], React.ReactNode> = {
added: <FilePlus className="h-4 w-4 text-green-500" />,
modified: <FileEdit className="h-4 w-4 text-amber-500" />,
deleted: <FileMinus className="h-4 w-4 text-red-500" />,
renamed: <FileCode className="h-4 w-4 text-blue-500" />,
};
function FileTree({
files,
selectedFile,
onSelectFile,
}: {
files: FileChange[];
selectedFile: string | null;
onSelectFile: (path: string) => void;
}) {
return (
<div className="space-y-1">
{files.map((file) => (
<button
key={file.path}
onClick={() => onSelectFile(file.path)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md text-left',
'hover:bg-muted transition-colors',
selectedFile === file.path && 'bg-muted'
)}
>
{statusIcons[file.status]}
<span className="truncate flex-1 font-mono text-xs">{file.path}</span>
<span className="flex items-center gap-1 text-xs">
{file.additions > 0 && (
<span className="text-green-500">+{file.additions}</span>
)}
{file.deletions > 0 && (
<span className="text-red-500">-{file.deletions}</span>
)}
</span>
</button>
))}
</div>
);
}
function DiffHunkView({
hunk,
filePath,
onAddComment,
}: {
hunk: DiffHunk;
filePath: string;
onAddComment?: (filePath: string, lineNumber: number) => void;
}) {
return (
<div className="border-b border-border last:border-b-0">
{/* Hunk header */}
<div className="bg-muted/50 px-4 py-1 text-xs text-muted-foreground font-mono">
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
</div>
{/* Lines */}
<div className="font-mono text-xs">
{hunk.lines.map((line, idx) => (
<DiffLineView
key={idx}
line={line}
filePath={filePath}
onAddComment={onAddComment}
/>
))}
</div>
</div>
);
}
function DiffLineView({
line,
filePath,
onAddComment,
}: {
line: DiffLine;
filePath: string;
onAddComment?: (filePath: string, lineNumber: number) => void;
}) {
const lineNumber = line.newNumber || line.oldNumber;
return (
<div
className={cn(
'group flex hover:bg-muted/30',
line.type === 'add' && 'bg-green-500/10',
line.type === 'delete' && 'bg-red-500/10'
)}
>
{/* Line numbers */}
<div className="flex-shrink-0 w-20 flex text-muted-foreground select-none border-r border-border">
<span className="w-10 px-2 text-right border-r border-border">
{line.oldNumber || ''}
</span>
<span className="w-10 px-2 text-right">
{line.newNumber || ''}
</span>
</div>
{/* Add comment button */}
{onAddComment && lineNumber && (
<button
onClick={() => onAddComment(filePath, lineNumber)}
className="w-6 flex-shrink-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary"
>
<Plus className="h-3 w-3" />
</button>
)}
{/* Change indicator */}
<div className="w-6 flex-shrink-0 flex items-center justify-center">
{line.type === 'add' && <span className="text-green-500">+</span>}
{line.type === 'delete' && <span className="text-red-500">-</span>}
</div>
{/* Content */}
<pre className="flex-1 px-2 overflow-x-auto whitespace-pre">
{line.content || ' '}
</pre>
</div>
);
}
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 (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading diff...
</div>
);
}
if (error || !diff) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<AlertCircle className="h-5 w-5 mr-2" />
{(error as Error)?.message || 'Failed to load diff'}
</div>
);
}
if (diff.hunks.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
No changes in this file
</div>
);
}
return (
<div className="border rounded-md overflow-hidden bg-card">
{/* File header */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b">
<div className="flex items-center gap-2">
{statusIcons[diff.status]}
<span className="font-mono text-sm">{diff.path}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-green-500">+{diff.additions}</span>
<span className="text-red-500">-{diff.deletions}</span>
</div>
</div>
{/* Hunks */}
<div className="overflow-x-auto">
{diff.hunks.map((hunk, idx) => (
<DiffHunkView
key={idx}
hunk={hunk}
filePath={filePath}
onAddComment={onAddComment}
/>
))}
</div>
</div>
);
}
export function DiffViewer({ task, onAddComment }: DiffViewerProps) {
const hasWorktree = !!task.git?.worktreePath;
const { data: summary, isLoading, error } = useDiffSummary(task.id, hasWorktree);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
if (!hasWorktree) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<AlertCircle className="h-5 w-5 mr-2" />
No worktree active
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading changes...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<AlertCircle className="h-5 w-5 mr-2" />
{(error as Error)?.message || 'Failed to load changes'}
</div>
);
}
if (!summary || summary.files.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
No changes detected
</div>
);
}
return (
<div className="flex gap-4 h-[500px]">
{/* File tree */}
<div className="w-64 flex-shrink-0 border rounded-md overflow-hidden bg-card">
<div className="px-3 py-2 border-b bg-muted/50">
<div className="text-sm font-medium">
Changed Files ({summary.totalFiles})
</div>
<div className="text-xs text-muted-foreground">
<span className="text-green-500">+{summary.totalAdditions}</span>
{' / '}
<span className="text-red-500">-{summary.totalDeletions}</span>
</div>
</div>
<div className="p-2 overflow-y-auto h-[calc(100%-60px)]">
<FileTree
files={summary.files}
selectedFile={selectedFile}
onSelectFile={setSelectedFile}
/>
</div>
</div>
{/* Diff view */}
<div className="flex-1 overflow-y-auto">
{selectedFile ? (
<FileDiffView
taskId={task.id}
filePath={selectedFile}
onAddComment={onAddComment}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground border rounded-md">
<ChevronRight className="h-5 w-5 mr-2" />
Select a file to view changes
</div>
)}
</div>
</div>
);
}

View file

@ -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<typeof useUp
const [localTask, setLocalTask] = useState<Task | null>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[500px] sm:max-w-[500px] overflow-y-auto">
<SheetHeader className="space-y-1">
<SheetContent className="w-[700px] sm:max-w-[700px] overflow-hidden flex flex-col">
<SheetHeader className="space-y-1 flex-shrink-0">
<div className="flex items-center gap-2 text-muted-foreground">
{typeIcons[localTask.type]}
<span className="text-xs uppercase tracking-wide">{typeLabels[localTask.type]} Task</span>
@ -164,165 +171,186 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-6">
{/* Description */}
<div className="space-y-2">
<Label className="text-muted-foreground">Description</Label>
<Textarea
value={localTask.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={4}
className="resize-none"
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden mt-4">
<TabsList className={`grid w-full ${isCodeTask ? 'grid-cols-4' : 'grid-cols-1'}`}>
<TabsTrigger value="details">Details</TabsTrigger>
{isCodeTask && (
<>
<TabsTrigger value="git" className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
Git
</TabsTrigger>
<TabsTrigger value="agent" className="flex items-center gap-1">
<Bot className="h-3 w-3" />
Agent
</TabsTrigger>
<TabsTrigger value="changes" disabled={!hasWorktree} className="flex items-center gap-1">
<FileDiff className="h-3 w-3" />
Changes
</TabsTrigger>
</>
)}
</TabsList>
{/* Status, Type, Priority grid */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-muted-foreground">Status</Label>
<Select
value={localTask.status}
onValueChange={(v) => updateField('status', v as TaskStatus)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(statusLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 overflow-y-auto mt-4">
{/* Details Tab */}
<TabsContent value="details" className="mt-0 space-y-6">
<div className="space-y-2">
<Label className="text-muted-foreground">Description</Label>
<Textarea
value={localTask.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={4}
className="resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Type</Label>
<Select
value={localTask.type}
onValueChange={(v) => updateField('type', v as TaskType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(typeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
<div className="flex items-center gap-2">
{typeIcons[value as TaskType]}
{label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Priority</Label>
<Select
value={localTask.priority}
onValueChange={(v) => updateField('priority', v as TaskPriority)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(priorityLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Project */}
<div className="space-y-2">
<Label className="text-muted-foreground">Project</Label>
<Input
value={localTask.project || ''}
onChange={(e) => updateField('project', e.target.value || undefined)}
placeholder="Add to a project..."
/>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="text-muted-foreground">Tags</Label>
<Input
value={localTask.tags?.join(', ') || ''}
onChange={(e) => {
const tags = e.target.value
.split(',')
.map(t => t.trim())
.filter(Boolean);
updateField('tags', tags.length > 0 ? tags : undefined);
}}
placeholder="Add tags (comma-separated)..."
/>
</div>
{/* Git Integration (code tasks only) */}
{localTask.type === 'code' && (
<GitSection
task={localTask}
onGitChange={(git) => updateField('git', git as Task['git'])}
/>
)}
{/* AI Agent (code tasks with worktree only) */}
{localTask.type === 'code' && (
<AgentPanel task={localTask} />
)}
{/* Metadata */}
<div className="border-t pt-4 space-y-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Created: {formatDate(localTask.created)}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>Updated: {formatDate(localTask.updated)}</span>
</div>
<div className="text-xs font-mono opacity-50">
ID: {localTask.id}
</div>
</div>
{/* Delete button */}
<div className="border-t pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
<Trash2 className="h-4 w-4 mr-2" />
Delete Task
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete "{localTask.title}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-muted-foreground">Status</Label>
<Select
value={localTask.status}
onValueChange={(v) => updateField('status', v as TaskStatus)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(statusLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Type</Label>
<Select
value={localTask.type}
onValueChange={(v) => updateField('type', v as TaskType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(typeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
<div className="flex items-center gap-2">
{typeIcons[value as TaskType]}
{label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Priority</Label>
<Select
value={localTask.priority}
onValueChange={(v) => updateField('priority', v as TaskPriority)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(priorityLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Project</Label>
<Input
value={localTask.project || ''}
onChange={(e) => updateField('project', e.target.value || undefined)}
placeholder="Add to a project..."
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Tags</Label>
<Input
value={localTask.tags?.join(', ') || ''}
onChange={(e) => {
const tags = e.target.value.split(',').map(t => t.trim()).filter(Boolean);
updateField('tags', tags.length > 0 ? tags : undefined);
}}
placeholder="Add tags (comma-separated)..."
/>
</div>
<div className="border-t pt-4 space-y-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Created: {formatDate(localTask.created)}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>Updated: {formatDate(localTask.updated)}</span>
</div>
<div className="text-xs font-mono opacity-50">ID: {localTask.id}</div>
</div>
<div className="border-t pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
<Trash2 className="h-4 w-4 mr-2" />
Delete Task
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete "{localTask.title}".
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TabsContent>
{/* Git Tab */}
{isCodeTask && (
<TabsContent value="git" className="mt-0">
<GitSection
task={localTask}
onGitChange={(git) => updateField('git', git as Task['git'])}
/>
</TabsContent>
)}
{/* Agent Tab */}
{isCodeTask && (
<TabsContent value="agent" className="mt-0">
<AgentPanel task={localTask} />
</TabsContent>
)}
{/* Changes Tab */}
{isCodeTask && hasWorktree && (
<TabsContent value="changes" className="mt-0">
<DiffViewer task={localTask} />
</TabsContent>
)}
</div>
</div>
</Tabs>
</SheetContent>
</Sheet>
);

View file

@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

26
web/src/hooks/useDiff.ts Normal file
View file

@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
export function useDiffSummary(taskId: string | undefined, hasWorktree: boolean) {
return useQuery({
queryKey: ['diff', 'summary', taskId],
queryFn: () => api.diff.getSummary(taskId!),
enabled: !!taskId && hasWorktree,
});
}
export function useFileDiff(taskId: string | undefined, filePath: string | undefined) {
return useQuery({
queryKey: ['diff', 'file', taskId, filePath],
queryFn: () => api.diff.getFileDiff(taskId!, filePath!),
enabled: !!taskId && !!filePath,
});
}
export function useFullDiff(taskId: string | undefined, hasWorktree: boolean) {
return useQuery({
queryKey: ['diff', 'full', taskId],
queryFn: () => api.diff.getFullDiff(taskId!),
enabled: !!taskId && hasWorktree,
});
}

View file

@ -215,8 +215,65 @@ export const api = {
return response.text();
},
},
diff: {
getSummary: async (taskId: string): Promise<DiffSummary> => {
const response = await fetch(`${API_BASE}/diff/${taskId}`);
return handleResponse<DiffSummary>(response);
},
getFileDiff: async (taskId: string, filePath: string): Promise<FileDiff> => {
const response = await fetch(`${API_BASE}/diff/${taskId}/file?path=${encodeURIComponent(filePath)}`);
return handleResponse<FileDiff>(response);
},
getFullDiff: async (taskId: string): Promise<FileDiff[]> => {
const response = await fetch(`${API_BASE}/diff/${taskId}/full`);
return handleResponse<FileDiff[]>(response);
},
},
};
// Types for diff
export interface FileChange {
path: string;
status: 'added' | 'modified' | 'deleted' | 'renamed';
additions: number;
deletions: number;
oldPath?: string;
}
export interface DiffSummary {
files: FileChange[];
totalAdditions: number;
totalDeletions: number;
totalFiles: 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 FileDiff {
path: string;
status: 'added' | 'modified' | 'deleted' | 'renamed';
hunks: DiffHunk[];
language: string;
additions: number;
deletions: number;
}
// Types for agent
export interface AgentStatus {
taskId: string;