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:
7
.changeset/fn-7976-mailbox-artifact-fixes.md
Normal file
7
.changeset/fn-7976-mailbox-artifact-fixes.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:DashboardNavigationDocs 2026-06-27-00:00: The right dock now hosts Chat as an inline tool panel; keep this user-facing roster aligned with STATIC_OVERFLOW_VIEW_ENTRIES so users know Chat can also pop out from the dock. -->
|
||||
<!-- FNXC:RightDockTasks 2026-06-28-19:55: The dock task-detail overlay is now anchored to the first-class Tasks tool tab. Document that Tasks is a dock-only auxiliary surface with a last-viewed detail/list fallback, not a new primary navigation destination. -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user