diff --git a/src/interface/web/app/components/chatMessage/FileContentSnippet.tsx b/src/interface/web/app/components/chatMessage/FileContentSnippet.tsx
new file mode 100644
index 00000000..202606fe
--- /dev/null
+++ b/src/interface/web/app/components/chatMessage/FileContentSnippet.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import React from "react";
+
+interface FileContentSnippetProps {
+ content: string;
+ targetLine?: number;
+ maxLines?: number; // Used when no target line; defaults to 20
+}
+
+export default function FileContentSnippet({
+ content,
+ targetLine,
+ maxLines = 20,
+}: FileContentSnippetProps) {
+ const lines = (content || "").split("\n");
+
+ if (targetLine && targetLine > 0 && targetLine <= lines.length) {
+ const startLine = Math.max(1, targetLine - 2);
+ const endLine = Math.min(lines.length, targetLine + 5);
+ const contextLines = lines.slice(startLine - 1, endLine);
+ return (
+
+ {contextLines.map((line, idx) => {
+ const lineNum = startLine + idx;
+ const isTarget = lineNum === targetLine;
+ return (
+
+
+ {lineNum.toString().padStart(3, " ")}:
+
+ {line}
+
+ );
+ })}
+
+ );
+ }
+
+ const previewLines = lines.slice(0, maxLines);
+ return (
+
+ {previewLines.map((line, index) => (
+
+
+ {(index + 1).toString().padStart(3, " ")}:
+
+ {line}
+
+ ))}
+ {lines.length > maxLines && (
+
+ ... and {lines.length - maxLines} more lines
+
+ )}
+
+ );
+}
diff --git a/src/interface/web/app/components/chatMessage/chatMessage.module.css b/src/interface/web/app/components/chatMessage/chatMessage.module.css
index dd09e98a..947836d1 100644
--- a/src/interface/web/app/components/chatMessage/chatMessage.module.css
+++ b/src/interface/web/app/components/chatMessage/chatMessage.module.css
@@ -23,6 +23,22 @@ div.chatMessageWrapper a span {
display: revert !important;
}
+/* File link styling */
+.chatMessageWrapper a.file-link {
+ color: hsl(var(--primary));
+ text-decoration: underline;
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.chatMessageWrapper a.file-link:hover {
+ color: hsl(var(--primary-foreground));
+ background-color: hsl(var(--primary));
+ text-decoration: none;
+ padding: 2px 4px;
+ border-radius: 4px;
+}
+
div.khojfullHistory {
padding-left: 4px;
}
diff --git a/src/interface/web/app/components/chatMessage/chatMessage.tsx b/src/interface/web/app/components/chatMessage/chatMessage.tsx
index 897504c3..b6afff76 100644
--- a/src/interface/web/app/components/chatMessage/chatMessage.tsx
+++ b/src/interface/web/app/components/chatMessage/chatMessage.tsx
@@ -6,11 +6,18 @@ import markdownIt from "markdown-it";
import mditHljs from "markdown-it-highlightjs";
import React, { useEffect, useRef, useState, forwardRef } from "react";
import { createRoot } from "react-dom/client";
+import { createPortal } from "react-dom";
import "katex/dist/katex.min.css";
-import { TeaserReferencesSection, constructAllReferences } from "../referencePanel/referencePanel";
+import {
+ TeaserReferencesSection,
+ constructAllReferences,
+} from "@/app/components/referencePanel/referencePanel";
import { renderCodeGenImageInline } from "@/app/common/chatFunctions";
+import { fileLinksPlugin } from "@/app/components/chatMessage/fileLinksPlugin";
+import FileContentSnippet from "@/app/components/chatMessage/FileContentSnippet";
+import { useFileContent } from "@/app/components/chatMessage/useFileContent";
import {
ThumbsUp,
@@ -41,7 +48,6 @@ import { convertColorToTextClass } from "@/app/common/colorUtils";
import { AgentData } from "@/app/components/agentCard/agentCard";
import renderMathInElement from "katex/contrib/auto-render";
-import "katex/dist/katex.min.css";
import ExcalidrawComponent from "../excalidraw/excalidraw";
import { AttachedFileText } from "../chatInputArea/chatInputArea";
import {
@@ -68,6 +74,8 @@ md.use(mditHljs, {
code: true,
});
+md.use(fileLinksPlugin);
+
export interface Context {
compiled: string;
file: string;
@@ -395,6 +403,23 @@ const ChatMessage = forwardRef((props, ref) =>
const [excalidrawData, setExcalidrawData] = useState("");
const [mermaidjsData, setMermaidjsData] = useState("");
+ // State for file content preview on file link click, hover
+ const [previewOpen, setPreviewOpen] = useState(false);
+ const [previewFilePath, setPreviewFilePath] = useState("");
+ const [previewLineNumber, setPreviewLineNumber] = useState(undefined);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [previewError, setPreviewError] = useState(null);
+ const [previewContent, setPreviewContent] = useState("");
+
+ const [hoverOpen, setHoverOpen] = useState(false);
+ const [hoverFilePath, setHoverFilePath] = useState("");
+ const [hoverLineNumber, setHoverLineNumber] = useState(undefined);
+ const [hoverLoading, setHoverLoading] = useState(false);
+ const [hoverError, setHoverError] = useState(null);
+ const [hoverContent, setHoverContent] = useState("");
+ const [hoverPos, setHoverPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
+ const hoverCloseTimeoutRef = useRef(null);
+
const interruptedRef = useRef(false);
const messageRef = useRef(null);
@@ -451,6 +476,13 @@ const ChatMessage = forwardRef((props, ref) =>
// Replace file links with base64 data
message = renderCodeGenImageInline(message, props.chatMessage.codeContext);
+ // Preprocess file:// links so markdown-it processes them
+ // We convert them to a custom scheme (filelink://) and handle in the plugin
+ message = message.replace(/\[([^\]]+)\]\(file:\/\/([^)]+)\)/g, (match, text, path) => {
+ // Use a special scheme that markdown-it will process
+ return `[${text}](filelink://${path})`;
+ });
+
// Add code context files to the message
if (props.chatMessage.codeContext) {
Object.entries(props.chatMessage.codeContext).forEach(([key, value]) => {
@@ -504,7 +536,12 @@ const ChatMessage = forwardRef((props, ref) =>
.replace(/RIGHTBRACKET/g, "\\]");
// Sanitize and set the rendered markdown
- setMarkdownRendered(DOMPurify.sanitize(markdownRendered));
+ // Configure DOMPurify to allow file link attributes
+ const cleanMarkdown = DOMPurify.sanitize(markdownRendered, {
+ ADD_ATTR: ["data-file-path", "data-line-number"],
+ });
+
+ setMarkdownRendered(cleanMarkdown);
}, [props.chatMessage.message, props.chatMessage.images, props.chatMessage.intent]);
useEffect(() => {
@@ -542,6 +579,90 @@ const ChatMessage = forwardRef((props, ref) =>
}
});
+ // Event delegation on the message container for reliability
+ const container = messageRef.current;
+
+ const delegatedPointerDown = (ev: Event) => {
+ const e = ev as MouseEvent;
+ const target = e.target as HTMLElement | null;
+ const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
+ if (!anchor) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const path = anchor.getAttribute("data-file-path") || "";
+ const line = anchor.getAttribute("data-line-number") || undefined;
+ if (!path) return;
+ // Close hover popover if open
+ setHoverOpen(false);
+ setPreviewFilePath(path);
+ setPreviewLineNumber(line ? parseInt(line) : undefined);
+ setPreviewOpen(true);
+ };
+
+ let currentHoverAnchor: HTMLAnchorElement | null = null;
+ const delegatedMouseOver = (ev: Event) => {
+ const e = ev as MouseEvent;
+ const target = e.target as HTMLElement | null;
+ const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
+ if (!anchor) return;
+ if (currentHoverAnchor === anchor) return;
+ currentHoverAnchor = anchor;
+ const rect = anchor.getBoundingClientRect();
+ const path = anchor.getAttribute("data-file-path") || "";
+ const line = anchor.getAttribute("data-line-number") || undefined;
+ if (!path) return;
+ setHoverPos({ x: Math.max(8, rect.left), y: rect.bottom + 6 });
+ setHoverFilePath(path);
+ setHoverLineNumber(line ? parseInt(line) : undefined);
+ if (hoverCloseTimeoutRef.current) {
+ window.clearTimeout(hoverCloseTimeoutRef.current);
+ hoverCloseTimeoutRef.current = null;
+ }
+ // Open immediately for reliability
+ setHoverOpen(true);
+ };
+
+ const delegatedMouseOut = (ev: Event) => {
+ const e = ev as MouseEvent;
+ const target = e.target as HTMLElement | null;
+ const related = e.relatedTarget as HTMLElement | null;
+ const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
+ const stillInsideAnchor = !!(related && anchor && anchor.contains(related));
+ // If moving between descendants of the same anchor, ignore
+ if (stillInsideAnchor) return;
+ // Schedule close; will be canceled if we move into the popover
+ if (hoverCloseTimeoutRef.current) {
+ window.clearTimeout(hoverCloseTimeoutRef.current);
+ }
+ hoverCloseTimeoutRef.current = window.setTimeout(() => {
+ setHoverOpen(false);
+ currentHoverAnchor = null;
+ hoverCloseTimeoutRef.current = null;
+ }, 200);
+ };
+
+ const delegatedKeyDown = (ev: Event) => {
+ const e = ev as KeyboardEvent;
+ const target = e.target as HTMLElement | null;
+ const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
+ if (!anchor) return;
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ e.stopPropagation();
+ const path = anchor.getAttribute("data-file-path") || "";
+ const line = anchor.getAttribute("data-line-number") || undefined;
+ if (!path) return;
+ setHoverOpen(false);
+ setPreviewFilePath(path);
+ setPreviewLineNumber(line ? parseInt(line) : undefined);
+ setPreviewOpen(true);
+ };
+
+ container.addEventListener("pointerdown", delegatedPointerDown);
+ container.addEventListener("keydown", delegatedKeyDown);
+ container.addEventListener("mouseover", delegatedMouseOver);
+ container.addEventListener("mouseout", delegatedMouseOut);
+
renderMathInElement(messageRef.current, {
delimiters: [
{ left: "$$", right: "$$", display: true },
@@ -549,8 +670,51 @@ const ChatMessage = forwardRef((props, ref) =>
{ left: "\\(", right: "\\)", display: false },
],
});
+ // Cleanup old listeners when content changes
+ return () => {
+ container.removeEventListener("pointerdown", delegatedPointerDown);
+ container.removeEventListener("keydown", delegatedKeyDown);
+ container.removeEventListener("mouseover", delegatedMouseOver);
+ container.removeEventListener("mouseout", delegatedMouseOut);
+ if (hoverCloseTimeoutRef.current) {
+ window.clearTimeout(hoverCloseTimeoutRef.current);
+ hoverCloseTimeoutRef.current = null;
+ }
+ };
}
- }, [markdownRendered, isHovering, messageRef]);
+ }, [markdownRendered, messageRef]);
+
+ // Fetch file content for dialog and hover using shared hook
+ const {
+ content: previewContentHook,
+ loading: previewLoadingHook,
+ error: previewErrorHook,
+ } = useFileContent(previewFilePath, previewOpen);
+ const {
+ content: hoverContentHook,
+ loading: hoverLoadingHook,
+ error: hoverErrorHook,
+ } = useFileContent(hoverFilePath, hoverOpen);
+
+ useEffect(() => {
+ setPreviewContent(previewContentHook);
+ }, [previewContentHook]);
+ useEffect(() => {
+ setPreviewLoading(previewLoadingHook);
+ }, [previewLoadingHook]);
+ useEffect(() => {
+ setPreviewError(previewErrorHook);
+ }, [previewErrorHook]);
+
+ useEffect(() => {
+ setHoverContent(hoverContentHook);
+ }, [hoverContentHook]);
+ useEffect(() => {
+ setHoverLoading(hoverLoadingHook);
+ }, [hoverLoadingHook]);
+ useEffect(() => {
+ setHoverError(hoverErrorHook);
+ }, [hoverErrorHook]);
function formatDate(timestamp: string) {
// Format date in HH:MM, DD MMM YYYY format
@@ -761,6 +925,120 @@ const ChatMessage = forwardRef((props, ref) =>
className={styles.chatMessage}
dangerouslySetInnerHTML={{ __html: markdownRendered }}
/>
+ {/* File preview hover dialog */}
+ {hoverOpen &&
+ typeof window !== "undefined" &&
+ createPortal(
+ {
+ if (hoverCloseTimeoutRef.current) {
+ window.clearTimeout(hoverCloseTimeoutRef.current);
+ hoverCloseTimeoutRef.current = null;
+ }
+ setHoverOpen(true);
+ }}
+ onMouseDown={(e) => {
+ // If user clicks the hover preview, open the dialog for the same file
+ e.preventDefault();
+ e.stopPropagation();
+ setHoverOpen(false);
+ if (hoverFilePath) {
+ setPreviewFilePath(hoverFilePath);
+ setPreviewLineNumber(hoverLineNumber);
+ setPreviewOpen(true);
+ }
+ }}
+ onMouseLeave={() => {
+ if (hoverCloseTimeoutRef.current) {
+ window.clearTimeout(hoverCloseTimeoutRef.current);
+ }
+ hoverCloseTimeoutRef.current = window.setTimeout(() => {
+ setHoverOpen(false);
+ hoverCloseTimeoutRef.current = null;
+ }, 200);
+ }}
+ style={{
+ position: "fixed",
+ left: hoverPos.x,
+ top: hoverPos.y,
+ zIndex: 9999,
+ }}
+ className="w-96 max-h-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md"
+ >
+
+
+
+ {hoverFilePath.split("/").pop() || hoverFilePath}
+
+ {hoverLineNumber && (
+
+ - Line {hoverLineNumber}
+
+ )}
+
+
+ {hoverLoading && (
+
+
+
+ )}
+ {!hoverLoading && hoverError && (
+
+ Error: {hoverError}
+
+ )}
+ {!hoverLoading && !hoverError && (
+
+
+
+ )}
+
+
+
,
+ document.body,
+ )}
+ {/* File preview popup dialog */}
+
{excalidrawData && }
{mermaidjsData && }
diff --git a/src/interface/web/app/components/chatMessage/fileLinksPlugin.ts b/src/interface/web/app/components/chatMessage/fileLinksPlugin.ts
new file mode 100644
index 00000000..62549d67
--- /dev/null
+++ b/src/interface/web/app/components/chatMessage/fileLinksPlugin.ts
@@ -0,0 +1,52 @@
+import MarkdownIt from "markdown-it";
+
+// File link renderer plugin for markdown-it
+// Handles links of the form [text](file:///path/to/file) or [text](file:///path/to/file#line=123)
+export function fileLinksPlugin(md: MarkdownIt) {
+ // Store the original link_open renderer
+ const defaultLinkOpenRenderer =
+ md.renderer.rules.link_open ||
+ function (tokens, idx, options, env, self) {
+ return self.renderToken(tokens, idx, options);
+ };
+
+ // Override the link_open renderer
+ md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
+ const token = tokens[idx];
+ const hrefIndex = token.attrIndex("href");
+
+ if (hrefIndex >= 0) {
+ const href = token.attrs![hrefIndex][1];
+
+ // Check if this is a filelink:// link (our preprocessed file:// links)
+ if (href.startsWith("filelink://")) {
+ // Extract file path and line number from filelink://path format
+ const filePath = href.replace("filelink://", "");
+ const fileMatch = filePath.match(/^(.+?)(?:#line=(\d+))?$/);
+
+ if (fileMatch) {
+ const actualFilePath = fileMatch[1];
+ const lineNumber = fileMatch[2];
+
+ // Add custom attributes for file links
+ token.attrSet("data-file-path", actualFilePath);
+ if (lineNumber) {
+ token.attrSet("data-line-number", lineNumber);
+ }
+ // Append class if it exists; otherwise set it
+ const classIdx = token.attrIndex("class");
+ if (classIdx >= 0 && token.attrs) {
+ token.attrs[classIdx][1] = `${token.attrs[classIdx][1]} file-link`;
+ } else {
+ token.attrSet("class", "file-link");
+ }
+ token.attrSet("href", "#"); // Prevent default navigation
+ token.attrSet("role", "button");
+ token.attrSet("tabindex", "0");
+ }
+ }
+ }
+
+ return defaultLinkOpenRenderer(tokens, idx, options, env, self);
+ };
+}
diff --git a/src/interface/web/app/components/chatMessage/useFileContent.ts b/src/interface/web/app/components/chatMessage/useFileContent.ts
new file mode 100644
index 00000000..47f86f68
--- /dev/null
+++ b/src/interface/web/app/components/chatMessage/useFileContent.ts
@@ -0,0 +1,43 @@
+import { useEffect, useState } from "react";
+
+export interface UseFileContentResult {
+ content: string;
+ loading: boolean;
+ error: string | null;
+}
+
+// Fetch file content for a given path when `enabled` is true.
+export function useFileContent(path: string | undefined, enabled: boolean): UseFileContentResult {
+ const [content, setContent] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ async function run() {
+ if (!enabled || !path) return;
+ setLoading(true);
+ setError(null);
+ setContent("");
+ try {
+ const resp = await fetch(`/api/content/file?file_name=${encodeURIComponent(path)}`);
+ if (!resp.ok) {
+ throw new Error(`Failed to fetch file content (${resp.status})`);
+ }
+ const data = await resp.json();
+ if (!cancelled) setContent(data.raw_text || "");
+ } catch (err) {
+ if (!cancelled)
+ setError(err instanceof Error ? err.message : "Failed to load file content");
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+ run();
+ return () => {
+ cancelled = true;
+ };
+ }, [path, enabled]);
+
+ return { content, loading, error };
+}