diff --git a/src/components/chat/view/subcomponents/Markdown.tsx b/src/components/chat/view/subcomponents/Markdown.tsx index dfd85697..fc1b9f19 100644 --- a/src/components/chat/view/subcomponents/Markdown.tsx +++ b/src/components/chat/view/subcomponents/Markdown.tsx @@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useTranslation } from 'react-i18next'; import { normalizeInlineCodeFences } from '../../utils/chatFormatting'; import { copyTextToClipboard } from '../../../../utils/clipboard'; +import { usePaletteOps } from '../../../../contexts/PaletteOpsContext'; type MarkdownProps = { children: React.ReactNode; className?: string; }; +// Links to the wider web (or in-page anchors) keep normal browser navigation; +// everything else is treated as a workspace file reference. +const isExternalHref = (href?: string): boolean => + !!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#')); + +// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`). +const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, ''); + +// A usable file path contains a separator or a filename with an extension. +const looksLikeFilePath = (value?: string): value is string => { + if (!value) { + return false; + } + const cleaned = stripLineSuffix(value.trim()); + if (!cleaned || cleaned === '#') { + return false; + } + return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned); +}; + +// Extract plain text from link children so a reference rendered only as link +// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened. +const childrenToText = (children: React.ReactNode): string => { + if (typeof children === 'string' || typeof children === 'number') { + return String(children); + } + if (Array.isArray(children)) { + return children.map(childrenToText).join(''); + } + if (React.isValidElement(children)) { + return childrenToText((children.props as { children?: React.ReactNode }).children); + } + return ''; +}; + type CodeBlockProps = { node?: any; inline?: boolean; @@ -123,11 +159,6 @@ const markdownComponents = { {children} ), - a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( - - {children} - - ), p: ({ children }: { children?: React.ReactNode }) =>
{children}
, table: ({ children }: { children?: React.ReactNode }) => (
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) { const content = normalizeInlineCodeFences(String(children ?? '')); const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); const rehypePlugins = useMemo(() => [rehypeKatex], []); + const { openFileInEditor } = usePaletteOps(); + + const components = useMemo( + () => ({ + ...markdownComponents, + a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => { + // Prefer the href when it is a real path; otherwise fall back to the + // link text, since models often emit `[src/foo.ts]()` with an empty href. + const linkText = childrenToText(linkChildren); + const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined; + + if (fileRef && !isExternalHref(href)) { + return ( + { + event.preventDefault(); + openFileInEditor(stripLineSuffix(fileRef)); + }} + > + {linkChildren} + + ); + } + + return ( + + {linkChildren} + + ); + }, + }), + [openFileInEditor], + ); return (
- + {content}
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 9d780623..96877ac0 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -11,6 +11,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useUiPreferences } from '../../../hooks/useUiPreferences'; +import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver'; import { authenticatedFetch } from '../../../utils/api'; import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; import EditorSidebar from '../../code-editor/view/EditorSidebar'; @@ -77,6 +78,10 @@ function MainContent({ isMobile, }); + // Resolves bare/partial file references (e.g. links inside chat messages) to + // real project files before opening them in the in-app editor. + const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen); + useEffect(() => { // Identify projects by DB `projectId`; the TaskMaster context uses the // same identifier to key its internal maps. @@ -121,6 +126,10 @@ function MainContent({ setActiveTab('files'); handleFileOpen(filePath); }, + // Opens the editor side panel in place, keeping the current tab (e.g. chat). + openFileInEditor: (filePath: string) => { + resolvedFileOpen(filePath); + }, }); if (isLoading) { diff --git a/src/contexts/PaletteOpsContext.tsx b/src/contexts/PaletteOpsContext.tsx index c0281780..aaec9a26 100644 --- a/src/contexts/PaletteOpsContext.tsx +++ b/src/contexts/PaletteOpsContext.tsx @@ -3,6 +3,9 @@ import type { MutableRefObject, ReactNode } from 'react'; export type PaletteOps = { openFile: (path: string) => void; + // Opens a file in the editor side panel without changing the active tab + // (used by in-chat file links so they behave like the inline edit view). + openFileInEditor: (path: string) => void; openSettings: (tab?: string) => void; refreshProjects: () => Promise | void; }; @@ -13,6 +16,7 @@ const PaletteOpsContext = createContext(null); const defaultOps: PaletteOps = { openFile: () => undefined, + openFileInEditor: () => undefined, openSettings: () => undefined, refreshProjects: () => undefined, }; @@ -27,6 +31,8 @@ export function usePaletteOps(): PaletteOps { return useMemo( () => ({ openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path), + openFileInEditor: (path) => + (ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path), openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab), refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(), }), @@ -36,18 +42,20 @@ export function usePaletteOps(): PaletteOps { export function usePaletteOpsRegister(partial: Partial) { const ref = useContext(PaletteOpsContext); - const { openFile, openSettings, refreshProjects } = partial; + const { openFile, openFileInEditor, openSettings, refreshProjects } = partial; useEffect(() => { if (!ref) return undefined; const prev = { ...ref.current }; if (openFile) ref.current.openFile = openFile; + if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor; if (openSettings) ref.current.openSettings = openSettings; if (refreshProjects) ref.current.refreshProjects = refreshProjects; return () => { if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile; + if (openFileInEditor && ref.current.openFileInEditor === openFileInEditor) ref.current.openFileInEditor = prev.openFileInEditor; if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings; if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects; }; - }, [ref, openFile, openSettings, refreshProjects]); + }, [ref, openFile, openFileInEditor, openSettings, refreshProjects]); } diff --git a/src/hooks/useFileOpenResolver.ts b/src/hooks/useFileOpenResolver.ts new file mode 100644 index 00000000..ed5fd1fa --- /dev/null +++ b/src/hooks/useFileOpenResolver.ts @@ -0,0 +1,108 @@ +import { useCallback, useRef } from 'react'; + +import { api } from '../utils/api'; +import type { Project } from '../types/app'; + +type FileNode = { + type: 'file' | 'directory'; + name: string; + path: string; + children?: FileNode[]; +}; + +type FlatFile = { + name: string; + path: string; +}; + +// `diffInfo` is intentionally `any` so this resolver can wrap editor handlers +// that expect a concrete diff payload type as well as generic callers. +type OnFileOpen = (filePath: string, diffInfo?: any) => void; + +const normalize = (value: string): string => value.replace(/\\/g, '/'); + +const flatten = (nodes: FileNode[], out: FlatFile[]): void => { + for (const node of nodes) { + if (node.type === 'file') { + out.push({ name: node.name, path: node.path }); + } else if (node.children && node.children.length > 0) { + flatten(node.children, out); + } + } +}; + +// References inside chat messages are often bare basenames (`foo.ts`) or partial +// paths (`utils/foo.ts`) rather than full paths, so match by path suffix and +// fall back to filename equality. +const findBestMatch = (files: FlatFile[], ref: string): string | null => { + const target = normalize(ref).replace(/^\.\//, '').replace(/^\/+/, ''); + if (!target) { + return null; + } + + const suffixMatch = files.find((file) => { + const filePath = normalize(file.path); + return filePath === target || filePath.endsWith(`/${target}`); + }); + if (suffixMatch) { + return suffixMatch.path; + } + + const base = target.split('/').pop() || target; + return files.find((file) => file.name === base)?.path ?? null; +}; + +/** + * Wraps an `onFileOpen` handler so a possibly bare/partial file reference is + * resolved against the project's file tree (cached per project) before the file + * is opened in the in-app editor. + */ +export function useFileOpenResolver( + selectedProject: Project | null | undefined, + onFileOpen: OnFileOpen, +): OnFileOpen { + const projectId = selectedProject?.projectId; + const cacheRef = useRef<{ projectId?: string; files: Promise | null }>({ + projectId: undefined, + files: null, + }); + + const loadFiles = useCallback((): Promise => { + if (!projectId) { + return Promise.resolve([]); + } + if (cacheRef.current.projectId === projectId && cacheRef.current.files) { + return cacheRef.current.files; + } + + const filesPromise = (async () => { + try { + const response = await api.getFiles(projectId); + if (!response.ok) { + return []; + } + const data = await response.json(); + const tree: FileNode[] = Array.isArray(data) ? data : []; + const flat: FlatFile[] = []; + flatten(tree, flat); + return flat; + } catch { + return []; + } + })(); + + cacheRef.current = { projectId, files: filesPromise }; + return filesPromise; + }, [projectId]); + + return useCallback( + (filePath: string, diffInfo?: any) => { + const ref = normalize(filePath).trim(); + void loadFiles().then((files) => { + const match = findBestMatch(files, ref); + onFileOpen(match ?? filePath, diffInfo); + }); + }, + [loadFiles, onFileOpen], + ); +}