FN-7864: add inline artifact preview/link to artifact-registered mail messages

Artifact-registration mailbox notifications now render a shared inline preview and open-artifact link instead of plain text metadata.

- Add MailboxArtifactAttachment component rendering an inline image/document preview plus an "open artifact" link from message.metadata (artifactId/artifactType/mimeType) via artifactMediaUrl
- Wire MailboxModal and MailboxView to render the new attachment for artifact-registered messages, with supporting CSS
- Emit metadata.mimeType from notifyArtifactRegistered in agent-tools.ts so mailbox surfaces can pick the right preview affordance without an extra artifact fetch
- Add/extend tests for the new component and for MailboxView/agent-artifact-tools coverage
- Update dashboard guide docs and add a changeset for the feature

Files changed:
 .changeset/fn-7864-artifact-mail-link.md           |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 .../app/components/MailboxArtifactAttachment.tsx   | 103 +++++++++++++++++++++
 packages/dashboard/app/components/MailboxModal.css |  74 +++++++++++++++
 packages/dashboard/app/components/MailboxModal.tsx |  15 +++
 packages/dashboard/app/components/MailboxView.tsx  |  15 +++
 .../__tests__/MailboxArtifactAttachment.test.tsx   |  65 +++++++++++++
 .../app/components/__tests__/MailboxView.test.tsx  |  93 +++++++++++++++++++
 .../src/__tests__/agent-artifact-tools.test.ts     |  32 ++++++-
 packages/engine/src/agent-tools.ts                 |   5 +
 10 files changed, 409 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7864

Fusion-Task-Lineage: a6502e18-5f7f-4c67-80fb-a709e4a52c50

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 12:01:00 -07:00
parent 9cfb40e137
commit 95a808af6e
10 changed files with 409 additions and 2 deletions

View File

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

View File

@@ -613,7 +613,7 @@ Mailbox view shows inbox/outbox communication threads and unread state.
- Inbox renders one row per message (no sender-based collapsing) - 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 - 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 - 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`, `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 - 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) - 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 - 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

View File

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

View File

@@ -321,6 +321,80 @@
word-break: break-word; 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 /* Markdown rendering inside message bodies. ReactMarkdown produces standard
block elements (p, ul, h*, code, table, ...). Reset margins so message block elements (p, ul, h*, code, table, ...). Reset margins so message
prose still feels compact, and let pre/table scroll horizontally. */ prose still feels compact, and let pre/table scroll horizontally. */

View File

@@ -36,6 +36,7 @@ import {
} from "../api"; } from "../api";
import { MessageComposer } from "./MessageComposer"; import { MessageComposer } from "./MessageComposer";
import { MailboxMessageContent } from "./MailboxMessageContent"; import { MailboxMessageContent } from "./MailboxMessageContent";
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
import type { Agent } from "../api"; import type { Agent } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
@@ -888,6 +889,13 @@ export function MailboxModal({
content={msg.content} content={msg.content}
className="mailbox-conversation-msg-body" 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> </div>
); );
})} })}
@@ -910,6 +918,13 @@ export function MailboxModal({
className="mailbox-message-body" className="mailbox-message-body"
testId="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> </div>

View File

@@ -38,6 +38,7 @@ import {
type ApprovalRequestDetail, type ApprovalRequestDetail,
} from "../api"; } from "../api";
import { MailboxMessageContent } from "./MailboxMessageContent"; import { MailboxMessageContent } from "./MailboxMessageContent";
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
import { MessageComposer } from "./MessageComposer"; import { MessageComposer } from "./MessageComposer";
import { ViewHeader } from "./ViewHeader"; import { ViewHeader } from "./ViewHeader";
import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails"; import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails";
@@ -873,6 +874,13 @@ export function MailboxView({
content={msg.content} content={msg.content}
className="mailbox-conversation-msg-body" 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> </div>
); );
})} })}
@@ -890,6 +898,13 @@ export function MailboxView({
className="mailbox-message-body" className="mailbox-message-body"
testId="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> </div>

View File

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

View File

@@ -25,6 +25,7 @@ vi.mock("../../api", () => ({
fetchApprovals: vi.fn(), fetchApprovals: vi.fn(),
fetchApprovalDetail: vi.fn(), fetchApprovalDetail: vi.fn(),
decideApproval: vi.fn(), decideApproval: vi.fn(),
artifactMediaUrl: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}` : ""}`),
})); }));
vi.mock("../../hooks/useViewportMode", () => { 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 () => { it("keeps list pane visible alongside detail pane on desktop/tablet", async () => {
mockFetchInbox.mockResolvedValue({ mockFetchInbox.mockResolvedValue({
messages: [mockMessage], messages: [mockMessage],

View File

@@ -254,7 +254,7 @@ describe("artifact register tool", () => {
it("sends exactly one system-to-user inbox notification with artifact metadata", async () => { it("sends exactly one system-to-user inbox notification with artifact metadata", async () => {
const { store, registerArtifact } = createMockStore(); 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); registerArtifact.mockResolvedValue(artifact);
const { messageStore, sendMessage } = createMockMessageStore(); const { messageStore, sendMessage } = createMockMessageStore();
@@ -276,12 +276,42 @@ describe("artifact register tool", () => {
artifactId: "art-notify", artifactId: "art-notify",
artifactType: "image", artifactType: "image",
title: "Screenshot", title: "Screenshot",
mimeType: "image/png",
authorId: AUTHOR_ID, authorId: AUTHOR_ID,
taskId: TASK_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 () => { it("still succeeds when notification sendMessage throws", async () => {
const { store, registerArtifact } = createMockStore(); const { store, registerArtifact } = createMockStore();
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-best-effort" })); registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-best-effort" }));

View File

@@ -1917,6 +1917,10 @@ function hasImageSignature(data: Buffer, mimeType: string): boolean {
function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifact: Artifact, authorId: string): void { function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifact: Artifact, authorId: string): void {
if (!messageStore) return; 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 { try {
messageStore.sendMessage({ messageStore.sendMessage({
fromType: "system", fromType: "system",
@@ -1928,6 +1932,7 @@ function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifa
artifactId: artifact.id, artifactId: artifact.id,
artifactType: artifact.type, artifactType: artifact.type,
title: artifact.title, title: artifact.title,
mimeType: artifact.mimeType,
authorId, authorId,
taskId: artifact.taskId, taskId: artifact.taskId,
}, },