Files
fusion/packages/dashboard/app/components/MailboxMessageContent.tsx
gsxdsm 4c2369ed99 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>
2026-08-01 01:22:20 -07:00

158 lines
5.6 KiB
TypeScript

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";
/*
FNXC:Markdown 2026-06-23-03:30:
The sanitize schema, rehype plugin chain (rehype-raw -> rehype-sanitize), and the
mermaid-aware code component now live in ./markdownPipeline so the task
description + summary in TaskDetailModal share the exact same XSS posture. This
component consumes those shared exports; see markdownPipeline.tsx for the rationale.
*/
const mailboxMarkdownComponents: Components = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
pre: ({ children, ...props }) => (
<pre {...props} className="mailbox-markdown-pre">
{children}
</pre>
),
table: ({ children, ...props }) => (
<table {...props} className="mailbox-markdown-table">
{children}
</table>
),
// Code-block override: fenced ```mermaid renders as a diagram; all other code
// keeps default rendering. See createMermaidCodeComponent in markdownPipeline.
code: createMermaidCodeComponent("mailbox-mermaid-diagram"),
};
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];
interface MailboxMessageContentProps {
/** Raw message body. Rendered as GitHub-flavored markdown. */
content: string;
/** Optional extra class for the wrapper. */
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;
}
/**
* Renders a mailbox message body as GitHub-flavored markdown.
*
* Supports embedded raw HTML (details/summary/kbd/sub/tables) via rehype-raw, with
* rehype-sanitize stripping XSS (script/style/iframe/event-handlers/javascript:).
* Fenced ```mermaid blocks render as diagrams via the lazy-loaded MermaidDiagram.
* HTML comments (`<!-- -->`) are dropped and never rendered.
*
* Memoized because mailbox detail panes can re-render on selection / SSE
* updates while the underlying message body is unchanged.
*/
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 (
<MailboxTaskLinkContext.Provider value={onOpenTask}>
<div className={wrapperClass} data-testid={testId}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={sharedRehypePlugins}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
</MailboxTaskLinkContext.Provider>
);
});