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