fix(chat): open file references in editor instead of new browser window

Clicking a file reference in a chat message (e.g. `useShellTerminal.ts`)
opened a new browser window because it was rendered as a plain anchor with
target="_blank" and an empty/relative href.

The markdown link renderer now intercepts file-path links — using the href,
or the link text when the href is empty — strips any `:line:col` suffix, and
opens the file in the in-app editor side panel while keeping the Chat tab
active (matching the inline edit view).

- useFileOpenResolver: resolves bare/partial references to real project
  files via the cached project file tree
- PaletteOpsContext: add `openFileInEditor` op that opens the editor without
  switching tabs
This commit is contained in:
Haileyesus
2026-06-28 18:30:29 +03:00
parent c88baaf8dc
commit 2afe0955ed
4 changed files with 204 additions and 8 deletions

View File

@@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting'; import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard'; import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
type MarkdownProps = { type MarkdownProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; 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 = { type CodeBlockProps = {
node?: any; node?: any;
inline?: boolean; inline?: boolean;
@@ -123,11 +159,6 @@ const markdownComponents = {
{children} {children}
</blockquote> </blockquote>
), ),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>, p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
table: ({ children }: { children?: React.ReactNode }) => ( table: ({ children }: { children?: React.ReactNode }) => (
<div className="my-2 overflow-x-auto"> <div className="my-2 overflow-x-auto">
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) {
const content = normalizeInlineCodeFences(String(children ?? '')); const content = normalizeInlineCodeFences(String(children ?? ''));
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []); 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 (
<a
href={href || fileRef}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
onClick={(event) => {
event.preventDefault();
openFileInEditor(stripLineSuffix(fileRef));
}}
>
{linkChildren}
</a>
);
}
return (
<a
href={href}
className="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
{linkChildren}
</a>
);
},
}),
[openFileInEditor],
);
return ( return (
<div className={className}> <div className={className}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}> <ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
{content} {content}
</ReactMarkdown> </ReactMarkdown>
</div> </div>

View File

@@ -11,6 +11,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext'; import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
import EditorSidebar from '../../code-editor/view/EditorSidebar'; import EditorSidebar from '../../code-editor/view/EditorSidebar';
@@ -77,6 +78,10 @@ function MainContent({
isMobile, 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(() => { useEffect(() => {
// Identify projects by DB `projectId`; the TaskMaster context uses the // Identify projects by DB `projectId`; the TaskMaster context uses the
// same identifier to key its internal maps. // same identifier to key its internal maps.
@@ -121,6 +126,10 @@ function MainContent({
setActiveTab('files'); setActiveTab('files');
handleFileOpen(filePath); handleFileOpen(filePath);
}, },
// Opens the editor side panel in place, keeping the current tab (e.g. chat).
openFileInEditor: (filePath: string) => {
resolvedFileOpen(filePath);
},
}); });
if (isLoading) { if (isLoading) {

View File

@@ -3,6 +3,9 @@ import type { MutableRefObject, ReactNode } from 'react';
export type PaletteOps = { export type PaletteOps = {
openFile: (path: string) => void; 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; openSettings: (tab?: string) => void;
refreshProjects: () => Promise<void> | void; refreshProjects: () => Promise<void> | void;
}; };
@@ -13,6 +16,7 @@ const PaletteOpsContext = createContext<Registry | null>(null);
const defaultOps: PaletteOps = { const defaultOps: PaletteOps = {
openFile: () => undefined, openFile: () => undefined,
openFileInEditor: () => undefined,
openSettings: () => undefined, openSettings: () => undefined,
refreshProjects: () => undefined, refreshProjects: () => undefined,
}; };
@@ -27,6 +31,8 @@ export function usePaletteOps(): PaletteOps {
return useMemo<PaletteOps>( return useMemo<PaletteOps>(
() => ({ () => ({
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path), openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
openFileInEditor: (path) =>
(ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path),
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab), openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(), refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
}), }),
@@ -36,18 +42,20 @@ export function usePaletteOps(): PaletteOps {
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) { export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
const ref = useContext(PaletteOpsContext); const ref = useContext(PaletteOpsContext);
const { openFile, openSettings, refreshProjects } = partial; const { openFile, openFileInEditor, openSettings, refreshProjects } = partial;
useEffect(() => { useEffect(() => {
if (!ref) return undefined; if (!ref) return undefined;
const prev = { ...ref.current }; const prev = { ...ref.current };
if (openFile) ref.current.openFile = openFile; if (openFile) ref.current.openFile = openFile;
if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor;
if (openSettings) ref.current.openSettings = openSettings; if (openSettings) ref.current.openSettings = openSettings;
if (refreshProjects) ref.current.refreshProjects = refreshProjects; if (refreshProjects) ref.current.refreshProjects = refreshProjects;
return () => { return () => {
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile; 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 (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects; if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
}; };
}, [ref, openFile, openSettings, refreshProjects]); }, [ref, openFile, openFileInEditor, openSettings, refreshProjects]);
} }

View File

@@ -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<FlatFile[]> | null }>({
projectId: undefined,
files: null,
});
const loadFiles = useCallback((): Promise<FlatFile[]> => {
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],
);
}