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,