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 */} + + + + +
+ {previewFilePath.split("/").pop() || previewFilePath} + + {previewLineNumber ? `- Line ${previewLineNumber}` : ""} + +
+
+
+
+ + {previewLoading && ( +
+ +
+ )} + {!previewLoading && previewError && ( +
+ Error: {previewError} +
+ )} + {!previewLoading && !previewError && ( +
+ +
+ )} +
+
+
+
{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 }; +}