diff --git a/.changeset/fn-7864-artifact-mail-link.md b/.changeset/fn-7864-artifact-mail-link.md new file mode 100644 index 0000000000..9131380d5b --- /dev/null +++ b/.changeset/fn-7864-artifact-mail-link.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Artifact-registration mail notifications now show an inline preview and open link. +category: feature +dev: MailboxView/MailboxModal render a shared MailboxArtifactAttachment from message.metadata (artifactId/artifactType/mimeType) via artifactMediaUrl; notifyArtifactRegistered now also emits metadata.mimeType. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 86d9112832..8e9c514cc7 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -613,7 +613,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`, `authorId`, and optional `taskId`; notification delivery is informational and never blocks or rolls back the artifact registration +- 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. - 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) - in the **Agents** tab, the agent selector now includes **All agents**, which shows one combined agent-to-agent stream (with sender + recipient labels); selecting a specific agent still shows Inbox/Outbox subtabs diff --git a/packages/dashboard/app/components/MailboxArtifactAttachment.tsx b/packages/dashboard/app/components/MailboxArtifactAttachment.tsx new file mode 100644 index 0000000000..4cd6e6443b --- /dev/null +++ b/packages/dashboard/app/components/MailboxArtifactAttachment.tsx @@ -0,0 +1,103 @@ +import { memo, useMemo, useState, type ReactNode } from "react"; +import type { ArtifactType } from "@fusion/core"; +import { artifactMediaUrl } from "../api"; + +export interface MailboxArtifactAttachmentProps { + artifactId?: unknown; + artifactType?: unknown; + title?: unknown; + mimeType?: unknown; + projectId?: string; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function readArtifactType(value: unknown): ArtifactType | "unknown" { + return value === "image" || value === "video" || value === "audio" || value === "document" || value === "other" + ? value + : "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. + */ +export const MailboxArtifactAttachment = memo(function MailboxArtifactAttachment({ + artifactId, + artifactType, + title, + mimeType, + projectId, +}: MailboxArtifactAttachmentProps) { + const id = readString(artifactId); + const type = readArtifactType(artifactType); + const label = readString(title) ?? "artifact"; + const mediaMimeType = readString(mimeType); + const [imageFailed, setImageFailed] = useState(false); + const mediaUrl = useMemo(() => id ? artifactMediaUrl(id, projectId) : "", [id, projectId]); + + if (!id) return null; + + const openLink = ( + <a + className="mailbox-artifact-attachment__link btn" + href={mediaUrl} + target="_blank" + rel="noreferrer" + aria-label={`Open artifact: ${label}`} + > + Open artifact + </a> + ); + + let preview: ReactNode = null; + if (type === "image" && !imageFailed) { + preview = ( + <img + className="mailbox-artifact-attachment__media mailbox-artifact-attachment__image" + src={mediaUrl} + alt={label} + loading="lazy" + onError={() => setImageFailed(true)} + /> + ); + } else if (type === "video") { + preview = ( + <video + className="mailbox-artifact-attachment__media" + src={mediaUrl} + controls + aria-label={`Video artifact: ${label}`} + /> + ); + } else if (type === "audio") { + preview = ( + <audio + className="mailbox-artifact-attachment__audio" + src={mediaUrl} + controls + aria-label={`Audio artifact: ${label}`} + /> + ); + } + + return ( + <div + className="mailbox-artifact-attachment" + data-testid="mailbox-artifact-attachment" + data-artifact-type={type} + data-artifact-mime-type={mediaMimeType} + > + <div className="mailbox-artifact-attachment__header"> + <span className="mailbox-artifact-attachment__title">{label}</span> + <span className="mailbox-artifact-attachment__type">{type === "unknown" ? "artifact" : type}</span> + </div> + {preview} + <div className="mailbox-artifact-attachment__actions"> + {openLink} + </div> + </div> + ); +}); diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css index 05b3d89cb0..64cec67f1c 100644 --- a/packages/dashboard/app/components/MailboxModal.css +++ b/packages/dashboard/app/components/MailboxModal.css @@ -321,6 +321,80 @@ word-break: break-word; } +/* +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. +*/ +.mailbox-artifact-attachment { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: var(--space-sm); + padding: var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-secondary); +} + +.mailbox-artifact-attachment__header, +.mailbox-artifact-attachment__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.mailbox-artifact-attachment__title { + color: var(--text); + font-weight: 600; +} + +.mailbox-artifact-attachment__type { + color: var(--text-muted); + font-size: 0.8rem; + text-transform: capitalize; +} + +.mailbox-artifact-attachment__media, +.mailbox-artifact-attachment__image, +.mailbox-artifact-attachment__audio { + width: 100%; +} + +.mailbox-artifact-attachment__media, +.mailbox-artifact-attachment__image { + max-height: min(45vh, 28rem); + border-radius: var(--radius-sm); + object-fit: contain; + background: var(--surface); +} + +.mailbox-artifact-attachment__audio { + display: block; +} + +.mailbox-artifact-attachment__link { + text-decoration: none; +} + +@media (max-width: 768px) { + .mailbox-artifact-attachment { + padding: var(--space-sm); + } + + .mailbox-artifact-attachment__header, + .mailbox-artifact-attachment__actions { + align-items: stretch; + flex-direction: column; + } + + .mailbox-artifact-attachment__link { + justify-content: center; + width: 100%; + } +} + /* Markdown rendering inside message bodies. ReactMarkdown produces standard block elements (p, ul, h*, code, table, ...). Reset margins so message prose still feels compact, and let pre/table scroll horizontally. */ diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index 6ee4668b5c..b72e077f0a 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -36,6 +36,7 @@ import { } from "../api"; import { MessageComposer } from "./MessageComposer"; import { MailboxMessageContent } from "./MailboxMessageContent"; +import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment"; import type { Agent } from "../api"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; @@ -888,6 +889,13 @@ export function MailboxModal({ content={msg.content} className="mailbox-conversation-msg-body" /> + <MailboxArtifactAttachment + artifactId={msg.metadata?.artifactId} + artifactType={msg.metadata?.artifactType} + title={msg.metadata?.title} + mimeType={msg.metadata?.mimeType} + projectId={projectId} + /> </div> ); })} @@ -910,6 +918,13 @@ export function MailboxModal({ className="mailbox-message-body" testId="mailbox-message-body" /> + <MailboxArtifactAttachment + artifactId={selectedMessage.metadata?.artifactId} + artifactType={selectedMessage.metadata?.artifactType} + title={selectedMessage.metadata?.title} + mimeType={selectedMessage.metadata?.mimeType} + projectId={projectId} + /> </> )} </div> diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index db9fa3a260..82c588baae 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -38,6 +38,7 @@ import { type ApprovalRequestDetail, } from "../api"; import { MailboxMessageContent } from "./MailboxMessageContent"; +import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment"; import { MessageComposer } from "./MessageComposer"; import { ViewHeader } from "./ViewHeader"; import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails"; @@ -873,6 +874,13 @@ export function MailboxView({ content={msg.content} className="mailbox-conversation-msg-body" /> + <MailboxArtifactAttachment + artifactId={msg.metadata?.artifactId} + artifactType={msg.metadata?.artifactType} + title={msg.metadata?.title} + mimeType={msg.metadata?.mimeType} + projectId={projectId} + /> </div> ); })} @@ -890,6 +898,13 @@ export function MailboxView({ className="mailbox-message-body" testId="mailbox-message-body" /> + <MailboxArtifactAttachment + artifactId={selectedMessage.metadata?.artifactId} + artifactType={selectedMessage.metadata?.artifactType} + title={selectedMessage.metadata?.title} + mimeType={selectedMessage.metadata?.mimeType} + projectId={projectId} + /> </> )} </div> diff --git a/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx b/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx new file mode 100644 index 0000000000..7af963058b --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MailboxArtifactAttachment.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MailboxArtifactAttachment } from "../MailboxArtifactAttachment"; +import { artifactMediaUrl } from "../../api"; + +vi.mock("../../api", () => ({ + artifactMediaUrl: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}` : ""}`), +})); + +const mockArtifactMediaUrl = vi.mocked(artifactMediaUrl); + +describe("MailboxArtifactAttachment", () => { + it("renders image artifacts inline with the project-scoped media URL", () => { + render( + <MailboxArtifactAttachment + artifactId="art-image" + artifactType="image" + title="Screenshot" + mimeType="image/png" + projectId="proj-1" + />, + ); + + expect(mockArtifactMediaUrl).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"); + }); + + it.each([ + ["document", "Spec"], + ["other", "Archive"], + ])("renders an open link for %s artifacts", (artifactType, title) => { + 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`); + }); + + 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"); + + 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"); + }); + + it("renders nothing when artifactId metadata is missing", () => { + const { container } = render(<MailboxArtifactAttachment artifactType="image" title="No id" />); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByTestId("mailbox-artifact-attachment")).toBeNull(); + }); + + it("degrades image load failures to the open artifact link", () => { + render(<MailboxArtifactAttachment artifactId="art-broken" artifactType="image" title="Broken screenshot" />); + + 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"); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index e160833fab..0617c942e7 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -25,6 +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}` : ""}`), })); vi.mock("../../hooks/useViewportMode", () => { @@ -810,6 +811,98 @@ describe("MailboxView", () => { }); }); + it("renders an inline artifact attachment in the single-message detail path", async () => { + const artifactMessage: Message = { + ...mockMessage, + metadata: { + artifactId: "art-mailbox-image", + artifactType: "image", + title: "Mailbox Screenshot", + mimeType: "image/png", + }, + }; + mockFetchInbox.mockResolvedValue(makeInboxResponse([artifactMessage], 1)); + mockFetchConversation.mockResolvedValue([artifactMessage]); + mockMarkMessageRead.mockResolvedValue({ ...artifactMessage, read: true }); + + render(<MailboxView {...defaultProps} projectId="project-a" />); + + 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-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"); + }); + }); + + it("renders no artifact attachment for messages without artifact metadata", async () => { + mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1)); + mockFetchConversation.mockResolvedValue([mockMessage]); + mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true }); + + render(<MailboxView {...defaultProps} />); + + 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-message-body")).toHaveTextContent(mockMessage.content); + expect(screen.queryByTestId("mailbox-artifact-attachment")).toBeNull(); + }); + }); + + it("renders artifact attachments inside conversation thread messages", async () => { + const rootMessage: Message = { + ...mockMessage, + id: "msg-artifact-root", + content: "Artifact root", + }; + const artifactReply: Message = { + ...mockMessage, + id: "msg-artifact-reply", + content: "New image artifact registered: Thread Image", + metadata: { + replyTo: { messageId: "msg-artifact-root" }, + artifactId: "art-thread-image", + artifactType: "image", + title: "Thread Image", + }, + read: true, + }; + mockFetchInbox.mockResolvedValue(makeInboxResponse([rootMessage], 1)); + mockFetchConversation.mockResolvedValue([rootMessage, artifactReply]); + mockMarkMessageRead.mockResolvedValue({ ...rootMessage, read: true }); + + render(<MailboxView {...defaultProps} />); + + await waitFor(() => { + expect(screen.getByTestId("mailbox-item-msg-artifact-root")).toBeDefined(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("mailbox-item-msg-artifact-root")); + }); + + 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"); + }); + }); + it("keeps list pane visible alongside detail pane on desktop/tablet", async () => { mockFetchInbox.mockResolvedValue({ messages: [mockMessage], diff --git a/packages/engine/src/__tests__/agent-artifact-tools.test.ts b/packages/engine/src/__tests__/agent-artifact-tools.test.ts index 6463dcf5eb..4b3b9c5aad 100644 --- a/packages/engine/src/__tests__/agent-artifact-tools.test.ts +++ b/packages/engine/src/__tests__/agent-artifact-tools.test.ts @@ -254,7 +254,7 @@ describe("artifact register tool", () => { it("sends exactly one system-to-user inbox notification with artifact metadata", async () => { const { store, registerArtifact } = createMockStore(); - const artifact = createMockArtifact({ id: "art-notify", type: "image", title: "Screenshot", uri: "artifacts/screenshot.png", content: undefined }); + const artifact = createMockArtifact({ id: "art-notify", type: "image", title: "Screenshot", mimeType: "image/png", uri: "artifacts/screenshot.png", content: undefined }); registerArtifact.mockResolvedValue(artifact); const { messageStore, sendMessage } = createMockMessageStore(); @@ -276,12 +276,42 @@ describe("artifact register tool", () => { artifactId: "art-notify", artifactType: "image", title: "Screenshot", + mimeType: "image/png", authorId: AUTHOR_ID, taskId: TASK_ID, }), })); }); + it("still sends artifact notification metadata when mimeType is absent", async () => { + const { store, registerArtifact } = createMockStore(); + registerArtifact.mockResolvedValue(createMockArtifact({ + id: "art-no-mime", + title: "Metadata-only artifact", + mimeType: undefined, + content: undefined, + uri: "artifact://metadata-only", + })); + const { messageStore, sendMessage } = createMockMessageStore(); + + const tool = createArtifactRegisterTool(store, AUTHOR_ID, messageStore); + const result = await runTool(tool, "call-no-mime-notify", { + type: "other", + title: "Metadata-only artifact", + uri: "artifact://metadata-only", + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + metadata: expect.objectContaining({ + artifactId: "art-no-mime", + title: "Metadata-only artifact", + mimeType: undefined, + }), + })); + expect(getText(result)).toContain("Registered artifact"); + }); + it("still succeeds when notification sendMessage throws", async () => { const { store, registerArtifact } = createMockStore(); registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-best-effort" })); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index acc37f8aac..eaa45be0b0 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -1917,6 +1917,10 @@ function hasImageSignature(data: Buffer, mimeType: string): boolean { function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifact: Artifact, authorId: string): void { if (!messageStore) return; + /* + FNXC:ArtifactRegistry 2026-07-12-00:00: + Artifact-registration mailbox notifications remain best-effort and keep their stable content string, but metadata now carries mimeType so dashboard mailbox surfaces can render document/other artifact affordances from metadata without an extra artifact fetch. + */ try { messageStore.sendMessage({ fromType: "system", @@ -1928,6 +1932,7 @@ function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifa artifactId: artifact.id, artifactType: artifact.type, title: artifact.title, + mimeType: artifact.mimeType, authorId, taskId: artifact.taskId, },