From 5ff7a20738dbff400a730050a4ec6013b8d87682 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 15:30:55 -0700 Subject: [PATCH] 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) --- .changeset/fn-7976-mailbox-artifact-fixes.md | 7 ++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/App.tsx | 15 ++-- .../App.taskPopupViewGating.test.tsx | 10 ++- .../app/__tests__/api-artifacts.test.ts | 12 +++- .../__tests__/legacy-artifact-media.test.ts | 27 +++++++ packages/dashboard/app/api/legacy.ts | 21 ++++-- .../app/components/ArtifactsGallery.tsx | 72 +++++++++++++++---- .../app/components/DocumentsView.tsx | 4 +- .../components/MailboxArtifactAttachment.tsx | 6 +- .../app/components/TaskDocumentsTab.tsx | 6 +- .../__tests__/DocumentsView.test.tsx | 31 +++++--- .../MailboxArtifactAttachment.test.tsx | 24 +++---- .../components/__tests__/MailboxView.test.tsx | 8 +-- .../__tests__/TaskDocumentsTab.test.tsx | 16 ++--- .../hooks/__tests__/usePoppedOutTasks.test.ts | 9 ++- .../dashboard/app/hooks/usePoppedOutTasks.ts | 17 +++-- 17 files changed, 206 insertions(+), 81 deletions(-) create mode 100644 .changeset/fn-7976-mailbox-artifact-fixes.md create mode 100644 packages/dashboard/app/api/__tests__/legacy-artifact-media.test.ts diff --git a/.changeset/fn-7976-mailbox-artifact-fixes.md b/.changeset/fn-7976-mailbox-artifact-fixes.md new file mode 100644 index 0000000000..5f1e9af9c3 --- /dev/null +++ b/.changeset/fn-7976-mailbox-artifact-fixes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Mailbox artifact messages — "Open artifact" now loads without an auth error and "View task" opens the task. +category: fix +dev: artifactMediaUrl now appends the fn_token query fallback for authenticated element/link loads (script-capable HTML artifact iframes stay token-free); isTaskPopupVisibleForView no longer gates non-board/list popup opens and usePoppedOutTasks.popOut upgrades same-id entries. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5783350e02..e39c02a19f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -116,7 +116,7 @@ FNXC:TaskPopupLayer 2026-07-04-18:36: Ordinary task popups sit on the board/task FNXC:TaskPopupViewGating 2026-07-13-00:00: The default-off popup view attachment setting must be documented as render-only hiding, not closing, because popup snapshots and shared geometry survive view switches. --> **Settings → Appearance → Open tasks as popups** changes ordinary board task-card clicks, List row/card opens, and right-dock Tasks-list clicks across desktop, tablet, and mobile viewports. When enabled, those clicks use the existing task popup/FloatingWindow surface on the board/task-detail layer instead of the full-panel task detail, List split-detail/docked detail, or right-dock task detail, keeping the board, List view, or dock list visible in the background while utility windows keep their higher global stacking. On desktop and tablet, task popups restore the last saved popup size and position between tasks; on mobile, task popups stay full-screen sheets. Deep `changes`/`retries`/`workflow` opens, List context-menu/refine actions, task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths. -**Settings → Appearance → Keep task popups on their Board/List view** is default off. When enabled, each open task-detail popup stays attached to the Board or List view where it was opened: navigating to Command Center, Agents, Settings, or a different task view hides the popup without closing it, and returning to the originating Board/List view re-shows the same popup in the same saved position. +**Settings → Appearance → Keep task popups on their Board/List view** is default off. When enabled, task-detail popups opened from Board or List stay attached to that originating view: navigating to Command Center, Agents, Settings, or a different task view hides the popup without closing it, and returning to the originating Board/List view re-shows the same popup in the same saved position. Explicit task opens from Mailbox, Artifacts/Documents, and other non-Board/List surfaces remain visible so their **View task** actions always open a usable window. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index dd875907a0..a551b962b2 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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; diff --git a/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx b/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx index de27e1eaa7..9f12f7a8c0 100644 --- a/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx +++ b/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx @@ -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( , ); - 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(); }); }); diff --git a/packages/dashboard/app/__tests__/api-artifacts.test.ts b/packages/dashboard/app/__tests__/api-artifacts.test.ts index e7731c00b4..c409980b9c 100644 --- a/packages/dashboard/app/__tests__/api-artifacts.test.ts +++ b/packages/dashboard/app/__tests__/api-artifacts.test.ts @@ -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", ); }); diff --git a/packages/dashboard/app/api/__tests__/legacy-artifact-media.test.ts b/packages/dashboard/app/api/__tests__/legacy-artifact-media.test.ts new file mode 100644 index 0000000000..b4650b6c78 --- /dev/null +++ b/packages/dashboard/app/api/__tests__/legacy-artifact-media.test.ts @@ -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"); + }); +}); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 297dc98a34..d911ffdad4 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -1737,12 +1737,23 @@ export async function fetchArtifacts( return api(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)); } /* diff --git a/packages/dashboard/app/components/ArtifactsGallery.tsx b/packages/dashboard/app/components/ArtifactsGallery.tsx index ae16a4f4f0..3e310f9cef 100644 --- a/packages/dashboard/app/components/ArtifactsGallery.tsx +++ b/packages/dashboard/app/components/ArtifactsGallery.tsx @@ -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) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); @@ -379,7 +380,7 @@ function AudioRow({ artifact, projectId, t, onOpenTask }: RowProps) { )} {formatTimestamp(artifact.createdAt)} -