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:
7
.changeset/fn-7864-artifact-mail-link.md
Normal file
7
.changeset/fn-7864-artifact-mail-link.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
103
packages/dashboard/app/components/MailboxArtifactAttachment.tsx
Normal file
103
packages/dashboard/app/components/MailboxArtifactAttachment.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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" }));
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user