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:
7
.changeset/FN-7166-artifact-live-refresh.md
Normal file
7
.changeset/FN-7166-artifact-live-refresh.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||

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