feat(KB-651): add changed files diff viewer for tasks

- Add backend API endpoint to get changed files and diffs for a task worktree
- Add useChangedFiles hook for fetching and managing diff data
- Create ChangedFilesModal component with file list and diff viewer
- Integrate modal into TaskCard with click handler to view changes
- Add frontend API integration and type definitions
- Include unit tests for hook and modal components
- Add changeset for the new feature
- Remove deprecated mission-related test files
This commit is contained in:
gsxdsm
2026-04-01 02:01:31 -07:00
parent e60edfecbb
commit cf5b4e55d6
14 changed files with 622 additions and 9 deletions

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useChangedFiles } from "../useChangedFiles";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchTaskFileDiffs: vi.fn(),
}));
const mockFetchTaskFileDiffs = vi.mocked(api.fetchTaskFileDiffs);
describe("useChangedFiles", () => {
beforeEach(() => {
mockFetchTaskFileDiffs.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it("fetches changed files for active tasks with a worktree and auto-selects first file", async () => {
mockFetchTaskFileDiffs.mockResolvedValueOnce([
{ path: "src/a.ts", status: "modified", diff: "diff --git a/src/a.ts b/src/a.ts" },
{ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" },
]);
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-progress"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.files).toHaveLength(2);
expect(result.current.selectedFile?.path).toBe("src/a.ts");
expect(mockFetchTaskFileDiffs).toHaveBeenCalledWith("KB-651");
});
it("does not fetch for tasks without worktrees or inactive columns", async () => {
const { result: noWorktree } = renderHook(() => useChangedFiles("KB-651", undefined, "in-progress"));
const { result: inactive } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "todo"));
await waitFor(() => expect(noWorktree.current.loading).toBe(false));
await waitFor(() => expect(inactive.current.loading).toBe(false));
expect(noWorktree.current.files).toEqual([]);
expect(noWorktree.current.selectedFile).toBeNull();
expect(inactive.current.files).toEqual([]);
expect(inactive.current.selectedFile).toBeNull();
expect(mockFetchTaskFileDiffs).not.toHaveBeenCalled();
});
it("returns an error state on fetch failure", async () => {
mockFetchTaskFileDiffs.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-review"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual([]);
expect(result.current.selectedFile).toBeNull();
expect(result.current.error).toBe("boom");
});
it("allows selecting a different file after data loads", async () => {
mockFetchTaskFileDiffs.mockResolvedValueOnce([
{ path: "src/a.ts", status: "modified", diff: "first" },
{ path: "src/b.ts", status: "added", diff: "second" },
]);
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-progress"));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.setSelectedFile(result.current.files[1]!);
});
expect(result.current.selectedFile?.path).toBe("src/b.ts");
});
});

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { fetchTaskFileDiffs, type TaskFileDiff } from "../api";
const ACTIVE_COLUMNS = new Set(["in-progress", "in-review"]);
interface UseChangedFilesResult {
files: TaskFileDiff[];
loading: boolean;
error: string | null;
selectedFile: TaskFileDiff | null;
setSelectedFile: (file: TaskFileDiff) => void;
}
export function useChangedFiles(taskId: string, worktree: string | undefined, column: string): UseChangedFilesResult {
const [files, setFiles] = useState<TaskFileDiff[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<TaskFileDiff | null>(null);
useEffect(() => {
if (!taskId || !worktree || !ACTIVE_COLUMNS.has(column)) {
setFiles([]);
setLoading(false);
setError(null);
setSelectedFile(null);
return;
}
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const result = await fetchTaskFileDiffs(taskId);
if (cancelled) return;
setFiles(result);
setSelectedFile((current) => {
if (result.length === 0) return null;
if (current) {
const match = result.find((file) => file.path === current.path && file.oldPath === current.oldPath);
if (match) return match;
}
return result[0] ?? null;
});
} catch (err) {
if (cancelled) return;
setFiles([]);
setSelectedFile(null);
setError(err instanceof Error ? err.message : "Failed to load changed files");
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [taskId, worktree, column]);
return { files, loading, error, selectedFile, setSelectedFile };
}