feat(FN-4521): complete Step 3 — unify active diff data source

Fusion-Task-Id: FN-4521
Fusion-Task-Lineage: 675472a2-480d-4b9e-8861-98d2d82680db
This commit is contained in:
Fusion
2026-05-14 13:31:59 -07:00
committed by gsxdsm
parent 203391f654
commit 0c7e7e67fd
5 changed files with 57 additions and 30 deletions

View File

@@ -1301,13 +1301,10 @@ function TaskCardComponent({
if (task.column === "in-progress") { if (task.column === "in-progress") {
const activeDiffCount = diffStats?.filesChanged; const activeDiffCount = diffStats?.filesChanged;
const fallbackCount = const fallbackCount =
activeDiffCount == null || activeDiffCount === 0 activeDiffCount == null
? task.modifiedFiles?.length ? task.modifiedFiles?.length
: undefined; : undefined;
const displayCount = const displayCount = activeDiffCount ?? fallbackCount;
activeDiffCount != null && activeDiffCount > 0
? activeDiffCount
: fallbackCount;
if (displayCount == null || displayCount === 0) { if (displayCount == null || displayCount === 0) {
return null; return null;
} }
@@ -1328,13 +1325,10 @@ function TaskCardComponent({
if (task.column === "in-review") { if (task.column === "in-review") {
const reviewDiffCount = diffStats?.filesChanged; const reviewDiffCount = diffStats?.filesChanged;
const fallbackCount = const fallbackCount =
reviewDiffCount == null || reviewDiffCount === 0 reviewDiffCount == null
? task.modifiedFiles?.length ? task.modifiedFiles?.length
: undefined; : undefined;
const displayCount = const displayCount = reviewDiffCount ?? fallbackCount;
reviewDiffCount != null && reviewDiffCount > 0
? reviewDiffCount
: fallbackCount;
if (displayCount == null || displayCount === 0) { if (displayCount == null || displayCount === 0) {
return null; return null;
} }

View File

@@ -231,8 +231,8 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
); );
} }
// Non-done task without a worktree → show worktree empty state // Non-done task without a worktree → only show fallback state when branch-fallback diff is empty.
if (!isDone && !worktree) { if (!isDone && !worktree && files.length === 0) {
if (modifiedFiles && modifiedFiles.length > 0) { if (modifiedFiles && modifiedFiles.length > 0) {
return renderModifiedFilesFallback(modifiedFiles, false); return renderModifiedFilesFallback(modifiedFiles, false);
} }

View File

@@ -116,6 +116,28 @@ describe("TaskChangesTab — worktree-backed (non-done tasks)", () => {
}); });
}); });
it("renders fetched file rows for active task without worktree when branch fallback has files", async () => {
mockFetchTaskDiff.mockResolvedValue({
files: [
{ path: "a.ts", status: "modified", additions: 1, deletions: 0, patch: "@@ -1 +1,2 @@" },
],
stats: { filesChanged: 1, additions: 1, deletions: 0 },
});
render(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="in-progress"
/>,
);
await waitFor(() => {
expect(screen.getByText("a.ts")).toBeTruthy();
});
expect(screen.queryByText("No worktree available for this task.")).toBeNull();
});
it("shows modifiedFiles fallback when an active task has no worktree diff", async () => { it("shows modifiedFiles fallback when an active task has no worktree diff", async () => {
mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } }); mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });

View File

@@ -53,25 +53,25 @@ describe("useTaskDiffStats", () => {
expect(mockFetchTaskDiff).toHaveBeenCalledWith("FN-123", undefined, "proj-1"); expect(mockFetchTaskDiff).toHaveBeenCalledWith("FN-123", undefined, "proj-1");
}); });
it("does not fetch for active columns without a worktree", async () => { it("fetches for active columns without a worktree", async () => {
mockFetchTaskDiff.mockResolvedValue({
files: [],
stats: { filesChanged: 2, additions: 4, deletions: 1 },
});
const { result: inProgress } = renderHook(() => const { result: inProgress } = renderHook(() =>
useTaskDiffStats("FN-123", "in-progress", "abc1234", undefined), useTaskDiffStats("FN-123", "in-progress", "abc1234", undefined),
); );
const { result: todo } = renderHook(() =>
useTaskDiffStats("FN-123", "todo", "abc1234", undefined),
);
const { result: inReview } = renderHook(() => const { result: inReview } = renderHook(() =>
useTaskDiffStats("FN-123", "in-review", "abc1234", undefined), useTaskDiffStats("FN-123", "in-review", "abc1234", undefined),
); );
await waitFor(() => expect(inProgress.current.loading).toBe(false)); await waitFor(() => expect(inProgress.current.loading).toBe(false));
await waitFor(() => expect(todo.current.loading).toBe(false));
await waitFor(() => expect(inReview.current.loading).toBe(false)); await waitFor(() => expect(inReview.current.loading).toBe(false));
expect(inProgress.current.stats).toBeNull(); expect(inProgress.current.stats).toEqual({ filesChanged: 2, additions: 4, deletions: 1 });
expect(todo.current.stats).toBeNull(); expect(inReview.current.stats).toEqual({ filesChanged: 2, additions: 4, deletions: 1 });
expect(inReview.current.stats).toBeNull(); expect(mockFetchTaskDiff).toHaveBeenCalledWith("FN-123", undefined, undefined);
expect(mockFetchTaskDiff).not.toHaveBeenCalled();
}); });
it("fetches diff stats for active tasks with a worktree", async () => { it("fetches diff stats for active tasks with a worktree", async () => {
@@ -169,6 +169,16 @@ describe("useTaskDiffStats", () => {
expect(mockFetchTaskDiff).toHaveBeenCalledTimes(2); expect(mockFetchTaskDiff).toHaveBeenCalledTimes(2);
}); });
it("does not fetch for inactive columns", async () => {
const { result: todo } = renderHook(() =>
useTaskDiffStats("FN-123", "todo", "abc1234", undefined),
);
await waitFor(() => expect(todo.current.loading).toBe(false));
expect(todo.current.stats).toBeNull();
expect(mockFetchTaskDiff).not.toHaveBeenCalled();
});
it("resets stats when column changes from done to non-done", async () => { it("resets stats when column changes from done to non-done", async () => {
mockFetchTaskDiff.mockResolvedValueOnce({ mockFetchTaskDiff.mockResolvedValueOnce({
files: [], files: [],

View File

@@ -31,12 +31,12 @@ interface UseTaskDiffStatsOptions {
const diffStatsCache = new Map<string, { stats: DiffStats; expiresAt: number }>(); const diffStatsCache = new Map<string, { stats: DiffStats; expiresAt: number }>();
const CACHE_TTL_MS = 30_000; // 30 seconds const CACHE_TTL_MS = 30_000; // 30 seconds
function getCacheKey(taskId: string, projectId?: string, worktree?: string, stepVersion?: string): string { function getCacheKey(taskId: string, projectId?: string, worktree?: string, stepVersion?: string, mode?: "done" | "active"): string {
return `${taskId}:${projectId ?? ""}:${worktree ?? ""}:${stepVersion ?? ""}`; return `${taskId}:${projectId ?? ""}:${worktree ?? ""}:${stepVersion ?? ""}:${mode ?? ""}`;
} }
function getCachedStats(taskId: string, projectId?: string, worktree?: string, stepVersion?: string): DiffStats | null { function getCachedStats(taskId: string, projectId?: string, worktree?: string, stepVersion?: string, mode?: "done" | "active"): DiffStats | null {
const key = getCacheKey(taskId, projectId, worktree, stepVersion); const key = getCacheKey(taskId, projectId, worktree, stepVersion, mode);
const entry = diffStatsCache.get(key); const entry = diffStatsCache.get(key);
if (!entry) return null; if (!entry) return null;
@@ -50,8 +50,8 @@ function getCachedStats(taskId: string, projectId?: string, worktree?: string, s
return entry.stats; return entry.stats;
} }
function setCachedStats(taskId: string, projectId: string | undefined, worktree: string | undefined, stepVersion: string | undefined, stats: DiffStats): void { function setCachedStats(taskId: string, projectId: string | undefined, worktree: string | undefined, stepVersion: string | undefined, mode: "done" | "active", stats: DiffStats): void {
const key = getCacheKey(taskId, projectId, worktree, stepVersion); const key = getCacheKey(taskId, projectId, worktree, stepVersion, mode);
diffStatsCache.set(key, { diffStatsCache.set(key, {
stats, stats,
expiresAt: Date.now() + CACHE_TTL_MS, expiresAt: Date.now() + CACHE_TTL_MS,
@@ -103,7 +103,7 @@ export function useTaskDiffStats(
} }
const shouldFetchDoneTask = column === "done"; const shouldFetchDoneTask = column === "done";
const shouldFetchActiveTask = (column === "in-progress" || column === "in-review") && Boolean(worktree); const shouldFetchActiveTask = column === "in-progress" || column === "in-review";
if (!taskId || (!shouldFetchDoneTask && !shouldFetchActiveTask)) { if (!taskId || (!shouldFetchDoneTask && !shouldFetchActiveTask)) {
setStats(null); setStats(null);
@@ -113,12 +113,13 @@ export function useTaskDiffStats(
const activeWorktree = shouldFetchActiveTask ? worktree : undefined; const activeWorktree = shouldFetchActiveTask ? worktree : undefined;
const stepVersionStr = stepVersion !== undefined ? String(stepVersion) : undefined; const stepVersionStr = stepVersion !== undefined ? String(stepVersion) : undefined;
const mode: "done" | "active" = shouldFetchDoneTask ? "done" : "active";
let cancelled = false; let cancelled = false;
async function load(forceRefresh = false) { async function load(forceRefresh = false) {
// Check cache first - return immediately without loading flicker (unless force refresh) // Check cache first - return immediately without loading flicker (unless force refresh)
if (!forceRefresh) { if (!forceRefresh) {
const cached = getCachedStats(taskId, projectId, activeWorktree, stepVersionStr); const cached = getCachedStats(taskId, projectId, activeWorktree, stepVersionStr, mode);
if (cached) { if (cached) {
if (!cancelled) { if (!cancelled) {
setStats(cached); setStats(cached);
@@ -134,7 +135,7 @@ export function useTaskDiffStats(
if (!cancelled) { if (!cancelled) {
setStats(data.stats); setStats(data.stats);
// Store in cache // Store in cache
setCachedStats(taskId, projectId, activeWorktree, stepVersionStr, data.stats); setCachedStats(taskId, projectId, activeWorktree, stepVersionStr, mode, data.stats);
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {