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:
Fusion
2026-05-12 21:55:41 -07:00
committed by gsxdsm
parent ad45a236fa
commit bbd92a1ae1
25 changed files with 515 additions and 35 deletions

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -249,6 +249,7 @@ export function AppModals({
{modalManager.filesOpen && (
<FileBrowserModal
initialWorkspace={modalManager.fileBrowserWorkspace}
initialFile={modalManager.fileBrowserInitialFile}
isOpen={true}
onClose={modalManager.closeFiles}
onWorkspaceChange={modalManager.setFileWorkspace}

View File

@@ -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">

View File

@@ -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>
);
})}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" }),

View File

@@ -155,6 +155,7 @@ describe("AppModals", () => {
filesOpen: false,
todosOpen: false,
fileBrowserWorkspace: "project",
fileBrowserInitialFile: null,
usageOpen: false,
usageAnchorRect: null,
systemStatsOpen: false,

View File

@@ -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" },

View File

@@ -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(

View File

@@ -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"],

View 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);
}

View File

@@ -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");
});
});

View File

@@ -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(() =>

View File

@@ -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,

View File

@@ -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 });
});
});

View 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);
}
}

View 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)),
);
}