diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a8004f72..51d67d98 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -320,6 +320,8 @@ function ChatInputInner({ // Wrapper component with PromptInputProvider interface ChatInputWithMentionsProps { knowledgeFiles: string[] + recentFiles: string[] + visibleFiles: string[] onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void isProcessing: boolean contextUsage: LanguageModelUsage @@ -329,6 +331,8 @@ interface ChatInputWithMentionsProps { function ChatInputWithMentions({ knowledgeFiles, + recentFiles, + visibleFiles, onSubmit, isProcessing, contextUsage, @@ -336,7 +340,7 @@ function ChatInputWithMentions({ usedTokens, }: ChatInputWithMentionsProps) { return ( - + { + const visible: string[] = [] + const isPathVisible = (path: string) => { + const parts = path.split('/') + // Root level files in knowledge are always visible + if (parts.length <= 2) return true + // Check if all parent directories are expanded + for (let i = 1; i < parts.length - 1; i++) { + const parentPath = parts.slice(0, i + 1).join('/') + if (!expandedPaths.has(parentPath)) return false + } + return true + } + + for (const file of knowledgeFiles) { + const fullPath = toKnowledgePath(file) + if (fullPath && isPathVisible(fullPath)) { + visible.push(file) + } + } + return visible + }, [knowledgeFiles, expandedPaths]) + // Get workspace root for full paths const [workspaceRoot, setWorkspaceRoot] = useState('') useEffect(() => { @@ -1236,6 +1264,8 @@ function App() {
)} diff --git a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx index 68544007..abd348da 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx @@ -49,7 +49,8 @@ import { import { nanoid } from "nanoid"; import { useMentionDetection } from "@/hooks/use-mention-detection"; import { MentionPopover } from "@/components/mention-popover"; -import { toKnowledgePath } from "@/lib/wiki-links"; +import { toKnowledgePath, wikiLabel } from "@/lib/wiki-links"; +import { getMentionHighlightSegments } from "@/lib/mention-highlights"; import { type ChangeEvent, type ChangeEventHandler, @@ -165,6 +166,8 @@ const useOptionalProviderMentions = () => useContext(ProviderMentionsContext); export type KnowledgeFilesContext = { files: string[]; + recentFiles: string[]; + visibleFiles: string[]; }; const ProviderKnowledgeFilesContext = createContext(null); @@ -176,6 +179,8 @@ export const useProviderKnowledgeFiles = () => { export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; knowledgeFiles?: string[]; + recentFiles?: string[]; + visibleFiles?: string[]; }>; /** @@ -185,6 +190,8 @@ export type PromptInputProviderProps = PropsWithChildren<{ export function PromptInputProvider({ initialInput: initialTextInput = "", knowledgeFiles = [], + recentFiles = [], + visibleFiles = [], children, }: PromptInputProviderProps) { // ----- textInput state @@ -323,8 +330,8 @@ export function PromptInputProvider({ ); const knowledgeFilesContext = useMemo( - () => ({ files: knowledgeFiles }), - [knowledgeFiles] + () => ({ files: knowledgeFiles, recentFiles, visibleFiles }), + [knowledgeFiles, recentFiles, visibleFiles] ); return ( @@ -913,9 +920,22 @@ export const PromptInputTextarea = ({ const textareaRef = useRef(null); const containerRef = useRef(null); + const highlightRef = useRef(null); const currentValue = controller?.textInput.value ?? ""; const knowledgeFiles = knowledgeFilesCtx?.files ?? []; + const recentFiles = knowledgeFilesCtx?.recentFiles ?? []; + const visibleFiles = knowledgeFilesCtx?.visibleFiles ?? []; + + // Build mention labels for highlighting (handles multi-word names like "AI Agents") + const mentionLabels = useMemo(() => { + if (knowledgeFiles.length === 0) return []; + const labels = knowledgeFiles + .map((path) => wikiLabel(path)) + .map((label) => label.trim()) + .filter(Boolean); + return Array.from(new Set(labels)); + }, [knowledgeFiles]); const { activeMention, cursorCoords } = useMentionDetection( textareaRef, @@ -923,6 +943,25 @@ export const PromptInputTextarea = ({ knowledgeFiles.length > 0 ); + // Use proper regex-based highlight segmentation that handles multi-word names + const mentionHighlights = useMemo( + () => getMentionHighlightSegments(currentValue, activeMention, mentionLabels), + [currentValue, activeMention, mentionLabels] + ); + + // Sync highlight overlay scroll with textarea + const syncHighlightScroll = useCallback(() => { + const textarea = textareaRef.current; + const highlight = highlightRef.current; + if (!textarea || !highlight) return; + highlight.scrollTop = textarea.scrollTop; + highlight.scrollLeft = textarea.scrollLeft; + }, []); + + useEffect(() => { + syncHighlightScroll(); + }, [currentValue, mentionHighlights.hasHighlights, syncHighlightScroll]); + const handleMentionSelect = useCallback( (path: string, displayName: string) => { if (!controller || !activeMention) return; @@ -1044,14 +1083,38 @@ export const PromptInputTextarea = ({ }; return ( -
+
+ {mentionHighlights.hasHighlights && ( + + )} setIsComposing(false)} onCompositionStart={() => setIsComposing(true)} onKeyDown={handleKeyDown} + onScroll={syncHighlightScroll} onPaste={handlePaste} placeholder={placeholder} {...props} @@ -1060,6 +1123,8 @@ export const PromptInputTextarea = ({ {knowledgeFiles.length > 0 && ( (null) const containerRef = useRef(null) + const highlightRef = useRef(null) const [mentions, setMentions] = useState([]) + const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null) + const lastSelectedPathRef = useRef(null) + + // Build mention labels for highlighting (handles multi-word names like "AI Agents") + const mentionLabels = useMemo(() => { + if (knowledgeFiles.length === 0) return [] + const labels = knowledgeFiles + .map((path) => wikiLabel(path)) + .map((label) => label.trim()) + .filter(Boolean) + return Array.from(new Set(labels)) + }, [knowledgeFiles]) const { activeMention, cursorCoords } = useMentionDetection( textareaRef, @@ -146,6 +164,25 @@ export function ChatSidebar({ knowledgeFiles.length > 0 ) + // Use proper regex-based highlight segmentation that handles multi-word names + const mentionHighlights = useMemo( + () => getMentionHighlightSegments(message, activeMention, mentionLabels), + [message, activeMention, mentionLabels] + ) + + // Sync highlight overlay scroll with textarea + const syncHighlightScroll = useCallback(() => { + const textarea = textareaRef.current + const highlight = highlightRef.current + if (!textarea || !highlight) return + highlight.scrollTop = textarea.scrollTop + highlight.scrollLeft = textarea.scrollLeft + }, []) + + useEffect(() => { + syncHighlightScroll() + }, [message, mentionHighlights.hasHighlights, syncHighlightScroll]) + const handleMentionSelect = useCallback( (path: string, displayName: string) => { if (!activeMention) return @@ -197,24 +234,54 @@ export function ChatSidebar({ document.addEventListener('mouseup', handleMouseUp) }, [width]) - // Auto-focus textarea when sidebar opens and auto-populate with current file if applicable + // Auto-focus textarea when sidebar opens useEffect(() => { textareaRef.current?.focus() + }, []) - // Auto-populate with @currentfile if opening from a knowledge file - if (selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md')) { - // Only auto-populate if there's no existing message - if (!message.trim()) { - const displayName = wikiLabel(selectedPath) - onMessageChange(`@${displayName} `) - setMentions([{ + // Auto-populate with @currentfile when switching knowledge files + useEffect(() => { + if (selectedPath === lastSelectedPathRef.current) return + lastSelectedPathRef.current = selectedPath ?? null + + if (!selectedPath || !selectedPath.startsWith('knowledge/') || !selectedPath.endsWith('.md')) { + return + } + + const displayName = wikiLabel(selectedPath) + const previousAuto = autoMentionRef.current + const trimmed = message.trim() + const previousToken = previousAuto ? `@${previousAuto.displayName}` : null + const shouldReplace = !trimmed || (previousToken && trimmed === previousToken) + + if (!shouldReplace) { + return + } + + const nextText = `@${displayName} ` + if (message !== nextText) { + onMessageChange(nextText) + } + + setMentions((prev) => { + const withoutPrevious = previousAuto + ? prev.filter((mention) => mention.path !== previousAuto.path) + : prev + if (withoutPrevious.some((mention) => mention.path === selectedPath)) { + return withoutPrevious + } + return [ + ...withoutPrevious, + { id: `mention-auto-${Date.now()}`, path: selectedPath, displayName, - }]) - } - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps + }, + ] + }) + + autoMentionRef.current = { path: selectedPath, displayName } + }, [selectedPath, message, onMessageChange]) const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning const canSubmit = Boolean(message.trim()) && !isProcessing @@ -377,17 +444,40 @@ export function ChatSidebar({ {/* Input area - responsive to sidebar width, matches floating bar position exactly */}
-