(null);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
@@ -621,6 +624,43 @@ function DocViewer({ artifact, projectId, t, addToast, onClose, onOpenTask, onAr
*/
const isHtml = (detail?.mimeType ?? artifact.mimeType)?.toLowerCase().split(";", 1)[0] === "text/html";
+ useEffect(() => {
+ if (!detail?.uri || !isHtml || !renderMarkdown) {
+ setHtmlPreviewUrl(null);
+ setHtmlPreviewError(null);
+ return;
+ }
+
+ const controller = new AbortController();
+ let objectUrl: string | undefined;
+ setHtmlPreviewUrl(null);
+ setHtmlPreviewError(null);
+
+ /*
+ FNXC:ArtifactRegistry 2026-07-15-14:45:
+ File-backed HTML previews need authenticated media access, but allow-scripts content can read a tokenized iframe URL and exfiltrate it. Fetch with the Authorization header and hand the sandboxed iframe a revocable blob URL so the bearer token never reaches executable artifact content.
+ */
+ void fetch(artifactMediaUrl(artifact.id, projectId), {
+ headers: withTokenHeader(),
+ signal: controller.signal,
+ })
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`Unable to load HTML preview (${response.status})`);
+ objectUrl = URL.createObjectURL(await response.blob());
+ if (!controller.signal.aborted) setHtmlPreviewUrl(objectUrl);
+ })
+ .catch((error: unknown) => {
+ if (!controller.signal.aborted) {
+ setHtmlPreviewError(error instanceof Error ? error.message : String(error));
+ }
+ });
+
+ return () => {
+ controller.abort();
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [artifact.id, detail?.uri, isHtml, projectId, renderMarkdown]);
+
const startEditing = () => {
setDraft(content);
setEditing(true);
@@ -695,16 +735,22 @@ function DocViewer({ artifact, projectId, t, addToast, onClose, onOpenTask, onAr
/>
) : isHtml && renderMarkdown ? (
-
+ detail.uri && !htmlPreviewUrl ? (
+
+ {htmlPreviewError ?? t("documents.loadingArtifact", "Loading artifact…")}
+
+ ) : (
+
+ )
) : detail.uri ? (
{t("documents.binaryDocArtifact", "This document is stored as a file.")}{" "}
-
+
{t("documents.openArtifactMedia", "Open artifact media")}
diff --git a/packages/dashboard/app/components/DocumentsView.tsx b/packages/dashboard/app/components/DocumentsView.tsx
index f5f8ec4c55..19253e7d5f 100644
--- a/packages/dashboard/app/components/DocumentsView.tsx
+++ b/packages/dashboard/app/components/DocumentsView.tsx
@@ -7,7 +7,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Artifact, ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
-import { artifactMediaUrl, fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
+import { artifactMediaUrlWithToken, fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
import { useArtifacts } from "../hooks/useArtifacts";
import { useDocuments } from "../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
@@ -113,7 +113,7 @@ function TaskArtifactInlineViewer({ artifact, projectId, content, loading, error
const category = getArtifactCategory(artifact);
const categoryLabel = getTaskArtifactCategoryLabel(t, category);
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
- const mediaUrl = artifactMediaUrl(artifact.id, projectId);
+ const mediaUrl = artifactMediaUrlWithToken(artifact.id, projectId);
const hasInlineText = category === "doc" && artifactHasInlineText(artifact);
useEffect(() => {
diff --git a/packages/dashboard/app/components/MailboxArtifactAttachment.tsx b/packages/dashboard/app/components/MailboxArtifactAttachment.tsx
index 0844158017..98f302195e 100644
--- a/packages/dashboard/app/components/MailboxArtifactAttachment.tsx
+++ b/packages/dashboard/app/components/MailboxArtifactAttachment.tsx
@@ -1,6 +1,6 @@
import { memo, useMemo, useState, type ReactNode } from "react";
import type { ArtifactType } from "@fusion/core";
-import { artifactMediaUrl } from "../api";
+import { artifactMediaUrlWithToken } from "../api";
export interface MailboxArtifactAttachmentProps {
artifactId?: unknown;
@@ -24,7 +24,7 @@ function readArtifactType(value: unknown): ArtifactType | "unknown" {
/**
* FNXC:ArtifactRegistry 2026-07-12-00:00:
- * Artifact-registration mail messages must expose the artifact announced by message.metadata. Render image artifacts inline, keep every type reachable through artifactMediaUrl(projectId-aware), and render nothing when metadata has no artifactId so ordinary messages keep their exact layout.
+ * Artifact-registration mail messages must expose the artifact announced by message.metadata. Render image artifacts inline, keep every type reachable through the authenticated project-aware media URL, and render nothing when metadata has no artifactId so ordinary messages keep their exact layout.
*
* FNXC:ArtifactRegistry 2026-07-12-00:00:
* Artifact-registration mail messages must also expose the producing task when message.metadata.taskId is paired with an onOpenTask handler. Render no task affordance when either side is absent so artifact-only and ordinary messages do not gain empty shells.
@@ -44,7 +44,7 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment
const mediaMimeType = readString(mimeType);
const task = readString(taskId);
const [imageFailed, setImageFailed] = useState(false);
- const mediaUrl = useMemo(() => id ? artifactMediaUrl(id, projectId) : "", [id, projectId]);
+ const mediaUrl = useMemo(() => id ? artifactMediaUrlWithToken(id, projectId) : "", [id, projectId]);
if (!id) return null;
diff --git a/packages/dashboard/app/components/TaskDocumentsTab.tsx b/packages/dashboard/app/components/TaskDocumentsTab.tsx
index 25966c2368..131ad822ec 100644
--- a/packages/dashboard/app/components/TaskDocumentsTab.tsx
+++ b/packages/dashboard/app/components/TaskDocumentsTab.tsx
@@ -13,7 +13,7 @@ import {
fetchTaskDocumentRevisions,
putTaskDocument,
deleteTaskDocument,
- artifactMediaUrl,
+ artifactMediaUrlWithToken,
} from "../api";
import { useArtifacts } from "../hooks/useArtifacts";
import { LoadingSpinner } from "./LoadingSpinner";
@@ -82,7 +82,7 @@ interface TaskArtifactCardProps {
function TaskArtifactCard({ artifact, projectId, onExpandImage }: TaskArtifactCardProps) {
const { t } = useTranslation("app");
- const mediaUrl = artifactMediaUrl(artifact.id, projectId);
+ const mediaUrl = artifactMediaUrlWithToken(artifact.id, projectId);
const typeLabel = getArtifactTypeLabel(t, artifact.type);
const preview = artifact.content ? getContentPreview(artifact.content, 320) : artifact.description;
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
@@ -721,7 +721,7 @@ export function TaskDocumentsTab({
diff --git a/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx b/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx
index 0332a1cc10..399c11aa82 100644
--- a/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx
@@ -18,6 +18,7 @@ vi.mock("../../api", () => ({
putTaskDocument: vi.fn(),
saveWorkspaceFileContent: vi.fn(),
artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
+ artifactMediaUrlWithToken: vi.fn((id: string) => `/api/artifacts/${id}/media?fn_token=daemon-token`),
}));
/*
@@ -404,6 +405,11 @@ describe("DocumentsView", () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Login mock
", { status: 200 })));
+ vi.stubGlobal("URL", {
+ createObjectURL: vi.fn(() => "blob:artifact-html-preview"),
+ revokeObjectURL: vi.fn(),
+ });
window.innerWidth = 1200;
setupHookDefaults();
mockFetchWorkspaceFileContent.mockResolvedValue({
@@ -415,6 +421,7 @@ describe("DocumentsView", () => {
});
afterEach(() => {
+ vi.unstubAllGlobals();
window.innerWidth = originalInnerWidth;
document.getSelection()?.removeAllRanges();
});
@@ -679,7 +686,7 @@ describe("DocumentsView", () => {
const imageEntry = screen.getByRole("button", { name: "Open KB-001 artifact Task screenshot" });
fireEvent.click(imageEntry);
expect(imageEntry).toHaveAttribute("aria-current", "true");
- expect(screen.getByRole("img", { name: "Task screenshot" })).toHaveAttribute("src", "/api/artifacts/task-artifact-image/media");
+ expect(screen.getByRole("img", { name: "Task screenshot" })).toHaveAttribute("src", "/api/artifacts/task-artifact-image/media?fn_token=daemon-token");
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
@@ -690,11 +697,11 @@ describe("DocumentsView", () => {
expect((await screen.findAllByText((_, element) => element?.textContent === "Fetched artifact markdown")).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "Open KB-ARTIFACTS artifact Task report PDF" }));
- expect(screen.getByTitle("PDF artifact: Task report PDF")).toHaveAttribute("src", "/api/artifacts/task-artifact-pdf/media");
- expect(screen.getByRole("link", { name: /open in new tab/i })).toHaveAttribute("href", "/api/artifacts/task-artifact-pdf/media");
+ expect(screen.getByTitle("PDF artifact: Task report PDF")).toHaveAttribute("src", "/api/artifacts/task-artifact-pdf/media?fn_token=daemon-token");
+ expect(screen.getByRole("link", { name: /open in new tab/i })).toHaveAttribute("href", "/api/artifacts/task-artifact-pdf/media?fn_token=daemon-token");
fireEvent.click(screen.getByRole("button", { name: "Open KB-ARTIFACTS artifact Task binary bundle" }));
- expect(screen.getByTestId("task-artifact-open-link")).toHaveAttribute("href", "/api/artifacts/task-artifact-other/media");
+ expect(screen.getByTestId("task-artifact-open-link")).toHaveAttribute("href", "/api/artifacts/task-artifact-other/media?fn_token=daemon-token");
});
it("renders video audio and html task artifact selections in the right pane", async () => {
@@ -916,12 +923,12 @@ describe("DocumentsView", () => {
fireEvent.click(artifactsTab);
expect(screen.getByRole("tab", { name: /show artifacts/i })).toHaveAttribute("aria-selected", "true");
- expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
+ expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
expect(screen.getByRole("button", { name: "Expand Image artifact" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Expand Video artifact" })).toBeInTheDocument();
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO");
- expect(screen.getByTestId("artifact-other-link")).toHaveAttribute("href", "/api/artifacts/artifact-other/media");
+ expect(screen.getByTestId("artifact-other-link")).toHaveAttribute("href", "/api/artifacts/artifact-other/media?fn_token=daemon-token");
// Category chips render for every present category with counts (All = total).
const filter = screen.getByRole("group", { name: /filter artifacts by category/i });
@@ -971,7 +978,7 @@ describe("DocumentsView", () => {
*/
fireEvent.click(screen.getByRole("button", { name: "Expand Image artifact" }));
let dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
- expect(within(dialog).getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
+ expect(within(dialog).getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
expect(screen.getByTestId("floating-window-artifact-media-artifact-image")).toBeInTheDocument();
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
@@ -1081,12 +1088,13 @@ describe("DocumentsView", () => {
FNXC:ArtifactsGallery 2026-07-11-10:20:
HTML doc artifacts must open as LIVE sandboxed previews by default (agents deliver interactive mockups as text/html documents), with a Source toggle for the raw markup.
*/
- it("renders HTML doc artifacts as a sandboxed live preview with a source toggle", async () => {
+ it("renders file-backed HTML previews token-free while keeping scripts sandboxed", async () => {
const htmlArtifact: ArtifactWithTask = {
id: "artifact-html",
type: "document",
title: "Login mockup",
mimeType: "text/html",
+ uri: "artifacts/login.html",
content: "Login mock
",
authorId: "design-agent",
authorType: "agent",
@@ -1113,13 +1121,14 @@ describe("DocumentsView", () => {
const iframe = document.querySelector(".artifacts-gallery-viewer-html");
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute("sandbox", "allow-scripts");
- expect(iframe).toHaveAttribute("srcdoc", "Login mock
");
+ expect(iframe).toHaveAttribute("src", "blob:artifact-html-preview");
+ expect(iframe?.getAttribute("src")).not.toContain("fn_token");
});
// The toggle shows the CURRENT mode (matching the Markdown/Plain convention): "Preview" while previewing.
fireEvent.click(within(dialog).getByRole("button", { name: "Preview" }));
expect(document.querySelector(".artifacts-gallery-viewer-html")).not.toBeInTheDocument();
- expect(within(dialog).getByText("Login mock
")).toBeInTheDocument();
+ expect(within(dialog).getByRole("link", { name: "Open artifact media" })).toHaveAttribute("href", "/api/artifacts/artifact-html/media?fn_token=daemon-token");
});
/*
@@ -1156,7 +1165,7 @@ describe("DocumentsView", () => {
// The FloatingWindow portals to document.body, so query the document rather than the render container.
const iframe = document.querySelector(".artifacts-gallery-viewer-pdf");
expect(iframe).toBeInTheDocument();
- expect(iframe).toHaveAttribute("src", "/api/artifacts/artifact-pdf/media");
+ expect(iframe).toHaveAttribute("src", "/api/artifacts/artifact-pdf/media?fn_token=daemon-token");
expect(iframe).toHaveAttribute("title", "Spec export");
fireEvent.keyDown(document, { key: "Escape" });
diff --git a/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx b/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx
index 222019a4ff..48e4e62177 100644
--- a/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx
@@ -1,13 +1,13 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MailboxArtifactAttachment } from "../MailboxArtifactAttachment";
-import { artifactMediaUrl } from "../../api";
+import { artifactMediaUrlWithToken } from "../../api";
vi.mock("../../api", () => ({
- artifactMediaUrl: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}` : ""}`),
+ artifactMediaUrlWithToken: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}&` : "?"}fn_token=daemon-token`),
}));
-const mockArtifactMediaUrl = vi.mocked(artifactMediaUrl);
+const mockArtifactMediaUrlWithToken = vi.mocked(artifactMediaUrlWithToken);
describe("MailboxArtifactAttachment", () => {
it("renders image artifacts inline with the project-scoped media URL", () => {
@@ -21,10 +21,10 @@ describe("MailboxArtifactAttachment", () => {
/>,
);
- expect(mockArtifactMediaUrl).toHaveBeenCalledWith("art-image", "proj-1");
+ expect(mockArtifactMediaUrlWithToken).toHaveBeenCalledWith("art-image", "proj-1");
const image = screen.getByRole("img", { name: "Screenshot" });
- expect(image).toHaveAttribute("src", "/api/artifacts/art-image/media?projectId=proj-1");
- expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toHaveAttribute("href", "/api/artifacts/art-image/media?projectId=proj-1");
+ expect(image).toHaveAttribute("src", "/api/artifacts/art-image/media?projectId=proj-1&fn_token=daemon-token");
+ expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toHaveAttribute("href", "/api/artifacts/art-image/media?projectId=proj-1&fn_token=daemon-token");
});
it("renders a View task affordance when task metadata and a handler are present", () => {
@@ -66,17 +66,17 @@ describe("MailboxArtifactAttachment", () => {
render();
expect(screen.queryByRole("img")).toBeNull();
- expect(screen.getByRole("link", { name: `Open artifact: ${title}` })).toHaveAttribute("href", `/api/artifacts/art-${artifactType}/media`);
+ expect(screen.getByRole("link", { name: `Open artifact: ${title}` })).toHaveAttribute("href", `/api/artifacts/art-${artifactType}/media?fn_token=daemon-token`);
});
it("renders controls media and an open link for video and audio artifacts", () => {
const { rerender, container } = render();
- expect(container.querySelector("video[controls]")).toHaveAttribute("src", "/api/artifacts/art-video/media");
- expect(screen.getByRole("link", { name: "Open artifact: Clip" })).toHaveAttribute("href", "/api/artifacts/art-video/media");
+ expect(container.querySelector("video[controls]")).toHaveAttribute("src", "/api/artifacts/art-video/media?fn_token=daemon-token");
+ expect(screen.getByRole("link", { name: "Open artifact: Clip" })).toHaveAttribute("href", "/api/artifacts/art-video/media?fn_token=daemon-token");
rerender();
- expect(container.querySelector("audio[controls]")).toHaveAttribute("src", "/api/artifacts/art-audio/media");
- expect(screen.getByRole("link", { name: "Open artifact: Recording" })).toHaveAttribute("href", "/api/artifacts/art-audio/media");
+ expect(container.querySelector("audio[controls]")).toHaveAttribute("src", "/api/artifacts/art-audio/media?fn_token=daemon-token");
+ expect(screen.getByRole("link", { name: "Open artifact: Recording" })).toHaveAttribute("href", "/api/artifacts/art-audio/media?fn_token=daemon-token");
});
it("renders nothing when artifactId metadata is missing", () => {
@@ -92,7 +92,7 @@ describe("MailboxArtifactAttachment", () => {
fireEvent.error(screen.getByRole("img", { name: "Broken screenshot" }));
expect(screen.queryByRole("img", { name: "Broken screenshot" })).toBeNull();
- expect(screen.getByRole("link", { name: "Open artifact: Broken screenshot" })).toHaveAttribute("href", "/api/artifacts/art-broken/media");
+ expect(screen.getByRole("link", { name: "Open artifact: Broken screenshot" })).toHaveAttribute("href", "/api/artifacts/art-broken/media?fn_token=daemon-token");
expect(screen.getByTestId("mailbox-artifact-view-task")).toBeInTheDocument();
});
});
diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
index 37ae96355a..8172dd2303 100644
--- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
@@ -25,7 +25,7 @@ vi.mock("../../api", () => ({
fetchApprovals: vi.fn(),
fetchApprovalDetail: vi.fn(),
decideApproval: vi.fn(),
- artifactMediaUrl: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}` : ""}`),
+ artifactMediaUrlWithToken: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}&` : "?"}fn_token=daemon-token`),
}));
vi.mock("../../hooks/useViewportMode", () => {
@@ -840,8 +840,8 @@ describe("MailboxView", () => {
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent(artifactMessage.content);
expect(screen.getByTestId("mailbox-artifact-attachment")).toBeInTheDocument();
- expect(screen.getByRole("img", { name: "Mailbox Screenshot" })).toHaveAttribute("src", "/api/artifacts/art-mailbox-image/media?projectId=project-a");
- expect(screen.getByRole("link", { name: "Open artifact: Mailbox Screenshot" })).toHaveAttribute("href", "/api/artifacts/art-mailbox-image/media?projectId=project-a");
+ expect(screen.getByRole("img", { name: "Mailbox Screenshot" })).toHaveAttribute("src", "/api/artifacts/art-mailbox-image/media?projectId=project-a&fn_token=daemon-token");
+ expect(screen.getByRole("link", { name: "Open artifact: Mailbox Screenshot" })).toHaveAttribute("href", "/api/artifacts/art-mailbox-image/media?projectId=project-a&fn_token=daemon-token");
expect(screen.getByTestId("mailbox-artifact-view-task")).toBeInTheDocument();
});
@@ -936,7 +936,7 @@ describe("MailboxView", () => {
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-artifact-attachment")).toBeInTheDocument();
- expect(screen.getByRole("img", { name: "Thread Image" })).toHaveAttribute("src", "/api/artifacts/art-thread-image/media");
+ expect(screen.getByRole("img", { name: "Thread Image" })).toHaveAttribute("src", "/api/artifacts/art-thread-image/media?fn_token=daemon-token");
expect(screen.getByTestId("mailbox-artifact-view-task")).toBeInTheDocument();
});
diff --git a/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx
index 3a5971829a..b994917071 100644
--- a/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import type { ArtifactWithTask, TaskDocument } from "@fusion/core";
import { TaskDocumentsTab } from "../TaskDocumentsTab";
-import { artifactMediaUrl, fetchTaskDocuments, fetchTaskDocumentRevisions } from "../../api";
+import { artifactMediaUrlWithToken, fetchTaskDocuments, fetchTaskDocumentRevisions } from "../../api";
import { useArtifacts } from "../../hooks/useArtifacts";
vi.mock("../../api", () => ({
@@ -15,7 +15,7 @@ vi.mock("../../api", () => ({
fetchTaskDocumentRevisions: vi.fn(),
putTaskDocument: vi.fn(),
deleteTaskDocument: vi.fn(),
- artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
+ artifactMediaUrlWithToken: vi.fn((id: string) => `/api/artifacts/${id}/media?fn_token=daemon-token`),
}));
vi.mock("../../hooks/useArtifacts", () => ({
@@ -24,7 +24,7 @@ vi.mock("../../hooks/useArtifacts", () => ({
const mockFetchTaskDocuments = vi.mocked(fetchTaskDocuments);
const mockFetchTaskDocumentRevisions = vi.mocked(fetchTaskDocumentRevisions);
-const mockArtifactMediaUrl = vi.mocked(artifactMediaUrl);
+const mockArtifactMediaUrlWithToken = vi.mocked(artifactMediaUrlWithToken);
const mockUseArtifacts = vi.mocked(useArtifacts);
function getDocumentCard(key: string): HTMLElement {
@@ -110,7 +110,7 @@ describe("TaskDocumentsTab", () => {
window.localStorage.clear();
mockFetchTaskDocuments.mockResolvedValue(mockDocuments);
mockFetchTaskDocumentRevisions.mockResolvedValue([]);
- mockArtifactMediaUrl.mockImplementation((id: string) => `/api/artifacts/${id}/media`);
+ mockArtifactMediaUrlWithToken.mockImplementation((id: string) => `/api/artifacts/${id}/media?fn_token=daemon-token`);
mockUseArtifacts.mockReturnValue({
artifacts: [],
loading: false,
@@ -201,18 +201,18 @@ describe("TaskDocumentsTab", () => {
expect(screen.getByRole("heading", { name: "Media artifacts" })).toBeInTheDocument();
});
- expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
+ expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
expect(screen.getByRole("button", { name: "Expand image artifact Image artifact" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Expand image artifact Video artifact/ })).not.toBeInTheDocument();
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO");
expect(screen.getByTestId("artifact-document-preview")).toHaveTextContent("Inline document preview");
- expect(screen.getByTestId("artifact-other-link")).toHaveAttribute("href", "/api/artifacts/artifact-other/media");
+ expect(screen.getByTestId("artifact-other-link")).toHaveAttribute("href", "/api/artifacts/artifact-other/media?fn_token=daemon-token");
expect(screen.getByText("agent-image")).toBeInTheDocument();
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
expect(document.querySelector(".documents-artifact-gallery--mobile")).not.toBeNull();
expect(mockUseArtifacts).toHaveBeenCalledWith({ projectId: "project-1", taskId: "KB-001" });
- expect(mockArtifactMediaUrl).toHaveBeenCalledWith("artifact-image", "project-1");
+ expect(mockArtifactMediaUrlWithToken).toHaveBeenCalledWith("artifact-image", "project-1");
});
it("opens image artifacts in a task-detail lightbox and restores focus on close", async () => {
@@ -232,7 +232,7 @@ describe("TaskDocumentsTab", () => {
const dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
expect(dialog).toBeInTheDocument();
- expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
+ expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "/api/artifacts/artifact-image/media?fn_token=daemon-token");
fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" }));
diff --git a/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts b/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts
index dcf3665714..5fa9089a80 100644
--- a/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts
@@ -5,16 +5,19 @@ import { usePoppedOutTasks } from "../usePoppedOutTasks";
const task = (id: string) => ({ id, title: id, status: "todo" } as never);
describe("usePoppedOutTasks", () => {
- it("popOut adds a task and dedupes by id", () => {
+ it("popOut adds a task and upgrades duplicate-id snapshots and origins", () => {
const { result } = renderHook(() => usePoppedOutTasks());
+ const stale = { ...task("1"), title: "stale" };
+ const fresh = { ...task("1"), title: "fresh" };
act(() => {
- result.current.popOut(task("1"));
- result.current.popOut(task("1"));
+ result.current.popOut(stale, "board");
+ result.current.popOut(fresh);
result.current.popOut(task("2"));
});
expect(result.current.tasks.map((t) => t.id)).toEqual(["1", "2"]);
+ expect(result.current.entries[0]).toEqual({ task: fresh, originTaskView: undefined });
});
it("records the originating task view for view-attached popups", () => {
diff --git a/packages/dashboard/app/hooks/usePoppedOutTasks.ts b/packages/dashboard/app/hooks/usePoppedOutTasks.ts
index ea4ddf0ddb..b055761e5d 100644
--- a/packages/dashboard/app/hooks/usePoppedOutTasks.ts
+++ b/packages/dashboard/app/hooks/usePoppedOutTasks.ts
@@ -1,6 +1,6 @@
/*
-FNXC:FloatingWindow 2026-06-24-00:00:
-Popped-out task-detail windows — movable, resizable, non-blocking FloatingWindows. Each entry is a task snapshot; several can be open at once. Snapshots survive a tasks revalidation (rendering prefers the live row by id). Pop-out dedupes by task id. Extracted from AppInner.
+FNXC:FloatingWindow 2026-07-15-14:55:
+Popped-out task-detail windows are movable, resizable, non-blocking FloatingWindows. Each entry is a task snapshot; several can be open at once. Reopening an id replaces its snapshot and origin so a stale or previously view-gated entry becomes current and visible. Extracted from AppInner.
*/
import { useCallback, useMemo, useState } from "react";
@@ -23,7 +23,14 @@ export function usePoppedOutTasks(): UsePoppedOutTasksResult {
const [entries, setEntries] = useState([]);
const popOut = useCallback((task: Task | TaskDetail, originTaskView?: TaskView) => {
- setEntries((current) => (current.some((entry) => entry.task.id === task.id) ? current : [...current, { task, originTaskView }]));
+ setEntries((current) => {
+ const existingIndex = current.findIndex((entry) => entry.task.id === task.id);
+ if (existingIndex === -1) return [...current, { task, originTaskView }];
+
+ const upgraded = [...current];
+ upgraded[existingIndex] = { task, originTaskView };
+ return upgraded;
+ });
}, []);
const close = useCallback((taskId: string) => {
@@ -31,8 +38,8 @@ export function usePoppedOutTasks(): UsePoppedOutTasksResult {
}, []);
/*
- FNXC:TaskPopupViewGating 2026-07-13-00:00:
- Popups store the TaskView where they were opened so the opt-in view gate can attach each modal to its originating Board/List surface. The snapshot stays in hook state while hidden; callers that only need legacy task snapshots can keep reading `tasks`.
+ FNXC:TaskPopupViewGating 2026-07-15-14:55:
+ Popups store their opening view so the opt-in gate can attach Board/List popups to that surface. Reopening a duplicate id updates this origin and its snapshot; callers that only need task snapshots can keep reading `tasks`.
*/
const tasks = useMemo(() => entries.map((entry) => entry.task), [entries]);