diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fa3fb5d8..a8004f72 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -31,9 +31,12 @@ import { PromptInputBody, PromptInputFooter, type PromptInputMessage, + PromptInputProvider, PromptInputSubmit, PromptInputTextarea, PromptInputTools, + usePromptInputController, + type FileMention, } from '@/components/ai-elements/prompt-input'; import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'; import { Shimmer } from '@/components/ai-elements/shimmer'; @@ -252,6 +255,99 @@ const collectDirPaths = (nodes: TreeNode[]): string[] => const collectFilePaths = (nodes: TreeNode[]): string[] => nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : [])) +// Inner component that uses the controller to access mentions +interface ChatInputInnerProps { + onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + isProcessing: boolean + contextUsage: LanguageModelUsage + maxTokens: number + usedTokens: number +} + +function ChatInputInner({ + onSubmit, + isProcessing, + contextUsage, + maxTokens, + usedTokens, +}: ChatInputInnerProps) { + const controller = usePromptInputController() + const message = controller.textInput.value + const canSubmit = Boolean(message.trim()) && !isProcessing + const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready' + + const handleSubmit = useCallback((msg: PromptInputMessage) => { + onSubmit(msg, controller.mentions.mentions) + controller.mentions.clearMentions() + }, [onSubmit, controller.mentions]) + + return ( + + + + + + + + + + + + + + + + + + + + + + + ) +} + +// Wrapper component with PromptInputProvider +interface ChatInputWithMentionsProps { + knowledgeFiles: string[] + onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + isProcessing: boolean + contextUsage: LanguageModelUsage + maxTokens: number + usedTokens: number +} + +function ChatInputWithMentions({ + knowledgeFiles, + onSubmit, + isProcessing, + contextUsage, + maxTokens, + usedTokens, +}: ChatInputWithMentionsProps) { + return ( + + + + ) +} + function App() { // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) @@ -577,7 +673,7 @@ function App() { } } - const handlePromptSubmit = async (message: PromptInputMessage) => { + const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => { if (isProcessing) return const { text } = message; @@ -604,9 +700,32 @@ function App() { setRunId(currentRunId) } + // Read mentioned file contents and format message with XML context + let formattedMessage = userMessage + if (mentions && mentions.length > 0) { + const attachedFiles = await Promise.all( + mentions.map(async (m) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: m.path }) + return { path: m.path, content: result.data as string } + } catch (err) { + console.error('Failed to read mentioned file:', m.path, err) + return { path: m.path, content: `[Error reading file: ${m.path}]` } + } + }) + ) + + if (attachedFiles.length > 0) { + const filesXml = attachedFiles + .map(f => `\n${f.content}\n`) + .join('\n') + formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + } + } + await window.ipc.invoke('runs:createMessage', { runId: currentRunId, - message: userMessage, + message: formattedMessage, }) } catch (error) { console.error('Failed to send message:', error) @@ -979,8 +1098,6 @@ function App() { const conversationContentClassName = hasConversation ? "mx-auto w-full max-w-4xl pb-28" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" - const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready' - const canSubmit = Boolean(message.trim()) && !isProcessing const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') return ( @@ -1117,40 +1234,14 @@ function App() {
- - - setMessage(e.target.value)} - placeholder="Type your message..." - disabled={isProcessing} - /> - - - - - - - - - - - - - - - - - - - +
@@ -1173,6 +1264,8 @@ function App() { contextUsage={contextUsage} maxTokens={maxTokens} usedTokens={usedTokens} + knowledgeFiles={knowledgeFiles} + selectedPath={selectedPath} /> )} 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 a529af66..68544007 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 @@ -47,6 +47,9 @@ import { XIcon, } from "lucide-react"; import { nanoid } from "nanoid"; +import { useMentionDetection } from "@/hooks/use-mention-detection"; +import { MentionPopover } from "@/components/mention-popover"; +import { toKnowledgePath } from "@/lib/wiki-links"; import { type ChangeEvent, type ChangeEventHandler, @@ -83,6 +86,19 @@ export type AttachmentsContext = { fileInputRef: RefObject; }; +export type FileMention = { + id: string; + path: string; // "knowledge/notes.md" + displayName: string; // "notes" +}; + +export type MentionsContext = { + mentions: FileMention[]; + addMention: (path: string, displayName: string) => void; + removeMention: (id: string) => void; + clearMentions: () => void; +}; + export type TextInputContext = { value: string; setInput: (v: string) => void; @@ -92,6 +108,7 @@ export type TextInputContext = { export type PromptInputControllerProps = { textInput: TextInputContext; attachments: AttachmentsContext; + mentions: MentionsContext; /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ __registerFileInput: ( ref: RefObject, @@ -105,6 +122,7 @@ const PromptInputController = createContext( const ProviderAttachmentsContext = createContext( null ); +const ProviderMentionsContext = createContext(null); export const usePromptInputController = () => { const ctx = useContext(PromptInputController); @@ -133,8 +151,31 @@ export const useProviderAttachments = () => { const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); +export const useProviderMentions = () => { + const ctx = useContext(ProviderMentionsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderMentions()." + ); + } + return ctx; +}; + +const useOptionalProviderMentions = () => useContext(ProviderMentionsContext); + +export type KnowledgeFilesContext = { + files: string[]; +}; + +const ProviderKnowledgeFilesContext = createContext(null); + +export const useProviderKnowledgeFiles = () => { + return useContext(ProviderKnowledgeFilesContext); +}; + export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; + knowledgeFiles?: string[]; }>; /** @@ -143,6 +184,7 @@ export type PromptInputProviderProps = PropsWithChildren<{ */ export function PromptInputProvider({ initialInput: initialTextInput = "", + knowledgeFiles = [], children, }: PromptInputProviderProps) { // ----- textInput state @@ -227,6 +269,37 @@ export function PromptInputProvider({ [attachmentFiles, add, remove, clear, openFileDialog] ); + // ----- mentions state (for @ file mentions) + const [mentionsList, setMentionsList] = useState([]); + + const addMention = useCallback((path: string, displayName: string) => { + setMentionsList((prev) => { + // Avoid duplicates + if (prev.some((m) => m.path === path)) { + return prev; + } + return [...prev, { id: nanoid(), path, displayName }]; + }); + }, []); + + const removeMention = useCallback((id: string) => { + setMentionsList((prev) => prev.filter((m) => m.id !== id)); + }, []); + + const clearMentions = useCallback(() => { + setMentionsList([]); + }, []); + + const mentions = useMemo( + () => ({ + mentions: mentionsList, + addMention, + removeMention, + clearMentions, + }), + [mentionsList, addMention, removeMention, clearMentions] + ); + const __registerFileInput = useCallback( (ref: RefObject, open: () => void) => { fileInputRef.current = ref.current; @@ -243,15 +316,25 @@ export function PromptInputProvider({ clear: clearInput, }, attachments, + mentions, __registerFileInput, }), - [textInput, clearInput, attachments, __registerFileInput] + [textInput, clearInput, attachments, mentions, __registerFileInput] + ); + + const knowledgeFilesContext = useMemo( + () => ({ files: knowledgeFiles }), + [knowledgeFiles] ); return ( - {children} + + + {children} + + ); @@ -824,10 +907,66 @@ export const PromptInputTextarea = ({ }: PromptInputTextareaProps) => { const controller = useOptionalPromptInputController(); const attachments = usePromptInputAttachments(); + const mentionsCtx = useOptionalProviderMentions(); + const knowledgeFilesCtx = useProviderKnowledgeFiles(); const [isComposing, setIsComposing] = useState(false); + const textareaRef = useRef(null); + const containerRef = useRef(null); + + const currentValue = controller?.textInput.value ?? ""; + const knowledgeFiles = knowledgeFilesCtx?.files ?? []; + + const { activeMention, cursorCoords } = useMentionDetection( + textareaRef, + currentValue, + knowledgeFiles.length > 0 + ); + + const handleMentionSelect = useCallback( + (path: string, displayName: string) => { + if (!controller || !activeMention) return; + + // Calculate the text before and after the @query + const currentText = controller.textInput.value; + const beforeAt = currentText.substring(0, activeMention.triggerIndex); + const afterQuery = currentText.substring( + activeMention.triggerIndex + 1 + activeMention.query.length + ); + + // Replace @query with @displayName followed by a space + const newText = `${beforeAt}@${displayName} ${afterQuery}`; + controller.textInput.setInput(newText); + + // Convert to knowledge path and add mention + const fullPath = toKnowledgePath(path); + if (fullPath && mentionsCtx) { + mentionsCtx.addMention(fullPath, displayName); + } + + // Focus back on textarea + textareaRef.current?.focus(); + }, + [controller, activeMention, mentionsCtx] + ); + + const handleMentionClose = useCallback(() => { + // The popover handles its own closing + }, []); + const handleKeyDown: KeyboardEventHandler = (e) => { + // If mention popover is open, let it handle navigation keys + if (activeMention && ["ArrowDown", "ArrowUp", "Tab"].includes(e.key)) { + // Don't prevent default here - the popover handles this via document listener + return; + } + if (e.key === "Enter") { + // If mention popover is open, Enter should select the item + if (activeMention) { + return; + } + if (isComposing || e.nativeEvent.isComposing) { return; } @@ -860,6 +999,12 @@ export const PromptInputTextarea = ({ attachments.remove(lastAttachment.id); } } + + // Close mention popover on Escape + if (e.key === "Escape" && activeMention) { + // Let the popover handle this + return; + } }; const handlePaste: ClipboardEventHandler = (event) => { @@ -899,17 +1044,31 @@ export const PromptInputTextarea = ({ }; return ( - setIsComposing(false)} - onCompositionStart={() => setIsComposing(true)} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - placeholder={placeholder} - {...props} - {...controlledProps} - /> +
+ setIsComposing(false)} + onCompositionStart={() => setIsComposing(true)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder} + {...props} + {...controlledProps} + /> + {knowledgeFiles.length > 0 && ( + + )} +
); }; diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 910514b0..0a2470e0 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -22,7 +22,10 @@ import { import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning' import { Shimmer } from '@/components/ai-elements/shimmer' import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' -import { type PromptInputMessage } from '@/components/ai-elements/prompt-input' +import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' +import { useMentionDetection } from '@/hooks/use-mention-detection' +import { MentionPopover } from '@/components/mention-popover' +import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links' interface ChatMessage { id: string @@ -107,10 +110,12 @@ interface ChatSidebarProps { isProcessing: boolean message: string onMessageChange: (message: string) => void - onSubmit: (message: PromptInputMessage) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void contextUsage: LanguageModelUsage maxTokens: number usedTokens: number + knowledgeFiles?: string[] + selectedPath?: string | null } export function ChatSidebar({ @@ -124,12 +129,51 @@ export function ChatSidebar({ message, onMessageChange, onSubmit, + knowledgeFiles = [], + selectedPath, }: ChatSidebarProps) { const [width, setWidth] = useState(defaultWidth) const [isResizing, setIsResizing] = useState(false) const startXRef = useRef(0) const startWidthRef = useRef(0) - const inputRef = useRef(null) + const textareaRef = useRef(null) + const containerRef = useRef(null) + const [mentions, setMentions] = useState([]) + + const { activeMention, cursorCoords } = useMentionDetection( + textareaRef, + message, + knowledgeFiles.length > 0 + ) + + const handleMentionSelect = useCallback( + (path: string, displayName: string) => { + if (!activeMention) return + + const beforeAt = message.substring(0, activeMention.triggerIndex) + const afterQuery = message.substring( + activeMention.triggerIndex + 1 + activeMention.query.length + ) + + const newText = `${beforeAt}@${displayName} ${afterQuery}` + onMessageChange(newText) + + const fullPath = toKnowledgePath(path) + if (fullPath) { + setMentions(prev => { + if (prev.some(m => m.path === fullPath)) return prev + return [...prev, { id: `mention-${Date.now()}`, path: fullPath, displayName }] + }) + } + + textareaRef.current?.focus() + }, + [activeMention, message, onMessageChange] + ) + + const handleMentionClose = useCallback(() => { + // The popover handles its own closing + }, []) const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault() @@ -153,10 +197,24 @@ export function ChatSidebar({ document.addEventListener('mouseup', handleMouseUp) }, [width]) - // Auto-focus input when sidebar opens + // Auto-focus textarea when sidebar opens and auto-populate with current file if applicable useEffect(() => { - inputRef.current?.focus() - }, []) + 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([{ + id: `mention-auto-${Date.now()}`, + path: selectedPath, + displayName, + }]) + } + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning const canSubmit = Boolean(message.trim()) && !isProcessing @@ -164,14 +222,27 @@ export function ChatSidebar({ const handleSubmit = () => { const trimmed = message.trim() if (trimmed && !isProcessing) { - onSubmit({ text: trimmed, files: [] }) + onSubmit({ text: trimmed, files: [] }, mentions) + setMentions([]) } } const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSubmit() + // If mention popover is open, let it handle navigation keys + if (activeMention && ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key)) { + return + } + + if (e.key === 'Enter') { + // If mention popover is open, Enter should select the item + if (activeMention) { + return + } + + if (!e.shiftKey) { + e.preventDefault() + handleSubmit() + } } } @@ -304,24 +375,25 @@ export function ChatSidebar({ {/* Input area - responsive to sidebar width, matches floating bar position exactly */} -
-
- +
+