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:
7
.changeset/fn-7924-artifact-mail-view-task-link.md
Normal file
7
.changeset/fn-7924-artifact-mail-view-task-link.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user