FN-7976: fix mailbox artifact open and view-task popups

Fix Mailbox/Artifacts media auth and ensure View task always opens a usable popup.

- Add artifactMediaUrlWithToken for authenticated img/video/audio/link loads while keeping artifactMediaUrl token-free for fetch and HTML previews
- Load script-capable HTML artifact previews via Authorization + revocable blob URL so tokens never reach allow-scripts iframes
- Keep non-board/list task popups (Mailbox, Documents) visible even when board/list-only popup gating is enabled
- Upgrade duplicate popOut entries so reopening a task refreshes snapshot and origin
- Document the behavior and add a patch changeset

Files changed:
 .changeset/fn-7976-mailbox-artifact-fixes.md       |  7 +++
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/App.tsx                     | 15 +++--
 .../app/__tests__/App.taskPopupViewGating.test.tsx | 10 ++-
 .../dashboard/app/__tests__/api-artifacts.test.ts  | 12 +++-
 .../api/__tests__/legacy-artifact-media.test.ts    | 27 ++++++++
 packages/dashboard/app/api/legacy.ts               | 21 +++++--
 .../dashboard/app/components/ArtifactsGallery.tsx  | 72 ++++++++++++++++++----
 .../dashboard/app/components/DocumentsView.tsx     |  4 +-
 .../app/components/MailboxArtifactAttachment.tsx   |  6 +-
 .../dashboard/app/components/TaskDocumentsTab.tsx  |  6 +-
 .../components/__tests__/DocumentsView.test.tsx    | 31 ++++++----
 .../__tests__/MailboxArtifactAttachment.test.tsx   | 24 ++++----
 .../app/components/__tests__/MailboxView.test.tsx  |  8 +--
 .../components/__tests__/TaskDocumentsTab.test.tsx | 16 ++---
 .../app/hooks/__tests__/usePoppedOutTasks.test.ts  |  9 ++-
 packages/dashboard/app/hooks/usePoppedOutTasks.ts  | 17 +++--
 17 files changed, 206 insertions(+), 81 deletions(-)

Fusion-Task-Id: FN-7976

Fusion-Task-Lineage: 4c25b3a6-5836-4629-b33e-647f213e3261

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 15:30:55 -07:00
parent 667f4c8a55
commit 5ff7a20738
17 changed files with 206 additions and 81 deletions

View File

@@ -224,13 +224,18 @@ export interface DashboardShortcutPopupHandlers {
closeTerminal: () => void;
}
/*
FNXC:TaskPopupViewGating 2026-07-15-14:55:
The board/list-only preference attaches only popups opened from Board or List. Mailbox and Documents have no board/list origin, so their explicit View task actions must remain visible on every surface rather than creating an invisible FloatingWindow.
*/
export function isTaskPopupVisibleForView(options: {
taskPopupsBoardListOnly: boolean;
taskView: TaskView;
originTaskView?: TaskView;
}): boolean {
if (!options.taskPopupsBoardListOnly) return true;
return (options.originTaskView === "board" || options.originTaskView === "list") && options.originTaskView === options.taskView;
if (options.originTaskView !== "board" && options.originTaskView !== "list") return true;
return options.originTaskView === options.taskView;
}
/*
@@ -688,8 +693,8 @@ function AppInner() {
originTaskView,
}), [taskPopupsBoardListOnly, taskView]);
/*
FNXC:TaskPopupViewGating 2026-07-13-00:00:
Default-off preserves today's globally visible task popups. When enabled, a popup is render-attached to the Board/List view where it was opened: switching views unmounts the FloatingWindow without clearing the hook snapshot, and returning to that same view remounts it with the shared persisted geometry.
FNXC:TaskPopupViewGating 2026-07-15-14:55:
Default-off preserves globally visible task popups. When enabled, only Board/List-origin popups are render-attached to their originating view; Mailbox, Documents, and other non-task-view opens remain visible so their View task actions always open a usable window.
*/
const visiblePoppedOutTaskEntries = useMemo(
() => poppedOutTaskEntries.filter((entry) => taskPopupsVisibleOnCurrentView(entry.originTaskView)),
@@ -1834,8 +1839,8 @@ function AppInner() {
FNXC:TaskPopupLayer 2026-07-04-18:36:
Ordinary task-detail popups belong to the board/task-detail layer, not the global floating-utility stack. Pass the task-detail layer so board/right-dock task opens preserve the visible board context while utility windows keep the higher app-wide raise/focus contract.
FNXC:TaskPopupViewGating 2026-07-13-00:00:
Rendering uses visible entries only; the source hook keeps hidden popup snapshots mounted in React state rather than clearing them on view change. This distinction lets the opt-in setting attach each popup to its originating Board/List view while default-off continues to render all open popups globally.
FNXC:TaskPopupViewGating 2026-07-15-14:55:
Rendering uses visible entries only; the source hook keeps Board/List-hidden snapshots in React state rather than clearing them on view change. Non-board/list opens stay visible even with the opt-in setting, preserving Mailbox and Documents View task behavior.
*/}
{visiblePoppedOutTaskEntries.map(({ task: snapshot }) => {
const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot;

View File

@@ -99,15 +99,19 @@ describe("App task popup view gating", () => {
expect(screen.getByTestId("floating-window-body-task-detail-FN-7944-board")).toHaveTextContent("FN-7944-board");
});
it("does not render popups opened away from Board/List when attachment is enabled", () => {
it("keeps Mailbox and other non-board/list popups visible when attachment is enabled", () => {
render(
<PopupGateHarness
taskView="command-center"
taskPopupsBoardListOnly
entries={[{ task: task("FN-7944-command"), originTaskView: "command-center" }]}
entries={[
{ task: task("FN-7976-mailbox") },
{ task: task("FN-7976-command"), originTaskView: "command-center" },
]}
/>,
);
expectNoTaskPopupShell("FN-7944-command");
expect(screen.getByTestId("floating-window-task-detail-FN-7976-mailbox")).toBeInTheDocument();
expect(screen.getByTestId("floating-window-task-detail-FN-7976-command")).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { artifactMediaUrl } from "../api";
import { artifactMediaUrl, artifactMediaUrlWithToken } from "../api";
import { clearAuthToken, setAuthToken } from "../auth";
afterEach(() => {
@@ -9,12 +9,18 @@ afterEach(() => {
describe("artifactMediaUrl", () => {
/*
* FNXC:ArtifactMediaAuth 2026-07-15-14:24:
* Browser-native image, video, and link requests cannot attach the dashboard's Authorization header. Keep this regression focused on the generated URL contract: encoded artifact id and project scope survive while the existing same-origin fn_token fallback is appended.
* Browser-native image, video, and link requests cannot attach the dashboard's Authorization header.
*
* FNXC:ArtifactRegistry 2026-07-15-12:00:
* FN-7976 keeps the base media URL token-free (fetch + HTML previews) and routes element/link auth through artifactMediaUrlWithToken so script-capable previews never receive a tokenized src.
*/
it("appends the daemon token for image and link navigation", () => {
it("keeps the base media URL token-free and tokenizes element/link loads separately", () => {
setAuthToken("daemon-token");
expect(artifactMediaUrl("artifact/with spaces", "project-1")).toBe(
"/api/artifacts/artifact%2Fwith%20spaces/media?projectId=project-1",
);
expect(artifactMediaUrlWithToken("artifact/with spaces", "project-1")).toBe(
"/api/artifacts/artifact%2Fwith%20spaces/media?projectId=project-1&fn_token=daemon-token",
);
});

View File

@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
async function loadLegacyApi() {
vi.resetModules();
return import("../legacy");
}
describe("artifactMediaUrlWithToken", () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, "", "/");
});
it("adds the daemon token for dashboard-owned browser media loads", async () => {
window.localStorage.setItem("fn.authToken", "daemon-abc");
const { artifactMediaUrl, artifactMediaUrlWithToken } = await loadLegacyApi();
expect(artifactMediaUrl("artifact 1", "project-1")).toBe("/api/artifacts/artifact%201/media?projectId=project-1");
expect(artifactMediaUrlWithToken("artifact 1", "project-1")).toBe("/api/artifacts/artifact%201/media?projectId=project-1&fn_token=daemon-abc");
});
it("leaves media URLs unchanged when dashboard authentication is disabled", async () => {
const { artifactMediaUrlWithToken } = await loadLegacyApi();
expect(artifactMediaUrlWithToken("artifact-1")).toBe("/api/artifacts/artifact-1/media");
});
});

View File

@@ -1737,12 +1737,23 @@ export async function fetchArtifacts(
return api<ArtifactWithTask[]>(withProjectId(path, projectId));
}
/*
FNXC:ArtifactRegistry 2026-07-15-12:00:
Keep artifactMediaUrl token-free so fetch callers and script-capable HTML previews can authenticate via Authorization without putting the daemon token into a URL that executable artifact content could read.
FNXC:ArtifactMediaAuth 2026-07-15-14:24:
Main previously always-tokenized this helper for browser-native media loads. FN-7976 supersedes that by splitting tokenized element/link loads into artifactMediaUrlWithToken while this base URL stays clean for header-auth fetch and HTML blob previews.
*/
export function artifactMediaUrl(id: string, projectId?: string): string {
/*
* FNXC:ArtifactMediaAuth 2026-07-15-14:24:
* Artifact previews and links use browser-native navigation, which cannot send the bearer header used by fetch. Reuse appendTokenQuery so authenticated media loads while its dashboard-owned URL guard prevents leaking the daemon token cross-origin.
*/
return appendTokenQuery(buildApiUrl(withProjectId(`/artifacts/${encodeURIComponent(id)}/media`, projectId)));
return buildApiUrl(withProjectId(`/artifacts/${encodeURIComponent(id)}/media`, projectId));
}
/*
FNXC:ArtifactRegistry 2026-07-15-12:00:
Artifact media element loads and link navigations cannot attach an Authorization header, so authenticated daemon media routes require the dashboard-owned fn_token query fallback. Keep artifactMediaUrl token-free for fetch callers and script-capable HTML previews; consumers that hand the URL to an img, video, audio, iframe, or anchor must use this helper unless executable content could read the URL.
*/
export function artifactMediaUrlWithToken(id: string, projectId?: string): string {
return appendTokenQuery(artifactMediaUrl(id, projectId));
}
/*

View File

@@ -18,7 +18,8 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Artifact, ArtifactWithTask } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { artifactMediaUrl, fetchArtifact, updateArtifact } from "../api";
import { artifactMediaUrl, artifactMediaUrlWithToken, fetchArtifact, updateArtifact } from "../api";
import { withTokenHeader } from "../auth";
import { FileEditor } from "./FileEditor";
import { FloatingWindow } from "./FloatingWindow";
@@ -270,7 +271,7 @@ interface TileProps {
function VisualTile({ artifact, category, projectId, t, onOpen }: TileProps) {
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
const mediaUrl = artifactMediaUrlWithToken(artifact.id, projectId);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
@@ -379,7 +380,7 @@ function AudioRow({ artifact, projectId, t, onOpenTask }: RowProps) {
)}
<span className="artifacts-gallery-row-meta">{formatTimestamp(artifact.createdAt)}</span>
</div>
<audio className="artifacts-gallery-audio" controls src={artifactMediaUrl(artifact.id, projectId)} aria-label={t("documents.artifactAudioLabel", "Audio artifact: {{title}}", { title })} />
<audio className="artifacts-gallery-audio" controls src={artifactMediaUrlWithToken(artifact.id, projectId)} aria-label={t("documents.artifactAudioLabel", "Audio artifact: {{title}}", { title })} />
</article>
);
}
@@ -403,7 +404,7 @@ function FileRow({ artifact, projectId, t, onOpenTask }: RowProps) {
</div>
<a
className="btn btn-sm artifacts-gallery-row-download"
href={artifactMediaUrl(artifact.id, projectId)}
href={artifactMediaUrlWithToken(artifact.id, projectId)}
target="_blank"
rel="noreferrer"
data-testid="artifact-other-link"
@@ -509,7 +510,7 @@ function ViewerMeta({ artifact, t, onOpenTask }: { artifact: ArtifactWithTask; t
function MediaLightbox({ artifact, projectId, t, onClose, onOpenTask }: OverlayProps) {
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
const mediaUrl = artifactMediaUrlWithToken(artifact.id, projectId);
const closeRef = useRef<HTMLButtonElement>(null);
return (
@@ -545,7 +546,7 @@ function MediaLightbox({ artifact, projectId, t, onClose, onOpenTask }: OverlayP
function PdfViewer({ artifact, projectId, t, onClose, onOpenTask }: OverlayProps) {
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
const mediaUrl = artifactMediaUrlWithToken(artifact.id, projectId);
const closeRef = useRef<HTMLButtonElement>(null);
return (
@@ -590,6 +591,8 @@ function DocViewer({ artifact, projectId, t, addToast, onClose, onOpenTask, onAr
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
const [detail, setDetail] = useState<Artifact | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string | null>(null);
const [htmlPreviewError, setHtmlPreviewError] = useState<string | null>(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
/>
</div>
) : isHtml && renderMarkdown ? (
<iframe
className="artifacts-gallery-viewer-html"
sandbox="allow-scripts"
title={title}
{...(detail.uri ? { src: artifactMediaUrl(artifact.id, projectId) } : { srcDoc: content })}
/>
detail.uri && !htmlPreviewUrl ? (
<p className="artifacts-gallery-viewer-loading">
{htmlPreviewError ?? t("documents.loadingArtifact", "Loading artifact…")}
</p>
) : (
<iframe
className="artifacts-gallery-viewer-html"
sandbox="allow-scripts"
title={title}
{...(detail.uri ? { src: htmlPreviewUrl! } : { srcDoc: content })}
/>
)
) : detail.uri ? (
<p className="artifacts-gallery-viewer-loading">
{t("documents.binaryDocArtifact", "This document is stored as a file.")}{" "}
<a href={artifactMediaUrl(artifact.id, projectId)} target="_blank" rel="noreferrer">
<a href={artifactMediaUrlWithToken(artifact.id, projectId)} target="_blank" rel="noreferrer">
{t("documents.openArtifactMedia", "Open artifact media")}
</a>
</p>

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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({
<div className="documents-artifact-lightbox-media-frame">
<img
className="documents-artifact-lightbox-media"
src={artifactMediaUrl(lightboxArtifact.id, projectId)}
src={artifactMediaUrlWithToken(lightboxArtifact.id, projectId)}
alt={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}
/>
</div>

View File

@@ -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("<h1>Login mock</h1>", { 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: "<h1>Login mock</h1>",
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", "<h1>Login mock</h1>");
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("<h1>Login mock</h1>")).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" });

View File

@@ -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(<MailboxArtifactAttachment artifactId={`art-${artifactType}`} artifactType={artifactType} title={title} />);
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(<MailboxArtifactAttachment artifactId="art-video" artifactType="video" title="Clip" />);
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(<MailboxArtifactAttachment artifactId="art-audio" artifactType="audio" title="Recording" />);
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();
});
});

View File

@@ -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();
});

View File

@@ -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" }));

View File

@@ -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", () => {

View File

@@ -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<PoppedOutTaskEntry[]>([]);
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]);