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:
@@ -6,6 +6,7 @@ import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight
|
||||
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
|
||||
import { useActivityLog } from "../hooks/useActivityLog";
|
||||
import type { Task, ProjectInfo } from "@fusion/core";
|
||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||
|
||||
interface ActivityLogModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -358,7 +359,7 @@ export function ActivityLogModal({
|
||||
{entry.taskTitle && (
|
||||
<span className="activity-log-task-title">{entry.taskTitle}</span>
|
||||
)}
|
||||
<span className="activity-log-entry-text">{entry.details}</span>
|
||||
<span className="activity-log-entry-text">{linkifyFilePaths(entry.details ?? "")}</span>
|
||||
</div>
|
||||
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
|
||||
<div className="activity-log-entry-metadata">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react";
|
||||
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import "./AgentLogViewer.css";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown";
|
||||
const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output";
|
||||
@@ -46,6 +47,16 @@ function formatTimestamp(iso: string): string {
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => {
|
||||
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
|
||||
const linkedChildren = linkifyFilePaths(text);
|
||||
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
return <code {...props}>{linkedChildren}</code>;
|
||||
},
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
@@ -139,7 +150,7 @@ function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactEle
|
||||
className={expanded ? "agent-log-tool-detail-content" : "agent-log-tool-detail-content agent-log-tool-detail-content--collapsed"}
|
||||
data-testid="tool-detail-content"
|
||||
>
|
||||
<pre className="agent-log-tool-detail">{detail}</pre>
|
||||
<pre className="agent-log-tool-detail">{linkifyFilePaths(detail)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -649,7 +660,7 @@ export function AgentLogViewer({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="agent-log-plain-block">{groupedText}</pre>
|
||||
<pre className="agent-log-plain-block">{linkifyFilePaths(groupedText)}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -665,7 +676,7 @@ export function AgentLogViewer({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="agent-log-plain-block">{groupedText}</pre>
|
||||
<pre className="agent-log-plain-block">{linkifyFilePaths(groupedText)}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -249,6 +249,7 @@ export function AppModals({
|
||||
{modalManager.filesOpen && (
|
||||
<FileBrowserModal
|
||||
initialWorkspace={modalManager.fileBrowserWorkspace}
|
||||
initialFile={modalManager.fileBrowserInitialFile}
|
||||
isOpen={true}
|
||||
onClose={modalManager.closeFiles}
|
||||
onWorkspaceChange={modalManager.setFileWorkspace}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
||||
// unstyled content when the lazy chunk loads. Do not re-import here.
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
@@ -42,6 +42,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
@@ -329,11 +330,25 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
}
|
||||
|
||||
const chatMarkdownComponents: Components = {
|
||||
p: ({ children, ...props }) => (
|
||||
<p {...props}>{linkifyReactChildren(children)}</p>
|
||||
),
|
||||
li: ({ children, ...props }) => (
|
||||
<li {...props}>{linkifyReactChildren(children)}</li>
|
||||
),
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre {...props} className="chat-markdown-pre">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, ...props }) => {
|
||||
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
|
||||
const linkedChildren = linkifyFilePaths(text);
|
||||
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
return <code {...props}>{linkedChildren}</code>;
|
||||
},
|
||||
table: ({ children, ...props }) => (
|
||||
<table {...props} className="chat-markdown-table">
|
||||
{children}
|
||||
@@ -795,7 +810,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
<TriangleAlert size={14} aria-hidden="true" />
|
||||
<span>Failure details</span>
|
||||
</summary>
|
||||
{failureInfo.detail && <pre className="chat-message-failure-detail">{failureInfo.detail}</pre>}
|
||||
{failureInfo.detail && <pre className="chat-message-failure-detail">{linkifyFilePaths(failureInfo.detail)}</pre>}
|
||||
{renderFailureReference(failureInfo.reference)}
|
||||
</details>
|
||||
)}
|
||||
@@ -834,7 +849,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
{message.thinkingOutput && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre>
|
||||
</details>
|
||||
)}
|
||||
{renderedAttachments}
|
||||
@@ -2613,7 +2628,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="chat-typing-indicator">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
||||
import { ChevronDown, Loader2, Maximize2, Minimize2, Search } from "lucide-react";
|
||||
import "./DevServerLogViewer.css";
|
||||
import type { DevServerLogEntry } from "../hooks/useDevServerLogs";
|
||||
import { linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
interface DevServerLogViewerProps {
|
||||
entries: DevServerLogEntry[];
|
||||
@@ -289,7 +290,7 @@ export function DevServerLogViewer({
|
||||
{entry.stream === "stderr" && (
|
||||
<span className="devserver-log-stream-badge" data-testid="devserver-log-stderr-badge">ERR</span>
|
||||
)}
|
||||
<span className="devserver-log-text">{highlightText(plainText, searchQuery.trim())}</span>
|
||||
<span className="devserver-log-text">{linkifyReactChildren(highlightText(plainText, searchQuery.trim()))}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -49,9 +49,16 @@ function isImageFile(filename: string): boolean {
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function getParentDirectory(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
return lastSlash > 0 ? normalized.slice(0, lastSlash) : ".";
|
||||
}
|
||||
|
||||
interface FileBrowserModalProps {
|
||||
isOpen?: boolean;
|
||||
initialWorkspace?: string;
|
||||
initialFile?: string | null;
|
||||
onClose: () => void;
|
||||
onWorkspaceChange?: (workspace: string) => void;
|
||||
projectId?: string;
|
||||
@@ -63,6 +70,7 @@ interface FileBrowserModalProps {
|
||||
*/
|
||||
export function FileBrowserModal({
|
||||
initialWorkspace = "project",
|
||||
initialFile = null,
|
||||
onClose,
|
||||
onWorkspaceChange,
|
||||
projectId,
|
||||
@@ -119,6 +127,19 @@ export function FileBrowserModal({
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFile) {
|
||||
setSelectedFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(initialFile);
|
||||
setPath(getParentDirectory(initialFile));
|
||||
if (isMobile) {
|
||||
setMobileView("editor");
|
||||
}
|
||||
}, [initialFile, isMobile, setPath]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const rawWidth = localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, Upload } from "lucide-react";
|
||||
import "./SettingsSyncLog.css";
|
||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -196,7 +197,7 @@ export function SettingsSyncLog({
|
||||
|
||||
{entry.details && (
|
||||
<span className="settings-sync-log__entry-details" title={entry.details}>
|
||||
{entry.details}
|
||||
{linkifyFilePaths(entry.details ?? "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import "./TaskDetailModal.css";
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult } from "@fusion/core";
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ import { appendTokenQuery } from "../auth";
|
||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
||||
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -51,7 +53,18 @@ interface ModelSelection {
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||
|
||||
|
||||
const markdownLinkifyComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => {
|
||||
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
|
||||
const linkedChildren = linkifyFilePaths(text);
|
||||
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
return <code {...props}>{linkedChildren}</code>;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective executor model following the engine's resolution order:
|
||||
@@ -2308,7 +2321,7 @@ export function TaskDetailContent({
|
||||
<div className="detail-section detail-summary">
|
||||
<h4>Summary</h4>
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
|
||||
{task.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -2654,7 +2667,7 @@ export function TaskDetailContent({
|
||||
<div className="spec-loading">Loading specification…</div>
|
||||
) : workingTask.prompt ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
|
||||
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Components } from "react-markdown";
|
||||
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api";
|
||||
import type { SelectedReviewItem } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
interface Props {
|
||||
task: Task | TaskDetail;
|
||||
@@ -55,6 +56,9 @@ function writeBooleanPref(key: string, value: boolean): void {
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => <code {...props}>{linkifyReactChildren(children)}</code>,
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
@@ -65,7 +69,7 @@ const markdownComponents: Components = {
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{linkifyReactChildren(children)}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, ...props }) => (
|
||||
@@ -317,7 +321,7 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="task-review-tab__body">{item.body}</pre>
|
||||
<pre className="task-review-tab__body">{linkifyFilePaths(item.body)}</pre>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -7,15 +7,19 @@ import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/co
|
||||
import { fetchWorkflowSteps } from "../api";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import type { Components } from "react-markdown";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
|
||||
// Markdown rendering components for workflow output
|
||||
const markdownComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => <code {...props}>{linkifyReactChildren(children)}</code>,
|
||||
pre: ({ children, className, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
className={["workflow-markdown-pre", className].filter(Boolean).join(" ")}
|
||||
>
|
||||
{children}
|
||||
{linkifyReactChildren(children)}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, className, ...props }) => (
|
||||
@@ -146,38 +150,38 @@ function LiveAgentLogOutput({
|
||||
if (entry.type === "tool") {
|
||||
return (
|
||||
<div key={i} className="workflow-live-log-tool">
|
||||
⚡ {entry.text}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||
⚡ {linkifyFilePaths(entry.text)}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.type === "tool_result") {
|
||||
return (
|
||||
<div key={i} className="workflow-live-log-tool-result">
|
||||
✓ {entry.text}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||
✓ {linkifyFilePaths(entry.text)}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.type === "tool_error") {
|
||||
return (
|
||||
<div key={i} className="workflow-live-log-tool-error">
|
||||
✗ {entry.text}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||
✗ {linkifyFilePaths(entry.text)}
|
||||
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.type === "thinking") {
|
||||
return (
|
||||
<div key={i} className="workflow-live-log-thinking">
|
||||
{entry.text}
|
||||
{linkifyFilePaths(entry.text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Default: text entries
|
||||
return (
|
||||
<span key={i} className="workflow-live-log-text">
|
||||
{entry.text}
|
||||
{linkifyFilePaths(entry.text)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -553,7 +557,7 @@ export function WorkflowResultsTab({
|
||||
</div>
|
||||
) : (
|
||||
<pre className="workflow-result-output-text">
|
||||
{result.output}
|
||||
{linkifyFilePaths(result.output)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
@@ -697,7 +701,7 @@ export function WorkflowResultsTab({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="workflow-result-output-text">{result.output}</pre>
|
||||
<pre className="workflow-result-output-text">{linkifyFilePaths(result.output)}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { AgentLogViewer } from "../AgentLogViewer";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import "../../styles.css";
|
||||
import "../TaskDetailModal.css";
|
||||
@@ -132,6 +133,18 @@ describe("AgentLogViewer", () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders file paths in plain log lines as clickable file-browser links", async () => {
|
||||
const openFile = vi.fn();
|
||||
render(
|
||||
<FileBrowserProvider openFile={openFile}>
|
||||
<AgentLogViewer entries={[makeEntry({ text: "writing packages/engine/src/scheduler.ts" })]} loading={false} />
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "packages/engine/src/scheduler.ts" }));
|
||||
expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: undefined, col: undefined });
|
||||
});
|
||||
|
||||
it("renders tool entries with distinct styling", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Read", type: "tool" }),
|
||||
|
||||
@@ -155,6 +155,7 @@ describe("AppModals", () => {
|
||||
filesOpen: false,
|
||||
todosOpen: false,
|
||||
fileBrowserWorkspace: "project",
|
||||
fileBrowserInitialFile: null,
|
||||
usageOpen: false,
|
||||
usageAnchorRect: null,
|
||||
systemStatsOpen: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { userEvent } from "@testing-library/user-event";
|
||||
import { ChatView } from "../ChatView";
|
||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
|
||||
// Mock scrollIntoView for JSDOM
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -523,6 +524,23 @@ describe("ChatView", () => {
|
||||
expect(screen.getByText("Hi there!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders file paths in assistant messages as clickable file-browser links", async () => {
|
||||
const openFile = vi.fn();
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "see `packages/foo/bar.ts:42` for details", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserProvider openFile={openFile}>
|
||||
<ChatView projectId="proj-123" addToast={vi.fn()} />
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "packages/foo/bar.ts:42" }));
|
||||
expect(openFile).toHaveBeenCalledWith("packages/foo/bar.ts", { line: 42, col: undefined });
|
||||
});
|
||||
|
||||
it("does not render markdown/plain toggle controls in the thread header", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
|
||||
@@ -106,6 +106,24 @@ describe("FileBrowserModal", () => {
|
||||
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined);
|
||||
});
|
||||
|
||||
it("opens with an initial file selected", async () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
initialFile="packages/dashboard/app/App.tsx"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("packages/dashboard/app/App.tsx").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(mockSetPath).toHaveBeenCalledWith("packages/dashboard/app");
|
||||
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "packages/dashboard/app/App.tsx", true, undefined);
|
||||
});
|
||||
|
||||
it("switches workspace and notifies parent", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -17,10 +17,35 @@ import {
|
||||
} from "./TaskDetailModal.test-helpers";
|
||||
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
||||
import * as dashboardApi from "../../api";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
|
||||
setupTaskDetailModalHooks();
|
||||
|
||||
describe("TaskDetailModal", () => {
|
||||
it("renders clickable file links in markdown content", async () => {
|
||||
const openFile = vi.fn();
|
||||
render(
|
||||
<FileBrowserProvider openFile={openFile}>
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
column: "done",
|
||||
summary: "See `packages/dashboard/app/App.tsx:12` for context.",
|
||||
prompt: "# Prompt\n\nInspect `packages/dashboard/app/App.tsx:12`."
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "packages/dashboard/app/App.tsx:12" })[0]!);
|
||||
expect(openFile).toHaveBeenCalledWith("packages/dashboard/app/App.tsx", { line: 12, col: undefined });
|
||||
});
|
||||
|
||||
describe("provenance display", () => {
|
||||
it.each([
|
||||
["dashboard_ui", undefined, "Created via Dashboard"],
|
||||
|
||||
Reference in New Issue
Block a user