FN-7166: Refresh artifact lists from registration events

Artifact list surfaces now refresh from authoritative registry and artifact-message signals.\n\n- Emit an artifact:registered TaskStore event after successful registry writes and forward it over dashboard SSE.\n- Subscribe useArtifacts to artifact/message SSE events, scope refreshes by project/task, debounce duplicate signals, and avoid loading flashes when project context is missing.\n- Document live artifact refresh behavior and add regression coverage for store events and hook refresh paths.\n\nFiles changed:\n .changeset/FN-7166-artifact-live-refresh.md        |   7 +\n docs/dashboard-guide.md                            |   3 +-\n packages/core/src/__tests__/artifacts.test.ts      |  20 +-\n packages/core/src/store.ts                         |   8 +-\n .../app/hooks/__tests__/useArtifacts.test.ts       | 227 +++++++++++++++++++--\n packages/dashboard/app/hooks/useArtifacts.ts       |  83 +++++++-\n packages/dashboard/src/sse.ts                      |   7 +\n 7 files changed, 335 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-7166

Fusion-Task-Lineage: f13e254d-d65c-423e-8d9c-94c31761eac2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 22:15:53 -07:00
parent 34efa8b89a
commit e5a0273282
7 changed files with 335 additions and 20 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Artifact lists now refresh live when new artifacts are registered.
category: fix
dev: TaskStore emits artifact:registered SSE; useArtifacts also accepts message:sent/message:received and coalesces scoped refreshes.

View File

@@ -597,6 +597,7 @@ Features:
- Search documents across tasks
- Open project markdown files with inline preview
- Browse the **Artifacts** tab for registry media registered by any agent, dashboard chat/user action, or system tool across tasks
- Already-open global and task-detail artifact lists refresh live from the artifact registry event when an agent, dashboard chat session, user action, or system tool registers a new artifact, while preserving active search filters and task scoping
- Use the tab-count badges to see the current counts for Project Files, Task Documents, and Artifacts; the Artifacts badge reflects the loaded `GET /api/artifacts` result set, including active search filters
- Use the responsive media gallery to scan thumbnail-first image and video cards with consistent framing, while audio, document, and generic artifacts remain readable cards in the same grid
- Expand image and video artifact thumbnails into a full-size lightbox; dismiss it with the close button, backdrop click, or Escape while non-previewable artifact cards keep their normal controls and links
@@ -609,7 +610,7 @@ Features:
- Toggle between raw text and rendered markdown using the **Markdown/Plain** button
- Highlight text in raw or rendered project-file previews, choose **Add comment**, and send the file path, selected snippet, and your comment to the **New Task** dialog
Agent registrations also surface through the [Mailbox View](#mailbox-view): successful `fn_artifact_register` calls send a best-effort system inbox notification so users can discover new media even before opening the gallery.
Agent registrations also surface through the [Mailbox View](#mailbox-view): successful `fn_artifact_register` calls send a best-effort system inbox notification so users can discover new media even before opening the gallery. Artifact list live-refresh does not depend on that best-effort message; it listens to the registry registration event.
![Artifacts view](./screenshots/documents-view.png)

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { existsSync, mkdtempSync } from "node:fs";
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
@@ -74,6 +74,24 @@ describe("TaskStore artifacts", () => {
await expect(store.getArtifact("missing-artifact")).resolves.toBeNull();
});
it("emits an authoritative event after artifact registration succeeds", async () => {
const task = await store.createTask({ title: "Artifact event task", description: "Emit artifact event" });
const registered = vi.fn();
store.on("artifact:registered", registered);
const artifact = await store.registerArtifact({
type: "document",
title: "Evented artifact",
content: "# Event",
authorId: "agent-alpha",
authorType: "agent",
taskId: task.id,
});
expect(registered).toHaveBeenCalledTimes(1);
expect(registered).toHaveBeenCalledWith(artifact);
});
it("stores binary artifacts on disk under the task artifacts directory", async () => {
const task = await store.createTask({ description: "Binary artifact task" });
const data = Buffer.from([0, 1, 2, 3, 255]);

View File

@@ -1039,6 +1039,7 @@ export interface TaskStoreEvents {
"task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }];
"task:merged": [result: MergeResult];
"settings:updated": [data: { settings: Settings; previous: Settings }];
"artifact:registered": [artifact: Artifact];
"agent:log": [entry: AgentLogEntry];
"merger:autostashOrphans": [data: {
rootDir: string;
@@ -13097,6 +13098,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
/**
* FNXC:ArtifactRegistry 2026-06-19-22:04:
* Register multi-type agent/user/system artifacts in SQLite while writing binary payloads to disk. Task-scoped binaries use `.fusion/tasks/{taskId}/artifacts/`; task-less binaries use `.fusion/artifacts/`, and both store only a relative `artifacts/<file>` uri in the row.
*
* FNXC:ArtifactRegistry 2026-06-27-00:00:
* Successful registry writes emit `artifact:registered` as the authoritative live-update signal. Dashboard inbox notifications remain best-effort discovery messages, so already-open artifact lists must not depend on message delivery to invalidate their SWR cache.
*/
async registerArtifact(input: ArtifactCreateInput): Promise<Artifact> {
const id = randomUUID();
@@ -13129,7 +13133,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
}
};
return input.taskId ? this.withTaskLock(input.taskId, register) : register();
const artifact = input.taskId ? await this.withTaskLock(input.taskId, register) : await register();
this.emit("artifact:registered", artifact);
return artifact;
}
/**

View File

@@ -2,13 +2,28 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { ArtifactWithTask } from "@fusion/core";
import { fetchArtifacts } from "../../api";
import { subscribeSse } from "../../sse-bus";
import { useArtifacts } from "../useArtifacts";
import { message } from "./sseTestHelpers";
const { handlers, unsubscribeMock } = vi.hoisted(() => ({
handlers: {} as Record<string, (event: MessageEvent) => void>,
unsubscribeMock: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchArtifacts: vi.fn(),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, opts: { events: Record<string, (event: MessageEvent) => void> }) => {
Object.assign(handlers, opts.events);
return unsubscribeMock;
}),
}));
const mockFetchArtifacts = vi.mocked(fetchArtifacts);
const mockSubscribeSse = vi.mocked(subscribeSse);
const mockArtifacts: ArtifactWithTask[] = [
{
@@ -23,10 +38,41 @@ const mockArtifacts: ArtifactWithTask[] = [
},
];
const newTaskArtifact: ArtifactWithTask = {
id: "artifact-2",
type: "document",
title: "Live note",
authorId: "agent-2",
authorType: "agent",
taskId: "FN-1",
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
};
const loadInitialArtifacts = async () => {
await act(async () => {
await vi.runAllTimersAsync();
});
};
const fireArtifactRegistration = async (
eventName: "artifact:registered" | "message:received" | "message:sent",
payload: object,
) => {
act(() => {
handlers[eventName]?.(message(payload));
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
};
describe("useArtifacts", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
window.localStorage.clear();
for (const key of Object.keys(handlers)) delete handlers[key];
unsubscribeMock.mockClear();
mockFetchArtifacts.mockResolvedValue(mockArtifacts);
});
@@ -42,9 +88,7 @@ describe("useArtifacts", () => {
expect(result.current.loading).toBe(true);
expect(result.current.artifacts).toEqual([]);
await act(async () => {
await vi.runAllTimersAsync();
});
await loadInitialArtifacts();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
@@ -61,9 +105,7 @@ describe("useArtifacts", () => {
searchQuery: "demo",
}));
await act(async () => {
await vi.runAllTimersAsync();
});
await loadInitialArtifacts();
expect(mockFetchArtifacts).toHaveBeenCalledWith({
type: "video",
@@ -79,9 +121,7 @@ describe("useArtifacts", () => {
{ initialProps: { searchQuery: undefined as string | undefined } },
);
await act(async () => {
await vi.runAllTimersAsync();
});
await loadInitialArtifacts();
mockFetchArtifacts.mockClear();
rerender({ searchQuery: "alpha" });
@@ -104,9 +144,7 @@ describe("useArtifacts", () => {
mockFetchArtifacts.mockResolvedValueOnce(mockArtifacts);
const { result } = renderHook(() => useArtifacts({ projectId: "project-4" }));
await act(async () => {
await vi.runAllTimersAsync();
});
await loadInitialArtifacts();
await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts));
mockFetchArtifacts.mockRejectedValueOnce(new Error("Artifacts failed"));
@@ -121,9 +159,7 @@ describe("useArtifacts", () => {
it("refreshes artifacts on demand", async () => {
const { result } = renderHook(() => useArtifacts({ projectId: "project-5" }));
await act(async () => {
await vi.runAllTimersAsync();
});
await loadInitialArtifacts();
mockFetchArtifacts.mockClear();
await act(async () => {
@@ -133,4 +169,165 @@ describe("useArtifacts", () => {
expect(mockFetchArtifacts).toHaveBeenCalledTimes(1);
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, "project-5");
});
it("refreshes task-scoped artifacts when a matching artifact registration arrives", async () => {
mockFetchArtifacts.mockResolvedValue([]);
const { result } = renderHook(() => useArtifacts({ projectId: "project-6", taskId: "FN-1" }));
await loadInitialArtifacts();
await waitFor(() => expect(result.current.artifacts).toEqual([]));
mockFetchArtifacts.mockClear();
mockFetchArtifacts.mockResolvedValue([newTaskArtifact]);
await fireArtifactRegistration("message:received", {
metadata: { artifactId: newTaskArtifact.id, taskId: "FN-1" },
});
await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1));
expect(mockFetchArtifacts).toHaveBeenCalledWith({ taskId: "FN-1", q: undefined }, "project-6");
await waitFor(() => expect(result.current.artifacts).toEqual([newTaskArtifact]));
});
it("does not refresh task-scoped artifacts for a different task", async () => {
renderHook(() => useArtifacts({ projectId: "project-7", taskId: "FN-1" }));
await loadInitialArtifacts();
mockFetchArtifacts.mockClear();
await fireArtifactRegistration("message:received", {
metadata: { artifactId: "artifact-other", taskId: "FN-2" },
});
expect(mockFetchArtifacts).toHaveBeenCalledTimes(0);
});
it("refreshes task-scoped artifacts from the authoritative artifact registration event", async () => {
mockFetchArtifacts.mockResolvedValue([]);
const { result } = renderHook(() => useArtifacts({ projectId: "project-8a", taskId: "FN-1" }));
await loadInitialArtifacts();
await waitFor(() => expect(result.current.artifacts).toEqual([]));
mockFetchArtifacts.mockClear();
mockFetchArtifacts.mockResolvedValue([newTaskArtifact]);
await fireArtifactRegistration("artifact:registered", {
id: newTaskArtifact.id,
taskId: "FN-1",
});
await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1));
expect(mockFetchArtifacts).toHaveBeenCalledWith({ taskId: "FN-1", q: undefined }, "project-8a");
await waitFor(() => expect(result.current.artifacts).toEqual([newTaskArtifact]));
});
it("refreshes project-scoped artifacts when any artifact registration arrives", async () => {
const updatedArtifacts = [...mockArtifacts, newTaskArtifact];
const { result } = renderHook(() => useArtifacts({ projectId: "project-8" }));
await loadInitialArtifacts();
await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts));
mockFetchArtifacts.mockClear();
mockFetchArtifacts.mockResolvedValue(updatedArtifacts);
await fireArtifactRegistration("message:sent", {
projectId: "project-8",
metadata: { artifactId: newTaskArtifact.id, taskId: "FN-1" },
});
await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1));
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, "project-8");
await waitFor(() => expect(result.current.artifacts).toEqual(updatedArtifacts));
});
it("does not refresh project-scoped artifacts when the payload projectId mismatches", async () => {
renderHook(() => useArtifacts({ projectId: "project-9" }));
await loadInitialArtifacts();
mockFetchArtifacts.mockClear();
await fireArtifactRegistration("message:received", {
projectId: "project-other",
metadata: { artifactId: "artifact-other", taskId: "FN-1" },
});
expect(mockFetchArtifacts).toHaveBeenCalledTimes(0);
});
it("ignores non-artifact messages", async () => {
renderHook(() => useArtifacts({ projectId: "project-10" }));
await loadInitialArtifacts();
mockFetchArtifacts.mockClear();
await fireArtifactRegistration("message:received", {
id: "message-not-artifact",
metadata: { taskId: "FN-1" },
});
expect(mockFetchArtifacts).toHaveBeenCalledTimes(0);
});
it("ignores malformed message payloads", async () => {
renderHook(() => useArtifacts({ projectId: "project-11" }));
await loadInitialArtifacts();
mockFetchArtifacts.mockClear();
act(() => {
handlers["message:received"]?.({ data: "not-json" } as MessageEvent);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(mockFetchArtifacts).toHaveBeenCalledTimes(0);
});
it("coalesces duplicate sent and received artifact events into one refresh", async () => {
renderHook(() => useArtifacts({ projectId: "project-12", taskId: "FN-1" }));
await loadInitialArtifacts();
mockFetchArtifacts.mockClear();
act(() => {
const event = message({ metadata: { artifactId: "artifact-duplicate", taskId: "FN-1" } });
handlers["message:received"]?.(event);
handlers["message:sent"]?.(event);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1));
});
it("subscribes to project-scoped artifact registration events and unsubscribes on unmount", async () => {
const { unmount } = renderHook(() => useArtifacts({ projectId: "project-13" }));
expect(mockSubscribeSse).toHaveBeenCalledWith("/api/events?projectId=project-13", {
events: expect.objectContaining({
"artifact:registered": expect.any(Function),
"message:received": expect.any(Function),
"message:sent": expect.any(Function),
}),
});
unmount();
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
});
it("does not subscribe or fetch when no projectId is available", async () => {
const { result } = renderHook(() => useArtifacts());
await loadInitialArtifacts();
expect(result.current.loading).toBe(false);
expect(result.current.artifacts).toEqual([]);
expect(mockSubscribeSse).not.toHaveBeenCalled();
expect(mockFetchArtifacts).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { ArtifactType, ArtifactWithTask } from "@fusion/core";
import { fetchArtifacts } from "../api";
import { subscribeSse } from "../sse-bus";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
export interface UseArtifactsResult {
@@ -40,13 +41,22 @@ export function useArtifacts(options?: {
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return Array.isArray(cached) ? cached : [];
});
const [loading, setLoading] = useState(() => artifacts.length === 0);
const [loading, setLoading] = useState(() => Boolean(projectId) && artifacts.length === 0);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const initialLoadCompleteRef = useRef(artifacts.length > 0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refreshRef = useRef<() => Promise<void>>(async () => {});
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();
}
@@ -90,11 +100,15 @@ export function useArtifacts(options?: {
}
}, [authorId, cacheKey, projectId, searchQuery, taskId, type]);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
useEffect(() => {
if (!cacheKey) {
initialLoadCompleteRef.current = false;
setArtifacts([]);
setLoading(true);
setLoading(false);
return;
}
@@ -126,6 +140,67 @@ 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.
*/
const handleArtifactRegistration = (event: MessageEvent, source: "artifact" | "message") => {
try {
const payload = JSON.parse(event.data) as {
id?: string | null;
projectId?: string | null;
taskId?: string | null;
metadata?: {
artifactId?: string | null;
taskId?: string | null;
} | null;
};
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 (taskId && artifactTaskId !== taskId) return;
if (sseRefreshDebounceRef.current) {
return;
}
sseRefreshDebounceRef.current = setTimeout(() => {
sseRefreshDebounceRef.current = null;
void refreshRef.current();
}, 300);
} catch {
// no-op: malformed or non-JSON SSE payloads must not trigger artifact refetches.
}
};
const handleAuthoritativeArtifact = (event: MessageEvent) => handleArtifactRegistration(event, "artifact");
const handleArtifactMessage = (event: MessageEvent) => handleArtifactRegistration(event, "message");
const params = new URLSearchParams();
params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"artifact:registered": handleAuthoritativeArtifact,
"message:received": handleArtifactMessage,
"message:sent": handleArtifactMessage,
},
});
return () => {
unsubscribe();
if (sseRefreshDebounceRef.current) {
clearTimeout(sseRefreshDebounceRef.current);
sseRefreshDebounceRef.current = null;
}
};
}, [cacheKey, projectId, taskId]);
useEffect(() => {
void refresh();
@@ -133,6 +208,10 @@ export function useArtifacts(options?: {
if (abortRef.current) {
abortRef.current.abort();
}
if (sseRefreshDebounceRef.current) {
clearTimeout(sseRefreshDebounceRef.current);
sseRefreshDebounceRef.current = null;
}
};
}, []);

View File

@@ -531,6 +531,11 @@ export function createSSE(
send(`event: task:merged\ndata: ${JSON.stringify(stripTaskEventHeavyFields(result))}\n\n`);
};
const onArtifactRegistered = (artifact: unknown) => {
/* FNXC:ArtifactRegistry 2026-06-27-00:00: Forward TaskStore's authoritative artifact registration event so live artifact surfaces refresh even when the best-effort inbox notification is absent or delayed. */
send(`event: artifact:registered\ndata: ${JSON.stringify(artifact)}\n\n`);
};
const onResearchRunCreated = (run: unknown) => {
send(`event: research:run:created\ndata: ${JSON.stringify(run)}\n\n`);
};
@@ -822,6 +827,7 @@ export function createSSE(
store.off("task:updated", onUpdated);
store.off("task:deleted", onDeleted);
store.off("task:merged", onMerged);
store.off("artifact:registered", onArtifactRegistered);
if (missionStore) {
missionStore.off("mission:created", onMissionCreated);
missionStore.off("mission:updated", onMissionUpdated);
@@ -931,6 +937,7 @@ export function createSSE(
store.on("task:updated", onUpdated);
store.on("task:deleted", onDeleted);
store.on("task:merged", onMerged);
store.on("artifact:registered", onArtifactRegistered);
if (missionStore) {
missionStore.on("mission:created", onMissionCreated);