diff --git a/.changeset/mailbox-task-links-stay-in-app.md b/.changeset/mailbox-task-links-stay-in-app.md
new file mode 100644
index 0000000000..b76a4d3cf0
--- /dev/null
+++ b/.changeset/mailbox-task-links-stay-in-app.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Open task links from mail in the active dashboard view.
+category: fix
+dev: Mail markdown task deep links now use the shared task-detail handler.
diff --git a/packages/dashboard/app/components/MailboxMessageContent.tsx b/packages/dashboard/app/components/MailboxMessageContent.tsx
index 3316727f3a..b0fc0cf6d2 100644
--- a/packages/dashboard/app/components/MailboxMessageContent.tsx
+++ b/packages/dashboard/app/components/MailboxMessageContent.tsx
@@ -1,8 +1,9 @@
-import { memo } from "react";
+import { createContext, memo, useContext, useMemo, type ComponentPropsWithoutRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import type { PluggableList } from "unified";
+import { isMobileViewport } from "../hooks/useViewportMode";
import { linkifyReactChildren } from "../utils/filePathLinkify";
import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline";
@@ -30,14 +31,74 @@ const mailboxMarkdownComponents: Components = {
// Code-block override: fenced ```mermaid renders as a diagram; all other code
// keeps default rendering. See createMermaidCodeComponent in markdownPipeline.
code: createMermaidCodeComponent("mailbox-mermaid-diagram"),
- // Open links in a new tab. Sanitize strips javascript: URLs and event handlers
- // before this runs, so href is safe.
- a: ({ children, ...props }) => (
-
+};
+
+const TASK_ID_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
+const MailboxTaskLinkContext = createContext<((taskId: string) => void) | undefined>(undefined);
+
+type MailboxMarkdownAnchorProps = ComponentPropsWithoutRef<"a">;
+
+/**
+ * Returns a task id only for same-origin dashboard deep links with a valid task parameter.
+ *
+ * FNXC:MailboxTaskLinks 2026-08-01-07:20:
+ * Mail task links must open in the existing tab; on non-mobile viewports they open the
+ * task detail modal through the shared onOpenTask handler instead of booting another dashboard tab.
+ */
+export function parseInAppTaskHref(href: string | undefined): string | null {
+ if (!href || typeof window === "undefined") return null;
+
+ try {
+ const url = new URL(href, window.location.origin);
+ const taskId = url.searchParams.get("task");
+ return url.origin === window.location.origin && taskId && TASK_ID_PATTERN.test(taskId)
+ ? taskId
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function MailboxMarkdownAnchor({ children, ...props }: MailboxMarkdownAnchorProps) {
+ const onOpenTask = useContext(MailboxTaskLinkContext);
+ const taskId = parseInAppTaskHref(props.href);
+
+ if (!taskId) {
+ /*
+ FNXC:MailboxTaskLinks 2026-08-01-07:34:
+ Only verified same-origin task deep links stay in the dashboard. Markdown sanitization makes all
+ remaining hrefs safe, while external and non-task links retain their established new-tab behavior.
+ */
+ return {children};
+ }
+
+ if (!onOpenTask) return {children};
+
+ return (
+ {
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
+
+ event.preventDefault();
+ /*
+ FNXC:MailboxTaskLinks 2026-08-01-07:20:
+ Mail task links retain their href for copy and modified-click behavior, but ordinary clicks
+ stay in the existing tab. Both viewport paths call the shared handler: desktop opens the task
+ detail modal and mobile opens its existing full-screen task sheet.
+ */
+ if (isMobileViewport()) {
+ onOpenTask(taskId);
+ return;
+ }
+ onOpenTask(taskId);
+ }}
+ >
{children}
- ),
-};
+ );
+}
const remarkPlugins: PluggableList = [remarkGfm];
@@ -48,6 +109,12 @@ interface MailboxMessageContentProps {
className?: string;
/** Optional data-testid for test selectors. */
testId?: string;
+ /**
+ * FNXC:MailboxTaskLinks 2026-08-01-07:34:
+ * The supplied dashboard handler preserves existing-tab task navigation and selects the appropriate
+ * task-detail surface for the current viewport.
+ */
+ onOpenTask?: (taskId: string) => void;
}
/**
@@ -65,19 +132,26 @@ export const MailboxMessageContent = memo(function MailboxMessageContent({
content,
className,
testId,
+ onOpenTask,
}: MailboxMessageContentProps) {
+ const markdownComponents = useMemo(
+ () => ({ ...mailboxMarkdownComponents, a: MailboxMarkdownAnchor }),
+ [onOpenTask],
+ );
const wrapperClass = className
? `mailbox-markdown ${className}`
: "mailbox-markdown";
return (
-
-
- {content}
-
-
+
+
+
+ {content}
+
+
+
);
});
diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx
index 9b1fdc26bd..8c2f34f2bb 100644
--- a/packages/dashboard/app/components/MailboxModal.tsx
+++ b/packages/dashboard/app/components/MailboxModal.tsx
@@ -946,6 +946,7 @@ export function MailboxModal({
({
},
}));
+function stubMobileViewport(matches: boolean) {
+ vi.stubGlobal("matchMedia", vi.fn().mockImplementation(() => ({ matches })));
+}
+
+beforeEach(() => {
+ stubMobileViewport(false);
+});
+
afterEach(() => {
cleanup();
+ vi.unstubAllGlobals();
});
describe("MailboxMessageContent", () => {
@@ -95,6 +104,72 @@ describe("MailboxMessageContent", () => {
expect(container.querySelector("a.file-path-link")).toBeNull();
});
+ describe("in-app task links", () => {
+ it("parses only same-origin hrefs with valid dashboard task ids", () => {
+ expect(parseInAppTaskHref("/?task=FN-1234")).toBe("FN-1234");
+ expect(parseInAppTaskHref("/?project=project-1&task=KB-002")).toBe("KB-002");
+ expect(parseInAppTaskHref(`${window.location.origin}/?task=FN-1234`)).toBe("FN-1234");
+ expect(parseInAppTaskHref("https://example.com/?task=FN-1234")).toBeNull();
+ expect(parseInAppTaskHref("mailto:operator@example.com?task=FN-1234")).toBeNull();
+ expect(parseInAppTaskHref("/?task=invalid")).toBeNull();
+ expect(parseInAppTaskHref("/?project=project-1")).toBeNull();
+ expect(parseInAppTaskHref("http://[invalid")).toBeNull();
+ });
+
+ it("opens a desktop task link through onOpenTask without navigating", async () => {
+ const onOpenTask = vi.fn();
+ const user = userEvent.setup();
+ const recordDefaultPrevention = vi.fn((event: MouseEvent) => event.defaultPrevented);
+ document.addEventListener("click", recordDefaultPrevention);
+ render();
+
+ const link = screen.getByTestId("mailbox-task-link");
+ expect(link).not.toHaveAttribute("target");
+ expect(link).not.toHaveAttribute("rel");
+ await user.click(link);
+ document.removeEventListener("click", recordDefaultPrevention);
+
+ expect(onOpenTask).toHaveBeenCalledTimes(1);
+ expect(onOpenTask).toHaveBeenCalledWith("FN-1234");
+ expect(recordDefaultPrevention).toHaveBeenCalledWith(expect.objectContaining({ defaultPrevented: true }));
+ });
+
+ it("keeps mobile task links in the existing tab through onOpenTask", async () => {
+ stubMobileViewport(true);
+ const onOpenTask = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ const link = screen.getByTestId("mailbox-task-link");
+ expect(link).not.toHaveAttribute("target", "_blank");
+ await user.click(link);
+ expect(onOpenTask).toHaveBeenCalledWith("FN-1234");
+ });
+
+ it("keeps task links as ordinary same-tab anchors without onOpenTask", () => {
+ render();
+ const link = screen.getByRole("link", { name: "FN-1234" });
+ expect(link).not.toHaveAttribute("target");
+ expect(link).not.toHaveAttribute("data-testid");
+ });
+
+ it("keeps external and same-origin non-task links in new tabs", () => {
+ render();
+ for (const link of screen.getAllByRole("link")) {
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ }
+ });
+
+ it("preserves modified-click behavior for task links", () => {
+ const onOpenTask = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByTestId("mailbox-task-link"), { ctrlKey: true });
+ expect(onOpenTask).not.toHaveBeenCalled();
+ });
+ });
+
it("defines token-only mailbox markdown anchor styles", () => {
const anchorRule = mailboxModalCss.match(/\.mailbox-markdown a\s*\{([^}]*)\}/)?.[1];
expect(mailboxModalCss).toMatch(/\.mailbox-markdown a\b/);
diff --git a/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx b/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx
index 6dc4c65554..9440b8eb77 100644
--- a/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
-import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
+import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { MailboxModal } from "../MailboxModal";
import * as apiModule from "../../api";
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
@@ -138,6 +139,7 @@ const defaultProps = {
describe("MailboxModal", () => {
afterEach(() => {
vi.useRealTimers();
+ vi.unstubAllGlobals();
});
beforeEach(() => {
@@ -436,6 +438,31 @@ describe("MailboxModal", () => {
});
});
+ it("opens markdown task links from the selected mobile mail detail in the existing tab", async () => {
+ vi.stubGlobal("matchMedia", vi.fn().mockImplementation(() => ({ matches: true })));
+ const taskMessage: Message = {
+ ...mockMessage,
+ id: "msg-modal-markdown-task-link",
+ content: "See [FN-1234](/?task=FN-1234) and [external](https://example.com).",
+ };
+ const onOpenTask = vi.fn();
+ const user = userEvent.setup();
+ mockFetchInbox.mockResolvedValue({ messages: [taskMessage], total: 1, unreadCount: 1 });
+ mockFetchConversation.mockResolvedValue([taskMessage]);
+ mockMarkMessageRead.mockResolvedValue({ ...taskMessage, read: true });
+
+ render();
+ await user.click(await screen.findByTestId("mailbox-item-msg-modal-markdown-task-link"));
+
+ const detail = await screen.findByTestId("mailbox-message-body");
+ const taskLink = within(detail).getByTestId("mailbox-task-link");
+ expect(taskLink).not.toHaveAttribute("target", "_blank");
+ await user.click(taskLink);
+ expect(onOpenTask).toHaveBeenCalledTimes(1);
+ expect(onOpenTask).toHaveBeenCalledWith("FN-1234");
+ expect(within(detail).getByRole("link", { name: "external" })).toHaveAttribute("target", "_blank");
+ });
+
it("opens task-only and planning-clarification related work from modal detail", async () => {
const taskMessage: Message = {
...mockMessage,
diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
index 9a8d364f0d..655e5d78f0 100644
--- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useEffect, type ReactNode } from "react";
import { loadAllAppCss } from "../../test/cssFixture";
-import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
+import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { MailboxView } from "../MailboxView";
import { NavigationHistoryProvider, useNavigationHistory, type UseNavigationHistoryResult } from "../../hooks/useNavigationHistory";
import * as apiModule from "../../api";
@@ -833,6 +834,30 @@ describe("MailboxView", () => {
});
});
+ it("opens markdown task links from the selected mail detail in the existing desktop tab", async () => {
+ const taskMessage: Message = {
+ ...mockMessage,
+ id: "msg-markdown-task-link",
+ content: "See [FN-1234](/?project=project-1&task=FN-1234) and [external](https://example.com).",
+ };
+ const onOpenTask = vi.fn();
+ const user = userEvent.setup();
+ mockFetchInbox.mockResolvedValue(makeInboxResponse([taskMessage], 1));
+ mockFetchConversation.mockResolvedValue([taskMessage]);
+ mockMarkMessageRead.mockResolvedValue({ ...taskMessage, read: true });
+
+ render();
+ await user.click(await screen.findByTestId("mailbox-item-msg-markdown-task-link"));
+
+ const detail = await screen.findByTestId("mailbox-message-body");
+ const taskLink = within(detail).getByTestId("mailbox-task-link");
+ expect(taskLink).not.toHaveAttribute("target", "_blank");
+ await user.click(taskLink);
+ expect(onOpenTask).toHaveBeenCalledTimes(1);
+ expect(onOpenTask).toHaveBeenCalledWith("FN-1234");
+ expect(within(detail).getByRole("link", { name: "external" })).toHaveAttribute("target", "_blank");
+ });
+
it("opens task-only and planning-clarification related work from mailbox detail", async () => {
const taskMessage: Message = {
...mockMessage,