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
|
### 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.
|
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 { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||||
|
import { FileBrowserProvider } from "./context/FileBrowserContext";
|
||||||
import { ShellProvider } from "./context/ShellContext";
|
import { ShellProvider } from "./context/ShellContext";
|
||||||
import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext";
|
import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext";
|
||||||
import { useShellConnection } from "./hooks/useShellConnection";
|
import { useShellConnection } from "./hooks/useShellConnection";
|
||||||
@@ -976,8 +977,13 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
}, [modalManager, pushNav]);
|
}, [modalManager, pushNav]);
|
||||||
|
|
||||||
const openFilesWithNav = useCallback(() => {
|
const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => {
|
||||||
modalManager.openFiles();
|
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 });
|
pushNav({ type: "modal", close: modalManager.closeFiles });
|
||||||
}, [modalManager, pushNav]);
|
}, [modalManager, pushNav]);
|
||||||
|
|
||||||
@@ -1515,7 +1521,7 @@ function AppInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent }}>
|
<NavigationHistoryProvider value={{ pushNav, replaceCurrent }}>
|
||||||
<>
|
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||||
<Header
|
<Header
|
||||||
shellHost={shellHost.host}
|
shellHost={shellHost.host}
|
||||||
onOpenSettings={openSettingsWithNav}
|
onOpenSettings={openSettingsWithNav}
|
||||||
@@ -1793,7 +1799,7 @@ function AppInner() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</FileBrowserProvider>
|
||||||
</NavigationHistoryProvider>
|
</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 { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
|
||||||
import { useActivityLog } from "../hooks/useActivityLog";
|
import { useActivityLog } from "../hooks/useActivityLog";
|
||||||
import type { Task, ProjectInfo } from "@fusion/core";
|
import type { Task, ProjectInfo } from "@fusion/core";
|
||||||
|
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||||
|
|
||||||
interface ActivityLogModalProps {
|
interface ActivityLogModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -358,7 +359,7 @@ export function ActivityLogModal({
|
|||||||
{entry.taskTitle && (
|
{entry.taskTitle && (
|
||||||
<span className="activity-log-task-title">{entry.taskTitle}</span>
|
<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>
|
</div>
|
||||||
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
|
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
|
||||||
<div className="activity-log-entry-metadata">
|
<div className="activity-log-entry-metadata">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { AgentLogEntry } from "@fusion/core";
|
import type { AgentLogEntry } from "@fusion/core";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
|
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import "./AgentLogViewer.css";
|
import "./AgentLogViewer.css";
|
||||||
|
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||||
|
|
||||||
const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown";
|
const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown";
|
||||||
const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output";
|
const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output";
|
||||||
@@ -46,6 +47,16 @@ function formatTimestamp(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markdownComponents: Components = {
|
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: ({ children, ...props }) => (
|
||||||
<pre
|
<pre
|
||||||
{...props}
|
{...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"}
|
className={expanded ? "agent-log-tool-detail-content" : "agent-log-tool-detail-content agent-log-tool-detail-content--collapsed"}
|
||||||
data-testid="tool-detail-content"
|
data-testid="tool-detail-content"
|
||||||
>
|
>
|
||||||
<pre className="agent-log-tool-detail">{detail}</pre>
|
<pre className="agent-log-tool-detail">{linkifyFilePaths(detail)}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -649,7 +660,7 @@ export function AgentLogViewer({
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="agent-log-plain-block">{groupedText}</pre>
|
<pre className="agent-log-plain-block">{linkifyFilePaths(groupedText)}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -665,7 +676,7 @@ export function AgentLogViewer({
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="agent-log-plain-block">{groupedText}</pre>
|
<pre className="agent-log-plain-block">{linkifyFilePaths(groupedText)}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export function AppModals({
|
|||||||
{modalManager.filesOpen && (
|
{modalManager.filesOpen && (
|
||||||
<FileBrowserModal
|
<FileBrowserModal
|
||||||
initialWorkspace={modalManager.fileBrowserWorkspace}
|
initialWorkspace={modalManager.fileBrowserWorkspace}
|
||||||
|
initialFile={modalManager.fileBrowserInitialFile}
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
onClose={modalManager.closeFiles}
|
onClose={modalManager.closeFiles}
|
||||||
onWorkspaceChange={modalManager.setFileWorkspace}
|
onWorkspaceChange={modalManager.setFileWorkspace}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
// 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.
|
// 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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
@@ -42,6 +42,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
|||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||||
|
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||||
|
|
||||||
export interface ChatViewProps {
|
export interface ChatViewProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -329,11 +330,25 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatMarkdownComponents: Components = {
|
const chatMarkdownComponents: Components = {
|
||||||
|
p: ({ children, ...props }) => (
|
||||||
|
<p {...props}>{linkifyReactChildren(children)}</p>
|
||||||
|
),
|
||||||
|
li: ({ children, ...props }) => (
|
||||||
|
<li {...props}>{linkifyReactChildren(children)}</li>
|
||||||
|
),
|
||||||
pre: ({ children, ...props }) => (
|
pre: ({ children, ...props }) => (
|
||||||
<pre {...props} className="chat-markdown-pre">
|
<pre {...props} className="chat-markdown-pre">
|
||||||
{children}
|
{children}
|
||||||
</pre>
|
</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: ({ children, ...props }) => (
|
||||||
<table {...props} className="chat-markdown-table">
|
<table {...props} className="chat-markdown-table">
|
||||||
{children}
|
{children}
|
||||||
@@ -795,7 +810,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
<TriangleAlert size={14} aria-hidden="true" />
|
<TriangleAlert size={14} aria-hidden="true" />
|
||||||
<span>Failure details</span>
|
<span>Failure details</span>
|
||||||
</summary>
|
</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)}
|
{renderFailureReference(failureInfo.reference)}
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
@@ -834,7 +849,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
{message.thinkingOutput && (
|
{message.thinkingOutput && (
|
||||||
<details className="chat-message-thinking">
|
<details className="chat-message-thinking">
|
||||||
<summary>Thinking</summary>
|
<summary>Thinking</summary>
|
||||||
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
|
<pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
{renderedAttachments}
|
{renderedAttachments}
|
||||||
@@ -2613,7 +2628,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
{streamingThinking && (
|
{streamingThinking && (
|
||||||
<details className="chat-message-thinking">
|
<details className="chat-message-thinking">
|
||||||
<summary>Thinking</summary>
|
<summary>Thinking</summary>
|
||||||
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
|
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
<div className="chat-typing-indicator">
|
<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 { ChevronDown, Loader2, Maximize2, Minimize2, Search } from "lucide-react";
|
||||||
import "./DevServerLogViewer.css";
|
import "./DevServerLogViewer.css";
|
||||||
import type { DevServerLogEntry } from "../hooks/useDevServerLogs";
|
import type { DevServerLogEntry } from "../hooks/useDevServerLogs";
|
||||||
|
import { linkifyReactChildren } from "../utils/filePathLinkify";
|
||||||
|
|
||||||
interface DevServerLogViewerProps {
|
interface DevServerLogViewerProps {
|
||||||
entries: DevServerLogEntry[];
|
entries: DevServerLogEntry[];
|
||||||
@@ -289,7 +290,7 @@ export function DevServerLogViewer({
|
|||||||
{entry.stream === "stderr" && (
|
{entry.stream === "stderr" && (
|
||||||
<span className="devserver-log-stream-badge" data-testid="devserver-log-stderr-badge">ERR</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -49,9 +49,16 @@ function isImageFile(filename: string): boolean {
|
|||||||
return IMAGE_EXTENSIONS.has(ext);
|
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 {
|
interface FileBrowserModalProps {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
initialWorkspace?: string;
|
initialWorkspace?: string;
|
||||||
|
initialFile?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onWorkspaceChange?: (workspace: string) => void;
|
onWorkspaceChange?: (workspace: string) => void;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -63,6 +70,7 @@ interface FileBrowserModalProps {
|
|||||||
*/
|
*/
|
||||||
export function FileBrowserModal({
|
export function FileBrowserModal({
|
||||||
initialWorkspace = "project",
|
initialWorkspace = "project",
|
||||||
|
initialFile = null,
|
||||||
onClose,
|
onClose,
|
||||||
onWorkspaceChange,
|
onWorkspaceChange,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -119,6 +127,19 @@ export function FileBrowserModal({
|
|||||||
}
|
}
|
||||||
}, [selectedFile]);
|
}, [selectedFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialFile) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(initialFile);
|
||||||
|
setPath(getParentDirectory(initialFile));
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileView("editor");
|
||||||
|
}
|
||||||
|
}, [initialFile, isMobile, setPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const rawWidth = localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
const rawWidth = localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import { ChevronDown, Download, Upload } from "lucide-react";
|
import { ChevronDown, Download, Upload } from "lucide-react";
|
||||||
import "./SettingsSyncLog.css";
|
import "./SettingsSyncLog.css";
|
||||||
|
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ export function SettingsSyncLog({
|
|||||||
|
|
||||||
{entry.details && (
|
{entry.details && (
|
||||||
<span className="settings-sync-log__entry-details" title={entry.details}>
|
<span className="settings-sync-log__entry-details" title={entry.details}>
|
||||||
{entry.details}
|
{linkifyFilePaths(entry.details ?? "")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import "./TaskDetailModal.css";
|
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 { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap } from "lucide-react";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import type { Components } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult } from "@fusion/core";
|
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,7 @@ import { appendTokenQuery } from "../auth";
|
|||||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||||
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
||||||
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
|
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
|
||||||
|
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||||
|
|
||||||
interface ModelSelection {
|
interface ModelSelection {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -51,7 +53,18 @@ interface ModelSelection {
|
|||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
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:
|
* Resolve the effective executor model following the engine's resolution order:
|
||||||
@@ -2308,7 +2321,7 @@ export function TaskDetailContent({
|
|||||||
<div className="detail-section detail-summary">
|
<div className="detail-section detail-summary">
|
||||||
<h4>Summary</h4>
|
<h4>Summary</h4>
|
||||||
<div className="markdown-body">
|
<div className="markdown-body">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
|
||||||
{task.summary}
|
{task.summary}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -2654,7 +2667,7 @@ export function TaskDetailContent({
|
|||||||
<div className="spec-loading">Loading specification…</div>
|
<div className="spec-loading">Loading specification…</div>
|
||||||
) : workingTask.prompt ? (
|
) : workingTask.prompt ? (
|
||||||
<div className="markdown-body">
|
<div className="markdown-body">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
|
||||||
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
|
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Components } from "react-markdown";
|
|||||||
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api";
|
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api";
|
||||||
import type { SelectedReviewItem } from "../api";
|
import type { SelectedReviewItem } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
task: Task | TaskDetail;
|
task: Task | TaskDetail;
|
||||||
@@ -55,6 +56,9 @@ function writeBooleanPref(key: string, value: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markdownComponents: Components = {
|
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: ({ children, ...props }) => (
|
||||||
<pre
|
<pre
|
||||||
{...props}
|
{...props}
|
||||||
@@ -65,7 +69,7 @@ const markdownComponents: Components = {
|
|||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{linkifyReactChildren(children)}
|
||||||
</pre>
|
</pre>
|
||||||
),
|
),
|
||||||
table: ({ children, ...props }) => (
|
table: ({ children, ...props }) => (
|
||||||
@@ -317,7 +321,7 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="task-review-tab__body">{item.body}</pre>
|
<pre className="task-review-tab__body">{linkifyFilePaths(item.body)}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/co
|
|||||||
import { fetchWorkflowSteps } from "../api";
|
import { fetchWorkflowSteps } from "../api";
|
||||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
|
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||||
|
|
||||||
// Markdown rendering components for workflow output
|
// Markdown rendering components for workflow output
|
||||||
const markdownComponents: Components = {
|
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: ({ children, className, ...props }) => (
|
||||||
<pre
|
<pre
|
||||||
{...props}
|
{...props}
|
||||||
className={["workflow-markdown-pre", className].filter(Boolean).join(" ")}
|
className={["workflow-markdown-pre", className].filter(Boolean).join(" ")}
|
||||||
>
|
>
|
||||||
{children}
|
{linkifyReactChildren(children)}
|
||||||
</pre>
|
</pre>
|
||||||
),
|
),
|
||||||
table: ({ children, className, ...props }) => (
|
table: ({ children, className, ...props }) => (
|
||||||
@@ -146,38 +150,38 @@ function LiveAgentLogOutput({
|
|||||||
if (entry.type === "tool") {
|
if (entry.type === "tool") {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="workflow-live-log-tool">
|
<div key={i} className="workflow-live-log-tool">
|
||||||
⚡ {entry.text}
|
⚡ {linkifyFilePaths(entry.text)}
|
||||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (entry.type === "tool_result") {
|
if (entry.type === "tool_result") {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="workflow-live-log-tool-result">
|
<div key={i} className="workflow-live-log-tool-result">
|
||||||
✓ {entry.text}
|
✓ {linkifyFilePaths(entry.text)}
|
||||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (entry.type === "tool_error") {
|
if (entry.type === "tool_error") {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="workflow-live-log-tool-error">
|
<div key={i} className="workflow-live-log-tool-error">
|
||||||
✗ {entry.text}
|
✗ {linkifyFilePaths(entry.text)}
|
||||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (entry.type === "thinking") {
|
if (entry.type === "thinking") {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="workflow-live-log-thinking">
|
<div key={i} className="workflow-live-log-thinking">
|
||||||
{entry.text}
|
{linkifyFilePaths(entry.text)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Default: text entries
|
// Default: text entries
|
||||||
return (
|
return (
|
||||||
<span key={i} className="workflow-live-log-text">
|
<span key={i} className="workflow-live-log-text">
|
||||||
{entry.text}
|
{linkifyFilePaths(entry.text)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -553,7 +557,7 @@ export function WorkflowResultsTab({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="workflow-result-output-text">
|
<pre className="workflow-result-output-text">
|
||||||
{result.output}
|
{linkifyFilePaths(result.output)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -697,7 +701,7 @@ export function WorkflowResultsTab({
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="workflow-result-output-text">{result.output}</pre>
|
<pre className="workflow-result-output-text">{linkifyFilePaths(result.output)}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { AgentLogViewer } from "../AgentLogViewer";
|
import { AgentLogViewer } from "../AgentLogViewer";
|
||||||
|
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||||
import type { AgentLogEntry } from "@fusion/core";
|
import type { AgentLogEntry } from "@fusion/core";
|
||||||
import "../../styles.css";
|
import "../../styles.css";
|
||||||
import "../TaskDetailModal.css";
|
import "../TaskDetailModal.css";
|
||||||
@@ -132,6 +133,18 @@ describe("AgentLogViewer", () => {
|
|||||||
consoleErrorSpy.mockRestore();
|
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", () => {
|
it("renders tool entries with distinct styling", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ text: "Read", type: "tool" }),
|
makeEntry({ text: "Read", type: "tool" }),
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ describe("AppModals", () => {
|
|||||||
filesOpen: false,
|
filesOpen: false,
|
||||||
todosOpen: false,
|
todosOpen: false,
|
||||||
fileBrowserWorkspace: "project",
|
fileBrowserWorkspace: "project",
|
||||||
|
fileBrowserInitialFile: null,
|
||||||
usageOpen: false,
|
usageOpen: false,
|
||||||
usageAnchorRect: null,
|
usageAnchorRect: null,
|
||||||
systemStatsOpen: false,
|
systemStatsOpen: false,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { userEvent } from "@testing-library/user-event";
|
|||||||
import { ChatView } from "../ChatView";
|
import { ChatView } from "../ChatView";
|
||||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||||
import { loadAllAppCss } from "../../test/cssFixture";
|
import { loadAllAppCss } from "../../test/cssFixture";
|
||||||
|
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||||
|
|
||||||
// Mock scrollIntoView for JSDOM
|
// Mock scrollIntoView for JSDOM
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
@@ -523,6 +524,23 @@ describe("ChatView", () => {
|
|||||||
expect(screen.getByText("Hi there!")).toBeInTheDocument();
|
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", () => {
|
it("does not render markdown/plain toggle controls in the thread header", () => {
|
||||||
setupMockChat({
|
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" },
|
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);
|
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 () => {
|
it("switches workspace and notifies parent", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -17,10 +17,35 @@ import {
|
|||||||
} from "./TaskDetailModal.test-helpers";
|
} from "./TaskDetailModal.test-helpers";
|
||||||
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
||||||
import * as dashboardApi from "../../api";
|
import * as dashboardApi from "../../api";
|
||||||
|
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||||
|
|
||||||
setupTaskDetailModalHooks();
|
setupTaskDetailModalHooks();
|
||||||
|
|
||||||
describe("TaskDetailModal", () => {
|
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", () => {
|
describe("provenance display", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["dashboard_ui", undefined, "Created via Dashboard"],
|
["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");
|
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", () => {
|
it("accepts plain Task object in openDetailWithChangesTab", () => {
|
||||||
const task = createTask("FN-789");
|
const task = createTask("FN-789");
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface ModalManager {
|
|||||||
filesOpen: boolean;
|
filesOpen: boolean;
|
||||||
todosOpen: boolean;
|
todosOpen: boolean;
|
||||||
fileBrowserWorkspace: string;
|
fileBrowserWorkspace: string;
|
||||||
|
fileBrowserInitialFile: string | null;
|
||||||
activityLogOpen: boolean;
|
activityLogOpen: boolean;
|
||||||
gitManagerOpen: boolean;
|
gitManagerOpen: boolean;
|
||||||
workflowStepsOpen: boolean;
|
workflowStepsOpen: boolean;
|
||||||
@@ -96,7 +97,7 @@ export interface ModalManager {
|
|||||||
toggleTerminal: () => void;
|
toggleTerminal: () => void;
|
||||||
closeTerminal: () => void;
|
closeTerminal: () => void;
|
||||||
|
|
||||||
openFiles: () => void;
|
openFiles: (workspace?: string, initialFile?: string | null) => void;
|
||||||
closeFiles: () => void;
|
closeFiles: () => void;
|
||||||
openTodos: () => void;
|
openTodos: () => void;
|
||||||
closeTodos: () => void;
|
closeTodos: () => void;
|
||||||
@@ -161,6 +162,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
const [filesOpen, setFilesOpen] = useState(false);
|
const [filesOpen, setFilesOpen] = useState(false);
|
||||||
const [todosOpen, setTodosOpen] = useState(false);
|
const [todosOpen, setTodosOpen] = useState(false);
|
||||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||||
|
const [fileBrowserInitialFile, setFileBrowserInitialFile] = useState<string | null>(null);
|
||||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||||
@@ -286,8 +288,17 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
setTerminalInitialCommand(undefined);
|
setTerminalInitialCommand(undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openFiles = useCallback(() => setFilesOpen(true), []);
|
const openFiles = useCallback((workspace?: string, initialFile?: string | null) => {
|
||||||
const closeFiles = useCallback(() => setFilesOpen(false), []);
|
if (workspace) {
|
||||||
|
setFileBrowserWorkspace(workspace);
|
||||||
|
}
|
||||||
|
setFileBrowserInitialFile(initialFile ?? null);
|
||||||
|
setFilesOpen(true);
|
||||||
|
}, []);
|
||||||
|
const closeFiles = useCallback(() => {
|
||||||
|
setFilesOpen(false);
|
||||||
|
setFileBrowserInitialFile(null);
|
||||||
|
}, []);
|
||||||
const openTodos = useCallback(() => setTodosOpen(true), []);
|
const openTodos = useCallback(() => setTodosOpen(true), []);
|
||||||
const closeTodos = useCallback(() => setTodosOpen(false), []);
|
const closeTodos = useCallback(() => setTodosOpen(false), []);
|
||||||
const setFileWorkspace = useCallback((workspace: string) => {
|
const setFileWorkspace = useCallback((workspace: string) => {
|
||||||
@@ -363,6 +374,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
filesOpen,
|
filesOpen,
|
||||||
todosOpen,
|
todosOpen,
|
||||||
fileBrowserWorkspace,
|
fileBrowserWorkspace,
|
||||||
|
fileBrowserInitialFile,
|
||||||
activityLogOpen,
|
activityLogOpen,
|
||||||
gitManagerOpen,
|
gitManagerOpen,
|
||||||
workflowStepsOpen,
|
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