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:
5
.changeset/fn-5708-files-changed-chip.md
Normal file
5
.changeset/fn-5708-files-changed-chip.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Backfill done-task "N files changed" chips when mergeDetails enrichment arrives after the initial done websocket snapshot. Task cards now pass a done-mode merge enrichment signature into diff-stats invalidation so `/api/tasks/:id/diff` is re-fetched and authoritative lineage stats render without requiring a manual refresh.
|
||||||
@@ -1020,6 +1020,15 @@ function TaskCardComponent({
|
|||||||
() => task.steps.map((s) => `${s.name}:${s.status}`).join("|"),
|
() => task.steps.map((s) => `${s.name}:${s.status}`).join("|"),
|
||||||
[task.steps],
|
[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
|
// Viewport-gated diff stats fetching - only fetch when card is visible
|
||||||
const { stats: diffStats, loading: diffLoading } = useTaskDiffStats(
|
const { stats: diffStats, loading: diffLoading } = useTaskDiffStats(
|
||||||
@@ -1031,6 +1040,7 @@ function TaskCardComponent({
|
|||||||
enabled: isInViewport,
|
enabled: isInViewport,
|
||||||
worktree: task.worktree,
|
worktree: task.worktree,
|
||||||
stepVersion: isActiveColumn ? stepVersion : undefined,
|
stepVersion: isActiveColumn ? stepVersion : undefined,
|
||||||
|
mergeSignature,
|
||||||
pollIntervalMs: isActiveColumn ? 30_000 : undefined,
|
pollIntervalMs: isActiveColumn ? 30_000 : undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3261,6 +3261,70 @@ describe("TaskCard", () => {
|
|||||||
expect(filesChangedButton).toBeNull();
|
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", () => {
|
it("prefers landedFiles fallback files-changed label for done tasks when lineage stats are unavailable", () => {
|
||||||
const onOpenDetailWithTab = vi.fn();
|
const onOpenDetailWithTab = vi.fn();
|
||||||
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
|
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", () => {
|
describe("stepVersion", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
__test_clearDiffStatsCache();
|
__test_clearDiffStatsCache();
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ interface UseTaskDiffStatsOptions {
|
|||||||
worktree?: string;
|
worktree?: string;
|
||||||
/** Version identifier that changes when steps update. Forces cache invalidation when changed. */
|
/** Version identifier that changes when steps update. Forces cache invalidation when changed. */
|
||||||
stepVersion?: number | string;
|
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. */
|
/** Poll interval in ms for active columns (in-progress, in-review). Forces re-fetch bypassing cache. */
|
||||||
pollIntervalMs?: number;
|
pollIntervalMs?: number;
|
||||||
}
|
}
|
||||||
@@ -83,6 +89,7 @@ export function __test_clearDiffStatsCache(): void {
|
|||||||
* @param commitSha - Merge commit SHA (undefined = no merge yet)
|
* @param commitSha - Merge commit SHA (undefined = no merge yet)
|
||||||
* @param projectId - Optional project identifier
|
* @param projectId - Optional project identifier
|
||||||
* @param options.enabled - When false, no fetch is made and returns empty/stable state
|
* @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(
|
export function useTaskDiffStats(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
@@ -95,6 +102,7 @@ export function useTaskDiffStats(
|
|||||||
const worktree = options.worktree;
|
const worktree = options.worktree;
|
||||||
const stepVersion = options.stepVersion;
|
const stepVersion = options.stepVersion;
|
||||||
const pollIntervalMs = options.pollIntervalMs;
|
const pollIntervalMs = options.pollIntervalMs;
|
||||||
|
const mergeSignature = options.mergeSignature;
|
||||||
const [stats, setStats] = useState<DiffStats | null>(null);
|
const [stats, setStats] = useState<DiffStats | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -117,13 +125,15 @@ 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 mergeSignatureStr = mergeSignature !== undefined ? String(mergeSignature) : undefined;
|
||||||
const mode: "done" | "active" = shouldFetchDoneTask ? "done" : "active";
|
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, mode);
|
const cacheVersion = mode === "done" ? mergeSignatureStr : stepVersionStr;
|
||||||
|
const cached = getCachedStats(taskId, projectId, activeWorktree, cacheVersion, mode);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStats(cached);
|
setStats(cached);
|
||||||
@@ -139,7 +149,8 @@ export function useTaskDiffStats(
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStats(data.stats);
|
setStats(data.stats);
|
||||||
// Store in cache
|
// 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 {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -170,7 +181,7 @@ export function useTaskDiffStats(
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [taskId, column, commitSha, projectId, enabled, worktree, stepVersion, pollIntervalMs]);
|
}, [taskId, column, commitSha, projectId, enabled, worktree, stepVersion, mergeSignature, pollIntervalMs]);
|
||||||
|
|
||||||
return { stats, loading };
|
return { stats, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user