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:
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user