feat(FN-4227): add clickable file path links across dashboard
- Add reusable file path linkification utilities and FileBrowser context wiring across dashboard modals and log surfaces - Linkify task detail, review, workflow, chat, activity, agent, dev server, and settings sync outputs so workspace paths open in the file browser - Preserve inline code styling when wrapping detected paths and extend FileBrowserModal handling for linked navigation - Cover the new linking behavior with dashboard tests and add a changeset plus AGENTS guidance for the reusable pattern Fusion-Task-Id: FN-4227
This commit is contained in:
5
.changeset/FN-4227-clickable-file-paths.md
Normal file
5
.changeset/FN-4227-clickable-file-paths.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
File paths in dashboard chat messages, logs, and task detail markdown are now clickable and open in the integrated file browser.
|
||||
@@ -876,6 +876,12 @@ Cards have `--focus-ring-strong` focus style and `--card-hover` background on ho
|
||||
|
||||
---
|
||||
|
||||
### File-path links in dashboard text
|
||||
|
||||
- Reuse `packages/dashboard/app/utils/filePathLinkify.tsx` plus `packages/dashboard/app/context/FileBrowserContext.tsx` whenever a dashboard surface should open workspace paths in `FileBrowserModal`.
|
||||
- Wrap plain text with `linkifyFilePaths(...)`, and wrap mixed JSX/markdown/highlight output with `linkifyReactChildren(...)` so existing formatting survives.
|
||||
- Mount surfaces under `FileBrowserProvider` and route clicks through its `openFile(path, { workspace?, line?, col? })` handler instead of adding one-off modal state plumbing.
|
||||
|
||||
### Adding New CSS
|
||||
|
||||
1. **Always use tokens** — `var(--space-md)`, `var(--text-muted)`, `var(--radius-md)`, `var(--transition-fast)`, etc. Never write `padding: 8px` or `color: #e6edf3` directly.
|
||||
|
||||
@@ -58,6 +58,7 @@ import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import { FileBrowserProvider } from "./context/FileBrowserContext";
|
||||
import { ShellProvider } from "./context/ShellContext";
|
||||
import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext";
|
||||
import { useShellConnection } from "./hooks/useShellConnection";
|
||||
@@ -976,8 +977,13 @@ function AppInner() {
|
||||
}
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openFilesWithNav = useCallback(() => {
|
||||
modalManager.openFiles();
|
||||
const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => {
|
||||
modalManager.openFiles(workspace, initialFile);
|
||||
pushNav({ type: "modal", close: modalManager.closeFiles });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openFileInBrowser = useCallback((path: string, opts?: { workspace?: string; line?: number; col?: number }) => {
|
||||
modalManager.openFiles(opts?.workspace, path);
|
||||
pushNav({ type: "modal", close: modalManager.closeFiles });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
@@ -1515,7 +1521,7 @@ function AppInner() {
|
||||
|
||||
return (
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent }}>
|
||||
<>
|
||||
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||
<Header
|
||||
shellHost={shellHost.host}
|
||||
onOpenSettings={openSettingsWithNav}
|
||||
@@ -1793,7 +1799,7 @@ function AppInner() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FileBrowserProvider>
|
||||
</NavigationHistoryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight
|
||||
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
|
||||
import { useActivityLog } from "../hooks/useActivityLog";
|
||||
import type { Task, ProjectInfo } from "@fusion/core";
|
||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||
|
||||
interface ActivityLogModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -358,7 +359,7 @@ export function ActivityLogModal({
|
||||
{entry.taskTitle && (
|
||||
<span className="activity-log-task-title">{entry.taskTitle}</span>
|
||||
)}
|
||||
<span className="activity-log-entry-text">{entry.details}</span>
|
||||
<span className="activity-log-entry-text">{linkifyFilePaths(entry.details ?? "")}</span>
|
||||
</div>
|
||||
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
|
||||
<div className="activity-log-entry-metadata">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react";
|
||||
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import "./AgentLogViewer.css";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown";
|
||||
const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output";
|
||||
@@ -46,6 +47,16 @@ function formatTimestamp(iso: string): string {
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => {
|
||||
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
|
||||
const linkedChildren = linkifyFilePaths(text);
|
||||
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
return <code {...props}>{linkedChildren}</code>;
|
||||
},
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
@@ -139,7 +150,7 @@ function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactEle
|
||||
className={expanded ? "agent-log-tool-detail-content" : "agent-log-tool-detail-content agent-log-tool-detail-content--collapsed"}
|
||||
data-testid="tool-detail-content"
|
||||
>
|
||||
<pre className="agent-log-tool-detail">{detail}</pre>
|
||||
<pre className="agent-log-tool-detail">{linkifyFilePaths(detail)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -649,7 +660,7 @@ export function AgentLogViewer({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="agent-log-plain-block">{groupedText}</pre>
|
||||
<pre className="agent-log-plain-block">{linkifyFilePaths(groupedText)}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -665,7 +676,7 @@ export function AgentLogViewer({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="agent-log-plain-block">{groupedText}</pre>
|
||||
<pre className="agent-log-plain-block">{linkifyFilePaths(groupedText)}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -249,6 +249,7 @@ export function AppModals({
|
||||
{modalManager.filesOpen && (
|
||||
<FileBrowserModal
|
||||
initialWorkspace={modalManager.fileBrowserWorkspace}
|
||||
initialFile={modalManager.fileBrowserInitialFile}
|
||||
isOpen={true}
|
||||
onClose={modalManager.closeFiles}
|
||||
onWorkspaceChange={modalManager.setFileWorkspace}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
||||
// unstyled content when the lazy chunk loads. Do not re-import here.
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
@@ -42,6 +42,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
@@ -329,11 +330,25 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
}
|
||||
|
||||
const chatMarkdownComponents: Components = {
|
||||
p: ({ children, ...props }) => (
|
||||
<p {...props}>{linkifyReactChildren(children)}</p>
|
||||
),
|
||||
li: ({ children, ...props }) => (
|
||||
<li {...props}>{linkifyReactChildren(children)}</li>
|
||||
),
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre {...props} className="chat-markdown-pre">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, ...props }) => {
|
||||
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
|
||||
const linkedChildren = linkifyFilePaths(text);
|
||||
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
return <code {...props}>{linkedChildren}</code>;
|
||||
},
|
||||
table: ({ children, ...props }) => (
|
||||
<table {...props} className="chat-markdown-table">
|
||||
{children}
|
||||
@@ -795,7 +810,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
<TriangleAlert size={14} aria-hidden="true" />
|
||||
<span>Failure details</span>
|
||||
</summary>
|
||||
{failureInfo.detail && <pre className="chat-message-failure-detail">{failureInfo.detail}</pre>}
|
||||
{failureInfo.detail && <pre className="chat-message-failure-detail">{linkifyFilePaths(failureInfo.detail)}</pre>}
|
||||
{renderFailureReference(failureInfo.reference)}
|
||||
</details>
|
||||
)}
|
||||
@@ -834,7 +849,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
{message.thinkingOutput && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre>
|
||||
</details>
|
||||
)}
|
||||
{renderedAttachments}
|
||||
@@ -2613,7 +2628,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="chat-typing-indicator">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
||||
import { ChevronDown, Loader2, Maximize2, Minimize2, Search } from "lucide-react";
|
||||
import "./DevServerLogViewer.css";
|
||||
import type { DevServerLogEntry } from "../hooks/useDevServerLogs";
|
||||
import { linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
interface DevServerLogViewerProps {
|
||||
entries: DevServerLogEntry[];
|
||||
@@ -289,7 +290,7 @@ export function DevServerLogViewer({
|
||||
{entry.stream === "stderr" && (
|
||||
<span className="devserver-log-stream-badge" data-testid="devserver-log-stderr-badge">ERR</span>
|
||||
)}
|
||||
<span className="devserver-log-text">{highlightText(plainText, searchQuery.trim())}</span>
|
||||
<span className="devserver-log-text">{linkifyReactChildren(highlightText(plainText, searchQuery.trim()))}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -49,9 +49,16 @@ function isImageFile(filename: string): boolean {
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function getParentDirectory(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
return lastSlash > 0 ? normalized.slice(0, lastSlash) : ".";
|
||||
}
|
||||
|
||||
interface FileBrowserModalProps {
|
||||
isOpen?: boolean;
|
||||
initialWorkspace?: string;
|
||||
initialFile?: string | null;
|
||||
onClose: () => void;
|
||||
onWorkspaceChange?: (workspace: string) => void;
|
||||
projectId?: string;
|
||||
@@ -63,6 +70,7 @@ interface FileBrowserModalProps {
|
||||
*/
|
||||
export function FileBrowserModal({
|
||||
initialWorkspace = "project",
|
||||
initialFile = null,
|
||||
onClose,
|
||||
onWorkspaceChange,
|
||||
projectId,
|
||||
@@ -119,6 +127,19 @@ export function FileBrowserModal({
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFile) {
|
||||
setSelectedFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(initialFile);
|
||||
setPath(getParentDirectory(initialFile));
|
||||
if (isMobile) {
|
||||
setMobileView("editor");
|
||||
}
|
||||
}, [initialFile, isMobile, setPath]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const rawWidth = localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, Upload } from "lucide-react";
|
||||
import "./SettingsSyncLog.css";
|
||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -196,7 +197,7 @@ export function SettingsSyncLog({
|
||||
|
||||
{entry.details && (
|
||||
<span className="settings-sync-log__entry-details" title={entry.details}>
|
||||
{entry.details}
|
||||
{linkifyFilePaths(entry.details ?? "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import "./TaskDetailModal.css";
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult } from "@fusion/core";
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ import { appendTokenQuery } from "../auth";
|
||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
||||
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -51,7 +53,18 @@ interface ModelSelection {
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||
|
||||
|
||||
const markdownLinkifyComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => {
|
||||
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
|
||||
const linkedChildren = linkifyFilePaths(text);
|
||||
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
return <code {...props}>{linkedChildren}</code>;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective executor model following the engine's resolution order:
|
||||
@@ -2308,7 +2321,7 @@ export function TaskDetailContent({
|
||||
<div className="detail-section detail-summary">
|
||||
<h4>Summary</h4>
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
|
||||
{task.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -2654,7 +2667,7 @@ export function TaskDetailContent({
|
||||
<div className="spec-loading">Loading specification…</div>
|
||||
) : workingTask.prompt ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
|
||||
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Components } from "react-markdown";
|
||||
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api";
|
||||
import type { SelectedReviewItem } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
interface Props {
|
||||
task: Task | TaskDetail;
|
||||
@@ -55,6 +56,9 @@ function writeBooleanPref(key: string, value: boolean): void {
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => <code {...props}>{linkifyReactChildren(children)}</code>,
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
@@ -65,7 +69,7 @@ const markdownComponents: Components = {
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{linkifyReactChildren(children)}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, ...props }) => (
|
||||
@@ -317,7 +321,7 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="task-review-tab__body">{item.body}</pre>
|
||||
<pre className="task-review-tab__body">{linkifyFilePaths(item.body)}</pre>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -7,15 +7,19 @@ import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/co
|
||||
import { fetchWorkflowSteps } from "../api";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import type { Components } from "react-markdown";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
// Markdown rendering components for workflow output
|
||||
const markdownComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => <code {...props}>{linkifyReactChildren(children)}</code>,
|
||||
pre: ({ children, className, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
className={["workflow-markdown-pre", className].filter(Boolean).join(" ")}
|
||||
>
|
||||
{children}
|
||||
{linkifyReactChildren(children)}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, className, ...props }) => (
|
||||
@@ -146,38 +150,38 @@ function LiveAgentLogOutput({
|
||||
if (entry.type === "tool") {
|
||||
return (
|
||||
<div key={i} className="workflow-live-log-tool">
|
||||
⚡ {entry.text}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||
⚡ {linkifyFilePaths(entry.text)}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.type === "tool_result") {
|
||||
return (
|
||||
<div key={i} className="workflow-live-log-tool-result">
|
||||
✓ {entry.text}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||
✓ {linkifyFilePaths(entry.text)}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.type === "tool_error") {
|
||||
return (
|
||||
<div key={i} className="workflow-live-log-tool-error">
|
||||
✗ {entry.text}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||
✗ {linkifyFilePaths(entry.text)}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.type === "thinking") {
|
||||
return (
|
||||
<div key={i} className="workflow-live-log-thinking">
|
||||
{entry.text}
|
||||
{linkifyFilePaths(entry.text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Default: text entries
|
||||
return (
|
||||
<span key={i} className="workflow-live-log-text">
|
||||
{entry.text}
|
||||
{linkifyFilePaths(entry.text)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -553,7 +557,7 @@ export function WorkflowResultsTab({
|
||||
</div>
|
||||
) : (
|
||||
<pre className="workflow-result-output-text">
|
||||
{result.output}
|
||||
{linkifyFilePaths(result.output)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
@@ -697,7 +701,7 @@ export function WorkflowResultsTab({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="workflow-result-output-text">{result.output}</pre>
|
||||
<pre className="workflow-result-output-text">{linkifyFilePaths(result.output)}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { AgentLogViewer } from "../AgentLogViewer";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import "../../styles.css";
|
||||
import "../TaskDetailModal.css";
|
||||
@@ -132,6 +133,18 @@ describe("AgentLogViewer", () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders file paths in plain log lines as clickable file-browser links", async () => {
|
||||
const openFile = vi.fn();
|
||||
render(
|
||||
<FileBrowserProvider openFile={openFile}>
|
||||
<AgentLogViewer entries={[makeEntry({ text: "writing packages/engine/src/scheduler.ts" })]} loading={false} />
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "packages/engine/src/scheduler.ts" }));
|
||||
expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: undefined, col: undefined });
|
||||
});
|
||||
|
||||
it("renders tool entries with distinct styling", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Read", type: "tool" }),
|
||||
|
||||
@@ -155,6 +155,7 @@ describe("AppModals", () => {
|
||||
filesOpen: false,
|
||||
todosOpen: false,
|
||||
fileBrowserWorkspace: "project",
|
||||
fileBrowserInitialFile: null,
|
||||
usageOpen: false,
|
||||
usageAnchorRect: null,
|
||||
systemStatsOpen: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { userEvent } from "@testing-library/user-event";
|
||||
import { ChatView } from "../ChatView";
|
||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
|
||||
// Mock scrollIntoView for JSDOM
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -523,6 +524,23 @@ describe("ChatView", () => {
|
||||
expect(screen.getByText("Hi there!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders file paths in assistant messages as clickable file-browser links", async () => {
|
||||
const openFile = vi.fn();
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "see `packages/foo/bar.ts:42` for details", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserProvider openFile={openFile}>
|
||||
<ChatView projectId="proj-123" addToast={vi.fn()} />
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "packages/foo/bar.ts:42" }));
|
||||
expect(openFile).toHaveBeenCalledWith("packages/foo/bar.ts", { line: 42, col: undefined });
|
||||
});
|
||||
|
||||
it("does not render markdown/plain toggle controls in the thread header", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
|
||||
@@ -106,6 +106,24 @@ describe("FileBrowserModal", () => {
|
||||
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined);
|
||||
});
|
||||
|
||||
it("opens with an initial file selected", async () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
initialFile="packages/dashboard/app/App.tsx"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("packages/dashboard/app/App.tsx").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(mockSetPath).toHaveBeenCalledWith("packages/dashboard/app");
|
||||
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "packages/dashboard/app/App.tsx", true, undefined);
|
||||
});
|
||||
|
||||
it("switches workspace and notifies parent", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -17,10 +17,35 @@ import {
|
||||
} from "./TaskDetailModal.test-helpers";
|
||||
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
||||
import * as dashboardApi from "../../api";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
|
||||
setupTaskDetailModalHooks();
|
||||
|
||||
describe("TaskDetailModal", () => {
|
||||
it("renders clickable file links in markdown content", async () => {
|
||||
const openFile = vi.fn();
|
||||
render(
|
||||
<FileBrowserProvider openFile={openFile}>
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
column: "done",
|
||||
summary: "See `packages/dashboard/app/App.tsx:12` for context.",
|
||||
prompt: "# Prompt\n\nInspect `packages/dashboard/app/App.tsx:12`."
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "packages/dashboard/app/App.tsx:12" })[0]!);
|
||||
expect(openFile).toHaveBeenCalledWith("packages/dashboard/app/App.tsx", { line: 12, col: undefined });
|
||||
});
|
||||
|
||||
describe("provenance display", () => {
|
||||
it.each([
|
||||
["dashboard_ui", undefined, "Created via Dashboard"],
|
||||
|
||||
26
packages/dashboard/app/context/FileBrowserContext.tsx
Normal file
26
packages/dashboard/app/context/FileBrowserContext.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface FileBrowserContextValue {
|
||||
openFile: (path: string, options?: { workspace?: string; line?: number; col?: number }) => void;
|
||||
}
|
||||
|
||||
const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
|
||||
|
||||
export function FileBrowserProvider({
|
||||
openFile,
|
||||
children,
|
||||
}: {
|
||||
openFile: FileBrowserContextValue["openFile"];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<FileBrowserContext.Provider value={{ openFile }}>
|
||||
{children}
|
||||
</FileBrowserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFileBrowser(): FileBrowserContextValue | null {
|
||||
return useContext(FileBrowserContext);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FileBrowserProvider, useFileBrowser } from "../FileBrowserContext";
|
||||
|
||||
function Probe() {
|
||||
const fileBrowser = useFileBrowser();
|
||||
return <div data-testid="probe">{fileBrowser ? "present" : "missing"}</div>;
|
||||
}
|
||||
|
||||
describe("FileBrowserContext", () => {
|
||||
it("returns null outside the provider", () => {
|
||||
render(<Probe />);
|
||||
expect(screen.getByTestId("probe")).toHaveTextContent("missing");
|
||||
});
|
||||
|
||||
it("returns the provided value inside the provider", () => {
|
||||
const openFile = vi.fn();
|
||||
render(
|
||||
<FileBrowserProvider openFile={openFile}>
|
||||
<Probe />
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("probe")).toHaveTextContent("present");
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,31 @@ describe("useModalManager", () => {
|
||||
expect(result.current.detailTaskInitialTab).toBe("definition");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, undefined, null],
|
||||
["worktree-FN-X", undefined, null],
|
||||
[undefined, "packages/foo/bar.ts", "packages/foo/bar.ts"],
|
||||
])("opens files modal with workspace %s and initial file %s", (workspace, initialFile, expectedInitialFile) => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openFiles(workspace, initialFile);
|
||||
});
|
||||
|
||||
expect(result.current.filesOpen).toBe(true);
|
||||
expect(result.current.fileBrowserInitialFile).toBe(expectedInitialFile);
|
||||
expect(result.current.fileBrowserWorkspace).toBe(workspace ?? "project");
|
||||
|
||||
act(() => {
|
||||
result.current.closeFiles();
|
||||
});
|
||||
|
||||
expect(result.current.filesOpen).toBe(false);
|
||||
expect(result.current.fileBrowserInitialFile).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts plain Task object in openDetailWithChangesTab", () => {
|
||||
const task = createTask("FN-789");
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ModalManager {
|
||||
filesOpen: boolean;
|
||||
todosOpen: boolean;
|
||||
fileBrowserWorkspace: string;
|
||||
fileBrowserInitialFile: string | null;
|
||||
activityLogOpen: boolean;
|
||||
gitManagerOpen: boolean;
|
||||
workflowStepsOpen: boolean;
|
||||
@@ -96,7 +97,7 @@ export interface ModalManager {
|
||||
toggleTerminal: () => void;
|
||||
closeTerminal: () => void;
|
||||
|
||||
openFiles: () => void;
|
||||
openFiles: (workspace?: string, initialFile?: string | null) => void;
|
||||
closeFiles: () => void;
|
||||
openTodos: () => void;
|
||||
closeTodos: () => void;
|
||||
@@ -161,6 +162,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [todosOpen, setTodosOpen] = useState(false);
|
||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||
const [fileBrowserInitialFile, setFileBrowserInitialFile] = useState<string | null>(null);
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
@@ -286,8 +288,17 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setTerminalInitialCommand(undefined);
|
||||
}, []);
|
||||
|
||||
const openFiles = useCallback(() => setFilesOpen(true), []);
|
||||
const closeFiles = useCallback(() => setFilesOpen(false), []);
|
||||
const openFiles = useCallback((workspace?: string, initialFile?: string | null) => {
|
||||
if (workspace) {
|
||||
setFileBrowserWorkspace(workspace);
|
||||
}
|
||||
setFileBrowserInitialFile(initialFile ?? null);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
setFilesOpen(false);
|
||||
setFileBrowserInitialFile(null);
|
||||
}, []);
|
||||
const openTodos = useCallback(() => setTodosOpen(true), []);
|
||||
const closeTodos = useCallback(() => setTodosOpen(false), []);
|
||||
const setFileWorkspace = useCallback((workspace: string) => {
|
||||
@@ -363,6 +374,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
filesOpen,
|
||||
todosOpen,
|
||||
fileBrowserWorkspace,
|
||||
fileBrowserInitialFile,
|
||||
activityLogOpen,
|
||||
gitManagerOpen,
|
||||
workflowStepsOpen,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
import { FilePathLink, linkifyFilePaths } from "../filePathLinkify";
|
||||
|
||||
describe("filePathLinkify", () => {
|
||||
it.each([
|
||||
"packages/dashboard/app/App.tsx",
|
||||
".fusion/tasks/FN-4227/PROMPT.md",
|
||||
"Dockerfile",
|
||||
"src/foo.ts:42",
|
||||
"src/foo.ts:42:7",
|
||||
])("matches %s", (value) => {
|
||||
const result = linkifyFilePaths(`open ${value} now`);
|
||||
expect(result.some((node) => typeof node !== "string")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://example.com/foo.md",
|
||||
"v1.2.3",
|
||||
"node_modules",
|
||||
"the literal string it.each should stay plain text",
|
||||
])("does not match %s", (value) => {
|
||||
const result = linkifyFilePaths(value);
|
||||
expect(result).toEqual([value]);
|
||||
});
|
||||
|
||||
it("opens the linked file through context", async () => {
|
||||
const user = userEvent.setup();
|
||||
const openFile = vi.fn();
|
||||
|
||||
render(
|
||||
<FileBrowserProvider openFile={openFile}>
|
||||
<FilePathLink path="src/foo.ts" line={42} col={7}>src/foo.ts:42:7</FilePathLink>
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "src/foo.ts:42:7" }));
|
||||
expect(openFile).toHaveBeenCalledWith("src/foo.ts", { line: 42, col: 7 });
|
||||
});
|
||||
});
|
||||
30
packages/dashboard/app/utils/filePathLinkify.css
Normal file
30
packages/dashboard/app/utils/filePathLinkify.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.file-path-link {
|
||||
display: inline;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
padding: 0 var(--space-xs);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-info);
|
||||
text-decoration: underline dotted;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.file-path-link:hover {
|
||||
background: color-mix(in srgb, var(--color-info) 12%, transparent);
|
||||
}
|
||||
|
||||
.file-path-link:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.file-path-link {
|
||||
padding-block: var(--space-md);
|
||||
margin-block: calc(var(--space-md) * -1);
|
||||
}
|
||||
}
|
||||
155
packages/dashboard/app/utils/filePathLinkify.tsx
Normal file
155
packages/dashboard/app/utils/filePathLinkify.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import "./filePathLinkify.css";
|
||||
import React, { cloneElement, isValidElement } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useFileBrowser } from "../context/FileBrowserContext";
|
||||
|
||||
// Two branches: the main branch requires a slash plus extension to avoid plain-prose false positives,
|
||||
// while the allowlist branch covers well-known root files agents commonly reference without a slash.
|
||||
export const FILE_PATH_REGEX = /(?<![\w@-])((?:[A-Za-z0-9_./@-]+\/)+[A-Za-z0-9_./@-]+\.[A-Za-z0-9]{1,8}(?::\d+(?::\d+)?)?|(?:Dockerfile|Makefile|AGENTS\.md|README\.md|README)(?::\d+(?::\d+)?)?)(?![\w-])/g;
|
||||
|
||||
const EXCLUDED_PROTOCOLS = ["http://", "https://", "mailto:", "git@", "ftp://"];
|
||||
const WELL_KNOWN_ROOT_FILES = new Set(["Dockerfile", "Makefile", "AGENTS.md", "README.md", "README"]);
|
||||
|
||||
function parseFilePathMatch(value: string): { path: string; line?: number; col?: number } {
|
||||
const match = /^(.*?)(?::(\d+)(?::(\d+))?)?$/.exec(value);
|
||||
if (!match) {
|
||||
return { path: value };
|
||||
}
|
||||
|
||||
return {
|
||||
path: match[1] ?? value,
|
||||
line: match[2] ? Number.parseInt(match[2], 10) : undefined,
|
||||
col: match[3] ? Number.parseInt(match[3], 10) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isVersionLike(value: string): boolean {
|
||||
return /^v?\d+(?:\.\d+)+$/.test(value);
|
||||
}
|
||||
|
||||
function hasPathSeparatorOrAllowlist(path: string): boolean {
|
||||
return path.includes("/") || WELL_KNOWN_ROOT_FILES.has(path);
|
||||
}
|
||||
|
||||
function isExcludedMatch(source: string, start: number, rawMatch: string): boolean {
|
||||
const prefix = source.slice(Math.max(0, start - 16), start).toLowerCase();
|
||||
if (rawMatch.startsWith("//") || EXCLUDED_PROTOCOLS.some((protocol) => prefix.endsWith(protocol))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { path } = parseFilePathMatch(rawMatch);
|
||||
if (isVersionLike(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasPathSeparatorOrAllowlist(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function FilePathLink({
|
||||
path,
|
||||
line,
|
||||
col,
|
||||
children,
|
||||
}: {
|
||||
path: string;
|
||||
line?: number;
|
||||
col?: number;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const fileBrowser = useFileBrowser();
|
||||
|
||||
if (!fileBrowser) {
|
||||
return <span>{children ?? path}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="file-path-link"
|
||||
onClick={() => fileBrowser.openFile(path, { line, col })}
|
||||
>
|
||||
{children ?? path}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function linkifyFilePaths(text: string, options?: { keyPrefix?: string }): ReactNode[] {
|
||||
if (!text) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let matchIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(FILE_PATH_REGEX)) {
|
||||
const rawMatch = match[0];
|
||||
const start = match.index ?? 0;
|
||||
const end = start + rawMatch.length;
|
||||
|
||||
if (isExcludedMatch(text, start, rawMatch)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (start > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, start));
|
||||
}
|
||||
|
||||
const { path, line, col } = parseFilePathMatch(rawMatch);
|
||||
nodes.push(
|
||||
<FilePathLink
|
||||
key={`${options?.keyPrefix ?? "file-path"}-${start}-${matchIndex}`}
|
||||
path={path}
|
||||
line={line}
|
||||
col={col}
|
||||
>
|
||||
{rawMatch}
|
||||
</FilePathLink>,
|
||||
);
|
||||
lastIndex = end;
|
||||
matchIndex += 1;
|
||||
}
|
||||
|
||||
if (lastIndex === 0) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function linkifyReactChildren(children: ReactNode): ReactNode {
|
||||
if (typeof children === "string") {
|
||||
const nodes = linkifyFilePaths(children);
|
||||
return nodes.length === 1 ? nodes[0] : <>{nodes}</>;
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return React.Children.map(children, (child) => linkifyReactChildren(child));
|
||||
}
|
||||
|
||||
if (!isValidElement<{ children?: ReactNode }>(children)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (typeof children.type === "string" && ["button", "code", "pre"].includes(children.type)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (children.props.children === undefined) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return cloneElement(
|
||||
children as ReactElement<{ children?: ReactNode }>,
|
||||
undefined,
|
||||
React.Children.map(children.props.children, (child) => linkifyReactChildren(child)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user