feat(FN-818): use merge commit diff for done-task Changes tab

- Refactor TaskChangesTab to use merge commit diff for done/archived tasks instead of branch diff
- Add CommitDiffTab component integration for displaying merge commit changes
- Remove legacy API endpoints and tests for branch-based diff on completed tasks
- Add comprehensive test suite for TaskChangesTab covering all task lifecycle states
- Document merge commit diff behavior in dashboard README
This commit is contained in:
gsxdsm
2026-04-03 23:04:13 -07:00
parent d2266466fd
commit 98bcb5df5c
6 changed files with 535 additions and 51 deletions

View File

@@ -1,21 +1,16 @@
import { useState, useEffect, useCallback } from "react";
import { FileCode, ChevronDown, ChevronRight, AlertCircle } from "lucide-react";
import { fetchTaskDiff, type TaskDiff } from "../api";
import { FileCode, ChevronDown, ChevronRight, AlertCircle, GitCommit } from "lucide-react";
import type { MergeDetails, Column } from "@fusion/core";
import { fetchTaskDiff, fetchCommitDiff, type TaskDiff } from "../api";
import { parsePatch, type ParsedFile } from "./CommitDiffTab";
import { highlightDiff } from "../utils/highlightDiff";
interface TaskChangesTabProps {
taskId: string;
worktree?: string;
projectId?: string;
}
function getFileStatus(file: string, patch: string): "added" | "modified" | "deleted" | "unknown" {
if (patch.includes("diff --git")) {
if (patch.includes("new file mode")) return "added";
if (patch.includes("deleted file mode")) return "deleted";
return "modified";
}
return "unknown";
column?: Column;
mergeDetails?: MergeDetails;
}
function getStatusColor(status: "added" | "modified" | "deleted" | "unknown"): string {
@@ -31,19 +26,79 @@ function getStatusColor(status: "added" | "modified" | "deleted" | "unknown"): s
}
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
function getStatusLabel(status: "added" | "modified" | "deleted" | "unknown"): string {
switch (status) {
case "added":
return "A";
case "deleted":
return "D";
case "modified":
return "M";
default:
return "?";
}
}
export function TaskChangesTab({ taskId, worktree, projectId }: TaskChangesTabProps) {
const [diffData, setDiffData] = useState<TaskDiff | null>(null);
/** Normalized file entry used by both worktree-backed and commit-backed paths */
interface NormalizedFile {
path: string;
status: "added" | "modified" | "deleted" | "unknown";
additions: number;
deletions: number;
patch: string;
}
/**
* TaskChangesTab displays file-level diffs for a task.
*
* For in-progress/in-review tasks it loads the diff from the live worktree.
* For done tasks with a recorded merge commit (mergeDetails.commitSha) it loads
* the diff from git history instead, so changes remain visible even after the
* worktree is cleaned up.
*/
export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails }: TaskChangesTabProps) {
const [files, setFiles] = useState<NormalizedFile[]>([]);
const [stats, setStats] = useState<{ filesChanged: number; additions: number; deletions: number }>({ filesChanged: 0, additions: 0, deletions: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const commitSha = column === "done" ? mergeDetails?.commitSha : undefined;
const useCommitDiff = !!commitSha;
const loadDiff = useCallback(async () => {
// Done task with merge commit → use commit-backed diff
if (useCommitDiff) {
try {
setLoading(true);
setError(null);
const data = await fetchCommitDiff(commitSha);
const parsed = parsePatch(data.patch || "");
const normalized: NormalizedFile[] = parsed.map((f) => ({
path: f.path,
status: f.status,
additions: f.additions,
deletions: f.deletions,
patch: f.patch,
}));
setFiles(normalized);
setStats({
filesChanged: mergeDetails?.filesChanged ?? normalized.length,
additions: mergeDetails?.insertions ?? normalized.reduce((s, f) => s + f.additions, 0),
deletions: mergeDetails?.deletions ?? normalized.reduce((s, f) => s + f.deletions, 0),
});
if (normalized.length > 0) {
setExpandedFiles(new Set([normalized[0].path]));
}
} catch (err: any) {
setError(err.message || "Failed to load commit diff");
} finally {
setLoading(false);
}
return;
}
// Non-done task → use worktree-backed diff
if (!worktree) {
setLoading(false);
return;
@@ -52,30 +107,37 @@ export function TaskChangesTab({ taskId, worktree, projectId }: TaskChangesTabPr
try {
setLoading(true);
setError(null);
const data = await fetchTaskDiff(taskId, undefined, projectId);
setDiffData(data);
// Auto-expand first file if there are files
if (data.files.length > 0) {
setExpandedFiles(new Set([data.files[0].path]));
const data: TaskDiff = await fetchTaskDiff(taskId, undefined, projectId);
const normalized: NormalizedFile[] = data.files.map((f) => ({
path: f.path,
status: f.status,
additions: f.additions,
deletions: f.deletions,
patch: f.patch,
}));
setFiles(normalized);
setStats(data.stats);
if (normalized.length > 0) {
setExpandedFiles(new Set([normalized[0].path]));
}
} catch (err: any) {
setError(err.message || "Failed to load diff");
} finally {
setLoading(false);
}
}, [taskId, worktree, projectId]);
}, [taskId, worktree, projectId, useCommitDiff, commitSha, mergeDetails]);
useEffect(() => {
loadDiff();
}, [loadDiff]);
const toggleFile = (file: string) => {
const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
if (next.has(file)) {
next.delete(file);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(file);
next.add(filePath);
}
return next;
});
@@ -103,7 +165,8 @@ export function TaskChangesTab({ taskId, worktree, projectId }: TaskChangesTabPr
);
}
if (!worktree) {
// Non-done task without a worktree → show worktree empty state
if (!useCommitDiff && !worktree) {
return (
<div className="detail-section">
<div className="task-changes-state task-changes-state--empty">
@@ -117,14 +180,16 @@ export function TaskChangesTab({ taskId, worktree, projectId }: TaskChangesTabPr
);
}
if (!diffData || diffData.files.length === 0) {
if (files.length === 0) {
return (
<div className="detail-section">
<div className="task-changes-state task-changes-state--empty">
<FileCode size={24} />
<p>No files modified.</p>
<span className="task-changes-state-hint">
The agent did not modify any files during execution.
{useCommitDiff
? "No file changes were recorded in the merge commit."
: "The agent did not modify any files during execution."}
</span>
</div>
</div>
@@ -133,10 +198,32 @@ export function TaskChangesTab({ taskId, worktree, projectId }: TaskChangesTabPr
return (
<div className="detail-section task-changes-tab">
{/* Commit metadata for done tasks */}
{useCommitDiff && mergeDetails && (
<div className="commit-diff-meta">
<div className="commit-diff-sha">
<GitCommit size={14} />
<code>{commitSha!.slice(0, 7)}</code>
</div>
{mergeDetails.mergeCommitMessage && (
<div className="commit-diff-message">{mergeDetails.mergeCommitMessage}</div>
)}
{mergeDetails.mergedAt && (
<div className="commit-diff-timestamp">
Merged {new Date(mergeDetails.mergedAt).toLocaleString()}
</div>
)}
</div>
)}
<div className="changes-header">
<h4>
<FileCode size={16} />
Modified Files ({diffData.files.length})
Files Changed ({stats.filesChanged})
<span className="changes-stat-summary">
<span className="diff-add">+{stats.additions}</span>{" "}
<span className="diff-del">-{stats.deletions}</span>
</span>
</h4>
<button
className="btn btn-sm"
@@ -148,46 +235,43 @@ export function TaskChangesTab({ taskId, worktree, projectId }: TaskChangesTabPr
</div>
<div className="changes-file-list">
{diffData.files.map((fileEntry) => {
const { path, status, patch } = fileEntry;
const isExpanded = expandedFiles.has(path);
{files.map((file) => {
const isExpanded = expandedFiles.has(file.path);
return (
<div
key={path}
key={file.path}
className={`changes-file-item ${isExpanded ? "expanded" : ""}`}
>
<button
className="changes-file-header"
onClick={() => toggleFile(path)}
onClick={() => toggleFile(file.path)}
>
<span className="changes-file-toggle">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span
className="changes-file-status"
style={{ color: getStatusColor(status) }}
title={status}
style={{ color: getStatusColor(file.status) }}
title={file.status}
>
{status === "added" && "A"}
{status === "modified" && "M"}
{status === "deleted" && "D"}
{getStatusLabel(file.status)}
</span>
<span className="changes-file-path" title={path}>
{path}
<span className="changes-file-path" title={file.path}>
{file.path}
</span>
<span
className="changes-file-stat"
title={`+${fileEntry.additions} -${fileEntry.deletions}`}
title={`+${file.additions} -${file.deletions}`}
>
+{fileEntry.additions} -{fileEntry.deletions}
+{file.additions} -{file.deletions}
</span>
</button>
{isExpanded && patch && (
{isExpanded && file.patch && (
<div className="changes-file-content">
<pre className="changes-diff-patch">
<code>{highlightDiff(patch)}</code>
<code>{highlightDiff(file.patch)}</code>
</pre>
</div>
)}