FN-7767: fix Artifacts tab showing 0 count on default-scope dashboards

Fix useArtifacts fetching/subscribing only when a projectId is present, which left the Artifacts tab stuck at 0 on single-project dashboards where currentProject is unset at mount.

- useArtifacts now builds a cache key and fetches/subscribes even without a projectId, scoping the cache under a __default__ key
- SSE subscription omits the projectId query param when unset (default/unscoped /api/events) and only filters incoming events by projectId when one is set
- Added/updated tests covering the default-scope fetch, cache, and SSE subscription paths
- Added a changeset documenting the fix

Files changed:
 .changeset/fn-7767-artifacts-default-scope.md      |  7 ++++
 .../app/hooks/__tests__/useArtifacts.test.ts       | 42 +++++++++++++++++---
 packages/dashboard/app/hooks/useArtifacts.ts       | 37 ++++++------------
 .../__tests__/artifacts-route-integration.test.ts  | 45 ++++++++++++++++++++++
 4 files changed, 100 insertions(+), 31 deletions(-)

Fusion-Task-Id: FN-7767

Fusion-Task-Lineage: b4ea9b1f-2908-4f5b-bf75-d6fdc45f9340

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 00:37:03 -07:00
parent 059016ef3c
commit 167067c5b0
4 changed files with 100 additions and 31 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the Artifacts tab count for default-scope dashboards.
category: fix
dev: useArtifacts now fetches and subscribes when no projectId is available, matching the default /api/artifacts scope.

View File

@@ -320,14 +320,46 @@ describe("useArtifacts", () => {
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
});
it("does not subscribe or fetch when no projectId is available", async () => {
/*
* FNXC:ArtifactRegistry 2026-07-10-00:00:
* The operator-visible "Artifacts tab always shows 0" repro was a single-project/default-scope dashboard mount where no currentProject id was threaded into useArtifacts. The server's real /api/artifacts route listed the agent-created image, but the hook short-circuited before fetch/SSE, so DocumentsView rendered a permanent 0 count.
*/
it("fetches and subscribes to the default artifact scope when no projectId is available", async () => {
const { result } = renderHook(() => useArtifacts());
expect(result.current.loading).toBe(true);
await loadInitialArtifacts();
expect(result.current.loading).toBe(false);
expect(result.current.artifacts).toEqual([]);
expect(mockSubscribeSse).not.toHaveBeenCalled();
expect(mockFetchArtifacts).not.toHaveBeenCalled();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.artifacts).toEqual(mockArtifacts);
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, undefined);
expect(mockSubscribeSse).toHaveBeenCalledWith("/api/events", {
events: expect.objectContaining({
"artifact:registered": expect.any(Function),
"message:received": expect.any(Function),
"message:sent": expect.any(Function),
}),
});
});
it("refreshes default-scope artifacts when an unscoped artifact registration arrives", async () => {
const updatedArtifacts = [...mockArtifacts, newTaskArtifact];
const { result } = renderHook(() => useArtifacts());
await loadInitialArtifacts();
await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts));
mockFetchArtifacts.mockClear();
mockFetchArtifacts.mockResolvedValue(updatedArtifacts);
await fireArtifactRegistration("artifact:registered", {
id: newTaskArtifact.id,
taskId: "FN-1",
});
await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1));
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, undefined);
await waitFor(() => expect(result.current.artifacts).toEqual(updatedArtifacts));
});
});

View File

@@ -33,15 +33,13 @@ export function useArtifacts(options?: {
}): UseArtifactsResult {
const { projectId, type, authorId, taskId, searchQuery } = options ?? {};
const filterKey = JSON.stringify({ type: type ?? null, authorId: authorId ?? null, taskId: taskId ?? null });
const cacheKey = projectId ? `${SWR_CACHE_KEYS.ARTIFACTS_PREFIX}${projectId}:${filterKey}` : null;
const projectScopeKey = projectId ?? "__default__";
const cacheKey = `${SWR_CACHE_KEYS.ARTIFACTS_PREFIX}${projectScopeKey}:${filterKey}`;
const [artifacts, setArtifacts] = useState<ArtifactWithTask[]>(() => {
if (!cacheKey) {
return [];
}
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return Array.isArray(cached) ? cached : [];
});
const [loading, setLoading] = useState(() => Boolean(projectId) && artifacts.length === 0);
const [loading, setLoading] = useState(() => artifacts.length === 0);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const initialLoadCompleteRef = useRef(artifacts.length > 0);
@@ -50,13 +48,6 @@ export function useArtifacts(options?: {
const sseRefreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refresh = useCallback(async () => {
if (!projectId) {
setArtifacts([]);
setLoading(false);
initialLoadCompleteRef.current = false;
return;
}
if (abortRef.current) {
abortRef.current.abort();
}
@@ -105,13 +96,6 @@ export function useArtifacts(options?: {
}, [refresh]);
useEffect(() => {
if (!cacheKey) {
initialLoadCompleteRef.current = false;
setArtifacts([]);
setLoading(false);
return;
}
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
if (Array.isArray(cached)) {
setArtifacts(cached);
@@ -141,13 +125,12 @@ export function useArtifacts(options?: {
}, [refresh]);
useEffect(() => {
if (!cacheKey || !projectId) {
return;
}
/*
* FNXC:ArtifactRegistry 2026-06-27-00:00:
* Already-open task and project artifact lists must live-refresh from TaskStore's authoritative artifact:registered SSE event and also accept the best-effort agent/chat message notifications as an additional signal. Task-scoped hooks filter by artifact/task metadata while project-scoped hooks rely on the projectId refetch and optional payload projectId guard, preserving SWR cached rendering, search filters, and scoped fetch behavior without showing a loading flash.
*
* FNXC:ArtifactRegistry 2026-07-10-00:00:
* Single-project dashboards do not always have a currentProject id when the Documents view mounts. The Artifacts tab must still fetch the server's default project scope and subscribe to unscoped SSE; otherwise the hook returns [] forever and the tab count stays 0 even though GET /api/artifacts and agent-created image media are valid.
*/
const handleArtifactRegistration = (event: MessageEvent, source: "artifact" | "message") => {
try {
@@ -163,7 +146,7 @@ export function useArtifacts(options?: {
const artifactId = source === "artifact" ? payload.id : payload.metadata?.artifactId;
const artifactTaskId = source === "artifact" ? payload.taskId : payload.metadata?.taskId;
if (!artifactId) return;
if (payload.projectId && payload.projectId !== projectId) return;
if (projectId && payload.projectId && payload.projectId !== projectId) return;
if (taskId && artifactTaskId !== taskId) return;
if (sseRefreshDebounceRef.current) {
@@ -182,7 +165,9 @@ export function useArtifacts(options?: {
const handleArtifactMessage = (event: MessageEvent) => handleArtifactRegistration(event, "message");
const params = new URLSearchParams();
params.set("projectId", projectId);
if (projectId) {
params.set("projectId", projectId);
}
const query = params.size > 0 ? `?${params.toString()}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
@@ -199,7 +184,7 @@ export function useArtifacts(options?: {
sseRefreshDebounceRef.current = null;
}
};
}, [cacheKey, projectId, taskId]);
}, [projectId, taskId]);
useEffect(() => {
void refresh();

View File

@@ -7,6 +7,7 @@ import http from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore, type ArtifactType, type ArtifactWithTask } from "@fusion/core";
import { createArtifactRegisterTool } from "@fusion/engine";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
@@ -130,6 +131,50 @@ describe("artifacts route integration", () => {
expect(res.body).toEqual(imageBytes);
});
/*
* FNXC:ArtifactRegistry 2026-07-10-00:00:
* FN-7767 pins the real-server/default-scope invariant the in-memory FN-7693/FN-7764 tests missed: an image created through the agent fn_artifact_register tool must be visible through the dashboard route with no projectId query (single-project/default server scope) and stream as image/png.
*/
it("an agent-tool image artifact is listed and streamed through the default server scope", async () => {
const task = await store.createTask({
title: "Agent screenshot",
description: "Artifact should surface in the default Artifacts tab scope",
});
const registerTool = createArtifactRegisterTool(store, "agent-fn-7767");
const registerResult = await registerTool.execute("call-register-fn-7767-image", {
type: "image",
title: "Agent-created screenshot",
description: "Default-scope image artifact",
mimeType: "image/png",
dataBase64: PNG_IMAGE_BYTES.toString("base64"),
taskId: task.id,
});
const artifactId = (registerResult.details as { artifactId?: string }).artifactId;
expect(artifactId).toBeTruthy();
const listRes = await REQUEST(app, "GET", "/api/artifacts");
expect(listRes.status).toBe(200);
const listed = (listRes.body as ArtifactWithTask[]).find((artifact) => artifact.id === artifactId);
expect(listed).toMatchObject({
id: artifactId,
type: "image",
title: "Agent-created screenshot",
mimeType: "image/png",
authorId: "agent-fn-7767",
authorType: "agent",
taskId: task.id,
taskTitle: "Agent screenshot",
});
expect(listRes.body).toHaveLength(1);
const mediaRes = await requestRawBuffer(app, `/api/artifacts/${artifactId}/media`);
expect(mediaRes.status).toBe(200);
expect(mediaRes.headers["content-type"]).toBe("image/png");
expect(mediaRes.body).toEqual(PNG_IMAGE_BYTES);
});
it("a global image artifact still streams from the managed global artifacts directory", async () => {
const imageBytes = PNG_IMAGE_BYTES;
const artifact = await store.registerArtifact({