From 167067c5b03cb658332df76c663a59f53ae7ef27 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 00:37:03 -0700 Subject: [PATCH] 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) --- .changeset/fn-7767-artifacts-default-scope.md | 7 +++ .../app/hooks/__tests__/useArtifacts.test.ts | 42 ++++++++++++++--- packages/dashboard/app/hooks/useArtifacts.ts | 37 +++++---------- .../artifacts-route-integration.test.ts | 45 +++++++++++++++++++ 4 files changed, 100 insertions(+), 31 deletions(-) create mode 100644 .changeset/fn-7767-artifacts-default-scope.md diff --git a/.changeset/fn-7767-artifacts-default-scope.md b/.changeset/fn-7767-artifacts-default-scope.md new file mode 100644 index 0000000000..0ccfff6921 --- /dev/null +++ b/.changeset/fn-7767-artifacts-default-scope.md @@ -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. diff --git a/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts b/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts index 063d5f76ab..b0dfff5885 100644 --- a/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts @@ -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)); }); }); diff --git a/packages/dashboard/app/hooks/useArtifacts.ts b/packages/dashboard/app/hooks/useArtifacts.ts index 037fad3c32..a4fbb897ef 100644 --- a/packages/dashboard/app/hooks/useArtifacts.ts +++ b/packages/dashboard/app/hooks/useArtifacts.ts @@ -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(() => { - if (!cacheKey) { - return []; - } const cached = readCache(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(null); const abortRef = useRef(null); const initialLoadCompleteRef = useRef(artifacts.length > 0); @@ -50,13 +48,6 @@ export function useArtifacts(options?: { const sseRefreshDebounceRef = useRef | 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(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(); diff --git a/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts b/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts index 0d703d3fbe..0c5719630b 100644 --- a/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts +++ b/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts @@ -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({