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 }) =>
@@ -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],
+ );
+}