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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 23:06:05 -07:00
parent 382a4d5d05
commit 2aefaad319
9 changed files with 124 additions and 5 deletions

View File

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

View File

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

View File

@@ -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>
);

View File

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

View File

@@ -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}
/>
</>
)}

View File

@@ -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}
/>
</>
)}

View File

@@ -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();
});
});

View File

@@ -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 () => {

View File

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