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

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