FN-8678: keep mailbox task links in dashboard
Keep valid dashboard task links from mailbox markdown in the current application surface. - Route same-origin task deep links through the shared task-opening handler. - Preserve normal links, modified-click behavior, and desktop/mobile task-detail surfaces. - Cover markdown task navigation in content, modal, and mailbox-view tests. - Add a patch changeset for the mailbox navigation fix. Files changed: .changeset/mailbox-task-links-stay-in-app.md | 7 ++ .../app/components/MailboxMessageContent.tsx | 106 +++++++++++++++++---- packages/dashboard/app/components/MailboxModal.tsx | 2 + packages/dashboard/app/components/MailboxView.tsx | 2 + .../__tests__/MailboxMessageContent.test.tsx | 81 +++++++++++++++- .../app/components/__tests__/MailboxModal.test.tsx | 29 +++++- .../app/components/__tests__/MailboxView.test.tsx | 27 +++++- 7 files changed, 233 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-8678 Fusion-Task-Lineage: c906f173-f78d-4b5a-a0ce-5294cfddcada Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/mailbox-task-links-stay-in-app.md
Normal file
7
.changeset/mailbox-task-links-stay-in-app.md
Normal file
@@ -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.
|
||||
@@ -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 }) => (
|
||||
<a {...props} target="_blank" rel="noopener noreferrer">
|
||||
};
|
||||
|
||||
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 <a {...props} target="_blank" rel="noopener noreferrer">{children}</a>;
|
||||
}
|
||||
|
||||
if (!onOpenTask) return <a {...props}>{children}</a>;
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
data-testid="mailbox-task-link"
|
||||
onClick={(event) => {
|
||||
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}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={wrapperClass} data-testid={testId}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={sharedRehypePlugins}
|
||||
components={mailboxMarkdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<MailboxTaskLinkContext.Provider value={onOpenTask}>
|
||||
<div className={wrapperClass} data-testid={testId}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={sharedRehypePlugins}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</MailboxTaskLinkContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -946,6 +946,7 @@ export function MailboxModal({
|
||||
<MailboxMessageContent
|
||||
content={msg.content}
|
||||
className="mailbox-conversation-msg-body"
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
<MailboxRelatedWorkLink
|
||||
metadata={msg.metadata}
|
||||
@@ -986,6 +987,7 @@ export function MailboxModal({
|
||||
content={selectedMessage.content}
|
||||
className="mailbox-message-body"
|
||||
testId="mailbox-message-body"
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
<MailboxRelatedWorkLink
|
||||
metadata={selectedMessage.metadata}
|
||||
|
||||
@@ -961,6 +961,7 @@ export function MailboxView({
|
||||
<MailboxMessageContent
|
||||
content={msg.content}
|
||||
className="mailbox-conversation-msg-body"
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
<MailboxRelatedWorkLink
|
||||
metadata={msg.metadata}
|
||||
@@ -995,6 +996,7 @@ export function MailboxView({
|
||||
content={selectedMessage.content}
|
||||
className="mailbox-message-body"
|
||||
testId="mailbox-message-body"
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
<MailboxRelatedWorkLink
|
||||
metadata={selectedMessage.metadata}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
import { MailboxMessageContent } from "../MailboxMessageContent";
|
||||
import { MailboxMessageContent, parseInAppTaskHref } from "../MailboxMessageContent";
|
||||
|
||||
const mailboxModalCss = readFileSync(resolve(__dirname, "../MailboxModal.css"), "utf8");
|
||||
|
||||
@@ -18,8 +18,17 @@ vi.mock("mermaid", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
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(<MailboxMessageContent content="See [FN-1234](/?task=FN-1234)." onOpenTask={onOpenTask} />);
|
||||
|
||||
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(<MailboxMessageContent content="See [FN-1234](/?task=FN-1234)." onOpenTask={onOpenTask} />);
|
||||
|
||||
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(<MailboxMessageContent content="See [FN-1234](/?task=FN-1234)." />);
|
||||
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(<MailboxMessageContent content="[external](https://example.com) [dashboard](/settings)" />);
|
||||
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(<MailboxMessageContent content="See [FN-1234](/?task=FN-1234)." onOpenTask={onOpenTask} />);
|
||||
|
||||
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/);
|
||||
|
||||
@@ -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(<MailboxModal {...defaultProps} onOpenTask={onOpenTask} />);
|
||||
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,
|
||||
|
||||
@@ -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(<MailboxView {...defaultProps} onOpenTask={onOpenTask} />);
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user