import { useState, useEffect, useCallback } from "react"; import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react"; import type { MergeDetails, Column } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchTaskDiff, fetchTaskCommitAssociations, type TaskDiff, type TaskCommitAssociationRow, } from "../api"; import { highlightDiff } from "../utils/highlightDiff"; import { ChangesDiffModal } from "./ChangesDiffModal"; import "./TaskDiffShared.css"; import "./TaskChangesTab.css"; interface TaskChangesTabProps { taskId: string; worktree?: string; projectId?: string; column?: Column; mergeDetails?: MergeDetails; /** * Files modified by the task during execution, captured from the worktree. * Used as a last-resort fallback when the live worktree diff is empty or the * recorded `mergeDetails.commitSha` resolves to an empty git commit (which * can happen when the merger stores a per-branch SHA that gets collapsed * into a different squash on main). * * Done-task sources of truth: * - Authoritative landed diff: `/api/tasks/:id/diff` lineage union. * - Fallback views keep a consistent `N file(s) changed` headline. * - Provenance is disclosed in `task-changes-state-hint` text. */ modifiedFiles?: string[]; } function getStatusLabel(status: "added" | "modified" | "deleted" | "unknown"): string { switch (status) { case "added": return "A"; case "deleted": return "D"; case "modified": return "M"; default: return "?"; } } function renderModifiedFilesFallback( fileList: string[], isDone: boolean, mergeDetails?: MergeDetails, source: "landed" | "execution" = "execution", ) { return (
{isDone && mergeDetails && (
{mergeDetails.commitSha && (
{mergeDetails.commitSha.slice(0, 7)}
)} {mergeDetails.mergedAt && (
Merged {new Date(mergeDetails.mergedAt).toLocaleString()}
)}
)}

{fileList.length} file{fileList.length === 1 ? "" : "s"} changed.

{isDone // FN-4647: done-task fallback must explicitly describe executor-captured scope. ? source === "landed" ? "These are files captured from the merged commit metadata. The lineage-backed diff is unavailable for this task." : "These are files captured from the worktree during execution. They may differ from the files that actually landed on main. The lineage-backed diff is unavailable for this task." : "The live worktree diff is empty. Showing the last file paths captured during execution — patches unavailable."}
{fileList.map((path) => (
{getStatusLabel("unknown")} {path}
))}
); } /** 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 it always attempts `/api/tasks/:id/diff` (lineage-backed when * needed) so the detailed view stays aligned with TaskCard/useTaskDiffStats. * * When a done task has no recorded merge commit SHA and the server returns no * diff rows (or diff loading fails), the tab falls back to the safe summary/ * modifiedFiles view instead of showing a hard error. This preserves the prior * graceful behavior while allowing FN-4563/FN-4576 lineage-backed parity. */ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails, modifiedFiles }: TaskChangesTabProps) { const [files, setFiles] = useState([]); 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(null); const [commitAssociations, setCommitAssociations] = useState([]); const [lineageId, setLineageId] = useState(null); const [expandedFiles, setExpandedFiles] = useState>(new Set()); const [currentFileIndex, setCurrentFileIndex] = useState(null); const [wordWrap, setWordWrap] = useState(true); const [expandedViewOpen, setExpandedViewOpen] = useState(false); const isDone = column === "done"; const isDoneWithCommit = isDone && Boolean(mergeDetails?.commitSha); const canLoad = column === "in-progress" || column === "in-review" || isDone; const loadDiff = useCallback(async () => { if (!canLoad && !isDone) { setLoading(false); return; } try { setLoading(true); setError(null); const associationsData = await fetchTaskCommitAssociations(taskId, projectId); setLineageId(associationsData.lineageId); setCommitAssociations(associationsData.associations); if (!canLoad) { setFiles([]); setStats({ filesChanged: 0, additions: 0, deletions: 0 }); return; } 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])); setCurrentFileIndex(0); } } catch (err) { if (isDone && !mergeDetails?.commitSha) { setFiles([]); setStats({ filesChanged: 0, additions: 0, deletions: 0 }); setError(null); } else { setError(getErrorMessage(err) || "Failed to load task changes"); } } finally { setLoading(false); } }, [taskId, projectId, canLoad, isDone, mergeDetails?.commitSha]); useEffect(() => { loadDiff(); }, [loadDiff]); const toggleFile = (filePath: string) => { setExpandedFiles((prev) => { const next = new Set(prev); if (next.has(filePath)) { next.delete(filePath); } else { next.add(filePath); // Update currentFileIndex to the newly expanded file const idx = files.findIndex((f) => f.path === filePath); if (idx !== -1) { setCurrentFileIndex(idx); } } return next; }); }; const navigateToFile = (index: number) => { if (index < 0 || index >= files.length) return; const targetPath = files[index].path; // Collapse all files and expand only the target setExpandedFiles(new Set([targetPath])); setCurrentFileIndex(index); }; const canGoPrev = currentFileIndex !== null && currentFileIndex > 0; const canGoNext = currentFileIndex !== null && currentFileIndex < files.length - 1; if (loading) { return (
Loading changes...
); } if (error) { return (
Error loading changes: {error}
); } // Non-done task without a worktree → only show fallback state when branch-fallback diff is empty. if (!isDone && !worktree && files.length === 0) { if (modifiedFiles && modifiedFiles.length > 0) { return renderModifiedFilesFallback(modifiedFiles, false); } return (

No worktree available for this task.

Changes will be shown once the task is in progress.
); } const renderCommitAssociations = () => (

Lineage commit associations

{lineageId && ( {lineageId} )}
{commitAssociations.length === 0 ? (

No associated commits recorded yet.

) : (
{commitAssociations.map((association) => { const matchedLabel = association.matchedBy.replace(/-/g, " "); return (
{association.commitSha.slice(0, 7)} {association.commitSubject}
{new Date(association.authoredAt).toLocaleString()} Confidence: {association.confidence} Match: {matchedLabel} Task snapshot: {association.taskIdSnapshot}
{association.note &&

{association.note}

}
); })}
)}
); if (files.length === 0) { if (isDone && !isDoneWithCommit) { const doneFallbackFiles = mergeDetails?.landedFiles && mergeDetails.landedFiles.length > 0 ? mergeDetails.landedFiles : modifiedFiles; if (doneFallbackFiles && doneFallbackFiles.length > 0) { return renderModifiedFilesFallback(doneFallbackFiles, true, mergeDetails, mergeDetails?.landedFiles?.length ? "landed" : "execution"); } const summaryFiles = mergeDetails?.filesChanged; const summaryAdditions = mergeDetails?.insertions; const summaryDeletions = mergeDetails?.deletions; const hasSummary = summaryFiles != null || summaryAdditions != null || summaryDeletions != null; return (

Detailed file changes unavailable.

{hasSummary ? `Final commit summary: ${summaryFiles ?? 0} file${(summaryFiles ?? 0) === 1 ? "" : "s"} changed, +${summaryAdditions ?? 0} additions, -${summaryDeletions ?? 0} deletions. Counts only the recorded merge/squash commit, not the full task lineage.` : "No merge commit was recorded for this task."}
); } if (!isDone && modifiedFiles && modifiedFiles.length > 0) { return renderModifiedFilesFallback(modifiedFiles, isDone, mergeDetails); } return (
{renderCommitAssociations()}

No files modified.

{isDone ? "No file changes were recorded in the merge commit." : "The agent did not modify any files during execution."}
); } return (
{renderCommitAssociations()} {/* Commit metadata for done tasks */} {isDone && mergeDetails && (
{mergeDetails.commitSha && (
{mergeDetails.commitSha.slice(0, 7)}
)} {mergeDetails.mergeCommitMessage && (
{mergeDetails.mergeCommitMessage}
)} {mergeDetails.mergedAt && (
Merged {new Date(mergeDetails.mergedAt).toLocaleString()}
)} {mergeDetails.noOpVerifiedShortCircuit && (
Verified short-circuit — work was already on main (rebase walked foreign commits).
)} {mergeDetails.landedFilesCaptureFallback === "attribution-failed" && (
Landed-files set may include foreign commits (attribution unavailable).
)}
)}

Files Changed ({stats.filesChanged})

+{stats.additions}{" "} -{stats.deletions}
{files.length > 0 && (
{currentFileIndex !== null ? `${currentFileIndex + 1}/${files.length}` : `—/${files.length}`}
)}
{files.map((file) => { const isExpanded = expandedFiles.has(file.path); return (
{isExpanded && file.patch && (
                    {highlightDiff(file.patch)}
                  
)}
); })}
setExpandedViewOpen(false)} onRefresh={loadDiff} />
); }