FN-5708: backfill files changed chip on done task cards

Ensure freshly done task cards show the "N files changed" chip once mergeDetails diff stats arrive.

- backfill TaskCard diff stat display logic so completed cards update when mergeDetails enrichment lands after initial render
- update useTaskDiffStats to derive files-changed counts from merge details with fallback-safe behavior
- add TaskCard and useTaskDiffStats test coverage for late-arriving mergeDetails and chip rendering
- add a changeset for @runfusion/fusion documenting the patch release

Files changed:
 .changeset/fn-5708-files-changed-chip.md           |  5 ++
 packages/dashboard/app/components/TaskCard.tsx     | 10 +++
 .../app/components/__tests__/TaskCard.test.tsx     | 64 +++++++++++++++
 .../app/hooks/__tests__/useTaskDiffStats.test.ts   | 92 ++++++++++++++++++++++
 packages/dashboard/app/hooks/useTaskDiffStats.ts   | 17 +++-
 5 files changed, 185 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-5708

Fusion-Task-Lineage: 3f961f3f-045b-473e-9636-ca33fd84282c
This commit is contained in:
gsxdsm
2026-05-29 21:32:09 -07:00
parent 9b54f52b39
commit 4a60c2ac41
5 changed files with 185 additions and 3 deletions

View File

@@ -1020,6 +1020,15 @@ function TaskCardComponent({
() => task.steps.map((s) => `${s.name}:${s.status}`).join("|"),
[task.steps],
);
const mergeSignature = useMemo(() => {
if (task.column !== "done") {
return undefined;
}
const landedFilesCount = task.mergeDetails?.landedFiles?.length ?? "";
const filesChanged = task.mergeDetails?.filesChanged ?? "";
return `${landedFilesCount}:${filesChanged}`;
}, [task.column, task.mergeDetails?.landedFiles?.length, task.mergeDetails?.filesChanged]);
// Viewport-gated diff stats fetching - only fetch when card is visible
const { stats: diffStats, loading: diffLoading } = useTaskDiffStats(
@@ -1031,6 +1040,7 @@ function TaskCardComponent({
enabled: isInViewport,
worktree: task.worktree,
stepVersion: isActiveColumn ? stepVersion : undefined,
mergeSignature,
pollIntervalMs: isActiveColumn ? 30_000 : undefined,
},
);

View File

@@ -3261,6 +3261,70 @@ describe("TaskCard", () => {
expect(filesChangedButton).toBeNull();
});
it("backfills done-card files-changed chip when mergeDetails enrichment arrives without remount", () => {
useTaskDiffStatsMock.mockImplementation((...args: any[]) => {
const options = args[4] as { mergeSignature?: string } | undefined;
if (options?.mergeSignature === "3:3") {
return { stats: { filesChanged: 3, additions: 9, deletions: 2 }, loading: false };
}
return { stats: null, loading: false };
});
const baseTask = makeTask({
column: "done",
mergeDetails: {
commitSha: "abc123",
insertions: 10,
deletions: 2,
mergedAt: "2026-04-25T15:00:00.000Z",
mergeConfirmed: true,
},
});
const { rerender } = render(
<TaskCard
task={baseTask}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: /files changed/i })).toBeNull();
expect(useTaskDiffStatsMock).toHaveBeenLastCalledWith(
"FN-001",
"done",
"abc123",
undefined,
expect.objectContaining({ mergeSignature: ":" }),
);
rerender(
<TaskCard
task={{
...baseTask,
mergeDetails: {
...baseTask.mergeDetails,
filesChanged: 3,
landedFiles: ["a.ts", "b.ts", "c.ts"],
},
}}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "3 files changed" })).toBeDefined();
expect(useTaskDiffStatsMock).toHaveBeenLastCalledWith(
"FN-001",
"done",
"abc123",
undefined,
expect.objectContaining({ mergeSignature: "3:3" }),
);
});
it("prefers landedFiles fallback files-changed label for done tasks when lineage stats are unavailable", () => {
const onOpenDetailWithTab = vi.fn();
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });

View File

@@ -467,6 +467,98 @@ describe("useTaskDiffStats", () => {
});
});
describe("mergeSignature", () => {
beforeEach(() => {
__test_clearDiffStatsCache();
mockFetchTaskDiff.mockClear();
});
it("re-fetches for done tasks when mergeSignature changes without remount", async () => {
mockFetchTaskDiff
.mockResolvedValueOnce({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
})
.mockResolvedValueOnce({
files: [{ path: "src/new.ts", status: "modified", additions: 4, deletions: 1, patch: "" }],
stats: { filesChanged: 1, additions: 4, deletions: 1 },
});
const { result, rerender } = renderHook(
({ mergeSignature }) => useTaskDiffStats(
"FN-MERGE-SIG",
"done",
"abc1234",
undefined,
{ mergeSignature },
),
{ initialProps: { mergeSignature: ":" as string } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.stats).toEqual({ filesChanged: 0, additions: 0, deletions: 0 });
expect(mockFetchTaskDiff).toHaveBeenCalledTimes(1);
rerender({ mergeSignature: "1:1" as string });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.stats).toEqual({ filesChanged: 1, additions: 4, deletions: 1 });
expect(mockFetchTaskDiff).toHaveBeenCalledTimes(2);
});
it("uses mergeSignature as done-mode cache key while active-mode behavior stays on stepVersion", async () => {
mockFetchTaskDiff
.mockResolvedValueOnce({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
})
.mockResolvedValueOnce({
files: [],
stats: { filesChanged: 2, additions: 8, deletions: 3 },
})
.mockResolvedValueOnce({
files: [],
stats: { filesChanged: 5, additions: 20, deletions: 4 },
});
const { result: doneFirst } = renderHook(() =>
useTaskDiffStats("FN-MERGE-CACHE", "done", "abc1234", undefined, { mergeSignature: ":" }),
);
await waitFor(() => expect(doneFirst.current.loading).toBe(false));
expect(doneFirst.current.stats).toEqual({ filesChanged: 0, additions: 0, deletions: 0 });
const { result: doneSecond } = renderHook(() =>
useTaskDiffStats("FN-MERGE-CACHE", "done", "abc1234", undefined, { mergeSignature: "2:2" }),
);
await waitFor(() => expect(doneSecond.current.loading).toBe(false));
expect(doneSecond.current.stats).toEqual({ filesChanged: 2, additions: 8, deletions: 3 });
const { result: activeWithMergeSigOnly } = renderHook(() =>
useTaskDiffStats("FN-MERGE-CACHE", "in-progress", undefined, undefined, {
worktree: "/repo/.worktrees/fn-merge-cache",
mergeSignature: "ignored-for-active",
stepVersion: "v1",
}),
);
await waitFor(() => expect(activeWithMergeSigOnly.current.loading).toBe(false));
expect(activeWithMergeSigOnly.current.stats).toEqual({ filesChanged: 5, additions: 20, deletions: 4 });
expect(mockFetchTaskDiff).toHaveBeenCalledTimes(3);
mockFetchTaskDiff.mockClear();
const { result: activeCacheHitDifferentMergeSig } = renderHook(() =>
useTaskDiffStats("FN-MERGE-CACHE", "in-progress", undefined, undefined, {
worktree: "/repo/.worktrees/fn-merge-cache",
mergeSignature: "different-but-ignored",
stepVersion: "v1",
}),
);
expect(activeCacheHitDifferentMergeSig.current.loading).toBe(false);
expect(activeCacheHitDifferentMergeSig.current.stats).toEqual({ filesChanged: 5, additions: 20, deletions: 4 });
expect(mockFetchTaskDiff).not.toHaveBeenCalled();
});
});
describe("stepVersion", () => {
beforeEach(() => {
__test_clearDiffStatsCache();

View File

@@ -19,6 +19,12 @@ interface UseTaskDiffStatsOptions {
worktree?: string;
/** Version identifier that changes when steps update. Forces cache invalidation when changed. */
stepVersion?: number | string;
/**
* Done-task merge enrichment signature (e.g. landedFiles length + filesChanged).
* For done cards this invalidates cache/refetches when mergeDetails enrichment lands,
* analogous to stepVersion invalidation for active columns.
*/
mergeSignature?: number | string;
/** Poll interval in ms for active columns (in-progress, in-review). Forces re-fetch bypassing cache. */
pollIntervalMs?: number;
}
@@ -83,6 +89,7 @@ export function __test_clearDiffStatsCache(): void {
* @param commitSha - Merge commit SHA (undefined = no merge yet)
* @param projectId - Optional project identifier
* @param options.enabled - When false, no fetch is made and returns empty/stable state
* @param options.mergeSignature - Done-mode invalidation signal derived from mergeDetails enrichment
*/
export function useTaskDiffStats(
taskId: string,
@@ -95,6 +102,7 @@ export function useTaskDiffStats(
const worktree = options.worktree;
const stepVersion = options.stepVersion;
const pollIntervalMs = options.pollIntervalMs;
const mergeSignature = options.mergeSignature;
const [stats, setStats] = useState<DiffStats | null>(null);
const [loading, setLoading] = useState(false);
@@ -117,13 +125,15 @@ export function useTaskDiffStats(
const activeWorktree = shouldFetchActiveTask ? worktree : undefined;
const stepVersionStr = stepVersion !== undefined ? String(stepVersion) : undefined;
const mergeSignatureStr = mergeSignature !== undefined ? String(mergeSignature) : undefined;
const mode: "done" | "active" = shouldFetchDoneTask ? "done" : "active";
let cancelled = false;
async function load(forceRefresh = false) {
// Check cache first - return immediately without loading flicker (unless force refresh)
if (!forceRefresh) {
const cached = getCachedStats(taskId, projectId, activeWorktree, stepVersionStr, mode);
const cacheVersion = mode === "done" ? mergeSignatureStr : stepVersionStr;
const cached = getCachedStats(taskId, projectId, activeWorktree, cacheVersion, mode);
if (cached) {
if (!cancelled) {
setStats(cached);
@@ -139,7 +149,8 @@ export function useTaskDiffStats(
if (!cancelled) {
setStats(data.stats);
// Store in cache
setCachedStats(taskId, projectId, activeWorktree, stepVersionStr, mode, data.stats);
const cacheVersion = mode === "done" ? mergeSignatureStr : stepVersionStr;
setCachedStats(taskId, projectId, activeWorktree, cacheVersion, mode, data.stats);
}
} catch {
if (!cancelled) {
@@ -170,7 +181,7 @@ export function useTaskDiffStats(
clearInterval(timer);
}
};
}, [taskId, column, commitSha, projectId, enabled, worktree, stepVersion, pollIntervalMs]);
}, [taskId, column, commitSha, projectId, enabled, worktree, stepVersion, mergeSignature, pollIntervalMs]);
return { stats, loading };
}