diff --git a/.changeset/fn-9194-mailbox-task-link-id.md b/.changeset/fn-9194-mailbox-task-link-id.md
new file mode 100644
index 0000000000..36f1d2afe4
--- /dev/null
+++ b/.changeset/fn-9194-mailbox-task-link-id.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Mailbox task links now show the real task ID instead of a raw placeholder.
+category: fix
+dev: Aligns MailboxRelatedWorkLink with the mailbox.viewTask and mailbox.viewTaskAria {{id}} variable contract.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 0e77625611..605e720e68 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -917,7 +917,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. When an
- 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`, 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 in the shared movable/resizable task-detail window.
+- 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`, 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 FN-NNNN** so users can open the producing task detail directly from the mailbox in the shared movable/resizable task-detail window.
- 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 ``" summary; a stateless heartbeat retrying the same gated command reuses the existing pending approval instead of creating a duplicate (FN-7609)
diff --git a/packages/dashboard/app/components/MailboxRelatedWorkLink.tsx b/packages/dashboard/app/components/MailboxRelatedWorkLink.tsx
index 7e77d53749..c4a96bc1b8 100644
--- a/packages/dashboard/app/components/MailboxRelatedWorkLink.tsx
+++ b/packages/dashboard/app/components/MailboxRelatedWorkLink.tsx
@@ -21,6 +21,11 @@ export function hasRelatedTaskLink(metadata: MessageMetadata | undefined, onOpen
* its task or planning session. Task metadata wins when both targets are present, and missing
* metadata or navigation handlers deliberately render no button so the detail never contains a
* dead affordance.
+ *
+ * FNXC:MailboxRelatedWork 2026-08-23-07:58:
+ * FN-9194 requires mailbox task links to display their destination task ID. The shared
+ * mailbox.viewTask entries interpolate {{id}}, so every consumer must pass { id }; a catalog hit
+ * outranks an inline default and a mismatched variable otherwise exposes the raw placeholder.
*/
export function MailboxRelatedWorkLink({
metadata,
@@ -36,11 +41,11 @@ export function MailboxRelatedWorkLink({
);
}
diff --git a/packages/dashboard/app/components/__tests__/MailboxRelatedWorkLink.test.tsx b/packages/dashboard/app/components/__tests__/MailboxRelatedWorkLink.test.tsx
index 5230e0bea6..0a2e4b1544 100644
--- a/packages/dashboard/app/components/__tests__/MailboxRelatedWorkLink.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MailboxRelatedWorkLink.test.tsx
@@ -1,7 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
+import { createInstance } from "i18next";
+import { I18nextProvider, initReactI18next } from "react-i18next";
+import realEnApp from "../../../../i18n/locales/en/app.json";
import { MailboxRelatedWorkLink } from "../MailboxRelatedWorkLink";
+async function createRealCatalogInstance() {
+ const instance = createInstance();
+ await instance.use(initReactI18next).init({
+ lng: "en",
+ fallbackLng: "en",
+ ns: ["app"],
+ defaultNS: "app",
+ returnNull: false,
+ returnEmptyString: false,
+ react: { useSuspense: false },
+ interpolation: { escapeValue: false },
+ resources: { en: { app: realEnApp } },
+ });
+ return instance;
+}
+
describe("MailboxRelatedWorkLink", () => {
it("opens a task when task metadata and its handler are available", () => {
const onOpenTask = vi.fn();
@@ -11,6 +30,34 @@ describe("MailboxRelatedWorkLink", () => {
expect(onOpenTask).toHaveBeenCalledWith("FN-8428");
});
+ it("interpolates task and planning-session destinations against the shipping English catalog", async () => {
+ const instance = await createRealCatalogInstance();
+ const onOpenTask = vi.fn();
+ const onOpenPlanningSession = vi.fn();
+ const { rerender, container } = render(
+
+
+ ,
+ );
+
+ const taskLink = screen.getByTestId("mailbox-view-task");
+ expect(taskLink).toHaveTextContent("View task FN-8428");
+ expect(taskLink).toHaveAccessibleName("View task: FN-8428");
+ expect(container.textContent).not.toContain("{{");
+ fireEvent.click(taskLink);
+ expect(onOpenTask).toHaveBeenCalledWith("FN-8428");
+
+ rerender(
+
+
+ ,
+ );
+ expect(screen.getByTestId("mailbox-open-planning-session")).toHaveAccessibleName("Open planning session: planning-8428");
+ });
+
it("opens a planning clarification session when no task target is available", () => {
const onOpenPlanningSession = vi.fn();
render(
@@ -31,6 +78,9 @@ describe("MailboxRelatedWorkLink", () => {
rerender();
expect(screen.queryByTestId("mailbox-open-planning-session")).toBeNull();
+ rerender();
+ expect(screen.queryByTestId("mailbox-view-task")).toBeNull();
+
rerender();
expect(screen.queryByTestId("mailbox-view-task")).toBeNull();
expect(screen.queryByTestId("mailbox-open-planning-session")).toBeNull();
diff --git a/packages/dashboard/app/components/__tests__/MailboxTaskLinkInterpolation.surfaces.test.tsx b/packages/dashboard/app/components/__tests__/MailboxTaskLinkInterpolation.surfaces.test.tsx
new file mode 100644
index 0000000000..3f8863312c
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/MailboxTaskLinkInterpolation.surfaces.test.tsx
@@ -0,0 +1,128 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createInstance } from "i18next";
+import { I18nextProvider, initReactI18next } from "react-i18next";
+import type { Message } from "@fusion/core";
+import realEnApp from "../../../../i18n/locales/en/app.json";
+import { MailboxView } from "../MailboxView";
+import { MailboxModal } from "../MailboxModal";
+import { useViewportMode } from "../../hooks/useViewportMode";
+import { useViewportMode as useHeaderViewportMode } from "../Header";
+
+vi.mock("../../api", () => ({
+ fetchInbox: vi.fn(), fetchOutbox: vi.fn(), fetchUnreadCount: vi.fn(), fetchAgentMailbox: vi.fn(), fetchAllAgentMailbox: vi.fn(),
+ markMessageRead: vi.fn(), markAllMessagesRead: vi.fn(), deleteMessage: vi.fn(), fetchConversation: vi.fn(), fetchMessage: vi.fn(),
+ sendMessage: vi.fn(), fetchAgents: vi.fn(), fetchApprovals: vi.fn(), fetchApprovalDetail: vi.fn(), decideApproval: vi.fn(),
+ artifactMediaUrlWithToken: vi.fn(), fetchNativeStructurePreview: vi.fn(),
+}));
+vi.mock("../../hooks/useViewportMode", () => ({
+ useViewportMode: vi.fn(() => "desktop"), isMobileViewport: () => false, isFullScreenSheetViewport: () => false,
+ isShortViewport: () => false, getViewportMode: () => "desktop", isTabletTouchViewport: () => false,
+}));
+vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn(() => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false })) }));
+vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => {}) }));
+vi.mock("../Header", () => ({ useViewportMode: vi.fn(() => "desktop") }));
+vi.mock("../ComposeChatPanel", () => ({ ComposeChatPanel: () => null }));
+vi.mock("lucide-react", () => ({
+ Mail: () => null, Send: () => null, Inbox: () => null, Bot: () => null, Trash2: () => null,
+ CheckCheck: () => null, Loader2: () => null, RefreshCw: () => null, MessageSquare: () => null,
+ User: () => null, X: () => null, Check: () => null, ChevronRight: () => null, ChevronDown: () => null,
+ AlertCircle: () => null, Map: () => null, Flag: () => null, Lightbulb: () => null, BarChart3: () => null,
+ Target: () => null, CircleAlert: () => null, Archive: () => null,
+}));
+
+import * as api from "../../api";
+
+const agents = [{ id: "agent-1", name: "Agent", role: "executor", state: "idle", createdAt: "2026-08-23T00:00:00.000Z", updatedAt: "2026-08-23T00:00:00.000Z", metadata: {} }];
+
+function message(id: string, metadata: Message["metadata"]): Message {
+ return {
+ id,
+ fromId: "agent-1",
+ fromType: "agent",
+ toId: "dashboard",
+ toType: "user",
+ type: "agent-to-user",
+ read: true,
+ archived: false,
+ content: `Mailbox message ${id}`,
+ metadata,
+ createdAt: "2026-08-23T00:00:00.000Z",
+ updatedAt: "2026-08-23T00:00:00.000Z",
+ };
+}
+
+async function createRealCatalogInstance() {
+ const instance = createInstance();
+ await instance.use(initReactI18next).init({
+ lng: "en",
+ fallbackLng: "en",
+ ns: ["app"],
+ defaultNS: "app",
+ returnNull: false,
+ returnEmptyString: false,
+ react: { useSuspense: false },
+ interpolation: { escapeValue: false },
+ resources: { en: { app: realEnApp } },
+ });
+ return instance;
+}
+
+function Host({ kind, ...props }: { kind: "view" | "modal" } & Record) {
+ return kind === "view"
+ ?
+ : ;
+}
+
+function renderHost(kind: "view" | "modal", instance: Awaited>, onOpenTask = vi.fn(), onOpenPlanningSession = vi.fn()) {
+ return render(
+
+
+ ,
+ );
+}
+
+/*
+ * FNXC:MailboxRelatedWork 2026-08-23-07:58:
+ * FN-9194 requires production mailbox hosts to resolve task-link copy through the shipping
+ * English catalog. The matrix preserves the visible and accessible task ID across viewport and
+ * conversation layouts, where a catalog placeholder mismatch would otherwise reach users.
+ */
+describe("mailbox task-link interpolation production surfaces", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: vi.fn() });
+ vi.mocked(api.fetchOutbox).mockResolvedValue({ messages: [], total: 0 });
+ vi.mocked(api.fetchUnreadCount).mockResolvedValue({ unreadCount: 0 });
+ vi.mocked(api.fetchAgents).mockResolvedValue(agents as any);
+ vi.mocked(api.fetchAllAgentMailbox).mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
+ });
+
+ afterEach(() => {
+ window.history.replaceState({}, "", "/");
+ });
+
+ it.each([
+ ["view", "desktop", "single"], ["view", "desktop", "threaded"], ["view", "mobile", "single"], ["view", "mobile", "threaded"],
+ ["modal", "desktop", "single"], ["modal", "desktop", "threaded"], ["modal", "mobile", "single"], ["modal", "mobile", "threaded"],
+ ] as const)("renders the concrete task ID in %s %s %s detail", async (kind, viewport, layout) => {
+ vi.mocked(useViewportMode).mockReturnValue(viewport);
+ vi.mocked(useHeaderViewportMode).mockReturnValue(viewport);
+ const selected = message("task-link", { taskId: "FN-1234" });
+ const thread = layout === "threaded"
+ ? [message("task-link-parent", {}), { ...selected, metadata: { ...selected.metadata, replyTo: { messageId: "task-link-parent" } } }]
+ : [];
+ vi.mocked(api.fetchInbox).mockResolvedValue({ messages: [selected], total: 1, unreadCount: 1 });
+ vi.mocked(api.fetchConversation).mockResolvedValue(thread as any);
+ const instance = await createRealCatalogInstance();
+ const onOpenTask = vi.fn();
+ const { container } = renderHost(kind, instance, onOpenTask);
+
+ fireEvent.click(await screen.findByTestId("mailbox-item-task-link"));
+ const taskLink = await screen.findByTestId("mailbox-view-task");
+ expect(taskLink).toHaveTextContent("View task FN-1234");
+ expect(taskLink).toHaveAccessibleName("View task: FN-1234");
+ expect(container.textContent).not.toContain("{{");
+ });
+
+});