From 2aefaad319c42e20bd2879a22cfe0a3baf503a41 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 23:06:05 -0700 Subject: [PATCH] FN-7924: Add View task link to artifact-registration mail notifications Artifact-registration mail messages now expose the task that produced the artifact so users can jump straight to it from the mailbox. - MailboxArtifactAttachment renders a "View task" button when message.metadata.taskId is present and an onOpenTask handler is supplied, alongside the existing Open artifact affordance - MailboxModal and MailboxView thread taskId metadata and onOpenTask through to MailboxArtifactAttachment for both the message-list and detail-pane renders - MainContent wires MailboxView's onOpenTask to the shared fetchTaskDetail -> openDetailTask path, with a toast on failure, so mailbox reuses the existing task-detail flow - docs/dashboard-guide.md documents the new View task affordance for artifact notifications - adds a minor changeset for @runfusion/fusion describing the new mail notification behavior - extends MailboxArtifactAttachment and MailboxView tests to cover the new taskId/onOpenTask wiring Files changed: .changeset/fn-7924-artifact-mail-view-task-link.md | 7 ++++ docs/dashboard-guide.md | 2 +- .../app/components/MailboxArtifactAttachment.tsx | 20 ++++++++++ packages/dashboard/app/components/MailboxModal.css | 2 +- packages/dashboard/app/components/MailboxModal.tsx | 6 +++ packages/dashboard/app/components/MailboxView.tsx | 6 +++ .../__tests__/MailboxArtifactAttachment.test.tsx | 35 ++++++++++++++++- .../app/components/__tests__/MailboxView.test.tsx | 45 +++++++++++++++++++++- .../app/components/dashboard/MainContent.tsx | 6 +++ 9 files changed, 124 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7924 Fusion-Task-Lineage: 800102e1-9025-40da-8130-9ab0e8acd747 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7924-artifact-mail-view-task-link.md | 7 +++ docs/dashboard-guide.md | 2 +- .../components/MailboxArtifactAttachment.tsx | 20 +++++++++ .../dashboard/app/components/MailboxModal.css | 2 +- .../dashboard/app/components/MailboxModal.tsx | 6 +++ .../dashboard/app/components/MailboxView.tsx | 6 +++ .../MailboxArtifactAttachment.test.tsx | 35 ++++++++++++++- .../components/__tests__/MailboxView.test.tsx | 45 ++++++++++++++++++- .../app/components/dashboard/MainContent.tsx | 6 +++ 9 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-7924-artifact-mail-view-task-link.md diff --git a/.changeset/fn-7924-artifact-mail-view-task-link.md b/.changeset/fn-7924-artifact-mail-view-task-link.md new file mode 100644 index 0000000000..9889d22de6 --- /dev/null +++ b/.changeset/fn-7924-artifact-mail-view-task-link.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Artifact-registration mail notifications now include a "View task" link to open the producing task. +category: feature +dev: MailboxArtifactAttachment renders a metadata-driven View-task affordance (message.metadata.taskId + onOpenTask); MainContent wires MailboxView's onOpenTask via fetchTaskDetail -> openDetailTask. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d3234ade64..c8973d378c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -627,7 +627,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. - Inbox renders one row per message (no sender-based collapsing) - clicking a message in the Mail tab opens the task detail pane with full message content and conversation context - reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading -- when an agent or dashboard chat session registers an artifact with `fn_artifact_register`, Fusion sends a best-effort `system` → user inbox message announcing the new artifact (for example, `New image artifact registered: `) with metadata for `artifactId`, `artifactType`, `title`, optional `mimeType`, `authorId`, and optional `taskId`; notification delivery is informational and never blocks or rolls back the artifact registration. Artifact notifications are actionable in message detail views: image artifacts show an inline preview plus **Open artifact**, while video/audio/document/other artifacts show an **Open artifact** link to the managed media URL. +- when an agent or dashboard chat session registers an artifact with `fn_artifact_register`, Fusion sends a best-effort `system` → user inbox message announcing the new artifact (for example, `New image artifact registered: <title>`) with metadata for `artifactId`, `artifactType`, `title`, optional `mimeType`, `authorId`, and optional `taskId`; notification delivery is informational and never blocks or rolls back the artifact registration. Artifact notifications are actionable in message detail views: image artifacts show an inline preview plus **Open artifact**, while video/audio/document/other artifacts show an **Open artifact** link to the managed media URL. When `taskId` metadata is present, the same artifact block also shows **View task** so users can open the producing task detail directly from the mailbox. - on first engine startup under Fusion `0.59.x`, each project receives one best-effort `system` inbox notice about the upcoming embedded-Postgres storage migration with the Discord help link; `metadata.kind = "postgres-migration-notice"` prevents duplicates across restarts. - mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests - for approvals gated by an agent's permission policy (permanent agents and task-worker heartbeats), the Approvals detail pane renders the gated action's real payload — tool name, shell command line or structured arguments, and working directory when present — instead of only a generic "Agent gated action for `<tool>`" summary; a stateless heartbeat retrying the same gated command reuses the existing pending approval instead of creating a duplicate (FN-7609) diff --git a/packages/dashboard/app/components/MailboxArtifactAttachment.tsx b/packages/dashboard/app/components/MailboxArtifactAttachment.tsx index 4cd6e6443b..0844158017 100644 --- a/packages/dashboard/app/components/MailboxArtifactAttachment.tsx +++ b/packages/dashboard/app/components/MailboxArtifactAttachment.tsx @@ -8,6 +8,8 @@ export interface MailboxArtifactAttachmentProps { title?: unknown; mimeType?: unknown; projectId?: string; + taskId?: unknown; + onOpenTask?: (taskId: string) => void; } function readString(value: unknown): string | undefined { @@ -23,6 +25,9 @@ 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. + * + * 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. */ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment({ artifactId, @@ -30,11 +35,14 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment title, mimeType, projectId, + taskId, + onOpenTask, }: MailboxArtifactAttachmentProps) { const id = readString(artifactId); const type = readArtifactType(artifactType); const label = readString(title) ?? "artifact"; const mediaMimeType = readString(mimeType); + const task = readString(taskId); const [imageFailed, setImageFailed] = useState(false); const mediaUrl = useMemo(() => id ? artifactMediaUrl(id, projectId) : "", [id, projectId]); @@ -51,6 +59,17 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment Open artifact </a> ); + const taskLink = task && onOpenTask ? ( + <button + type="button" + className="mailbox-artifact-attachment__link btn" + aria-label={`View task: ${task}`} + data-testid="mailbox-artifact-view-task" + onClick={() => onOpenTask(task)} + > + View task + </button> + ) : null; let preview: ReactNode = null; if (type === "image" && !imageFailed) { @@ -97,6 +116,7 @@ export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment {preview} <div className="mailbox-artifact-attachment__actions"> {openLink} + {taskLink} </div> </div> ); diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css index 64cec67f1c..1a127f55ea 100644 --- a/packages/dashboard/app/components/MailboxModal.css +++ b/packages/dashboard/app/components/MailboxModal.css @@ -323,7 +323,7 @@ /* FNXC:ArtifactRegistry 2026-07-12-00:00: -Artifact-registration mail messages now render metadata-driven media affordances directly below the markdown body. The block must share mailbox spacing/radius tokens and collapse entirely for messages without artifactId metadata. +Artifact-registration mail messages now render metadata-driven media and producing-task affordances directly below the markdown body. The Open artifact link and View task button share the same mailbox action styling, spacing/radius tokens, and collapse entirely for messages without artifactId metadata. */ .mailbox-artifact-attachment { display: flex; diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index b72e077f0a..82de95f091 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -56,6 +56,7 @@ interface MailboxModalProps { onClose: () => void; projectId?: string; addToast?: (msg: string, type?: "success" | "error") => void; + onOpenTask?: (taskId: string) => void; agents?: Agent[]; } @@ -169,6 +170,7 @@ export function MailboxModal({ onClose, projectId, addToast, + onOpenTask, agents = [], }: MailboxModalProps) { const { t } = useTranslation("app"); @@ -895,6 +897,8 @@ export function MailboxModal({ title={msg.metadata?.title} mimeType={msg.metadata?.mimeType} projectId={projectId} + taskId={msg.metadata?.taskId} + onOpenTask={onOpenTask} /> </div> ); @@ -924,6 +928,8 @@ export function MailboxModal({ title={selectedMessage.metadata?.title} mimeType={selectedMessage.metadata?.mimeType} projectId={projectId} + taskId={selectedMessage.metadata?.taskId} + onOpenTask={onOpenTask} /> </> )} diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 82c588baae..af1120644e 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -56,6 +56,7 @@ type MailboxTab = "inbox" | "outbox" | "agents" | "approvals"; interface MailboxViewProps { projectId?: string; addToast?: (msg: string, type?: "success" | "error") => void; + onOpenTask?: (taskId: string) => void; /** Callback when unread count changes (for header badge updates) */ onUnreadCountChange?: (count: number) => void; } @@ -213,6 +214,7 @@ function buildReplyThread(messages: Message[], selectedMessage: Message): Messag export function MailboxView({ projectId, addToast, + onOpenTask, onUnreadCountChange, }: MailboxViewProps) { const { t } = useTranslation("app"); @@ -880,6 +882,8 @@ export function MailboxView({ title={msg.metadata?.title} mimeType={msg.metadata?.mimeType} projectId={projectId} + taskId={msg.metadata?.taskId} + onOpenTask={onOpenTask} /> </div> ); @@ -904,6 +908,8 @@ export function MailboxView({ title={selectedMessage.metadata?.title} mimeType={selectedMessage.metadata?.mimeType} projectId={projectId} + taskId={selectedMessage.metadata?.taskId} + onOpenTask={onOpenTask} /> </> )} diff --git a/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx b/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx index 7af963058b..222019a4ff 100644 --- a/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx @@ -27,6 +27,38 @@ describe("MailboxArtifactAttachment", () => { expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toHaveAttribute("href", "/api/artifacts/art-image/media?projectId=proj-1"); }); + it("renders a View task affordance when task metadata and a handler are present", () => { + const onOpenTask = vi.fn(); + render( + <MailboxArtifactAttachment + artifactId="art-image" + artifactType="image" + title="Screenshot" + taskId="FN-1234" + onOpenTask={onOpenTask} + />, + ); + + fireEvent.click(screen.getByTestId("mailbox-artifact-view-task")); + + expect(screen.getByRole("button", { name: "View task: FN-1234" })).toHaveTextContent("View task"); + expect(onOpenTask).toHaveBeenCalledWith("FN-1234"); + }); + + it("does not render a View task affordance without an open-task handler", () => { + render(<MailboxArtifactAttachment artifactId="art-image" artifactType="image" title="Screenshot" taskId="FN-1234" />); + + expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull(); + expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toBeInTheDocument(); + }); + + it("does not render a View task affordance without task metadata", () => { + render(<MailboxArtifactAttachment artifactId="art-image" artifactType="image" title="Screenshot" onOpenTask={vi.fn()} />); + + expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull(); + expect(screen.getByRole("link", { name: "Open artifact: Screenshot" })).toBeInTheDocument(); + }); + it.each([ ["document", "Spec"], ["other", "Archive"], @@ -55,11 +87,12 @@ describe("MailboxArtifactAttachment", () => { }); it("degrades image load failures to the open artifact link", () => { - render(<MailboxArtifactAttachment artifactId="art-broken" artifactType="image" title="Broken screenshot" />); + render(<MailboxArtifactAttachment artifactId="art-broken" artifactType="image" title="Broken screenshot" taskId="FN-1234" onOpenTask={vi.fn()} />); 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.getByTestId("mailbox-artifact-view-task")).toBeInTheDocument(); }); }); diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index 0617c942e7..37ae96355a 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -819,13 +819,15 @@ describe("MailboxView", () => { artifactType: "image", title: "Mailbox Screenshot", mimeType: "image/png", + taskId: "FN-1234", }, }; + const onOpenTask = vi.fn(); mockFetchInbox.mockResolvedValue(makeInboxResponse([artifactMessage], 1)); mockFetchConversation.mockResolvedValue([artifactMessage]); mockMarkMessageRead.mockResolvedValue({ ...artifactMessage, read: true }); - render(<MailboxView {...defaultProps} projectId="project-a" />); + render(<MailboxView {...defaultProps} projectId="project-a" onOpenTask={onOpenTask} />); await waitFor(() => { expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined(); @@ -840,6 +842,39 @@ describe("MailboxView", () => { 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.getByTestId("mailbox-artifact-view-task")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("mailbox-artifact-view-task")); + expect(onOpenTask).toHaveBeenCalledWith("FN-1234"); + }); + + it("does not render a View task affordance for artifact messages without task metadata", async () => { + const artifactMessage: Message = { + ...mockMessage, + metadata: { + artifactId: "art-mailbox-image", + artifactType: "image", + title: "Mailbox Screenshot", + }, + }; + mockFetchInbox.mockResolvedValue(makeInboxResponse([artifactMessage], 1)); + mockFetchConversation.mockResolvedValue([artifactMessage]); + mockMarkMessageRead.mockResolvedValue({ ...artifactMessage, read: true }); + + render(<MailboxView {...defaultProps} onOpenTask={vi.fn()} />); + + await waitFor(() => { + expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("mailbox-item-msg-001")); + }); + + await waitFor(() => { + expect(screen.getByTestId("mailbox-artifact-attachment")).toBeInTheDocument(); + expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull(); }); }); @@ -879,14 +914,16 @@ describe("MailboxView", () => { artifactId: "art-thread-image", artifactType: "image", title: "Thread Image", + taskId: "FN-5678", }, read: true, }; + const onOpenTask = vi.fn(); mockFetchInbox.mockResolvedValue(makeInboxResponse([rootMessage], 1)); mockFetchConversation.mockResolvedValue([rootMessage, artifactReply]); mockMarkMessageRead.mockResolvedValue({ ...rootMessage, read: true }); - render(<MailboxView {...defaultProps} />); + render(<MailboxView {...defaultProps} onOpenTask={onOpenTask} />); await waitFor(() => { expect(screen.getByTestId("mailbox-item-msg-artifact-root")).toBeDefined(); @@ -900,7 +937,11 @@ describe("MailboxView", () => { 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.getByTestId("mailbox-artifact-view-task")).toBeInTheDocument(); }); + + fireEvent.click(screen.getByTestId("mailbox-artifact-view-task")); + expect(onOpenTask).toHaveBeenCalledWith("FN-5678"); }); it("keeps list pane visible alongside detail pane on desktop/tablet", async () => { diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index df543c4d7a..98cc108e18 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -358,6 +358,12 @@ export function MainContent({ <MailboxView projectId={currentProject?.id} addToast={addToast} + /* FNXC:ArtifactRegistry 2026-07-12-00:00: Artifact-registration mail notifications open their producing task through the shared task-detail fetch path so the mailbox does not invent a separate deep-link scheme. */ + onOpenTask={(taskId) => { + void fetchTaskDetail(taskId, currentProject?.id) + .then((task) => openDetailTask(task as TaskDetail)) + .catch(() => addToast?.("Failed to open task", "error")); + }} onUnreadCountChange={setMailboxUnreadCount} /> </PageErrorBoundary>